스킬 불러오는 중
스킬 불러오는 중
iart-ai/explainer-video-skills · GitHub
This skill should be used when the user asks to "make a Spotify Wrapped style video", "build a year in review / year-in-review video", "create a personalized data video", "generate a recap video", "build a wrapped video generator", "turn a data table into shareable videos", or "make per-user stat videos". Covers the data row → one shareable video pattern, the Wrapped scene grammar (big-number reveals, top-X lists, superlatives), animated data counters, vertical 9:16 framing, and batch-rendering many personalized videos from one template.
프로젝트 폴더에서 아래 명령어를 실행하고, 설치할 에이전트를 선택하세요.
npx skills add iart-ai/explainer-video-skills --skill wrapped-video설치 명령을 직접 실행해야 적용됩니다. 지원 에이전트와 필요한 권한·라이선스는 제작자의 안내를 확인하세요.
README.mdreferences/batch-pipeline.md# Batch pipeline: one template → many personalized videos
The whole point of a Wrapped is scale: write the template once, render a unique MP4 for every row in a data table. This file covers turning a CSV/JSON of records into a folder of personalized videos, validating data, controlling concurrency, and fanning out to thousands.
## The shape of the pipeline
```
data table (CSV/DB) → rows → validate each (schema) → inputProps
→ renderMedia → out/wrapped-<id>.mp4
```
Every video is a pure function of one validated row. If a row fails validation, skip it and log — never render a half-filled Wrapped.
## CSV → rows
Keep one column per schema field. Nested fields (top-X arrays) are easiest as a JSON string in a single cell, parsed on load.
```ts
// load-rows.ts
import { parse } from "csv-parse/sync";
import { readFileSync } from "node:fs";
import { wrappedSchema } from "./src/schema";
const raw = parse(readFileSync("users.csv"), { columns: true });
export const rows = raw.map((r: any) =>
wrappedSchema.parse({
name: r.name,
year: Number(r.year),
minutesListened: Number(r.minutes),
topArtists: JSON.parse(r.top_artists), // '[{"rank":1,"label":"...","value":312}]'
topGenre: r.top_genre,
percentile: Number(r.percentile),
accent: r.accent,
// attach a stable id for the output filename:
}) && { ...r, id: r.id }
);
```
## The render loop
Bundle once, then render each row. Reuse the same `serveUrl` across all renders — bundling is the slow part, so never re-bundle per row.
```ts
// render-all.ts — npx tsx render-all.ts
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import { wrappedSchema } from "./src/schema";
import users from "./users.json";
const serveUrl = await bundle({ entryPoint: "./src/index.ts" });
for (const user of users) {
let props;
try { props = wrappedSchema.parse(user); }
catch (e) { console.warn("skip", user.id, "invalid row"); continue; }
const composition = await selectComposition({ serveUrl, id: "Wrapped", inputProps: props });
await renderMedia({
composition, serveUrl, codec: "h264",
inputProps: props,
outputLocation: `out/wrapped-${user.id}.mp4`,
crf: 18, // visually lossless for social
});
console.log("done", user.id);
}
```
## Concurrency
A single `renderMedia` already uses multiple threads for frames. To render multiple *videos* in parallel, cap concurrency to roughly the core count so machines don't thrash:
```ts
import pLimit from "p-limit";
const limit = pLimit(4); // tune to CPU cores
await Promise.all(users.map((u) => limit(() => renderOne(serveUrl, u))));
```
For a no-code-loop alternative, the CLI renders one row at a time and is easy to script from bash:
```bash
npx remotion render src/index.ts Wrapped out/wrapped-42.mp4 \
--props='{"name":"Sam","year":2026,"minutesListened":41203,"percentile":3,"accent":"#1DB954","topGenre":"Indie","topArtists":[{"rank":1,"label":"Phoebe Bridgers","value":312}]}'
```
```bash
# loop a JSONL file of rows, one render per line
while IFS= read -r row; do
id=$(echo "$row" | jq -r .id)
npx remotion render src/index.ts Wrapped "out/wrapped-$id.mp4" --props="$row"
done < users.jsonl
```
## Scaling to thousands
- **Remotion Lambda** — `renderMediaOnLambda` fans frames across serverless functions; loop your rows and fire many renders concurrently for near-linear throughput. Best when you need 10k+ videos on a deadline (a Wrapped launch day).
- **Dedupe** — hash each prop set; identical inputs (e.g. users with the same stats) render once and reuse.
- **Idempotent naming** — name outputs by a stable record id (`wrapped-<id>.mp4`) so reruns overwrite rather than duplicate, and failed rows can be re-driven by id.
- **Validate upstream** — run `wrappedSchema.safeParse` over the whole table before rendering anything; fix data once, not mid-batch.
- **Thumbnails** — also render a still (`renderStill`) of the share-card frame per user for previews and link unfurls.
## File naming & delivery
| Concern | Pattern |
|---|---|
| Output name | `wrapped-<recordId>.mp4` (stable, idempotent) |
| Share still | `wrapped-<recordId>.jpg` from the outro frame |
| Hosting | upload to object storage; map `recordId → signed URL` |
| Personalized link | `app.example.com/wrapped/<recordId>` resolves to that user's video |
## Pipeline checklist
- Bundle once, render many — never re-bundle per row.
- Validate every row against the schema; skip + log invalid rows.
- Cap parallel video renders to ~core count; let each render use its own threads.
- Stable per-record filenames so reruns are idempotent.
- Render a share-still per user alongside the MP4.
- For very large batches, move to Lambda and dedupe identical prop sets.
---
## 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 generate thousands of personalized "wrapped"/recap videos from one template and a data table — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=explainer-video-skills&utm_content=ref_footer&utm_term=wrapped-video)
references/remotion-recipes.md# Remotion recipes for Wrapped scenes
Runnable scene components and the sequencing that strings them into a recap. All assume the `Wrapped` schema from SKILL.md and a 1080×1920 / 30fps composition.
## The one rule that prevents broken renders
Drive **all** animation from `useCurrentFrame()`. Never use `setState`, `setInterval`, `Date.now()`, or CSS `@keyframes`/`transition` for timing — the renderer snapshots discrete frames, so wall-clock animation flickers or freezes. Everything below derives its state purely from the current frame.
## Staggered top-X list
Each row springs in on a delayed frame so the list builds 1→5. One identical motion per row.
```tsx
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
type Item = { rank: number; label: string; value: number };
export const TopList: React.FC<{ items: Item[]; accent: string }> = ({ items, accent }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<div style={{ display: "flex", flexDirection: "column", gap: 28, padding: "0 80px",
height: "100%", justifyContent: "center" }}>
{items.map((it, i) => {
const delay = i * 8; // 8 frames between rows
const p = spring({ frame: frame - delay, fps, config: { damping: 200 } });
const y = interpolate(p, [0, 1], [40, 0]);
return (
<div key={it.rank} style={{ opacity: p, transform: `translateY(${y}px)`,
display: "flex", alignItems: "baseline", gap: 24 }}>
<span style={{ fontSize: 56, fontWeight: 900, color: accent,
fontVariantNumeric: "tabular-nums" }}>{it.rank}</span>
<span style={{ fontSize: 64, fontWeight: 800, color: "#fff", flex: 1 }}>{it.label}</span>
<span style={{ fontSize: 40, color: "#ffffff99" }}>{it.value}</span>
</div>
);
})}
</div>
);
};
```
## Percentile bar (the climax scene)
Bar fills to `percentile` while the number counts up beside it.
```tsx
export const Percentile: React.FC<{ percentile: number; accent: string }> = ({ percentile, accent }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame, fps, config: { damping: 200 } });
const pct = interpolate(p, [0, 1], [0, percentile]);
return (
<div style={{ height: "100%", display: "flex", flexDirection: "column",
justifyContent: "center", padding: "0 80px", gap: 32 }}>
<span style={{ fontSize: 56, color: "#fff" }}>You're in the top</span>
<span style={{ fontSize: 200, fontWeight: 900, color: accent,
fontVariantNumeric: "tabular-nums" }}>{Math.round(pct)}%</span>
<div style={{ height: 24, borderRadius: 12, background: "#ffffff22" }}>
<div style={{ width: `${pct}%`, height: "100%", borderRadius: 12, background: accent }} />
</div>
<span style={{ fontSize: 40, color: "#ffffff99" }}>of all listeners this year</span>
</div>
);
};
```
## Sequencing scenes into the film
Use `<Series>` to lay scenes back-to-back without computing absolute frames by hand. Wrap each in a full-bleed background.
```tsx
import { AbsoluteFill, Series } from "remotion";
import { BigNumber } from "./BigNumber";
import { TopList } from "./TopList";
import { Percentile } from "./Percentile";
import type { Wrapped } from "./schema";
const Bg: React.FC<React.PropsWithChildren<{ color?: string }>> = ({ children, color }) => (
<AbsoluteFill style={{ background: color ?? "#0a0a0a" }}>{children}</AbsoluteFill>
);
export const Wrapped: React.FC<Wrapped> = (p) => (
<Series>
<Series.Sequence durationInFrames={90}>
<Bg><Intro name={p.name} year={p.year} /></Bg>
</Series.Sequence>
<Series.Sequence durationInFrames={120}>
<Bg><BigNumber value={p.minutesListened} label="minutes listened" accent={p.accent} /></Bg>
</Series.Sequence>
<Series.Sequence durationInFrames={120}>
<Bg><TopList items={p.topArtists} accent={p.accent} /></Bg>
</Series.Sequence>
<Series.Sequence durationInFrames={120}>
<Bg color={p.accent}><Percentile percentile={p.percentile} accent="#fff" /></Bg>
</Series.Sequence>
{/* add persona, peak-month, outro the same way */}
</Series>
);
```
`<Series.Sequence>` resets each child's `useCurrentFrame()` to 0 at its own start, so every scene's springs animate from the beginning — no per-scene frame math.
## Transitions between scenes
For a polished cut, use `@remotion/transitions` instead of hard cuts:
```tsx
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={120}>{/* scene A */}</TransitionSeries.Sequence>
<TransitionSeries.Transition presentation={fade()} timing={linearTiming({ durationInFrames: 12 })} />
<TransitionSeries.Sequence durationInFrames={120}>{/* scene B */}</TransitionSeries.Sequence>
</TransitionSeries>
```
Keep transitions short (8–15 frames) and consistent. A quick fade or vertical slide matches the story-swipe feel; avoid mixing many transition types.
## Fonts
Load a heavy display weight — bold numerals carry the whole look. Use `@remotion/google-fonts` so the font is bundled for batch renders (no FOUT, no missing glyphs in headless rendering):
```tsx
import { loadFont } from "@remotion/google-fonts/Inter";
const { fontFamily } = loadFont();
// apply fontFamily on the root container; use fontWeight 800–900 for headline numbers
```
For a true Wrapped feel, a bespoke heavy grotesque (Inter/Archivo/Anton-style) with `fontVariantNumeric: "tabular-nums"` on every counter keeps digits from jittering as they roll.
## Animation cheatsheet
- `spring({ frame, fps, config: { damping: 200 } })` → smooth 0→1, no overshoot. Lower damping = bouncier.
- `interpolate(spring01, [0,1], [from,to])` → map progress to any range; add `{ extrapolateRight: "clamp" }` to freeze at the end.
- Counters: `Math.round(interpolate(p, [0,1], [0, value]))` + `toLocaleString()` + `tabular-nums`.
- Stagger: feed `frame - i*delay` into each item's spring.
- Hold still: when a scene's payoff is reached, stop animating — let it sit so it can be screenshotted.
references/scene-grammar.md# Scene grammar: the Wrapped storyboard
A Wrapped is not a freeform film. It is a fixed sequence of short, reusable scene *types*, each a slot a data row fills. Lock the grammar once; only the data and palette change each year. This is why one template scales to millions of personalized videos.
## The crescendo
Order scenes so personal stakes rise toward a climax, then resolve on a still share card:
1. **Context first** — small, framing facts ("you listened on 287 days").
2. **Build** — top-X lists, genres, time patterns.
3. **Climax** — the single most personal/impressive stat (the percentile, the hero number), held longest and largest.
4. **Resolve** — outro share card, motion settles to a clean freeze.
Never open on the biggest number; there is nowhere to go after it. Never end on motion; the last frame must be a clean, screenshot-ready card.
## A full 7-scene storyboard (22s @ 30fps = 660 frames)
| # | Scene | Frames | Sec | Beat |
|---|---|---|---|---|
| 1 | Intro: "{name}, your {year} wrapped" | 0–90 | 0–3 | Brand + palette set |
| 2 | Big number: minutes listened | 90–210 | 3–7 | First count-up, hero scale |
| 3 | Top-5 artists, staggered | 210–330 | 7–11 | Ranked list builds 1→5 |
| 4 | Top genre / persona card | 330–420 | 11–14 | "You're an Indie Explorer" |
| 5 | Percentile: "top {x}% of listeners" | 420–540 | 14–18 | Bar fills, climax stat |
| 6 | Peak month / heatmap | 540–600 | 18–20 | "March was your month" |
| 7 | Outro share card (holds still) | 600–660 | 20–22 | Logo + handle + CTA, frozen |
Scale by stretching/trimming the montage scenes (3–6), never the intro or the count-up payoffs. For 15s, drop scenes 4 and 6. For 45s, add a second list (top tracks) and a comparison scene.
## Scene-type recipes
**Intro.** Big name + year, palette wash. One line of motion: name slides/fades up over a gradient using the per-user `accent`. Sets the visual identity for the whole film.
**Big-number reveal.** One number, oversized (180–240px), counts up with a spring, label below. Nothing else on screen. This is the most-shared scene type — give it room.
**Top-X list.** Ranks 1→5 enter staggered (rank 1 first), each `{rank, label, value}`. Rank number small and accent-colored, label bold white, value muted. The stagger *is* the animation; keep each row's motion identical.
**Superlative / persona.** Map data to a named archetype ("Indie Explorer", "Night Owl", "Top Fan"). Compute the label from thresholds so it personalizes automatically. Big label, one supporting line.
**Comparison / percentile.** "More than 92% of listeners." A horizontal bar fills to `percentile`, number counts up alongside. Strongest dopamine scene — usually the climax.
**Time / peak.** "Your busiest month was March." A simple 12-bar mini-chart with the peak bar in accent, others muted. Reads instantly even frozen.
**Outro share card.** Holds **completely still ≥2s**. Name, @handle, product logo, short CTA/URL. No competing motion. This frame is the share asset — design it as a poster.
## Copywriting patterns (data → words)
Generate headline copy from the data, not hand-written per user:
- **Superlative tiers:** `percentile <= 1 ? "Top 1% — you're a superfan" : percentile <= 10 ? "Top 10%" : "..."`.
- **Persona from dominant dimension:** pick the archetype whose threshold the row clears (genre share, late-night ratio, diversity count).
- **Comparison framing:** always "more than X% of {peers}" — concrete peer group beats abstract numbers.
- **Second person, present, short.** "You listened to 412 artists." Not "The user listened to..." Each line ≤ 7 words so it fits one bold frame.
## 9:16 safe-area map
Render **1080×1920**. Story/Reels/TikTok UI overlays the edges and the platform may crop.
| Zone | Region | Rule |
|---|---|---|
| Top 12% | 0–230px | Avoid type; platform clock/handle/close-button sit here |
| Bottom 18% | 1575–1920px | Avoid type/CTA; like/share/caption UI sits here |
| Center 80% h | ~230–1575px | All numbers, names, lists, the share CTA |
| Horizontal | center 90% w | Keep big type off the left/right 5% |
Place the single most important element at vertical center (~960px) — it survives every crop and is where the eye lands. Test by overlaying a TikTok/Reels UI mock and confirming no number is occluded.
## Motion language
Keep one motion vocabulary across all scenes so the film reads as one piece: same spring config for entrances, same fade for exits, accent color used consistently for "the number that matters." Consistency makes speed feel like confidence rather than chaos. Per-scene component code is in `remotion-recipes.md`.
SKILL.md---
name: wrapped-video
description: This skill should be used when the user asks to "make a Spotify Wrapped style video", "build a year in review / year-in-review video", "create a personalized data video", "generate a recap video", "build a wrapped video generator", "turn a data table into shareable videos", or "make per-user stat videos". Covers the data row → one shareable video pattern, the Wrapped scene grammar (big-number reveals, top-X lists, superlatives), animated data counters, vertical 9:16 framing, and batch-rendering many personalized videos from one template.
version: 0.1.0
---
# Wrapped Video
Build a "Spotify Wrapped"-style recap: take a row of data about one person (or account, team, year) and turn it into a punchy, shareable vertical video. The core idea is **one template × a data table → many personalized videos**. Write the template once, then render a unique film for every row.
## When to use
- Year-in-review / "your 2026 wrapped" recaps for any product with per-user stats.
- Personalized data videos: fitness year, reading year, spending recap, gaming stats, sales rep recap, student progress.
- Any time the deliverable is "the same video, but with each person's numbers" — at 1 or 100,000 copies.
This is a **data → share-bait** pattern, not a hand-edited film. If there is no data table (or no per-record data), use a different skill.
## The two non-negotiables
1. **Data drives everything.** Every headline, number, name, and color comes from props, never hardcoded. A scene that can't be filled from a data row does not belong in a Wrapped.
2. **Built to be screenshotted.** Each scene must read in under 2 seconds and look good frozen — that frozen frame is what gets shared to a story. Design for the pause, not the play.
## The Wrapped scene grammar
A Wrapped is a fixed sequence of short scene *types*, each ~2.5–4s. Pick 5–7 and order them as a build. Same grammar every year; only the data and palette change.
| Scene type | Job | Data shape |
|---|---|---|
| Intro / "Your 2026, wrapped" | Brand the moment, set palette | name, year |
| Big-number reveal | One hero stat, counts up huge | one number + unit + label |
| Top-X list | Ranked 1→5, staggered in | array of `{rank, label, value}` |
| Superlative / persona | "You're in the top 1%", an archetype | computed tier/label |
| Comparison | "more than 92% of listeners" | percentile or ratio |
| Time/heatmap | "your busiest month was March" | series or peak |
| Outro / share card | Logo + handle + CTA, holds still | name, handle |
Order as a crescendo: small context first, biggest/most personal stat as the climax, then the still share card. See `references/scene-grammar.md` for a full 7-scene storyboard with timings.
## The recap story arc
The scene grammar is the skeleton; the *arc* is what makes a Wrapped feel like a gift instead of a dashboard. A recap is a tiny five-beat drama about one viewer — sequence it so each reveal feels bigger and more personal than the last.
| Beat | Job | Maps to |
|---|---|---|
| Build-up | Brand the moment, promise it's theirs | Intro: "{name}, your {year}" |
| Escalating reveals | Stack stats that rise in stakes | Top-X, genres, time patterns |
| The "big number" | One hero stat, max scale, held longest | Big-number / count-up climax |
| Personalized superlative | Name *who they are*, not just what they did | Persona/percentile tier |
| Shareable payoff | A still poster they want to post | Outro share card |
**Sequence stats for rising impact — smallest first, biggest last.** Rank every stat by emotional payload (raw size, rarity, how flattering) and play them in ascending order. Never open on the hero number: there's nowhere to climb after it, and the rest of the film feels like a comedown. Hold the climax stat largest and longest; everything before it is set-up.
**The hook is "this is about YOU."** A Wrapped wins because the viewer is the protagonist. Earn that in the first 2 seconds: lead with their name, use their per-user `accent`, and write every line in second person ("You listened to 412 artists"). Generic copy ("Top genre: Indie") breaks the spell — reframe as "You're an Indie kind of person." If a frame would read identically for two different users, it isn't pulling its weight.
**Design the final share-frame as the destination.** The whole arc exists to deliver a poster worth posting. The outro is not a credits roll — it's the payoff: the headline superlative or hero number restated, name + @handle, logo, one short CTA, holding **completely still ≥2s** so a screenshot or auto-loop lands clean. Decide this frame first and build the crescendo toward it. Copywriting tiers and the full storyboard are in `references/scene-grammar.md`.
## Data → video shape
Define a typed schema for one record. The whole video is a pure function of it.
```ts
// src/schema.ts
import { z } from "zod";
export const wrappedSchema = z.object({
name: z.string(),
year: z.number(),
minutesListened: z.number(),
topArtists: z.array(z.object({ rank: z.number(), label: z.string(), value: z.number() })),
topGenre: z.string(),
percentile: z.number(), // 0–100, "top X%"
accent: z.string(), // per-user palette, e.g. "#1DB954"
});
export type Wrapped = z.infer<typeof wrappedSchema>;
```
Register it as `defaultProps` + `schema` on the composition so each render just swaps props.
```tsx
// src/Root.tsx
import { Composition } from "remotion";
import { Wrapped as Recap } from "./Wrapped";
import { wrappedSchema } from "./schema";
export const Root = () => (
<Composition
id="Wrapped"
component={Recap}
schema={wrappedSchema}
durationInFrames={30 * 22} // 22s @ 30fps
fps={30}
width={1080} height={1920} // 9:16 vertical — the share format
defaultProps={{ name: "Sam", year: 2026, minutesListened: 41203,
topArtists: [{ rank: 1, label: "Phoebe Bridgers", value: 312 }],
topGenre: "Indie", percentile: 3, accent: "#1DB954" }}
/>
);
```
## The signature move: the big-number counter
The count-up is the heartbeat of every Wrapped. Drive it from `useCurrentFrame()` (never `setState`/`setInterval` — that flickers on render), ease it with a spring, and format with `toLocaleString()`.
```tsx
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
export const BigNumber: React.FC<{ value: number; label: string; accent: string }> =
({ value, label, accent }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame, fps, config: { damping: 200 } }); // 0→1, settles
const shown = Math.round(interpolate(progress, [0, 1], [0, value]));
const pop = interpolate(progress, [0, 1], [0.6, 1]); // overshoot-free scale-in
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center",
justifyContent: "center", height: "100%", transform: `scale(${pop})` }}>
<span style={{ fontSize: 220, fontWeight: 900, color: accent, lineHeight: 1,
fontVariantNumeric: "tabular-nums" }}>{shown.toLocaleString()}</span>
<span style={{ fontSize: 48, color: "#fff", marginTop: 24 }}>{label}</span>
</div>
);
};
```
Use `tabular-nums` so digits don't jitter width as they roll. For the top-X list, reuse one spring per item with a staggered `delay` (rank 1 first) — see `references/remotion-recipes.md`.
## Vertical 9:16, designed for the pause
Render **1080×1920**. Stories/Reels/TikTok crop and overlay UI, so keep all type and key numbers inside the **center 80% height**, clear of the **top 12%** and **bottom 18%**. One bold idea per frame, oversized type, high-contrast accent on a flat/gradient background. Per-user `accent` color makes each share feel personal. Full safe-area map in `references/scene-grammar.md`.
## Batch: one template → many videos
The payoff. Render every data row to its own MP4 with `renderMedia`, passing the row as `inputProps`. Generate a CSV of jobs, loop, name files per record.
```ts
// render-all.ts — run with: npx tsx render-all.ts
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import { wrappedSchema } from "./src/schema";
import users from "./users.json"; // array of rows matching the schema
const serveUrl = await bundle({ entryPoint: "./src/index.ts" });
for (const user of users) {
const props = wrappedSchema.parse(user); // validate the row
const comp = await selectComposition({ serveUrl, id: "Wrapped", inputProps: props });
await renderMedia({
composition: comp, serveUrl, codec: "h264",
inputProps: props,
outputLocation: `out/wrapped-${user.id}.mp4`,
});
console.log("rendered", user.id);
}
```
For thousands of rows, fan out across machines/Lambda and dedupe identical prop sets. Pipeline, scaling, and a Node + CLI batch variant are in `references/batch-pipeline.md`.
## Build checklist
- Every visible string/number comes from props; nothing hardcoded.
- 5–7 scenes, crescendo order, biggest/most personal stat as the climax.
- Numbers count up via `useCurrentFrame` + spring, `tabular-nums`, locale-formatted.
- 9:16 1080×1920; key content in center 80%, clear of top 12% / bottom 18%.
- Each scene reads in <2s and looks good frozen (it will be screenshotted).
- One schema validates every row before render; batch script names files per record.
## 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`.
A Wrapped is a Remotion composition rendered per data row — frame-deterministic, so any exact frame renders headlessly with no seek harness. The deliverable is an MP4 (often many) carrying each person's exact numbers; verify one representative row by stills before you batch.
**Output contract:**
- A Remotion project with the composition registered (`<Composition>` + zod `schema` + `defaultProps`), all motion frame-driven (no timers / `Date.now()` / `Math.random()` — count-ups via `useCurrentFrame` + spring).
- Deliverable = the rendered `out/wrapped-*.mp4` per row (plus the project, so any row re-renders).
- Per-user stats baked into props and validated by the schema before render; 9:16 1080×1920.
- Duration data-dependent? compute it in `calculateMetadata`, not by hand.
**Verify loop — stills of ONE row → inspect → batch.** Render a representative user's frames first (cheap, no encode); catch a layout/data bug once instead of N times.
```bash
# Frame-exact stills at start / mid / end for ONE representative row — pass that row as props
npx remotion still Wrapped out/f-start.png --frame=0 --props='{...one user...}'
npx remotion still Wrapped out/f-mid.png --frame=N --props='{...one user...}'
npx remotion still Wrapped out/f-end.png --frame=L --props='{...one user...}' # L = durationInFrames - 1
# Inspect: every visible string/number comes from that row and is EXACT (big-number reveal lands on the
# real value, top-X ranks/labels correct); key content inside center 80%, clear of top 12% / bottom 18%.
# Only after the representative stills check out, batch-render every row:
npx tsx render-all.ts
```
- `npx remotion compositions` reads `durationInFrames`/`fps` to pick the end frame and the big-number's settle frame.
- **README demo GIF for free**: `npx remotion render Wrapped out/demo.gif --codec=gif --props='{...}'`.
**Before you finish:**
1. `npx remotion still` renders cleanly at frame 0, mid, and last for the representative row — no errors, no missing assets/fonts.
2. Big-number reveal lands on the EXACT prop value at its settle frame; top-X ranks/labels/values match the row.
3. 9:16: key numbers/type inside center 80%, clear of top 12% / bottom 18%; each checked frame reads frozen.
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers; schema validates every row before batch.
5. Representative MP4 encoded and plays; then batch all rows (file per record); (optional) GIF for the README.
## Reference files
- `references/scene-grammar.md` — full 7-scene storyboard with frame timings, the build/crescendo logic, superlative & percentile copywriting patterns, and the 9:16 safe-area map.
- `references/remotion-recipes.md` — runnable scene components: staggered top-X list, percentile bar, scene sequencing with `<Series>`, transitions, fonts, and the no-flicker animation rules.
- `references/batch-pipeline.md` — CSV/JSON → many MP4s: schema validation, the render loop, concurrency, Lambda fan-out, and file naming.