Back to samples Case study · Production pipeline

The animation is anchored to the voice

A pipeline that turns a source document into a narrated, animated training video. The interesting part is not that it renders a video. It is that no timing in the composition was typed by a human: the voiceover generates the timings, and the animation binds to them by name.

1:43
finished video, rendered
24
narration sentences
19
named beats
0
hand-timed content cues
The problem

Training video ages faster than it can be re-edited

A product changes, one sentence in the script is now wrong, and the fix means opening an edit timeline and dragging every keyframe after the change. The cost of a correction is high enough that the correction usually does not happen, so the video quietly drifts out of date while still being the thing new hires watch.

That is a timing-architecture problem, not a video problem. If every visual cue is pinned to a hand-placed position on a timeline, the timeline is what you maintain. So the pipeline pins the visuals to the narration instead, and lets the narration own the clock.

The chain

From a document to a hosted video

Pipeline stages A source document becomes a script, the script becomes a voiceover, the voiceover generates a timing file, the composition binds to those timings, and the result is rendered and hosted. The source document stage is not published. source doc not published script 24 sentences voiceover ElevenLabs timing.js generated composition HyperFrames render 1080p mp4 everything downstream of here inherits the voice's clock the source document belongs to a client and is described rather than shown; everything from the script onward is on this page
timing.js is the hinge. It is generated, never edited, and it carries five tables the rest of the build reads: the total length, 24 sentence cues, 19 named beats, 5 scene windows, and the chapter cards.
The proof

Three moments, traced from voice to pixel

Each block below shows the same thing from three angles: the narrated sentence with its timing, the beat the generator derived from it, the line of animation code that binds to that beat, and the frames from the rendered file on either side of it.

1 · A rule card that arrives with the rule
beat b0_rule · 49 ms after the sentence starts
timing.js · the narration cue, generated from the voiceover
{"s": 12.401, "e": 16.144, "t": "pick one primary submission method per worker type."}
timing.js · the named beat
"b0_rule": 12.45
anim.js line 94 · the binding
tl.fromTo("#s0-rule", { opacity: 0, scale: 0.75 }, { opacity: 1, scale: 1, duration: 0.45, ease: "back.out(1.7)" }, T.b0_rule);
Frame at 12.30 seconds: the layout is on screen but there is no rule card.
t = 12.30s · no card
Frame at 12.95 seconds: a yellow card reading One primary method per worker type has appeared.
t = 12.95s · card present

The card reads One primary method per worker type. The narrator says pick one primary submission method per worker type. Nobody typed 12.45 anywhere: the generator wrote it from where that sentence begins in the audio.

2 · Seven day cells that stagger with the sentence
beat b1_days · stagger derived, not drawn
timing.js · the narration cue
{"s": 32.388, "e": 35.71, "t": "which days are working days, which are rest days."}
anim.js lines 101 to 104 · five working days cascade, then the two rest days
tl.fromTo(sel, { opacity: 0, y: 14 }, { opacity: 1, y: 0, duration: 0.35, ease: "power2.out" }, T.b1_days + i * 0.12); tl.fromTo("#s1-d6", { ... }, T.b1_days + 0.7); tl.fromTo("#s1-d7", { ... }, T.b1_days + 0.82);
Frame at 33.70 seconds: a work schedule row showing Monday to Friday marked eight hours and Saturday and Sunday marked rest.
t = 33.70s · all seven cells in, weekdays and rest days visually distinct

The five weekdays cascade at 120 ms apart from the beat. Saturday and Sunday are held back to +0.70s and +0.82s, which is where the sentence reaches which are rest days. The offsets are relative to the beat, so if the sentence moves, the whole cascade moves with it.

3 · A three-step interface change from one beat
beat b4_approve · cursor, then state, then badge
timing.js · the narration cue
{"s": 89.376, "e": 94.282, "t": "As a manager, open the submitted timesheet, review it, and approve."}
anim.js lines 129 to 131 · one beat, three sequenced consequences
moveClick(4, "#s4-approvebtn", T.b4_approve + 0.2, true); tl.to("#s4-pend", { opacity: 0, duration: 0.25 }, T.b4_approve + 0.5); tl.fromTo("#s4-appr", { opacity: 0, scale: 0.6 }, { opacity: 1, scale: 1, duration: 0.4, ease: "back.out(1.8)" }, T.b4_approve + 0.6);
Frame at 92.40 seconds: a timesheet row with a yellow Submitted badge and the cursor below the approve button.
t = 92.40s · Submitted, cursor approaching
Frame at 93.70 seconds: the cursor is on the approve button and the badge now reads Approved in green.
t = 93.70s · cursor on the button, badge is Approved

