⏳ This skill is pending AI review.

Scores will appear once the review pipeline completes.

version unknown

cartoon-3d-short

@mromidev⭐ 1.9k stars

Make a ~55-70 s vertical cartoon story short in the "2D characters in a colourful 3D world" style (flat bean-head cartoon cards with ink outlines, painted 3D town/world with black ink lines, saturated sky, depth-of-field backgrounds, gliding cameras, voice narration, synthesized SFX + march-style music), rendered with three.js inside Remotion. Proven on "The Bridge That Fell for a Song" (2026-10-31). Use when the user asks for another video "like the bridge video", "like the soldiers/bridge short", "in that cartoon 3D style", a history/science story short with cartoon characters in 3D, or says "use the cartoon short skill".

Choose how to use this skill

You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.

1. Native installer

This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.

Do not guess an installer command or replace an existing version without reviewing the diff.

2. Complete package recommended

Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.

No complete ProSkills package is published for this listing yet.

3. Prompt-only

Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.

Need only the instruction file?

Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.

No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.

—/10

// RATINGS

⭐GitHub Stars
⭐⭐⭐⭐⭐ 1.9k on GitHubGitHub ↗

Very popular

🟢ProSkills Score
—
📍

Not yet listed on ClawHub or SkillsMP

// README

Claude Animated Shorts

The free Claude skill from the video "How to Make Viral Animated Shorts Using Claude (Full Workflow)".

It makes 55-70 s vertical animated story shorts: 2D characters in a colourful 3D world, with ink outlines, a painted town, lens blur, gliding cameras, a march score and sound effects. Everything is rendered with three.js inside Remotion on your own PC, with no AI video generator.

Proven on The Bridge That Fell for a Song, a 67 s short about the 1831 Broughton bridge.

What's inside

PathWhat it is
SKILL.mdThe skill: the full pipeline, every rule, every fix, and the final look values
templates/bridge/The full source of the bridge short: world, characters, ink, lens blur, grade, story/camera
templates/audio/The synthesized sound: SFX, march music, the mix with ducking and loudness
templates/scripts/Stills, side-by-side sheets, shot merge and frame-by-frame QA helpers
references/STYLE_BIBLE.mdThe script and visual formula measured from 20 reference shorts
references/reports/Seven measured reports: motion, depth, textures, characters, lighting, outlines, grade
references/frames/38 full-size reference frames (540x960) to compare your frames against
references/sheets/20 contact sheets, one per reference short, 60 frames each (1,200 frames)

Install

  1. Copy this folder to ~/.claude/skills/cartoon-3d-short/ (clone it there, or download the ZIP).
  2. You need Remotion with @remotion/three, plus Node, Python 3 and ffmpeg.
  3. In Claude Code, say: "Use the cartoon short skill and make a short about …".

Claude then follows SKILL.md: topic and facts, then script, then voice, then the shot plan in story.ts, then stills, then renders. Agents check your frames against the reports and the frames, and you get the final 2K render and sound mix.

Notes

  • Set REMOTION_TMP to a folder on a big drive. Every Remotion bundle copies public/ (~1.3 GB) into the temp folder.
  • Story, characters and voice should always be your own. Use the references for the look, not the content.

License

The code and docs are MIT (see LICENSE). The reference frames are included for style study only.

// HOW IT'S BUILT

// FILES

