스킬 불러오는 중
스킬 불러오는 중
iart-ai/data-animation-skills · GitHub
This skill should be used when the user asks to "make a bar chart race", "turn a CSV into a video", "build an animated data visualization", "create an animated statistics video", "animate a chart/graph", "make a number counter/ticker", "animate a ranking over time", or "batch-render one chart template across many datasets". Covers data-driven keyframing, value interpolation, rank transitions, counters, pacing, annotation, and template×data batch output.
프로젝트 폴더에서 아래 명령어를 실행하고, 설치할 에이전트를 선택하세요.
npx skills add iart-ai/data-animation-skills --skill chart-animation설치 명령을 직접 실행해야 적용됩니다. 지원 에이전트와 필요한 권한·라이선스는 제작자의 안내를 확인하세요.
README.mdreferences/bar-chart-race.md# Bar Chart Race — complete implementations
A bar chart race ranks a set of items over time and animates both their values and
their positions. The two hard parts:
1. **Sparse data.** Real datasets have one row per year/month. Playing one row per second
looks like a slideshow. The fix is to interpolate continuously *between* rows.
2. **Reordering.** When item B overtakes item A, the bars must glide past each other,
not teleport. The trick: derive each bar's y-position from an **interpolated rank**,
so position is just another value you tween.
## Data shape
Assume time-series keyed by item, one object per timestamp:
```js
// rows[i] = the state at keyframe i; keys are item names, values are numbers
const rows = [
{ date: "2019", Apple: 961, Samsung: 805, Xiaomi: 122, Oppo: 116 },
{ date: "2020", Apple: 1043, Samsung: 933, Xiaomi: 146, Oppo: 144 },
{ date: "2021", Apple: 1188, Samsung: 901, Xiaomi: 191, Oppo: 143 },
{ date: "2022", Apple: 1287, Samsung: 870, Xiaomi: 188, Oppo: 130 },
];
const items = ["Apple", "Samsung", "Xiaomi", "Oppo"];
```
## Remotion component (the recommended path)
Deterministic, server-rendered, exports clean MP4. Every value derives from
`useCurrentFrame()` — there are zero library timers, so it never flickers.
```jsx
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
import { scaleLinear } from "d3-scale";
const COLORS = { Apple: "#8a5cf6", Samsung: "#2563eb", Xiaomi: "#f59e0b", Oppo: "#10b981" };
const easeInOut = (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
const fmt = new Intl.NumberFormat("en-US");
// Seconds spent per keyframe pair. 1.5s reads as a brisk-but-trackable race.
const SECONDS_PER_ROW = 1.5;
export const BarChartRace = ({ rows, items }) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const barH = 56;
const gap = 18;
const chartW = width - 360; // leave room for labels left + values right
const left = 220;
// 1. frame → continuous position across the sparse rows
const tRow = frame / (fps * SECONDS_PER_ROW);
const i = Math.min(Math.floor(tRow), rows.length - 2);
const f = easeInOut(Math.min(Math.max(tRow - i, 0), 1));
// 2. interpolate every item's value at this exact moment
const data = items.map((name) => ({
name,
value: rows[i][name] + (rows[i + 1][name] - rows[i][name]) * f,
}));
// 3. rank by interpolated value, then derive y from rank (so overtakes glide)
data.sort((a, b) => b.value - a.value);
data.forEach((d, rank) => { d.y = rank * (barH + gap); });
// 4. x-scale relative to the current leader, so the longest bar fills the frame
const maxValue = Math.max(...data.map((d) => d.value));
const x = scaleLinear().domain([0, maxValue]).range([0, chartW]);
// interpolated date label ("2021" → "2022" as f crosses)
const dateLabel = f < 0.5 ? rows[i].date : rows[i + 1].date;
return (
<div style={{ width, height, background: "#0b0b10", fontFamily: "Inter, sans-serif", position: "relative" }}>
{data.map((d) => (
// STABLE KEY per item is essential — it lets React keep each bar's DOM node
// across reorders so the browser/renderer doesn't recycle nodes mid-glide.
<div
key={d.name}
style={{
position: "absolute",
top: 120 + d.y,
left,
height: barH,
width: x(d.value),
background: COLORS[d.name],
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
color: "#fff",
fontWeight: 700,
fontVariantNumeric: "tabular-nums", // digits don't wiggle
paddingRight: 16,
}}
>
<span style={{ position: "absolute", right: "calc(100% + 16px)", whiteSpace: "nowrap" }}>
{d.name}
</span>
{fmt.format(Math.round(d.value))}
</div>
))}
<div style={{ position: "absolute", bottom: 60, right: 80, fontSize: 96, fontWeight: 800, color: "#ffffff22", fontVariantNumeric: "tabular-nums" }}>
{dateLabel}
</div>
</div>
);
};
```
Compose it with an intro hold and a final hold so the data lands:
```jsx
import { Composition } from "remotion";
import { BarChartRace } from "./BarChartRace";
import data from "./data/phones.json"; // { rows, items }
const SECONDS_PER_ROW = 1.5, fps = 30, hold = 2; // seconds of final hold
const durationInFrames = Math.round((data.rows.length - 1) * SECONDS_PER_ROW * fps + hold * fps);
export const Root = () => (
<Composition
id="Race" component={BarChartRace}
durationInFrames={durationInFrames} fps={fps} width={1920} height={1080}
defaultProps={{ rows: data.rows, items: data.items }}
/>
);
```
### Why each choice matters
- **Rank-derived y, not data-index y.** If y came from the original array order, bars would
jump when the sort changed. Deriving y from the freshly-sorted rank each frame makes
position a continuous, tween-able quantity.
- **`easeInOut` on the inter-row progress** gives each transition a gentle start/stop, so
the race breathes between years instead of moving at constant speed.
- **`extrapolateRight: "clamp"`** (used elsewhere) and clamping `f` prevent values shooting
past the final row on the last frame.
- **Leader-relative x-scale** keeps the frame full: the top bar always spans the chart,
and others scale against it, which exaggerates the gaps that make a race exciting.
## Vanilla canvas variant (no React)
When embedding in a plain page or rendering frames with `node-canvas`, the same math applies —
just draw instead of returning JSX. Drive it from an explicit frame counter, not `Date.now()`,
so it stays reproducible.
```js
function drawFrame(ctx, frame, { rows, items, fps = 30, secondsPerRow = 1.5, W, H }) {
const easeInOut = (t) => (t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t+2, 2)/2);
const tRow = frame / (fps * secondsPerRow);
const i = Math.min(Math.floor(tRow), rows.length - 2);
const f = easeInOut(Math.min(Math.max(tRow - i, 0), 1));
const data = items
.map((name) => ({ name, value: rows[i][name] + (rows[i+1][name] - rows[i][name]) * f }))
.sort((a, b) => b.value - a.value);
const max = Math.max(...data.map((d) => d.value));
const barH = 48, gap = 16, left = 220, chartW = W - 360;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "#0b0b10"; ctx.fillRect(0, 0, W, H);
data.forEach((d, rank) => {
const y = 120 + rank * (barH + gap);
const w = (d.value / max) * chartW;
ctx.fillStyle = "#8a5cf6"; ctx.fillRect(left, y, w, barH);
ctx.fillStyle = "#fff"; ctx.font = "700 24px Inter"; ctx.textBaseline = "middle";
ctx.textAlign = "right"; ctx.fillText(d.name, left - 16, y + barH/2);
ctx.textAlign = "left";
ctx.fillText(Math.round(d.value).toLocaleString(), left + w + 12, y + barH/2);
});
}
// browser: requestAnimationFrame loop incrementing `frame`
// offline render: loop frame=0..N, drawFrame, then pipe canvas.toBuffer() to ffmpeg
```
## Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Bars flicker / teleport | Position from array index, or a CSS/D3 transition fighting the frame | Derive y from interpolated rank; remove all library timers |
| Numbers jitter in width | Proportional figures | `font-variant-numeric: tabular-nums` |
| Race feels like a slideshow | Playing one raw row per second | Interpolate continuously between rows (`f`) |
| Last frame overshoots | No clamp on the final segment | Clamp `f` to `[0,1]` and `i` to `rows.length-2` |
| Can't track the leader | Too fast | Raise `SECONDS_PER_ROW`; add a slow-down on key crossovers |
references/data-pipeline.md# Data Pipeline — CSV → props, counters, line reveal, batch render
This file covers everything around the chart itself: getting messy data into clean typed
props, the recipes for counters and line/area reveals, the theme pattern that keeps a batch
on-brand, and the script that renders one template across many datasets.
## CSV → typed props
Treat the dataset as the single input. Parse, validate, and *fail loud* before rendering —
a silent `NaN` becomes a bar of height 0 in the final MP4, which you only notice after a
3-minute render.
```js
import { parse } from "csv-parse/sync";
import fs from "node:fs";
// Wide format: first column = date, remaining columns = items.
// date,Apple,Samsung,Xiaomi,Oppo
// 2019,961,805,122,116
export function loadRaceData(csvPath) {
const text = fs.readFileSync(csvPath, "utf8");
const records = parse(text, { columns: true, skip_empty_lines: true, trim: true });
if (records.length < 2) throw new Error(`${csvPath}: need ≥2 rows for a race`);
const items = Object.keys(records[0]).filter((k) => k !== "date");
const rows = records.map((r, idx) => {
const row = { date: String(r.date) };
for (const item of items) {
const v = Number(r[item]);
if (!Number.isFinite(v)) throw new Error(`${csvPath} row ${idx} (${r.date}): "${item}" is not a number ("${r[item]}")`);
row[item] = v;
}
return row;
});
return { rows, items };
}
```
Write the validated props to JSON so Remotion can consume them via `--props`:
```js
fs.writeFileSync("props/phones.json", JSON.stringify(loadRaceData("data/phones.csv")));
```
### Cleaning rules worth enforcing
- **Coerce and check every numeric cell** — strip `$`, `,`, `%` before `Number()`.
- **Fill gaps explicitly.** A missing year breaks interpolation. Either forward-fill the
last value or linearly interpolate the gap; never leave `undefined`.
- **Sort rows by time ascending.** Out-of-order rows make the race run backwards.
- **Cap item count.** Beyond ~12 bars a race is unreadable; keep top-N per frame and drop
the rest, or pre-aggregate the long tail into "Other".
## Theme object — one design, many videos
Hardcoding colors in the component means editing code to rebrand. Put all visual decisions
in one object and pass it as a prop, so 50 renders stay identical except for the numbers.
```js
export const theme = {
bg: "#0b0b10",
font: "Inter, sans-serif",
bar: { height: 56, gap: 18, radius: 8 },
// deterministic color per item: stable across datasets that share names
palette: ["#8a5cf6", "#2563eb", "#f59e0b", "#10b981", "#ef4444", "#06b6d4"],
colorFor: (name, i) => theme.palette[i % theme.palette.length],
};
```
## Animated counter / number ticker
Interpolate the number, ease it, round it, then format. The `tabular-nums` + `lineHeight: 1`
pair is what keeps the digits from wiggling or clipping.
```jsx
import { useCurrentFrame, interpolate } from "remotion";
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
export const Counter = ({ to, from = 0, startFrame = 0, durationFrames = 50, format = "number" }) => {
const frame = useCurrentFrame();
const t = easeOutCubic(
interpolate(frame, [startFrame, startFrame + durationFrames], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
})
);
const value = from + (to - from) * t;
const fmt = {
number: new Intl.NumberFormat("en-US"),
currency: new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }),
percent: new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1 }),
}[format];
// percent expects a fraction; round number/currency to whole units
const display = format === "percent" ? fmt.format(value / 100) : fmt.format(Math.round(value));
return (
<span style={{ fontVariantNumeric: "tabular-nums", lineHeight: 1, fontWeight: 800 }}>
{display}
</span>
);
};
```
## Animated line / area reveal
Draw the full path, then reveal it left-to-right with `stroke-dasharray`. The reveal length
is the only animated quantity — again a pure function of frame.
```jsx
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
import { scaleLinear, line } from "d3-shape"; // line from d3-shape; scaleLinear from d3-scale
export const LineReveal = ({ series }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const W = 1600, H = 700, pad = 60;
const x = scaleLinear().domain([0, series.length - 1]).range([pad, W - pad]);
const y = scaleLinear().domain([0, Math.max(...series)]).range([H - pad, pad]);
const path = line()((series.map ? series : []).map((v, i) => [x(i), y(v)]));
// reveal over 2 seconds; pathLength=1 normalizes regardless of real length
const reveal = interpolate(frame, [0, fps * 2], [0, 1], { extrapolateRight: "clamp" });
return (
<svg width={W} height={H}>
<path
d={path} fill="none" stroke="#8a5cf6" strokeWidth={4}
pathLength={1} strokeDasharray={1} strokeDashoffset={1 - reveal}
/>
</svg>
);
};
```
For an **area** fill, draw a second `<path>` using d3's `area()`, and fade its opacity in
behind the stroke as `reveal` approaches 1 so the fill arrives after the line is drawn.
## Annotate the moment that matters
Don't label everything. Compute when a key event happens (a crossover, a record) and only
show the callout in a window around it, fading in and out:
```js
const eventFrame = fps * 6.2; // when the crossover happens
const calloutOpacity = interpolate(
frame,
[eventFrame - 10, eventFrame, eventFrame + fps * 1.5, eventFrame + fps * 1.5 + 10],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
```
## Batch render — template × data → N videos
The whole reason to do charts in code. One composition, every dataset, one command.
```bash
#!/usr/bin/env bash
set -euo pipefail
mkdir -p props out
# 1. validate + convert every CSV to typed props JSON (fails loud on bad data)
for csv in data/*.csv; do
name="$(basename "$csv" .csv)"
node scripts/build-props.mjs "$csv" "props/$name.json"
done
# 2. render the SAME composition once per dataset
for p in props/*.json; do
name="$(basename "$p" .json)"
npx remotion render Race "out/$name.mp4" --props="$p" --concurrency=4
done
echo "Rendered $(ls out/*.mp4 | wc -l) videos from one template."
```
Where `scripts/build-props.mjs` calls `loadRaceData` from above and writes the JSON.
Because the component reads everything from props (data + theme) and hardcodes nothing,
swapping the CSV is the entire edit — the design, pacing, and brand stay locked across all N.
### Keeping a batch consistent
- Pin `fps`, resolution, and the theme object in code, not per-dataset.
- Derive `durationInFrames` from the data length so each video is exactly as long as its data.
- Use deterministic `colorFor(name, i)` so an item that appears in several datasets keeps its color.
---
## 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 render N data videos from one template and a CSV/dataset with accurate, locked-in numbers — change the data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=data-animation-skills&utm_content=ref_footer&utm_term=chart-animation)
SKILL.md---
name: chart-animation
description: This skill should be used when the user asks to "make a bar chart race", "turn a CSV into a video", "build an animated data visualization", "create an animated statistics video", "animate a chart/graph", "make a number counter/ticker", "animate a ranking over time", or "batch-render one chart template across many datasets". Covers data-driven keyframing, value interpolation, rank transitions, counters, pacing, annotation, and template×data batch output.
version: 0.1.0
---
# Data Video
Turn a dataset into a chart that moves with intent: a bar chart race, a growing line, a count-up stat. The craft is mapping numbers to pixels every frame, easing the *value* (not just the opacity), and pacing the data so a viewer can actually read it.
## When to use
- Bar chart race / ranking-over-time (the headline use case).
- Animated line/area reveal, animated counters and number tickers.
- One chart design rendered across many datasets (template × CSV → N videos).
## The one rule that prevents 90% of bugs
**Drive every value from the current frame — never from wall-clock time or a library's internal animation loop.** A video frame is rendered deterministically; if a bar's height comes from a CSS transition or a D3/GSAP/Chart.js animation, it flickers or desyncs because the renderer and the animation clock disagree. Compute the displayed value as a pure function of frame.
```js
import { useCurrentFrame, interpolate } from "remotion";
const frame = useCurrentFrame();
const value = interpolate(frame, [0, 60], [0, 1287], { extrapolateRight: "clamp" });
```
In Remotion, disable all third-party chart animations (`animation: false` in Chart.js, no D3 `.transition()`) and let `useCurrentFrame()` be the only clock. D3 is still great for *scales and shapes* (`scaleLinear`, `scaleBand`, `line()`) — just not its timers.
## Map data → pixels
A chart is two functions: a value scale and a frame interpolator. Keep them separate.
| Layer | Tool | Job |
|---|---|---|
| Value scale | `scaleLinear` / `scaleBand` | data units → px (height, x-position) |
| Frame interpolation | `interpolate` / `spring` | frame → eased progress 0→1 |
| Display value | `round` + `Intl.NumberFormat` | progress → the number a human reads |
```js
import { scaleLinear } from "d3-scale";
const yScale = scaleLinear().domain([0, maxValue]).range([0, chartHeight]);
const barHeight = yScale(interpolate(frame, [0, 45], [0, datum.value], { extrapolateRight: "clamp" }));
```
## Easing value changes
Ease the value the same way you'd ease motion. For a count-up or a bar growing in, `easeOutCubic` reads as "settling on a number." For rank changes, prefer `spring` so a bar that overtakes another has a little weight.
```js
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
const t = easeOutCubic(interpolate(frame, [start, end], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }));
const shown = from + (to - from) * t;
```
Never linearly interpolate a value the viewer is reading unless it's a steady time axis (a clock, a year counter) — linear motion on a headline number feels robotic.
## Counters / number tickers
Interpolate the underlying number, then format on render. Two musts: **round before formatting** (no `1287.4013`), and **fix the digit box** so the layout doesn't jump as digits change.
```js
const n = Math.round(interpolate(frame, [0, 50], [0, 1287], { extrapolateRight: "clamp" }));
const label = new Intl.NumberFormat("en-US").format(n); // "1,287"
// CSS: font-variant-numeric: tabular-nums; line-height: 1; → no width jitter, no clipping
```
For currency/percent use `Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })`. Tabular figures keep each digit the same width so the number doesn't wiggle.
## Bar chart race — rank transitions
The signature move. Real datasets are sparse (yearly/monthly rows); a smooth race needs **interpolated keyframes between data rows** plus bars that slide to new ranks. The y-position comes from a rank that is itself interpolated, so an overtake animates as a glide, not a jump.
```js
// frame → continuous time between sparse rows, then rank each item by its interpolated value
const t = frame / fps; // seconds
const i = Math.min(Math.floor(t), rows.length - 2);
const f = easeInOut(t - i); // 0→1 within the current row pair
const vals = items.map((it) => ({
it,
v: rows[i][it] + (rows[i + 1][it] - rows[i][it]) * f, // interpolated value
}));
vals.sort((a, b) => b.v - a.v); // rank this frame
vals.forEach((d, rank) => { d.y = rank * (barH + gap); }); // y from interpolated rank
```
Render with stable keys per item (so React tracks each bar across reorders), animate `y` and `width` off the per-frame value, and show a moving year/date label. See `references/bar-chart-race.md` for a complete, runnable Remotion component.
## Pacing data over time
Animation that's too fast is unreadable. Budget time per data event, not per second of polish.
| Beat | Budget | Why |
|---|---|---|
| Intro / first frame | hold 1–1.5s | let the viewer read axes + units before motion |
| Per data row (race) | 0.3–0.6s | fast enough to feel alive, slow enough to track the leader |
| A highlighted moment | +0.5–1s slow-down | dwell on the crossover / record / inflection |
| Final state | hold 2–3s | the takeaway needs to land and be screenshot-able |
Reveal progressively — one series, then the next — instead of all data at once; viewers can only track one moving thing at a time.
## Labeling & annotation
Numbers without context are noise. Always render: an always-visible **value label** on each bar/point (it animates with the value), a **time indicator** (year/date), and **axis units once**. Add a callout only at the moment it matters (a crossover, a peak) — annotate the inflection, then let it fade.
## Template × data — batch output
The payoff of code-driven charts: one design, many datasets. Make the dataset an input prop, never a hardcoded constant, then render once per file.
```jsx
// Remotion: data is a prop; same composition, different CSV → different video
export const Race = ({ data }) => { /* …reads `data`, hardcodes nothing… */ };
```
```bash
# render the same template for every dataset in /data
for f in data/*.json; do
name=$(basename "$f" .json)
npx remotion render Race "out/$name.mp4" --props="$f"
done
```
Keep colors/fonts/layout in a single theme object so 50 videos stay brand-consistent and only the numbers change. See `references/data-pipeline.md` for CSV→props parsing, validation, and the full batch script.
## Output checklist
- Every animated value is a pure function of `useCurrentFrame()`; no library timers.
- Numbers are rounded and formatted (`Intl.NumberFormat`), digits use `tabular-nums`.
- Race bars carry stable keys; rank changes glide via interpolated rank.
- Axes/units labeled once; value labels animate with their value.
- Intro hold, readable per-row pace, final hold ≥2s.
- Dataset is an input prop — one template renders every CSV in the folder.
## 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`.
Remotion is frame-deterministic — every bar height, counter value, and rank is a pure function of `useCurrentFrame()`, so you can render any exact frame headlessly with no seek harness. The deliverable is an MP4 carrying **exact numbers**, so still-inspection is non-negotiable here: a one-pixel layout bug is forgivable, a wrong digit is not.
**Output contract:**
- A Remotion project with the chart registered (`<Composition>` + zod `schema` + `defaultProps`), all motion frame-driven (no timers / `Date.now()` / `Math.random()`, no Chart.js/D3 `.transition()` clocks).
- Deliverable = the rendered `out/*.mp4` (plus the project, so the user can re-render with new datasets).
- Data-dependent duration (N rows × frames/row)? compute it in `calculateMetadata`, not by hand.
**Verify loop — render stills → inspect → encode.** Render single frames first (cheap, no video encode), then encode only once the numbers and layout are right.
```bash
# 1. Frame-exact stills at start / mid / end (PNG, headless, fast) — with the SHIPPED props
npx remotion still Race out/f-start.png --frame=0 --props='{"data":[...]}'
npx remotion still Race out/f-mid.png --frame=90 --props='{"data":[...]}'
npx remotion still Race out/f-end.png --frame=149 --props='{"data":[...]}' # last = durationInFrames - 1
# 2. Inspect each PNG — FIDELITY (axis labels, value labels, year/date, ranks, units all EXACT —
# round + tabular-nums working, no 1287.4013) AND artifacts (bar overflow, off-canvas label,
# clipped safe-area, missing font, wrong data binding / wrong column mapped).
# 3. Only after the stills check out, encode:
npx remotion render Race out/race.mp4 --props='{"data":[...]}'
```
- Use `npx remotion compositions` to read each chart's `durationInFrames`/`fps` and pick the end frame.
- **Data-driven / batch (the headline case)**: verify ONE representative dataset via stills *before* batch-rendering all N files — a height-scale or column-mapping bug caught once beats finding it in 50 MP4s.
- **README demo GIF for free**: `npx remotion render Race out/demo.gif --codec=gif`.
**Before you finish:**
1. `npx remotion still` renders cleanly at frame 0, mid, and last — no errors, no missing fonts/assets.
2. Every number is **exact** (rounded, `Intl.NumberFormat`, `tabular-nums`) and inside the safe area at each frame.
3. Frame-driven only — no `Date.now()` / `Math.random()` / library timers (determinism holds in CI).
4. The **shipped** dataset props render correctly (not just `defaultProps`) — right column mapped, right scale.
5. Full MP4 encoded and plays; (optional) GIF rendered for the README.
## Reference files
- `references/bar-chart-race.md` — a complete runnable Remotion bar-chart-race component: sparse-row keyframe interpolation, per-frame ranking, gliding y-positions, animated value labels, and a D3-scale axis. Plus a vanilla canvas variant.
- `references/data-pipeline.md` — CSV/JSON → typed props parsing and validation, the theme object pattern, animated counters/line-reveal recipes, and the template×data batch render script for N videos.