references/retention-pacing.md
# Retention Pacing — Short-Form Video Reference
Deep reference for the `short-form-video` skill: a full runnable Remotion short, a hook library, a retention-debugging method, per-platform safe zones, and batch output. All timing is frame-driven so renders are deterministic.
## 1. A complete `<Short>` composition
One self-contained vertical short: frame-driven shot scheduler with uneven cuts, a punch-in pattern interrupt, an on-screen hook, a 9:16 safe-area overlay (dev only), and a seamless loop. Topic content is a prop, so the same file renders many shorts.
```tsx
// Short.tsx — 1080×1920, 30fps. Register at 600 frames (20s) for a clean loop.
import {
AbsoluteFill, useCurrentFrame, useVideoConfig,
interpolate, spring, Sequence, Img,
} from "remotion";
type Beat = { at: number; text: string; src?: string }; // at = seconds
export type ShortProps = {
hook: string; // on-screen by frame 1
beats: Beat[]; // body, one idea each
loopText: string; // sentence that feeds back to frame 0
};
// --- frame-driven shot scheduler: uneven cuts read as momentum ---
const useShot = (cuts: number[]) => {
const { fps } = useVideoConfig();
const frame = useCurrentFrame();
let i = 0;
for (let k = 0; k < cuts.length; k++) if (frame >= cuts[k] * fps) i = k;
const startF = cuts[i] * fps;
const endF = (cuts[i + 1] ?? cuts[i] + 2) * fps;
const local = (frame - startF) / (endF - startF); // 0→1 within shot
return { index: i, local, startF };
};
// --- punch-in: the cheapest pattern interrupt, snaps on the cut ---
const PunchIn: React.FC<{ startF: number; to?: number; children: React.ReactNode }> = ({
startF, to = 1.1, children,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const s = spring({ frame: frame - startF, fps, config: { damping: 13, stiffness: 220 } });
const scale = interpolate(s, [0, 1], [1, to]);
return <AbsoluteFill style={{ transform: `scale(${scale})`, transformOrigin: "50% 45%" }}>{children}</AbsoluteFill>;
};
// --- hook: text present from frame 0, micro-overshoot, no fade-up ---
const Hook: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame, fps, config: { damping: 9, stiffness: 260 } });
const scale = interpolate(pop, [0, 1], [0.9, 1]);
return (
<div style={{
position: "absolute", top: 360, left: 90, right: 90, // inside 900-wide safe box
transform: `scale(${scale})`, transformOrigin: "0 0",
fontFamily: "Inter, sans-serif", fontWeight: 800, fontSize: 76, lineHeight: 1.05,
color: "#fff", textShadow: "0 4px 24px rgba(0,0,0,.55)",
}}>{text}</div>
);
};
export const Short: React.FC<ShortProps> = ({ hook, beats, loopText }) => {
const { fps, durationInFrames } = useVideoConfig();
const frame = useCurrentFrame();
const cuts = [0, ...beats.map((b) => b.at)]; // hook shot + one per beat
const { index, startF } = useShot(cuts);
const current = index === 0 ? null : beats[index - 1];
// seamless loop: match the LAST 0.5s back toward the opening frame
const tailF = durationInFrames - frame;
const loopFade = interpolate(tailF, [0, fps * 0.5], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ backgroundColor: "#0b0b0f" }}>
<PunchIn startF={startF}>
{current?.src && <Img src={current.src} style={{ width: "100%", height: "100%", objectFit: "cover" }} />}
</PunchIn>
{index === 0 && <Hook text={hook} />}
{/* body caption — placement only; styling/word-timing belongs to caption-animation */}
{current && (
<div style={{
position: "absolute", left: 90, right: 90, bottom: 380, // above the 320px bottom UI band
fontFamily: "Inter, sans-serif", fontWeight: 700, fontSize: 60, color: "#fff",
textAlign: "center", textShadow: "0 3px 18px rgba(0,0,0,.6)",
}}>{current.text}</div>
)}
{/* loop sentence rises as the clip ends, easing the eye back to frame 0 */}
<div style={{
position: "absolute", left: 90, right: 90, bottom: 380, opacity: 1 - loopFade,
fontFamily: "Inter, sans-serif", fontWeight: 800, fontSize: 64, color: "#fff", textAlign: "center",
}}>{loopText}</div>
<SafeZone />{/* remove for final render */}
</AbsoluteFill>
);
};
```
Register it with a duration that *is* the loop length:
```ts
// Root.tsx
import { Composition } from "remotion";
import { Short } from "./Short";
export const Root = () => (
<Composition
id="Short" component={Short}
durationInFrames={600} fps={30} width={1080} height={1920}
defaultProps={{
hook: "You're editing Shorts wrong —",
beats: [
{ at: 1.2, text: "Cut every 2–4 seconds", src: "b1.jpg" },
{ at: 4.0, text: "Never on a fixed grid", src: "b2.jpg" },
{ at: 7.5, text: "Kill the flat middle", src: "b3.jpg" },
{ at: 11.0, text: "Match last frame to first", src: "b4.jpg" },
],
loopText: "…and that's why mine loop forever",
}}
/>
);
```
## 2. The 9:16 safe-area overlay (dev-only)
Render this on top while editing; delete it before the final export.
```tsx
import { AbsoluteFill } from "remotion";
export const SafeZone: React.FC = () => (
<AbsoluteFill style={{ pointerEvents: "none" }}>
{/* universal safe box: center 900×1400 in 1080×1920 */}
<div style={{ position: "absolute", left: 90, top: 260, width: 900, height: 1400, outline: "2px dashed #39d", opacity: .6 }} />
{/* platform UI bands to keep clear */}
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: 120, background: "rgba(255,0,0,.12)" }} />
<div style={{ position: "absolute", bottom: 0, left: 0, right: 0, height: 320, background: "rgba(255,0,0,.12)" }} />
<div style={{ position: "absolute", bottom: 320, top: 120, right: 0, width: 120, background: "rgba(255,128,0,.10)" }} />
</AbsoluteFill>
);
```
## 3. Hook pattern library
The hook is a sentence that opens a gap the brain must close, plus the most striking frame as frame 0. Copyable shapes:
| Pattern | Template | Note |
|---|---|---|
| Open loop | "The real reason your ___ keeps ___ …" | answer withheld until payoff |
| Direct promise | "___ in 30 seconds, no ___ needed" | name the payoff + the timebox |
| Negation | "Stop ___. You're doing it backwards." | strong-opinion content |
| Pattern interrupt | snap-zoom / whip / hard visual mismatch | entertainment, no text needed |
| Stakes | "This ___ cost me $___ — don't repeat it" | story / case-study |
| Listicle | "3 ___ that ___ (the 3rd one ___ )" | tees up a forward reference |
Rules that matter more than the wording: (1) the hook text is on-screen by frame 1 — most viewers are muted; (2) frame 0 is the best frame, never a fade-up from black; (3) one gap only — stacking two hooks closes neither.
## 4. Retention-curve debugging
Treat the per-second retention graph (TikTok Analytics, YT Studio "Audience retention", Reels) as a profiler.
| Symptom on the curve | Likely cause | Fix |
|---|---|---|
| Cliff at 0:00–0:02 | weak hook / slow first frame | new hook line; cut the ramp-in; lead with the best frame |
| Steady gentle decline | normal — leave it | — |
| Flat-then-drop in the middle | low-energy body, cuts too sparse | add interrupts; tighten cut spacing to 2–4s |
| Bump back up near the end | viewers are looping | lean in — tighten the loop, shorten total length |
| Spike at one timestamp | a re-watched moment | front-load that beat or make it the hook |
Target ≥70% average view-through. Iterate the hook first — it moves the curve more than any other edit because it gates everything after it.
## 5. Per-platform safe zones
All on a 1080×1920 canvas. The universal box (center 900×1400) is the intersection; design inside it and one master fits every platform.
| Platform | Top clear | Bottom clear | Right clear | Notes |
|---|---|---|---|---|
| Universal (use this) | ~120px | ~320px | ~120px | center 900×1400 holds on all |
| TikTok | ~108px | ~320px | ~120px | caption + button rail at bottom-right |
| Instagram Reels | ~120px | ~310px | ~84px | audio bar + caption bottom |
| YouTube Shorts | ~120px | ~300px | ~60px | channel + subscribe bottom band |
| TikTok Ads | ~108px | ~370px | ~120px | extra room for the CTA button |
When unsure, design to the *largest* margin (TikTok Ads bottom 370px) and nothing ever clips.
## 6. Template × topic — batch output
The payoff of code-driven shorts: lock the design once, swap only the props, render N videos. Make topic content a prop (already done above), keep one theme object, and loop over JSON files.
```jsx
// theme.ts — one source of truth so 50 shorts stay on-brand
export const theme = { font: "Inter", hookSize: 76, bodySize: 60, bg: "#0b0b0f", fg: "#fff", accent: "#39d" };
```
```bash
# render the same Short template for every topic file in /topics
for f in topics/*.json; do
name=$(basename "$f" .json)
npx remotion render Short "out/$name.mp4" --props="$f"
done
```
Each `topics/*.json` is one `ShortProps` object (`hook`, `beats`, `loopText`). Keep the hook line, cut points, and loop sentence per-topic; everything else stays fixed so the series reads as one brand.
---
## 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 batch-produce short-form videos from one template × data — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=short-form-video)
SKILL.md
---
name: short-form-video
description: This skill should be used when the user asks to "make a Reels/TikTok/YouTube Shorts video", "build a short-form video template", "write a hook for a short", "edit for retention", "add pattern interrupts", "make a video loop", "fix the first 3 seconds", "pace cuts for a vertical video", or "set up 9:16 safe areas". Covers the hook→retention→loop grammar of vertical short-form (distinct from a launch hype film).
version: 0.1.0
---
# Short-Form Video
Engineer a 9:16 video that survives the swipe. Short-form is not a small launch film — it is a retention machine: a hook that earns the first second, pattern interrupts that reset attention every few seconds, and often a loop that buys watch-time past 100%. The craft is timing, not polish.
## When to use
- Reels / TikTok / YouTube Shorts (7–60s, vertical 9:16).
- Retention editing: fixing drop-off, adding pattern interrupts, tightening pacing.
- Hooks, seamless loops, repeatable short-form templates (one design, many topics).
## Story structure (the retention contract)
Short-form storytelling is a contract with the viewer's thumb: every second must re-earn the next one. The narrative is shaped *backwards from the retention curve*, not forwards from an intro. Lock these before touching the timeline.
- **One idea per video.** A short carries exactly one payoff. A second idea splits attention and the curve sags in the middle — make it a second video instead. If you can't name the single takeaway in a sentence, you don't have a short yet.
- **Open a loop in the first ~3s, close it last.** The hook poses a question or gap (the open loop); the payoff answers it. The viewer stays because the loop is open — so never answer it early, and never bury the payoff under setup.
- **No dead air.** Every beat either advances the idea or resets attention. Cut anything that does neither. Silence, slow ramps, throat-clearing intros ("hey guys, in this video…") are where viewers swipe.
- **Why the curve, not the cut, is the unit of work.** The algorithm distributes whatever holds the per-second retention curve flat; a "good" edit that flattens the curve loses to an ugly one that keeps it up. You are editing the *curve*, and the hook gates everything after it — fix it first.
The narrative spine (the `Hook → Setup → Body → Payoff/loop` arc is budgeted in the table below):
| Move | Story job | Failure if missed |
|---|---|---|
| Hook | Open the loop; promise the payoff | Swipe in first 3s |
| Tension hold | Keep the gap open through the body | Flat middle, mid-drop |
| Payoff | Close the loop — the one idea, delivered | "Wasted my time," no share |
| Loop / CTA | Feed back to frame 0, or one clear ask | No re-watch, no action |
(`The retention arc` table below assigns seconds to each move; this section is the narrative logic and ordering behind it. `Hook grammar` lists the hook archetypes.)
## The one metric that drives everything
**Retention, not production value.** The algorithm distributes whatever holds viewers; ~87% decide to stay or swipe inside 3 seconds, and the threshold for distribution sits near **70%+ average view-through** (Shorts ~73%, TikTok ~78%, Reels ~65%). Every decision below trades against that curve. A flawless shot that bores at 0:04 loses to an ugly one that holds.
## The retention arc
| Beat | Job | Budget (of a 20s short) |
|---|---|---|
| Hook (0–1s) | One frame + one line that creates an open loop | 0–1s |
| Setup | Pay off *why watch* — promise, stakes, or the gap | 1–4s |
| Body | Deliver value in beats; one idea per beat | 4–17s |
| Payoff / loop | Close the loop — or feed it back to frame 0 | 17–20s |
Scale the *body*, never the hook. A 60s short has a longer body and more beats — the same 1-second hook.
## Hook grammar (first ~1s)
The hook is a sentence + a visual that opens a curiosity gap the brain needs closed. Pick one pattern and commit:
| Hook type | Shape | Best for |
|---|---|---|
| Open loop | "The reason your X keeps Y…" (answer delayed) | most content |
| Direct promise | "3 ways to ___ in 30 seconds" | educational / listicle |
| Pattern interrupt | snap-zoom / whip-pan / visual mismatch in frame 1 | entertainment |
| Negation | "Stop doing ___" / "You're ___ wrong" | strong opinions |
Two rules: the hook line is **on-screen as text by frame 1** (85% watch muted), and the most striking *visual* is also the first frame — never a slow ramp-in. Hold a strong opening frame; do not fade up from black.
## Pattern interrupts & cut rhythm
A pattern interrupt is any change that resets attention: a hard cut, zoom punch, b-roll insert, sound effect, caption pop, or angle change. In short-form, density is high — a *visual change every ~2–4 seconds*; videos with an interrupt roughly every 4s hold ~58% vs ~41% for a static talking head. The killer is a flat middle: TikTok throttles distribution when the watch-through curve flattens, so never stitch the body from low-energy takes.
```js
// Drive every interrupt off the FRAME, not wall-clock — renders deterministically.
import { useCurrentFrame } from "remotion";
const fps = 30;
const cuts = [0, 1.2, 3.0, 5.4, 8.0, 11.2, 14.0]; // seconds — uneven on purpose
const frame = useCurrentFrame();
const shot = cuts.findIndex((t, i) => frame < (cuts[i + 1] ?? Infinity) * fps);
```
Vary the interval — a metronome reads as boredom; uneven cuts read as momentum. A punch-in zoom is the cheapest interrupt that needs no new footage:
```jsx
import { useCurrentFrame, interpolate, spring, useVideoConfig } from "remotion";
const PunchIn = ({ at, children }) => {
const frame = useCurrentFrame(); const { fps } = useVideoConfig();
const s = spring({ frame: frame - at * fps, fps, config: { damping: 12, stiffness: 200 } });
const scale = interpolate(s, [0, 1], [1, 1.12]); // snap to 112% on the beat
return <div style={{ transform: `scale(${scale})`, transformOrigin: "50% 45%" }}>{children}</div>;
};
```
## Captions
Most viewers watch muted, so on-screen captions are not optional — they carry the content and double as a pattern interrupt when each phrase pops in. Keep 4–7 words per line, high contrast, inside the safe area. **Do not re-implement caption animation here** — use the `caption-animation` skill for word-by-word timing, karaoke highlight, and styling; this skill only places captions on the retention timeline and inside the 9:16 safe zone.
## Loops & re-watch
A seamless loop counts each re-watch as fresh watch-time — a clean 8s loop watched 3× is 300% view-through, the strongest possible signal. Two techniques: match the **last frame to the first** (same composition, position, color) so the cut is invisible, and write a **sentence loop** — phrase the hook so the payoff feeds back into frame 0 ("…and that's why I never — " → loops to the start). Best length for re-watch is ~7–15s.
## 9:16 safe areas
Compose inside the universal safe zone so platform UI never covers text or faces.
| Zone | Pixels (1080×1920) | Avoid |
|---|---|---|
| Universal safe (all platforms) | center **900×1400** | anything critical outside it |
| Top | keep clear ~120px | profile / sound UI |
| Bottom | keep clear ~320px | captions, CTA, hashtags, audio bar |
| Right | keep clear ~120px | like / comment / share rail |
Put the hook text and key subject in the center 900×1400 box. Reserve the bottom ~320px even if it looks empty in the editor — that is where the platform paints its own captions and buttons.
## Output checklist
- Hook line is on-screen text by frame 1; strongest visual is the first frame (no fade-up).
- One open loop / promise set in the first ~4s.
- A visual pattern interrupt every ~2–4s; intervals uneven; no flat middle.
- Captions present (via `caption-animation`), 4–7 words, inside safe area.
- Loop closed — matched first/last frame or a sentence that feeds frame 0.
- Composed inside the 900×1400 center box; bottom 320px / top 120px / right 120px kept clear.
- Every animated value derived from `useCurrentFrame()` — no CSS/library timers.
## 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`.
The short ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`) — every cut, punch-in, and interrupt driven by `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` + the project (re-render per topic). 9:16 vertical (1080×1920) is the default.
**Verify loop — render stills → inspect → encode.** Three frames test the three things that make or break retention: the hook, the pacing, and the loop seam.
```bash
# Stills at hook / mid-body / last frame — WITH SHIPPED PROPS (the real topic, not defaults)
npx remotion still Short out/f-hook.png --frame=10 --props='{"topic":"...","hook":"..."}' # ~first 3s reads instantly
npx remotion still Short out/f-mid.png --frame=N --props='{"topic":"...","hook":"..."}'
npx remotion still Short out/f-end.png --frame=L --props='{"topic":"...","hook":"..."}' # L = durationInFrames-1
# Inspect each PNG:
# - HOOK frame (~frame 0-ish / first 3s): hook line is on-screen text and reads instantly; strongest visual already up (no fade-up)
# - LOOP seam: compare f-hook(frame 0) vs f-end(last frame) — same composition/position/color for an invisible loop cut
# - 9:16 safe area: hook + key subject inside the center 900x1400 box; clear of top ~12%, bottom ~20-35% (captions/CTA/audio), right action rail
npx remotion render Short out/short.mp4 --props='{"topic":"...","hook":"..."}' # encode once stills verify
npx remotion render Short out/demo.gif --codec=gif # README proof clip
```
**Per topic / batch**: verify ONE topic via stills *before* rendering all of them. Use `npx remotion compositions` for `durationInFrames`/`fps` to pick the mid + last frames.
**Before you finish:**
1. Stills render cleanly at the hook, mid, and last frame — no errors.
2. The hook frame reads instantly (on-screen text + strongest visual up front, no fade-up); first vs last frame match for a clean loop seam.
3. Hook + subject + captions sit inside the 9:16 center 900×1400 safe box at every checked frame.
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers; cuts uneven, no flat middle.
5. A real topic's shipped props (not `defaultProps`) render correctly; MP4 encoded, GIF optional.
## Reference files
- `references/retention-pacing.md` — a complete runnable Remotion `<Short>` composition: a frame-driven shot scheduler with uneven cuts, the PunchIn pattern interrupt, an on-screen hook, a 9:16 safe-area overlay, and a seamless first/last-frame loop. Plus a hook-pattern library, a retention-curve debugging method, per-platform safe-zone maps, and the template×topic batch-render pattern.