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
@@ -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">“</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)
|
||||
Reference in new issue
Block a user