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
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Pick where the code card and the difficulty meter should sit.
3
+ *
4
+ * Samples frames from the b-roll, scores regions by how busy they are (edge
5
+ * energy) plus a penalty for skin tones, and moves the overlays to the calmest
6
+ * places that don't cover Erin or Instagram's own UI.
7
+ *
8
+ * The card is the important one: it's large and centred, so on footage where
9
+ * she's centre-frame it lands squarely on her face and crops her to a
10
+ * forehead. It can move down, and it can shrink — a smaller card that shows
11
+ * her face beats a bigger one that doesn't.
12
+ *
13
+ * A caveat worth keeping in mind when reading the scores: the skin test is
14
+ * per-pixel COLOUR classification, not face detection. It fires happily on
15
+ * wood, terracotta, and warm-lit walls, and it can't tell a face from a hand.
16
+ * It's a good "avoid this region" prior and a poor face locator.
17
+ *
18
+ * Usage: npx tsx scripts/place-overlay.ts episodes/what-prints-001.json
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+ import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { tmpdir } from "node:os";
24
+ import sharp from "sharp";
25
+ import { SAFE_AREA } from "../src/overlay/safeArea";
26
+ import {
27
+ CARD_MIN_WIDTH_PCT,
28
+ CARD_WIDTH_PCT,
29
+ cardBox,
30
+ cardCenterRange,
31
+ } from "../src/overlay/cardLayout";
32
+
33
+ const W = 1080;
34
+ const H = 1920;
35
+ const ANALYSIS_W = 270; // quarter scale — plenty for a busyness map
36
+ const SCALE = W / ANALYSIS_W;
37
+
38
+ // Measured off a render, not guessed: the pill draws 400x146.
39
+ const METER = { width: 400, height: 150 };
40
+ const MARGIN = 70;
41
+
42
+ /**
43
+ * The meter's home. A series is recognisable partly because things sit in the
44
+ * same place every time, so the badge stays here unless the footage underneath
45
+ * is genuinely too busy to read against — content-aware placement is the
46
+ * exception, not the rule.
47
+ */
48
+ const HOME = { x: MARGIN, y: 1330 };
49
+
50
+ /**
51
+ * How much busier than the calmest available spot HOME is allowed to be before
52
+ * we give up on consistency. Relative, not absolute: footage with Erin in frame
53
+ * scores high everywhere (the skin penalty dominates), so a fixed threshold
54
+ * would move the badge on every episode and we would never keep a house
55
+ * position at all.
56
+ */
57
+ const HOME_TOLERANCE = 1.25;
58
+
59
+ // Instagram's own chrome — see src/overlay/safeArea.ts.
60
+ const IG_BOTTOM_RESERVED = SAFE_AREA.bottom;
61
+ const IG_RIGHT_RESERVED = SAFE_AREA.right;
62
+
63
+ type Episode = {
64
+ broll: string;
65
+ card: string;
66
+ durationSec: number;
67
+ revealAtSec: number;
68
+ trimBeforeSec: number;
69
+ meterX?: number;
70
+ meterY?: number;
71
+ cardY?: number;
72
+ cardWidthPct?: number;
73
+ };
74
+
75
+ /**
76
+ * How much busier the full-size, centred card may be than the best alternative
77
+ * before it gets moved or shrunk.
78
+ *
79
+ * Same reasoning as HOME_TOLERANCE: the card should sit in its usual place on
80
+ * most episodes, because a series is recognisable partly by things being where
81
+ * they always are. Moving it is for the episodes where it genuinely lands on
82
+ * her face.
83
+ */
84
+ const CARD_TOLERANCE = 1.2;
85
+
86
+ const frameAt = (video: string, seconds: number, out: string) => {
87
+ execFileSync("ffmpeg", [
88
+ "-y", "-loglevel", "error",
89
+ "-ss", String(seconds),
90
+ "-i", video,
91
+ "-frames:v", "1",
92
+ "-vf", `scale=${ANALYSIS_W}:-1`,
93
+ out,
94
+ ]);
95
+ };
96
+
97
+ /** Per-pixel "how much is going on here", 0..1, plus a skin-tone flag. */
98
+ const analyzeFrame = async (file: string) => {
99
+ const { data, info } = await sharp(file)
100
+ .resize(ANALYSIS_W)
101
+ .raw()
102
+ .toBuffer({ resolveWithObject: true });
103
+ const { width, height, channels } = info;
104
+
105
+ const energy = new Float64Array(width * height);
106
+ const at = (x: number, y: number, c: number) =>
107
+ data[(y * width + x) * channels + c];
108
+
109
+ for (let y = 1; y < height - 1; y++) {
110
+ for (let x = 1; x < width - 1; x++) {
111
+ const lum = (xx: number, yy: number) =>
112
+ 0.299 * at(xx, yy, 0) + 0.587 * at(xx, yy, 1) + 0.114 * at(xx, yy, 2);
113
+ const gx = lum(x + 1, y) - lum(x - 1, y);
114
+ const gy = lum(x, y + 1) - lum(x, y - 1);
115
+ let e = Math.min(1, Math.hypot(gx, gy) / 90);
116
+
117
+ // Skin tones: strongly penalized so the badge never lands on her hands.
118
+ const r = at(x, y, 0);
119
+ const g = at(x, y, 1);
120
+ const b = at(x, y, 2);
121
+ const isSkin =
122
+ r > 95 && g > 40 && b > 20 && r > g && r > b && r - Math.min(g, b) > 15;
123
+ if (isSkin) e += 1.5;
124
+
125
+ energy[y * width + x] = e;
126
+ }
127
+ }
128
+ return { energy, width, height };
129
+ };
130
+
131
+ /** Summed-area table so rectangle scores are O(1). */
132
+ const integral = (src: Float64Array, width: number, height: number) => {
133
+ const sum = new Float64Array((width + 1) * (height + 1));
134
+ for (let y = 0; y < height; y++) {
135
+ let rowSum = 0;
136
+ for (let x = 0; x < width; x++) {
137
+ rowSum += src[y * width + x];
138
+ sum[(y + 1) * (width + 1) + (x + 1)] =
139
+ sum[y * (width + 1) + (x + 1)] + rowSum;
140
+ }
141
+ }
142
+ return sum;
143
+ };
144
+
145
+ const rectMean = (
146
+ sum: Float64Array,
147
+ width: number,
148
+ x0: number, y0: number, x1: number, y1: number,
149
+ ) => {
150
+ const s =
151
+ sum[y1 * (width + 1) + x1] -
152
+ sum[y0 * (width + 1) + x1] -
153
+ sum[y1 * (width + 1) + x0] +
154
+ sum[y0 * (width + 1) + x0];
155
+ const area = Math.max(1, (x1 - x0) * (y1 - y0));
156
+ return s / area;
157
+ };
158
+
159
+ const main = async () => {
160
+ const episodePath = process.argv[2];
161
+ if (!episodePath) {
162
+ console.error("usage: place-overlay.ts <episode.json>");
163
+ process.exit(1);
164
+ }
165
+ const episode = JSON.parse(readFileSync(episodePath, "utf8")) as Episode;
166
+ const video = join(import.meta.dirname, "../public", episode.broll);
167
+
168
+ const cardMeta = await sharp(
169
+ join(import.meta.dirname, "../public", episode.card),
170
+ ).metadata();
171
+ const aspect = cardMeta.height! / cardMeta.width!;
172
+
173
+ // Sample across the CARD's time on screen, not the meter's. The card is up
174
+ // from the first frame and the meter only appears part-way in, so the meter's
175
+ // window misses exactly the stretch where the card can land on her.
176
+ const dir = mkdtempSync(join(tmpdir(), "reel-place-"));
177
+ const cardStart = episode.trimBeforeSec;
178
+ const end = episode.trimBeforeSec + episode.durationSec;
179
+ const cardTimes = [0, 0.25, 0.5, 0.75, 0.95].map(
180
+ (f) => cardStart + (end - cardStart) * f,
181
+ );
182
+
183
+ const cardMaps = [];
184
+ for (const [i, t] of cardTimes.entries()) {
185
+ const f = join(dir, `c${i}.png`);
186
+ frameAt(video, t, f);
187
+ cardMaps.push(await analyzeFrame(f));
188
+ }
189
+ const cw = cardMaps[0].width;
190
+ const ch = cardMaps[0].height;
191
+ const cardSums = cardMaps.map((m) => integral(m.energy, cw, ch));
192
+
193
+ /** Worst-frame busyness under a box, in frame coordinates. */
194
+ const boxScore = (bx: number, by: number, bw: number, bh: number) =>
195
+ Math.max(
196
+ ...cardSums.map((sum) =>
197
+ rectMean(
198
+ sum,
199
+ cw,
200
+ Math.max(0, Math.floor(bx / SCALE)),
201
+ Math.max(0, Math.floor(by / SCALE)),
202
+ Math.min(cw, Math.ceil((bx + bw) / SCALE)),
203
+ Math.min(ch, Math.ceil((by + bh) / SCALE)),
204
+ ),
205
+ ),
206
+ );
207
+
208
+ // The card's usual place: full width, centred on the safe band. Everything
209
+ // else is measured against this, because staying put is the default.
210
+ const cardHome = cardBox(aspect);
211
+ const homeCardScore = boxScore(
212
+ cardHome.x,
213
+ cardHome.y,
214
+ cardHome.width,
215
+ cardHome.height,
216
+ );
217
+
218
+ // Candidates: a few widths, each swept down its legal vertical range. Larger
219
+ // is preferred, so the sort breaks ties toward keeping the card readable.
220
+ type CardCand = { widthPct: number; centerY: number; score: number };
221
+ const cardCands: CardCand[] = [];
222
+ for (const widthPct of [CARD_WIDTH_PCT, 0.78, 0.7, CARD_MIN_WIDTH_PCT]) {
223
+ const box = cardBox(aspect, widthPct);
224
+ // Leave the meter somewhere to live: it must sit below the card.
225
+ const [minC, maxC] = cardCenterRange(box.height, METER.height + 60);
226
+ for (let cy = minC; cy <= maxC; cy += 40) {
227
+ const y = cy - box.height / 2;
228
+ cardCands.push({
229
+ widthPct,
230
+ centerY: cy,
231
+ // Shrinking is a real cost — a smaller card is harder to read — so it
232
+ // has to buy a real improvement in busyness to win.
233
+ score:
234
+ boxScore(box.x, y, box.width, box.height) *
235
+ (1 + (CARD_WIDTH_PCT - widthPct) * 0.5),
236
+ });
237
+ }
238
+ }
239
+ cardCands.sort((a, b) => a.score - b.score);
240
+ const bestCard = cardCands[0];
241
+
242
+ const moveCard = bestCard && homeCardScore > bestCard.score * CARD_TOLERANCE;
243
+ if (moveCard) {
244
+ episode.cardY = Math.round(bestCard.centerY);
245
+ episode.cardWidthPct = Number(bestCard.widthPct.toFixed(3));
246
+ console.log(
247
+ `${episodePath}: card busy at home (${homeCardScore.toFixed(3)}) — ` +
248
+ `moved to y=${episode.cardY} at ${Math.round(bestCard.widthPct * 100)}% width ` +
249
+ `(${bestCard.score.toFixed(3)})`,
250
+ );
251
+ } else {
252
+ delete episode.cardY;
253
+ delete episode.cardWidthPct;
254
+ console.log(
255
+ `${episodePath}: card stays home (busyness ${homeCardScore.toFixed(3)})`,
256
+ );
257
+ }
258
+
259
+ // Whatever the card ended up as, the meter goes below THAT.
260
+ const placedCard = cardBox(
261
+ aspect,
262
+ episode.cardWidthPct ?? CARD_WIDTH_PCT,
263
+ episode.cardY,
264
+ );
265
+ const cardBottom = placedCard.bottom;
266
+
267
+ // Sample the window where the meter is on screen.
268
+ const start = episode.trimBeforeSec + episode.revealAtSec;
269
+ const times = [start, (start + end) / 2, Math.max(start, end - 0.2)];
270
+
271
+ const maps = [];
272
+ for (const [i, t] of times.entries()) {
273
+ const f = join(dir, `f${i}.png`);
274
+ frameAt(video, t, f);
275
+ maps.push(await analyzeFrame(f));
276
+ }
277
+
278
+ const width = maps[0].width;
279
+ const height = maps[0].height;
280
+ const sums = maps.map((m) => integral(m.energy, width, height));
281
+
282
+ // Candidate slots: the band under the card, clear of Instagram's UI.
283
+ const mw = METER.width / SCALE;
284
+ const mh = METER.height / SCALE;
285
+ const candidates: { x: number; y: number; score: number; raw: number }[] = [];
286
+
287
+ // Always below the code card — the meter reads as the answer to the snippet,
288
+ // so it should never sit above it.
289
+ const yBands: [number, number][] = [
290
+ [cardBottom + 40, H - IG_BOTTOM_RESERVED - METER.height],
291
+ ];
292
+
293
+ for (const [yStart, yEnd] of yBands) {
294
+ if (yEnd <= yStart) continue;
295
+ for (let py = Math.round(yStart); py <= yEnd; py += 30) {
296
+ for (
297
+ let px = MARGIN;
298
+ px <= W - IG_RIGHT_RESERVED - METER.width;
299
+ px += 40
300
+ ) {
301
+ const x0 = Math.floor(px / SCALE);
302
+ const y0 = Math.floor(py / SCALE);
303
+ const x1 = Math.min(width, Math.ceil(x0 + mw));
304
+ const y1 = Math.min(height, Math.ceil(y0 + mh));
305
+
306
+ // Worst frame decides — the spot must stay clear the whole time.
307
+ let score = 0;
308
+ for (const sum of sums) {
309
+ score = Math.max(score, rectMean(sum, width, x0, y0, x1, y1));
310
+ }
311
+ // Mild preference for the lower-left, where the badge has always sat.
312
+ const pull =
313
+ 0.04 * (px / W) + 0.04 * (1 - py / H);
314
+ candidates.push({ x: px, y: py, score: score + pull, raw: score });
315
+ }
316
+ }
317
+ }
318
+
319
+ candidates.sort((a, b) => a.score - b.score);
320
+ const best = candidates[0];
321
+
322
+ // Score the home slot on the same frames, nudged down if a tall card reaches it.
323
+ const home = { x: HOME.x, y: Math.max(HOME.y, Math.round(cardBottom + 40)) };
324
+ const hx = Math.floor(home.x / SCALE);
325
+ const hy = Math.floor(home.y / SCALE);
326
+ let homeScore = 0;
327
+ for (const sum of sums)
328
+ homeScore = Math.max(
329
+ homeScore,
330
+ rectMean(
331
+ sum,
332
+ width,
333
+ hx,
334
+ hy,
335
+ Math.min(width, Math.ceil(hx + mw)),
336
+ Math.min(height, Math.ceil(hy + mh)),
337
+ ),
338
+ );
339
+
340
+ rmSync(dir, { recursive: true, force: true });
341
+
342
+ // Compare like with like: best.score carries the lower-left pull bias, best.raw doesn't.
343
+ // No candidate at all means the card left no legal band below it — keep the
344
+ // house position rather than failing the whole episode over a badge.
345
+ const useHome = !best || homeScore <= best.raw * HOME_TOLERANCE;
346
+ const chosen = useHome ? home : best;
347
+ episode.meterX = Math.round(chosen.x);
348
+ episode.meterY = Math.round(chosen.y);
349
+ writeFileSync(episodePath, JSON.stringify(episode, null, 2) + "\n");
350
+
351
+ console.log(
352
+ useHome
353
+ ? `${episodePath}: meter at home ${episode.meterX},${episode.meterY} (busyness ${homeScore.toFixed(3)})`
354
+ : `${episodePath}: home too busy (${homeScore.toFixed(3)} vs ${best.raw.toFixed(3)} available) — ` +
355
+ `moved to ${episode.meterX},${episode.meterY}`,
356
+ );
357
+ };
358
+
359
+ main();
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Prep code-card screenshots for overlay use:
3
+ * crop away the (near-)black background around the card, then
4
+ * mask to rounded corners so the output PNG has transparency.
5
+ *
6
+ * Usage: npx tsx scripts/prep-card.ts [file.jpg ...] (default: all of cards-raw/)
7
+ */
8
+ import sharp from "sharp";
9
+ import { readdirSync, mkdirSync } from "node:fs";
10
+ import { join, parse } from "node:path";
11
+
12
+ const RAW = join(import.meta.dirname, "../public/assets/cards-raw");
13
+ const OUT = join(import.meta.dirname, "../public/assets/cards");
14
+ mkdirSync(OUT, { recursive: true });
15
+
16
+ // The card body is ~#1c1c1e on a #000 background. Scan rows/columns of raw
17
+ // pixels and find the bounding box of everything brighter than the background.
18
+ const findCardBounds = async (file: string) => {
19
+ const { data, info } = await sharp(file)
20
+ .greyscale()
21
+ .raw()
22
+ .toBuffer({ resolveWithObject: true });
23
+ const { width, height } = info;
24
+ const THRESH = 12; // > pure-black background + JPEG noise
25
+ let top = height, bottom = 0, left = width, right = 0;
26
+ for (let y = 0; y < height; y++) {
27
+ for (let x = 0; x < width; x++) {
28
+ if (data[y * width + x] > THRESH) {
29
+ if (y < top) top = y;
30
+ if (y > bottom) bottom = y;
31
+ if (x < left) left = x;
32
+ if (x > right) right = x;
33
+ }
34
+ }
35
+ }
36
+ if (top >= bottom || left >= right) throw new Error(`no card found in ${file}`);
37
+ return { left, top, width: right - left + 1, height: bottom - top + 1 };
38
+ };
39
+
40
+ const roundedMask = (w: number, h: number, r: number) =>
41
+ Buffer.from(
42
+ `<svg width="${w}" height="${h}"><rect width="${w}" height="${h}" rx="${r}" ry="${r}" fill="#fff"/></svg>`,
43
+ );
44
+
45
+ const prep = async (file: string) => {
46
+ const bounds = await findCardBounds(file);
47
+ const radius = Math.round(bounds.width * 0.045); // matches the cards' corner radius
48
+ const out = join(OUT, parse(file).name + ".png");
49
+ await sharp(file)
50
+ .extract(bounds)
51
+ .composite([{ input: roundedMask(bounds.width, bounds.height, radius), blend: "dest-in" }])
52
+ .png()
53
+ .toFile(out);
54
+ console.log(`${parse(file).base} -> ${parse(out).base} (${bounds.width}x${bounds.height})`);
55
+ };
56
+
57
+ const args = process.argv.slice(2);
58
+ const files = args.length
59
+ ? args
60
+ : readdirSync(RAW)
61
+ .filter((f) => /\.jpe?g$/i.test(f))
62
+ .map((f) => join(RAW, f));
63
+
64
+ const main = async () => {
65
+ for (const f of files) {
66
+ try {
67
+ await prep(f);
68
+ } catch (e) {
69
+ console.error(`FAILED ${f}: ${(e as Error).message}`);
70
+ }
71
+ }
72
+ };
73
+ main();
Binary file
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Burn overlay layers onto a clip without re-rendering the clip.
3
+ *
4
+ * Usage:
5
+ * npx tsx scripts/render-overlay.ts <input video> <overlay json> <output mp4>
6
+ *
7
+ * Two passes:
8
+ * 1. Remotion renders the `Overlay` composition alpha-only to ProRes 4444,
9
+ * at `--scale` so a 1080x1920 composition rasterizes natively at the
10
+ * output resolution. Type and the dot matrix stay crisp at 4K without any
11
+ * component knowing 4K exists.
12
+ * 2. ffmpeg scales/crops the source to the target frame and composites the
13
+ * overlay over it, hardware-encoded via videotoolbox.
14
+ *
15
+ * The source video never enters Chrome. That's the point: pushing 4K frames
16
+ * through a headless browser is slow and re-encodes footage that was already
17
+ * fine, and it's what makes the round trip feel instant instead of awful.
18
+ */
19
+ import { execFileSync } from "node:child_process";
20
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join, resolve } from "node:path";
23
+ import { overlaySchema, type OverlayProps } from "../src/overlay/types";
24
+
25
+ const FRAME = { width: 1080, height: 1920 };
26
+
27
+ const run = (cmd: string, args: string[]) =>
28
+ execFileSync(cmd, args, { stdio: "inherit", cwd: resolve(__dirname, "..") });
29
+
30
+ const probe = (file: string) => {
31
+ const out = execFileSync(
32
+ "ffprobe",
33
+ [
34
+ "-v", "error",
35
+ "-select_streams", "v:0",
36
+ "-show_entries", "stream=width,height,duration",
37
+ "-show_entries", "format=duration",
38
+ "-of", "json",
39
+ file,
40
+ ],
41
+ { encoding: "utf8" },
42
+ );
43
+ const json = JSON.parse(out);
44
+ const stream = json.streams?.[0] ?? {};
45
+ const duration = Number(stream.duration ?? json.format?.duration ?? 0);
46
+ return {
47
+ width: Number(stream.width),
48
+ height: Number(stream.height),
49
+ duration,
50
+ };
51
+ };
52
+
53
+ /**
54
+ * Burn `props` onto `input`, writing `output`. Exported so the Overlay Factory
55
+ * worker can call it directly instead of shelling back out to this file.
56
+ */
57
+ export const renderOverlay = (
58
+ input: string,
59
+ props: OverlayProps,
60
+ output: string,
61
+ ) => {
62
+ if (!existsSync(input)) throw new Error(`No such video: ${input}`);
63
+
64
+ const source = probe(input);
65
+ if (!source.width || !source.height) {
66
+ throw new Error(`Could not read video dimensions from ${input}`);
67
+ }
68
+
69
+ // Output keeps the source's vertical resolution, so 4K in means 4K out. The
70
+ // overlay is scaled to match rather than the footage being scaled to the
71
+ // overlay — the whole reason the footage stays untouched.
72
+ const targetHeight = source.height;
73
+ const targetWidth = Math.round((targetHeight * FRAME.width) / FRAME.height);
74
+ const scale = targetHeight / FRAME.height;
75
+
76
+ console.log(
77
+ `source ${source.width}x${source.height} ${source.duration.toFixed(1)}s ` +
78
+ `→ ${targetWidth}x${targetHeight} (overlay scale ${scale.toFixed(2)}x)`,
79
+ );
80
+
81
+ const tmp = mkdtempSync(join(tmpdir(), "overlay-"));
82
+ const alphaFile = join(tmp, "overlay.mov");
83
+
84
+ try {
85
+ // Pass 1 — overlay only, alpha preserved.
86
+ run("npx", [
87
+ "remotion", "render", "Overlay",
88
+ alphaFile,
89
+ "--codec", "prores",
90
+ "--prores-profile", "4444",
91
+ // ProRes 4444 is the only route to a real alpha channel out of Remotion;
92
+ // "transparent" without it silently composites onto black. The PNG frame
93
+ // format isn't optional either — JPEG frames have no alpha to carry, and
94
+ // Remotion rejects the combination rather than quietly flattening it.
95
+ "--pixel-format", "yuva444p10le",
96
+ "--image-format", "png",
97
+ "--scale", String(scale),
98
+ "--props", JSON.stringify(props),
99
+ ]);
100
+
101
+ // Pass 2 — composite. force_original_aspect_ratio=increase + crop is
102
+ // objectFit:cover, so footage that isn't already 9:16 fills the frame
103
+ // instead of letterboxing.
104
+ run("ffmpeg", [
105
+ "-y",
106
+ "-i", input,
107
+ "-i", alphaFile,
108
+ "-filter_complex",
109
+ `[0:v]scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=increase,` +
110
+ `crop=${targetWidth}:${targetHeight},setsar=1[bg];` +
111
+ `[1:v]scale=${targetWidth}:${targetHeight}[ov];` +
112
+ // format=yuv420p is not cosmetic: the overlay filter hands back BGRA,
113
+ // and an HEVC file in BGRA imports into iOS Photos as a file that
114
+ // won't play. Everything downstream of here expects 4:2:0.
115
+ `[bg][ov]overlay=0:0:format=auto,format=yuv420p[out]`,
116
+ "-map", "[out]",
117
+ // Keep the original audio when there is one; -shortest stops at the
118
+ // overlay's length so a long clip trims to the overlay, not the reverse.
119
+ "-map", "0:a?",
120
+ "-c:v", "hevc_videotoolbox",
121
+ "-b:v", targetHeight >= 2000 ? "40M" : "12M",
122
+ "-tag:v", "hvc1",
123
+ "-c:a", "aac",
124
+ "-b:a", "192k",
125
+ "-shortest",
126
+ output,
127
+ ]);
128
+
129
+ console.log(`\n✓ ${output}`);
130
+ } finally {
131
+ rmSync(tmp, { recursive: true, force: true });
132
+ }
133
+ };
134
+
135
+ // CLI entry — skipped when the worker imports renderOverlay.
136
+ if (require.main === module) {
137
+ const [input, overlayJson, output] = process.argv.slice(2);
138
+ if (!input || !overlayJson || !output) {
139
+ console.error(
140
+ "usage: render-overlay.ts <input video> <overlay json> <output mp4>",
141
+ );
142
+ process.exit(1);
143
+ }
144
+ if (!existsSync(overlayJson)) throw new Error(`No such JSON: ${overlayJson}`);
145
+ renderOverlay(
146
+ input,
147
+ overlaySchema.parse(JSON.parse(readFileSync(overlayJson, "utf8"))),
148
+ output,
149
+ );
150
+ }