overlay-factory-worker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +215 -0
  2. package/package.json +64 -0
  3. package/public/fonts/Handjet-variable.woff2 +0 -0
  4. package/remotion.config.ts +14 -0
  5. package/scripts/check-legibility.ts +284 -0
  6. package/scripts/check-safe-area.ts +148 -0
  7. package/scripts/custom-fonts.ts +114 -0
  8. package/scripts/export.sh +31 -0
  9. package/scripts/field-images.ts +93 -0
  10. package/scripts/ig-probe.ts +83 -0
  11. package/scripts/ig-sync.ts +204 -0
  12. package/scripts/ingest.sh +34 -0
  13. package/scripts/library.ts +143 -0
  14. package/scripts/look-card.ts +107 -0
  15. package/scripts/look-store.ts +176 -0
  16. package/scripts/make-card.ts +237 -0
  17. package/scripts/merge-index.ts +74 -0
  18. package/scripts/new-episode.ts +149 -0
  19. package/scripts/overlay-worker.ts +1119 -0
  20. package/scripts/place-overlay.ts +359 -0
  21. package/scripts/prep-card.ts +73 -0
  22. package/scripts/quality.ts +0 -0
  23. package/scripts/render-overlay.ts +150 -0
  24. package/scripts/report.ts +127 -0
  25. package/scripts/rerender-cards.ts +116 -0
  26. package/scripts/series.ts +816 -0
  27. package/scripts/set-difficulty.ts +62 -0
  28. package/scripts/state-dir.ts +102 -0
  29. package/scripts/stock.ts +254 -0
  30. package/scripts/verify.ts +149 -0
  31. package/scripts/wp-restock.ts +281 -0
  32. package/src/Root.tsx +112 -0
  33. package/src/index.css +1 -0
  34. package/src/index.ts +4 -0
  35. package/src/lab/FontLab.tsx +50 -0
  36. package/src/lab/FontSheet.tsx +188 -0
  37. package/src/lab/PillLab.tsx +121 -0
  38. package/src/overlay/Composition.tsx +297 -0
  39. package/src/overlay/DifficultyMeter.tsx +86 -0
  40. package/src/overlay/PixelText.tsx +134 -0
  41. package/src/overlay/Title.tsx +75 -0
  42. package/src/overlay/brandFonts.ts +58 -0
  43. package/src/overlay/cardLayout.ts +94 -0
  44. package/src/overlay/fonts.ts +19 -0
  45. package/src/overlay/look.ts +155 -0
  46. package/src/overlay/safeArea.ts +89 -0
  47. package/src/overlay/types.ts +134 -0
  48. package/src/series/what-prints/CodeCard.tsx +107 -0
  49. package/src/series/what-prints/Composition.tsx +106 -0
  50. package/src/series/what-prints/codeCardTypes.ts +105 -0
  51. package/src/series/what-prints/types.ts +22 -0
  52. package/tsconfig.json +17 -0
  53. package/worker/cli.mjs +151 -0
  54. package/worker/service.mjs +404 -0
