1920s Silent Film — our demo

One example among many. Don't reuse its story, arc, shots, props or timings.

Demo: The Runaway Loaf (54.2 s) · silent-film.mp4 · source in demo/

A 40–60 second one-reel comedy: a baker's perfect round loaf escapes and ends up with a hungry girl. The references it leans on hardest: Keaton's locked-off chain gag and Lloyd's near-miss; the score follows a photoplay cue sheet (Maestoso / Andante / Hurry / Misterioso / Agitato / Tenderly).

Story & structure

A short one-reel comedy as it would look projected: the frame is a 4:3 print in a gate, between theatre curtains; the image is an ink drawing with washed greys and hatching, printed on silver stock that flickers, weaves, scratches and burns at the edges. Nobody speaks. Story beats are told by action in wide shots and by intertitle cards. The only sound is a cinema piano (and the projector in the booth).

The comic engine is Keaton's: an ordinary person with one small goal, a physical world that misbehaves, a face that does not react — and one gag built as a chain of cause and effect that the audience watches happen in a single frame.

How the demo used the medium:

Native power Story use
Locked-off wide shot A mechanical gag: A triggers B, B leaves the frame, the audience waits, B comes back and hits C. The waiting is the joke.
Intertitles Three to five cards carry what the action can't: the premise twist ("The loaf had other plans."), a shout ("STOP THAT BAKER!"), one line of dialogue ("Is it yours, mister?"). Keep each card to a few words.
Projection speed Undercrank the chase (motion sampled at 18 fps, 1.3× speed, faster flicker); return to 16 fps and a calmer print for tender scenes. Speed is an emotion.
The iris Open on the object of the story, close on the last glance. An iris that stops half-way lets a character look at the audience (a wink).
Ageing as mood More damage when things go wrong, a warmer sepia and a cleaner print when the film turns tender.

Story shape (as in the demo): pride (a baker lifts his perfect round loaf high) → the object escapes (it wobbles on the sill, rolls off and away down the hill — a round loaf, so it can really roll) → the chase with one near-miss (he grabs, it hops over his hand, he stares into his empty fist) → the chain gag in one take (see-saw → a melon flies out of frame → the constable strolls in → the melon lands on his helmet) → it stops at the feet of someone who needs it more (a hungry girl) → silence → a small act (he breaks the loaf in two) → echo (she lifts her half high, as he did) → the iris closes on her wink. Cut rather than add: a tram, a fog chase, a rooftop hang and an eight-step market chain in one film are unreadable in thumbnails. One legible chain beats three busy ones.

Adapting any topic: find the object that escapes, the person it should end up with, one mechanical chain that can happen in a single wide frame, and a gesture that can be repeated at the end with a new meaning.

Shots

Beat Camera
Opening Curtains part on a dark screen while the projector spins up; the title card; an iris opens on the lifted loaf.
Setup A locked full shot: the whole premise (pride → sill → the roll → double take) plays in one frame.
Chase A tracking shot: the town scrolls at 0.8, lamp posts sweep past in front at 1.35, the hero and the object stay in frame.
The chain gag (signature) A locked-off extreme wide, no cut: see-saw → melon out of the top of frame → 2.5 s of waiting → constable walks in and stops on his mark → melon lands.
Breath A locked full shot, then true silence.
Tender A slow push-in that is motivated: the camera comes down to the child's level as the hero kneels (zoom 1.6 → 2.1).
Ending The iris closes on the child, stops, she winks at the audience, it shuts; "The End"; the curtains close.

Score structure

Palette & props

Titles, intertitles & end card

Pitfalls

Build notes

