Loading...
Loading...
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 skill4agent add iart-ai/explainer-video-skills wrapped-video| 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 |
| 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 |
references/scene-grammar.md| 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 |
accentreferences/scene-grammar.md// 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>;defaultPropsschema// 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" }}
/>
);useCurrentFrame()setStatesetIntervaltoLocaleString()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>
);
};tabular-numsdelayreferences/remotion-recipes.mdaccentreferences/scene-grammar.mdrenderMediainputProps// 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);
}references/batch-pipeline.mduseCurrentFrametabular-numsPackaged helper (): tile your stills withscripts/, then assert the encode withscripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png. Seescripts/probe-mp4.sh out.mp4 [WxH] [fps].scripts/README.md
<Composition>schemadefaultPropsDate.now()Math.random()useCurrentFrameout/wrapped-*.mp4calculateMetadata# 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.tsnpx remotion compositionsdurationInFramesfpsnpx remotion render Wrapped out/demo.gif --codec=gif --props='{...}'npx remotion stillDate.now()Math.random()references/scene-grammar.mdreferences/remotion-recipes.md<Series>references/batch-pipeline.md