Paper-cut Lightbox — our demo

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

Demo: The Last Night Train 《末班夜車》 (51.0 s, 1920×1080, 30 fps) · paper-lantern.mp4 · source in demo/ · engine: three.js 0.170 (WebGL, SSAA 2×, VSM soft shadows, custom DoF + bloom compositor), Canvas2D-drawn paper layers, headless Chrome frame capture.

An old night train makes its last run. At a snowy mountain halt its windows light up car by car and it pulls away over a curved viaduct; it runs alongside a pine forest past three small halts; it goes into a long tunnel and the picture blinks to black; it comes out in a river valley where the town lights its windows as it passes; it slows along the sea cliffs; at the seaside terminus its windows go out one by one, the last one last, and the station lamp stays on. No figures: the story is told by the train, its lit windows, the stations and the light. English narration (Kokoro bm_george) with Chinese and English subtitles. The line and its stations are fiction (demo/FACTS.md).

Story & structure

Hook: a dark train at a lit halt; its windows come on one car at a time, engine first, each with a rising glockenspiel note. Turn: the tunnel, a 0.27 s blink of true dark and silence, and on the other side the snow is gone. Landing: the same windows go out one by one at the terminus, the falling notes mirror the rising ones, and the station lamp is the last light in the frame.

Every section has its own colour ladder (changing ladder = another place) and its own camera move:

Section Ladder Camera
The halt and the viaduct snow blue #060b22 sky → #7385b6 peaks → #1b2650 viaduct → #080d24 front slow push, then a fast pull-out with the departing engine, then a lateral move along the valley
The forest deeper blue, a pale snow slope #44588e behind the line travelling alongside at the train's speed, rising; foreground pines rush past
The tunnel cold slate #26335f cliff push toward the portal, fade, a 0.27 s black
The river teal #04131c sky → #5f8c94 hills → #2c5260 town hard cut out of black on a small flare, lateral move with the train
The sea cliffs indigo #070a26 → #7272b0 far waves crane down past thin clouds to the train on the cliff curve
The terminus pre-dawn violet, sea #6a6aaa, platform #151a40 wide on the arrival, close along the carriages as the windows go out, a beat on the last window, pull back to the lamp

Native moves spent: the lamp turns on (car by car), depth as distance (the viaduct, the forest strip, the river), travelling alongside, locked frame with only light changing (the terminus), silence (the tunnel blink), and a paper ticket that drops on a thread for the end card.

Shots

Film time; dissolves are 0.6 s.

# Time Shot Camera / transition
S1 0.0–13.8 PINE HALT: a house on a snowy rise with two lit windows, the platform lamp clicks on (0.4 s); windows light car by car from 1.5 s, one every 0.34 s; headlight 5.25 s, horn 5.5 s, departure 5.9 s; at 10 s the train is on the curved arched viaduct over the snowy valley push → pull-out following the engine → lateral
S2 13.8–22.0 the forest: the line rises and falls; a pale snow slope with a lit house at each of three halts (ELM, STONE, MILL HALT), lamp posts, name boards below the line dissolve; travelling alongside, rising
S3 22.0–25.75 a stone arch portal in a snowy cliff; the carriages go in one by one, the headlight goes out; black 25.5–25.77 s dissolve; push in; fade to black
S4 25.75–35.4 the river valley: a low arched bridge, the town on the hill behind it lights its windows after the train passes; warm paper flakes on the water; the moon hard cut, small flare; lateral
S5 35.4–40.2 the sea cliffs: thin clouds, a far headland with one light, three seas (far waves small, near waves big), the train on the cliff curve, a dark rock in front dissolve; crane down
S6 40.2–51.0 SEA END: canopy with a scalloped valance, posts, railing, name board; a sea wall takes the train's shadow; the train stops at 41.0 s; windows go out from 42.0 s, the last one at 45.75 s; the lamp lays a warm pool on the platform; the ticket drops at 47.5 s and swings to rest dissolve; wide → close lateral → pull back

Score structure

Palette & props

Titles, subtitles & end card

Pitfalls

Build

