Add ad-video skills (ad-creative, launch, testimonial)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VU6qqzefQ2TxgZetJ7beKU
This commit is contained in:
15 files changed
+1406
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: ad-creative-video
|
||||
description: This skill should be used when the user asks to "make a video ad", "create an animated ad", "build a performance/UGC-style ad", "batch-produce ad creative variations", "generate ad variants for A/B testing", "swap headline/offer/CTA across many ad versions", or "export one ad in multiple aspect ratios for Meta/TikTok/Reels". Covers ad hook structures, hook→CTA message-match, data-driven variant generation (1 template × CSV = N ads), multi-aspect export, and platform specs.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Ad Creative Video
|
||||
|
||||
Build ONE motion-graphics ad template, then mass-produce variants from a data table — different hooks, offers, CTAs, and products — so a performance marketer can A/B test dozens of versions across placements and aspect ratios. The craft is making the template fully data-driven and message-matched, so 40 variants stay on-brand and only the tested variable changes.
|
||||
|
||||
## When to use
|
||||
|
||||
- Performance/UGC-style video ads where the first 1.5–3s must stop the scroll.
|
||||
- Batch variant generation: one template rendered against many rows (headline/offer/CTA/product swaps) for A/B testing.
|
||||
- Multi-aspect export of the same ad for Meta Feed, Reels/Stories, TikTok, YouTube.
|
||||
|
||||
Not for sale/discount countdown promos, and not for testimonial/review-driven ads — those are different structures. Stay on the batch-variation, test-everything angle.
|
||||
|
||||
## The two rules that make variants worth running
|
||||
|
||||
1. **Isolate one variable per variant.** A test only teaches something if exactly one thing changes. Hold layout, motion, colors, and timing constant; swap *only* the field under test (hook, or offer, or CTA). Mixing two changes makes the winner uninterpretable.
|
||||
2. **Message-match the hook to the CTA.** The promise made in the first 3 seconds must be the promise the button pays off. A "stop wasting 2 hours a day" hook ends on "Save 2 hours — try free," not a generic "Shop now." The hook and CTA are a matched pair in every row of the data table.
|
||||
|
||||
## Ad anatomy (15–30s)
|
||||
|
||||
| Beat | Job | Budget |
|
||||
|---|---|---|
|
||||
| Hook (0–3s) | Stop the scroll; state the problem or pattern-interrupt | 0–3s |
|
||||
| Context | Make the viewer feel the problem / agitate | 3–8s |
|
||||
| Payoff | Show the product as the solution, one clear benefit | 8–18s |
|
||||
| Proof | One concrete number, demo, or result | 18–25s |
|
||||
| CTA (last 3s) | Single action, message-matched to the hook | hold ≥2s |
|
||||
|
||||
Land the hook's emotional trigger before the 2-second mark — judgment forms in ~1.7s and scroll speed keeps rising. Test the hook FIRST; it moves CPA more than any other element.
|
||||
|
||||
## Hook taxonomy (the field you vary most)
|
||||
|
||||
| Hook type | Template | When it wins |
|
||||
|---|---|---|
|
||||
| Problem-first | "I didn't realize how much X was costing me until…" | pain is felt but unnamed |
|
||||
| Pattern interrupt | "This is going to sound controversial, but…" | crowded feed, generic category |
|
||||
| Curiosity gap | "Nobody talks about the one thing that…" | educated, skeptical audience |
|
||||
| Immediate benefit | "Cut your X in half in 7 days" | clear, quantifiable outcome |
|
||||
| Direct callout | "If you do X every morning, stop." | sharp audience segment |
|
||||
|
||||
Keep a column of 5–10 hook strings and let the batch render produce one ad per hook against the same body — that is the cleanest hook test possible.
|
||||
|
||||
## Data-driven template (everything is a prop)
|
||||
|
||||
Hardcode nothing the marketer might A/B test. The composition reads a single `variant` object; the renderer never edits the component. Make every animated value a pure function of `useCurrentFrame()` so each frame renders deterministically (no CSS transitions, no library timers — they desync the render).
|
||||
|
||||
```tsx
|
||||
import {useCurrentFrame, interpolate, AbsoluteFill, useVideoConfig} from "remotion";
|
||||
|
||||
type Variant = {hook: string; benefit: string; proof: string; cta: string; bg: string; accent: string};
|
||||
|
||||
export const AdTemplate: React.FC<{v: Variant}> = ({v}) => {
|
||||
const frame = useCurrentFrame(); const {fps} = useVideoConfig();
|
||||
const hookIn = interpolate(frame, [0, 8], [40, 0], {extrapolateRight: "clamp"});
|
||||
const ctaIn = interpolate(frame, [fps * 22, fps * 23], [0, 1], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
|
||||
return (
|
||||
<AbsoluteFill style={{background: v.bg, fontFamily: "Inter, sans-serif"}}>
|
||||
<h1 style={{transform: `translateY(${hookIn}px)`, opacity: interpolate(frame,[0,8],[0,1])}}>{v.hook}</h1>
|
||||
{frame > fps * 8 && <p>{v.benefit}</p>}
|
||||
{frame > fps * 18 && <strong>{v.proof}</strong>}
|
||||
<button style={{opacity: ctaIn, background: v.accent}}>{v.cta}</button>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
See `references/batch-ad-pipeline.md` for the complete template, the CSV→props parser with validation, and the brand-lock theme object.
|
||||
|
||||
## 1 template × CSV = N variants
|
||||
|
||||
The payoff: author the ad once, then let a CSV drive the matrix. Each row is one fully-formed, message-matched variant. Render once per row.
|
||||
|
||||
```bash
|
||||
# variants.csv: id,hook,benefit,proof,cta,bg,accent → one MP4 per row
|
||||
node csv-to-props.js variants.csv ./props # writes props/<id>.json per row
|
||||
for f in props/*.json; do
|
||||
id=$(basename "$f" .json)
|
||||
npx remotion render AdTemplate "out/${id}.mp4" --props="$f"
|
||||
done
|
||||
```
|
||||
|
||||
To build a clean test matrix, hold every column constant and vary one: 5 hooks × 1 body = 5 ads (hook test); then take the winning hook × 3 CTAs (CTA test). `references/batch-ad-pipeline.md` has a matrix generator that expands variable lists into the CSV so combinatorial tests stay one-variable-at-a-time.
|
||||
|
||||
## Multi-aspect export
|
||||
|
||||
Each placement wants a native aspect. Compose against a center safe zone so one master reframes cleanly to all of them — don't letterbox, re-center.
|
||||
|
||||
| Aspect | Placements | Resolution | Keep-clear safe zone |
|
||||
|---|---|---|---|
|
||||
| 9:16 | Reels, Stories, TikTok In-Feed | 1080×1920 | top ~14%, bottom ~20–35% (UI/caption), sides ~6% |
|
||||
| 4:5 | Meta Feed (best feed real estate) | 1080×1350 | center 90%; CTA out of bottom 10% |
|
||||
| 1:1 | Feed, broad reach | 1080×1080 | center square survives every crop |
|
||||
| 16:9 | YouTube, in-stream | 1920×1080 | center 90% |
|
||||
|
||||
Design the hook text, product, and CTA inside the **1:1 center square** so they survive every crop. Drive aspect from a prop and render each per variant:
|
||||
|
||||
```bash
|
||||
for ar in 9x16 4x5 1x1; do
|
||||
npx remotion render AdTemplate "out/${id}-${ar}.mp4" --props="props/${id}.json" \
|
||||
--props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done
|
||||
```
|
||||
|
||||
Keep critical content out of the 9:16 bottom third — that is where the platform stacks captions, the CTA button, and the username. `references/platform-specs.md` has exact pixel safe zones and per-platform duration/format limits.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- One variable per variant; layout/motion/timing identical across a test.
|
||||
- Hook lands its trigger before 2s; hook and CTA are message-matched in every row.
|
||||
- Every animated value is a pure function of `useCurrentFrame()` — no CSS/library timers.
|
||||
- Dataset is a prop; nothing testable is hardcoded; one template renders the whole CSV.
|
||||
- Each variant exported 9:16 + 4:5 + 1:1 (+16:9 if needed), key content in the center square.
|
||||
- Brand stays locked across all variants via a single theme object.
|
||||
|
||||
## Deliver & verify (rendered stills → MP4)
|
||||
|
||||
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
|
||||
|
||||
**Output contract:**
|
||||
- A Remotion ad template registered as `<Composition>` (+ zod `schema` + `defaultProps`), every animated value frame-driven (no CSS transitions / library timers / `Date.now()` / `Math.random()`).
|
||||
- Deliverable = the rendered `out/*.mp4` per variant per aspect (plus the project + CSV, so the marketer re-renders on new rows).
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Cheap PNGs first, full encode only once they're clean. Render with the **shipped** props (the real row), not just `defaultProps`.
|
||||
|
||||
```bash
|
||||
# Frame-exact stills across the hook→CTA arc, with a real variant's props
|
||||
npx remotion still AdTemplate out/f-hook.png --frame=12 --props=props/v1.json # hook readable < 2s
|
||||
npx remotion still AdTemplate out/f-mid.png --frame=300 --props=props/v1.json # benefit/proof
|
||||
npx remotion still AdTemplate out/f-cta.png --frame=689 --props=props/v1.json # CTA, message-matched
|
||||
# inspect each: fidelity (hook / offer / proof / CTA text exact, brand bg+accent right)
|
||||
# AND artifacts (text overflow, off-canvas, CTA inside the 9:16 bottom third, missing font, wrong row binding)
|
||||
```
|
||||
|
||||
**Multi-aspect / batch — verify one variant in EACH aspect before batch-rendering the matrix.** A layout bug repeats across every row × aspect; catch it once.
|
||||
|
||||
```bash
|
||||
for ar in 9x16 4x5 1x1; do # one representative variant, every target aspect
|
||||
npx remotion still AdTemplate "out/v1-${ar}.png" --frame=300 \
|
||||
--props=props/v1.json --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done
|
||||
# stills clean in all aspects? → then batch-render every row × aspect:
|
||||
for f in props/*.json; do id=$(basename "$f" .json); for ar in 9x16 4x5 1x1; do
|
||||
npx remotion render AdTemplate "out/${id}-${ar}.mp4" --props="$f" --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done; done
|
||||
npx remotion render AdTemplate out/demo.gif --codec=gif --props=props/v1.json # README demo
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. `npx remotion still` renders cleanly at hook / mid / CTA — no errors, no missing fonts/assets.
|
||||
2. Hook/offer/proof/CTA text exact and brand colors right; nothing in the 9:16 bottom third or outside the center safe zone.
|
||||
3. Frame-driven only — no CSS/library timers, `Date.now()`, or `Math.random()`.
|
||||
4. Shipped row's props render correctly (not just `defaultProps`); one variable per variant holds.
|
||||
5. One variant verified in 9:16 + 4:5 + 1:1 before the batch; MP4s play; (optional) GIF for the README.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/batch-ad-pipeline.md` — the full runnable Remotion ad template, the CSV→typed-props parser with validation, a one-variable test-matrix generator, the brand-lock theme object, and the complete batch + multi-aspect render script.
|
||||
- `references/platform-specs.md` — exact 2025/2026 pixel safe zones, durations, aspect ratios, and format limits for Meta Feed/Reels/Stories, TikTok In-Feed, and YouTube, plus a hook→CTA message-match worksheet.
|
||||
@@ -0,0 +1,289 @@
|
||||
# Batch Ad Pipeline — full template, parser, matrix, render
|
||||
|
||||
This is the complete, runnable implementation behind the SKILL.md overview: a data-driven Remotion ad template, a CSV→props parser with validation, a one-variable test-matrix generator, a brand-lock theme, and the batch + multi-aspect render scripts. Hardcode nothing a marketer might want to A/B test.
|
||||
|
||||
## 1. The brand-lock theme
|
||||
|
||||
Keep every brand-controlled value in one object so 40 variants stay consistent and only the *tested* fields change per row. The variant data supplies copy and per-campaign accents; the theme supplies the locked look.
|
||||
|
||||
```ts
|
||||
// theme.ts
|
||||
export const theme = {
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
ink: "#0B0B0F",
|
||||
paper: "#FFFFFF",
|
||||
radius: 20,
|
||||
hookSize: 76, // px, on the 1080-wide master
|
||||
bodySize: 44,
|
||||
proofSize: 52,
|
||||
ctaSize: 40,
|
||||
pad: 80, // outer padding; also the side safe margin
|
||||
durationInSeconds: 25,
|
||||
} as const;
|
||||
```
|
||||
|
||||
## 2. Aspect config
|
||||
|
||||
Drive layout from a single aspect prop so one composition reframes to every placement. Dimensions and the keep-clear bottom band differ per aspect.
|
||||
|
||||
```ts
|
||||
// aspects.ts
|
||||
export type Aspect = "9x16" | "4x5" | "1x1" | "16x9";
|
||||
export const ASPECTS: Record<Aspect, {w: number; h: number; bottomSafe: number}> = {
|
||||
"9x16": {w: 1080, h: 1920, bottomSafe: 540}, // captions + CTA + username stack here
|
||||
"4x5": {w: 1080, h: 1350, bottomSafe: 135},
|
||||
"1x1": {w: 1080, h: 1080, bottomSafe: 110},
|
||||
"16x9": {w: 1920, h: 1080, bottomSafe: 110},
|
||||
};
|
||||
```
|
||||
|
||||
## 3. The data-driven template
|
||||
|
||||
Every animated value is a pure function of `useCurrentFrame()` — no CSS transitions, no GSAP/library timers (they desync deterministic rendering and flicker in the export). The component reads one `variant` plus an `aspect`; the renderer never edits this file.
|
||||
|
||||
```tsx
|
||||
// AdTemplate.tsx
|
||||
import {
|
||||
AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, spring, Sequence,
|
||||
} from "remotion";
|
||||
import {theme} from "./theme";
|
||||
import {ASPECTS, Aspect} from "./aspects";
|
||||
|
||||
export type Variant = {
|
||||
id: string;
|
||||
hook: string; // 0–3s scroll-stopper
|
||||
benefit: string; // the single payoff claim
|
||||
proof: string; // one concrete number / result
|
||||
cta: string; // message-matched to the hook
|
||||
accent: string; // per-campaign accent color
|
||||
bg: string; // background color
|
||||
};
|
||||
|
||||
export const AdTemplate: React.FC<{v: Variant; aspect: Aspect}> = ({v, aspect}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const {fps} = useVideoConfig();
|
||||
const {bottomSafe} = ASPECTS[aspect];
|
||||
|
||||
// Hook rises + fades in over the first 8 frames, holds, then eases up and out at 8s.
|
||||
const hookY = interpolate(frame, [0, 8], [48, 0], {extrapolateRight: "clamp"});
|
||||
const hookOpacity =
|
||||
interpolate(frame, [0, 8], [0, 1], {extrapolateRight: "clamp"}) *
|
||||
interpolate(frame, [fps * 7, fps * 8], [1, 0], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
|
||||
|
||||
// CTA springs in for impact in the last 3s and holds.
|
||||
const ctaProgress = spring({frame: frame - fps * 22, fps, config: {damping: 14, stiffness: 120}});
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{backgroundColor: v.bg, fontFamily: theme.fontFamily, color: theme.ink}}>
|
||||
<AbsoluteFill style={{padding: theme.pad, paddingBottom: bottomSafe, justifyContent: "center"}}>
|
||||
|
||||
{/* HOOK 0–8s */}
|
||||
<Sequence durationInFrames={fps * 8}>
|
||||
<h1 style={{fontSize: theme.hookSize, fontWeight: 800, lineHeight: 1.05,
|
||||
transform: `translateY(${hookY}px)`, opacity: hookOpacity}}>
|
||||
{v.hook}
|
||||
</h1>
|
||||
</Sequence>
|
||||
|
||||
{/* BENEFIT 8–18s */}
|
||||
<Sequence from={fps * 8} durationInFrames={fps * 10}>
|
||||
<FadeUp delay={0}>
|
||||
<p style={{fontSize: theme.bodySize, fontWeight: 600}}>{v.benefit}</p>
|
||||
</FadeUp>
|
||||
</Sequence>
|
||||
|
||||
{/* PROOF 18–22s */}
|
||||
<Sequence from={fps * 18} durationInFrames={fps * 4}>
|
||||
<FadeUp delay={0}>
|
||||
<strong style={{fontSize: theme.proofSize, color: v.accent,
|
||||
fontVariantNumeric: "tabular-nums"}}>{v.proof}</strong>
|
||||
</FadeUp>
|
||||
</Sequence>
|
||||
|
||||
{/* CTA last 3s, message-matched to the hook */}
|
||||
<Sequence from={fps * 22}>
|
||||
<button style={{
|
||||
alignSelf: "flex-start", marginTop: 40, padding: "22px 44px",
|
||||
fontSize: theme.ctaSize, fontWeight: 700, border: "none",
|
||||
borderRadius: theme.radius, color: theme.paper, backgroundColor: v.accent,
|
||||
transform: `scale(${interpolate(ctaProgress, [0, 1], [0.85, 1])})`,
|
||||
opacity: ctaProgress,
|
||||
}}>
|
||||
{v.cta}
|
||||
</button>
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const FadeUp: React.FC<{children: React.ReactNode; delay: number}> = ({children, delay}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const o = interpolate(frame - delay, [0, 10], [0, 1], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
|
||||
const y = interpolate(frame - delay, [0, 10], [24, 0], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
|
||||
return <div style={{opacity: o, transform: `translateY(${y}px)`}}>{children}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
## 4. Register the composition (aspect + variant as props)
|
||||
|
||||
```tsx
|
||||
// Root.tsx
|
||||
import {Composition} from "remotion";
|
||||
import {AdTemplate} from "./AdTemplate";
|
||||
import {ASPECTS} from "./aspects";
|
||||
import {theme} from "./theme";
|
||||
|
||||
const defaultVariant = {
|
||||
id: "demo", hook: "Spending 2 hrs/day on reports?", benefit: "Auto-build them in one click.",
|
||||
proof: "Teams save 11 hrs a week.", cta: "Save 2 hours — try free", accent: "#4F46E5", bg: "#FFFFFF",
|
||||
};
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
const aspect = "9x16" as const; // overridden per-render via --props
|
||||
const {w, h} = ASPECTS[aspect];
|
||||
return (
|
||||
<Composition
|
||||
id="AdTemplate"
|
||||
component={AdTemplate}
|
||||
durationInFrames={theme.durationInSeconds * 30}
|
||||
fps={30}
|
||||
width={w}
|
||||
height={h}
|
||||
defaultProps={{v: defaultVariant, aspect}}
|
||||
// Resolve real dimensions from the incoming aspect prop at render time:
|
||||
calculateMetadata={({props}) => {
|
||||
const a = ASPECTS[(props as any).aspect ?? "9x16"];
|
||||
return {width: a.w, height: a.h};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
`calculateMetadata` lets a single composition output any aspect from the `aspect` prop — no separate composition per ratio.
|
||||
|
||||
## 5. CSV → typed props, with validation
|
||||
|
||||
The dataset is the input. Parse, validate every row (a bad hex or empty CTA must fail loudly, not render a broken ad), and write one props file per variant.
|
||||
|
||||
```js
|
||||
// csv-to-props.js usage: node csv-to-props.js variants.csv ./props [aspect]
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const [, , csvPath, outDir, aspect = "9x16"] = process.argv;
|
||||
const REQUIRED = ["id", "hook", "benefit", "proof", "cta", "accent", "bg"];
|
||||
const isHex = (s) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s);
|
||||
|
||||
const parseCSV = (text) => {
|
||||
const [head, ...lines] = text.trim().split(/\r?\n/);
|
||||
const cols = head.split(",").map((c) => c.trim());
|
||||
return lines.filter(Boolean).map((line) => {
|
||||
// simple split; wrap fields containing commas in double quotes in the CSV
|
||||
const cells = line.match(/("([^"]|"")*"|[^,]*)/g).filter((_, i, a) => i < a.length - 1);
|
||||
const row = {};
|
||||
cols.forEach((c, i) => (row[c] = (cells[i] ?? "").replace(/^"|"$/g, "").replace(/""/g, '"').trim()));
|
||||
return row;
|
||||
});
|
||||
};
|
||||
|
||||
const rows = parseCSV(fs.readFileSync(csvPath, "utf8"));
|
||||
fs.mkdirSync(outDir, {recursive: true});
|
||||
|
||||
const seen = new Set();
|
||||
let errors = 0;
|
||||
rows.forEach((row, n) => {
|
||||
const where = `row ${n + 2} (${row.id || "no-id"})`;
|
||||
REQUIRED.forEach((k) => { if (!row[k]) { console.error(`✗ ${where}: missing "${k}"`); errors++; } });
|
||||
if (seen.has(row.id)) { console.error(`✗ ${where}: duplicate id`); errors++; }
|
||||
seen.add(row.id);
|
||||
if (row.hook && row.hook.length > 60) console.warn(`⚠ ${where}: hook >60 chars may clip in 9:16`);
|
||||
["accent", "bg"].forEach((k) => { if (row[k] && !isHex(row[k])) { console.error(`✗ ${where}: "${k}" not a hex color`); errors++; } });
|
||||
if (errors) return;
|
||||
fs.writeFileSync(path.join(outDir, `${row.id}.json`), JSON.stringify({v: row, aspect}, null, 2));
|
||||
});
|
||||
|
||||
if (errors) { console.error(`\n${errors} error(s) — no broken ads written.`); process.exit(1); }
|
||||
console.log(`✓ ${rows.length} variant prop files written to ${outDir}`);
|
||||
```
|
||||
|
||||
Example `variants.csv` (one fully message-matched ad per row):
|
||||
|
||||
```csv
|
||||
id,hook,benefit,proof,cta,accent,bg
|
||||
v01_problem,Spending 2 hrs/day on reports?,Auto-build them in one click.,Teams save 11 hrs a week.,Save 2 hours — try free,#4F46E5,#FFFFFF
|
||||
v02_interrupt,Your reporting tool is lying to you.,See the real numbers instantly.,Teams save 11 hrs a week.,See the real numbers — free,#DC2626,#0B0B0F
|
||||
v03_curiosity,Nobody talks about this reporting trick.,One click builds the whole report.,Teams save 11 hrs a week.,Try the trick — free,#059669,#FFFFFF
|
||||
```
|
||||
|
||||
Note the CTA mirrors the hook in every row — that is the message-match discipline encoded into the data.
|
||||
|
||||
## 6. Test-matrix generator — one variable at a time
|
||||
|
||||
A test only teaches something if exactly one field changes. This expands a base variant plus a list of values for ONE field into a CSV, so a hook test or a CTA test stays clean and combinatorial mistakes are impossible.
|
||||
|
||||
```js
|
||||
// make-matrix.js usage: node make-matrix.js > variants.csv
|
||||
const base = {benefit: "Auto-build them in one click.", proof: "Teams save 11 hrs a week.",
|
||||
cta: "Save 2 hours — try free", accent: "#4F46E5", bg: "#FFFFFF"};
|
||||
|
||||
// Vary ONLY this field; everything else is held identical across the test.
|
||||
const FIELD = "hook";
|
||||
const values = [
|
||||
"Spending 2 hrs/day on reports?",
|
||||
"Your reporting tool is lying to you.",
|
||||
"Nobody talks about this reporting trick.",
|
||||
"Cut report time in half this week.",
|
||||
"If you build reports by hand, stop.",
|
||||
];
|
||||
|
||||
const esc = (s) => (/[",]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
|
||||
const cols = ["id", "hook", "benefit", "proof", "cta", "accent", "bg"];
|
||||
console.log(cols.join(","));
|
||||
values.forEach((val, i) => {
|
||||
const row = {...base, [FIELD]: val, id: `${FIELD}_${String(i + 1).padStart(2, "0")}`};
|
||||
console.log(cols.map((c) => esc(row[c])).join(","));
|
||||
});
|
||||
```
|
||||
|
||||
To run a CTA test next, set `FIELD = "cta"`, paste the *winning* hook into `base.hook`, and list CTA variants. Same one-variable rule, next stage of the funnel.
|
||||
|
||||
## 7. Batch + multi-aspect render
|
||||
|
||||
Render every variant in every aspect a campaign needs. Concurrency keeps large batches fast.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# render-all.sh one MP4 per (variant × aspect)
|
||||
set -euo pipefail
|
||||
|
||||
node make-matrix.js > variants.csv # 1) build the one-variable test matrix
|
||||
ASPECTS=("9x16" "4x5" "1x1") # placements this campaign needs
|
||||
|
||||
for ar in "${ASPECTS[@]}"; do
|
||||
node csv-to-props.js variants.csv "props/$ar" "$ar" # 2) typed, validated props per aspect
|
||||
for f in "props/$ar"/*.json; do
|
||||
id=$(basename "$f" .json)
|
||||
npx remotion render AdTemplate "out/${id}_${ar}.mp4" \
|
||||
--props="$f" --concurrency=4 --log=error # 3) render
|
||||
done
|
||||
done
|
||||
|
||||
echo "Done. $(ls out/*.mp4 | wc -l) ad variants in ./out"
|
||||
```
|
||||
|
||||
5 hooks × 3 aspects = 15 MP4s from one template and one command. Add a CTA stage and the same template produces the next test wave with zero new component code.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Two variables changed at once** — the winner is uninterpretable. The matrix generator prevents this; keep using it.
|
||||
- **Hook clips in 9:16** — long hooks overflow; the parser warns past 60 chars. Keep hooks short and high in the frame.
|
||||
- **CTA hidden behind platform UI** — anything in the 9:16 bottom third gets covered by captions and the CTA button. The `bottomSafe` padding reserves that band.
|
||||
- **Drift across variants** — accent/copy live in the data, everything else in `theme`. If a variant looks off-brand, the fix is the theme, not the row.
|
||||
- **Animation flicker in export** — caused by CSS transitions or library timers. Every value must derive from `useCurrentFrame()`.
|
||||
|
||||
---
|
||||
## Built by the team behind iart.ai
|
||||
|
||||
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can mass-produce ad-creative variants from one template × a data table — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=ad-video-skills&utm_content=ref_footer&utm_term=ad-creative-video)
|
||||
@@ -0,0 +1,72 @@
|
||||
# Platform Specs & Message-Match Worksheet
|
||||
|
||||
Exact pixel safe zones, durations, aspect ratios, and format limits per placement (2025/2026), plus a worksheet for building message-matched hook→CTA pairs before they go into the CSV. Verify against each platform's current Ads Manager docs before a big spend — placements and safe zones shift.
|
||||
|
||||
## Aspect ratio → placement map
|
||||
|
||||
| Aspect | Resolution | Native placements |
|
||||
|---|---|---|
|
||||
| 9:16 | 1080×1920 | TikTok In-Feed, Instagram/Facebook Reels, Stories, YouTube Shorts |
|
||||
| 4:5 | 1080×1350 | Meta Feed (largest feed real estate, recommended for Feed video) |
|
||||
| 1:1 | 1080×1080 | Meta Feed, broad/automatic placements, X |
|
||||
| 16:9 | 1920×1080 | YouTube in-stream, landscape feed, in-article |
|
||||
|
||||
Master rule: design the hook, product, and CTA inside the **1:1 center square** so a single composition reframes to every aspect by re-centering, not letterboxing. Black bars on a vertical feed read as "ad" and underperform.
|
||||
|
||||
## Safe zones (keep-clear bands)
|
||||
|
||||
Platform UI — captions, CTA chips, usernames, profile icons, progress bars — overlaps the frame. Keep all critical content out of these bands.
|
||||
|
||||
| Aspect / placement | Top keep-clear | Bottom keep-clear | Sides |
|
||||
|---|---|---|---|
|
||||
| 9:16 Reels/Stories (Meta) | ~14% (~270px on 1920h) | ~20–35% (~384–672px) | ~6% (~65px) |
|
||||
| 9:16 TikTok In-Feed | ~130px | ~484px (right-side icon rail + caption) | right ~140px |
|
||||
| 4:5 Meta Feed | ~5% | ~10% (keep CTA above it) | ~5% |
|
||||
| 1:1 / 16:9 | center 90% safe | center 90% safe | center 90% safe |
|
||||
|
||||
The 9:16 bottom third is the single most violated zone — captions, the CTA button, and the handle stack there. The `bottomSafe` padding in `batch-ad-pipeline.md` reserves it.
|
||||
|
||||
## Duration & format limits
|
||||
|
||||
| Placement | Sweet spot | Hard limits | Format |
|
||||
|---|---|---|---|
|
||||
| Meta Feed (4:5/1:1) | 6–15s | up to 240 min, ≤4GB | MP4/MOV, H.264 |
|
||||
| Meta Reels/Stories (9:16) | 8–15s | Reels ≤90s, Stories ≤2 min | MP4/MOV |
|
||||
| TikTok In-Feed (9:16) | 15–30s (TikTok cites 21–34s) | up to 10 min (non-Spark) | MP4/MOV, ≥540×960 |
|
||||
| YouTube in-stream (16:9) | 15–30s | skippable after 5s | MP4 |
|
||||
|
||||
Practical default for performance video: **15–30s, with the hook trigger landing before 2s.** Most ad-account data shows attention falls off a cliff after ~15s, so front-load everything that matters.
|
||||
|
||||
## Hook → CTA message-match worksheet
|
||||
|
||||
Fill one row per ad *before* writing the CSV. The CTA must pay off the exact promise the hook made; if it doesn't, the click is wasted and the test is noisy. Each completed row becomes one record in `variants.csv`.
|
||||
|
||||
| Hook (0–3s promise) | Hook type | Single benefit | Proof point | CTA (pays off the hook) |
|
||||
|---|---|---|---|---|
|
||||
| "Spending 2 hrs/day on reports?" | problem-first | one-click report build | "save 11 hrs/week" | "Save 2 hours — try free" |
|
||||
| "Your reporting tool is lying to you." | pattern interrupt | shows real numbers instantly | "save 11 hrs/week" | "See the real numbers — free" |
|
||||
| "Nobody talks about this reporting trick." | curiosity gap | one click builds it | "save 11 hrs/week" | "Try the trick — free" |
|
||||
| "Cut report time in half this week." | immediate benefit | auto-built reports | "save 11 hrs/week" | "Cut it in half — free" |
|
||||
| "If you build reports by hand, stop." | direct callout | automate it now | "save 11 hrs/week" | "Automate yours — free" |
|
||||
|
||||
Bad pairing to avoid: a "stop wasting 2 hours" hook ending on a generic "Shop now" — the promise and the payoff don't connect, so even a great hook converts poorly.
|
||||
|
||||
## Testing order (which field to vary first)
|
||||
|
||||
Run tests in funnel order; isolate one field per wave using the matrix generator:
|
||||
|
||||
1. **Hook** — biggest lever on CPA; test 5–8 hooks against one identical body.
|
||||
2. **Offer / benefit** — take the winning hook, vary the core promise.
|
||||
3. **CTA** — winning hook + offer, vary the closing action.
|
||||
4. **Format / aspect** — same winning ad, compare placements.
|
||||
|
||||
Budget guide many teams use: ~60% to proven winners, ~30% to variations of winners, ~10% to fresh concepts. Run each test long enough for a real sample (commonly several hundred impressions per variant, ~1 week to smooth daily swings) before declaring a winner.
|
||||
|
||||
## Pre-flight checklist per variant
|
||||
|
||||
- Hook trigger lands before 2s and fits the frame (≤~60 chars in 9:16).
|
||||
- CTA visibly pays off the hook's specific promise.
|
||||
- Critical content inside the center square and outside every keep-clear band.
|
||||
- Duration within the placement's sweet spot.
|
||||
- Exported in every aspect the campaign's placements require.
|
||||
- Exactly one field differs from the other variants in this test wave.
|
||||
Reference in new issue
Block a user