> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mavera.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Video + Focus Group Double Analysis

> Run Video Analysis on a creative, then feed the AI scores as stimulus into a Focus Group for layered analysis combining frame-level metrics with synthetic audience interpretation

## Mavera Surfaces

| Surface                                             | Role                                                                      |
| --------------------------------------------------- | ------------------------------------------------------------------------- |
| **Files** (`POST /files/upload-url`, `POST /files`) | Upload the video creative                                                 |
| **Video Analysis** (`POST /video-analyses`)         | Frame-level AI scoring: engagement, emotion, attention, brand recall, CTA |
| **Focus Groups** (`POST /focus-groups`)             | Simulated audience panel reacts to the video *and* the AI scores          |
| **Mave** (`POST /mave/chat`)                        | Synthesize both layers into a single insight report                       |

***

## What Value Does Mavera Add?

| Value                 | How                                                                                                                                                              |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Insurance**         | Two independent methods catch what either alone would miss. AI finds frame-level issues; personas find emotional disconnects.                                    |
| **Opening new doors** | Using AI scores *as stimulus* for a Focus Group creates a feedback loop: "The AI says high emotion but low brand recall — do you agree?" Personas explain *why*. |
| **Saving time**       | A traditional creative test requires real audience recruitment. This delivers comparable depth in minutes.                                                       |

***

## When to Use This

* You have a new creative and want both quantitative scores and qualitative interpretation before launch.
* Video Analysis returned surprising results (high emotion, low brand recall) and you need to understand *why*.
* You need more than numbers for stakeholders — they want to hear "what the audience thinks."
* You're testing a risky concept and need two independent signals before committing budget.

<Info>
  This is the most thorough single-ad analysis in the playbook library. It combines Video Analysis depth with Focus Group interpretive power, then synthesizes both with Mave.
</Info>

***

## What You Need