LIB=<library root> sh styles/paper-lantern/demo/build.sh          # fonts, voice, timeline, picture, mix, master, poster, style frame
LIB=<library root> sh styles/paper-lantern/demo/build.sh 40 51    # only a span of the film → demo/out/cut_40-51.mp4

On a fresh clone, first: sh plugin/skills/lemo-opuscar/scripts/setup.sh deps voice, sh tools/fetch.sh instruments vsco2ce, sh tools/fetch.sh instruments vcsl, sh tools/fetch.sh instruments salamander. demo/tools/fonts.py downloads the two OFL fonts and subsets Noto Serif SC to the film's characters. The narration WAVs are regenerated from demo/voices/lines.json when missing, then checked with asr_check.py.

demo/tools/timeline.py is the one timeline: narration starts, subtitle cues, every action's time (HIT: the lamp, each window, the departure, the tunnel, the stop, each window going out, the card) and the shot list. The page (timeline.json) and mix.py read it; it writes paper-lantern.srt. Query options: ?from=a&to=b renders a span, ?nosub=1 hides the subtitles (for stills). About 40 s for 1 530 frames with 2 workers on an M-series Mac.

Reading-time check (core/render/readcheck.mjs): the page exposes no window.TEXTS, so the tool reports that it cannot check. The paper text on screen (the name boards, the ticket) stays in frame for at least 4 s each; subtitles are timed by the .srt (each ≥ 1.8 s and ≥ its line + 0.6 s).

Engine reference

Page contract (src/main.js): window.READY, window.DUR, window.render(t) (deterministic). Query ?ssaa=N (default 2).

Module Key API Notes
paper.js sheet({ U, w, h, draw, glowDraw?, glow, glowCol, trans, z, x, y, ax, ay, shadow, recv, finish, ppm }) One paper layer; ax/ay = pivot. Per sheet: uGlowLit (switch the glow), uGR (glow only inside a radius; it lights the river town behind the train), uGY, uClip. Shared: uLight, uFloor, uBeamO/uBeamD (the headlight wedge).
skyPanel, moon, text, burst, finish, paint, PPM
stage.js stage({ light, lightR, lightCol, key, keyCol, keyPos, keyTarget, amb, floor }), aim(cam, [px,py,pz, tx,ty,tz], t, { hand }), dispose() Scene, top spot (VSM), backlight uniforms, hand-held drift.
post.js Pipe.shot(scene, cam, { focus, aper, maxCoc, bloom }, target), Pipe.final({ mixB, expo, vig, sat, contrast, lift, gain, fade }) Grade keys fall back to defaults every frame.
world.js peaks(x, o), firs(x, o), car(x, g, L, H, loco), tint, cut; world(E) = shot S1 Motifs in metre space; sheets are cut in neutral grey and coloured by tint().
shots.js s2…s6(E); helpers train(S, U, z), lampPost, haltSign, glowDisc, band, fir Each shot: { S, update(t), post(), grade() }, t in film seconds.

Minimal shot (a train crossing a lit valley):

import { sheet, skyPanel } from './paper.js';
import { stage, aim } from './stage.js';
import { tint, PAPER, peaks } from './world.js';
import { train } from './shots.js';
export function crossing(E) {
  const S = stage({ light: [0, .03, -.1], lightR: .12, lightCol: '#b8c8f0', key: 1, amb: .2, floor: .3 });
  const { U } = S;
  S.add(skyPanel({ w: 1, h: .5, z: -.142, stops: [[0, '#060b22'], [1, '#283a6a']], stars: 200 }));
  const far = S.add(sheet({ U, w: .74, h: .36, z: -.126, trans: .85, draw: x => { x.fillStyle = PAPER; peaks(x, { x0: -.37, x1: .37, base: 0, list: [[-.1, .1, .07], [.12, .08, .06]] }); } }));
  tint(far, '#7385b6');
  const tr = train(S, U, -.0785);
  return {
    S,
    update(t) { tr.set(-.2 + .12 * t, () => -.006, 1); aim(S.cam, [0, 0, .43, 0, -.01, -.08], t); },
    post() { return { focus: .51, aper: 10, maxCoc: 10, bloom: { strength: .3, radius: .5, threshold: 1.15 } }; },
    grade() { return {}; },
  };
}