Reelsmith Docs

Animation

Each video's animation is one file, videos/<name>/index.html: React 18 and Babel in the browser, no build step, plus the Reelsmith runtime runtime/animations.jsx. The agent writes it with the html-animation skill. This page is the reference for what the runtime provides and the rules the file must follow.

The skeleton

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My video</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  <script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
  <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
  <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
  <script type="text/babel" src="../../runtime/animations.jsx"></script>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    html, body { width: 100%; height: 100%; background: #0a0a0a; overflow: hidden; }
    #root { width: 100%; height: 100vh; position: relative; }
  </style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
function slideUp(t, dur = 0.45) {
  const e = Easing.easeOutCubic(clamp(t / dur, 0, 1));
  return { opacity: e, transform: `translateY(${(1 - e) * 22}px)` };
}

function Scene1() {
  const cueBill = useWordCue(1, "bill");
  return (
    <Sprite {...useSceneWindow(1)}>
      {({ localTime }) => (
        <div style={{ position: 'absolute', inset: 0, background: '#0B0F19' }}>
          <div style={{ position: 'absolute', left: 64, top: 420, width: 520,
                        fontFamily: 'Inter', fontSize: 64, fontWeight: 600, color: '#F5F7FF',
                        ...slideUp(Math.max(0, localTime - cueBill)) }}>
            Your bill
          </div>
          <SubtitleRail sceneIdx={1} bottom={150} fontSize={28} variant="clean" accentColor="#22D3EE" fontFamily="Inter" />
        </div>
      )}
    </Sprite>
  );
}

function App() {
  return (
    <Stage width={720} height={1280} scenesSrc="scenes.json" background="#0B0F19"
           loop={false} persistKey="my-video">
      <Scene1 />
    </Stage>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

Rules of the skeleton:

  • Load the scripts in this order: React, ReactDOM, Babel, the runtime, then the style's kit if it has one (<script type="text/babel" src="../../styles/motion/kit.jsx"></script>; reflective has styles/reflective/kit.jsx). The skeleton above uses the runtime alone; a motion scene would use the kit's SceneRoot and components instead of hand-written divs.
  • <Stage> gets width={720} height={1280} and scenesSrc="scenes.json". No duration prop: the length is Σ dur from scenes.json.
  • persistKey is unique per video (the preview remembers the playhead under it).
  • One <Sprite {...useSceneWindow(N)}> per scene.

The page is always served over HTTP from the project root, so ../../runtime/animations.jsx resolves. Every Reelsmith tool that opens it (preview, sheet, draft, render) does this for you. Opening the file with file:// does not work.

Scene clocks

Each scene has two start times on the global playhead:

ClockValueUsed for
audioStartΣ dur of the earlier sceneswhere sN.mp3 starts inside voiceover.mp3; Whisper times in scenes.json count from here
windowStartaudioStart − 0.5 (0 for scene 1)where the scene's Sprite mounts, so it can crossfade over the previous scene

A Sprite's localTime runs on the window clock. useWordTimings and useWordCue add the difference, so their times are on the same clock as localTime. Compare cues against localTime, never against the global time.

If you pass a custom overlap to useSceneWindow(N, { overlap: X }), pass the same { overlap: X } to useWordTimings and useWordCue for that scene. SubtitleRail and WordReveal always use the default.

During the 0.5 s overlap two scenes are mounted. Give each scene root a zIndex equal to its number and a short opacity fade-in (about 0.3 s) so the overlap reads as a crossfade.

Runtime API

Everything below is on window (no imports).

Timing and word sync

APISignatureReturns
useSceneWindowuseSceneWindow(idx, { overlap = 0.5 } = {}){ start, end } in global seconds; spread into <Sprite>
useWordTimingsuseWordTimings(sceneIdx, { overlap = 0.5 } = {})[{ word, start, end }] on the scene's window clock; [] if the scene has no words
useWordCueuseWordCue(sceneIdx, phrase, { overlap = 0.5 } = {})seconds (window clock) when phrase starts, or Infinity
useScenesuseScenes()the parsed scenes.json array, or null while loading
useTimeuseTime()the global playhead in seconds
useTimelineuseTimeline(){ time, duration, playing, setTime, setPlaying }
useSpriteuseSprite()the enclosing Sprite's { localTime, progress, duration, visible }

useWordCue phrase matching

const cueStat = useWordCue(2, "103 billion");
...slideUp(Math.max(0, localTime - cueStat))
  • Case-insensitive, punctuation stripped on both sides: "$103B" matches Whisper's "103", "four of five" matches "Four of five,".
  • A multi-word phrase matches consecutive tokens: "sound of one" matches ["sound", "of", "one."].
  • The first match in the scene wins.
  • If the whole phrase is not found, the first word of the phrase is tried, with a console warning.
  • If nothing matches, it warns and returns Infinity, so the overlay stays hidden instead of appearing at the wrong time. reelsmith lint reports every cue that does not resolve.
  • Digits match digits: "21 million" matches ["21", "million"]; "twenty-one million" does not unless Whisper wrote it that way. Check scenes.json before choosing a phrase.

Components

ComponentProps (defaults)Notes
Stagewidth = 1280, height = 720, duration, background = '#f6f4ef', loop = true, autoplay = true, persistKey = 'animstage', scenesSrc = null, audioSrc, childrenAlways pass width={720} height={1280} scenesSrc="scenes.json". audioSrc: undefined finds voiceover-mix.mp3, then voiceover.mp3, next to the page; a string plays that file; null is silent. Preview only; render mode never plays audio
Spritestart = 0, end = Infinity, keepMounted = false, childrenRenders only while start ≤ time ≤ end. children may be a function receiving { localTime, progress, duration, visible }
SubtitleRailsceneIdx, chunkSize = 5, bottom = 300, fontSize = 32, accentColor = '#c8102e', variant = 'caps', fontFamily = "'Manrope', sans-serif"Karaoke subtitles in chunks of chunkSize words, see below
WordRevealsceneIdx, x = 30, y = 900, width = 660, fontSize = 52, fontWeight = 800, fontFamily = "'Barlow Condensed', sans-serif", color = '#fff', accentColor = '#c8102e', lineHeight = 1.15, animDur = 0.07, uppercase = falseThe scene's words appear one by one as spoken. Numbers and ALL-CAPS words turn accentColor
KenBurnssrc, localTime = 0, duration = 1, startScale = 1.0, endScale = 1.08, panFrom = {x:0,y:0}, panTo = {x:0,y:0}, position = 'center', gradient = true, entryDur = 0.5, exitDur = 0.4, width = '100%', height = '100%', x = 0, y = 0, radius = 0One image with a slow zoom and pan. Pass the Sprite's localTime and duration
ImageCutimages: [{ src, position, panDir: {x,y}, alt }], cuts: [0, …], localTime, duration, crossfadeDur = 0.15, kenBurnsScale = 1.06, gradient = true, entryDur = 0.5, exitDur = 0.4, …Cuts between images at cuts (local seconds, cuts[0] is 0), each with its own Ken Burns. Bind the cut times to word cues
Parallaxbg: { src, position }, fg = null, localTime, duration, amount = 24, direction = 'up', fgSpeedMult = -0.5, gradient = true, entryDur = 0.5, exitDur = 0.4, …Two-layer depth drift
TextSpritetext, x = 0, y = 0, size = 48, color = '#111', font, weight = 600, entryDur = 0.45, exitDur = 0.35, entryEase, exitEase, align = 'left', letterSpacingText with a built-in entry and exit; it fades out at the end of its Sprite
ImageSpritesrc, x, y, width = 400, height = 300, entryDur = 0.6, exitDur = 0.4, kenBurns = false, kenBurnsScale = 1.08, radius = 12, fit = 'cover', placeholder = nullAn image with entry and exit
RectSpritex, y, width = 100, height = 100, color = '#111', radius = 8, entryDur = 0.4, exitDur = 0.3, renderA rectangle with a spring entry; render(ctx) returns style overrides

TextSprite, ImageSprite and RectSprite have exits baked in. For content overlays use enter-only motion instead (the "no early exits" rule below).

Motion helpers (pure functions)

HelperSignatureReturns
interpolateinterpolate(input, output, ease = Easing.linear)a function t => value; input and output are equal-length arrays; ease may be an array, one per segment
animateanimate({ from = 0, to = 1, start = 0, end = 1, ease = Easing.easeInOutCubic })a function t => value
springspring(t, k = 170, d = 26)displacement 0 → 1 of a spring released at t = 0; low d overshoots, d ≥ 2√k does not
tracktrack(t, keys, k = 170, d = 26)value at t for keyframes [[time, value], …], each key springing from the previous value
seededRandomseededRandom(seed = 1)a function returning 0..1, deterministic per seed (use instead of Math.random)
clampclamp(v, min, max)v limited to the range
Easinglinear, easeIn/Out/InOut + Quad, Cubic, Quart, Expo, Sine, Back, and easeOutElasticfunctions from 0..1 to 0..1

Spring presets that work: snappy UI k=320, d=30, soft k=140, d=22, punch k=200, d=14.

const cue = useWordCue(2, "600 milliseconds");
const s = spring(localTime - cue, 200, 14);          // bouncy punch-in
const x = track(localTime, [[0, -200], [cueA, 0], [cueB, 180]]);
const rand = seededRandom(42);                       // same scatter every frame

Contexts are exported too (TimelineContext, ScenesContext, SpriteContext), for custom components.

SubtitleRail

Every narrated scene has one, as the last child of the scene's root div.

<SubtitleRail sceneIdx={N} bottom={150} fontSize={28} variant="clean" accentColor={CYAN} fontFamily="Inter" />
PropDefaultMeaning
sceneIdxrequiredthe scene number, same as useSceneWindow(N)
chunkSize5words per subtitle chunk
bottom300distance from the bottom edge in px
fontSize32px
accentColor'#c8102e'caps: numbers and ALL-CAPS words; clean: the word being spoken
variant'caps''caps': condensed uppercase on a dark pill, the current word scales up. 'clean': sentence case, no pill, soft shadow, wider gaps, the current word in accentColor at the same size
fontFamily"'Manrope', sans-serif"used by clean only

The rail is hidden until the scene's first word is spoken. Row gap, line height and letter spacing are managed by the runtime; do not override them. Each style pack gives its props:

StyleProps
reflectivebottom={170} fontSize={26} variant="clean" accentColor={GOLD2}
tech-newsdefaults
motionbottom={150} fontSize={28} variant="clean" accentColor={CYAN} fontFamily="Inter"

Only a silent scene (no words in scenes.json, like a tech-news cta-end card) goes without a rail.

Rule 4: every frame is a pure function of time

The renderer seeks to a time, captures a frame, and moves on, across several browsers at once and in any order. A frame must depend only on the time.

Banned in index.html:

BannedUse instead
Math.random()seededRandom(seed)
setTimeout, setIntervalvalues computed from localTime
requestAnimationFramevalues computed from localTime
Date.now(), performance.now(), new Date()localTime, useTime()
CSS transition: and animation:spring, track, interpolate, Easing

Render mode (?render=1, used by the renderer and the contact sheet) also disables every CSS transition and animation on the page, so anything that only moves through CSS is static in the MP4.

Word-cue binding

Every overlay that introduces a spoken thing (a name, a number, a callout, a bullet) gets its time from useWordCue. A raw delay over 0.6 s is a bug. Allowed raw delays of 0.6 s or less: scene-opening flourishes (a label, a divider, the first headline) and stagger offsets after a cue (cue + 0.15).

No early exits

Text overlays must not fade out before the scene ends. Use enter-only motion (slideUp-style helpers, springs, the kit's entries). A fade() helper that includes an exit is fine only for full-scene backgrounds and CTA cards.

Layout rules

  • Canvas 720×1280.
  • Safe area: every text line keeps 48 px or more from both sides and is 520 px wide or less. The contact sheet lists every violation; decoration that bleeds on purpose carries a data-bleed attribute.
  • Keep content clear of the subtitle band (above bottom plus two subtitle lines).
  • Something new on screen every 2 to 4 s. Vary entries. No scene that is only a centered title.

The shared base rules are in styles/design.md; each style pack adds its own (see Styles).

The linters

reelsmith lint videos/<name>

Runs both linters and exits non-zero if either fails.

check-sync (tools/check-sync.js) flags:

  • localTime - N, delay={N} and delay: N with N over 0.6 on a line with no useWordCue call or cue… variable;
  • Math.random, timers, requestAnimationFrame, the wall clock, CSS transition: and animation:;
  • ...fade( spread on a text overlay.

validate-sync (tools/validate-sync.js) checks against scenes.json:

  • every useWordCue(N, "phrase") resolves to a real word in scene N;
  • no fade() on text overlays;
  • every narrated scene (one with words) has a SubtitleRail for its number (a warning).

To allow a deliberate exception, put // sync-ok on the line or the line above.

Previewing

reelsmith preview videos/<name>              # live playback with the voice
reelsmith preview videos/<name> --lan        # also reachable from a phone on the same Wi-Fi
reelsmith preview videos/<name> --port=3001 --no-open   # another port, no browser tab

The port defaults to 3000, or a free one when 3000 is taken.

The preview has a playback bar. Keys: Space plays and pauses, the arrow keys step 0.1 s (1 s with Shift), 0 or Home goes to the start. With --lan, open the printed /qr page on your computer and scan it with the phone.

Checking the result

reelsmith sheet videos/<name>            # frames/contact-sheet.png
reelsmith sheet videos/<name> --stills   # plus full-size frames/stills/*.jpg
reelsmith draft videos/<name>            # draft.mp4, 15 fps, 0.75× size, with the voice
reelsmith clip videos/<name> --from=12 --to=24   # clip-12s-24s.mp4 (add --draft for draft quality)

sheet also takes --per-scene=<N> (default 3), --cols=<N> (default 4), --times=<a,b,c> for extra frames and --out=<file.png>.

The contact sheet samples the hook (0.3, 1 and 2 s) and three frames per scene, and labels each with the words being spoken, so sync can be judged from the sheet alone. The html-animation skill scores it on hook, phone readability, breathing room, motion, variety, style accuracy and sync, and iterates until every score is 8 or higher.

View this page on GitHub