| Requirement                        | Details                                                                                                 |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------- |
| **Mavera API key**                 | Starts with `mvra_live_`. Get one at [Developer Settings](https://app.mavera.io/settings/developer).    |
| **Workspace ID**                   | From your dashboard URL (`ws_...`).                                                                     |
| **Persona ID(s)**                  | At least one persona matching your target audience.                                                     |
| **One video creative**             | MP4 or MOV, 15–60 s.                                                                                    |
| **Credits**                        | \~100–250 (Video) + \~75–125 (Focus Group) + \~15–30 (Mave). See [Credits Estimate](#credits-estimate). |
| **Python 3.8+** or **Node.js 18+** | `requests` for Python; native `fetch` for Node.                                                         |

```
MAVERA_API_KEY=mvra_live_your_key_here
MAVERA_WORKSPACE_ID=ws_your_workspace_id
TARGET_PERSONA_ID=persona_your_target
```

***

## The Flow

```mermaid theme={"dark"}
flowchart LR
    A["Upload"] --> B["Video Analysis"]
    B --> C["Extract Stimulus"]
    C --> D["Focus Group"]
    D --> E["Mave Synthesis"]
```

<Steps>
  <Step title="Upload the video">
    Standard three-step Files API upload.
  </Step>

  <Step title="Run Video Analysis">
    Get frame-level scores. These become the raw data layer.
  </Step>

  <Step title="Extract stimulus from AI scores">
    Find *tensions* — places where one metric is high but another is low. These become Focus Group prompts.
  </Step>

  <Step title="Run Focus Group with AI stimulus">
    Present the video AND the AI findings to a 25-person panel: "The AI says this. Do you agree?"
  </Step>

  <Step title="Synthesize with Mave">
    Feed both layers into Mave for a combined insight report.
  </Step>
</Steps>

***

## Stage 1 — Upload + Video Analysis

<CodeGroup>
  ```python Python theme={"dark"}
  import os, time, json, requests

  API_KEY = os.environ["MAVERA_API_KEY"]
  WORKSPACE_ID = os.environ["MAVERA_WORKSPACE_ID"]
  PERSONA_ID = os.environ["TARGET_PERSONA_ID"]
  BASE = "https://app.mavera.io/api/v1"
  HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}


  def upload_video(path: str) -> dict:
      with open(path, "rb") as f:
          content = f.read()
      name = os.path.basename(path)
      mime = "video/mp4" if path.lower().endswith(".mp4") else "video/quicktime"

      url_resp = requests.post(f"{BASE}/files/upload-url", headers=HEADERS, json={
          "file_name": name, "file_type": mime, "file_size": len(content), "workspace_id": WORKSPACE_ID,
      }).json()
      if "error" in url_resp:
          raise Exception(url_resp["error"]["message"])

      requests.put(url_resp["upload_url"], data=content, headers={"Content-Type": mime}).raise_for_status()

      file_rec = requests.post(f"{BASE}/files", headers=HEADERS, json={
          "name": name, "type": mime, "url": url_resp["public_url"],
          "workspace_id": WORKSPACE_ID, "file_size": len(content),
      }).json()
      if "error" in file_rec:
          raise Exception(file_rec["error"]["message"])
      return {"id": file_rec["id"], "name": name}


  def create_analysis(asset_id: str, label: str) -> dict:
      resp = requests.post(f"{BASE}/video-analyses", headers=HEADERS, json={
          "title": f"Double Analysis: {label}", "asset_id": asset_id,
          "goal": "Comprehensive assessment: engagement, emotional arc, brand recall, CTA",
          "brand": "Brand", "product": "Product", "primary_intent": "Drive purchase consideration",
          "chunk_duration": 5, "frames_per_chunk": 3, "workspace_id": WORKSPACE_ID,
      }).json()
      if "error" in resp: raise Exception(resp["error"]["message"])
      return resp

  def poll_analysis(analysis_id: str, timeout_min: int = 20) -> dict:
      for _ in range(timeout_min * 4):
          resp = requests.get(f"{BASE}/video-analyses/{analysis_id}", headers=HEADERS).json()
          if "error" in resp: raise Exception(resp["error"]["message"])
          if resp["status"] == "COMPLETED": return resp
          if resp["status"] == "FAILED": raise Exception(f"Analysis {analysis_id} failed")
          time.sleep(15)
      raise TimeoutError(f"Analysis {analysis_id} timed out")
  ```

  ```javascript JavaScript theme={"dark"}
  const fs = require("fs");
  const path = require("path");

  const API_KEY = process.env.MAVERA_API_KEY;
  const WORKSPACE_ID = process.env.MAVERA_WORKSPACE_ID;
  const PERSONA_ID = process.env.TARGET_PERSONA_ID;
  const BASE = "https://app.mavera.io/api/v1";
  const HEADERS = { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" };

  async function uploadVideo(videoPath) {
    const content = fs.readFileSync(videoPath);
    const name = path.basename(videoPath);
    const mime = videoPath.toLowerCase().endsWith(".mp4") ? "video/mp4" : "video/quicktime";

    const urlResp = await fetch(`${BASE}/files/upload-url`, {
      method: "POST", headers: HEADERS,
      body: JSON.stringify({ file_name: name, file_type: mime, file_size: content.length, workspace_id: WORKSPACE_ID }),
    }).then((r) => r.json());
    if (urlResp.error) throw new Error(urlResp.error.message);

    await fetch(urlResp.upload_url, { method: "PUT", body: content, headers: { "Content-Type": mime } });

    const fileRec = await fetch(`${BASE}/files`, {
      method: "POST", headers: HEADERS,
      body: JSON.stringify({ name, type: mime, url: urlResp.public_url, workspace_id: WORKSPACE_ID, file_size: content.length }),
    }).then((r) => r.json());
    if (fileRec.error) throw new Error(fileRec.error.message);
    return { id: fileRec.id, name };
  }

  async function createAnalysis(assetId, label) {
    const resp = await fetch(`${BASE}/video-analyses`, {
      method: "POST", headers: HEADERS,
      body: JSON.stringify({ title: `Double: ${label}`, asset_id: assetId,
        goal: "Comprehensive assessment: engagement, emotional arc, brand recall, CTA",
        brand: "Brand", product: "Product", primary_intent: "Drive purchase consideration",
        chunk_duration: 5, frames_per_chunk: 3, workspace_id: WORKSPACE_ID }),
    }).then((r) => r.json());
    if (resp.error) throw new Error(resp.error.message);
    return resp;
  }

  async function pollAnalysis(analysisId, timeoutMin = 20) {
    for (let i = 0; i < timeoutMin * 4; i++) {
      const r = await fetch(`${BASE}/video-analyses/${analysisId}`, { headers: HEADERS }).then((r) => r.json());
      if (r.error) throw new Error(r.error.message);
      if (r.status === "COMPLETED") return r;
      if (r.status === "FAILED") throw new Error(`Analysis ${analysisId} failed`);
      await new Promise((x) => setTimeout(x, 15000));
    }
    throw new Error(`Analysis ${analysisId} timed out`);
  }
  ```
</CodeGroup>

***

## Stage 2 — Extract Stimulus from AI Scores

This is the key step. Transform raw metrics into natural-language statements. The best stimulus highlights *tensions* — where one metric is high but another is low.

<CodeGroup>
  ```python Python theme={"dark"}
  def extract_stimulus(metrics: dict) -> list[dict]:
      """Find tensions in metrics — contradictory pairs are the best Focus Group prompts."""
      o, e, a = metrics.get("overall_score", 0), metrics.get("emotional_impact", 0), metrics.get("attention_score", 0)
      br, cta = metrics.get("brand_recall_likelihood", "MEDIUM"), metrics.get("cta_effectiveness", 0)
      chunks = metrics.get("chunks", [])
      stimuli = [{"finding": f"Overall score: {o}/100 ({'top' if o >= 75 else 'middle' if o >= 50 else 'bottom'} tier).", "tension_level": 0}]

      if e >= 7 and br in ("LOW", "VERY_LOW"):
          stimuli.append({"finding": f"High emotion ({e}/10) but low brand recall ({br}). Feels something, can't name who.", "tension_level": 3})
      elif e <= 4 and br in ("HIGH", "VERY_HIGH"):
          stimuli.append({"finding": f"Strong recall ({br}) but low emotion ({e}/10). Knows the brand, doesn't care.", "tension_level": 3})
      if a >= 8 and cta <= 4:
          stimuli.append({"finding": f"High attention ({a}/10) but weak CTA ({cta}/10). Holds eyes, doesn't convert.", "tension_level": 3})
      if chunks:
          f_eng, l_eng = chunks[0].get("engagement", 0), chunks[-1].get("engagement", 0)
          if f_eng >= 70 and l_eng <= 40:
              stimuli.append({"finding": f"Engagement drops from {f_eng} to {l_eng}. Hook works, ad loses people.", "tension_level": 2})
          peak = max(chunks, key=lambda c: c.get("emotional_intensity", 0))
          stimuli.append({"finding": f"Peak emotion at {peak.get('start_time', 0)}s ({peak.get('emotional_intensity', 0)}/10).", "tension_level": 1})

      stimuli.append({"finding": f"Emotion: {e}/10, Attention: {a}/10, Recall: {br}, CTA: {cta}/10.", "tension_level": 0})
      return sorted(stimuli, key=lambda s: s["tension_level"], reverse=True)

  def format_stimulus(stimuli):
      return "\n".join(f"{'⚡' if s['tension_level'] >= 2 else '📊'} {s['finding']}" for s in stimuli)
  ```

  ```javascript JavaScript theme={"dark"}
  function extractStimulus(metrics) {
    const o = metrics.overall_score || 0, e = metrics.emotional_impact || 0;
    const a = metrics.attention_score || 0, br = metrics.brand_recall_likelihood || "MEDIUM";
    const cta = metrics.cta_effectiveness || 0, chunks = metrics.chunks || [];
    const tier = o >= 75 ? "top" : o >= 50 ? "middle" : "bottom";
    const stimuli = [{ finding: `Overall: ${o}/100 (${tier} tier).`, tensionLevel: 0 }];

    if (e >= 7 && ["LOW", "VERY_LOW"].includes(br))
      stimuli.push({ finding: `High emotion (${e}/10) but low recall (${br}). Feels something, can't name who.`, tensionLevel: 3 });
    else if (e <= 4 && ["HIGH", "VERY_HIGH"].includes(br))
      stimuli.push({ finding: `Strong recall (${br}) but low emotion (${e}/10). Knows brand, doesn't care.`, tensionLevel: 3 });
    if (a >= 8 && cta <= 4)
      stimuli.push({ finding: `High attention (${a}/10) but weak CTA (${cta}/10). Holds eyes, doesn't convert.`, tensionLevel: 3 });
    if (chunks.length) {
      const fE = chunks[0].engagement || 0, lE = chunks[chunks.length - 1].engagement || 0;
      if (fE >= 70 && lE <= 40) stimuli.push({ finding: `Engagement drops ${fE}→${lE}. Hook works, ad loses people.`, tensionLevel: 2 });
      const peak = chunks.reduce((x, y) => (y.emotional_intensity || 0) > (x.emotional_intensity || 0) ? y : x);
      stimuli.push({ finding: `Peak emotion at ${peak.start_time ?? 0}s (${peak.emotional_intensity}/10).`, tensionLevel: 1 });
    }
    stimuli.push({ finding: `Emotion: ${e}/10, Attention: ${a}/10, Recall: ${br}, CTA: ${cta}/10.`, tensionLevel: 0 });
    return stimuli.sort((x, y) => y.tensionLevel - x.tensionLevel);
  }

  function formatStimulus(stimuli) {
    return stimuli.map((s) => `${s.tensionLevel >= 2 ? "⚡" : "📊"} ${s.finding}`).join("\n");
  }
  ```
</CodeGroup>

<Warning>
  The stimulus logic looks for *tensions* — contradictory metric pairs. These are the most productive Focus Group prompts because they force personas to explain nuance that raw scores can't capture.
</Warning>

***

## Stage 3 — Focus Group with AI Stimulus

Questions *reference the AI scores directly*. High-tension findings become probing prompts.

<CodeGroup>
  ```python Python theme={"dark"}
  def build_focus_group_questions(stimuli: list[dict]) -> list[dict]:
      questions = [
          {"question": "Watch this ad. First impression? Would you keep watching or scroll past?", "type": "OPEN_ENDED", "order": 1},
          {"question": "0-10, how likely to recommend this product after watching?", "type": "NPS", "order": 2},
      ]
      order = 3
      for s in [s for s in stimuli if s["tension_level"] >= 2][:3]:
          questions.append({"question": f'AI analysis found: "{s["finding"]}" — Do you agree? Why or why not?',
                            "type": "OPEN_ENDED", "order": order})
          order += 1
      questions.append({"question": "What single change would improve this ad most?", "type": "OPEN_ENDED", "order": order})
      order += 1
      questions.append({"question": "Do you trust the AI's assessment?", "type": "MULTIPLE_CHOICE",
                        "options": ["Yes", "Partially", "No", "Need context"], "order": order})
      return questions


  def run_focus_group(asset_id: str, stimuli: list[dict]) -> dict:
      resp = requests.post(f"{BASE}/focus-groups", headers=HEADERS, json={
          "name": "Double Analysis", "sample_size": 25, "persona_ids": [PERSONA_ID],
          "workspace_id": WORKSPACE_ID, "assets": [{"id": asset_id, "label": "Ad Under Review"}],
          "questions": build_focus_group_questions(stimuli),
      }).json()
      if "error" in resp: raise Exception(resp["error"]["message"])
      return resp

  def poll_focus_group(fg_id: str, timeout_min: int = 20) -> dict:
      for _ in range(timeout_min * 6):
          resp = requests.get(f"{BASE}/focus-groups/{fg_id}", headers=HEADERS).json()
          if "error" in resp: raise Exception(resp["error"]["message"])
          if resp["status"] == "COMPLETED": return resp
          time.sleep(10)
      raise TimeoutError(f"Focus group {fg_id} timed out")
  ```

  ```javascript JavaScript theme={"dark"}
  function buildFocusGroupQuestions(stimuli) {
    const questions = [
      { question: "Watch this ad. First impression? Keep watching or scroll past?", type: "OPEN_ENDED", order: 1 },
      { question: "0-10, how likely to recommend this product after watching?", type: "NPS", order: 2 },
    ];
    let order = 3;
    for (const s of stimuli.filter((s) => s.tensionLevel >= 2).slice(0, 3)) {
      questions.push({ question: `AI analysis found: "${s.finding}" — Do you agree? Why or why not?`, type: "OPEN_ENDED", order: order++ });
    }
    questions.push({ question: "What single change would improve this ad most?", type: "OPEN_ENDED", order: order++ });
    questions.push({ question: "Do you trust the AI's assessment?", type: "MULTIPLE_CHOICE",
      options: ["Yes", "Partially", "No", "Need context"], order: order++ });
    return questions;
  }

  async function runFocusGroup(assetId, stimuli) {
    const resp = await fetch(`${BASE}/focus-groups`, {
      method: "POST", headers: HEADERS,
      body: JSON.stringify({
        name: "Double Analysis: Video + Focus Group", sample_size: 25,
        persona_ids: [PERSONA_ID], workspace_id: WORKSPACE_ID,
        assets: [{ id: assetId, label: "Ad Under Review" }],
        questions: buildFocusGroupQuestions(stimuli),
      }),
    }).then((r) => r.json());
    if (resp.error) throw new Error(resp.error.message);
    return resp;
  }

  async function pollFocusGroup(fgId, timeoutMin = 20) {
    for (let i = 0; i < timeoutMin * 6; i++) {
      const resp = await fetch(`${BASE}/focus-groups/${fgId}`, { headers: HEADERS }).then((r) => r.json());
      if (resp.error) throw new Error(resp.error.message);
      if (resp.status === "COMPLETED") return resp;
      await new Promise((r) => setTimeout(r, 10000));
    }
    throw new Error(`Focus group ${fgId} timed out`);
  }
  ```
</CodeGroup>

<Tip>
  The most powerful question pattern: "The AI says \[specific finding]. Do you agree? Why or why not?" This forces personas to engage with data rather than give generic reactions.
</Tip>

***

## Stage 4 — Mave Synthesis

Feed both layers in. Mave produces a report that explains the numbers with audience-level insight.

<CodeGroup>
  ```python Python theme={"dark"}
  def format_fg_results(fg_results: dict) -> str:
      lines = []
      for r in fg_results.get("results", []):
          lines.append(f"### Q: {r['question']}")
          if r["type"] == "NPS": lines.append(f"NPS: {r.get('nps_score', 'N/A')}")
          if r.get("summary"): lines.append(r["summary"])
          lines.append("")
      return "\n".join(lines)


  def generate_layered_report(metrics: dict, stimuli: list[dict], fg_results: dict) -> str:
      m = metrics
      metrics_line = f"Overall: {m.get('overall_score', '?')}/100 | Emotion: {m.get('emotional_impact', '?')}/10 | Attention: {m.get('attention_score', '?')}/10 | Recall: {m.get('brand_recall_likelihood', '?')} | CTA: {m.get('cta_effectiveness', '?')}/10"
      chunks_text = "\n".join(f"  {c.get('start_time', 0)}–{c.get('end_time', 5)}s: eng={c.get('engagement', '?')}, emo={c.get('emotional_intensity', '?')}"
                              for c in m.get("chunks", []))

      prompt = f"""You are a senior creative analyst producing a layered video ad analysis.

  ## Layer 1: AI Video Analysis
  **Metrics:** {metrics_line}
  **Chunks:**
  {chunks_text or "No chunk data."}
  **Findings:**
  {format_stimulus(stimuli)}

  ## Layer 2: Focus Group (25 respondents, shown ad + AI findings)
  {format_fg_results(fg_results)}

  ## Your Task — Layered Analysis
  1. **Executive Summary** — 3 sentences no single layer could produce.
  2. **Where AI and Audience Agree** — Highest-confidence insights.
  3. **Where They Disagree** — Which signal to trust and why.
  4. **Tension Resolution** — Audience reaction to each high-tension finding.
  5. **Emotional Journey** — Chunk data + audience descriptions combined.
  6. **Brand Recall** — AI score vs what audience remembers.
  7. **CTA Assessment** — AI score vs NPS.
  8. **The One Change** — Highest-impact change from both layers.
  9. **Final Verdict** — Ship, iterate, or rethink?

  Reference AI scores and Focus Group summaries together."""

      resp = requests.post(f"{BASE}/mave/chat", headers=HEADERS,
                           json={"message": prompt}, timeout=180).json()
      if "error" in resp:
          raise Exception(resp["error"]["message"])
      return resp["content"]
  ```

  ```javascript JavaScript theme={"dark"}
  function formatFgResults(fgResults) {
    return (fgResults.results || []).map((r) => {
      let out = `### Q: ${r.question}\n`;
      if (r.type === "NPS") out += `NPS: ${r.nps_score ?? "N/A"}\n`;
      if (r.summary) out += `${r.summary}\n`;
      return out;
    }).join("\n");
  }

  async function generateLayeredReport(metrics, stimuli, fgResults) {
    const metricsLine = `Overall: ${metrics.overall_score ?? "?"}/100 | Emotion: ${metrics.emotional_impact ?? "?"}/10 | Attention: ${metrics.attention_score ?? "?"}/10 | Recall: ${metrics.brand_recall_likelihood ?? "?"} | CTA: ${metrics.cta_effectiveness ?? "?"}/10`;
    const chunksText = (metrics.chunks || []).map((c) =>
      `  ${c.start_time ?? 0}–${c.end_time ?? 5}s: eng=${c.engagement ?? "?"}, emo=${c.emotional_intensity ?? "?"}`
    ).join("\n");

    const prompt = `You are a senior creative analyst producing a layered video ad analysis.

  ## Layer 1: AI Video Analysis
  **Metrics:** ${metricsLine}
  **Chunks:**
  ${chunksText || "No chunk data."}
  **Findings:**
  ${formatStimulus(stimuli)}

  ## Layer 2: Focus Group (25 respondents, shown ad + AI findings)
  ${formatFgResults(fgResults)}

  ## Layered Analysis
  1. **Executive Summary** — 3 sentences no single layer could produce.
  2. **Where They Agree** — Highest-confidence insights.
  3. **Where They Disagree** — Which signal to trust?
  4. **Tension Resolution** — Audience reaction to high-tension findings.
  5. **Emotional Journey** — Chunk data + audience descriptions.
  6. **Brand Recall** — AI score vs audience memory.
  7. **CTA** — AI score vs NPS.
  8. **The One Change** — Highest-impact change.
  9. **Final Verdict** — Ship, iterate, or rethink?`;

    const resp = await fetch(`${BASE}/mave/chat`, {
      method: "POST", headers: HEADERS,
      body: JSON.stringify({ message: prompt }), signal: AbortSignal.timeout(180000),
    }).then((r) => r.json());
    if (resp.error) throw new Error(resp.error.message);
    return resp.content;
  }
  ```
</CodeGroup>

***

## Running the Full Pipeline

<CodeGroup>
  ```python Python theme={"dark"}
  def run_double_analysis(video_path: str = "./new_creative.mp4"):
      asset = upload_video(video_path)
      analysis = create_analysis(asset["id"], asset["name"])
      result = poll_analysis(analysis["id"])
      metrics = result.get("results", {}).get("full_video_metrics", {})
      print(f"AI Score: {metrics.get('overall_score')}/100")

      stimuli = extract_stimulus(metrics)
      print(format_stimulus(stimuli))

      fg = run_focus_group(asset["id"], stimuli)
      fg_result = poll_focus_group(fg["id"])
      report = generate_layered_report(metrics, stimuli, fg_result)

      with open("double_analysis_report.md", "w") as f:
          f.write(f"# Double Analysis — {asset['name']}\n\n{format_stimulus(stimuli)}\n\n---\n\n{report}")
      print("Saved to double_analysis_report.md")
      return {"metrics": metrics, "stimuli": stimuli, "fg_result": fg_result, "report": report}

  if __name__ == "__main__":
      import sys
      run_double_analysis(sys.argv[1] if len(sys.argv) > 1 else "./new_creative.mp4")
  ```

  ```javascript JavaScript theme={"dark"}
  async function runDoubleAnalysis(videoPath = "./new_creative.mp4") {
    const asset = await uploadVideo(videoPath);
    const analysis = await createAnalysis(asset.id, asset.name);
    const result = await pollAnalysis(analysis.id);
    const metrics = result.results?.full_video_metrics || {};
    const stimuli = extractStimulus(metrics);
    const fg = await runFocusGroup(asset.id, stimuli);
    const fgResult = await pollFocusGroup(fg.id);
    const report = await generateLayeredReport(metrics, stimuli, fgResult);
    const date = new Date().toISOString().split("T")[0];
    fs.writeFileSync("double_analysis_report.md",
      `# Double Analysis\n\n**Ad:** ${asset.name} | **AI Score:** ${metrics.overall_score ?? "?"}/100\n\n` +
      `## Stimulus\n\n${formatStimulus(stimuli)}\n\n## Focus Group\n\n${formatFgResults(fgResult)}\n\n---\n\n${report}`);
    return { metrics, stimuli, fgResult, report };
  }
  runDoubleAnalysis(process.argv[2] || "./new_creative.mp4");
  ```
</CodeGroup>

***

## Example Output

```markdown theme={"dark"}
# Video + Focus Group Double Analysis

**Ad:** spring_launch_30s.mp4 | **AI Score:** 72/100

## AI Stimulus
⚡ High emotion (8/10) but low brand recall (LOW). Feel something, can't name who.
⚡ High attention (8/10) but weak CTA (3/10). Holds attention, doesn't convert.

## Executive Summary
The combination reveals a creative that *moves* people but fails to *brand*
them. 18 of 25 Focus Group respondents described how the ad made them feel,
but only 7 named the brand without prompting...

## Where AI and Audience Disagree
AI rated CTA at 3/10, but 15 respondents said they'd "probably visit the
website." AI measures CTA clarity/placement; audience responds to overall
persuasion — different valid lenses on the same ad...
```

***

## Variations

<AccordionGroup>
  <Accordion title="Multiple personas for richer Focus Group">
    Use 3–5 persona IDs for segment-level reactions to the AI findings.
  </Accordion>

  <Accordion title="A/B double analysis — two creatives">
    Run the full pipeline on both Creative A and Creative B. Compare layered reports side by side.
  </Accordion>

  <Accordion title="Iterative refinement loop">
    After the first analysis, make changes, re-upload, run again. Compare reports to measure improvement.
  </Accordion>

  <Accordion title="Skip Mave — present layers directly">
    For stakeholder meetings, use the AI stimulus + Focus Group results as-is without Mave synthesis.
  </Accordion>

  <Accordion title="Force stimulus when no tensions exist">
    If all metrics are moderate, manually inject: "All metrics are moderate (50–70). Is this ad 'safe but forgettable'?"
  </Accordion>
</AccordionGroup>

***

## Credits Estimate

| Stage                             | Typical Cost  | Notes                          |
| --------------------------------- | ------------- | ------------------------------ |
| File upload                       | 0             | Free                           |
| Video Analysis                    | 100–250       | Depends on video length        |
| Focus Group (N=25, 6–8 questions) | 75–150        | Sample size + question count   |
| Mave synthesis                    | 15–30         | Large context from both layers |
| **Single-ad double analysis**     | **\~190–430** | Conservative range             |

<Tip>
  Reserve this for high-stakes creatives — hero campaigns, product launches, brand films. For routine testing, use [Ad Creative Audit](/playbooks/ad-creative-audit) instead.
</Tip>

***

## See Also

<CardGroup cols={2}>
  <Card title="Ad Creative Audit" icon="video" href="/playbooks/ad-creative-audit">
    Score multiple ads without the Focus Group layer
  </Card>

  <Card title="Hook Analysis Sprint" icon="stopwatch" href="/playbooks/hook-analysis-sprint">
    Deep-dive into the first 3 seconds across 10 variants
  </Card>

  <Card title="Competitor Reel" icon="film" href="/playbooks/competitor-reel">
    Analyze competitor ads with the same Video Analysis pipeline
  </Card>

  <Card title="Video Analysis" icon="video" href="/features/video-analysis">
    Metrics reference, chunk options, and chat endpoint
  </Card>

  <Card title="Focus Groups" icon="users" href="/features/focus-groups">
    All 12 question types and audience simulation
  </Card>

  <Card title="Mave Agent" icon="brain" href="/features/mave-agent">
    Research agent for synthesis and recommendations
  </Card>
</CardGroup>
