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:
Claude committed 2026-10-18 21:50:04 +00:00
1 parent 0b42fdeae1
commit a7b0e631b9
15 files changed
+1406

No files matched your search

@@ -0,0 +1,140 @@
# Batch & Typography
How to feed many reviews into one card design, plus the quote-typography craft that separates a credible testimonial from a sloppy one.
## Review prop schema
One object per testimonial. Keep it flat so a CSV row maps to it directly.
```ts
type Review = {
quote: string; // verbatim — never paraphrased or trimmed mid-sentence
highlight?: string; // EXACT substring of `quote`; one phrase only
rating: number; // 0–max, halves allowed (4.5)
max?: number; // default 5
name: string; // required — a quote with no name is not social proof
role?: string;
company?: string;
avatar?: string; // real photo URL, or omit (never a stock face)
};
```
### Validation (fail loud, before rendering 50 videos)
```ts
function validate(r: Review): string[] {
const errs: string[] = [];
if (!r.quote?.trim()) errs.push("quote is empty");
if (!r.name?.trim()) errs.push("name is required (no anonymous testimonials)");
const max = r.max ?? 5;
if (r.rating < 0 || r.rating > max) errs.push(`rating ${r.rating} out of 0–${max}`);
if (r.highlight && !r.quote.includes(r.highlight))
errs.push(`highlight "${r.highlight}" is not a substring of the quote`);
return errs;
}
```
The `highlight ⊂ quote` check is the important one: it guarantees you only ever emphasize words the customer actually wrote. If the highlight isn't an exact substring, the emphasis silently disappears in the card — so block the render instead.
## CSV → props
Reviews usually arrive as a spreadsheet export. Map columns to the schema and coerce the rating.
```js
import { parse } from "csv-parse/sync";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
// CSV columns: quote,highlight,rating,name,role,company,avatar
const rows = parse(readFileSync("reviews.csv"), { columns: true, trim: true });
mkdirSync("reviews", { recursive: true });
rows.forEach((row, i) => {
const r = {
quote: row.quote,
highlight: row.highlight || undefined,
rating: Number(row.rating),
name: row.name,
role: row.role || undefined,
company: row.company || undefined,
avatar: row.avatar || undefined,
};
const errs = validate(r);
if (errs.length) { console.error(`Row ${i + 1} (${r.name}):`, errs); process.exit(1); }
writeFileSync(`reviews/${String(i).padStart(3, "0")}-${slug(r.name)}.json`,
JSON.stringify(r, null, 2));
});
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
```
## Batch render — one video per review
```bash
mkdir -p out
for f in reviews/*.json; do
name=$(basename "$f" .json)
npx remotion render Testimonial "out/$name.mp4" --props="$f"
done
```
Everything visual lives in the single `theme` object in the composition (colors, fonts, quote-mark glyph, spacing). Because only the props change per render, 50 testimonials stay pixel-for-pixel brand-locked while the words and score vary. To re-theme the whole set, edit one object and re-run the loop.
For multi-aspect (9:16 feed + 1:1 post + 16:9 site), register the composition at each size with a shared center-safe layout and loop over both `reviews/*.json` and the aspect list.
## Quote typography craft
Motion is the easy part; legible, well-set type is what makes the quote trustworthy.
### Smart quotes, always
Straight quotes (`"`) read as un-proofed. Convert to typographic quotes before rendering.
```js
const smarten = (s) => s
.replace(/(^|[\s([{])"/g, "$1“").replace(/"/g, "”") // “ ”
.replace(/(^|[\s([{])'/g, "$1‘").replace(/'/g, "’") // ‘ ’
.replace(/--/g, "—"); // em dash
```
### Hanging quotation marks
A leading quote glyph indents the first line and breaks the flush-left edge. Hang it into the margin so the text block stays optically aligned — the gold standard for set quotes.
```css
blockquote { hanging-punctuation: first; } /* Safari/WebKit */
/* Cross-engine fallback: pull the opening mark left by its own width */
blockquote::before { content: "\201C"; margin-left: -0.55em; color: var(--muted); }
```
When the card uses a large decorative quote mark (as in `quote-card.md`), drop the inline opening glyph so the mark isn't doubled.
### Splitting into lines / clauses
Reveal in reading order means splitting on meaning, not on a fixed character count. Prefer clause boundaries (`. ! ? , ; —`), then pack to a comfortable measure (~32–40 chars at card size). The `toLines` helper in `quote-card.md` does exactly this. Never split a short quote into one-word-per-line — that reads as a ransom note, not a testimonial.
### Orphans & widows
A single dangling word on the last line looks broken. Bind the last two words with a non-breaking space so they wrap together.
```js
const noOrphan = (s) => s.replace(/ ([^ ]+)\s*$/, " $1");
```
### Legibility in motion
- Use a font that stays readable while moving — avoid thin weights and tight tracking; a serif or a humanist sans at 500–600 weight holds up best on a rising line.
- Animate **opacity + a small translate** only. Blur, rotation, and scale on body text hurt readability mid-reveal.
- Contrast: body text ≥ 4.5:1 against the card; the highlight accent should pass against both the card and the ink it sits behind.
- One emphasized span per quote. A second highlight halves the weight of the first.
## Star-rating accessibility & correctness
- The visual stars are decorative; expose the value to assistive tech with `role="img"` and `aria-label="4.5 out of 5 stars"` on the container (already in `quote-card.md`).
- For fractional scores, the width-clip method (`rating / max * 100%`) is exact for any decimal — no rounding to the nearest half-star, so 4.3 renders as 4.3.
- Never animate the stars before the quote is read, and never show more filled stars than the data supports. A faked rating is the fastest way to lose the trust the testimonial was meant to build.
- If a review has no rating, omit the star row entirely rather than defaulting to 5 — an invented score is worse than none.
## Pacing notes
- Hold the finished card still for the back half of the clip (≥3s) so it's screenshot-able and re-shareable — shares are the whole point of social proof.
- For a sequence of testimonials in one video, give each card a full read (~3–4s) and hard-cut between them; cross-dissolving quotes makes both unreadable.
@@ -0,0 +1,241 @@
# Quote Card — complete runnable component
A full testimonial card you can drop into a Remotion project, plus a zero-dependency HTML/CSS variant for non-Remotion pipelines. Both reveal in reading order, emphasize one phrase, fill stars to the real score, and sign off with the author.
## Timing map (9:16, 30fps, ~10s)
| Frame | Beat |
|---|---|
| 0–8 | Quote mark scales in; card settles |
| 8–48 | Quote body, line-by-line (8 + i×6) |
| 44–54 | Key-phrase highlight sweep (after its line settles) |
| 56–70 | Stars fill left→right to `rating/max` |
| 72–88 | Author block slides up, holds |
| 88–300 | Everything holds still (screenshot-able) |
The card holds motionless for the back half — stillness is what makes a testimonial read as a fact rather than an ad.
## Remotion composition
```tsx
import {
AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate, Img,
} from "remotion";
export type Review = {
quote: string; // verbatim testimonial
highlight?: string; // exact substring of `quote` to emphasize
rating: number; // e.g. 4.5
max?: number; // default 5
name: string;
role?: string;
company?: string;
avatar?: string; // real photo URL, or omit
};
const theme = {
bg: "#0E1116",
ink: "#F5F7FA",
muted: "rgba(245,247,250,.55)",
accent: "#FFC24B",
font: "Inter, system-ui, sans-serif",
quoteFont: "'Newsreader', Georgia, serif",
quoteMark: "“", // left double quotation mark
};
// Split a quote into clauses for reading-order reveal.
// Prefer sentence/clause boundaries; fall back to ~32-char wraps.
const toLines = (q: string): string[] => {
const parts = q.match(/[^.!?,;]+[.!?,;]?\s*/g) ?? [q];
const lines: string[] = [];
let buf = "";
for (const p of parts) {
if ((buf + p).length > 34 && buf) { lines.push(buf.trim()); buf = p; }
else buf += p;
}
if (buf.trim()) lines.push(buf.trim());
return lines;
};
// Render a line, wrapping the highlight substring in <mark> if present.
const Line: React.FC<{ text: string; highlight?: string; sweep: number }> = ({
text, highlight, sweep,
}) => {
if (!highlight || !text.includes(highlight)) return <>{text}</>;
const [before, after] = text.split(highlight);
return (
<>
{before}
<mark
style={{
background: `linear-gradient(90deg, ${theme.accent} ${sweep}%, transparent ${sweep}%)`,
color: "inherit",
padding: "0 .08em",
borderRadius: 3,
WebkitBoxDecorationBreak: "clone",
}}
>
{highlight}
</mark>
{after}
</>
);
};
export const Testimonial: React.FC<Review> = ({
quote, highlight, rating, max = 5, name, role, company, avatar,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const lines = toLines(quote);
// Quote mark
const markIn = spring({ frame, fps, config: { damping: 14 } });
// Highlight sweep, after the body has revealed
const hlStart = 8 + lines.length * 6 + 4;
const sweep = interpolate(frame, [hlStart, hlStart + 10], [0, 100], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
});
// Stars fill, after the quote
const fillStart = hlStart + 14;
const target = (rating / max) * 100;
const starW = interpolate(frame, [fillStart, fillStart + 14], [0, target], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
});
// Author block slides up last
const authStart = fillStart + 16;
const auth = spring({ frame: frame - authStart, fps, config: { damping: 200 } });
return (
<AbsoluteFill style={{ background: theme.bg, fontFamily: theme.font,
padding: 120, justifyContent: "center", color: theme.ink }}>
<div
aria-hidden
style={{ fontFamily: theme.quoteFont, fontSize: 200, lineHeight: .6,
color: theme.accent, opacity: markIn, transform: `scale(${markIn})`,
transformOrigin: "left top" }}
>
{theme.quoteMark}
</div>
<blockquote style={{ fontFamily: theme.quoteFont, fontSize: 64,
lineHeight: 1.25, margin: "24px 0 48px", fontWeight: 500 }}>
{lines.map((line, i) => {
const enter = spring({ frame: frame - (8 + i * 6), fps,
config: { damping: 200 } });
return (
<span key={i} style={{ display: "block", opacity: enter,
transform: `translateY(${(1 - enter) * 14}px)` }}>
<Line text={line} highlight={highlight} sweep={sweep} />
</span>
);
})}
</blockquote>
{/* Stars */}
<div role="img" aria-label={`${rating} out of ${max} stars`}
style={{ position: "relative", display: "inline-block", fontSize: 52,
letterSpacing: 6, marginBottom: 56 }}>
<div style={{ color: "rgba(245,247,250,.20)" }}>{"★".repeat(max)}</div>
<div style={{ position: "absolute", inset: 0, overflow: "hidden",
whiteSpace: "nowrap", width: `${starW}%`, color: theme.accent }}>
{"★".repeat(max)}
</div>
</div>
{/* Author */}
<figcaption style={{ display: "flex", alignItems: "center", gap: 20,
opacity: auth, transform: `translateY(${(1 - auth) * 16}px)` }}>
{avatar && (
<Img src={avatar} alt=""
style={{ width: 88, height: 88, borderRadius: "50%", objectFit: "cover" }} />
)}
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 38, fontWeight: 700 }}>{name}</span>
<span style={{ fontSize: 30, color: theme.muted }}>
{[role, company].filter(Boolean).join(", ")}
</span>
</div>
</figcaption>
</AbsoluteFill>
);
};
```
Register it with a duration long enough for the back-half hold:
```tsx
<Composition id="Testimonial" component={Testimonial}
durationInFrames={300} fps={30} width={1080} height={1920}
defaultProps={{
quote: "We shipped in days, not months. Onboarding saved us 10 hours a week.",
highlight: "saved us 10 hours a week",
rating: 4.5, name: "Dana Reyes", role: "Head of Ops", company: "Northwind",
}} />
```
## Why these choices
- **`spring` with `damping: 200`** gives a clean rise with no overshoot — testimonials should feel composed, not bouncy. The quote mark uses lighter damping (14) for a touch of life on the one decorative element.
- **Highlight as a gradient `<mark>`** wipes a real highlighter behind the phrase without a second element or a mask, and survives line wraps via `box-decoration-break: clone`.
- **Stars as two stacked glyph rows clipped by width** render any fractional score (4.5, 4.3) with no half-star asset, and the filled row inherits the brand accent.
- **Author after stars** matches reading psychology: claim → proof → who. Reordering weakens trust.
## Zero-dependency HTML/CSS variant
For pipelines that capture a headless-browser timeline (or just a web embed). Animations are CSS `@keyframes` with per-line `animation-delay`; if rendering frame-exact video, prefer the Remotion version above so frames stay deterministic.
```html
<figure class="card" style="--accent:#FFC24B; --rating:4.5; --max:5">
<div class="qmark" aria-hidden="true">&ldquo;</div>
<blockquote>
<span style="--i:0">We shipped in days, not months.</span>
<span style="--i:1">Onboarding <mark>saved us 10 hours a week.</mark></span>
</blockquote>
<div class="stars" role="img" aria-label="4.5 out of 5 stars">
<div class="track">★★★★★</div><div class="fill">★★★★★</div>
</div>
<figcaption>
<img src="dana.jpg" alt="" class="avatar">
<div><b>Dana Reyes</b><small>Head of Ops, Northwind</small></div>
</figcaption>
</figure>
```
```css
.card { --bg:#0E1116; --ink:#F5F7FA; background:var(--bg); color:var(--ink);
font-family:Inter,system-ui,sans-serif; padding:120px; max-width:1080px; }
.qmark { font:200px/.6 'Newsreader',Georgia,serif; color:var(--accent);
animation:pop .4s cubic-bezier(.22,1,.36,1) both; }
blockquote { font:500 64px/1.25 'Newsreader',Georgia,serif; margin:24px 0 48px; }
blockquote span { display:block; opacity:0;
animation:rise .5s cubic-bezier(.22,1,.36,1) both;
animation-delay:calc(.27s + var(--i) * .2s); }
mark { background:linear-gradient(90deg,var(--accent) 100%,transparent 100%);
color:inherit; padding:0 .08em; border-radius:3px;
-webkit-box-decoration-break:clone; box-decoration-break:clone;
animation:sweep .35s ease-out both; animation-delay:1s; }
@keyframes sweep { from { background-size:0 100% } to { background-size:100% 100% } }
.stars { position:relative; display:inline-block; font-size:52px; letter-spacing:6px; }
.track { color:rgba(245,247,250,.20); }
.fill { position:absolute; inset:0; overflow:hidden; white-space:nowrap;
color:var(--accent); width:0; animation:fill .5s ease-out 1.3s forwards; }
@keyframes fill { to { width:calc(var(--rating) / var(--max) * 100%) } }
figcaption { display:flex; align-items:center; gap:20px; margin-top:56px;
opacity:0; animation:rise .5s ease-out 1.8s both; }
.avatar { width:88px; height:88px; border-radius:50%; object-fit:cover; }
figcaption b { font-size:38px; } figcaption small { display:block; font-size:30px;
color:rgba(245,247,250,.55); }
@keyframes pop { from { opacity:0; transform:scale(.7) } to { opacity:1; transform:none } }
@keyframes rise { from { opacity:0; transform:translateY(14px) } to { opacity:1; transform:none } }
```
The CSS `mark` sweep uses `background-size` (animatable) rather than a gradient color-stop, since color-stop percentages don't animate in CSS. The star `--rating/--max` math is the same fractional fill as the Remotion version.
---
## 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 turn a list of reviews/quotes into testimonial videos, swap text, 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=testimonial-video)