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.
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.
What You Need
| Requirement | Details |
|---|---|
| Mavera API key | Starts with mvra_live_. Get one at Developer Settings. |
| 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. |
| 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
1
Upload the video
Standard three-step Files API upload.
2
Run Video Analysis
Get frame-level scores. These become the raw data layer.
3
Extract stimulus from AI scores
Find tensions — places where one metric is high but another is low. These become Focus Group prompts.
4
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?”
5
Synthesize with Mave
Feed both layers into Mave for a combined insight report.
Stage 1 — Upload + Video Analysis
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")
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`);
}
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.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)
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");
}
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.
Stage 3 — Focus Group with AI Stimulus
Questions reference the AI scores directly. High-tension findings become probing prompts.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")
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`);
}
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.
Stage 4 — Mave Synthesis
Feed both layers in. Mave produces a report that explains the numbers with audience-level insight.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"]
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;
}
Running the Full Pipeline
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")
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");
Example Output
# 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
Multiple personas for richer Focus Group
Multiple personas for richer Focus Group
Use 3–5 persona IDs for segment-level reactions to the AI findings.
A/B double analysis — two creatives
A/B double analysis — two creatives
Run the full pipeline on both Creative A and Creative B. Compare layered reports side by side.
Iterative refinement loop
Iterative refinement loop
After the first analysis, make changes, re-upload, run again. Compare reports to measure improvement.
Skip Mave — present layers directly
Skip Mave — present layers directly
For stakeholder meetings, use the AI stimulus + Focus Group results as-is without Mave synthesis.
Force stimulus when no tensions exist
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’?”
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 |
Reserve this for high-stakes creatives — hero campaigns, product launches, brand films. For routine testing, use Ad Creative Audit instead.
See Also
Ad Creative Audit
Score multiple ads without the Focus Group layer
Hook Analysis Sprint
Deep-dive into the first 3 seconds across 10 variants
Competitor Reel
Analyze competitor ads with the same Video Analysis pipeline
Video Analysis
Metrics reference, chunk options, and chat endpoint
Focus Groups
All 12 question types and audience simulation
Mave Agent
Research agent for synthesis and recommendations