package/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # reel-factory
2
+
3
+ Automated overlay editing for Erin's reel series. Feed it b-roll + a code card +
4
+ an episode JSON, get a finished vertical MP4 with the "What **Prints?**" title,
5
+ the code card, and a timed difficulty reveal.
6
+
7
+ ## Flow
8
+
9
+ ```bash
10
+ npx tsx scripts/make-card.ts puzzles/x.json # write a NEW code card from a puzzle JSON
11
+ ./scripts/ingest.sh # pull assets from ~/Desktop/Automate Editing (via Finder)
12
+ npx tsx scripts/prep-card.ts # code-card JPGs -> transparent rounded PNGs
13
+ npx tsx scripts/new-episode.ts # picks next card + b-roll, places the meter
14
+ npx remotion render WhatPrints out/what-prints-XXX.mp4 --props=episodes/what-prints-XXX.json
15
+ ./scripts/export.sh out/what-prints-XXX.mp4 # copy to Desktop/Automate Editing/exports
16
+ ```
17
+
18
+ Preview while tweaking: `npm run dev` (Remotion Studio).
19
+
20
+ ## Episode JSON
21
+
22
+ ```json
23
+ {
24
+ "broll": "assets/b-roll/IMG_4858.MOV",
25
+ "card": "assets/cards/IMG_4030.png",
26
+ "titleLine1": "What",
27
+ "titleLine2": "Prints?",
28
+ "difficulty": "hard", // easy | medium | hard
29
+ "durationSec": 10,
30
+ "revealAtSec": 4, // when the difficulty meter pops in
31
+ "trimBeforeSec": 0, // skip into the b-roll clip
32
+ "meterX": 70, // set by scripts/place-overlay.ts
33
+ "meterY": 670
34
+ }
35
+ ```
36
+
37
+ `episodes/index.json` maps every code card to its episode metadata (answer, why,
38
+ caption) extracted from the caption screenshots. `episodes/used.json` tracks
39
+ which cards/b-roll are consumed.
40
+
41
+ ## Generating code cards
42
+
43
+ The original cards were phone screenshots of a chat app's code block, cropped by
44
+ `prep-card.ts`. New ones are rendered directly: write a puzzle JSON in `puzzles/`
45
+ and run `scripts/make-card.ts`. It highlights the snippet with Shiki (any
46
+ language), renders the `CodeCard` composition to a transparent rounded PNG at
47
+ `public/assets/cards/<slug>.png`, and upserts the entry into `cards.json` and
48
+ `index.json` — so re-running the same slug edits in place.
49
+
50
+ ```json
51
+ {
52
+ "slug": "nan-trap",
53
+ "name": "Bowser's NaN Trap",
54
+ "language": "Python",
55
+ "code": "score = float(\"nan\")\nbest = score\n\nprint(score == best)",
56
+ "answer": "False",
57
+ "why": "NaN is never equal to itself...",
58
+ "difficulty": "easy"
59
+ }
60
+ ```
61
+
62
+ Every card is executed before it renders and put through `scripts/quality.ts`:
63
+ the snippet must print exactly what `answer` claims, print the *same thing*
64
+ three runs in a row and on every installed Python 3.11-3.14, be answerable in a
65
+ comment (no memory addresses, nothing over 120 chars), not duplicate an existing
66
+ card, and carry a real `why`. Any failure stops the render; `--no-verify` and
67
+ `--no-gates` override. Audit the whole library with `npx tsx scripts/quality.ts`
68
+ or `npx tsx scripts/verify.ts`.
69
+
70
+ Cards that fail get a `retired` note in `index.json` rather than being deleted —
71
+ `new-episode.ts` skips them, and the audits ignore them.
72
+
73
+ Keep lines under ~45 characters and snippets under ~8 lines — beyond that the
74
+ script shrinks the type to fit and warns. Colors and geometry live in
75
+ `src/series/what-prints/codeCardTypes.ts`; preview with `npm run dev`.
76
+
77
+ The `/what-prints-card` skill writes the puzzles and drives this end to end.
78
+
79
+ ## Engagement data
80
+
81
+ `npx tsx scripts/ig-sync.ts` pulls the Instagram history for `@erin.codes` into
82
+ `episodes/performance.json` — captions, likes, comments, and per-post insights
83
+ (reach, saves, shares, views). Read-only; the token comes from
84
+ `~/code/crossposter/.env` (the `ig-auto-dm` one has expired).
85
+
86
+ Insights calls are rate-limited, so by default it only fetches them for posts
87
+ whose caption looks like a series episode. Use `--since=2026-06-01` for a date
88
+ range or `--all` for every reel; results already fetched are reused.
89
+
90
+ `npx tsx scripts/report.ts` then ranks the published episodes against the
91
+ account's own baseline and links each post back to the card it came from
92
+ (captions are the join key; set `permalink` on an `index.json` entry to force it).
93
+
94
+ Judge cards by **comments per 1,000 reach** — reach swings by orders of magnitude
95
+ and likes track reach, so raw likes say more about distribution than about
96
+ whether the puzzle landed.
97
+
98
+ ## Overlay Factory
99
+
100
+ The overlay components (`src/overlay/`) are shared: What Prints composites them
101
+ over its own b-roll, and the Overlay Factory tool on Goose Tools burns them onto
102
+ any clip uploaded from a phone.
103
+
104
+ Burn overlays onto a clip locally:
105
+
106
+ ```bash
107
+ npm run overlay -- clip.mov overlay.json out.mp4
108
+ ```
109
+
110
+ Run the worker so `/dashboard/overlay` can send jobs to this machine. The token
111
+ comes from `~/.goosetools/env`, where the Carousel Maker's installer already
112
+ put it — one token serves every Goose Tools worker on a machine:
113
+
114
+ ```bash
115
+ npm run overlay:worker # foreground, stops when the terminal closes
116
+ npm run overlay:install # keep it running across reboots (launchd)
117
+ npm run overlay:uninstall # stop it and remove the agent
118
+ ```
119
+
120
+ `overlay:install` writes `~/Library/LaunchAgents/com.goosetools.overlay.plist`,
121
+ matching the Carousel/Caption/Brand agents. Logs land in
122
+ `~/.goosetools/overlay-worker.log`. The plist sets PATH explicitly — launchd
123
+ starts with an almost-empty environment, and the render shells out to ffmpeg,
124
+ npx and claude, so an inherited-PATH assumption is what leaves one of these
125
+ agents running but failing every job.
126
+
127
+ Two passes, and the split is the point. Remotion renders **only the overlay**,
128
+ alpha-only to ProRes 4444, at `--scale` so the 1080x1920 composition rasterizes
129
+ natively at the output resolution. ffmpeg then composites that over the
130
+ untouched source. The footage never enters Chrome, so a 4K clip stays 4K and
131
+ renders in seconds rather than minutes — and every coordinate in the overlay
132
+ components stays in 1080x1920 space no matter the output size.
133
+
134
+ Output is HEVC in `yuv420p`. That pixel format isn't cosmetic: the overlay
135
+ filter hands back BGRA, and an HEVC file in BGRA imports into iOS Photos as a
136
+ file that won't play.
137
+
138
+ ## Series
139
+
140
+ What Prints? is special-cased in the worker — it runs your snippet, checks the
141
+ answer against a real run, applies the quality gates, and places the meter
142
+ against the footage. Nothing generated can do that, which is why it stays code.
143
+
144
+ Every other series is generated. Tapping "+ New series" on Goose Tools queues a
145
+ job; this machine reads your reference images with Claude and writes
146
+ `series/<slug>/`:
147
+
148
+ ```
149
+ series/hot-take/
150
+ series.json the fields the form asks for, and where each layer sits
151
+ claim.html self-contained HTML+CSS, {{field}} placeholders
152
+ verdict.html
153
+ ```
154
+
155
+ `series/` is gitignored — each worker builds its own from its own user's
156
+ references, so no two machines have the same set.
157
+
158
+ The hard rule for templates is **no CSS animation**. Remotion renders by seeking
159
+ to each frame and screenshotting, so a CSS animation or transition freezes on
160
+ whatever the first frame painted. Motion comes from the layer's `enter` value
161
+ (`fade`, `slide-up`, `slide-down`, `pop`), which the composition drives with
162
+ Remotion's own spring. Templates also can't carry `<script>` or any external
163
+ URL; `scripts/series.ts` rejects a generation that does, and the composition
164
+ strips scripts again at render time rather than trusting it.
165
+
166
+ ## Instagram's safe area
167
+
168
+ `src/overlay/safeArea.ts` holds where Instagram draws over a reel, as
169
+ three rectangles rather than one inset margin — the action rail only runs down
170
+ the lower half, so a flat "keep the right 260px clear" would flag the title and
171
+ card as unsafe when nothing is near them.
172
+
173
+ | Zone | Area |
174
+ |---|---|
175
+ | top bar | full width, y 0-250 |
176
+ | action rail | x 820-1080, y 1080-1700 |
177
+ | caption | x 0-820, y 1500-1920 |
178
+
179
+ `npx tsx scripts/check-safe-area.ts episodes/what-prints-00X.json` measures the
180
+ real overlay bounding boxes out of a render (same two-render diff as the
181
+ legibility check) and reports anything intersecting a zone. It also flags
182
+ elements outside the 3:4 centre crop that the profile grid uses for thumbnails.
183
+
184
+ Numbers are conservative — IG moves its chrome between app versions and notched
185
+ phones differ from flat ones. If something looks wrong on a real phone, adjust
186
+ the zones there and every check follows.
187
+
188
+ ## Does it read on a phone?
189
+
190
+ `npx tsx scripts/check-legibility.ts episodes/what-prints-00X.json` renders three
191
+ frames twice — once whole, once with `hideOverlays` so only the background
192
+ draws — diffs them to find the overlay pixels, and measures real contrast.
193
+
194
+ Text (the title) is scored on *local* contrast: strokes against what's
195
+ immediately around them in the finished frame, so the dark halo counts. Panels
196
+ (the code card, the difficulty pill) are scored on how well the opaque block
197
+ separates from the footage, because their internal contrast is fixed by design.
198
+ Thresholds are calibrated against the known-bad title, which measured 4.3:1 and
199
+ would have passed a textbook WCAG bar; the fixed one measures 12.3:1.
200
+
201
+ `new-episode.ts` runs this automatically after placing the meter.
202
+
203
+ ## Notes
204
+
205
+ - macOS blocks direct shell reads of `~/Desktop` (TCC). All Desktop I/O goes
206
+ through Finder AppleScript — that's why ingest/export use `osascript`.
207
+ - Exports are silent; add trending audio in Instagram.
208
+ - The title and code card are in fixed positions every episode (title centred
209
+ 200px from the top, card centred at 86% width). The difficulty meter has a
210
+ house position too — lower-left at (70, 1400) — and `scripts/place-overlay.ts`
211
+ only moves it when the footage there is more than 25% busier than the calmest
212
+ spot available. The comparison is relative because footage with Erin in frame
213
+ scores high everywhere. Override by editing meterX/meterY.
214
+ - New series (Interviewer, Games): add `src/series/<name>/` with its own
215
+ composition, register it in `src/Root.tsx`, same episode-JSON pattern.
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "overlay-factory-worker",
3
+ "version": "0.1.0",
4
+ "description": "The Goose Tools Overlay Factory worker — your computer renders reel overlays for goosetools.com with Remotion and ffmpeg.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ernkerr/reel-factory.git"
8
+ },
9
+ "license": "MIT",
10
+ "dependencies": {
11
+ "@remotion/cli": "4.0.507",
12
+ "@remotion/google-fonts": "^4.0.507",
13
+ "@remotion/media": "^4.0.507",
14
+ "@remotion/tailwind-v4": "4.0.507",
15
+ "@vercel/blob": "^2.8.0",
16
+ "react": "19.2.3",
17
+ "react-dom": "19.2.3",
18
+ "remotion": "4.0.507",
19
+ "sharp": "^0.35.3",
20
+ "shiki": "^4.4.3",
21
+ "tailwindcss": "4.0.0",
22
+ "tsx": "^4.23.12",
23
+ "zod": "^4.4.3"
24
+ },
25
+ "devDependencies": {
26
+ "@remotion/eslint-config-flat": "4.0.507",
27
+ "@types/react": "19.2.7",
28
+ "@types/web": "0.0.166",
29
+ "eslint": "9.19.0",
30
+ "prettier": "3.8.1",
31
+ "typescript": "5.9.3"
32
+ },
33
+ "scripts": {
34
+ "dev": "remotion studio",
35
+ "build": "remotion bundle",
36
+ "upgrade": "remotion upgrade",
37
+ "lint": "eslint src && tsc",
38
+ "overlay": "tsx scripts/render-overlay.ts",
39
+ "overlay:worker": "tsx scripts/overlay-worker.ts",
40
+ "overlay:install": "node worker/cli.mjs install",
41
+ "overlay:uninstall": "node worker/cli.mjs uninstall",
42
+ "overlay:status": "node worker/cli.mjs status",
43
+ "overlay:doctor": "node worker/cli.mjs doctor"
44
+ },
45
+ "sideEffects": [
46
+ "*.css"
47
+ ],
48
+ "author": "Erin Kerr",
49
+ "bin": {
50
+ "overlay-factory-worker": "worker/cli.mjs"
51
+ },
52
+ "engines": {
53
+ "node": ">=20"
54
+ },
55
+ "files": [
56
+ "worker",
57
+ "scripts",
58
+ "src",
59
+ "public/fonts/Handjet-variable.woff2",
60
+ "remotion.config.ts",
61
+ "tsconfig.json",
62
+ "README.md"
63
+ ]
64
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Note: When using the Node.JS APIs, the config file
3
+ * doesn't apply. Instead, pass options directly to the APIs.
4
+ *
5
+ * All configuration options: https://remotion.dev/docs/config
6
+ */
7
+
8
+ import { Config } from "@remotion/cli/config";
9
+ import { enableTailwind } from '@remotion/tailwind-v4';
10
+
11
+ Config.setRspack(true);
12
+ Config.setVideoImageFormat("jpeg");
13
+ Config.setOverwriteOutput(true);
14
+ Config.overrideBundlerConfig(enableTailwind);
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Will the overlays actually read against this b-roll?
3
+ *
4
+ * The code gates in quality.ts check the puzzle. This checks the *picture* —
5
+ * the failure that shipped a title nobody could read over a bright keyboard.
6
+ *
7
+ * Renders each sampled frame twice: once whole, once with the overlays hidden
8
+ * (`hideOverlays`). Pixels that differ are overlay; the second render says what
9
+ * each one is sitting on. From that we get a real WCAG contrast ratio per
10
+ * region instead of guessing.
11
+ *
12
+ * npx tsx scripts/check-legibility.ts episodes/what-prints-002.json
13
+ */
14
+ import { execFileSync } from "node:child_process";
15
+ import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { tmpdir } from "node:os";
18
+ import sharp from "sharp";
19
+
20
+ const ROOT = join(import.meta.dirname, "..");
21
+ const W = 1080;
22
+ const H = 1920;
23
+
24
+ /**
25
+ * Calibrated against two real frames rather than taken off the shelf.
26
+ *
27
+ * WCAG's 3:1 for large text assumes static, solid type. A dot-matrix title over
28
+ * moving footage is neither — it's mostly gaps, and it moves. The title that
29
+ * shipped unreadable over a bright keyboard measures 4.3:1 here and would have
30
+ * *passed* a WCAG-derived threshold; the fixed one measures 12.3:1. So the bar
31
+ * sits above the known-bad case, not at the standard.
32
+ *
33
+ * If a future episode fails at a number that looks fine on screen, re-check
34
+ * these against it rather than assuming the render is wrong.
35
+ */
36
+ const LIMITS = {
37
+ // Strokes sitting straight on the footage — the demanding case.
38
+ text: { fail: 5.0, warn: 8.0 },
39
+ // An opaque panel only has to separate from what's behind it; its own text
40
+ // contrast is fixed by the card design and can't be spoiled by the b-roll.
41
+ panel: { fail: 3.0, warn: 4.5 },
42
+ };
43
+
44
+ /**
45
+ * Regions to judge separately, in full-frame pixels.
46
+ *
47
+ * The mode matters. "text" elements sit straight on the footage, so every
48
+ * stroke has to win against whatever is behind it. "panel" elements are opaque
49
+ * — the code card, the difficulty pill — so their internal text contrast is
50
+ * fixed by design and the only question is whether the panel separates from the
51
+ * background. Scoring a panel per-pixel compares its white text against the
52
+ * footage *behind the panel*, which the viewer never sees, and reports a
53
+ * failure that isn't real.
54
+ */
55
+ const REGIONS = [
56
+ { name: "title", x: 0, y: 150, w: W, h: 420, mode: "text" as const },
57
+ { name: "card", x: 0, y: 600, w: W, h: 800, mode: "panel" as const },
58
+ { name: "meter", x: 0, y: 1300, w: W, h: 320, mode: "panel" as const },
59
+ ];
60
+
61
+ type Episode = { durationSec: number; revealAtSec: number };
62
+
63
+ const srgbToLinear = (c: number) => {
64
+ const s = c / 255;
65
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
66
+ };
67
+
68
+ const luminance = (r: number, g: number, b: number) =>
69
+ 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
70
+
71
+ const contrast = (l1: number, l2: number) => {
72
+ const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
73
+ return (hi + 0.05) / (lo + 0.05);
74
+ };
75
+
76
+ const renderStill = (
77
+ episodePath: string,
78
+ frame: number,
79
+ out: string,
80
+ hideOverlays: boolean,
81
+ ) => {
82
+ const episode = JSON.parse(readFileSync(episodePath, "utf8")) as object;
83
+ const props = join(ROOT, ".legibility-props.json");
84
+ writeFileSync(props, JSON.stringify({ ...episode, hideOverlays }));
85
+ try {
86
+ execFileSync(
87
+ "npx",
88
+ [
89
+ "remotion",
90
+ "still",
91
+ "WhatPrints",
92
+ out,
93
+ `--props=${props}`,
94
+ `--frame=${frame}`,
95
+ "--log=error",
96
+ ],
97
+ { cwd: ROOT, stdio: "inherit" },
98
+ );
99
+ } finally {
100
+ rmSync(props, { force: true });
101
+ }
102
+ };
103
+
104
+ const raw = async (file: string) => {
105
+ const { data, info } = await sharp(file)
106
+ .resize(W, H, { fit: "fill" })
107
+ .removeAlpha()
108
+ .raw()
109
+ .toBuffer({ resolveWithObject: true });
110
+ return { data, channels: info.channels };
111
+ };
112
+
113
+ /** How far out from a stroke to look for "the background right next to it". */
114
+ const RING = 7;
115
+
116
+ /**
117
+ * Text: local contrast between the strokes and what immediately surrounds them
118
+ * in the finished frame.
119
+ *
120
+ * Not stroke-vs-footage-underneath. The title carries a dark halo, so a white
121
+ * dot on a bright desk still reads fine — the eye compares the dot to the halo
122
+ * around it, not to the desk it hides. Measuring against the raw footage scores
123
+ * a perfectly readable title as a failure.
124
+ *
125
+ * Strokes are the pixels the two renders strongly disagree about; the ring is
126
+ * everything just outside them, sampled from the composited frame so the halo
127
+ * and scrim count.
128
+ */
129
+ const scoreRegion = (
130
+ full: Uint8Array,
131
+ bg: Uint8Array,
132
+ ch: number,
133
+ r: (typeof REGIONS)[number],
134
+ ) => {
135
+ const x0 = r.x;
136
+ const y0 = r.y;
137
+ const w = Math.min(W, r.x + r.w) - x0;
138
+ const h = Math.min(H, r.y + r.h) - y0;
139
+
140
+ const stroke = new Uint8Array(w * h);
141
+ for (let y = 0; y < h; y++) {
142
+ for (let x = 0; x < w; x++) {
143
+ const i = ((y + y0) * W + (x + x0)) * ch;
144
+ const d =
145
+ Math.abs(full[i] - bg[i]) +
146
+ Math.abs(full[i + 1] - bg[i + 1]) +
147
+ Math.abs(full[i + 2] - bg[i + 2]);
148
+ // Solid strokes only. Anti-aliased edges sit between the two colours by
149
+ // definition, and there are a lot of them around thousands of small
150
+ // squares — counting them would drag any percentile down to ~1:1.
151
+ if (d >= 200) stroke[y * w + x] = 1;
152
+ }
153
+ }
154
+
155
+ // Separable max filter = dilation; ring is what the dilation added.
156
+ const dil = new Uint8Array(w * h);
157
+ const tmp = new Uint8Array(w * h);
158
+ for (let y = 0; y < h; y++)
159
+ for (let x = 0; x < w; x++) {
160
+ let on = 0;
161
+ for (let k = -RING; k <= RING && !on; k++) {
162
+ const xx = x + k;
163
+ if (xx >= 0 && xx < w && stroke[y * w + xx]) on = 1;
164
+ }
165
+ tmp[y * w + x] = on;
166
+ }
167
+ for (let y = 0; y < h; y++)
168
+ for (let x = 0; x < w; x++) {
169
+ let on = 0;
170
+ for (let k = -RING; k <= RING && !on; k++) {
171
+ const yy = y + k;
172
+ if (yy >= 0 && yy < h && tmp[yy * w + x]) on = 1;
173
+ }
174
+ dil[y * w + x] = on;
175
+ }
176
+
177
+ const strokeLum: number[] = [];
178
+ const ringLum: number[] = [];
179
+ for (let y = 0; y < h; y++)
180
+ for (let x = 0; x < w; x++) {
181
+ const p = y * w + x;
182
+ if (!dil[p]) continue;
183
+ const i = ((y + y0) * W + (x + x0)) * ch;
184
+ const l = luminance(full[i], full[i + 1], full[i + 2]);
185
+ if (stroke[p]) strokeLum.push(l);
186
+ else ringLum.push(l);
187
+ }
188
+
189
+ if (strokeLum.length < 200 || !ringLum.length) return null;
190
+ const mid = (n: number[]) => n.sort((a, b) => a - b)[n.length >> 1];
191
+ // The ring's brightest quarter is the hardest thing the strokes compete with.
192
+ const sorted = ringLum.sort((a, b) => a - b);
193
+ const ringHigh = sorted[Math.floor(sorted.length * 0.75)];
194
+ const ratio = contrast(mid(strokeLum), ringHigh);
195
+ return { pixels: strokeLum.length, p10: ratio, median: ratio };
196
+ };
197
+
198
+ /**
199
+ * Panels: how well the opaque block separates from the footage around it.
200
+ * Compares the panel body's typical luminance to the typical luminance of what
201
+ * it covers, so a dark card on a dark desk is what fails here — not its text.
202
+ */
203
+ const scorePanel = (
204
+ full: Uint8Array,
205
+ bg: Uint8Array,
206
+ ch: number,
207
+ r: (typeof REGIONS)[number],
208
+ ) => {
209
+ const fl: number[] = [];
210
+ const bl: number[] = [];
211
+ for (let y = r.y; y < Math.min(H, r.y + r.h); y++) {
212
+ for (let x = r.x; x < Math.min(W, r.x + r.w); x++) {
213
+ const i = (y * W + x) * ch;
214
+ const d =
215
+ Math.abs(full[i] - bg[i]) +
216
+ Math.abs(full[i + 1] - bg[i + 1]) +
217
+ Math.abs(full[i + 2] - bg[i + 2]);
218
+ if (d < 90) continue;
219
+ fl.push(luminance(full[i], full[i + 1], full[i + 2]));
220
+ bl.push(luminance(bg[i], bg[i + 1], bg[i + 2]));
221
+ }
222
+ }
223
+ if (!fl.length) return null;
224
+ const mid = (n: number[]) => n.sort((a, b) => a - b)[n.length >> 1];
225
+ const ratio = contrast(mid(fl), mid(bl));
226
+ return { pixels: fl.length, p10: ratio, median: ratio };
227
+ };
228
+
229
+ const main = async () => {
230
+ const episodePath = process.argv[2];
231
+ if (!episodePath) {
232
+ console.error("usage: check-legibility.ts <episode.json>");
233
+ process.exit(1);
234
+ }
235
+ const ep = JSON.parse(readFileSync(episodePath, "utf8")) as Episode;
236
+ const dir = mkdtempSync(join(tmpdir(), "reel-legibility-"));
237
+
238
+ // Sample after the meter appears so every overlay is on screen.
239
+ const fps = 30;
240
+ const frames = [
241
+ Math.round((ep.revealAtSec + 0.5) * fps),
242
+ Math.round(((ep.revealAtSec + ep.durationSec) / 2) * fps),
243
+ Math.round((ep.durationSec - 0.4) * fps),
244
+ ];
245
+
246
+ const worst = new Map<string, number>();
247
+ const modeOf = new Map(REGIONS.map((r) => [r.name, r.mode]));
248
+ for (const f of frames) {
249
+ const a = join(dir, `full-${f}.png`);
250
+ const b = join(dir, `bg-${f}.png`);
251
+ renderStill(episodePath, f, a, false);
252
+ renderStill(episodePath, f, b, true);
253
+ const [full, bg] = [await raw(a), await raw(b)];
254
+
255
+ for (const r of REGIONS) {
256
+ const s =
257
+ r.mode === "panel"
258
+ ? scorePanel(full.data, bg.data, full.channels, r)
259
+ : scoreRegion(full.data, bg.data, full.channels, r);
260
+ if (!s) continue;
261
+ const prev = worst.get(r.name);
262
+ if (prev === undefined || s.p10 < prev) worst.set(r.name, s.p10);
263
+ }
264
+ }
265
+ rmSync(dir, { recursive: true, force: true });
266
+
267
+ console.log(`\n${episodePath} — worst contrast across ${frames.length} frames`);
268
+ let failed = false;
269
+ for (const [name, p10] of worst) {
270
+ const lim = LIMITS[modeOf.get(name) ?? "text"];
271
+ const verdict = p10 < lim.fail ? "FAIL" : p10 < lim.warn ? "warn" : "ok";
272
+ if (p10 < lim.fail) failed = true;
273
+ console.log(` ${name.padEnd(6)} ${p10.toFixed(1)}:1 ${verdict}`);
274
+ }
275
+ console.log(
276
+ failed
277
+ ? `\nThat overlay stops reading on a phone. Darken the\n` +
278
+ `scrim in Composition.tsx, or pick calmer b-roll for this episode.`
279
+ : "",
280
+ );
281
+ process.exit(failed ? 1 : 0);
282
+ };
283
+
284
+ main();