# REPORT — 01-frame-by-frame · 「手作」

## Concept (two sentences)

A hand-drawn match is struck on a matchbox and the flame that catches turns out to be a
little spirit: it anticipates, leaps out of the match, dances for four bars, then
detonates into pure cel animation — smoke puffs, sparks, speed lines, star bursts — and
the debris is sucked back in to re-form the hand-lettered title **手作 / MADE BY HAND**.
The whole film is drawn "on twos" like real cel animation (with one-second smears for the
fast actions and a three-frame boil on the held end card), so the linework breathes the
way Giant Ant / Buck hand-drawn work does, over a warm paper ground.

## Beat sheet (timecodes)

| t (s) | beat | craft |
|---|---|---|
| 0.00–0.30 | **打火** the match is dragged across the striker strip | on 1s; 3-frame drag ghost + a 2.6x smear frame |
| 0.30 | **咬住** strike: hit flash, 16 sparks fly up-right, paper recoils | hit frame + camera shake |
| 0.44 | **点燃** the head catches; a fwoosh of flame grows out of the match | 9 sparks, first sparkles |
| 1.30–1.58 | **预备** the spirit squashes (sy 0.56) and leans back | anticipation, held-breath in the score |
| 1.58–1.96 | **跃出** parabolic leap right; body stretched up to 2.25x | on 1s, smear |
| 1.96 | **落地** squash (sx 1.38) + dust puff + 7 hatch strokes on the floor | elasticSettle overshoot |
| 2.10–4.30 | **跳舞** five poses on twos, hop-lift on every beat | 2s drawings, 120 bpm walking bass + xylophone |
| ~3.35 | **翻面** quick cel turnaround (2–3 frames) | squash & stretch through the turn |
| 4.30–5.36 | **屏息** the spirit sinks, closes its eyes, the music thins out | busy -> calm contrast (-25 dB section) |
| 5.36–6.20 | **蓄力** it grows 196->250 px, vibrates, sparks orbit, roll accelerates | accelerando |
| 6.20–7.10 | **HERO** it bursts: jagged cel explosion star, 8 scalloped smoke clouds, 32+20 sparks, 16+12 speed lines, 4/5-point star bursts | the hero moment at 62 % of runtime |
| 7.12–7.34 | **回收** smoke converges into the centre, streaks drag inward | transition carried by the FX, no crossfade |
| 7.34 | **标题落定** 手作 lands with a 0.05 overshoot wobble, the flourish draws on | title boil |
| 8.30–10.00 | **定格** end card held with living micro-motion: boil on threes, mascot waving, sparkles | 3-frame boil, poster frame |

## How each signature feature is realised

| signature feature | where / how |
|---|---|
| **Line boil** — lines redrawn with slight variation every few frames | `makeBoil(seed)` precomputes two spatially-coherent value-noise fields per part; `shotIdx(t)` selects one of **4 drawings** and holds it for 2 frames (12 fps). Every path — box, match, flame, arms, smoke, doodles, flourishes, the end-card underline — has its own seed, so nothing boils in sync. Amp 1.2–3.2 px, scaled with the character's size. |
| **一拍二 / 一拍三 pacing** | `HOLD = 2` so the drawings advance at 12 fps for the whole film. The dance *poses* are additionally quantised (`tq = floor(t*12)/12`), so the motion itself is on twos. Fast actions (strike, leap, turnaround) are computed from the raw `t` — on ones. The held title uses `floor(t*24/3)%4` — on threes. |
| **Smear / 拖影帧** | Strike: the match body is scaled 1.6–2.6x along the drag on the odd frame, plus three alpha-decaying ghost copies of the whole match. Leap: the body is stretched up to 2.25x (sy) and thinned to 0.5 (sx) at the arc's apex, on ones. Turnaround: sx passes 1 -> 0.26 -> -1. |
| **Cel FX 手绘烟火** | All pure geometry: `boomStar` (jagged cream/yellow/cream star), `B.cloudPts` (scalloped single-silhouette cel clouds), `sparkField` (ballistic tapered strokes with 4-point glints), `speedLines` (contained tapered rays), `starBurst` (4/5-point stars), `suckLines` (inward streaks). Nothing is a bitmap; everything boils. |
| **纸纹底 (multiply 10–15 %)** | `buildPaper()` bakes the warm ground at load (fine value-noise tooth, 620 drawn paper fibres, 300 flecks, edge falloff). `buildGrain()` makes a half-res multiply layer of grey 224–255; drawn every frame at `globalAlpha 0.7` with `multiply`, landing the darkening at roughly 10–14 %. A soft radial vignette (20 % at the corners) finishes it. |
| **Hand-drawn brush** | `brush.mjs` is a real variable-width renderer: centreline -> dense Catmull-Rom samples -> per-sample normals -> a closed left/right outline polygon offset by a width profile. Used for the arms, match stick, glyph accents, speed lines and the sun's ink sweep. Shape outlines use a stroked path with round joins for crispness. |
| **Off-register hand colouring** | `soft()` fills each shape offset 2–4 px from its ink outline, so the tomato / yellow / cream sit slightly off the line like a mis-printed cel. |