98 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.

  • SKILL.md15 KBc4564b54b081upstream ↗
    ---
    name: cartoon-3d-short
    description: Make a ~55-70 s vertical cartoon story short in the "2D characters in a colourful 3D world" style (flat bean-head cartoon cards with ink outlines, painted 3D town/world with black ink lines, saturated sky, depth-of-field backgrounds, gliding cameras, voice narration, synthesized SFX + march-style music), rendered with three.js inside Remotion. Proven on "The Bridge That Fell for a Song" (2026-10-31). Use when the user asks for another video "like the bridge video", "like the soldiers/bridge short", "in that cartoon 3D style", a history/science story short with cartoon characters in 3D, or says "use the cartoon short skill".
    ---
    
    # Cartoon 3D story short (three.js + Remotion)
    
    Proven example: **The Bridge That Fell for a Song**. It is an original 67 s short about the 1831 Broughton bridge and 74 soldiers marching in step.
    - Code: `src/videos/bridge/` of your Remotion project (composition `BridgeShort`, 1080x1920, 30 fps).
    - Script, voice and sound: `~/bridge-short/`.
    - Style research: `references/`.
    
    The style reference is a channel studied privately (`references/`: STYLE_BIBLE.md, reports/*.md, frames/).
    - Those videos are private study material. Never upload, publish or send them.
    - Never copy their scripts, characters or shots. Story, characters and voice are always OUR OWN.
    - Never say the channel's name in a voice-over or description.
    
    User rules that apply:
    - No captions or labels in the video.
    - English only.
    - Voice: one steady tone.
    - Jokes are researched real facts.
    - Standard mix: music +3 dB, SFX -6 dB, voice unchanged.
    - Final export 2K vertical: 1440x2560, `--scale 1.3333`.
    - Opus max verifies every check.
    - Save after every step.
    - "Just do it" once the script is approved.
    
    ## Pipeline
    1. **Topic and facts.** Pick one surprising true story with a physical cause and effect (it should be visual), plus a simple everyday analogy that explains it.
       - Write the facts with their source at the top of `SCRIPT.md`.
    2. **Script** (~3.7 words/s, 55-70 s). Beat formula from the bridge short:
       - **Hook:** the absurd fact in one line ("Seventy-four soldiers knocked down an iron bridge just by walking across it.").
       - **Setting:** year, place, pride.
       - **Action:** escalation over three or four beats, each a little bigger.
       - **Snap:** the moment it breaks.
       - **Deadpan double line:** "Luckily ... / Unluckily ...".
       - **Explanation:** one physics idea through an everyday analogy (the kid on a swing).
       - **Consequence:** the rule it created.
       - **Callback twist:** the ending.
       - Never describe on screen what the viewer already sees.
    3. **Voice.** Record or generate `voice.wav`, then make its word timings `words.json` (any speech-to-text with word times).
       - Put them in the project folder, e.g. `~/bridge-short/voice/`.
       - Hand-fix the word timings. Everything is timed to words.json.
    4. **Shot plan** in `story.ts`: copy `templates/bridge/` (the final bridge source, same as `src/videos/bridge/` of your Remotion project) to `src/videos/<name>/`, register a new composition next to `BridgeShort` in `src/Root.tsx`, and rename it.
       - `SHOTS`: id, a/b frames, light day/golden, crowd mode, `tau` story time.
       - `XFADE`: crossfades at place/time changes.
       - `FLASH_AT`: a white flash after the hook.
       - `CAMS`: per-shot keys `{f, dur?, cut?, s: f => {pos, look, fov, roll}}`.
       - `KICKS`: impact flip-shakes.
       - `DOF`/`dofAt`: lens blur per beat.
       - `FOREGROUND`: near framing props.
       - Every visual reaction lands 0-3 frames before its word.
    5. **World.**
       - `lights.ts`: LIGHTS plus the LOOK sky/river/cloud palette per light.
       - `shade.ts`: two-tone shading and `untone()`.
       - `build.ts`: town, houses and Kenney CC0 kit props, recoloured, with sight-line culling of anything blocking a camera.
       - `paint.ts`: HD Canvas2D painted textures with seams painted in as dark tints.
       - `world.ts`: sky shader, fog = horizon colour, river, per-frame posing, sun frozen per shot and camera-relative.
    6. **Characters.**
       - `toon.ts`: the Canvas2D `Pen` drawing API, 600x1000 design canvas, soles at y = 975, bean heads, stick limbs, plum variable-width outline, face library.
       - `cast.ts`: camera-facing billboards, crowds, effect cards, blinks, walk cycles. All billboards go on the `NOINK` layer.
       - `cardshade.ts`: cards lit by the scene light, no emissive.
    7. **Post** (`Bridge.tsx` + `ink.ts` + `lensblur.ts` + `grade.ts`).
       - Composer order: ink → lens pre → lens main → bloom → NEUTRAL tone map → grade (MSAA 4).
       - Every pixel parameter scales with output height / 1920.
    8. **Sound** (`~/bridge-short/audio/`: timeline.py, sfx_gen.py, sfx.py, music.py, instruments.py, dsp.py, mix.py).
       - Event table → synthesized SFX plus an original score ducked under the voice.
       - Output: mix.wav (48 kHz, exact length), stems and cues.md.
       - Lock the picture's time constants before the sound is made. Any later timing change means re-running the sound.
    9. **Look checks.** Run `node scripts/bridge-stills.mjs out/<name>/x_ 100,400 0.5`.
       - Set `BRIDGE_ENTRY=src/sandboxes/<n>/index.tsx` to render a sandbox copy.
       - Make side-by-sides against reference frames with `python scripts/sbs_sheet.py out.jpg --ours ... --ref ...`.
       - Send them to an Opus max verifier: 1-3 images, a ≤200-word verdict.
    10. **Final render.**
        - `npx remotion render src/index.ts <Comp> out/<name>/video.mp4 --codec h264 --crf 16 --scale 1.3333`
        - Mux with `-map 0:v:0 -map 1:a:0` (Remotion writes a silent audio track).
        - QA: `python scripts/short_qa.py final.mp4 out/<name>/qa shots.json` checks every frame for black, white, frozen and pop frames, plus loudness and sheets.
        - Fix anything it finds, then run a deep Opus audit.
    11. **Deliver.** SendUserFile the final mp4, update memory, write notes.
    
    ## Final look values (bridge after 4 verifier rounds; all in `templates/bridge/`)
    Start every new short from `templates/bridge/` (copy to `src/videos/<name>/`): the look lives in these values, the
    story lives in story.ts / build.ts layout / cast.ts roles. Opus max scores at the end: colour day 7, golden 5-6,
    lighting 5-6, ink 6, textures 5-6, characters 7, depth 6-7, composition 6-7 (from 5/3/4/5/4/7/4/6 at the start).
    - **Light presets** (`lights.ts`, sun camera-relative and frozen per shot):
      - day: az 75 / el 48, lit [1.58,1.5,1.33], shade [.30,.29,.36], lift [.05,.06,.11] (cool tint ONLY in shade), sky
        top #3e7fd2 → horizon #b8d0ec, fog = horizon, 25→300 m, uSat 1.08, ~15 % blue haze past 60 m.
      - golden: az 6 / el 7 (backlit low sun), key toward #FFB36B ×1.5, violet-blue shade (facade ≈ #444380), sky
        #ffc47c → #8a6f8a, peach fog only past 50 m, sun disc #ffcf9e + bloom, contact blobs + real card shadows.
    - **Albedos** (`paint.ts`/`build.ts`): muted brick / cream / ochre / terracotta houses with plinth, cornice, sills,
      hoods, door step, chimney + pot, heights 7 / 8.4 / 9.8 m; warm grey/beige stone (saturation −35 % from "storybook");
      ochre wood deck C* ~51-58 with near-black seams; iron dark and desaturated. Never lemon, never lavender lit faces.
    - **Ink** (`ink.ts`): #030303, 5.1 px near (6 m) → 3.3 px far (90 m) at 1080x1920, creases ×0.8 from 35°, depth
      jumps from 1 %, fog tint 0.3 only far; character cards on layer `NOINK` (they carry their own plum ink).
    - **Lens** (`lensblur.ts` + `dofAt` in story.ts): cInf px at 1080: hook/river CU 15-16, close-ups 12-14, MS 10-12,
      park 7, golden sway 5, wides/aerials 0. Focus on the eyes of the front row.
    - **Post** (`Bridge.tsx`): ink → lens pre → lens main → bloom (thr 1.0, int .5, 6 levels, radius .6) → NEUTRAL tone
      map → grade; MSAA 4. Film grain exists but is OFF (reference measures no grain; it quadruples the file size).
    - **Characters** (`toon.ts`/`cast.ts`/`cardshade.ts`): bean heads h/w .94, plum ink #241726, eyes .26 W apart, no blush,
      14 expressions; adults 3.5 head-heights; walk bob 5 %, lean 1-7°, planted 0.8 m stride; blinks 0.5-2.3 s; tight
      contact shadow ~.5 under the feet + sun-direction tail by day; golden rim inner only (never an offset outer copy).
    - **Camera** (`story.ts`): cuts on big jumps, smootherstep glides 18-40 f, every hold creeps (no static hold ≥4 f),
      flip kicks decaying over 7 f on impacts, 4-4.6° rolls, foreground props (`FOREGROUND`) in ~1/3 of shots.
    
    ## Look targets (measured from the reference; see references/reports/)
    - **Ink:**
      - World lines: about 3 px at 1080 wide, near-black #0b0a0c, nearly constant with distance. Silhouettes, depth jumps and normal creases only. No line on wall/floor joins or the horizon. Character cards are never outlined by the world ink.
      - Character ink: dark plum #201628-#2f1d28, thicker on the bottom/shadow side.
    - **Colour:**
      - Day median luma ~.57. Non-ink p5 ~.23 (no crushed blacks). Lit chroma C* p90 ~55. Day sky top ~#5e93d9, horizon 1.28x brighter.
      - Golden: peach horizon #ffd08a, violet-blue shadows (never brown), backlit sun.
    - **Depth:**
      - DOF on about 1/3 of screen time (CU, MS, inserts), cInf 10-16 px at 1080, focus on the eyes. Establishing and wide shots stay sharp.
      - Fog = horizon colour, 25→420 m day, 20→360 m golden.
      - Bloom only on HDR emissives: threshold 1.0.
    - **Camera:**
      - Hard cuts on big jumps. Everything else is a smootherstep glide of 18-40 f, peak ≤ 25 px/f at 720p.
      - Every hold pushes in 0.15-0.35 %/f, with 0.6 %/f on punch words.
      - Rolls of 4-5°.
      - No hand-held float. Impact shakes flip sign every frame and decay over 7 f.
    - **Characters:**
      - About 3.5 head-heights tall, legs 1.4 HH.
      - Stick limbs about 0.055 of head width, ink-dot hands.
      - Bean head (h/w ~0.93, narrower chin, pointier crown).
      - No blush or eye highlights. Faces at brightness ~.93.
      - Walk bob 3-7 %, lean 1-7°, planted feet.
      - Blinks every 0.5-2.3 s, lasting 4 f.
    
    ## Look-match loop (how the bridge look was reached; reuse for any "make it look like X" pass)
    1. **Measure the reference first.** One analysis agent per topic (motion, depth, textures, characters, lighting, outlines, grade), each writing numbers, not adjectives, to `references/reports/<topic>.md`. Shared brief: `compare/CONTEXT.md`. Reports are the source of truth for every agent after.
    2. **Sandboxes, one owner per file.** Copy the project folder to `src/videos/<name>_{cam,world,mat,char,post}` with entries `src/sandboxes/<n>/index.tsx` (same `index.tsx` as main, only the import path changed). Ownership used for the bridge:
       - cam: story.ts (camera keys, cuts, kicks, DOF table, foreground props)
       - world: world.ts, lights.ts, shade.ts, grade.ts (sun, sky, fog, river, colour, grade)
       - mat: paint.ts, build.ts (painted textures, buildings, props)
       - char: toon.ts, cast.ts, cardshade.ts (character drawing, posing, card lighting, fx cards)
       - post: ink.ts, Bridge.tsx, lensblur.ts (outline pass, composer, lens blur, bloom)
       Agents only edit their own files in their own sandbox and render stills with `BRIDGE_ENTRY=src/sandboxes/<n>/index.tsx node scripts/bridge-stills.mjs out/<name>/<n>/x_ <frames> 0.5`.
    3. **Merge** with `python scripts/bridge_merge.py [cam,world,mat,char,post]` (backs main up to `backup/bridge_<time>/`, copies owned files only). Then `npx tsc --noEmit` (filter to the project folder), then **re-sync every sandbox from main** (`cp -f src/videos/<name>/* src/videos/<name>_<n>/`) before the next round.
    4. **Verify** with a fresh Opus max agent: side-by-side sheets (`python scripts/sbs_sheet.py out.jpg --ours a b --ref r1 r2`), score each area out of 10 (colour day / golden, lighting, ink, textures, characters, depth, composition) plus a numbered fix list with frame numbers. Map each fix to the file owner and resume that owner's agent with SendMessage (keeps its context). Bridge scores: round 1 5/3/4/5/4/7/4/6 → round 2 5/4/5/6/4/7/6/6 → round 3 see notes.
    5. Contracts between owners live in exported constants (e.g. `NOINK` layer from ink.ts, `dofAt`/`FOREGROUND` from story.ts, `LIGHTS`/`LOOK` from lights.ts). Changing a contract means telling every owner that reads it.
    
    ## Gotchas
    - Kenney nature-kit materials are teal: recolour them by material name in build.ts.
    - Cameras end up inside houses: the sight-line culler in build.ts/world.ts removes anything blocking any camera ray.
    - Ink in the same merged effect pass as DOF gets overwritten. Ink must be its own pass before the blur.
    - Pixel-sized post parameters must scale with resolution: stills are at 0.5, the final at 1.333. R3F clamps dpr to ≥ 1, so `--scale 0.5` stills still render a 1080x1920 buffer.
    - **Disk:** every Remotion bundle copies `public/` (~1.3 GB) into `the temp folder\remotion-webpack-bundle-*` and never deletes it. 60+ GB filled the system drive during the bridge passes and a render died with ENOSPC. Always render with `export TEMP='<big drive>/remotion-tmp' TMP='<big drive>/remotion-tmp'` (bridge-stills.mjs sets it itself) and ask the user before deleting old bundle folders in the shared the temp folder (auto mode blocks the sweep). <big drive>/remotion-tmp fills too (~500 folders / 110 GB after the 4 bridge rounds): when a project is finished and nothing is rendering, delete its `remotion-webpack-bundle-*` and `puppeteer_dev_chrome_profile-*` folders there.
    - In JS strings write `"<big drive>/remotion-tmp"`, never `"<big drive>\remotion-tmp"` (`\r` becomes a carriage return).
    - Remotion writes a silent audio track: mux with `-map 0:v:0 -map 1:a:0`.
    - Sub-agents can't write report files in this harness: ask them for a ≤150-word final message and save it yourself to `out/<name>/<n>/REPORT.md`.
    - Scratch `.ts` files inside `out/` break `tsc`: name them `.txt`.
    - 2K renders run ~4 fps (≈10 min for 67 s). Stills at 0.5 take 3-8 s each after a ~40 s bundle.
    - Film grain at 2.5 % made the 2K file 910 MB instead of ~250 MB and will turn to mush on YouTube; the reference has none. Keep it off.
    
    ## Readability traps the verifier caught (check every new prop/shot for these)
    - **A swing drawn as one rope that disappears behind the kid reads as a HANGING.** Always two ropes to a wide plank
      with the kid between them (`swingKid` in toon.ts), and a frame whose legs clear the arc (A-frame at each end).
    - Props or flotsam closer than ~2 m to a lens become huge cut-off blobs at the frame edge. When a camera moves, re-check
      every prop near its new position (the river cameras moved into the floating hats).
    - A foreground bush cut by the frame edge reads as a "green ball"; a foreground prop must be recognisable or gone.
    - Flags: red + yellow star/diamond reads as Vietnam's. Use a period-correct flag (St George's cross for England 1831).
    - Painted signs lit by a side sun go grey-blue and unreadable: give the sign face an unlit material.
    - Golden rim light done as an offset outer copy turns every hat into a picture frame: inner rim only, head/torso only.
    - Balcony/railing rails at chest height through characters: put the lens ~2.5 m up so rails cross legs, not torsos.
    - Flat holds read as freezes in QA (frozen span ≥12 f): every hold needs a creeping push-in.
    
  • README.md2.2 KB422d67078b3eupstream ↗
    # Claude Animated Shorts
    
    The free Claude skill from the video **"How to Make Viral Animated Shorts Using Claude (Full Workflow)"**.
    
    It makes 55-70 s vertical animated story shorts: 2D characters in a colourful 3D world, with ink outlines, a painted town, lens blur, gliding cameras, a march score and sound effects. Everything is rendered with three.js inside Remotion on your own PC, with no AI video generator.
    
    Proven on **The Bridge That Fell for a Song**, a 67 s short about the 1831 Broughton bridge.
    
    ## What's inside
    | Path | What it is |
    |---|---|
    | `SKILL.md` | The skill: the full pipeline, every rule, every fix, and the final look values |
    | `templates/bridge/` | The full source of the bridge short: world, characters, ink, lens blur, grade, story/camera |
    | `templates/audio/` | The synthesized sound: SFX, march music, the mix with ducking and loudness |
    | `templates/scripts/` | Stills, side-by-side sheets, shot merge and frame-by-frame QA helpers |
    | `references/STYLE_BIBLE.md` | The script and visual formula measured from 20 reference shorts |
    | `references/reports/` | Seven measured reports: motion, depth, textures, characters, lighting, outlines, grade |
    | `references/frames/` | 38 full-size reference frames (540x960) to compare your frames against |
    | `references/sheets/` | 20 contact sheets, one per reference short, 60 frames each (1,200 frames) |
    
    ## Install
    1. Copy this folder to `~/.claude/skills/cartoon-3d-short/` (clone it there, or download the ZIP).
    2. You need [Remotion](https://www.remotion.dev/) with `@remotion/three`, plus Node, Python 3 and ffmpeg.
    3. In Claude Code, say: *"Use the cartoon short skill and make a short about …"*.
    
    Claude then follows `SKILL.md`: topic and facts, then script, then voice, then the shot plan in `story.ts`, then stills, then renders. Agents check your frames against the reports and the frames, and you get the final 2K render and sound mix.
    
    ## Notes
    - Set `REMOTION_TMP` to a folder on a big drive. Every Remotion bundle copies `public/` (~1.3 GB) into the temp folder.
    - Story, characters and voice should always be your own. Use the references for the look, not the content.
    
    ## License
    The code and docs are MIT (see `LICENSE`). The reference frames are included for style study only.
    
  • references/frames/short01_0060.jpg78 KB3f584ca3b94dupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short01_0180.jpg51 KB1c8cffeb3d09upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short02_0060.jpg37 KB54f12e6ff84fupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short02_0180.jpg50 KBc8692f67e414upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short03_0060.jpg51 KB6ee48766d97fupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short03_0180.jpg46 KBedfa5366548dupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short04_0060.jpg41 KBa4e2ffa590afupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short04_0180.jpg41 KB4d2d7f376501upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short05_0060.jpg51 KB4070ae6f7421upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short05_0180.jpg44 KBb71089800a39upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short06_0060.jpg70 KB54f5b6adcf6cupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short06_0180.jpg112 KB9afa386dfb00upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short07_0060.jpg45 KBa4c96e65f713upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short07_0180.jpg53 KB51469a732426upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short08_0060.jpg32 KBfe783b98a508upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short08_0180.jpg74 KB1941cca75cebupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short09_0060.jpg51 KBb21bf0c27f8dupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short09_0180.jpg60 KB996d5f2b8c59upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short10_0060.jpg68 KB0543dd544a9aupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short10_0180.jpg49 KB8a147a41f35cupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short11_0060.jpg44 KB2fce6675269fupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short11_0180.jpg72 KBc132489afecbupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short12_0060.jpg61 KB587f5c57eb84upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short12_0180.jpg48 KB38337a9c1459upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short13_0060.jpg65 KB9ec5862f0ebfupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short13_0180.jpg44 KB07a992594890upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short14_0060.jpg65 KBc67de7a86574upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short14_0180.jpg47 KBc72cb9b2819cupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short15_0060.jpg41 KBb7d86c01b808upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short16_0060.jpg49 KB02e0efde4cf0upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short16_0180.jpg44 KBb2653a62fe67upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short17_0060.jpg54 KBbd6da97da262upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short18_0060.jpg78 KBfa73a99ee668upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short18_0180.jpg58 KBb77175490ee8upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short19_0060.jpg79 KB8eeff09f56f7upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short19_0180.jpg41 KB7932975d9613upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short20_0060.jpg43 KB0b8690e3fd2aupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/frames/short20_0180.jpg38 KB88db302d854dupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/reports/characters.md47 KBa10425e7456cupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/reports/CONTEXT.md4.7 KB051808dd4f65upstream ↗
    # Look-match study: shared context for every agent (read fully before starting)
    
    ## Goal
    Our original 67 s vertical short "The Bridge That Fell for a Song" (1831 Broughton bridge, 74 marching soldiers) is
    rendered in three.js and currently looks flat, pastel and cheap next to the reference channel (the reference channel, studied
    privately). The user wants our picture to be as close as possible in QUALITY and STYLE: colourful, high contrast,
    "targeted" colours, black ink outlines on the 3D world, painted textures, real shading/lighting on characters and
    world, deep blurred backgrounds, lively movement, better character shapes (heads not perfect circles, better body
    structure). Story, script, characters and voice stay OURS: derive general rules and numbers, never trace or copy his
    specific characters, props or shots.
    
    ## Reference material (private study only: never upload, publish or send any of it anywhere)
    - 20 reference shorts, 720x1280, 30 fps: `references/vids/NN_<id>.mp4` (01 = newest).
    - Contact sheets, each = 2 s = 60 frames: `references/measure/NN_<id>_sheets/sNN.jpg`.
    - Earlier summary: `references/STYLE_BIBLE.md`. Quick overview sheet: `references/refs_sheet.jpg`.
    - Extract frames yourself, e.g. `ffmpeg -v error -ss 12.4 -i references/vids/01_Mz72MuPgnGY.mp4 -frames:v 1 out.png`
      (use PNG for measurements; JPEG is fine for viewing). Crop/zoom with PIL (nearest-neighbour upscales for line widths).
    - Burned-in captions: one white bold word with a black outline, centred, roughly 60-74 % of frame height. MASK that band
      (or detect the white+black text) before any statistics, and do not mistake caption outlines for scene ink lines.
    
    ## Our current frames (state before this pass)
    - `your Remotion project/out/bridge/test/` b_*.jpg, c_*.jpg (540x960 stills, frame number in the name), contact sheets
      `sheet_c1.jpg`, `sheet_c2.jpg`. Shots: 1-7 soldiers marching on a plank suspension bridge (day), 8 bolt snaps + deck
      drops, 9-10 soldiers sitting in the river, 11-13 golden hour (bridge, park swing, side view), 14 bank + officer
      hammering a sign, 15 tourists strolling (day).
    - Do NOT render new stills and do NOT edit anything under `your Remotion project` (other agents and the lead are editing it).
    
    ## Our stack (so recommendations are implementable)
    - `your Remotion project` Remotion 4.0.290 + three 0.175 + @react-three/fiber 9.1.2 + @react-three/postprocessing 3.0.4 +
      postprocessing 6.37.2, composition `BridgeShort` 1080x1920 30 fps. Source in `src/videos/bridge/` of your Remotion project:
      - `story.ts` timeline, cameras (CAMS), lights (LIGHTS day/golden: sun dir/colour/intensity, hemisphere, fill, sky colours).
      - `world.ts` World: gradient sky sphere, HemisphereLight + DirectionalLight sun (4096 shadow map) + fill, Fog(90,560),
        sprite clouds, water mesh, procedural bridge, per-frame posing; `build.ts` town (boxy gabled houses with canvas
        facade textures, Kenney CC0 low-poly GLB props converted to MeshLambertMaterial); `paint.ts` procedural Canvas2D
        textures (grass, cobble, stone, planks, beam, water, roof, brick, chain, facades).
      - `toon.ts` characters drawn with Canvas2D (a `Pen` helper: poly/ell/line/stroke) on a 600x1000 design canvas,
        soles at y=975: bodyFront / bodySide, outfits (rifleman with shako, officer, kid, parent, townsfolk), expressions,
        effect sprites (notes, bursts, splash, stars...). Current heads are perfect circles, thick uniform outline, flat fills.
      - `cast.ts` sprite pool; each character = camera-facing billboard plane, MeshLambertMaterial with the drawing as
        map + emissiveMap (emissive about 0.36, so it looks pasted-on), alphaTest 0.5; casts shadows via a depth material.
      - `ink.ts` current outline: screen-space Laplacian of 1/viewZ only (no normals), threshold 0.06 -> almost no lines.
      - `Bridge.tsx` EffectComposer chain: Ink -> DepthOfField -> Bloom(0.3, threshold 0.86) -> ToneMapping NEUTRAL, MSAA 4.
    - Tools on this machine: python (numpy 2.4, PIL 12, scipy 1.18; no OpenCV), ffmpeg/ffprobe. Do not pip-install anything,
      do not download code or tools from the internet.
    
    ## Deliverable (each agent)
    1. Work only inside `references/<your-topic>/` (scripts, crops, side-by-side images).
    2. Write `references/reports/<your-topic>.md`:
       - measured numbers (tables) from MANY reference frames across the 20 videos (frame-by-frame where it matters),
         and the same measurements on our stills, with the gap stated plainly;
       - paths of the comparison crops / side-by-sides you made;
       - a concrete implementation recipe for OUR stack: exact parameter values, hex colours, GLSL / TypeScript snippets,
         which file/function to change;
       - the top 5 changes ranked by visual impact.
    3. Final message: at most 200 words summary + the report path.
    
  • references/reports/depth.md32 KBe159ef59ec66upstream ↗
    # Depth cues, glow and framing: reference vs ours
    
    Private study. Only general rules and numbers are taken from the 20 reference videos: no traced shots, characters or props.
    Working folder: `references/depth/` (scripts, data, crops).
    Units: blur is a Gaussian sigma in **720p px** (reference 720x1280) unless marked. Our stills are 540x960 and are scaled ×720/540.
    To get a blur-disc radius at our 1080x1920 output, use **R(px @1080p) ≈ 3 × sigma720** (a uniform disc has sigma = R/2, and 1080p is ×1.5).
    
    ## 0. The gap in one table
    
    | Cue | Reference (539 frames, every 2 s, 20 videos) | Ours (56 test stills, `out/bridge/test`) | Gap |
    |---|---|---|---|
    | Background DOF | On in about 1/3 of shots. When on, background sigma720 is CU 4.2, MS 4.0, FS 2.7, WS 1.9 | Background sigma720 0.71-0.99. Excess over subject ≤ 0.71 (median 0.48) | **No visible DOF anywhere.** `DepthOfFieldEffect` caps the blur radius at `bokehScale` px (0.9-2.2 px) |
    | Foreground blur | 20% of frames have a near foreground object, which is blurred to sigma720 3.7-7.8 | Almost no foreground objects. Tourist heads in b_200 are sharp (0.96) | Missing |
    | Rack focus | None inside shots. Whole-frame blur pulses (sigma 3.6-5) of 0.3-1 s at some cuts | None | Transition-level only; see the motion report |
    | Aerial perspective | Things at the skyline sit 40% closer to the horizon colour than near things (dA 0.41 vs 0.69). Darkest far pixels are lifted to 15% of the horizon colour | Far things are *further* from the horizon colour than near ones (0.80 vs 0.70). Dark floor 0.02 | **No haze.** `Fog(#cfe3f2, 90, 560)` hardly reaches the town (40-200 m) |
    | Sky gradient (day) | Horizon/upper-sky luminance 1.28. Upper sky #5e93d9 (S 0.54), horizon #9fb6e6 (S 0.32) | 1.04. Upper #8cb6e7 (S 0.39), horizon #9abfe8 | Our upper sky is washed out and too flat |
    | Sky (golden) | 1.55 (n = 3). Upper #9a7e7f mauve, horizon #ffd18a bright yellow-orange | 1.05. Upper #b5919f, horizon #cfa197 muddy pink | Orange and purple mix too early |
    | Glow | Emissive sources only (moon, lamp, fire, sun): e-fold 14-21 px at 720p, +0.23-0.50 of (source − bg) next to the edge. White faces never glow: hot edges are *sharper* (0.38) than normal edges (0.52) | Nothing reaches the threshold. p99.9 luminance is 0.81 and the maximum is 0.85, so the bloom does nothing | Bloom is inert. There are no emissive sources and no pure whites |
    | Camera height | Median 0.79 × character height. 82% eye level, 11% high, 3% low, 3% top-down | Median 0.80 × (1.6 m / 2.0 m), but 29% of shots are below 0.5 × | Slightly too many low shots |
    | Headroom / horizon | Head top at y = 0.28 (CU), 0.35 (MS), 0.38 (FS), 0.50 (WS). Horizon at 0.45 | 0.33-0.41. Horizon 0.43-0.48 | OK |
    | FOV | Not reliably measurable (see 4.4) | 34-46°, median 40 | No evidence to change |
    
    Comparison sheets (reference on top, ours below):
    - `depth/crops/sbs_dof.jpg`: DOF.
    - `depth/crops/sbs_haze.jpg`: sky and haze, with swatches for upper sky, sky just above the skyline, and the colour of the first band below the skyline.
    - `depth/crops/glow_sources_ref.jpg`: glow around the moon, a lamp, a fireball and the sun.
    - `depth/crops/boxes_ref_1..6.jpg` and `boxes_ours_1..3.jpg`: every measured box with its sigma.
    - `depth/crops/r04_t6_blurmap.jpg` and `blurviz_A.jpg`: per-edge blur maps.
    - `depth/crops/z_*.png`: 2x zooms of blurred reference backgrounds and foregrounds.
    - `depth/crops/rack_strip.jpg`: the rack-focus candidates.
    
    ---
    
    ## 1. Depth of field
    
    ### 1.1 Method
    - **Edge-spread fit.** At every strong edge, a profile across the edge is fitted with `a + b·Φ((t−t0)/σ) + c·t` using batched Levenberg-Marquardt.
      - Fits are kept only if the relative RMS ≤ 0.07.
      - Detection runs at scales 1, 2 and 4 so that very soft edges are still found.
      - The method was validated on synthetic edges with sigma 0.6-16.
    - **Caption mask.** The caption band (white word with a black outline, 55-86% of frame height) is masked out before any statistics.
    - **Frame statistics.** Per frame, 40 px tiles take the 30th percentile of their edges. "Above head" is the median tile value above the labelled head top. Shot labels (type, head top/feet, horizon, camera height, foreground, background distance, dutch) were assigned by hand for all 539 frames (`data/labels.csv`).
    - **Curated boxes.** On 47 reference frames and 22 of our stills, subject (S), background (B) and foreground (F) boxes were measured with dense sampling (`data/box_stats_ref.csv`, `data/box_stats_ours.csv`).
    
    Scripts: `scripts/blurlib.py`, `batch_blur.py`, `analyze_ref.py`, `box_measure.py`.
    
    ### 1.2 How often and how strong (all 530 non-transition frames)
    
    | Type | n | Share with DOF on (bg sigma720 > 1.2) | bg sigma720 when on, median (IQR) | R @1080p | Share > 1.0 overall |
    |---|---|---|---|---|---|
    | CU | 12 | 42% | 4.2 (2.8-4.4) | ~12.5 px | 58% |
    | MS | 121 | 21% | 4.0 (1.9-4.9) | ~12 px | 29% |
    | FS | 131 | 18% | 2.7 (2.0-3.4) | ~8 px | 23% |
    | WS | 55 | 24% | 1.9 (1.4-2.5) | ~5.7 px | 25% |
    | Inserts (tile p80) | 117 | 46% | 2.8 | ~8.3 px | — |
    | **All** | 530 | — | above-head median 0.57 (IQR 0.38-1.07, p90 2.75) | — | 27% |
    
    **Sharp baseline.** The 10th percentile of edge sigma per frame is 0.33 (IQR 0.30-0.37). Reference line work is crisp, so blur is a deliberate per-shot choice.
    
    **Per video.** The above-head median ranges from 0.38 (videos 09 and 10, 0-5% of frames blurred) to 2.12 (video 04, 64% blurred).
    
    **DOF is not a fixed lens.** In FS with a far background, the median is only 0.62 (20% > 1.0, 0% > 2.0). In MS with a near wall it is 0.60 (30% > 1.0). DOF is switched on for chosen beats, mostly intimate CU/MS moments, inserts and foreground-framed shots, and switched off for establishing and most full shots.
    
    ### 1.3 Measured examples (sigma720, p30 of each box)
    
    | Frame | Type | Subject | Background | Foreground |
    |---|---|---|---|---|
    | 05_f2010 | MS | 0.30 | 6.97 (photo wall behind) | — |
    | 13_f0930 | CU | 0.34 | 5.33 (forest, also hazed) | — |
    | 05_f2250 | MS | — | 4.92 | — |
    | 08_f0210 | MS over-the-shoulder | 0.36 | 4.53 | 3.71 (head in front) |
    | 11_f0150 / 03_f0810 | MS | sharp | 3.16 / 2.76 | — |
    | 03_f0930 / 04_f0210 / 04_f0390 / 04_f0330 | FS | ~0.35 | 2.80 / 2.54 / 2.35 / 1.85-2.74 | ground 10.8 (04_f0210) |
    | 17_f0270 | MS | 0.44 | 1.77 | **7.79** (slot machines in front) |
    | 16_f0990 | MS | 0.29 | 0.32 (sharp booth) | **5.52** (cat in front) |
    | 14_f0030 / 01_f0210 / 07_f0750 | FS | sharp | 0.99 / 0.96 / 0.75 | — |
    | 16_f0150 / 14_f0390 | WS | sharp | 1.27 / 0.74 | — |
    | 10_f1650 | CU | 3.26 | 3.42 | focus on a foreground key |
    | 01_f0570 | FS | 1.81 (near character) | focus on the far ship | — |
    
    Ours (all 21 stills with S+B):
    - Subject 0.60-1.02 (median 0.76).
    - Background 0.71-0.99 (median 0.89).
    - Excess background blur sqrt(B² − S²) has a median of 0.48 and a maximum of 0.71.
    - Only foreground: b_200 tourist heads at 0.96.
    - Our stills are half resolution, so their absolute sharpness is not comparable. The B−S difference is the fair measure, and it is about zero.
    
    ### 1.4 How blur grows behind the subject
    - **Thin-lens shape with saturation.** CoC(d) = C∞·|1 − F/d| fits the reference.
      - Crowd 03_f1830: heads of diameter 200 → 135 → 110 → 70 px (distance ratio 1 → 1.5 → 1.8 → 2.9) have sigma 0.33 → 0.33 → 1.14-1.31 → 1.55.
      - Thin lens with C∞ = 2.4 sigma predicts 0, 0.8, 1.07 and 1.58.
    - **Saturation.** In the western street (04 at 6 s), every building from mid to far distance sits at sigma 1.85-2.0. Blur reaches its limit within about 3× the focus distance and then stays flat.
    - **Near walls.** A wall about 1 m behind a character is blurred much less than the sky or town in the same kind of shot. This follows from thin-lens geometry and does not need a separate rule.
    
    ### 1.5 Rack focus
    Three candidate windows were checked frame by frame (`scripts/rack.py`, `crops/rack_strip.jpg`, 6 horizontal bands per frame):
    - 04 frames 100-220.
    - 10 frames 1600-1700.
    - 01 frames 540-620.
    
    **No focus pulls inside shots were found.**
    
    - In 04, a new shot starts with the whole frame at sigma 3.6 for 0.8 s, then 1.8 for 1.1 s, then a whip pan.
    - In 10, a push-in blurs the whole frame to sigma 4-5 for 0.8 s, then cuts to sharp.
    
    These are transition blurs (motion report), not DOF.
    
    ### 1.6 Why ours shows nothing (code)
    postprocessing 6.37.2 `DepthOfFieldEffect` works like this:
    - CoC = `smoothstep(0, focusRange, |d − F|)` in world units.
    - Bokeh is a 64-tap Vogel disc of radius `coc × bokehScale × texelSize`, and the bokeh materials are sized to the **full** 1080x1920 buffer.
    - So the maximum disc radius is `bokehScale` px.
    - Our 2.2 / 1.5 / 0.9 px gives sigma720 ≈ 0.7 / 0.5 / 0.3, which is below the baseline.
    
    Raising `bokehScale` to the 8-20 px we need has three problems:
    - 64 taps spread over a 20 px disc look grainy.
    - The far buffer is premultiplied by CoC without renormalising, which risks dark halos around the subject.
    - The smoothstep CoC under-blurs foreground objects. At d = F/2 it gives 0.16 where the thin lens gives 1.0.
    
    **Replace it with a thin-lens gather effect (recipe 6.1).**
    
    ---
    
    ## 2. Atmosphere
    
    ### 2.1 Method
    `scripts/atmos.py` writes `data/atmos_ref.csv` and `data/atmos_ours.csv`.
    
    - **Sky mask.** The sky is the smooth (|∇L| < 0.01/px), bright (L > 0.35) region connected to the top edge.
    - **Skyline.** The median of the lowest sky pixel per column.
    - **Sky colours.**
      - "Upper" is the median colour of the top 15% of the visible sky.
      - "Low" is the band just above the skyline (the airlight A).
    - **Haze below the skyline**, in 5 bands (0-4, 4-10, 10-20, 20-35, 35-100% of frame height below it):
      - **Dark floor:** p10 of the normalised dark channel minfilter(min_c I_c/A_c). 0 means full-strength ink or shadow; 1 means everything is lifted to the horizon colour.
      - **dA:** the RGB distance from the band's median colour to A.
    - Reference frames are restricted to the 40 exteriors with a labelled horizon. All 43 of our stills that contain sky are used.
    
    ### 2.2 Results
    
    | Metric | Ref day (n 37) | Ours day (n 30) | Ref golden (n 3) | Ours golden (n 13) |
    |---|---|---|---|---|
    | Upper sky | **#5e93d9**, L 0.53, S 0.54 | #8cb6e7, L 0.69, S 0.39 | **#9a7e7f**, L 0.55 | #b5919f, L 0.60 |
    | Sky above skyline | **#9fb6e6**, L 0.72, S 0.32 | #9abfe8, L 0.73, S 0.34 | **#ffd18a**, L 0.84 | #cfa197, L 0.67 |
    | Low/upper luminance | **1.28** (1.18-1.40) | 1.04 (1.01-1.05) | **1.55** (1.49-1.87) | 1.05 |
    | Dark floor just below skyline | 0.15 (0-0.24) | 0.02 | 0.12 | 0.00 |
    | dA: far band → near band | **0.41 → 0.69** | 0.80 → 0.70 | 0.60 → 0.78 | 0.77 → 0.82 |
    | Band colour just below skyline | #b2b0a1 (pale grey-beige) | #7c4d4b (dark brick red) | #be755f | #87270b |
    
    ### 2.3 What this means
    - **Day, reference.** Skyline objects are blended about 40% toward the horizon colour (k ≈ 1 − 0.41/0.69). Their ink and shadows lift by about 15%. Near objects keep full colour.
    - **Ours.** Our skyline town (40-180 m away) is darker and more saturated than the foreground.
    - **Why our fog does nothing.** Fog 90 → 560 is 0 at 70 m, 0.085 at 130 m and 0.19 at 180 m. Its colour #cfe3f2 is also a pale cyan that does not match our horizon.
    - **Sky gradient.** The reference keeps a pale horizon and reaches a deep, saturated blue within about 20° elevation. Our shader `mix(hor, top, pow(h, 0.55))` (h = sin(elevation)) hits about 50% at the top of a 40° frame, so the whole visible sky looks the same pale blue.
    - **Golden.** Our sky is muddy because the orange horizon is already 30-40% mixed with the purple-blue top at the skyline.
    
    ---
    
    ## 3. Bloom and glow
    
    ### 3.1 Method
    `scripts/bloom.py`, `scripts/glow_crops.py`, `scripts/sim_bloom.py`.
    
    **(a) Edge widths.** ESF sigma of edges whose bright side is hot (local max L ≥ 0.95) vs normal edges (0.45-0.85), per frame.
    - Reference: hot 0.38, normal 0.52. Per frame hot/normal = 0.80; only 2% of frames are above 1.5.
    - **White surfaces (heads, eyes, walls, white clouds) do not bloom.** Their edges are the crispest in the frame.
    - Ours: 1.01. No hot pixels exist at all.
    
    **(b) Emissive sources** (`crops/glow_sources_ref.jpg`). The radial profile from the centroid of the L ≥ 0.85 blob. "Excess" = (L − bg)/(L_src − bg).
    
    | Source | Disc r0 (px720) | Excess at r0+4 | r0+10 | r0+20 | r0+40 | e-fold (px720) |
    |---|---|---|---|---|---|---|
    | Moon, 11_f1350 | 38 | 0.37 | 0.32 | 0.22 | 0.10 | 19.6 |
    | Moon, 11_f1050 | 40 | 0.23 | 0.21 | 0.15 | 0.05 | 18.0 |
    | Ceiling lamp, 05_f0210 | 42 | 0.46 | 0.38 | 0.30 | 0.14 | 20.9 |
    | Fireball, 12_f0930 | 36 | 0.42 | 0.23 | — | 0.05 | 14.4 |
    | Sun, 11_f0930 | 58 | 0.50 | 0.49 | 0.41 | 0.11 | 19.7 |
    
    **Target glow.** A broad, flat halo:
    - excess 0.25-0.5 right at the edge;
    - still 0.15-0.4 at +20 px (720p);
    - e-fold 14-21 px at 720p, which is **21-31 px at 1080p** (about 1.5% of frame height).
    - The sun scene washes 7% of the frame to L ≥ 0.93.
    
    **Peak white.**
    - Reference: p99.9 luminance 0.99, and the maximum is 1.0 in nearly every frame.
    - Ours: p99.9 0.81, maximum 0.85 (p90 of frames: 0.97).
    - Nothing in our frames crosses `luminanceThreshold 0.86`. This is a grade/exposure issue for the colour report, but it is the reason our bloom is invisible.
    
    ### 3.2 Calibrating the mipmap bloom (numpy port of MipmapBlurPass)
    The port uses the same 13-tap downsample (inner 4 at 0.125, outer 9 at 1/18), the 9-tap tent upsample, and `mix(down[i], up, radius)`. Output is at half resolution. g(d) is the glow d px (1080p) outside a 60 px source of unit masked value.
    
    | levels | radius | g(4) | g(20) | g(60) | g(150) | e-fold (px1080) |
    |---|---|---|---|---|---|---|
    | 8 (our current) | 0.85 | 0.14 | 0.049 | 0.017 | 0.0067 | 40 (long thin tail) |
    | 6 | 0.5 | 0.13 | 0.022 | 0.004 | 0.0004 | 24.5 |
    | **6** | **0.6** | ~0.15 | ~0.04 | ~0.011 | ~0.001 | **~30** |
    | 6 | 0.7 | 0.18 | 0.057 | 0.019 | 0.002 | 35 |
    | 5 | 0.85 | 0.26 | 0.124 | 0.022 | 0.00001 | 21 |
    
    - **levels 6, radius 0.6** matches the reference e-fold.
    - The reference halo is flatter near the source than any mip kernel. For the few big sources, add an additive glow sprite (recipe 6.4).
    - Small sparks (r0 6 px) get only g(4) ≈ 0.045, so they need a much higher HDR value.
    
    ---
    
    ## 4. Composition
    
    From `data/labels.csv` (530 frames) and our CAMS (`story.ts`, character card height 2.0-2.1 m with `SU = 0.00216`).
    
    ### 4.1 Shot mix and framing (reference)
    
    | Type | Share | Head-top y (IQR) | Visible character height | Feet y | Foreground object |
    |---|---|---|---|---|---|
    | CU | 3% | 0.28 (0.21-0.35) | 0.65 | cut | 39% |
    | MS | 27% | 0.35 (0.25-0.38) | 0.50 | cut at ~0.80 | 38% (s 46, b 9) |
    | FS | 28% | 0.38 (0.35-0.42) | 0.35 (0.27-0.45) | 0.75 | 16% |
    | WS | 12% | 0.50 (0.42-0.55) | 0.15 | 0.64 | 16% |
    | EWS | 8% | 0.51 | 0.10 | 0.59 | 5% |
    | Inserts | 22% | — | — | — | 9% |
    
    Other reference framing numbers:
    - **Horizon** (FS/WS/EWS): y = 0.45 (IQR 0.39-0.50).
    - **Background distance:** near wall 58%, mid 23%, far 20%.
    - **Dutch tilt:** 2% (11 of 530).
    - **Placement:** characters sit in the middle of the vertical frame, with the caption over their lower body.
    
    ### 4.2 Camera height
    **Reference.**
    - Labels: eye level 82%, high 11%, low 3.4%, top-down 3.2%.
    - From horizon geometry (34 FS/WS/EWS frames, level camera): camera height = (feet − horizon)/(feet − head) = **0.79 × character height**, IQR 0.67-1.00.
    - 10% of these frames are below 0.5×. High shots run 1.5-1.9×.
    
    **Ours (CAMS).**
    - Height above the deck: median 1.6 m = **0.80 ×** (matches).
    - But 29% of shots are below 0.5× (≤ 1.0 m):
      - f48: 0.6 m
      - f88: 0.4 m
      - f353 and f440: 0.36 m
      - f823: 0.7 m
      - f1685: 0.8 m
    - 47% are at 1.0-2.2 m. The rest are high shots and aerials.
    
    ### 4.3 Ours vs targets (from CAMS geometry; feet and head projected for a character at the look point)
    - **Head top.** Our MS shots (f585/640/925/984) have the head top at 0.35-0.40 and a full-height equivalent of 0.74-0.77, which is the same as the reference MS. Our FS shots (f283, f768, f1729, f1857) have the head top at 0.29-0.41 and character height 0.32-0.41, matching the reference FS.
    - **Horizon** 0.42-0.49 on level shots (matches).
    - **Foreground objects:** essentially none (reference: 38% of MS and CU).
    - **Inserts:** we have about 3 (anchor, snap, boots) vs 22% in the reference. This is out of scope here, but those shots carry most of the reference's DOF.
    
    ### 4.4 FOV
    - I tried vanishing points: straight segments, RANSAC, then f² = −(v1−c)·(v2−c).
    - It failed validation on our own stills: on c_1800 it estimated 25° against a true 42°.
    - On reference frames it gave inconsistent values (1-73°), because stylised architecture is rarely orthogonal and many segments sit on frame edges.
    - The ground-plane cues (horizon vs feet vs character size) cancel the focal length.
    - Character sizes and positions at our distances already match the reference at fov 36-44.
    - **Recommendation: keep fov 34-46; there is no measurable reason to change it.**
    
    ---
    
    ## 5. Gaps ranked by visual impact (top 5)
    
    1. **DOF on chosen shots.**
       - Ours: zero. Reference: about 1/3 of shots.
       - Use a thin-lens gather effect: C∞ 12-14 px (CU/MS/inserts), 8 (FS when used), 5 (WS when used); foreground 15-24 px.
       - Focus on the subject's eyes.
    2. **Aerial perspective.**
       - Fog colour = horizon colour; linear fog 25 → 420 m (day) and 20 → 360 m (golden).
       - The skyline town is then pulled 20-40% toward the horizon, and the ink is fog-tinted.
       - This takes our skyline from #7c4d4b-dark to the reference's pale grey-beige.
    3. **Sky gradient.**
       - Deep upper blue #5e93d9 at about 20° elevation, pale horizon (luminance ratio 1.28).
       - Golden: bright #ffd08a horizon that holds to about 3°, then mauve/indigo.
    4. **Foreground framing objects in about 1/3 of MS/CU.**
       - Rail post, chain link, flag, a spectator's shoulder or hat, at 0.3-0.5 × the focus distance and covering 10-25% of a frame edge.
       - The new DOF blurs them to the reference's 4-8 sigma720.
    5. **Glow only on emissive sources and real whites.**
       - Bloom: threshold 1.0, levels 6, radius 0.6, intensity 0.5.
       - Emissive HDR ×3 (sun, lamps, fire), ×8 for sparks.
       - Faces stay crisp.
       - Pair with the colour report's exposure fix so white heads and clouds reach 0.97-1.0.
       - Minor: raise f48 and f823 to about 1.4 m so fewer than 15% of shots are low-angle.
    
    ---
    
    ## 6. Recipe (our stack: three 0.175, postprocessing 6.37.2, @react-three/postprocessing 3.0.4)
    
    ### 6.1 New file `src/videos/bridge/lensblur.ts`: thin-lens DOF, single gather pass
    - `@react-three/postprocessing` 3.0.4 puts any `CONVOLUTION` effect into its own `EffectPass` automatically, so `<primitive object={fx.lens} />` can replace `fx.dof` in place (after ink, before bloom).
    - `getViewZ`, `readDepth`, `texelSize` and `inputBuffer` are provided by `EffectPass`, as `ink.ts` already relies on.
    
    ```ts
    // Thin-lens depth of field in one gather pass (scatter-as-gather), for the reference's soft backgrounds.
    // CoC radius in output px = cInf * |1 - focus / depth| (near side x nearScale), clamped to maxCoC.
    // Pass 1 gathers the pixel's own disc; samples farther than the pixel may not spread over it (keeps the subject's
    // silhouette clean). Pass 2 lets a blurred foreground spread over sharper pixels behind it.
    import { Effect, EffectAttribute, BlendFunction } from "postprocessing";
    import { Uniform } from "three";
    
    const frag = /* glsl */ `
    uniform float focus;      // focus distance along the view axis (m)
    uniform float cInf;       // blur-disc radius (px, output res) of things at infinity; 0 = off
    uniform float nearScale;  // extra blur for things in front of the focus plane
    uniform float maxCoC;     // px
    #define TAPS 64
    #define NEAR_TAPS 32
    #define GOLDEN 2.39996323
    
    float cocAt(float d) {
      float c = cInf * (1.0 - focus / max(d, 1e-3));
      if (c < 0.0) c *= nearScale;
      return clamp(c, -maxCoC, maxCoC);
    }
    float distAt(vec2 uv) { return -getViewZ(readDepth(uv)); }
    float hash12(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
    
    void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth, out vec4 outputColor) {
      if (cInf < 0.25) { outputColor = inputColor; return; }
      float d0 = -getViewZ(depth);
      float r0 = abs(cocAt(d0));
      float rot = hash12(gl_FragCoord.xy) * 6.2831853;   // fixed per pixel: no frame-to-frame shimmer
      vec3 acc = inputColor.rgb; float wsum = 1.0;
      if (r0 > 0.5) {
        for (int i = 0; i < TAPS; i++) {
          float rr = sqrt((float(i) + 0.5) / float(TAPS)) * r0;
          float a = float(i) * GOLDEN + rot;
          vec2 suv = uv + vec2(cos(a), sin(a)) * rr * texelSize;
          float ds = distAt(suv);
          float rs = abs(cocAt(ds));
          if (ds > d0) rs = min(rs, r0);                // background never bleeds over a nearer pixel
          float w = clamp(rs - rr + 0.5, 0.0, 1.0);      // does the sample's disc reach us?
          acc += texture2D(inputBuffer, suv).rgb * w; wsum += w;
        }
      }
      vec3 col = acc / wsum;
      vec3 fg = vec3(0.0); float fw = 0.0, cov = 0.0;
      for (int i = 0; i < NEAR_TAPS; i++) {
        float rr = sqrt((float(i) + 0.5) / float(NEAR_TAPS)) * maxCoC;
        if (rr <= r0) continue;                          // already covered by pass 1
        float a = float(i) * GOLDEN + rot + 1.0;
        vec2 suv = uv + vec2(cos(a), sin(a)) * rr * texelSize;
        float ds = distAt(suv);
        if (ds >= d0 * 0.97) continue;                   // only things in front of us
        float rs = abs(cocAt(ds));
        float w = clamp(rs - rr + 0.5, 0.0, 1.0);
        fg += texture2D(inputBuffer, suv).rgb * w; fw += w;
        cov += w * (maxCoC * maxCoC) / max(rs * rs, 1.0);
      }
      if (fw > 0.0) col = mix(col, fg / fw, clamp(cov / float(NEAR_TAPS), 0.0, 1.0));
      outputColor = vec4(col, inputColor.a);
    }`;
    
    export class LensBlurEffect extends Effect {
      constructor({ maxCoC = 24, nearScale = 1.2 } = {}) {
        super("LensBlurEffect", frag, {
          blendFunction: BlendFunction.NORMAL,
          attributes: EffectAttribute.DEPTH | EffectAttribute.CONVOLUTION,
          uniforms: new Map<string, Uniform>([
            ["focus", new Uniform(10)], ["cInf", new Uniform(0)],
            ["nearScale", new Uniform(nearScale)], ["maxCoC", new Uniform(maxCoC)],
          ]),
        });
      }
      set focus(v: number) { this.uniforms.get("focus")!.value = v; }
      set cInf(v: number) { this.uniforms.get("cInf")!.value = v; }
    }
    ```
    
    **Cost.** About 96 taps × 2 fetches per pixel at 1080x1920. This is fine on the ANGLE GPU path.
    
    **Sky.** Depth 1.0 means the far plane (1500 m), so the sky and clouds get the full `cInf`, which matches the soft clouds in reference DOF shots.
    
    **Fallback if you only want a quick test with the built-in effect:**
    `new DepthOfFieldEffect(camera, { resolutionScale: 1 })`, `worldFocusRange = 2 * focus`, `bokehScale = cInf`. This approximates the far side of the thin lens but under-blurs foreground objects. Expect grain and dark rims above 8 px.
    
    ### 6.2 `Bridge.tsx` (replaces the `dof` lines)
    
    ```ts
    import { LensBlurEffect } from "./lensblur";
    import { dofAt } from "./story";
    const fwd = new THREE.Vector3(), fpt = new THREE.Vector3();   // module scope
    
    const fx = useMemo(() => ({
      ink: new InkEffect(),
      lens: new LensBlurEffect({ maxCoC: 24, nearScale: 1.2 }),
      bloom: new BloomEffect({ intensity: 0.5, luminanceThreshold: 1.0, luminanceSmoothing: 0.2, mipmapBlur: true, levels: 6, radius: 0.6 }),
      tone: new ToneMappingEffect({ mode: ToneMappingMode.NEUTRAL }),
    }), []);
    // <primitive object={fx.lens} dispose={null} />  in place of fx.dof
    
    // per frame, after target.copy(world.update(id, f, cam, scene)):
    const d = dofAt(f);
    cam.getWorldDirection(fwd);
    if (d.focus) fpt.set(...d.focus(f)); else fpt.copy(target);
    fx.lens.focus = Math.max(0.3, fpt.sub(cam.position).dot(fwd));   // view-axis distance, not Euclidean
    fx.lens.cInf = d.cInf;
    ```
    
    ### 6.3 `story.ts`: a DOF table next to CAMS (same `f`/`dur` as the camera entries, so blur ramps with camera moves)
    
    ```ts
    /** Lens blur per shot (thin lens). cInf = blur-disc radius in px at 1080x1920 for things at infinity; 0 = all sharp.
     *  Reference: DOF on in ~1/3 of shots; when on, background sigma720 CU 4.2, MS 4.0, FS 2.7, WS 1.9,
     *  foreground objects 4-8 (disc radius at 1080p = 3 x sigma720). Establishing/wide/most full shots stay sharp. */
    export type Dof = { f: number; dur?: number; cInf: number; focus?: (f: number) => V3 };
    const eyes = (id: number) => (f: number): V3 => [0, D0 + 1.6, Fz(id, f)];
    const boots = (id: number) => (f: number): V3 => [0, D0 + 0.3, Fz(id, f)];
    export const DOF: Dof[] = [
      { f: 0, cInf: 0 },                                   // establishing
      { f: 88, dur: 9, cInf: 12, focus: eyes(1) },          // low MS, front soldier
      { f: 126, cInf: 0 },                                  // aerial wides, FS
      { f: 353, dur: 10, cInf: 16, focus: boots(3) },       // boots CU
      { f: 440, cInf: 16, focus: boots(4) },
      { f: 444, dur: 13, cInf: 0 },                         // side WS / FS
      { f: 534, dur: 13, cInf: 10, focus: eyes(5) },        // MS
      { f: 585, dur: 10, cInf: 12, focus: eyes(5) },
      { f: 640, cInf: 12, focus: eyes(6) },
      { f: 643, dur: 11, cInf: 14 },                        // CU, focus = look (the face)
      { f: 717, dur: 12, cInf: 0 },                         // high WS, FS, low WS
      { f: 871, dur: 11, cInf: 8 },                         // high MS, focus = look
      { f: 925, dur: 11, cInf: 12, focus: eyes(7) },        // MS
      { f: 984, cInf: 12, focus: eyes(8) },
      { f: 987, dur: 12, cInf: 14 },                        // anchor insert, focus = look
      { f: 1039, dur: 8, cInf: 10 },                        // snap insert
      { f: 1062, dur: 18, cInf: 0 },                        // aerial, river, park, bank FS
      { f: 1770, dur: 12, cInf: 10 },                       // officer MS, focus = look
      { f: 1857, cInf: 0 },
      { f: 1905, dur: 10, cInf: 10 },                       // MS
      { f: 1962, dur: 24, cInf: 0 },                        // closing aerial
    ];
    export const dofAt = (f: number) => {
      let i = 0; while (i + 1 < DOF.length && f >= DOF[i + 1].f) i++;
      const d = DOF[i], p = DOF[Math.max(0, i - 1)];
      const k = d.dur ? Math.min(1, (f - d.f) / d.dur) : 1;
      return { cInf: p.cInf + (d.cInf - p.cInf) * k, focus: d.focus };
    };
    ```
    
    **Notes on the DOF table.**
    - **Coverage.** These values put DOF on about 35% of screen time: CU/MS beats, inserts and the boots shot. That matches the reference share.
    - **Fallback.** If a shot reads too soft, halve `cInf` before turning it off. Reference "on" shots never go below sigma720 1.2 (about 4 px radius).
    - **Foreground objects.** For the reference's foreground framing, place an object at 0.3-0.5 × the focus distance in DOF shots. With nearScale 1.2 it lands at 15-24 px, the same as the reference's 4-8 sigma720.
    
    ### 6.4 Bloom: glow only where light is emitted
    - **Bloom settings.**
      - `luminanceThreshold 1.0`: no lit surface crosses it, so white heads stay crisp as in the reference.
      - `luminanceSmoothing 0.2`, `intensity 0.5`, `mipmapBlur true`, `levels 6`, `radius 0.6`.
      - This gives an e-fold of about 30 px at 1080p, the reference's 14-21 px at 720p.
      - The current `levels 8 / radius 0.85` makes a thin, long, frame-wide veil instead.
    - **Emissive sources.**
      - Use HDR colours (the composer buffer is half-float, and r3f-postprocessing turns renderer tone mapping off).
      - Big sources: `new THREE.MeshBasicMaterial({ color: new THREE.Color(3.0, 2.6, 2.0) })` for the sun disc (golden shots), lanterns and fire.
      - Sparks: `(8, 6, 3)`, for example where the bolt snaps.
      - Expected excess next to a big source: 3 × 0.5 × g(4 px) ≈ 0.22 linear, which is the reference's +0.25-0.5 of (source − bg).
    - **Big sources (sun, lamp): add a flat halo sprite** to match the reference's flat glow:
      - additive `Sprite` of radius 2.2 × the source radius;
      - texture alpha = 0.35 × exp(−(r/(1.1·r_src))²);
      - `blending: THREE.AdditiveBlending`, `depthWrite: false`, `fog: false`.
    - **Exposure (colour report).** Whites must reach 0.97-1.0, as in the reference (p99.9 0.99). Ours peak at 0.85.
    
    ### 6.5 Fog and sky (`world.ts` + the `LIGHTS` table)
    **LIGHTS table.**
    
    ```ts
    // LIGHTS (where top / hor / fog live)
    day:    { /* … */ top: "#4284d3", hor: "#c9cdee", skyCurve: [0.0, 0.5, 0.6],  fog: "#b0c0e8", fogNear: 25, fogFar: 420 },
    golden: { /* … */ top: "#443876", hor: "#ffd08a", skyCurve: [0.05, 0.6, 1.0], fog: "#f4c68c", fogNear: 20, fogFar: 360 },
    ```
    
    **Sky shader** (constructor; `h` = sin(elevation)).
    
    ```ts
    uniforms: { top: { value: new THREE.Color() }, hor: { value: new THREE.Color() }, curve: { value: new THREE.Vector3(0, 0.5, 0.6) } },
    fragmentShader: `uniform vec3 top; uniform vec3 hor; uniform vec3 curve; varying vec3 vP;
      void main(){ float h = clamp(vP.y, 0.0, 1.0);
        float k = pow(clamp((h - curve.x) / (curve.y - curve.x), 0.0, 1.0), curve.z);
        gl_FragColor = vec4(mix(hor, top, k), 1.0); }`,
    ```
    
    **`update()`.**
    
    ```ts
    this.skyMat.uniforms.curve.value.set(...L.skyCurve);
    this.fog.color.set(L.fog); this.fog.near = L.fogNear; this.fog.far = L.fogFar;
    ```
    
    **Check of the sky values.**
    - Day: at 4° elevation the sky is #a0b7e6, and at 20° it is #5e93d9. Both match the reference.
    - Golden: it stays #ffd08a up to 3°, and is #9c807f at 20° (reference #9a7e7f).
    - NEUTRAL tone mapping compresses linear values above 0.76, so check one still per light.
    
    **What the new fog does.**
    
    | Distance | Fog amount |
    |---|---|
    | Soldiers (≤ 15 m) | 0 |
    | Far tower (~57 m) | 0.08 |
    | Far-bank riverfront (70 m) | 0.11 |
    | Back rows (100-140 m) | 0.19-0.29 |
    | Main street (180 m) | 0.39 |
    | Aerial shots of the town (250 m) | 0.57 |
    
    **Fog-tinted ink (`ink.ts`, coordinate with the ink report).** The reference lifts far ink by about 15%. Add `fogColor`, `fogNear` and `fogFar` uniforms and replace `ink` in the final mix:
    
    ```glsl
    float fogK = clamp((dist - fogNear) / (fogFar - fogNear), 0.0, 1.0);
    vec3 inkC = mix(ink, fogColor, fogK * 0.6);
    outputColor = vec4(mix(inputColor.rgb, inkC, k), inputColor.a);
    ```
    
    Also raise `farFade` to about 600, so far outlines haze out instead of vanishing at 260 m.
    
    ### 6.6 Composition rules for CAMS
    - **Keep:**
      - head top at y ≈ 0.28 (CU), 0.35 (MS), 0.38 (FS), 0.50 (WS);
      - horizon at 0.40-0.50;
      - fov 34-46.
    - **Camera height.**
      - Default: 0.7-1.0 × character height = **1.4-2.0 m above the deck** (reference median 0.79×).
      - Keep the deliberately low beats (boots f353/440, f88).
      - Raise f48 (0.6 → 1.4 m) and f823 (0.7 → 1.4 m). That brings low shots (below 0.5×) from 29% to about 15%. The reference has 3% labelled low and 10% below 0.5×.
    - **Foreground framing.** In about 1/3 of MS/CU (for example f534, f585, f925, f1770), put a near object at 0.3-0.5 × the focus distance covering 10-25% of a frame edge. Reference: 38% of MS/CU, blurred to sigma720 4-8.
    - **Dutch tilt.** The reference uses it in 2% of frames. Keep our rolls ≤ 0.05 rad and use them on at most 2-3 shots.
    - **No focus pulls inside shots.** Change DOF only at cuts, or ramp it with camera moves (the `dur` in the table).
    
    ---
    
    ## 7. Files
    
    **Report:** `references/reports/depth.md`
    
    **Data** (`references/depth/data/`):
    - `labels.csv`: 539 labelled frames.
    - `frame_stats.csv`: per-frame DOF statistics.
    - `edges/*.npy`: per-edge fits.
    - `box_stats_ref.csv`, `box_stats_ours.csv`: curated boxes.
    - `atmos_ref.csv`, `atmos_ours.csv`.
    - `bloom_ref.csv`, `bloom_ours.csv`.
    - `dense/`: dense per-frame fits.
    
    **Scripts** (`references/depth/scripts/`):
    - `blurlib.py`: ESF fit and caption mask.
    - `batch_blur.py`, `analyze_ref.py`, `box_measure.py`.
    - `rack.py`, `atmos.py`, `bloom.py`, `glow_crops.py`.
    - `sim_bloom.py`: numpy MipmapBlurPass.
    - `vp_fov.py`: the failed FOV attempt, kept for the record.
    - `sbs.py`: comparison sheets.
    
    **Crops** (`references/depth/crops/`):
    - `sbs_dof.jpg`, `sbs_haze.jpg`, `glow_sources_ref.jpg`, `glow_top.jpg`, `rack_strip.jpg`.
    - `boxes_ref_1..6.jpg`, `boxes_ours_1..3.jpg`, `r04_t6_blurmap.jpg`, `blurviz_A.jpg`, `z_*.png`.
    
    **Caveats.**
    - Our stills are half-resolution JPEG test renders, so compare blur *differences* (background vs subject), not absolute sharpness.
    - Golden-hour reference n = 3.
    - Bloom calibration is a CPU port of the shader. Verify on one emissive still.
    - Nothing was rendered and nothing under `your Remotion project` was edited.
    
  • references/reports/grade.md27 KBc1a686770f35upstream ↗
    # Grade: colour and tone, reference vs ours
    
    Folder: `references/grade/`. Scripts are in `scripts/`, data in `data/`, images in `out/`.
    
    All reference material stays private and local. No stills were rendered and nothing under `your Remotion project` was edited; the stills were only read.
    
    ## 0. The gap in plain words
    
    Compared with the reference day frames, our day frames have four problems:
    
    - **Too dark in the mids and top.**
      - Median Y' is 0.436 against 0.571.
      - p99 is 0.783 against 0.967, so we have no near-whites.
    - **Big pure-black areas.**
      - Non-ink p5 is 0.026 against 0.234.
      - The reference has no black areas; its darks are coloured and lifted.
    - **Low-chroma lit surfaces.** Lit C* p50 is 23 against 34, and p90 is 37 against 55.
    - **No vignette.** Our corners are brighter than the centre at 1.46, against the reference's 0.86 darkening.
    
    Two more gaps:
    
    - **Black point.** Reference blacks are warm-lifted, with a black point of (35,19,20). Ours is blue-black, (2,0,13).
    - **Golden.** Ours is much darker (median 0.33 against 0.52) and over-saturated orange (HSV S p50 .71 against .45). Its shadows are brown (dark-zone b* +11), where the reference's are violet (b* −6). It is also missing the reference's roughly 25 % share of violet, blue, magenta and rose hues.
    
    A grade fixes the tone curve, blacks, overall saturation, cast and vignette. It cannot fix pastel or dark albedo, brown or black cast shadows, or missing hue families. See section 6.
    
    ## 1. Method
    
    - **Frames.**
      - Reference: every 15th frame of all 20 videos, 2164 frames.
      - Decoding: ffmpeg `scale=in_color_matrix=bt709:in_range=tv:out_range=pc:flags=accurate_rnd+full_chroma_int`, then area-scaled to 540x960. That is the same grid as our stills; the default ffmpeg conversion was 1 level off, so it was replaced.
      - Ours: 39 stills in `your Remotion project/out/bridge/test`, b_*/c_*: 28 day, 9 golden (shots 11-13), plus the 2 dark bolt close-ups c_1000 and c_1036.
    - **Luma.** Y' = 0.2126 R' + 0.7152 G' + 0.0722 B' on sRGB-encoded 0..1 values. These are Rec.709 weights on encoded values, i.e. luma, not linear luminance.
    - **Caption mask.** Caption text sits at rows 662-694 in 99.5 % of frames, at the same place in all videos. Full-width rows 650-706 are removed from every statistic, and local contrast also drops ±5 rows. No other overlay was found.
    - **Ink mask.** Black top-hat (7x7 grey closing − Y' > 0.12) AND Y' < 0.30. "Non-ink" stats drop the ink dilated by 1 px.
    - **Per-frame metrics** (`scripts/metrics.py`):
      - Y' p1/5/25/50/75/95/99, mean and std;
      - HSV S mean/p50/p90 over V > 0.15;
      - Hasler-Süsstrunk colourfulness;
      - local contrast = std(Y' − 9x9 box mean);
      - a saturation-weighted 12-bin hue histogram and Lab means per zone: shadows Y' < 0.3, mids, highlights Y' > 0.7;
      - black point = mean RGB of the darkest 0.5 %;
      - vignette = mean Y' in 10 normalised-radius bins (r = 1 at the corners), plus corner/centre (r ≥ 0.9 vs r < 0.3) and mid-row edge/centre;
      - lit chroma C*ab p50/p90 over L* 40-85 non-ink pixels.
    - **Clusters.** Manual per-video range labels from timeline strips (`scripts/labels.py`, `out/strips/NN_lab.jpg`), with k-means only as an aid.
    
      | Label | Meaning | Frames |
      |---|---|---|
      | D | day exterior | 552 |
      | G | golden/sunset | 162 |
      | N | night | 352 |
      | I | interior | 835 |
      | X | excluded (graphics, documents, flashes, body-interior, scope) | 263 |
    
      Overview: `out/clusters_overview.jpg`.
    - **Raw data.**
      - `data/ref_frames.csv`: every reference frame with its label.
      - `data/ours_frames_before.csv`.
      - `data/summary.json`: per-cluster aggregates.
      - `data/samples.npz`: pooled pixels.
    
    ## 2. Measured tables
    
    Per-frame medians per cluster, then ours before and after the fitted grade. The "+grade" columns are our stills graded in numpy with exactly the shader maths (section 5), then re-measured with the same ink mask.
    
    | per-frame median | ref day | ref golden | ref night | ref interior | ref all | OURS day now | OURS day +grade | OURS golden now | OURS golden +grade |
    |---|---|---|---|---|---|---|---|---|---|
    | frames | 552 | 162 | 352 | 835 | 1901 | 28 | 28 | 9 | 9 |
    | Y' p1 | 0.134 | 0.124 | 0.068 | 0.084 | 0.095 | 0.012 | 0.051 | 0.016 | 0.056 |
    | Y' p5 | 0.205 | 0.180 | 0.087 | 0.121 | 0.137 | 0.026 | 0.071 | 0.072 | 0.138 |
    | Y' p25 | 0.417 | 0.356 | 0.150 | 0.257 | 0.278 | 0.253 | 0.366 | 0.212 | 0.324 |
    | Y' p50 | 0.571 | 0.516 | 0.243 | 0.448 | 0.449 | 0.436 | 0.577 | 0.329 | 0.467 |
    | Y' p75 | 0.706 | 0.668 | 0.366 | 0.607 | 0.618 | 0.554 | 0.696 | 0.459 | 0.631 |
    | Y' p95 | 0.895 | 0.828 | 0.669 | 0.870 | 0.857 | 0.746 | 0.844 | 0.688 | 0.778 |
    | Y' p99 | 0.967 | 0.912 | 0.817 | 0.942 | 0.943 | 0.783 | 0.886 | 0.812 | 0.818 |
    | Y' mean | 0.558 | 0.508 | 0.295 | 0.460 | 0.474 | 0.415 | 0.533 | 0.358 | 0.473 |
    | Y' std | 0.202 | 0.182 | 0.176 | 0.221 | 0.205 | 0.213 | 0.228 | 0.195 | 0.197 |
    | non-ink Y' p5 | 0.234 | 0.211 | 0.088 | 0.129 | 0.151 | 0.026 | 0.071 | 0.090 | 0.166 |
    | non-ink Y' p50 | 0.583 | 0.526 | 0.244 | 0.459 | 0.463 | 0.451 | 0.599 | 0.329 | 0.467 |
    | HSV S mean | 0.439 | 0.459 | 0.586 | 0.436 | 0.465 | 0.455 | 0.450 | 0.649 | 0.452 |
    | HSV S p50 | 0.429 | 0.448 | 0.611 | 0.422 | 0.467 | 0.373 | 0.345 | 0.714 | 0.458 |
    | HSV S p90 | 0.737 | 0.671 | 0.840 | 0.764 | 0.762 | 0.843 | 0.844 | 0.969 | 0.655 |
    | colourfulness (H-S) | 82.8 | 71.2 | 66.6 | 68.0 | 71.7 | 65.6 | 85.9 | 62.6 | 61.5 |
    | C*ab lit p50 (L* 40-85) | 33.8 | 36.8 | 36.7 | 28.4 | 31.9 | 23.5 | 26.2 | 22.7 | 25.4 |
    | C*ab lit p90 | 54.9 | 53.0 | 64.4 | 54.9 | 56.6 | 37.4 | 52.8 | 48.9 | 42.5 |
    | local contrast (std 9x9 HP) | 0.072 | 0.051 | 0.048 | 0.066 | 0.062 | 0.063 | 0.072 | 0.035 | 0.042 |
    | ink fraction | 0.026 | 0.019 | 0.018 | 0.025 | 0.023 | 0.048 | 0.048 | 0.015 | 0.015 |
    | vignette corner/centre | 0.856 | 0.754 | 0.461 | 0.671 | 0.719 | 1.458 | 1.222 | 1.430 | 1.148 |
    | vignette mid-edge/centre | 0.975 | 0.952 | 0.753 | 0.866 | 0.904 | 1.093 | 1.046 | 0.912 | 0.862 |
    
    | zone (non-ink pixels) | ref day | ref golden | ref night | ref interior | ref all (D+G+N+I) | ours day | ours golden |
    |---|---|---|---|---|---|---|---|
    | shadows Y'<0.3 share | 0.17 | 0.20 | 0.61 | 0.36 | 0.34 | 0.34 | 0.45 |
    | shadows Y'<0.3 mean a*/b* | +12.5 / -2.5 | +19.1 / -5.8 | +19.7 / -33.1 | +14.8 / -2.5 | +16.5 / -13.4 | +1.0 / -3.2 | +5.3 / +11.4 |
    | shadows Y'<0.3 S-weighted hue (strength) | 331 deg (0.32) | 322 deg (0.54) | 239 deg (0.86) | 333 deg (0.42) | 277 deg (0.41) | 164 deg (0.19) | 43 deg (0.41) |
    | mids share | 0.55 | 0.54 | 0.34 | 0.39 | 0.44 | 0.53 | 0.52 |
    | mids mean a*/b* | +4.0 / +8.1 | +19.4 / +15.8 | +21.3 / -32.4 | +10.3 / +7.1 | +10.5 / +2.7 | +2.4 / +4.2 | +14.1 / +23.1 |
    | mids S-weighted hue (strength) | 56 deg (0.20) | 14 deg (0.71) | 242 deg (0.66) | 17 deg (0.31) | 9 deg (0.18) | 53 deg (0.16) | 27 deg (0.95) |
    | highlights Y'>0.7 share | 0.28 | 0.26 | 0.05 | 0.24 | 0.22 | 0.13 | 0.03 |
    | highlights Y'>0.7 mean a*/b* | -0.5 / +6.2 | +12.3 / +23.3 | +6.0 / +1.6 | +2.8 / +13.1 | +2.7 / +11.1 | -2.2 / -11.6 | +11.5 / +21.6 |
    | highlights Y'>0.7 S-weighted hue (strength) | 58 deg (0.27) | 25 deg (0.93) | 1 deg (0.26) | 36 deg (0.71) | 37 deg (0.55) | 207 deg (0.63) | 25 deg (1.00) |
    | after grade: ours day / ours golden | sh a*/b* +11.2/+0.6 +16.8/-5.6, hue 349/311 | mid a*/b* +2.7/-3.2 +10.2/+12.8, hue 184/24 | hi a*/b* -2.6/-4.0 +14.4/+9.7, hue 193/8 |
    | black point RGB (darkest 0.5 %) | 35,19,20 | 48,16,22 | 9,10,38 | 23,12,20 | 25,13,22 | 2,0,13 | 4,0,7 |
    | darkest 0.5 % of non-ink area | 40,27,33 | 47,22,34 | 10,12,40 | 26,16,27 | 28,18,31 | 2,0,14 | 4,0,7 |
    | white point RGB (brightest 0.5 %) | 254,251,246 | 254,240,220 | 242,230,236 | 253,246,239 | 253,247,240 | 230,206,218 | 240,201,170 |
    
    | ref day hue histogram (S-weighted, %) | red 0 | orange 30 | yellow 60 | chartr. 90 | green 120 | spring 150 | cyan 180 | azure 210 | blue 240 | violet 270 | magenta 300 | rose 330 |
    |---|---|---|---|---|---|---|---|---|---|---|---|---|
    | sh | 25 | 26 | 1 | 1 | 1 | 0 | 5 | 13 | 17 | 3 | 3 | 6 |
    | mid | 7 | 40 | 4 | 9 | 4 | 1 | 4 | 28 | 3 | 0 | 0 | 1 |
    | hi | 1 | 50 | 8 | 2 | 1 | 1 | 5 | 29 | 2 | 0 | 0 | 0 |
    
    | ours day hue histogram (S-weighted, %) | red 0 | orange 30 | yellow 60 | chartr. 90 | green 120 | spring 150 | cyan 180 | azure 210 | blue 240 | violet 270 | magenta 300 | rose 330 |
    |---|---|---|---|---|---|---|---|---|---|---|---|---|
    | sh | 8 | 26 | 3 | 0 | 2 | 15 | 13 | 29 | 3 | 0 | 0 | 2 |
    | mid | 2 | 53 | 1 | 1 | 0 | 1 | 3 | 37 | 1 | 0 | 0 | 0 |
    | hi | 0 | 17 | 0 | 0 | 0 | 4 | 2 | 76 | 0 | 0 | 0 | 0 |
    
    | ref golden hue histogram (S-weighted, %) | red 0 | orange 30 | yellow 60 | chartr. 90 | green 120 | spring 150 | cyan 180 | azure 210 | blue 240 | violet 270 | magenta 300 | rose 330 |
    |---|---|---|---|---|---|---|---|---|---|---|---|---|
    | sh | 29 | 14 | 0 | 0 | 0 | 0 | 0 | 8 | 12 | 14 | 9 | 12 |
    | mid | 21 | 49 | 7 | 2 | 0 | 0 | 1 | 2 | 6 | 2 | 4 | 5 |
    | hi | 12 | 77 | 6 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 3 |
    
    | ours golden hue histogram (S-weighted, %) | red 0 | orange 30 | yellow 60 | chartr. 90 | green 120 | spring 150 | cyan 180 | azure 210 | blue 240 | violet 270 | magenta 300 | rose 330 |
    |---|---|---|---|---|---|---|---|---|---|---|---|---|
    | sh | 8 | 45 | 5 | 14 | 1 | 1 | 1 | 24 | 0 | 0 | 1 | 0 |
    | mid | 12 | 80 | 3 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 |
    | hi | 0 | 100 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
    
    | radial bin r | 0.0-0.1 | 0.1-0.2 | 0.2-0.3 | 0.3-0.4 | 0.4-0.5 | 0.5-0.6 | 0.6-0.7 | 0.7-0.8 | 0.8-0.9 | 0.9-1.0 |
    |---|---|---|---|---|---|---|---|---|---|---|
    | ref day | 1.00 | 1.00 | 1.00 | 1.02 | 1.01 | 1.00 | 0.97 | 0.95 | 0.90 | 0.86 |
    | ref golden | 1.01 | 1.00 | 1.00 | 0.98 | 0.97 | 0.94 | 0.89 | 0.85 | 0.80 | 0.75 |
    | ref night | 1.06 | 1.00 | 0.94 | 0.86 | 0.79 | 0.70 | 0.62 | 0.55 | 0.51 | 0.46 |
    | ref interior | 1.03 | 1.00 | 0.97 | 0.94 | 0.90 | 0.85 | 0.80 | 0.77 | 0.71 | 0.67 |
    | ref all (D+G+N+I) | 1.02 | 1.00 | 0.98 | 0.96 | 0.94 | 0.89 | 0.84 | 0.81 | 0.76 | 0.72 |
    | ours day | 0.86 | 1.01 | 1.11 | 1.18 | 1.26 | 1.35 | 1.35 | 1.39 | 1.38 | 1.46 |
    | ours golden | 1.03 | 1.00 | 1.02 | 1.04 | 1.06 | 1.24 | 1.29 | 1.39 | 1.47 | 1.43 |
    
    ### 2b. Per-video consistency (day frames)
    
    Every video agrees on the direction:
    
    - warm-rose dark zone, with a* positive in all 13 videos;
    - lifted, warm black point;
    - near-white tops, with p99 ≥ 0.94.
    
    The size varies: median Y' runs 0.38-0.68, non-ink p5 0.09-0.38, and lit C* p50 18-53.
    
    | video | D | G | N | I | X | day Y' p50 | day non-ink Y' p5 | day Y' p99 | day C*lit p50 / p90 | day colourf. | day corner/centre | day dark-zone a*/b* | day black pt RGB |
    |---|---|---|---|---|---|---|---|---|---|---|---|---|---|
    | 01 | 54 | 0 | 56 | 19 | 1 | 0.39 | 0.09 | 1.00 | 53 / 77 | 99 | 0.65 | +13 / -2 | 16,4,9 |
    | 02 | 64 | 11 | 0 | 50 | 4 | 0.51 | 0.27 | 0.96 | 37 / 53 | 77 | 0.77 | +8 / -4 | 26,13,19 |
    | 03 | 69 | 5 | 20 | 5 | 32 | 0.59 | 0.23 | 0.95 | 26 / 64 | 67 | 0.93 | +8 / -0 | 25,14,18 |
    | 04 | 63 | 11 | 10 | 26 | 3 | 0.51 | 0.19 | 0.95 | 32 / 59 | 86 | 0.90 | +13 / -6 | 38,24,38 |
    | 05 | 22 | 0 | 90 | 33 | 11 | 0.65 | 0.29 | 0.94 | 33 / 52 | 88 | 0.88 | +10 / -9 | 37,18,10 |
    | 06 | 0 | 0 | 0 | 87 | 40 | - | - | - | - | - | - | - | - |
    | 07 | 12 | 31 | 46 | 23 | 7 | 0.38 | 0.21 | 0.98 | 49 / 75 | 100 | 0.98 | +24 / +6 | 18,10,19 |
    | 08 | 0 | 4 | 12 | 89 | 11 | - | - | - | - | - | - | - | - |
    | 09 | 0 | 0 | 6 | 112 | 3 | - | - | - | - | - | - | - | - |
    | 10 | 12 | 0 | 24 | 73 | 8 | 0.61 | 0.25 | 0.96 | 35 / 73 | 91 | 0.90 | +10 / -0 | 58,32,28 |
    | 11 | 14 | 30 | 26 | 60 | 0 | 0.68 | 0.29 | 0.97 | 18 / 36 | 58 | 0.83 | +23 / +5 | 49,29,34 |
    | 12 | 78 | 33 | 0 | 5 | 11 | 0.57 | 0.27 | 0.95 | 32 / 43 | 87 | 0.99 | +9 / +1 | 34,20,20 |
    | 13 | 63 | 4 | 3 | 0 | 12 | 0.48 | 0.22 | 0.98 | 42 / 59 | 93 | 0.84 | +15 / +14 | 44,23,24 |
    | 14 | 51 | 3 | 6 | 30 | 0 | 0.65 | 0.35 | 0.98 | 27 / 41 | 69 | 0.79 | +14 / +14 | 52,32,28 |
    | 15 | 0 | 0 | 0 | 12 | 57 | - | - | - | - | - | - | - | - |
    | 16 | 0 | 0 | 53 | 38 | 0 | - | - | - | - | - | - | - | - |
    | 17 | 0 | 0 | 0 | 38 | 24 | - | - | - | - | - | - | - | - |
    | 18 | 40 | 6 | 0 | 21 | 13 | 0.56 | 0.19 | 0.99 | 48 / 73 | 90 | 0.96 | +9 / -8 | 31,16,15 |
    | 19 | 10 | 24 | 0 | 54 | 7 | 0.66 | 0.38 | 0.96 | 21 / 36 | 67 | 0.81 | +8 / +1 | 48,31,25 |
    | 20 | 0 | 0 | 0 | 60 | 19 | - | - | - | - | - | - | - | - |
    | **all day** | 552 | 162 | 352 | 835 | 263 | 0.57 | 0.23 | 0.97 | 34 / 55 | 83 | 0.86 | +11 / +0 | 35,19,20 |
    Y_p50 per-video day median range 0.38 - 0.68 over 13 videos
    A_p5 per-video day median range 0.09 - 0.38 over 13 videos
    C_lit_p50 per-video day median range 17.68 - 52.80 over 13 videos
    colourfulness per-video day median range 58.31 - 99.51 over 13 videos
    
    ### 2c. Per-hue lit colour (pooled pixels; brightest 30 % of the S > 0.25 pixels in each 30° hue bin)
    
    These are the albedo and lighting targets for section 6.
    
    | hue (day) | ref share | ref lit colour (L*, C*) | ours share | ours now (L*, C*) | ours + grade (L*, C*) |
    |---|---|---|---|---|---|
    | red 0 | 9 % | #e85648 (56, 69) | 3 % | #983c22 (38, 52) | #c55c4f (51, 40) |
    | orange 30 | 38 % | #e3b684 (77, 31) | 37 % | #b7906f (62, 23) | #daad89 (73, 24) |
    | yellow 60 | 3 % | #d3c677 (80, 44) | 1 % | #6d6b3f (45, 20) | #e4d283 (84, 40) |
    | chartreuse 90 | 7 % | #7cbb48 (70, 66) | 1 % | #6fb03a (66, 66) | #80d23e (77, 80) |
    | green 120 | 3 % | #58af47 (65, 66) | 1 % | #246014 (36, 50) | #3b9f27 (58, 72) |
    | spring 150 | 1 % | #93cdb6 (78, 24) | 6 % | #0e5c2e (34, 39) | #0b8550 (49, 48) |
    | cyan 180 | 5 % | #6dc7ce (75, 28) | 7 % | #32938a (55, 31) | #30c3c5 (72, 38) |
    | azure 210 | 27 % | #8fc4fa (77, 29) | 42 % | #9abfe9 (76, 25) | #addaff (85, 23) |
    | blue 240 | 5 % | #6d76aa (51, 28) | 1 % | #4d5384 (36, 27) | #767aac (53, 27) |
    | violet 270 | 1 % | #5d4c72 (36, 24) | 0 % | #543f60 (31, 20) | #261f2b (13, 9) |
    | magenta 300 | 1 % | #713e74 (34, 43) | 0 % | - | #2d132c (10, 19) |
    | rose 330 | 1 % | #733d56 (34, 25) | 1 % | #5f0622 (19, 40) | #920f38 (32, 53) |
    
    | hue (golden) | ref share | ref lit colour (L*, C*) | ours share | ours now (L*, C*) | ours + grade (L*, C*) |
    |---|---|---|---|---|---|
    | red 0 | 20 % | #f09a93 (71, 33) | 6 % | #d0a296 (70, 20) | #b06762 (48, 21) |
    | orange 30 | 47 % | #ffc590 (83, 37) | 66 % | #d0a377 (70, 28) | #e5b992 (78, 27) |
    | yellow 60 | 5 % | #fee792 (92, 41) | 4 % | #536008 (38, 45) | #7f8c37 (56, 46) |
    | chartreuse 90 | 1 % | #6e8f35 (55, 51) | 8 % | #3e5405 (33, 43) | #637636 (47, 37) |
    | green 120 | 0 % | #628e62 (55, 28) | 1 % | #2e6e1e (41, 49) | #448145 (48, 43) |
    | spring 150 | 0 % | #76b197 (68, 26) | 1 % | #09422a (24, 24) | #36784f (45, 37) |
    | cyan 180 | 1 % | #36aaae (64, 32) | 0 % | #304849 (29, 10) | #175961 (34, 20) |
    | azure 210 | 4 % | #668cbe (58, 37) | 13 % | #283f50 (25, 13) | #475f8e (40, 28) |
    | blue 240 | 7 % | #717be8 (54, 56) | 0 % | - | #5a648d (43, 24) |
    | violet 270 | 5 % | #8967b1 (49, 48) | 0 % | - | #5a307a (30, 35) |
    | magenta 300 | 5 % | #a065a0 (50, 38) | 0 % | #4c1357 (19, 46) | #442645 (20, 22) |
    | rose 330 | 6 % | #d588a5 (65, 34) | 0 % | #3e2a34 (20, 13) | #60293e (25, 28) |
    
    ### 2d. Lit vs shadow on the same material
    
    Hand-picked patches, Otsu split. Crops: `out/shadow_crops_ref.jpg` and `out/shadow_crops_ours.jpg`.
    
    | frame | lit | shadow | linear lit/shadow |
    |---|---|---|---|
    | ref 04, day sand | #d2ac81 (L* 73) | #6a6d92 (L* 47, b* −21) | 2.8 |
    | ref 18, day pavement | #edd8bf (L* 87) | #7481b8 (L* 55, b* −30) | 3.1 |
    | ref 12, day deck | #e1c38d (L* 80) | #5e6474 (L* 42) | 4.4 |
    | ref 04, golden sand | #d28d4a (L* 64) | #514d74 (L* 35) | 4.0 |
    | ours b_1900, deck | #a76d30 (L* 51) | #452512 (L* 19, b* +19, brown) | 7.5 |
    | ours b_100 | #b17332 (L* 54) | #5f4116 (L* 30) | 3.4 |
    
    The reference shadows are lighter than ours and blue-violet. Their B channel is often higher than the lit side's: lit B 129 against shadow B 146. That cannot come from a darker version of one albedo, so it is a painted shadow colour.
    
    Sky, top 10 % / 10-20 % / 20-30 % of rows:
    
    | | top 10 % | 10-20 % | 20-30 % |
    |---|---|---|---|
    | ref day | #5997e2 (S .61) | #70adf1 | #82b8f5 |
    | ours day | #8db7e7 (S .39) | #95bce8 | #9dc1e9 |
    
    - Ref golden: the top is violet, #937b80 (IQR #61426f to #da97a1); rows 20-40 % are #d99d83 peach.
    - Ours golden: the top is #b6919e to #d2a294, pink-mauve.
    
    ## 3. Side-by-sides and sheets
    
    Each side-by-side is three panels: ours now | ours + fitted grade | reference frame with similar content.
    
    - Day: `out/side_by_side/sbs_c_140.jpg`, `sbs_b_470.jpg`, `sbs_c_1745.jpg`, `sbs_b_1180.jpg`, `sbs_c_1075.jpg`.
    - Golden: `out/side_by_side/sbs_c_1340.jpg`, `sbs_c_1450.jpg`, `sbs_c_1650.jpg`, `sbs_b_1500.jpg`.
    - All 39 stills, before on top and graded below: `out/graded_overview.jpg`. Full-size graded PNGs are in `out/graded/`.
    - Tone curves and Y' CDFs (ref, ours now, ours graded): `out/curves_cdf.png`.
    - Clusters: `out/clusters_overview.jpg`, `out/clusters/`.
    
    What the side-by-sides show:
    
    - **Day.** The grade reads clearly better: brighter, a punchy blue sky and water, warmer deck, near-whites, and a light vignette.
    - **Golden.** It gets brighter and loses the orange-soup cast, but it turns lilac and slightly washed. The reference golden keeps saturated reds and oranges under a violet sky, and grading cannot add those hues (section 6).
    
    ## 4. The fitted grade
    
    The fit is `scripts/fit2.py`, staged and regularised. The model works on sRGB-encoded values and is identical in numpy (`scripts/grade.py`) and GLSL. Steps, in order:
    
    1. **Tone curve.** One RGB curve, a monotone cubic Hermite (Fritsch-Carlson) through 7 knots at x = 0, .1, .25, .5, .75, .9, 1.
       - It is fitted to the non-ink Y' quantiles of the matching reference cluster.
       - Constraints: smoothness penalty, slope ≥ 0.35, y(1) = 1, black point Y' ≈ 0.035.
    2. **Saturation around Y'.** Separate amounts for shadows, mids and highlights.
       - Masks: wS = 1 − smoothstep(0, .55, Y'), wH = smoothstep(.45, 1, Y'), wM = rest.
       - Fitted to zone chroma, lit chroma, HSV S and colourfulness, bounded to 0.6-1.6.
    3. **Three-way tint.** A zero-luma BT.709 Cb/Cr offset per zone.
       - Fitted to the a*/b* of near-neutral pixels (C* < 20), the dark-zone colour and the per-frame black point.
       - Penalties: chroma preservation, L2, and a cap of 28/255 per channel.
    4. **Vignette.** × (1 − k·smoothstep(r0, 1, r)), fitted to the reference cluster mean images after content normalisation.
       - Measured: D k = 0.06, r0 = 0.50; G k = 0.11, r0 = 0.39.
       - Used slightly stronger, because our sky brightens the corners.
    
    | | DAY (fit to ref D) | GOLDEN (fit to ref G) |
    |---|---|---|
    | curve y at x = 0, .1, .25, .5, .75, .9, 1 | 0.0345, 0.1828, 0.3812, 0.6605, 0.8814, 0.9631, 1 | 0.0311, 0.1877, 0.4017, 0.6754, 0.8750, 0.9582, 1 |
    | curve tangents | 1.483, 1.404, 1.220, 0.987, 0.661, 0.435, 0.369 | 1.567, 1.498, 1.253, 0.924, 0.645, 0.472, 0.418 |
    | saturation sh / mid / hi | 0.983 / 1.273 / 1.457 | 0.832 / 0.880 / 1.706 |
    | shadow tint (Cb, Cr) | (−0.0291, +0.0411): hue 20° warm red, RGB (+16.5, −3.5, −13.8)/255 | (+0.0528, +0.0004): hue 246° blue-violet, RGB (+0.2, −2.6, +25.0)/255 |
    | mid tint (Cb, Cr) | (+0.0376, −0.0104): hue 230° cool, RGB (−4.2, −0.6, +17.8)/255 | (+0.0583, −0.0003): hue 245°, RGB (−0.1, −2.7, +27.6)/255 |
    | highlight tint (Cb, Cr) | (+0.0144, −0.0047): RGB (−1.9, −0.1, +6.8)/255 | 0 (see note) |
    | vignette k, r0 | 0.10, 0.45 | 0.16, 0.40 |
    
    Reading the presets:
    
    - **Day.** Lift blacks warm, and brighten the mids by about +0.2 at 0.25-0.5. Roll off the top. Boost mid and highlight saturation, cool the yellow mids, and add a light vignette.
    - **Golden.** Brighten hard. Pull saturation down in shadows and mids, where our orange is over-saturated. Cancel the orange cast with blue-violet in the shadows and mids, so they move from brown to violet. Use a stronger vignette.
    
    Golden highlight tint: the fit wanted a violet highlight tint, but it was driven by only 754 neutral highlight pixels and moved the highlight mean away from the reference's orange. It was therefore set to 0.
    
    Golden is the weaker fit. The golden look depends on hue content our golden scenes do not have. Use `amount` 0.8 for golden.
    
    Stability: a first run with neutral-only tint targets reproduced the same day preset to within 0.01 (`data/grade_params_v1_neutral_only.json`). It made golden explode to a +71/255 magenta shadow tint, which is why the tint stage got its chroma, black-point and cap terms. Logs: `data/fit2_v2.log`. Presets: `data/grade_params.json`.
    
    ## 5. Implementation recipe (our stack)
    
    **A. New file `src/videos/bridge/grade.ts`.**
    
    - Copy `references/grade/GradeEffect.ts` verbatim. It needs postprocessing 6.37.2 and three 0.175.
    - It contains the GLSL `mainImage` with the uniforms `gradeKy[7]`, `gradeKm[7]`, `gradeSat`, `gradeTintS/M/H`, `gradeVig` and `gradeAmount`. It also has the `GradeEffect` class with `set(p, q?, t)` for blending presets and an `amount` dial, plus the `GRADE_DAY` / `GRADE_GOLDEN` constants from the table above.
    - The core of the shader:
    
    ```glsl
    void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
      vec3 src = gradeEncode(inputColor.rgb);                 // linear (after tone mapping) -> sRGB
      vec3 c = vec3(gradeCurve(src.r), gradeCurve(src.g), gradeCurve(src.b));
      const vec3 W = vec3(0.2126, 0.7152, 0.0722);
      float y = dot(c, W);
      float wS = 1.0 - smoothstep(0.0, 0.55, y), wH = smoothstep(0.45, 1.0, y), wM = 1.0 - wS - wH;
      float s = gradeSat.y + (gradeSat.x - gradeSat.y) * wS + (gradeSat.z - gradeSat.y) * wH;
      c = vec3(y) + s * (c - vec3(y));
      c += wS * gradeTintS + wM * gradeTintM + wH * gradeTintH;
      float r = length(uv * 2.0 - 1.0) * 0.70710678;          // 1 at the corners (same as the numpy fit)
      c *= 1.0 - gradeVig.x * smoothstep(gradeVig.y, 1.0, r);
      c = clamp(c, 0.0, 1.0);
      outputColor = vec4(gradeDecode(mix(src, c, gradeAmount)), inputColor.a);
    }
    ```
    
    **B. `Bridge.tsx`, inside `Inner`.** Put the grade last, after `fx.tone`, and choose the preset from the shot's `light`:
    
    ```tsx
    import { GradeEffect, GRADE_DAY, GRADE_GOLDEN } from "./grade";
    import { SHOTS } from "./story";
    // in the fx useMemo:
      grade: new GradeEffect(GRADE_DAY, 1.0),
    // in <EffectComposer>, AFTER <primitive object={fx.tone} dispose={null} />:
      <primitive object={fx.grade} dispose={null} />
    // every render (the "base"/"over" Inner instances change shot id over time):
    const light = (SHOTS.find((s) => s.id === id) ?? SHOTS[0]).light;
    fx.grade.set(light === "golden" ? GRADE_GOLDEN : GRADE_DAY);
    fx.grade.amount = light === "golden" ? 0.8 : 1.0;
    ```
    
    The grade is display-referred and expects tone-mapped input in 0..1, so it must come after `ToneMappingEffect(NEUTRAL)`. Bloom stays before tone mapping, as now.
    
    **C. Re-fit after the lighting and albedo work lands.** The grade was fitted to today's stills, so once renders get brighter or more colourful it will over-correct. Either:
    
    - lower `amount` (0.5-0.7); or
    - re-fit in about 5 minutes. Put the new stills in a folder and point `SRC` in `scripts/run_ours.py` and `scripts/validate.py` at it. Then run `run_ours.py` → `samples.py` → `fit2.py day golden` → `emit_ts.py` → `validate.py data/grade_params.json`.
    
    **D. Cheap source-side wins.** These reduce how hard the grade has to work.
    
    - `lights.ts` day sky: `hor: "#c4e0f7"` → `"#8fc0f2"`.
    - `world.ts` sky shader: change the exponent `pow(h, 0.55)` → `pow(h, 0.4)`. The frame top is only about 20° above the horizon, so today the pale horizon colour dominates and the sky top reads #8db7e7 instead of #5997e2.
    - `lights.ts` golden sky: `top: "#5a66b8"` → `"#6a4f8c"` (violet) and `hor: "#ffc48c"` → `"#f7a27e"` (peach).
    - These are untested starting points (no renders allowed in this pass). Check the final frame against the sky targets in 2d.
    
    ## 6. What a grade cannot fix: targets for the lighting and texture work
    
    Measure these on the final graded frame. The reference numbers are final-frame values.
    
    1. **Cast shadows.** Ours are brown and near-black: L* 19-30, b* +19, lit/shadow ratio up to 7.5:1.
       - Target: a sunlit/shadow linear ratio of 2.8-4.4:1 on the same material, median about 3.5:1 day and 4:1 golden.
       - Shadow L* 42-55 day, about 35 golden, blue-violet with b* −20 to −30. Examples: #6a6d92, #7481b8, #5e6474; golden #514d74.
       - A grade cannot separate shadow from lit at equal luma. Do it in the material: a shadow-tint term added where sun visibility is below 1, plus a blue-violet hemisphere ground/sky. For example, in `onBeforeCompile`: `outgoingLight = mix(outgoingLight, outgoingLight * 0.55 + vec3(0.20, 0.21, 0.36) * 0.35, 1.0 - sunVis)`.
    2. **Albedo chroma and lightness** (table 2c).
       - Even after the grade, our lit reds are #c55c4f (L* 51, C* 40) against #e85648 (L* 56, C* 69), and our greens are dark spring green.
       - Paint targets for lit day surfaces:
         - red #e85648
         - orange/wood #e3b684 (L* 77)
         - yellow #d3c677
         - green #7fbc4e / #5ab04c (L* 65-70, C* 64)
         - azure #93c6f9
         - blue #7980b0
       - Lit-surface chroma targets: C* p50 ≥ 32 and p90 ≥ 55 (graded day: 26 / 53).
    3. **Large black areas** (shakos, coats, chains, thick outlines).
       - Ours have non-ink p5 = 0.026, and 0.071 even after the grade; the reference is 0.234.
       - Dark materials must be coloured darks at L* 16-22, not black: navy #262c51, aubergine #2e2439, plum #3d2530, oxblood #4d2725.
       - Ink should be about 2-2.6 % of pixels (ours is 4.8 %), as thin lines with warm near-black cores of about (35,19,20).
    4. **Missing hue families in golden.**
       - The reference golden is about 20 % red/rose (#f09a93, #d588a5) and about 17 % blue/violet/magenta (#717be8, #8967b1, #a065a0). Ours is 66 % orange, with almost no violet or blue.
       - The grade only shifts the cast. Golden needs violet sky and shadow fill (`ground: "#4f3f84"` is right; make the shadow term violet) and red/rose accents in the set dressing.
    5. **Flat, emissive-looking characters.** cast.ts uses emissive ≈ 0.36, which flattens their tone range, so no curve can create real form shading on them. Lower emissive to ≤ 0.12 and let the sun and hemisphere shade them (the lighting report owns this).
    
    ## 7. Top 5 changes, ranked by visual impact
    
    1. **Add `GradeEffect` after tone mapping**, with the day/golden presets per shot (section 5 A-B).
       - Day: median Y' goes .436 → .577 (ref .571), p99 .78 → .89, colourfulness 66 → 86 (ref 83), lit C* p90 37 → 53 (ref 55).
       - Day: vignette and the warm lifted black point come for free.
    2. **Recolour cast shadows in the material:** lighter, blue-violet, lit/shadow ratio about 3.5:1, shadow L* 42-55 (6.1).
    3. **Replace black materials with coloured darks** (L* 16-22, hex above) and thin the ink to 2-2.6 %. This removes our biggest remaining tonal gap: non-ink p5 0.07 against 0.23.
    4. **Raise albedo chroma and lightness** to the per-hue targets (red #e85648, green #7fbc4e, wood #e3b684), and saturate the day sky toward #5997e2 at the top (6.2, 5D).
    5. **Golden-specific:** violet sky top #6a4f8c with peach horizon, violet shadow fill and red/rose accents. Then re-fit the grade (5C) so golden stops needing the strong blue tint.
    
  • references/reports/lighting.md42 KB3bbe884dd4e4upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/reports/motion.md27 KBba256d4852bfupstream ↗
    # Motion: reference vs ours (camera grammar + character animation)
    
    Units: **px = pixels at the reference size 720x1280** (multiply by 1.5 for our 1080x1920). **f** = frame at 30 fps.
    Reference = 20 shorts, 32,337 frames, burned-in caption band excluded from every camera measurement.
    Ours = `story.ts` / `cast.ts` / `toon.ts` as of 2026-10-31. Rendering was off-limits, so ours is measured **from the code with
    the same maths**: `ourcam.py` runs the exact `CAMS` + `run()` + `camAt()` (a private copy, executed with node) through the same
    per-frame similarity fit and the same statistics as the reference; `ourwalk.py` evaluates the `bodyFront` / `bodySide` formulas.
    
    ## 0. Verdict
    
    1. **Our camera moves are 3-frame snaps; the reference glides.** Our key moves peak at a median 115 px/f (up to 488) with a 3 f
       attack. Reference moves are symmetric ease-in-out: ~10 f in, ~10 f out, ~23 f total, with a median peak of 14-16 px/f.
       The "ease-out burst" grammar in STYLE_BIBLE.md (and in the `story.ts` header comment) came from an integer phase
       correlation that cannot see motion below ~4 px/f. It is wrong.
    2. **The reference camera is almost never dead still.** 65 % of frames move. Even in quiet frames it keeps pushing in
       (0.14 %/f median). There is no hand-held float at all. Impacts are 6-7 frame kicks that flip sign every frame.
    3. **Characters are animated on ones.** No line boil, no squash/stretch, no smears, no idle loops. Everything happens on
       acting beats that land 0-3 f before the word.
    4. **Walks are bouncy and planted.**
       - Body bob is 3-7 % of height; ours is 1.7 % in front view.
       - Lean swings from 1° to 7° every step.
       - A step takes 9-12 f; ours takes 15 f, and 20-38 f for the tourists.
       - The stance foot sticks to the ground; ours skate 13-60 % in side views.
    5. **Blinks are frequent and shaped:** every 0.5-2.3 s, 4 frames (half / shut / shut / half), plus one on every expression
       swap. Ours blink every 4 s and only open/shut.
    
    ## 1. Method (scripts in `references/motion/scripts/`)
    
    - **Camera (`cam2.py`, `camstats.py`, `camshape.py`)**
      - Each frame is fitted against the previous one: a robust 9-tile sub-pixel phase-correlation similarity fit (translation,
        zoom, roll) at 360x640. Tile bands are 3-29 %, 29-56 % and 80-97 % of the height, so captions are excluded. Outlier tiles
        are rejected.
      - The fits are integrated into a path and smoothed with a Gaussian of σ = 1.2 f.
      - Speed is translation combined with zoom × 734 px (the frame-corner radius).
      - Classes: hold < 0.3 px/f, drift < 2, move ≥ 2.
      - Bursts are local peaks ≥ 6 px/f. Attack and release are measured to 10 % of the peak.
    - **Ours (`ours/dump.mjs`, `ourcam.py`)**
      - `camAt()` is projected through a 5x5 point grid on the subject plane into the next frame, then fitted the same way.
      - Shot boundaries with no on-screen cut (shots 3-8 and 10 start from the previous framing) are kept as moves.
      - Real cuts (flash, crossfades) are masked like the reference cuts.
    - **Characters**
      - Contact sheets of 2-4 s at 30 fps. 
      - NCC template tracking (`track.py`), compensated for camera motion with background patches (`idle.py`, `walkstat.py`).
      - Frame-diff hold detection (`uniq.py`).
      - Eye-strip dark-pixel counts (`blink.py`).
      - Mouth blob area vs the voice RMS envelope, tested at lags −4..+4 f (`mouth.py`).
    
    ## 2. Camera
    
    ### 2.1 Reference vs ours
    
    | Measure | Reference (median, range over 20 videos) | Ours (computed from story.ts) | Gap |
    |---|---|---|---|
    | Hold (< 0.3 px/f) | 10 % (1-27 %) | 22 % | more dead-still frames |
    | Drift (0.3-2 px/f) | 24 % (4-38 %) | 22 % | ≈ |
    | Move (≥ 2 px/f) | 65 % (34-94 %) | 55 % | ≈ |
    | Median speed | 3.6 px/f (1.3-12.9) | 2.9 px/f | ≈ |
    | p90 speed | 16.9 px/f (9.7-29.4) | 28.1 px/f | ours has spikes |
    | Moves per 10 s | 6.0 (2.9-8) | 2.2 | half as many |
    | Move peak speed | 13.7 px/f (p25 5.9, p75 24.6, p90 36) | **115 px/f** (79-488 on the big ones) | **8x too fast** |
    | Burst attack (10 % to peak) | 10 f (p25 6, p75 18) | **3 f** | snap |
    | Burst release (peak to 10 %) | 10 f (p25 6, p75 19) | 6 f | |
    | Attack / release | 1.0; 62 % symmetric, 18 % ease-out, 20 % ease-in | 0.57; quartic ease-out puts 26-41 % of the move in frame 1 (dur 14-8) | wrong curve |
    | Speed at ±10 f from the peak (median profile) | 0.6 of peak (full width at half max > 20 f) | 0.03-0.05 of peak (full width at half max ≈ 5 f) | see the profile plot |
    | Size of one move | 101 px, zoom 23 % | 331 px, zoom 46 % | 3x bigger moves in 1/4 of the time |
    | Strong moves (peak ≥ 25 px/f) | attack 8 f, release 7 f; e.g. v14 160-240: 1→38 px/f in 16 f, 12 f plateau, 13 f release | 3 f attack, 5-7 f release | |
    | Zoom in quiet frames (translation < 2 px/f) | active (≥ 0.05 %/f) in 70 % of frames, 83 % of those push in; rate median 0.14 %/f, p75 0.34, p90 0.70 | active 55 %, 63 % push in; median 0.06 %/f, p75 0.16 | pushes 2.4x slower, often lateral drift instead |
    | Push-in runs (≥ 12 f) | 336 push-ins vs 180 pull-outs; median 35 f, +22 %, 0.61 %/f. Slow ones (< 0.3 %/f): 25 f, +3.7 % | linear `drift()` | no deliberate push-ins |
    | Snap zoom / crane | v08 snap zoom 0.4→5.9 %/f (+38 % in 14 f); v03 crane pull-back up to −4.6 %/f, ~45 f speeding up, ~60 f slowing down | 13 moves that zoom by up to 275 % or travel 0.7-3.7 frame widths in 3-8 f | |
    | Roll moves | 3.2 per 10 s, median 3.6° over 18 f (p75 8.1°, p90 17.5°) | 3.0 per 10 s, ~2.5° over ~9 f (max 9.6°) | half as long, a bit small |
    | Hand-held float | **none**: high-frequency residual 0.08 px median (0.05-0.29) | 0.006 m sine float = 0.03 px/f median (invisible) | delete it |
    | Impact shake | v14 spear: offset flips sign **every frame** (15 Hz), ~3 px offset (≈5 px/f), roll ±0.1-0.35°, gone in 6-8 f; the push-in stops at the hit | 6.4 Hz sine, linear decay over 15 f: bolt 28.7 px/f, deck→water 15.5, hammer 10.7 on the first frame | 2-5x stronger, 2x longer, slow wobble |
    | Whip pans | 0.45 per minute (5 real ones in 18 min). 45-93 px/f for 7-25 f, 2-3 f ramp up and down, **strong directional motion blur**: v13 175-183 (30→93→25), v07 58-90 (~47 vertical), v09 1334-1343 (peak 56) | none on purpose, but 13 un-blurred "fly" moves at 79-488 px/f | |
    | Crowd beats | formations stand still while a crane or truck move supplies the motion (v03 51 s, v14 0 s and 125 s) | marching column (story), townsfolk waving and hopping nonstop | keep the march, still the extras |
    
    ### 2.2 Our key moves (`story.ts` CAMS)
    
    Each move is measured as above. "Needed" is the glide length that keeps the peak at ≤ 25 px/f (the reference p75), using
    smootherstep (peak = 1.875 × distance / duration).
    
    | Key f | Shot | Move | Zoom | Peak now | Needed | Do |
    |---|---|---|---|---|---|---|
    | 48 | 1 | 129 px | +64 % | 106 px/f | 37 f | glide 37 f |
    | 88 | 1 | 338 | +34 % | 94 | 31 f | glide 32 f |
    | 190 | 2 | 36 | +24 % | 37 | 13 f | glide 18 f |
    | 246 | 2 | 97 | +26 % | 47 | 16 f | glide 18 f |
    | 283 | 3 | 150 | +170 % | 164 | 94 f | **cut** (delete the copy of shot 2's framing) |
    | 353 | 3 | 346 | +54 % | 112 | 40 f | glide 40 f |
    | 444 | 4 | 1574 | −104 % | 373 | 120 f | **cut** (delete key 440) |
    | 497 | 4 | 606 | +39 % | 80 | 50 f | **cut** |
    | 534 | 5 | 898 | −6 % | 203 | 68 f | **cut** (delete key 530) |
    | 585 | 5 | 22 | +20 % | 35 | 11 f | glide 18 f |
    | 643 | 6 | 486 | −39 % | 79 | 42 f | **cut** (delete key 640) |
    | 717 | 6 | 317 | −126 % | 218 | 73 f | **cut** |
    | 768 | 6 | 254 | +38 % | 76 | 28 f | glide 29 f |
    | 823 | 7 | 204 | −57 % | 84 | 35 f | glide 35 f (or cut) |
    | 871 | 7 | 411 | +88 % | 159 | 58 f | **cut** |
    | 925 | 7 | 1437 | −1 % | 276 | 108 f | **cut** |
    | 987 | 8 | 2011 | −275 % | 427 | 160 f | **cut** (delete key 984) |
    | 1039 | 8 | 2631 | +39 % | 488 | 198 f | **cut**, and kick on the snap |
    | 1062 | 8 | 826 | −86 % | 230 | 78 f | **cut** |
    | 1099 | 8 | — | — | — | — | already a cut (dur 1) |
    | 1217 | 10 | 57 | +24 % | 39 | 14 f | glide 18 f |
    | 1560 | 12 | 49 | −35 % | 45 | 20 f | glide 20 f |
    | 1685 | 13 | 1045 | +28 % | 115 | 80 f | **cut** |
    | 1770 | 14 | 1928 | +75 % | 392 | 150 f | **cut** |
    | 1905 | 15 | 15 | +46 % | 76 | 26 f | glide 26 f |
    | 1962 | 15 | 164 | −153 % | 202 | 85 f | **crane pull-out**: start ~1935, glide 90 f to the end (peak ≈3 %/f; reference cranes reach 4.6 %/f) |
    
    ## 3. Character animation
    
    | Item | Reference (measured) | Ours (from code) | Gap |
    |---|---|---|---|
    | Drawing rate | **on ones**. A moving character changes every frame; no 2s or 3s holds. Static characters are pixel-identical (mean abs diff 0.15 grey levels). | March poses quantised at 1/120 of a cycle (ones). **Tourists use `q(…, 24)` at 0.39-0.75 Hz, so each pose holds 1.7-3.2 f.** | tourists are on 2s/3s |
    | Step length in time | 9-12 f per step (2.5-3.3 steps/s): v04 cowboy 10.6 f, v13 carriers ~9 f, v03 kid ~12 f | march 15 f (2.0 steps/s); tourists 20-38 f (0.8-1.5 steps/s) | march 25-65 % slower; tourists 2-3x slower |
    | Body bob, peak to peak | 3-7 % of height, lowest at contact: cowboy 47 px on a 617 px figure = 7.6 % (0.32 head heights); carriers ~4 %; kid ~3 % | front march 14 units = **1.7 %**, stroll **1.0 %**. Side view (from leg geometry) 4.1 % at stomp 1.0 → 9.0 % at stomp 1.5. Lowest at contact (correct). | front view 3-4x too flat |
    | Lean | 1° at passing → 5-7° forward at contact, in phase with the bob (cowboy) | side: a constant 3°; front: none | no lean cycle |
    | Leg action | side walk: big stride, thigh swings about ±35-45°, and the knee rises until the thigh is nearly level at passing (high-knee strut, v04 650-651 and 661-662; visual estimate) | side: thigh 30°×stomp + 16°×stomp lift, knee 70°×stomp. Front: foot lift 60 u for the march, 27 u for the stroll. | close enough at stomp ≥ 1.35 |
    | Feet | **planted**. The stance foot moves with the ground at ≤ 1.1 px/f while the ground moves 7.2 px/f (v04 649-651, tracked). A soft blue-grey contact shadow sits under the foot. | **Side views skate**: column 39 % (stomp 1.0), 20 % (1.35), 13 % (1.5); tourists ~60 %. The legs (250 u = 0.54 m) cannot cover 0.8 m steps at 1.6 m/s and 2 steps/s. | visible in shots 4, 13, 15 |
    | Arms | noodle arms hang or hold props; swing too small or hidden to measure on the studied walkers | front: hand ±16 u (±4°); side: hand ±90 u (±22°) | no data, leave as is |
    | Idle in holds | **none**: frozen to 0.01-0.8 px over 32 f (v20 sitting). Only acting idles, e.g. the v20 sleeper's head lolls 10-15° in 8-10 f, holds ~20 f, then returns. | standing figures frozen (good); water bob 0.03 m at 0.35 Hz (motivated, good). **Townsfolk wave at 1.4 Hz and the kid hops at 1.1 Hz without a break.** | extras too busy |
    | Blinks | 3-4 f long: half, shut ×1-2 (a line), half. Gaps 0.2-2.3 s, typically 0.5-1.5 s (v19: 7 blinks in 4 s, v06: 6 in 4 s). Blinks cover expression swaps. | `blinkAt`: every 3.98 s, 4.3 f, open/shut only, not tied to swaps | 3-5x fewer, no in-between frames |
    | Mouth (talking) | smooth shapes changing on ones (line, rect with teeth, open with tongue, wide); r ≈ 0.47 with the voice RMS; the mouth leads by 0-1 f (v10 officer) | no talking characters; expression mouths are static | n/a (narration only) |
    | Reaction timing | expression or action change lands 0-3 f **before** the word onset (v03 −1 / −3, v10 0, v14 0). Swaps are instant (1 frame). | `exprMarch` swaps at the onset −1.6..+2.0 f (median ~0), instant | about 2 f late |
    | Squash / stretch, smears | none | none | keep |
    | Anticipation / impact | jump: 2-3 f crouch (v10). Spear (v14): 3 f to lower, **~6 f held back, a 1-frame thrust**, impact flash + 4-5 f ring, contact on the word onset. No overshoot frames in these sequences. | hammer: 6 f smooth raise, 2 f hold, **3 f** ease-in strike, 9 f burst; contact on the word (good) | no held wind-up, strike too slow, burst too long |
    | Line boil | **none** (amplitude 0) | none | keep 0 |
    | Turns | real 3D bodies (capsule + sphere); turns take 1-4 f with the face decal sliding round the head (v10). Front→back ~2 f (v16 759→761); back→front can be a 1-frame swap (v16 801→802). | yaw billboards. Front↔side swap and mirror flip happen in 1 f whenever the camera crosses cos 0.57, **including in the middle of a camera move**. | pops during glides |
    
    ## 4. Files
    
    **Comparison plots (ours vs reference, same method)**
    - `references/motion/ours/burst_profile_ref_vs_ours.png`: median speed profile of a move, aligned on its peak
      (306 reference moves in blue, our 30 in red).
    - `references/motion/ours/ours_speed.png`: our whole-film speed timeline, in the same format as the reference
      `references/motion/cam/NN_*_speed.png` and `cam/speed_combo.png`.
    - Data: `cam/camstats.json`, `cam/NN_*_rob.npy` (reference) and `ours/ours_rob.npy`, `ours/ours_run_rob.npy` (ours).
    
    **Reference sequences (`references/motion/sheets/`)**
    
    | Topic | Sheets |
    |---|---|
    | Walks | `v04_640_cowboywalk.jpg`, `v04_645_legs.jpg`, `v04_645_feet.jpg`, `v04_649_footzoom.png`, `v03_465_kidwalk.jpg`, `v03_459_kidbody.jpg`, `v13_846_carrier.jpg`, `v03_44.5_walk_full.jpg`, `v04_21_walk_full.jpg`, `v11_14_walk_full.jpg`, `v13_27_walk_full.jpg`, `v16_24.5_walk_full.jpg` |
    | Turns | `v10_8_turn.jpg`, `v16_755_turn_purple.jpg`, `v16_796_turn_orange.jpg` |
    | Blinks | `blink_v19_490.jpg`, `blink_v06_30.jpg`, `blink_v10_571.jpg`, `blink_v17_120.jpg`, `v19_blink_zoom.jpg`, `v10_blink_zoom.jpg` |
    | Mouths | `v05_1795_mouth.jpg`, `v08_128_cashier_mouth.jpg`, `v10_538_officer_face.jpg` |
    | Actions | `v14_40_spear.jpg`, `v10_34_jump.jpg`, `v10_36_jumpzoom.jpg`, `v01_45_arm.jpg` |
    | Idle | `v20_30_sit_idle.jpg`, `v20_30_sleep.jpg`, `v10_767_bed.jpg` |
    | Crowds | `v03_51_crowd.jpg`, `v14_0_soldiers.jpg`, `v14_125_field.jpg` |
    | Whips | `v07_60_whip.jpg`, `v09_1328_whip.jpg` |
    
    Overviews are in `overview/small_*.jpg` and the coordinate grids in `grid/combo*.jpg`.
    
    ## 5. Implementation recipe
    
    ### 5.1 `story.ts`: camera
    
    **a) Key blend: ease-in-out glides and real cuts.** This replaces the quartic `easeOut` inside `run()`. Keep `easeOut`
    itself, because `snapEnd` uses it for the cable physics. Also replace the header comment (line 3) with: "continuous slow
    push-ins; symmetric ease-in-out moves ~23 f (10 in / 10 out), peak ~15 px/f at 720p; big reframes are cuts".
    
    ```ts
    /** reference camera ease: symmetric smootherstep. Peak speed = 1.875 x distance / dur, so keep dur >= 0.075 x distance (720p px) */
    export const glide = (u: number) => { const x = clamp01(u); return x * x * x * (x * (6 * x - 15) + 10); };
    type Key = { f: number; dur?: number; cut?: boolean; s: (f: number) => Partial<Cam> & { pos: V3; look: V3 } };
    const run = (keys: Key[], f: number) => {
      let cur = full(keys[0].s(f));
      for (let i = 1; i < keys.length; i++) {
        const k = keys[i];
        if (f < k.f) break;
        cur = k.cut ? full(k.s(f)) : mix(cur, full(k.s(f)), glide((f - k.f) / (k.dur ?? 24)));
      }
      return cur;
    };
    ```
    
    Then apply the table in 2.2 to the keys:
    - Moves of at most half a frame width (≤ 360 px) and at most 40 % zoom become `glide` with `dur` 18-40.
    - Anything bigger becomes `cut: true`. For the boundary keys, simply delete the key that copies the previous shot's framing.
    
    So that the move's fastest frame lands where the snap used to land, start each glide about `dur/2` earlier. This is a
    judgment call: camera-to-word timing was not measured.
    
    **b) Holds: push in instead of sliding sideways.** Use this in place of the lateral `drift(...)` on hold keys:
    
    ```ts
    /** slow dolly toward the look point; the subject grows ~rate per frame.
     *  Reference quiet frames: 0.14 %/f median, 0.34 p75. Emphasis pushes: ~0.6 %/f for ~35 f (+22 %). */
    const push = (pos: V3, look: V3, f: number, f0: number, rate = 0.0025): V3 => {
      const k = (1 - rate) ** Math.max(0, f - f0);
      return [look[0] + (pos[0] - look[0]) * k, look[1] + (pos[1] - look[1]) * k, look[2] + (pos[2] - look[2]) * k];
    };
    // e.g. shot 2: { f: 190, dur: 18, s: (f) => { const look: V3 = [0, 8, 8]; return { pos: push([10, 10.5, -42], look, f, 190, 0.002), look, fov: 42 }; } },
    ```
    
    Rates:
    - 0.0015-0.0035 on every hold.
    - 0.006 for about 35 f on the punch words: "pride of the whole town", "starts bouncing", the bolt line, "the best drilled soldiers".
    
    The push stops on an impact: cut, or end the key at the hit frame. On emphasis keys, set `roll` to ±0.06-0.14 rad
    (3.6-8°) with `dur` ≥ 18. The current ±0.02-0.06 is fine on calm keys.
    
    **c) Impacts: frame-alternating kicks, no float.** This replaces `SHAKES`, the `k` / `n()` float and the sine shake in
    `camAt`:
    
    ```ts
    // impact kicks (reference v14): image offset flips sign every frame, ~3 px at 720p per unit, gone in 7 f, roll +-0.23 deg per unit
    const KICKS: [number, number][] = [[SNAP, 1], [DROP1, 1.6], [59.79, 0.5], [60.33, 0.5], [61.69, 0.7]];
    const kickAt = (t: number) => {
      let a = 0;
      for (const [t0, g] of KICKS) {
        const n = Math.round((t - t0) * FPS);
        if (n >= 0 && n < 7) a += g * (n % 2 ? -1 : 1) * (1 - n / 7) ** 1.5;
      }
      return a;
    };
    export const camAt = (id: number, f: number): Cam => {
      const c = run(CAMS[id], f), a = kickAt(T(f));
      if (!a) return c;
      const d: V3 = [c.look[0] - c.pos[0], c.look[1] - c.pos[1], c.look[2] - c.pos[2]];
      const L = Math.hypot(...d), h = Math.hypot(d[0], d[2]) || 1;
      const m = (3 / 640) * Math.tan((c.fov * Math.PI) / 360) * L * a; // metres at the look distance for 3 px x a
      return { ...c, look: [c.look[0] - (d[2] / h) * m, c.look[1] + 0.6 * m, c.look[2] + (d[0] / h) * m], roll: c.roll + a * 0.004 };
    };
    ```
    
    **d) March cadence (optional, needs the sound agent).**
    - **Option A**, closest to the reference (2.6 steps/s = 11.5 f per step):
      `export const MARCH_HZ = 1.3; export const marchPhase = (tau: number) => (((tau * MARCH_HZ) % 1) + 1) % 1;`
      - The deck bounce follows automatically, at 2.6 Hz.
      - Any footstep or stomp sound timed to 2 steps/s must be re-timed to `marchPhase` 0 and 0.5.
      - Together with the stride in 5.2, skating drops to within about ±5 %.
    - **Option B**: keep 2 steps/s, the historical quick march. Use stride ×1.25 at stomp ≥ 1.35 (slide ≤ 7 %), and reframe
      shot 4 to a 3/4 front view, where the 28 % slide at stomp 1.0 does not read.
    
    **e) Stable views for billboards (optional).** Pick front or side per key, not per frame, so a soldier never pops in the
    middle of a glide:
    
    ```ts
    /** frame at which the current key's move has finished; world.ts builds the CamInfo for viewOf() from camAt(id, keyEnd(id, f)) */
    export const keyEnd = (id: number, f: number) => {
      let e = 0;
      for (const k of CAMS[id]) if (f >= k.f) e = k.f + (k.cut ? 0 : k.dur ?? 24);
      return Math.max(f, e);
    };
    ```
    
    ### 5.2 `toon.ts`: body mechanics
    
    **`bodyFront`: bob.** The reference bobs 3-7 % of height, lowest at contact. Set the march to 5 % (40 u p-p) and the stroll to
    3 % (24 u):
    
    ```ts
    const bob = mode === "march" ? 20 * Math.cos(2 * two * phase) : mode === "stroll" ? 12 * Math.cos(2 * two * phase) : mode === "sit" ? 170 : 0;
    ```
    
    The legs are drawn from `hipY`, so the low point reads as a knee bend. Nothing else needs to change.
    
    **`bodySide`: lean cycle and stride.**
    
    ```ts
    export type SideOpts = { phase?: number; expr?: Expr; mode?: "march" | "stand" | "swing" | "push"; blink?: boolean | number; stomp?: number; stride?: number; pose?: number };
    // signature: { ..., stomp = 1, stride = 1, pose = 0 }
    legs.push([rad(30 * stomp * stride * Math.sin(q) + 16 * stomp * lift), rad(70 * stomp * lift), k]);
    // lean swings with the step like the reference walk: 1 deg at passing, 7 deg at contact (phase 0 / 0.5)
    const lean = mode === "push" ? 14 * pose : mode === "march" ? 4 + 3 * Math.cos(2 * two * phase) : 0;
    ```
    
    **`faceFront` / `bodySide` eyes: half-lid in-between.** `blink` becomes `boolean | number`, where 1 is shut and 0.5 is half.
    Pass `skin` in from `bodyFront`.
    
    ```ts
    const faceFront = (p: Pen, cx: number, cy: number, s: number, expr: Expr, look = 0, blink: boolean | number = false, skin = SKIN) => {
      const lid = blink === true ? 1 : Number(blink) || 0;
      // ... in the eye loop: shut = the existing arc when lid >= 1 (or whistle); default dot eyes get a half-lid when 0 < lid < 1:
      //   p.dot(x + look * 9 * s, ey, 13 * s);
      //   if (lid > 0) { p.ell(x + look * 9 * s, ey - 7 * s, 17 * s, 10 * s, skin, 0); p.stroke([[x - 15 * s, ey - 1 * s], [x + 15 * s, ey - 1 * s]], lw * 0.8); }
      //   else p.dot(x + look * 9 * s - 4 * s, ey - 5 * s, 4 * s, WHITE);
    ```
    
    In `bodySide`, test `lid >= 1` for the shut arc. For `0 < lid < 1`, draw a 30 u horizontal ink line through the eye instead
    of the dot.
    
    ### 5.3 `cast.ts`: cast behaviour
    
    **Blinks.** This replaces `blinkAt`:
    
    ```ts
    /** eyelid 0 open .. 1 shut. Reference: gaps 0.5-2.3 s, 4-frame blink half / shut / shut / half, and a blink across every expression swap */
    const LID = [0.5, 1, 1, 0.5];
    const SWAPS: Record<number, number[]> = { 4: [16.17, 17.3], 5: [19.48, 20.6], 6: [21.9, 23.7], 7: [29.4, 30.6], 8: [SNAP] };
    const lidAt = (t: number, i: number, swaps: number[] = []) => {
      for (const ts of swaps) { const k = Math.floor((t - ts + LEAD) * FPS) + 1; if (k >= 0 && k < 4) return LID[k]; } // shut frames hide the swap
      let s = -2 * hh(i, 9);
      for (let n = 0; s <= t && n < 400; n++) {
        const k = Math.floor((t - s) * FPS);
        if (k >= 0 && k < 4) return LID[k];
        s += 0.5 + 1.8 * hh(i * 31 + n, 13); // next gap 0.5-2.3 s
      }
      return 0;
    };
    // column(): const blink = expr !== "whistle" ? lidAt(t, i, SWAPS[id]) : 0;   (sitters / townsfolk / officer / park: lidAt(t, i))
    ```
    
    The pool key already quantises numbers, so this adds only two drawing variants per face: half and shut.
    
    **Reactions land 2 f early.** In `exprMarch`, subtract `LEAD` from every switch time:
    
    ```ts
    const LEAD = 2 / FPS; // reference expression and action changes land 0-3 f before the word onset
    case 4: return t < 16.17 - LEAD ? { expr: i % 2 ? "neutral" : "stern", look: 0 } : t < 17.3 - LEAD ? { expr: lau ? "shock" : "nervous", look: 0 } : { expr: "neutral", look: 0 };
    ```
    
    Do the same for the times in cases 5-8. Define `LEAD` once at the top of the module, before `lidAt` and `CHEER` use it.
    
    **Column: stride matched to the walking speed.** In `column()`:
    
    ```ts
    // side-view stride so the stance foot keeps pace with the column (Option A cadence 2.6 steps/s: slide within about +-5 %)
    const so: SideOpts = { phase: mode === "march" ? ph : 0, expr, mode: mode === "march" ? "march" : "stand", blink, stomp: st, stride: Math.max(0.9, 1.4 - (st - 1)) };
    // Option B (2 steps/s): stride: st >= 1.3 ? 1.25 : 1.4
    ```
    
    **Tourists: on ones, a real stroll cadence, no skating.** In `tourists()`:
    
    ```ts
    const phase = q(((t - t0) * 1.2 + hh(i, 5)) % 1, 30); // 2.4 steps/s, 25 f per cycle, a new pose every frame (was sp/1.4 Hz, 24 poses = 2-3 f holds)
    const so: SideOpts = { mode: "march", phase, expr, stomp: 0.82 * sp }; // stride grows with walking speed: no skating
    ```
    
    **Extras hold still except on cue.** The reference background crowds are frozen. In `townsfolkAt`, gate the wave and the hop
    with these cue windows (taken from `words.json`):
    
    ```ts
    const CHEER: [number, number][] = [[8.01 - LEAD, 9.45], [65.96 - LEAD, 67.5]]; // "pride of the whole town", "like a crowd of tourists"
    const on = CHEER.some(([a, b]) => t >= a && t < b);
    const hop = kid && on ? Math.max(0, Math.sin(t * 7 + i)) * 0.18 : 0;
    const fo: FrontOpts = { mode: "stand", expr, arms: i % 3 === 2 || !on ? undefined : wave(t, i, 420), blink: lidAt(t, i + 40) };
    ```
    
    **Sitters.**
    - Water sitters keep the bob; it is motivated by the water.
    - Bank sitters stay frozen apart from one acting beat, the reference sleeper loll:
    
    ```ts
    /** head loll: 12 deg in 9 f, hold 20 f, back in 9 f (reference v20 sleeper); use as FrontOpts.headTilt for "dazed" sitters, staggered by hh(i) */
    const loll = (t: number, t0: number) => { const n = (t - t0) * FPS; return n < 0 || n > 38 ? 0 : 12 * (n < 9 ? smooth(n / 9) : n < 29 ? 1 : 1 - smooth((n - 29) / 9)); };
    ```
    
    **Officer hammer: held wind-up, 1-frame strike, short burst.** This replaces `hammerK`:
    
    ```ts
    // reference action timing (v14 spear): short wind-up, ~6 f held at the top, 1-frame strike, contact on the word onset
    const hammerK = (t: number) => {
      let k = 1;
      for (const th of HITS) {
        const n = (t - th) * FPS; // frames to contact
        if (n >= -11 && n < -7) k = Math.min(k, 1 - smooth((n + 11) / 4)); // wind-up 4 f
        else if (n >= -7 && n < 0) k = 0; // held at the top 7 f; first frame with n >= 0 is the hit
      }
      return k;
    };
    // officer(): burst window th .. th + 5 / FPS (was 0.3 s = 9 f); reference ring is 4-5 f
    ```
    
    **Settings to leave as they are:**
    - line boil 0;
    - no squash/stretch or smears;
    - no breathing or idle loops on standing figures;
    - march poses on ones;
    - whistle notes.
    
    ### 5.4 `Bridge.tsx`: whip blur (optional, at most one whip)
    
    Only add a whip if it gets blur. The reference whips are 45-93 px/f over 7-25 f with a 2-3 f ramp, always smeared. Set `vel`
    each frame to the camera's screen motion between `f` and `f+1` (in UV units) × 0.5 shutter, and 0 otherwise:
    
    ```ts
    import { Effect } from "postprocessing";
    import { Uniform, Vector2 } from "three";
    const frag = /* glsl */ `
    uniform vec2 vel;
    void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
      if (dot(vel, vel) < 1e-8) { outputColor = inputColor; return; }
      vec4 acc = vec4(0.0);
      for (int i = 0; i < 12; i++) acc += texture2D(inputBuffer, uv + vel * (float(i) / 11.0 - 0.5));
      outputColor = acc / 12.0;
    }`;
    export class WhipBlur extends Effect { constructor() { super("WhipBlur", frag, { uniforms: new Map([["vel", new Uniform(new Vector2())]]) }); } }
    ```
    
    ## 6. Top 5 changes, ranked by visual impact
    
    1. **Turn the fly-snaps into cuts and everything else into glides** (`story.ts`: `run`, `Key`, `CAMS`; table 2.2).
       - Today, 13 key moves travel 0.7-3.7 frame widths or zoom by up to 275 % in 3-8 frames, at 79-488 px/f with no blur.
       - The reference moves peak at 14-16 px/f and ease in and out over ~23 f. Its whips (≤ 93 px/f) always carry heavy blur.
       - This is the biggest "cheap" tell in our film.
    2. **Push in on every hold; roll on emphasis beats** (`story.ts` CAMS: `push()` instead of `drift()`).
       - Base 0.15-0.35 %/f, and 0.6 %/f for ~35 f on punch words.
       - Rolls of 4-8° glided over ≥ 18 f.
       - Stop the push on impacts.
    3. **Walk and march mechanics** (`toon.ts` `bodyFront` / `bodySide`; `cast.ts` `column` / `tourists`; optional
       `marchPhase`).
       - Front bob 1.7 % → 5 % (march) and 1 % → 3 % (stroll).
       - Side lean cycles from 1° to 7°.
       - Stride tied to speed, so feet stop skating.
       - Tourists stroll at 2.4 steps/s on ones.
    4. **Blinks and reaction lead** (`cast.ts` `lidAt` / `exprMarch`; `toon.ts` `faceFront`).
       - Blink gaps of 0.5-2.3 s.
       - 4-frame half / shut / shut / half shape.
       - A blink across every expression swap.
       - Swaps 2 f before the word.
    5. **Impact grammar** (`story.ts` `camAt` → `kickAt`; `cast.ts` `hammerK`).
       - Replace the 15-frame 6.4 Hz wobble with 7-frame kicks that flip sign every frame (~3 px at 720p, roll ±0.23°).
       - Hammer: held wind-up, a 1-frame strike on the word, a 5-frame burst.
       - Delete the invisible float.
    
    Next in line:
    - background townsfolk frozen except on cue;
    - stable billboard views during glides;
    - at most one blurred whip.
    
    ## 7. Limits
    
    - **Ours is computed, not rendered.** The camera numbers are exact to the code; the walk numbers are exact to the drawing
      formulas.
    - **The stride / slide figures are approximate.** They come from `bodySide`'s lowest-foot rule; the true contact depends on
      the boot ellipse.
    - **Reference walk numbers rest on few clean walkers** (v04 cowboy, v13 carriers, v03 kid), because most of the reference
      is acting rather than walking. Leg angles are visual estimates from the sheets. Arm swing could not be measured.
    - **Judgment calls, not measurements:**
      - starting glides `dur/2` early;
      - the cue windows for the extras;
      - the loll staggering;
      - keeping 2 steps/s as Option B.
    
  • references/reports/outlines.md36 KB8b5973a3d231upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/reports/textures.md66 KB5c609147a68aupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short01_sheet.jpg773 KB4f6245701a7eupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short02_sheet.jpg862 KBc875113a805eupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short03_sheet.jpg782 KB93743e613ad7upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short04_sheet.jpg687 KBb864d4860480upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short05_sheet.jpg689 KB9a85ff9236d5upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short06_sheet.jpg924 KB578c6a4b868eupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short07_sheet.jpg749 KB39b860a8ad3dupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short08_sheet.jpg817 KB830458b2d807upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short09_sheet.jpg668 KBdb297ba25daeupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short10_sheet.jpg780 KBd54fcbec1b20upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short11_sheet.jpg839 KBa8acb8e26c6dupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short12_sheet.jpg736 KB7e0ef8c460e1upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short13_sheet.jpg879 KB53275de690aeupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short14_sheet.jpg728 KB32f91991edf1upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short15_sheet.jpg611 KBa531f0f928f3upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short16_sheet.jpg765 KB7cefa910a373upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short17_sheet.jpg1.1 MBfb6592bf3552upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short18_sheet.jpg850 KBdf5ca325b424upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short19_sheet.jpg812 KB772ee337ba0cupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/sheets/short20_sheet.jpg570 KB9ca742d28527upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • references/STYLE_BIBLE.md3.5 KB09a9d8df987aupstream ↗
    # the reference channel style bible (study of latest 20 shorts, 2026-10-30)
    Private study only. Videos: vids/ (720p, original EN audio), transcripts: tx/, per-video measurements: measure/summary.json,
    30 fps contact sheets: measure/<video>_sheets/ (each sheet = 2 s, 60 frames).
    
    ## Format
    - Vertical 1080x1920, 30 fps, 31-78 s (median ~60 s), voice starts at frame 1-4 (no intro, no logo, no outro, no CTA).
    - Voice: one male narrator, fast and steady: 3.5-4.1 words/s (median 3.7), almost no pauses (word gaps >0.25 s total <1 s per video).
    - Loudness -14 LUFS integrated, LRA ~2.4 LU (heavily compressed). Music/SFX bed under voice only ~5-10 dB below speech.
    - Captions: one word (sometimes 2-3) at a time, white bold sans, thin black outline, lower-middle, hard swaps on each word.
      (OUR RULE: no captions unless the user asks.)
    
    ## Script formula (20/20 follow it)
    1. **Hook = paradox stated as fact, sentence 1, no setup.** A person did something that sounds dumb/impossible with stakes,
       or "If you X, you'd Y", or "X is illegal, yet ...". ~10-16 words.
    2. **Setting in 1-2 lines**, present tense, concrete details (time of day, a named everyman, numbers).
    3. **Escalation, 3-6 beats**: others laugh / doubt / mock (short in-character dialogue lines), stakes climb.
       1-2 exaggeration jokes per video (claim vs reality, "so slow X, and X was ...").
    4. **Mystery beat**: a question or "Wait, ...?" (~1 per video).
    5. **Pivot word "You see,"** (14 of 20 videos) → the real mechanism. Often an everyday analogy
       ("Ever ...?", "think about the last time ...", "picture your body as ...").
    6. **Ironic payoff** in the last 1-2 sentences, short, lands the twist. Video ends on the last word.
    - Two modes: story mode (third person "he", named everyman, present tense) and "Imagine you're..." second person.
    - Sentences ~13.5 words median, plain spoken English, contractions, no jargon unless explained in the next clause.
    
    ## Picture
    - 2D flat cartoon characters (simple outlined faces, thin black outline, flat colors) standing in **3D toon environments**.
    - 3D environments: hand-painted textures, saturated colors, **thin black ink outlines on 3D geometry** (edges + creases),
      soft depth of field on backgrounds, warm key light + blue/purple shadows, light pools (window light) that also tint the characters.
    - Characters receive scene light/shadow (they are lit, not pure emission), cast ground shadows.
    - Color stats (per video median): lum mean 0.43-0.54, lum std 0.21-0.29, p2 0.03-0.11 (deep darks), p98 0.9-1.0,
      saturation 0.38-0.65 (very saturated). Lighting changes with story beats (noon -> sunset -> night-blue for the explanation).
    - Every sentence is illustrated literally; props carry the joke (beers, frogs, milk cart).
    
    ## Camera / editing (measured)
    - **Hard cuts almost never**: 0-1 per video in 12/20 videos; most "cuts" detected are 4-frame flash effects.
    - Camera holds ~80% of frames (moves < 0.5 px/frame); moves are **ease-out bursts**: peak speed at the first frame of the move,
      decaying over ~8-10 frames (720p px/frame e.g. 6, 26, 12, 14, 6, 5, 2, 1).
    - Transitions = continuous camera moves: follow an object (bullet, oil stream, ball) from one subject to the next,
      whip pans with motion blur, fly-through (window/porthole), or a 6-8 frame crossfade dissolve when changing place.
    - Slow push-ins during holds; low camera heights; frequent dutch tilts for drama/falls.
    
    ## Effects
    - Muzzle fire, glow/aura bursts, sparkles on impact, splash sprites, speed lines, dust puffs, music notes, stars,
      light shafts; all flat 2D cartoon sprites composited into the 3D scene.
    
  • LICENSE1.0 KB4b9fa4c5f3a3upstream ↗
    MIT License
    
    Copyright (c) 2026 Mr omi
    
    Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files
    (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify,
    merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
    furnished to do so, subject to the following conditions:
    
    The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
    
    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
    OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
    LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    
  • templates/audio/cues.md49 KBe544e32d0384upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/audio/dsp.py11 KBdda3e1679e55upstream ↗
    # Own small DSP kit for "The Bridge That Fell for a Song" soundtrack (numpy + scipy only, no third-party audio code):
    # filters, noise colours, envelopes, STFT spectral shaping, modal / additive helpers, stereo placement,
    # synthetic reverb impulse responses, BS.1770-4 loudness (integrated / momentary / short-term / LRA), true peak,
    # 16/24-bit WAV read/write.
    import wave
    import numpy as np
    from scipy import signal
    
    SR = 48000
    TAU = 2 * np.pi
    
    
    def ns(t):
        return int(round(t * SR))
    
    
    def tt(dur):
        return np.arange(ns(dur)) / SR
    
    
    def dbg(db):
        return 10.0 ** (db / 20.0)
    
    
    def rng(seed):
        return np.random.default_rng(seed)
    
    
    # ---------------------------------------------------------------- filters
    def _sos(kind, f, order):
        return signal.butter(order, f, btype=kind, fs=SR, output="sos")
    
    
    def lp(x, f, order=2):
        return signal.sosfilt(_sos("lowpass", min(f, SR * 0.45), order), x, axis=-1)
    
    
    def hp(x, f, order=2):
        return signal.sosfilt(_sos("highpass", f, order), x, axis=-1)
    
    
    def bp(x, lo, hi, order=2):
        return signal.sosfilt(_sos("bandpass", [lo, min(hi, SR * 0.45)], order), x, axis=-1)
    
    
    def biquad(kind, f0, q=0.707, gain_db=0.0):
        A = 10 ** (gain_db / 40)
        w = TAU * f0 / SR
        cw, sw = np.cos(w), np.sin(w)
        al = sw / (2 * q)
        if kind == "peak":
            b = [1 + al * A, -2 * cw, 1 - al * A]
            a = [1 + al / A, -2 * cw, 1 - al / A]
        elif kind == "bpf":
            b = [al, 0.0, -al]
            a = [1 + al, -2 * cw, 1 - al]
        elif kind == "lowshelf":
            sa = 2 * np.sqrt(A) * al
            b = [A * ((A + 1) - (A - 1) * cw + sa), 2 * A * ((A - 1) - (A + 1) * cw), A * ((A + 1) - (A - 1) * cw - sa)]
            a = [(A + 1) + (A - 1) * cw + sa, -2 * ((A - 1) + (A + 1) * cw), (A + 1) + (A - 1) * cw - sa]
        elif kind == "highshelf":
            sa = 2 * np.sqrt(A) * al
            b = [A * ((A + 1) + (A - 1) * cw + sa), -2 * A * ((A - 1) + (A + 1) * cw), A * ((A + 1) + (A - 1) * cw - sa)]
            a = [(A + 1) - (A - 1) * cw + sa, 2 * ((A - 1) - (A + 1) * cw), (A + 1) - (A - 1) * cw - sa]
        else:
            raise ValueError(kind)
        a = np.array(a, float)
        return np.array(b, float) / a[0], a / a[0]
    
    
    def peq(x, f0, gain_db, q=1.0):
        b, a = biquad("peak", f0, q, gain_db)
        return signal.lfilter(b, a, x, axis=-1)
    
    
    def shelf(x, f0, gain_db, kind="high", q=0.707):
        b, a = biquad("highshelf" if kind == "high" else "lowshelf", f0, q, gain_db)
        return signal.lfilter(b, a, x, axis=-1)
    
    
    def reson(x, f0, q):
        b, a = biquad("bpf", f0, q)
        return signal.lfilter(b, a, x, axis=-1)
    
    
    def resbank(x, modes):
        """parallel resonators: modes = [(freq, q, gain)]"""
        y = np.zeros_like(x)
        for f, q, g in modes:
            if f < SR * 0.45:
                y += g * reson(x, f, q)
        return y
    
    
    # ---------------------------------------------------------------- noise
    def colored(n, r, slope=0.0, lo=20.0):
        """noise with power spectrum ~ f^-slope (0 white, 1 pink, 2 brown), unit std"""
        X = np.fft.rfft(r.standard_normal(n))
        f = np.fft.rfftfreq(n, 1 / SR)
        f = np.maximum(f, lo)
        X *= f ** (-slope / 2)
        x = np.fft.irfft(X, n)
        return x / (np.std(x) + 1e-12)
    
    
    def smooth_noise(n, r, rate_hz, ctrl=200):
        """slow random curve in [-1, 1]-ish (low-passed control noise), e.g. for gusts or pitch drift"""
        m = max(4, int(n / SR * ctrl) + 4)
        c = r.standard_normal(m)
        c = signal.sosfiltfilt(signal.butter(2, min(rate_hz / (ctrl / 2), 0.99), output="sos"), c)
        c /= np.max(np.abs(c)) + 1e-12
        return np.interp(np.arange(n) / SR * ctrl, np.arange(m), c)
    
    
    # ---------------------------------------------------------------- envelopes / shaping
    def ramp(n, kind="cos"):
        u = np.linspace(0, 1, max(n, 1))
        return 0.5 - 0.5 * np.cos(np.pi * u) if kind == "cos" else u
    
    
    def fades(x, fin=0.003, fout=0.01):
        x = np.array(x, dtype=float, copy=True)
        a, b = ns(fin), ns(fout)
        n = x.shape[-1]
        if a > 0:
            x[..., : min(a, n)] *= ramp(a)[: min(a, n)]
        if b > 0:
            x[..., max(0, n - b):] *= ramp(b)[::-1][-min(b, n):]
        return x
    
    
    def curve(n, pts, t0=0.0):
        """piecewise-linear automation sampled per audio sample: pts = [(time_s, value)] relative to t0"""
        xs = np.array([p[0] for p in pts], float)
        ys = np.array([p[1] for p in pts], float)
        return np.interp(t0 + np.arange(n) / SR, xs, ys)
    
    
    def shape(x, gain, nper=1024, hop=256):
        """time-varying spectral gain via STFT; gain(times[F], freqs[B]) -> (B, F) magnitude multipliers"""
        n = len(x)
        f, t, Z = signal.stft(x, fs=SR, window="hann", nperseg=nper, noverlap=nper - hop, boundary="zeros", padded=True)
        G = gain(t, f)
        _, y = signal.istft(Z * G, fs=SR, window="hann", nperseg=nper, noverlap=nper - hop, boundary=True)
        y = y[:n]
        if len(y) < n:
            y = np.pad(y, (0, n - len(y)))
        return y
    
    
    def phase(f):
        """instantaneous frequency (Hz per sample) -> phase (rad)"""
        return TAU * np.cumsum(f) / SR
    
    
    def modal(dur, modes, r=None, glide=0.0, glide_tau=0.02):
        """sum of exponentially decaying sines: modes = [(freq, amp, tau)]; optional common pitch glide (fraction)"""
        t = tt(dur)
        y = np.zeros_like(t)
        g = 1 + glide * np.exp(-t / glide_tau) if glide else None
        for m in modes:
            f, a, tau = m[0], m[1], m[2]
            if f >= SR * 0.45:
                continue
            ph0 = r.uniform(0, TAU) if r is not None else 0.0
            if g is None:
                y += a * np.exp(-t / tau) * np.sin(TAU * f * t + ph0)
            else:
                y += a * np.exp(-t / tau) * np.sin(phase(f * g) + ph0)
        return y
    
    
    def impulse_train(times, dur, amps=None):
        x = np.zeros(ns(dur) + 1)
        for i, tm in enumerate(times):
            k = ns(tm)
            if 0 <= k < len(x):
                x[k] += 1.0 if amps is None else amps[i]
        return x
    
    
    # ---------------------------------------------------------------- stereo
    def pan(x, p=0.0):
        """constant-power pan of a mono signal; centre gives L = R = x"""
        th = (float(np.clip(p, -1, 1)) + 1) * np.pi / 4
        return np.stack([x * np.cos(th), x * np.sin(th)]) * np.sqrt(2)
    
    
    def st(x):
        x = np.asarray(x, float)
        if x.ndim == 1:
            return np.stack([x, x])
        return np.concatenate([x, x]) if x.shape[0] == 1 else x
    
    
    def place(buf, x, t, gain=1.0):
        """add x (mono or stereo) into buf (2, N) starting at time t (s)"""
        x = st(x)
        i = ns(t)
        a, b = max(0, i), min(buf.shape[1], i + x.shape[1])
        if b > a:
            buf[:, a:b] += gain * x[:, a - i: b - i]
    
    
    def widen(x, r, ms=11.0, amount=0.5):
        """decorrelate a mono sound into stereo (short complementary delays), keeps the centre image"""
        d = ns(ms / 1000)
        y = np.pad(x, (0, d))
        dl = np.pad(x, (d, 0))
        L = y + amount * 0.5 * dl
        R = y - amount * 0.5 * dl
        return np.stack([L, R])[:, : len(x)] / (1 + 0.25 * amount)
    
    
    # ---------------------------------------------------------------- reverb
    def make_ir(rt60, seed, length=None, pre=0.012, hf=1.0, early=(), density_lp=None):
        """stereo IR: decorrelated decaying noise, highs decay faster (hf), optional early reflections [(t, gain, pan)]"""
        r = rng(seed)
        n = ns(length or rt60 * 1.25)
        t = np.arange(n) / SR
        ir = np.zeros((2, n))
        for ch in range(2):
            z = r.standard_normal(n) * np.exp(-6.91 * t / rt60)
            z = shape(z, lambda T, F: np.exp(-6.91 * np.maximum(T[None, :], 0) / rt60 * hf * (F[:, None] / 5000.0) ** 1.2), 512, 128)
            z *= ramp(min(n, ns(0.02)))[np.minimum(np.arange(n), ns(0.02) - 1)]
            ir[ch] = z
        if density_lp:
            ir = lp(ir, density_lp, 1)
        for et, eg, ep in early:
            k = ns(et)
            if k < n:
                spike = np.zeros(n)
                spike[k] = 1.0
                spike = lp(spike, 6000, 1)
                ir += eg * pan(spike, ep) * np.sqrt(np.mean(ir ** 2) * 2000)
        ir /= np.sqrt(np.sum(ir ** 2) / 2)
        return np.concatenate([np.zeros((2, ns(pre))), ir], axis=1)
    
    
    def convolve_st(x, ir):
        """x mono (n) or stereo (2, n) send -> stereo wet (2, n) (tail cropped)"""
        n = x.shape[-1]
        if x.ndim == 1:
            return np.stack([signal.fftconvolve(x, ir[c])[:n] for c in range(2)])
        return np.stack([signal.fftconvolve(x[c], ir[c])[:n] for c in range(2)])
    
    
    # ---------------------------------------------------------------- loudness (ITU-R BS.1770-4 / EBU R128)
    _KB1 = np.array([1.53512485958697, -2.69169618940638, 1.19839281085285])
    _KA1 = np.array([1.0, -1.69065929318241, 0.73248077421585])
    _KB2 = np.array([1.0, -2.0, 1.0])
    _KA2 = np.array([1.0, -1.99004745483398, 0.99007225036621])
    
    
    def kfilt(x):
        return signal.lfilter(_KB2, _KA2, signal.lfilter(_KB1, _KA1, x, axis=-1), axis=-1)
    
    
    def _blocks(x, win, hop):
        x = st(x)
        z2 = kfilt(x) ** 2
        c = np.concatenate([np.zeros((2, 1)), np.cumsum(z2, axis=-1)], axis=-1)
        w, h = ns(win), ns(hop)
        if x.shape[1] < w:
            z = (c[:, -1] / w).sum()
            return np.array([-0.691 + 10 * np.log10(z + 1e-30)]), np.array([z])
        s = np.arange(0, x.shape[1] - w + 1, h)
        z = ((c[:, s + w] - c[:, s]) / w).sum(0)
        return -0.691 + 10 * np.log10(z + 1e-30), z
    
    
    def integrated(x):
        L, z = _blocks(x, 0.4, 0.1)
        keep = L > -70
        if not keep.any():
            return -np.inf
        rel = -0.691 + 10 * np.log10(z[keep].mean()) - 10
        k2 = keep & (L > rel)
        return -0.691 + 10 * np.log10(z[k2].mean())
    
    
    def momentary(x, hop=0.01):
        return _blocks(x, 0.4, hop)[0]
    
    
    def momentary_max(x):
        return float(momentary(x).max())
    
    
    def short_term(x, hop=0.1):
        return _blocks(x, 3.0, hop)[0]
    
    
    def lra(x):
        L, z = _blocks(x, 3.0, 0.1)
        keep = L > -70
        if keep.sum() < 2:
            return 0.0
        rel = -0.691 + 10 * np.log10(z[keep].mean()) - 20
        v = L[keep & (L > rel)]
        return float(np.percentile(v, 95) - np.percentile(v, 10))
    
    
    def true_peak(x):
        up = signal.resample_poly(st(x), 4, 1, axis=-1)
        return 20 * np.log10(np.abs(up).max() + 1e-30)
    
    
    def norm_to(x, target_lufs, mode="momentary"):
        """scale x so its max momentary (events) or integrated (beds) loudness hits target"""
        cur = momentary_max(x) if mode == "momentary" else integrated(x)
        if not np.isfinite(cur):
            return x
        return x * dbg(target_lufs - cur)
    
    
    # ---------------------------------------------------------------- wav io
    def read_wav(path):
        with wave.open(path, "rb") as w:
            n, ch, sw, sr = w.getnframes(), w.getnchannels(), w.getsampwidth(), w.getframerate()
            raw = w.readframes(n)
        if sw == 2:
            x = np.frombuffer(raw, "<i2").astype(np.float64) / 32768.0
        elif sw == 3:
            b = np.frombuffer(raw, np.uint8).reshape(-1, 3).astype(np.int32)
            v = b[:, 0] | (b[:, 1] << 8) | (b[:, 2] << 16)
            x = np.where(v >= 1 << 23, v - (1 << 24), v).astype(np.float64) / float(1 << 23)
        elif sw == 4:
            x = np.frombuffer(raw, "<i4").astype(np.float64) / float(1 << 31)
        else:
            raise ValueError(sw)
        return x.reshape(-1, ch).T, sr
    
    
    def write_wav(path, x, bits=24):
        x = st(x)
        ch, n = x.shape
        if bits == 24:
            q = np.clip(np.round(x.T * 8388607.0), -8388608, 8388607).astype("<i4")
            data = np.ascontiguousarray(q).view(np.uint8).reshape(n, ch, 4)[:, :, :3].tobytes()
            sw = 3
        else:
            q = np.clip(np.round(x.T * 32767.0), -32768, 32767).astype("<i2")
            data = q.tobytes()
            sw = 2
        with wave.open(path, "wb") as w:
            w.setnchannels(ch)
            w.setsampwidth(sw)
            w.setframerate(SR)
            w.writeframes(data)
    
  • templates/audio/instruments.py13 KB0fea5875bb5aupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/audio/mix.py16 KB998054a32da0upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/audio/music.py20 KBcfc7e0601edcupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/audio/sfx.py19 KB521ea47298e1upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/audio/sfx_gen.py25 KB298c878787d8upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/audio/timeline.py5.3 KB255edcb3375bupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/bridge/Bridge.tsx6.3 KBa487a69d7e16upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/bridge/build.ts44 KB83a60bd58604upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/bridge/cardshade.ts6.2 KB5b861648c8daupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/bridge/cast.ts29 KBf540a1e1d851upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/bridge/grade.ts9.8 KB9d7386dccc65upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/bridge/ink.ts20 KBa169e9cc052aupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/bridge/lensblur.ts7.8 KB277acf65187bupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/bridge/lights.ts4.1 KB524e477afd5aupstream ↗
    // Light presets per time of day. The sun is CAMERA-RELATIVE and frozen per shot (world.ts update()):
    // az = degrees from the shot camera's forward axis (0 = sun straight ahead = backlight, +90 = screen right,
    // 180 = behind camera), el = elevation. lit/shade/lift are LINEAR RGB multipliers for shade.ts (may exceed 1).
    // Field names follow lighting.md 9.1 exactly (the CHAR and POST agents read them); values are tuned on renders.
    type C3 = [number, number, number];
    export type LightPreset = {
      az: number; el: number;
      lit: C3; shade: C3; lift: C3; lambert: number; sideK: number; term: number;      // world two-tone (shade.ts)
      charTint: C3; charShade: number; charGrad: number; bodyGrad: number; sideTint: C3; // character cards (cast.ts)
      blob: C3; actorShadow: boolean;                                                    // contact blobs / card shadows
      top: string; hor: string; skyCurve: C3; fog: string; fogNear: number; fogFar: number; sunDisc: string; // depth.md 6.5
      sunCol: string; sunI: number; sky: string; ground: string; hemiI: number;         // three lights (unpatched mats only)
      bias: number; normalBias: number;
    };
    export const LIGHTS: Record<"day" | "golden", LightPreset> = {
      day: {
        az: 75, el: 48,
        lit: [1.58, 1.5, 1.33], shade: [0.3, 0.29, 0.36], lift: [0.05, 0.06, 0.11], lambert: 0.35, sideK: 0.84, term: 0.2,
        charTint: [1.0, 0.94, 0.92], charShade: 0.86, charGrad: 0.07, bodyGrad: 0.2, sideTint: [0.92, 0.90, 0.96],
        blob: [0.62, 0.58, 0.58], actorShadow: false,
        top: "#3e7fd2", hor: "#b8d0ec", skyCurve: [0.0, 0.5, 0.6], fog: "#b8d0ec", fogNear: 25, fogFar: 300, sunDisc: "#000000",
        sunCol: "#fff3e0", sunI: 2.8, sky: "#dcecff", ground: "#b49a78", hemiI: 0.55,
        bias: -0.00025, normalBias: 0.015,
      },
      golden: {
        az: 6, el: 7,
        lit: [1.5, 0.68, 0.24], shade: [0.15, 0.15, 0.3], lift: [0.04, 0.06, 0.17], lambert: 0.35, sideK: 0.82, term: 0.07,
        charTint: [1.0, 0.78, 0.56], charShade: 0.88, charGrad: 0.1, bodyGrad: 0.25, sideTint: [0.85, 0.80, 0.95],
        blob: [0.75, 0.74, 0.9], actorShadow: true,
        top: "#6a4f8c", hor: "#ffd08a", skyCurve: [0.05, 0.6, 1.0], fog: "#efb489", fogNear: 50, fogFar: 380, sunDisc: "#ffcf9e",
        sunCol: "#ffab5e", sunI: 5.5, sky: "#d8b0b8", ground: "#8a6a60", hemiI: 0.5,
        bias: -0.0006, normalBias: 0.03,
      },
    };
    
    /** world-only look per light (sky stops by elevation in degrees, river colours, cloud palette). All colours are
     *  ON-SCREEN targets under the current PBR-Neutral tone map; world.ts pre-inverts the tone map (shade.ts untone). */
    export type WorldLook = {
      sky: { c: string[]; e: number[] };                         // 5 stops: c[i] at elevation e[i] (deg), smoothstep between
      water: { near: string; mid: string; far: string; crest: string; trough: string; glint: string; shade: C3 };
      bounce: C3;                                                 // shade.ts uBounce: albedo x bounce on shaded faces turned to the lens
      sat: number;                                                // shade.ts uSat: world albedo saturation (1 = as painted)
      cloud: { lit: string; mid: string; shade: string };
    };
    export const LOOK: Record<"day" | "golden", WorldLook> = {
      day: {
        // depth.md 2.2: horizon #9fb6e6 just above the skyline, upper sky #5e93d9 at ~20 deg (grade: frame top #5997e2)
        sky: { c: ["#b8d0ec", "#9cbfea", "#6a9fe0", "#4f8cdb", "#3373cc"], e: [0, 3, 8, 16, 40] },
        water: { near: "#22a7b6", mid: "#167fb2", far: "#1a5596", crest: "#7ddfe8", trough: "#0e4a80", glint: "#ffffff", shade: [0.5, 0.56, 0.72] },
        cloud: { lit: "#fbf8f1", mid: "#e1e5f3", shade: "#c3cbe9" },
        bounce: [0, 0, 0],
        sat: 1.08,
      },
      golden: {
        // sunset, not dusk: #ffd08a horizon, peach/orange through ~10 deg, warm rose at 20, muted warm mauve only near the top
        // (the golden grade adds ~+11/255 blue to shadows/mids, so these lean warm)
        sky: { c: ["#ffc47c", "#f7ae78", "#e8967c", "#c4848e", "#8a6f8a"], e: [0, 4, 10, 20, 40] },
        water: { near: "#1e3a6e", mid: "#355f96", far: "#d4a48e", crest: "#d9a088", trough: "#16294f", glint: "#ffe2b0", shade: [0.42, 0.46, 0.72] },
        cloud: { lit: "#ffd2a8", mid: "#f2a08f", shade: "#a98fc4" },
        bounce: [0.04, 0.02, 0.0],
        sat: 1.1,
      },
    };
    
  • templates/bridge/paint.ts43 KB2634aa206632upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/bridge/shade.ts5.3 KB2866d9abda23upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/bridge/story.ts23 KB1d999ebfe6e3upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/bridge/toon.ts53 KB69a82595dc41upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/bridge/world.ts38 KB8a1a433902f6upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/sandbox_index_example.tsx450 B87d8d357a381upstream ↗
    // Sandbox entry for the look pass: renders only src/videos/bridge_world (an isolated copy of the bridge short).
    import React from "react";
    import { Composition, registerRoot } from "remotion";
    import { BridgeShort, BRIDGE_FRAMES } from "../../videos/bridge_world/Bridge";
    
    const Root: React.FC = () => <Composition id="BridgeShort" component={BridgeShort} durationInFrames={BRIDGE_FRAMES} fps={30} width={1080} height={1920} />;
    registerRoot(Root);
    
  • templates/SCRIPT_example.md1.8 KB3cf4837dcae2upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/scripts/bridge-stills.mjs1.7 KB09df2a07b608upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/scripts/bridge_merge.py1.4 KB0d2160131c8cupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/scripts/bridge_shots.py1.4 KB54ecdeb1ecc6upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/scripts/sbs_sheet.py1.1 KBcbdfe7b29587upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • templates/scripts/short_qa.py4.6 KB0158afe2fb6dupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

Plain-text copy, no login: /skill-bundles/mromidev-claude-animated-shorts.md

// REPO STATS

1.9k stars