This is the one that shows the pattern properly. A simulated cursor travels and clicks, the pending state fades, and the approved badge pops, at +0.2s, +0.5s and +0.6s from a single anchor. The choreography is written as offsets from a spoken word, so it survives any change to what is said before it.

The generated file

What the voiceover produces

One script runs the audio through transcription and writes the timing file. Its first line is an instruction to humans.

// GENERATED by make_v2.py (sentence subs + title gaps) - do not edit
const TOTAL = 103.0;

const SUBS = [                 // 24 sentence cues, start and end
  {"s": 12.401, "e": 16.144, "t": "pick one primary submission method per worker type."},
  ...
];

const T = {                    // 19 named beats the animation binds to
  "b0_rule": 12.45,
  "b1_days": 32.44,
  "b4_approve": 92.59,
  ...
};

const SCENES = {               // 5 scene windows, with gaps left for chapter cards
  "sc00": { "s": 0.0,   "e": 22.01 },
  "sc01": { "s": 25.01, "e": 46.24 },
  ...
};
The honest version of "no hand timing"

The composition file makes 45 animation calls. Twenty-four bind directly to a named beat. The rest are either helper definitions that take the beat as an argument, or they read the subtitle and scene tables that the same voiceover produced.

The only literal times typed into the file are 0 for initial states, 0.15s for the opening title, and four closing cues written as TOTAL minus a fraction of a second, where TOTAL is the length of the audio. So it is not that nothing was hand-timed. It is that nothing about the content was.

The output

The finished video

1:43, 1920x1080, AAC audio, faststart. Rendered from the composition above, then checked frame by frame for tearing (0 torn frames) and re-transcribed end to end to verify the audio.
What you are watching

This is a de-branded portfolio cut of client work. The client's name, product name, URL and brand palette were replaced with my own studio's; the instructional design, the animated interface and the narration are the original. Every interface on screen was always drawn in HTML and CSS against invented sample data, so nothing here depicts a real product, and the on-screen footer says so. The source document that started the chain is not published.

Economics

The number that matters is the cost of a change

Production cost is the usual thing people quote, and it is the less interesting one. What this architecture actually buys is the cost of the second version.

Changing one sentence of narration
StepHand-timed editThis pipeline
Re-record or regenerate the lineyesyes
Re-time every cue after the changeby handregenerated
Re-check every downstream animationby handnot needed
Re-renderyesyes

The middle two rows are the whole argument. In a hand-timed edit they scale with the length of the video and with how early in it the change lands. Here they cost nothing, because the animation never knew the numbers in the first place, only the names.

Limitations

What this system does not do

  • It does not write the script. A model drafts from the source document, and then a human rewrites it, because narration that has never been read aloud by its author sounds like narration.
  • It does not design the scenes. Every interface in the composition is hand-built HTML and CSS. The pipeline times them; it does not invent them.
  • Beat names are a manual mapping. The generator produces the cues automatically, but deciding that a beat called b4_approve is the one worth anchoring a click to is an editorial judgement made once per video.
  • It is not a service with an interface. It is a set of scripts I run. There is no queue, no retry policy, and no dashboard.
  • The economics table above is reasoning, not a measured benchmark. I have not stopwatch-tested a hand-timed edit of the same video to compare against.
One decision that paid off later

Why this one could be published at all

Every interface in these videos is drawn in HTML and CSS with invented data, rather than captured as screenshots. That was a production decision: drawn interfaces re-render at any resolution, animate natively, and never need a staging environment with realistic-looking data in it.

It turned out to be a decision about something else entirely. When the time came to make a portfolio cut, de-branding meant changing values in one block of design tokens. A sibling series in the same programme had embedded real screenshots, and de-branding that one meant rebuilding every screen from scratch, so it stayed unpublished.

The constraint that makes a system maintainable and the constraint that makes its output shareable turned out to be the same constraint. That is worth knowing before you start, not after.