styles/silent-film/demo/
  timeline.js    cue-sheet sections (bpm, beats) + named hits — the single source of truth → tools/dump_timeline.mjs → timeline.json
  engine/        ink.js (tonal forms, ink line, wash, hatch, drawShape) · redraw.js (Redraw: frame → ink drawing, WebGL2)
                 film.js (FilmPost print + damage) · cards.js (intertitles, deco borders, iris, theatre) · figure.js + heads.js (2.5D rig)
  chars.js poses.js sets.js   the cast, key poses, town set pieces
  shots.js       one function per section: bakery · chase · market · roll · sil · tender · irisShot (tonal paintings)
  film.js        assembly: shot → Redraw → iris → FilmPost → gate + curtains; per-section look (damage, sepia, sampling fps, crank)
  cardspecs.js   the intertitles        frames.js   model sheet, card sheet, redraw sample, one-colour example
  sheets.js      rig check scenes, not in the film: ?scene=sheets.test (turnaround + run pose) · heads · redrawFig · armTest · loafTest
  stage.js       the small scene → FilmPost → gate helper that sheets.js draws through
  music/score.py  photoplay score from timeline.json      mix.py   score + projector
  tools/         cuecheck.py · subs.py · dump_timeline.mjs
  1. Write the cue sheet (timeline.js) first: sections, tempi, and every gag beat as a named hit. The score is generated from the same file, so every hit lands (0 ms).
  2. Paint each shot in greys (?scene= stills for review), then look at it through the whole pipeline — the Redraw pass changes what reads.
  3. sh demo/build.sh: timeline → score → cue check → mix → srt → render (≈ 55 s with 2 workers for 1301 frames) → mux (−14 LUFS) → grain re-encode → stills. About 2.5 minutes.
  4. Review 1 frame per second, then frame strips for every gag at 0.1–0.2 s.

Engine reference

Four modules, usable on their own:

Module Main API
engine/ink.js (tonal drawing) setFrame(t, {boil}) · form(g, pts, {v, line, grad, shade, cyl}) (a filled, shaded, outlined shape in grey value v) · stroke(g, pts, w) · drawShape(g, shape, style) (any path in this style; style.color draws it in a hue for the colour mask) · sparkPts(cx, cy, r, tail) · catmull, ellipsePts, rectPts, mottle, shade, grey(v)
engine/redraw.js new Redraw(w, h).render(canvasOrImageOrVideo, {frame, lines, tone, hatch, levels, gap, sigma, p, eps, phi, bilateral, src}) → canvas. Turns the tonal painting or a real video frame into the ink drawing (redraw mode: bilateral: 3, p: 34, eps: .24 for photos).
engine/film.js new FilmPost(w, h).render(src, {frame, strength, sepia, crank, grain, flicker, weave, scratches, burn, vignette, halation, exposure, jump}, colorMask?) → canvas · damage(g, x, y, w, h, frame, strength) (dust, hair, blotch). Stack it on any 2D canvas.
engine/cards.js intertitle(g, W, H, {level, lines:[{text, font, size, italic, weight, spacing}], shake}, t) · decoBorder(g, x, y, w, h, level) · iris(g, W, H, cx, cy, r) · theatre(g, W, H, gate, spill, curtainOpen)
engine/figure.js + heads.js drawFigure(g, character, {x, ground, scale, yaw, pose, t, props}) · P0(), mixPose, runPose(ph), walkPose(ph), runBob · characters in chars.js, poses in poses.js

The one colour. The print is monochrome; to keep one element in its own hue, draw it into a separate mask canvas (colour + alpha) and pass that to FilmPost.render: it keeps the hue where the mask has alpha, but still gets the grain, flicker and vignette.

import { drawShape, sparkPts, grey, setFrame } from './engine/ink.js';
import { Redraw } from './engine/redraw.js';
import { FilmPost, damage } from './engine/film.js';

setFrame(t);
const W = 1440, H = 1080, mk = () => Object.assign(document.createElement('canvas'), { width: W, height: H });
const frame = mk(), f = frame.getContext('2d'), mask = mk(), m = mask.getContext('2d');
f.fillStyle = grey(.9); f.fillRect(0, 0, W, H);
const sp = sparkPts(720, 460, 220, 260);                 // a four-point light with a short tail
drawShape(f, sp.star, { v: .6, shade: 12 });            // tonal version (gets inked + hatched)
drawShape(f, { pts: sp.tail, closed: false }, { line: 10 });
drawShape(m, sp.star, { color: '#D97757' });            // the one colour
const inked = new Redraw(W, H).render(frame, { frame: Math.round(t * 24) });
const print = new FilmPost(W, H).render(inked, { frame: Math.round(t * 24), strength: .6, sepia: .15 }, mask);
g.drawImage(print, 240, 0); damage(g, 240, 0, W, H, Math.round(t * 24), .6);

(?scene=frames.oneColour renders this example.)