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.
- package/README.md +215 -0
- package/package.json +64 -0
- package/public/fonts/Handjet-variable.woff2 +0 -0
- package/remotion.config.ts +14 -0
- package/scripts/check-legibility.ts +284 -0
- package/scripts/check-safe-area.ts +148 -0
- package/scripts/custom-fonts.ts +114 -0
- package/scripts/export.sh +31 -0
- package/scripts/field-images.ts +93 -0
- package/scripts/ig-probe.ts +83 -0
- package/scripts/ig-sync.ts +204 -0
- package/scripts/ingest.sh +34 -0
- package/scripts/library.ts +143 -0
- package/scripts/look-card.ts +107 -0
- package/scripts/look-store.ts +176 -0
- package/scripts/make-card.ts +237 -0
- package/scripts/merge-index.ts +74 -0
- package/scripts/new-episode.ts +149 -0
- package/scripts/overlay-worker.ts +1119 -0
- package/scripts/place-overlay.ts +359 -0
- package/scripts/prep-card.ts +73 -0
- package/scripts/quality.ts +0 -0
- package/scripts/render-overlay.ts +150 -0
- package/scripts/report.ts +127 -0
- package/scripts/rerender-cards.ts +116 -0
- package/scripts/series.ts +816 -0
- package/scripts/set-difficulty.ts +62 -0
- package/scripts/state-dir.ts +102 -0
- package/scripts/stock.ts +254 -0
- package/scripts/verify.ts +149 -0
- package/scripts/wp-restock.ts +281 -0
- package/src/Root.tsx +112 -0
- package/src/index.css +1 -0
- package/src/index.ts +4 -0
- package/src/lab/FontLab.tsx +50 -0
- package/src/lab/FontSheet.tsx +188 -0
- package/src/lab/PillLab.tsx +121 -0
- package/src/overlay/Composition.tsx +297 -0
- package/src/overlay/DifficultyMeter.tsx +86 -0
- package/src/overlay/PixelText.tsx +134 -0
- package/src/overlay/Title.tsx +75 -0
- package/src/overlay/brandFonts.ts +58 -0
- package/src/overlay/cardLayout.ts +94 -0
- package/src/overlay/fonts.ts +19 -0
- package/src/overlay/look.ts +155 -0
- package/src/overlay/safeArea.ts +89 -0
- package/src/overlay/types.ts +134 -0
- package/src/series/what-prints/CodeCard.tsx +107 -0
- package/src/series/what-prints/Composition.tsx +106 -0
- package/src/series/what-prints/codeCardTypes.ts +105 -0
- package/src/series/what-prints/types.ts +22 -0
- package/tsconfig.json +17 -0
- package/worker/cli.mjs +151 -0
- 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
|
+
}
|
|
Binary file
|
|
@@ -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();
|