스킬 불러오는 중
스킬 불러오는 중
iart-ai/data-animation-skills · GitHub
This skill should be used when the user asks to "make an animated presentation", "turn a pitch deck into a video", "convert slides/a deck to video", "make a presentation video", "build a deck video", "narrate a slide deck as a video", or "auto-advance slides synced to a voiceover". Covers rebuilding slides as motion graphics with staggered build reveals, slide-to-slide transitions, and timing locked to per-slide narration.
프로젝트 폴더에서 아래 명령어를 실행하고, 설치할 에이전트를 선택하세요.
npx skills add iart-ai/data-animation-skills --skill presentation-video설치 명령을 직접 실행해야 적용됩니다. 지원 에이전트와 필요한 권한·라이선스는 제작자의 안내를 확인하세요.
README.mdreferences/sequencing.md# Sequencing: deck schema → narration-timed video
How to assemble many slides into one auto-advancing video where **every slide lasts exactly as long as its narration**. Covers the deck JSON schema, measuring audio in `calculateMetadata`, building total + per-slide durations, the `<TransitionSeries>` assembly, transition frame-budget accounting, and the batch render.
## 1. Deck schema (the source of truth)
Author the deck as data, not as a hardcoded component. One slide = one idea + the narration line + the path to its rendered audio. This is what you'd export from an outline or a deck-to-script step.
```ts
// deck.ts
export type DeckSlide =
| { kind: "title"; id: string; title: string; subtitle?: string; audioUrl: string }
| { kind: "section"; id: string; index: number; title: string; audioUrl: string }
| { kind: "content"; id: string; title: string; bullets: string[]; audioUrl: string };
export type Deck = { fps: number; width: number; height: number; slides: DeckSlide[] };
```
```json
// deck.json — narration lines live next to the slide content
{
"fps": 30, "width": 1920, "height": 1080,
"slides": [
{ "kind": "title", "id": "t", "title": "Acme Q3", "subtitle": "Growth review", "audioUrl": "audio/t.mp3" },
{ "kind": "section", "id": "s1", "index": 1, "title": "Where we are", "audioUrl": "audio/s1.mp3" },
{ "kind": "content", "id": "c1", "title": "Three signals", "bullets": ["Revenue up 28%", "Churn down to 1.9%", "NPS at 61"], "audioUrl": "audio/c1.mp3" }
]
}
```
## 2. Measure narration → durations (the rule, in code)
`calculateMetadata` runs before render, so it is the right place to read each audio file's real length and turn it into frames. The composition's total duration is the sum; each slide carries its own frame count downstream as a prop.
```tsx
// Root.tsx
import { Composition, getInputProps } from "remotion";
import { getAudioDurationInSeconds } from "@remotion/media-utils";
import { DeckVideo } from "./DeckVideo";
import deck from "./deck.json";
const HANDLE = 0.4; // seconds of breathing room after each voice line
export const RemotionRoot: React.FC = () => (
<Composition
id="Deck"
component={DeckVideo}
fps={deck.fps}
width={deck.width}
height={deck.height}
defaultProps={{ deck }}
calculateMetadata={async ({ props }) => {
const fps = props.deck.fps;
// measure every narration clip in parallel
const durations = await Promise.all(
props.deck.slides.map((s) => getAudioDurationInSeconds(s.audioUrl))
);
const timed = props.deck.slides.map((s, i) => ({
...s,
durationInFrames: Math.ceil((durations[i] + HANDLE) * fps),
}));
const total = timed.reduce((n, s) => n + s.durationInFrames, 0);
return { durationInFrames: total, props: { deck: { ...props.deck, slides: timed } } };
}}
/>
);
```
Now each slide object has a `durationInFrames` that is *guaranteed* to fit its voiceover. No slide can cut off the audio, and none holds an awkward silence beyond `HANDLE`.
## 3. Transition frame-budget accounting
A `<TransitionSeries.Transition>` does **not** add time — it overlaps its neighbors, consuming frames from the end of the outgoing slide and the start of the incoming one. So a 15-frame fade between slide A and B steals ~7–8 frames of visible time from each.
That is fine for the quiet fades between content slides (a few frames at the head/tail, where nothing is being read). But if a slide's narration must play *in full* with no overlap, add the transition length back into that slide's budget:
```ts
const TRANSITION = 15; // frames
const SECTION_TRANSITION = 24; // bigger, for dividers
// give a slide extra frames so the overlap doesn't eat into spoken words
slide.durationInFrames += TRANSITION; // one transition touches it (interior slides: + both, see assembly)
```
Rule of thumb: keep transitions short (10–18 frames) so the stolen time lands in the slide's lead-in/tail, not over a spoken sentence. Reserve `SECTION_TRANSITION` (a slide/push) for dividers only.
## 4. Assembly with `<TransitionSeries>`
Map the timed deck into sequences, choosing the component by `kind` and the transition by neighbor type — a bigger `slide()` around section dividers, a quiet `fade()` everywhere else.
```tsx
// DeckVideo.tsx
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
import { Slide } from "./Slide";
import { TitleCard } from "./TitleCard";
import { SectionDivider } from "./SectionDivider";
import type { Deck } from "./deck";
const render = (s: any) => {
if (s.kind === "title") return <TitleCard title={s.title} subtitle={s.subtitle} />;
if (s.kind === "section") return <SectionDivider index={s.index} title={s.title} />;
return <Slide {...s} />;
};
export const DeckVideo: React.FC<{ deck: Deck }> = ({ deck }) => {
const slides = deck.slides as any[];
return (
<TransitionSeries>
{slides.map((s, i) => {
const next = slides[i + 1];
const big = s.kind === "section" || next?.kind === "section";
return (
<React.Fragment key={s.id}>
<TransitionSeries.Sequence durationInFrames={s.durationInFrames}>
{render(s)}
</TransitionSeries.Sequence>
{next && (
<TransitionSeries.Transition
timing={linearTiming({ durationInFrames: big ? 24 : 15 })}
presentation={big ? slide() : fade()}
/>
)}
</React.Fragment>
);
})}
</TransitionSeries>
);
};
```
Each slide's `<Audio>` is mounted *inside* its own component (see `slide-components.md`), so the sequencer positioning the visuals also positions the voice — they cannot desync.
## 5. Render
```bash
# single deck
npx remotion render Deck out/deck.mp4
# many decks from one template (e.g. one per customer/region)
for f in decks/*.json; do
name=$(basename "$f" .json)
npx remotion render Deck "out/$name.mp4" --props="$f"
done
```
## Pitfalls
- **Guessed durations.** Any hardcoded slide length will drift from the voice. Always measure (step 2).
- **Audio mounted at the deck level.** Mount narration *per slide*, inside the slide component, or a re-order silently misaligns every voice line.
- **Heavy transition on every cut.** Tiring, and it eats spoken time. Quiet fades for content; big transitions only at dividers.
- **One mega-slide.** If narration for a slide is long and bullets overflow, split into two slides — never shrink the type to fit.
- **Missing fonts at render.** Load brand fonts with `@remotion/google-fonts` or `staticFile`, or text reflows between preview and render.
references/slide-components.md# Slide Components (runnable Remotion)
Complete, copy-pasteable building blocks for a narrated presentation video: a shared theme, an animated title card, a staggered bullet list, a generic content slide that mounts its own narration, and a section divider. Every reveal is a pure function of `useCurrentFrame()`, so it stays frame-locked to the rendered audio.
Install: `npm i remotion @remotion/transitions @remotion/media-utils`.
## Shared theme
Keep all brand decisions in one object. Every component reads from it, so a rebrand is one edit and 40 slides stay consistent.
```ts
// theme.ts
export const theme = {
bg: "#0B0B12",
fg: "#F5F5F7",
dim: "#9AA0B4",
accent: "#6C5CE7",
font: "Inter, system-ui, sans-serif",
titlePx: 72,
bodyPx: 30,
sectionPx: 120,
pad: 96, // px, ~5% of 1920 — keeps content in the safe center
maxWidthPct: 90,
} as const;
```
## Reusable enter helper
One motion language across the whole video. `useEnter` returns 0→1 with a fade+rise; everything visible uses it so nothing feels out of place.
```tsx
// useEnter.ts
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
export const useEnter = (delayFrames = 0, durationInFrames = 12) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({
frame: frame - delayFrames,
fps,
durationInFrames,
config: { damping: 200 }, // critically damped: arrives, never bounces
});
return {
opacity: p,
transform: `translateY(${interpolate(p, [0, 1], [18, 0])}px)`,
};
};
```
## Title card
The video's opening. Title rises first, subtitle follows after a short delay — the same stagger used for bullets.
```tsx
// TitleCard.tsx
import { AbsoluteFill } from "remotion";
import { theme } from "./theme";
import { useEnter } from "./useEnter";
export const TitleCard: React.FC<{ title: string; subtitle?: string }> = ({ title, subtitle }) => {
const t = useEnter(0);
const s = useEnter(8); // 8 frames later
return (
<AbsoluteFill style={{ background: theme.bg, justifyContent: "center", padding: theme.pad }}>
<h1 style={{ ...t, color: theme.fg, font: `700 ${theme.titlePx}px ${theme.font}`, margin: 0, lineHeight: 1.1 }}>
{title}
</h1>
{subtitle && (
<p style={{ ...s, color: theme.dim, font: `400 ${theme.bodyPx}px ${theme.font}`, marginTop: 24 }}>
{subtitle}
</p>
)}
<div style={{ ...t, width: 96, height: 6, background: theme.accent, marginTop: 40, borderRadius: 3 }} />
</AbsoluteFill>
);
};
```
## Staggered bullet list
The core build. Each bullet starts `index * STAGGER` frames later, so they reveal in reading order. Tune `STAGGER` to the narration pace — slower for formal talks, tighter for product demos.
```tsx
// BulletList.tsx
import { theme } from "./theme";
import { useEnter } from "./useEnter";
const STAGGER = 8; // frames between bullets (~0.27s @ 30fps)
export const BulletList: React.FC<{ items: string[] }> = ({ items }) => (
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: 28 }}>
{items.map((text, i) => (
<Bullet key={i} delay={i * STAGGER}>{text}</Bullet>
))}
</ul>
);
const Bullet: React.FC<{ delay: number; children: React.ReactNode }> = ({ delay, children }) => {
const e = useEnter(delay);
return (
<li style={{ ...e, display: "flex", gap: 20, alignItems: "baseline" }}>
<span style={{ width: 12, height: 12, borderRadius: 6, background: theme.accent, flexShrink: 0, transform: "translateY(2px)" }} />
<span style={{ color: theme.fg, font: `400 ${theme.bodyPx}px ${theme.font}`, lineHeight: 1.35 }}>
{children}
</span>
</li>
);
};
```
## Content slide (mounts its own narration)
The key correctness pattern: **each slide mounts its own `<Audio>`.** Because the audio lives inside the same component that draws the visuals, and both are placed by the sequencer at the same start frame, voice and picture physically cannot drift. `startFrom`/duration are handled by the parent `Sequence`.
```tsx
// Slide.tsx
import { AbsoluteFill, Audio } from "remotion";
import { theme } from "./theme";
import { useEnter } from "./useEnter";
import { BulletList } from "./BulletList";
export type SlideData = {
id: string;
title: string;
bullets: string[];
audioUrl: string; // narration for THIS slide
durationInFrames: number; // derived from audio length (see sequencing.md)
};
export const Slide: React.FC<SlideData> = ({ title, bullets, audioUrl }) => {
const head = useEnter(0);
return (
<AbsoluteFill style={{ background: theme.bg, padding: theme.pad, justifyContent: "center" }}>
<div style={{ maxWidth: `${theme.maxWidthPct}%` }}>
<h2 style={{ ...head, color: theme.fg, font: `700 ${theme.titlePx}px ${theme.font}`, margin: "0 0 48px" }}>
{title}
</h2>
<BulletList items={bullets} />
</div>
{/* Narration locked to this slide. The Sequence around it sets when it starts. */}
<Audio src={audioUrl} />
</AbsoluteFill>
);
};
```
## Section divider
Punctuation between topics. A large section number + title, held still — pair it with a more pronounced transition (see `sequencing.md`) so it reads as a chapter break.
```tsx
// SectionDivider.tsx
import { AbsoluteFill } from "remotion";
import { theme } from "./theme";
import { useEnter } from "./useEnter";
export const SectionDivider: React.FC<{ index: number; title: string }> = ({ index, title }) => {
const num = useEnter(0);
const ttl = useEnter(6);
return (
<AbsoluteFill style={{ background: theme.accent, justifyContent: "center", padding: theme.pad }}>
<div style={{ ...num, color: theme.bg, font: `800 ${theme.sectionPx}px ${theme.font}`, opacity: 0.25 }}>
{String(index).padStart(2, "0")}
</div>
<h2 style={{ ...ttl, color: theme.bg, font: `700 ${theme.titlePx}px ${theme.font}`, margin: 0 }}>
{title}
</h2>
</AbsoluteFill>
);
};
```
## Legibility checklist for these components
- Body text stays at `theme.bodyPx` (≥ 28px @ 1080p). If bullets overflow the frame, split the slide — never lower `bodyPx`.
- `theme.fg` on `theme.bg` must clear 4.5:1 contrast; verify any custom slide background the same way.
- `theme.pad` keeps content inside the center safe area; do not place text in the outer ~5%.
- Use `font-variant-numeric: tabular-nums` on any live numbers so they don't wiggle.
---
## 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 deck or slide outline into a narrated animated presentation video from a template — change the text/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=presentation-video)
SKILL.md---
name: presentation-video
description: This skill should be used when the user asks to "make an animated presentation", "turn a pitch deck into a video", "convert slides/a deck to video", "make a presentation video", "build a deck video", "narrate a slide deck as a video", or "auto-advance slides synced to a voiceover". Covers rebuilding slides as motion graphics with staggered build reveals, slide-to-slide transitions, and timing locked to per-slide narration.
version: 0.1.0
---
# Presentation Video
Turn a deck or slide outline into a narrated, auto-advancing animated video. Each slide is **rebuilt as motion graphics** — staggered element reveals, real transitions, timing locked to the voiceover — not a screen-recording of someone clicking through PowerPoint.
## When to use
- A pitch deck, webinar, or course module that should become a self-playing video.
- A bullet-point outline that needs to become narrated, animated slides.
- A deck where slide timing must track a voiceover exactly (auto-advance, no clicking).
## Rebuild, don't record
A screen-recording inherits the deck's static look, mouse jitter, and abrupt slide cuts. Rebuilding in code (Remotion) buys: per-frame-deterministic reveals, transitions that actually ease, text that stays crisp at any resolution, and slide durations driven by audio length. The deck becomes the *script*; the video is a new artifact.
## Narrative flow: the deck is one argument, not N slides
Before timing or motion, fix the *story*. A presentation video that advances itself has no presenter to recover a lost thread — the structure must carry the logic on its own. Build the spine first; slides are beats on it.
- **One throughline (the spine).** State the deck's single takeaway in one sentence. Every slide must advance that sentence; if a slide doesn't, cut it or move it to an appendix. The viewer should be able to recount the spine after one watch.
- **One message per slide.** A slide makes exactly one point — its title *is* that point, phrased as a claim ("Churn is our real growth tax"), not a label ("Churn"). Two ideas means two slides.
- **Transitions carry logic, not just motion.** The cut between slides should answer "so what's next?" — therefore, but, which means, here's the proof. Match the motion to the logical move: a quiet fade for "and also", a bigger section push for "new act". A transition that doesn't mark a logical step is just decoration.
- **Open with a hook, close with a CTA.** The first slide earns attention in ~5s — a sharp question, a surprising stat, the stakes — not an agenda or a logo hold. The last slide states the single action you want ("Start the pilot", "Invest $2M for 15%"), held still as the takeaway frame.
### Tension → resolution (the pitch spine)
A pitch is a drama, not a feature list. Sequence it so tension rises, then resolves — each act sets up the next.
| Act | Beat | Job |
|---|---|---|
| Problem | "Here's what's broken / at stake" | Create tension the viewer feels |
| Insight | "Here's what everyone misses" | The non-obvious turn; earn the solution |
| Solution | "Here's our answer" | Resolve the tension the problem built |
| Proof | "Here's why it works" | Traction, demo, numbers |
| Ask | "Here's what we want" | One concrete CTA |
Don't reveal the solution before the problem has landed — an unearned solution feels like a feature, not a relief. Map your slides onto these acts; if an act is missing the argument has a hole.
### Pacing: keep the build in sync with the voice
The throughline plays out in time, so on-screen build and VO must advance together — narration owns the clock (next section), and reveals are scheduled against it so the viewer reads the line *as it's spoken*, never ahead. One message per slide makes this natural: a slide carries one point for exactly as long as the voice argues it, then the transition marks the logical step to the next. Long narration on one point means split the slide, not race the build.
## The one rule: narration owns the clock
**Each slide lasts exactly as long as its narration audio, plus a small handle.** Measure every voiceover clip's real duration, convert to frames, and make that the slide's length. Never eyeball "this slide feels like ~5s" — it always drifts and the audio gets cut off or runs dry.
```ts
import { getAudioDurationInSeconds } from "@remotion/media-utils";
// In a Remotion calculateMetadata() / build step, per slide:
const sec = await getAudioDurationInSeconds(slide.audioUrl);
const HANDLE = 0.4; // breathing room after the voice ends
slide.durationInFrames = Math.ceil((sec + HANDLE) * fps);
```
Then the *reveals inside* the slide are scheduled against that duration — bullets land on the words, not on a fixed grid.
## Build reveals: animate to reveal, never to decorate
The point of a build is pacing: show each element when the narration reaches it, so the viewer reads the line being spoken — not three lines ahead. Stagger elements in; keep titles, logos, and background shapes static.
| Choice | Recommendation | Why |
|---|---|---|
| Enter motion | fade + 12–24px rise | Polished, calm; reads as "arriving" |
| Per-element duration | 0.25–0.5s | Fast enough to flow, slow enough to register |
| Stagger between bullets | 0.15–0.4s | Sequential, not a wall of text |
| Avoid | fly-in, spin, bounce, typewriter | Reads as decoration; competes with the voice |
Drive the stagger off `useCurrentFrame()` so it stays in lockstep with the rendered audio (see `references/slide-components.md` for the full component).
```tsx
import { useCurrentFrame, interpolate, spring, useVideoConfig } from "remotion";
const Bullet: React.FC<{ index: number; children: React.ReactNode }> = ({ index, children }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const STAGGER = 8; // frames between bullets (~0.27s @ 30fps)
const enter = frame - index * STAGGER; // each bullet starts later
const p = spring({ frame: enter, fps, config: { damping: 200 }, durationInFrames: 12 });
return (
<li style={{ opacity: p, transform: `translateY(${interpolate(p, [0, 1], [18, 0])}px)` }}>
{children}
</li>
);
};
```
## Slide-to-slide transitions
Sequence slides with `<TransitionSeries>` from `@remotion/transitions`. Each slide is a `Sequence` with its narration-derived `durationInFrames`; a `Transition` sits between them. Keep transitions quiet (short fade or gentle slide) — the content is the star, and a busy wipe on every cut gets tiring fast.
```tsx
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
<TransitionSeries>
{slides.map((s, i) => (
<React.Fragment key={s.id}>
<TransitionSeries.Sequence durationInFrames={s.durationInFrames}>
<Slide {...s} />
</TransitionSeries.Sequence>
{i < slides.length - 1 && (
<TransitionSeries.Transition
timing={linearTiming({ durationInFrames: 15 })}
presentation={fade()}
/>
)}
</React.Fragment>
))}
</TransitionSeries>
```
Reserve a bigger transition (a slide/push) for **section dividers** only — it signals "new topic" and earns the extra motion. A transition borrows frames from both neighbors, so add its length back into the slide budget if a slide's narration must play in full. See `references/sequencing.md`.
## Title & section dividers
A title card and section dividers give the video rhythm and let the viewer breathe between dense slides. Treat a divider as its own short "slide": a large section number/word, the section title, a hold of ~1.5–2s, and a more pronounced transition on either side. Keep the divider's visual language (color, type) consistent so it reads as punctuation, not a different video.
## Keep text legible & on-brand
Slides are text-dense, so legibility is the whole game.
- **Type scale, not deck scale.** A deck viewed on a laptop ≠ a video viewed in a feed. Bump body to ≥ 28–32px at 1080p; titles 56–80px. If a slide needs a 16px footnote to fit, it needs to be split into two slides.
- **One idea per slide.** If narration for a slide runs long, split it — never shrink type to fit.
- **Contrast + safe margins.** Hold a ≥ 4.5:1 contrast ratio; keep all content within the center 90% so it survives platform cropping.
- **Lock the brand in a theme object.** Colors, fonts, logo, and spacing live in one `theme` so every slide is consistent and a rebrand is a one-line change.
```ts
export const theme = {
bg: "#0B0B12", fg: "#F5F5F7", accent: "#6C5CE7",
font: "Inter, system-ui, sans-serif",
titlePx: 72, bodyPx: 30, maxWidthPct: 90,
} as const;
```
## Workflow
1. **Outline → script.** One slide = one idea + the exact narration line(s). This is the source of truth.
2. **Generate/record narration per slide.** One audio file per slide (TTS or recorded).
3. **Measure audio → set slide durations.** `getAudioDurationInSeconds` → frames (the rule above).
4. **Build slide components** with staggered reveals tied to `useCurrentFrame()`.
5. **Sequence** with `<TransitionSeries>`; add title + section dividers.
6. **Mount each slide's audio** inside its `Sequence` so voice and visuals can never drift.
7. **Legibility/brand pass**, then render to MP4.
## Output checklist
- Every slide's `durationInFrames` is derived from its narration audio, not guessed.
- Each slide's audio is mounted inside its own `Sequence` (visuals + voice locked together).
- Bullets reveal sequentially via `useCurrentFrame()`; titles/logos static.
- Reveals are fade+rise, 0.25–0.5s, no decorative effects.
- Slide-to-slide transitions are quiet; bigger transitions only on section dividers.
- Body type ≥ 28px @ 1080p, ≥ 4.5:1 contrast, content within center 90%.
- One idea per slide; long narration splits the slide instead of shrinking type.
## 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 bullet build, transition, and slide position is a pure function of `useCurrentFrame()`, so you can render any exact frame headlessly with no seek harness. The deliverable is a narrated MP4, so still-inspection catches a clipped title, an off-brand color, or a bullet that overflows before you waste a full encode.
**Output contract:**
- A Remotion project with the deck registered (`<Composition>` + zod `schema` + `defaultProps`), all motion frame-driven (no timers / `Date.now()` / `Math.random()`); per-slide audio mounted inside its own `Sequence`.
- Deliverable = the rendered `out/*.mp4` (plus the project, so the user can re-render with new slides/narration).
- Slide durations are narration-derived — compute total + per-slide `durationInFrames` in `calculateMetadata` (it measures each audio clip), never by hand.
**Verify loop — render stills → inspect → encode.** Render single frames first (cheap, no video encode), then encode only once each slide builds and reads right.
```bash
# 1. Frame-exact stills — with the SHIPPED props. Land one inside each slide's settled state:
npx remotion still Deck out/f-title.png --frame=20 --props='{"slides":[...]}' # title card settled
npx remotion still Deck out/f-mid.png --frame=240 --props='{"slides":[...]}' # a content slide, bullets built
npx remotion still Deck out/f-end.png --frame=899 --props='{"slides":[...]}' # last frame = durationInFrames - 1
# 2. Inspect each PNG — FIDELITY (slide text exact, numbers/labels correct, brand colors/fonts applied)
# AND artifacts (text overflow, body type too small, content outside center 90% safe-crop, missing
# font, low contrast, a bullet caught mid-build where the slide should be settled).
# 3. Only after the stills check out, encode:
npx remotion render Deck out/deck.mp4 --props='{"slides":[...]}'
```
- Use `npx remotion compositions` to read the deck's `durationInFrames`/`fps` and pick the end frame; sample a still **inside each slide's hold**, not on a transition, to judge the built state.
- **Data-driven / batch**: verify ONE representative deck (real slides + narration) via stills *before* rendering every variant — catch a type-scale or overflow bug once, not per deck.
- **README demo GIF for free**: `npx remotion render Deck out/demo.gif --codec=gif`.
**Before you finish:**
1. `npx remotion still` renders cleanly at title, a content slide, and the last frame — no errors, no missing fonts.
2. Slide text is exact, body type ≥ 28px @ 1080p, ≥ 4.5:1 contrast, all content inside the center 90% at each frame.
3. Frame-driven only — no `Date.now()` / `Math.random()` / library timers (determinism holds in CI).
4. The **shipped** props render correctly (not just `defaultProps`) — right slides, narration mounted, durations from audio.
5. Full MP4 encoded and plays (voice + visuals locked); (optional) GIF rendered for the README.
## Reference files
- `references/slide-components.md` — complete runnable Remotion `Slide`, staggered `BulletList`, animated `TitleCard`, and `SectionDivider` components with a shared theme and per-slide narration mounted via `<Audio>`.
- `references/sequencing.md` — the full deck sequencer: `calculateMetadata` that measures every narration clip and sets total + per-slide durations, `<TransitionSeries>` assembly, transition frame-budget accounting, and a JSON deck-schema → video data flow.