## Technical route

HTML Canvas2D, no WebGL, no vendored libraries. `index.html` loads `parts/sketch.mjs`,
which exposes `window.renderAt(t)` and `window.__ready`. Every frame is a **pure function
of `t`** — no per-call accumulation, no `Math.random()` (only `mulberry32`), no
`Date.now()`, no CSS transition, no rAF state.

Static work is precomputed once at load: the paper ground, the grain layer and — the
interesting one — **four full-colour "drawings" of the title lockup**. Each title element
is rendered from system type into a mask, dilated by a *ragged*, seeded radius (so the
outline reads as a brush edge, not a stroke), tinted, and composited with a per-element
jitter; the four results are the title's boil drawings. That gets real hand-lettered ink
boiling out of an ordinary system font without vendoring any typeface.

Per-frame cost is far under the 250 ms budget: a frame's `renderAt` is a few ms of polygon
work; the render loop's 0.5 s/frame is the CDP screenshot round-trip, not the draw.

Sound is one cue sheet shared with the picture (written at the top of *both*
`parts/sketch.mjs` and `audio.py`). `audio.py` synthesises everything from oscillators and
filtered noise: pizzicato (triangle + a bright partial through a low-pass), xylophone
(fast-decaying partials at 1 / 3.06 / 5.62), woodblock klicks, a scraped-striker texture,
a riser, a snare roll that accelerates into the burst, a poof, sparkle bells and a
Schroeder-ish plate reverb. Every visual hit lands on an audio onset at the same time.

## Check numbers

| spec | measured |
|---|---|
| duration | **10.000 s** (240 frames @ 24 fps) |
| resolution | **1920 x 1080**, yuv420p, H.264 MP4 |
| fps | **24** |
| audio | stereo AAC **48 kHz** |
| integrated loudness | **-13.7 LUFS** (target -14 +/-2) |
| true peak | **-1.5 dBTP** (limit -1.0) |
| LRA | 3.6 LU |
| black frames | **0** |
| frozen frames | **0** |
| console errors / 404s | **0** |
| verification line | no PROBLEMS |

## Honest top-3 weaknesses

1. **Hands and arm acting.** The arms are tapered ink strokes ending in a plain tomato
   ball; there are no wrists, no finger detail, and during the 0.4 s birth the arms barely
   perform. A real hand-drawn character would get more secondary action in the arms and a
   stronger follow-through on the landing.
2. **The explosion's smoke reads slightly hard.** Eight scalloped clouds converge with a
   single outline weight; where two overlap, the doubled ink line is a little graphic
   rather than airy. Real cel smoke would carry a couple of interior detail lines and a
   lighter outline on the later drawings. There is also more negative space than I would
   like on the left during the back half of the dance.
3. **The score is synthesiser-clean.** The pizzicato and xylophone are oscillator models,
   so they lack the velocity variation and room of real playing; the mix is essentially
   one layer plus reverb rather than stemmed dynamics. It is musical and locked to the
   frame, but it would not fool anyone into thinking a trio played it.

*(A fourth, purely technical note: the title drawings are built at half resolution and
drawn 2x up, so a handful of anti-aliased edge pixels can differ by +/-1/255 between runs
in Chrome. The frames are visually identical; there is no logic-level non-determinism —
`renderAt(t)` is a pure function and the overwhelming majority of frames re-render
byte-identically.)*

## Self-assessment (1–10)

| axis | score | note |
|---|---|---|
| style fidelity | **9** | boil on 4 drawings, on-2s timing, smear frames, cel FX, paper multiply, variable-width ink — an animator names the style instantly |
| concept wow | **8** | the match -> spirit -> explosion -> lettering arc is clean and charming; the hero burst is the strongest single image |
| motion craft | **8** | anticipation / squash / stretch / arcs / overshoot are all present and beats lock to the music; the arms and the two-cloud overlaps are where it slips |
| design & typography | **9** | 4-colour palette held throughout, warm paper, a real lockup with hierarchy and correct CJK punctuation (~5 % margins, no edge clipping) |
| finish & texture | **8** | paper tooth, off-register fills, vignette, no banding, no tofu; some smoke outlines are harder than ideal |
| sound & sync | **8** | genre-correct jazz bed + cartoon SFX, every hit on the frame, mastered to spec; synthesiser timbre is the ceiling |
| technical | **9** | pure function of t, precomputed layers, 0 console errors, 0 black/frozen frames, exact 10.000 s, spec-compliant audio |

## Harness / shared-code changes

* **`shared/audio.py` — `pad()` ignored its `gain` argument.** It returned the raw
  low-passed saw stack (~5x too loud), which made the calm section the loudest part of the
  mix. Fixed to `return [v * gain for v in lowpass(t, cutoff)]`. After the fix the mix
  envelope reads correctly (dance ~ -16 dB, calm ~ -26 dB, burst peak ~ -9 dB).
* No bug found in `harness/`. One behaviour worth knowing: `buildAudio()` only runs the
  sound script when `out/<slug>/audio.wav` is missing, so during iteration the wav must be
  deleted to pick up edits.
