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,1119 @@
1
+ /**
2
+ * Overlay Factory worker.
3
+ *
4
+ * Polls Goose Tools for overlay jobs, renders them here, uploads the result.
5
+ * The render needs Remotion and ffmpeg, which is exactly why it can't run in a
6
+ * serverless function — this machine is the renderer.
7
+ *
8
+ * npm run overlay:worker
9
+ *
10
+ * The token is the same one the Carousel Maker worker uses — one connected
11
+ * computer serves every Goose Tools worker — so this reads ~/.goosetools/env,
12
+ * where that worker's installer already saved it. Set OVERLAY_WORKER_TOKEN to
13
+ * override, or get a fresh token from /dashboard/overlay.
14
+ */
15
+ import { upload } from "@vercel/blob/client";
16
+ import { execFileSync } from "node:child_process";
17
+ import {
18
+ existsSync,
19
+ mkdirSync,
20
+ mkdtempSync,
21
+ readFileSync,
22
+ rmSync,
23
+ statSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { homedir, tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+ import sharp from "sharp";
29
+ import { overlaySchema } from "../src/overlay/types";
30
+ import { cardBox } from "../src/overlay/cardLayout";
31
+ import { renderOverlay } from "./render-overlay";
32
+ import {
33
+ generateSeries,
34
+ generateSingle,
35
+ sweepSingles,
36
+ readSeriesSpec,
37
+ renderSeriesPreview,
38
+ seriesLayers,
39
+ type Brand,
40
+ } from "./series";
41
+ import {
42
+ markEpisodeUsed,
43
+ nextEpisode,
44
+ restock,
45
+ stockCount,
46
+ } from "./stock";
47
+ // Only the functions — library exports its own Puzzle type, and the one in
48
+ // this file means something different (a typed-in one-off, not a library card).
49
+ import { listPuzzles, markUsed, pickPuzzle } from "./library";
50
+ import { applyFeedback, patchLook, readLook, resolveLook } from "./look-store";
51
+ import { LOOK_BOUNDS } from "../src/overlay/look";
52
+ import { cardForLook } from "./look-card";
53
+ import { resolveCustomFonts, type ResolvedFont } from "./custom-fonts";
54
+ import { resolveFieldImages } from "./field-images";
55
+ import { autoRestock, whatPrintsRestock } from "./wp-restock";
56
+ import { ensureStateLinks } from "./state-dir";
57
+
58
+ // Installed from npm, the writable directories under ROOT are symlinks into
59
+ // ~/.goosetools/overlay. Re-made on every start: `update` reinstalls the
60
+ // package directory and the previous run's links go with it. No-op in a
61
+ // checkout, where state has always lived in the repo. First thing, before
62
+ // anything reads or writes through one of those paths.
63
+ ensureStateLinks();
64
+
65
+ /**
66
+ * Where the Goose Tools installer keeps this machine's worker credentials.
67
+ * Reading it means the usual case needs no environment variables at all.
68
+ */
69
+ const readSharedEnv = (): Record<string, string> => {
70
+ const file = join(homedir(), ".goosetools", "env");
71
+ if (!existsSync(file)) return {};
72
+ const out: Record<string, string> = {};
73
+ for (const line of readFileSync(file, "utf8").split("\n")) {
74
+ const m = line.match(/^\s*([A-Z_]+)\s*=\s*(.*)$/);
75
+ if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
76
+ }
77
+ return out;
78
+ };
79
+
80
+ const shared = readSharedEnv();
81
+ const BASE_URL =
82
+ process.env.GOOSE_TOOLS_URL ?? shared.GOOSETOOLS_URL ?? "https://goosetools.com";
83
+ const TOKEN = process.env.OVERLAY_WORKER_TOKEN ?? shared.WORKER_TOKEN;
84
+ /**
85
+ * How long to wait when there was nothing to do — adaptive.
86
+ *
87
+ * The idle interval is the floor on how long ANY tap waits before this machine
88
+ * even hears about it, so while someone is actually working it has to be
89
+ * short: at 30s a preview redraw spent most of its life queued, which is what
90
+ * made dragging feel broken rather than slow.
91
+ *
92
+ * But most of the day nobody is posting, and asking every 4s around the clock
93
+ * is ~20,000 requests a day to catch a few minutes of activity. So it stays
94
+ * quick for a couple of minutes after the last job and then backs off.
95
+ */
96
+ const POLL_FAST_MS = 3_000;
97
+ // 60s, not 10s: the 10s idle poll (with three sibling workers doing the
98
+ // same) kept the prod database awake around the clock and burned its whole
99
+ // compute quota. A first tap after a quiet spell now waits up to a minute;
100
+ // everything after it rides the fast lane.
101
+ const POLL_IDLE_MS = 60_000;
102
+ const STAY_FAST_MS = 2 * 60 * 1000;
103
+
104
+ /** When the last job finished — the thing "are we still working?" means. */
105
+ let lastJobAt = 0;
106
+ const pollDelay = () =>
107
+ Date.now() - lastJobAt < STAY_FAST_MS ? POLL_FAST_MS : POLL_IDLE_MS;
108
+
109
+ if (!TOKEN) {
110
+ console.error(
111
+ "No worker token. Either connect this computer from /dashboard/setup,\n" +
112
+ "or set OVERLAY_WORKER_TOKEN=gt_...",
113
+ );
114
+ process.exit(1);
115
+ }
116
+
117
+ type Puzzle = {
118
+ name: string;
119
+ language: string;
120
+ code: string;
121
+ answer: string;
122
+ difficulty: "easy" | "medium" | "hard";
123
+ why?: string;
124
+ };
125
+
126
+ type NewSeries = {
127
+ slug: string;
128
+ name: string;
129
+ instructions: string;
130
+ refUrls: string[];
131
+ };
132
+
133
+ type Job = {
134
+ id: string;
135
+ kind: "overlay" | "series" | "restock" | "preview" | "single" | "look";
136
+ /** kind='single' and one-off previews: what it should say, and what to copy. */
137
+ single?: { say: string; refUrls: string[] } | null;
138
+ /** The preview this job confirms, so it reuses that overlay rather than writing a new one. */
139
+ singleFor?: string | null;
140
+ /** kind='preview': a note asking for a change, if this is a revision. */
141
+ feedback?: string | null;
142
+ /** kind='preview': an exact change — a drag, or a reset. No model involved. */
143
+ lookPatch?: unknown;
144
+ /** kind='restock': how many episodes to research and write. */
145
+ restockCount?: number;
146
+ /** Null when the series is set to render in its own colours. */
147
+ brand: (Brand & { customFonts?: { family: string; url: string }[] }) | null;
148
+ newSeries: NewSeries | null;
149
+ series: string;
150
+ puzzle: Puzzle | Record<string, string> | null;
151
+ sourceUrl: string;
152
+ sourceName: string | null;
153
+ durationSec: number | null;
154
+ layers: unknown[];
155
+ /** Cards this account has consumed on any machine. */
156
+ usedKeys?: string[];
157
+ };
158
+
159
+ const ROOT = join(__dirname, "..");
160
+ const PUBLIC = join(ROOT, "public");
161
+
162
+ /** A one-off's directory name — the same for its preview and its burn. */
163
+ const singleSlug = (id: string) => `single-${id.slice(0, 8)}`;
164
+
165
+ /** Filesystem-safe, collision-free, and still readable in assets/cards/. */
166
+ const slugFor = (job: Job, puzzle: Puzzle) =>
167
+ `${(puzzle.name || "puzzle")
168
+ .toLowerCase()
169
+ .replace(/[^a-z0-9]+/g, "-")
170
+ .replace(/^-|-$/g, "")
171
+ .slice(0, 40) || "puzzle"}-${job.id.slice(0, 8)}`;
172
+
173
+ /**
174
+ * Run a script, and on failure raise the part a human wrote.
175
+ *
176
+ * execFileSync's message is the whole command line plus a Node stack trace,
177
+ * which is what ends up in job.error and then on her phone. The scripts fail
178
+ * with real explanations ("failed 1 quality gate(s): why is missing or too
179
+ * thin") — that's the sentence worth surfacing, so pull it out and drop the
180
+ * interpreter noise.
181
+ */
182
+ const run = (cmd: string, args: string[]) => {
183
+ try {
184
+ return execFileSync(cmd, args, {
185
+ cwd: ROOT,
186
+ encoding: "utf8",
187
+ maxBuffer: 10 * 1024 * 1024,
188
+ stdio: ["ignore", "pipe", "pipe"],
189
+ });
190
+ } catch (e) {
191
+ const err = e as { stdout?: string; stderr?: string; message?: string };
192
+ const text = `${err.stdout ?? ""}\n${err.stderr ?? ""}`;
193
+ const lines = text.split("\n");
194
+ const start = lines.findIndex((l) => /^\s*(Error|TypeError):/.test(l));
195
+ const meaningful =
196
+ start === -1
197
+ ? []
198
+ : lines
199
+ .slice(start)
200
+ .filter((l) => !/^\s*at\s/.test(l)) // stack frames
201
+ .filter((l) => l.trim())
202
+ .slice(0, 6);
203
+ throw new Error(
204
+ meaningful.length
205
+ ? meaningful.join("\n").replace(/^\s*Error:\s*/, "")
206
+ : (err.message ?? "script failed"),
207
+ );
208
+ }
209
+ };
210
+
211
+ /**
212
+ * Build the What Prints? layers.
213
+ *
214
+ * The puzzle comes from the library on this machine — 30-odd cards already
215
+ * written, verified against a real run, and rendered. Asking someone to retype
216
+ * a puzzle they already have was the wrong shape entirely: the tool's job is to
217
+ * pair a clip with a puzzle, not to collect one.
218
+ *
219
+ * `puzzle.key` picks a specific card; without it, the next unused one, exactly
220
+ * as scripts/new-episode.ts would choose. A one-off typed puzzle still works —
221
+ * it goes through make-card.ts so it gets the same verify and quality gates the
222
+ * library cards passed.
223
+ */
224
+ const whatPrintsLayers = (
225
+ job: Job,
226
+ input: Record<string, string> | Puzzle | null,
227
+ brollRelPath: string,
228
+ durationSec: number,
229
+ lookCardPath?: string,
230
+ ) => {
231
+ const typed = input && "code" in input && input.code ? (input as Puzzle) : null;
232
+ const revealAtSec = Math.min(2, Math.max(0, durationSec - 1));
233
+
234
+ let cardRelPath: string;
235
+ let difficulty: "easy" | "medium" | "hard";
236
+
237
+ if (typed) {
238
+ const slug = slugFor(job, typed);
239
+ const puzzleFile = join(tmpdir(), `${slug}.json`);
240
+ writeFileSync(
241
+ puzzleFile,
242
+ JSON.stringify({ ...typed, slug, name: typed.name || slug }),
243
+ );
244
+ try {
245
+ console.log(" rendering card + verifying the answer");
246
+ run("npx", ["tsx", "scripts/make-card.ts", puzzleFile]);
247
+ } finally {
248
+ rmSync(puzzleFile, { force: true });
249
+ }
250
+ cardRelPath = `assets/cards/${slug}.png`;
251
+ difficulty = typed.difficulty;
252
+ } else {
253
+ const chosen = pickPuzzle(
254
+ (input as Record<string, string> | null)?.key,
255
+ job.usedKeys ?? [],
256
+ );
257
+ console.log(` puzzle: ${chosen.name} (${chosen.difficulty}) — ${chosen.key}`);
258
+ // The same card the preview showed, so what she approved is what renders.
259
+ cardRelPath = lookCardPath ?? chosen.cardRelPath;
260
+ difficulty = chosen.difficulty;
261
+ // Claimed as soon as it's chosen. A crash mid-render costs one card from
262
+ // the queue; double-posting the same puzzle costs a post.
263
+ markUsed(chosen.key, `overlay-${job.id.slice(0, 8)}`);
264
+ consumedKey = chosen.key;
265
+ }
266
+
267
+ // place-overlay reads and rewrites an episode file, so it gets a throwaway
268
+ // one pointing at this job's clip and card.
269
+ const episodeFile = join(tmpdir(), `overlay-${job.id.slice(0, 8)}-episode.json`);
270
+ let meterX: number | undefined;
271
+ let meterY: number | undefined;
272
+ // Set only when the card had to move off something — usually her face.
273
+ let cardY: number | undefined;
274
+ let cardWidthPct: number | undefined;
275
+ try {
276
+ writeFileSync(
277
+ episodeFile,
278
+ JSON.stringify({
279
+ broll: brollRelPath,
280
+ card: cardRelPath,
281
+ durationSec,
282
+ revealAtSec,
283
+ trimBeforeSec: 0,
284
+ }),
285
+ );
286
+ console.log(" placing the meter against the footage");
287
+ run("npx", ["tsx", "scripts/place-overlay.ts", episodeFile]);
288
+ const placed = JSON.parse(readFileSync(episodeFile, "utf8"));
289
+ meterX = placed.meterX;
290
+ meterY = placed.meterY;
291
+ cardY = placed.cardY;
292
+ cardWidthPct = placed.cardWidthPct;
293
+ } catch (e) {
294
+ // Placement is an optimisation, not a requirement — the meter has a
295
+ // sensible home position. Losing it shouldn't cost her the whole render.
296
+ console.warn(
297
+ ` ! placement skipped (${e instanceof Error ? e.message.split("\n")[0] : e})`,
298
+ );
299
+ } finally {
300
+ rmSync(episodeFile, { force: true });
301
+ }
302
+
303
+ return whatPrintsComposite(job, {
304
+ cardRelPath,
305
+ difficulty,
306
+ revealAtSec,
307
+ cardY,
308
+ cardWidthPct,
309
+ meterX,
310
+ meterY,
311
+ });
312
+ };
313
+
314
+ /**
315
+ * Assemble the layers, with the series' stored look applied over whatever the
316
+ * browser sent. The look is what feedback edits, so it has to win.
317
+ */
318
+ const whatPrintsComposite = (
319
+ job: Job,
320
+ p: {
321
+ cardRelPath: string;
322
+ difficulty: "easy" | "medium" | "hard";
323
+ revealAtSec: number;
324
+ cardY?: number;
325
+ cardWidthPct?: number;
326
+ meterX?: number;
327
+ meterY?: number;
328
+ },
329
+ ) => {
330
+ const look = resolveLook(readLook(job.series));
331
+ const layers: unknown[] = [];
332
+
333
+ for (const raw of job.layers) {
334
+ const l = raw as Record<string, unknown>;
335
+ if (l.type === "title") {
336
+ layers.push({
337
+ ...l,
338
+ sansSize: look.title.sansSize,
339
+ rows: look.title.rows,
340
+ cell: look.title.cell,
341
+ gap: look.title.gap,
342
+ weight: look.title.weight,
343
+ family: look.title.family,
344
+ // The checkbox can only turn the scrim OFF; the look can't force it on
345
+ // over an explicit choice in the form.
346
+ scrim: l.scrim === false ? false : look.title.scrim,
347
+ });
348
+ } else {
349
+ layers.push(l);
350
+ }
351
+ }
352
+
353
+ // A hand-placed position beats the placement script. The script is a good
354
+ // guess about footage it has just looked at; a drag is a decision.
355
+ layers.push({
356
+ type: "image",
357
+ src: p.cardRelPath,
358
+ // Null means automatic: centred horizontally, placed vertically by the
359
+ // script that just looked at the footage.
360
+ ...(look.card.x == null ? {} : { x: look.card.x }),
361
+ y: look.card.y ?? p.cardY,
362
+ widthPct: look.card.y != null ? look.card.widthPct : (p.cardWidthPct ?? look.card.widthPct),
363
+ opacity: look.card.opacity,
364
+ });
365
+
366
+ if (look.meter.show) {
367
+ layers.push({
368
+ type: "meter",
369
+ difficulty: p.difficulty,
370
+ fromSec: p.revealAtSec,
371
+ x: look.meter.x ?? p.meterX,
372
+ y: look.meter.y ?? p.meterY,
373
+ });
374
+ }
375
+ return layers;
376
+ };
377
+
378
+ const api = async (path: string, body?: unknown) => {
379
+ const res = await fetch(`${BASE_URL}/api/overlay/worker/${path}`, {
380
+ method: "POST",
381
+ headers: {
382
+ authorization: `Bearer ${TOKEN}`,
383
+ "content-type": "application/json",
384
+ },
385
+ body: JSON.stringify(body ?? {}),
386
+ });
387
+ if (res.status === 204) return null;
388
+ if (!res.ok) throw new Error(`${path}: ${res.status} ${await res.text()}`);
389
+ return res.json();
390
+ };
391
+
392
+ /** ffprobe the downloaded clip — the browser's duration guess isn't authoritative. */
393
+ const probeDuration = (file: string): number => {
394
+ const out = execFileSync(
395
+ "ffprobe",
396
+ [
397
+ "-v", "error",
398
+ "-show_entries", "format=duration",
399
+ "-of", "default=nw=1:nk=1",
400
+ file,
401
+ ],
402
+ { encoding: "utf8" },
403
+ );
404
+ return Number(out.trim());
405
+ };
406
+
407
+ /**
408
+ * Generate a new series from references and instructions. No footage involved:
409
+ * the output is series/<slug>/ on this machine, plus the field list the form
410
+ * will ask for from now on.
411
+ */
412
+ const runSeriesJob = async (job: Job) => {
413
+ const spec = job.newSeries;
414
+ console.log(`→ series ${spec?.slug ?? "?"} — building`);
415
+ try {
416
+ if (!spec?.slug) throw new Error("Series job carried no definition");
417
+ const fields = await generateSeries({ ...spec, brand: job.brand });
418
+
419
+ // The tile art. Best-effort: a series that renders but whose preview fails
420
+ // is still a usable series, and the tile falls back to a reference image.
421
+ let previewUrl: string | undefined;
422
+ const previewPath = join(tmpdir(), `${spec.slug}-preview.png`);
423
+ try {
424
+ console.log(" rendering preview frame");
425
+ renderSeriesPreview(spec.slug, fields, job.brand, previewPath);
426
+ const blob = await upload(
427
+ `overlay/series-previews/${spec.slug}.png`,
428
+ readFileSync(previewPath),
429
+ {
430
+ access: "public",
431
+ handleUploadUrl: `${BASE_URL}/api/overlay/upload`,
432
+ clientPayload: JSON.stringify({ workerToken: TOKEN }),
433
+ contentType: "image/png",
434
+ },
435
+ );
436
+ previewUrl = blob.url;
437
+ } catch (e) {
438
+ console.warn(
439
+ ` ! preview skipped (${e instanceof Error ? e.message.split("\n")[0] : e})`,
440
+ );
441
+ } finally {
442
+ rmSync(previewPath, { force: true });
443
+ }
444
+
445
+ // Whether it wrote itself a content brief decides if it can ever restock.
446
+ const canRestock = Boolean(readSeriesSpec(spec.slug)?.content?.what);
447
+ await api("complete", {
448
+ jobId: job.id,
449
+ ok: true,
450
+ fields,
451
+ previewUrl,
452
+ canRestock,
453
+ });
454
+ console.log(`✓ series ${spec.slug} ready (${fields.length} field(s))`);
455
+ } catch (e) {
456
+ const message = e instanceof Error ? e.message : String(e);
457
+ console.error(`✗ series ${spec?.slug}: ${message}`);
458
+ await api("complete", { jobId: job.id, ok: false, error: message });
459
+ }
460
+ };
461
+
462
+ /**
463
+ * What this episode of a generated series should say.
464
+ *
465
+ * Typed values win. Otherwise it takes the next unused episode from the
466
+ * series' own queue — the generic version of What Prints? taking the next
467
+ * unused card, so any series that has a content brief keeps producing without
468
+ * anyone typing.
469
+ */
470
+ const resolveValues = (job: Job): Record<string, string> => {
471
+ const typed = (job.puzzle ?? {}) as Record<string, string>;
472
+ if (Object.keys(typed).length > 0) return typed;
473
+
474
+ const episode = nextEpisode(job.series);
475
+ if (!episode) {
476
+ throw new Error(
477
+ `"${job.series}" has no episodes left. Restock it from the tool, or fill the fields in yourself.`,
478
+ );
479
+ }
480
+ console.log(` episode: ${Object.values(episode.values).join(" · ").slice(0, 80)}`);
481
+ // Claimed at selection, like a What Prints? card: a crash costs one episode
482
+ // from the queue, a double-claim costs a duplicate post.
483
+ markEpisodeUsed(job.series, episode.id, `overlay-${job.id.slice(0, 8)}`);
484
+ return episode.values;
485
+ };
486
+
487
+ /**
488
+ * Research and write new episodes for a series that's running low.
489
+ */
490
+ const runRestockJob = async (job: Job) => {
491
+ console.log(`→ restock ${job.series} — researching`);
492
+ try {
493
+ // What Prints? writes real puzzles rather than field values: the snippet
494
+ // has to be executed and gated before it can become a post, which no
495
+ // generated series can do.
496
+ if (job.series === "what-prints") {
497
+ const { accepted, rejected } = await whatPrintsRestock(job.restockCount ?? 6);
498
+ await api("complete", {
499
+ jobId: job.id,
500
+ ok: true,
501
+ stock: listPuzzles().filter((p) => !p.used).length,
502
+ wrote: accepted.length,
503
+ note:
504
+ `Wrote ${accepted.length}` +
505
+ (rejected.length ? ` · ${rejected.length} failed checking` : ""),
506
+ });
507
+ console.log(`✓ restock what-prints: +${accepted.length}`);
508
+ return;
509
+ }
510
+
511
+ const spec = readSeriesSpec(job.series);
512
+ if (!spec) throw new Error(`Series "${job.series}" isn't built on this computer`);
513
+ if (!spec.content?.what) {
514
+ throw new Error(
515
+ `"${spec.name}" has no content brief, so it can't write its own episodes. Rebuild it and say where its content should come from.`,
516
+ );
517
+ }
518
+ const written = await restock({
519
+ slug: job.series,
520
+ seriesName: spec.name,
521
+ fields: spec.fields,
522
+ content: spec.content,
523
+ count: job.restockCount ?? 10,
524
+ });
525
+ await api("complete", {
526
+ jobId: job.id,
527
+ ok: true,
528
+ stock: stockCount(job.series),
529
+ wrote: written.length,
530
+ });
531
+ console.log(`✓ restock ${job.series}: +${written.length} (${stockCount(job.series)} in stock)`);
532
+ } catch (e) {
533
+ const message = e instanceof Error ? e.message : String(e);
534
+ console.error(`✗ restock ${job.series}: ${message}`);
535
+ await api("complete", { jobId: job.id, ok: false, error: message });
536
+ }
537
+ };
538
+
539
+ /**
540
+ * Remember where something was put. No render, no image, no wait.
541
+ *
542
+ * Dragging is answered by the browser, which holds the pieces and can move
543
+ * them as many times as it likes. This is only the part that has to outlive
544
+ * the tab: the position, written into the series' stored look so the final
545
+ * burn puts it in the same place.
546
+ */
547
+ const runLookJob = async (job: Job) => {
548
+ try {
549
+ if (!job.lookPatch) throw new Error("Look job carried no change");
550
+ patchLook(job.series, job.lookPatch as never);
551
+ console.log(`✓ look ${job.series} — saved`);
552
+ await api("complete", { jobId: job.id, ok: true });
553
+ } catch (e) {
554
+ const message = e instanceof Error ? e.message : String(e);
555
+ console.error(`✗ look ${job.series}: ${message}`);
556
+ await api("complete", { jobId: job.id, ok: false, error: message });
557
+ }
558
+ };
559
+
560
+ /**
561
+ * Render the look as a still, and apply any note that came with it.
562
+ *
563
+ * No clip involved: the point is to see and correct the overlay BEFORE
564
+ * committing a 4K render to it. The still is drawn over a neutral grey for the
565
+ * same reason the series tiles are — this is showing the overlay, and real
566
+ * footage behind it makes two attempts hard to compare.
567
+ */
568
+ const runPreviewJob = async (job: Job) => {
569
+ console.log(`→ preview ${job.series}${job.feedback ? " (revision)" : ""}`);
570
+ const dir = mkdtempSync(join(tmpdir(), `overlay-preview-${job.id}-`));
571
+ const alpha = join(dir, "alpha.png");
572
+
573
+ // A one-off previews the overlay it is about to burn. The templates are
574
+ // KEPT: the burn reuses them, because generating again would write a
575
+ // different overlay from the one that was approved.
576
+ if (job.single) {
577
+ try {
578
+ const { layers } = await generateSingle({
579
+ slug: singleSlug(job.singleFor ?? job.id),
580
+ say: job.single.say,
581
+ refUrls: job.single.refUrls,
582
+ durationSec: 6,
583
+ brand: job.brand,
584
+ keep: true,
585
+ });
586
+ const fonts = await resolveCustomFonts(job.brand?.customFonts ?? []);
587
+ const propsFile = join(dir, "props.json");
588
+ writeFileSync(propsFile, JSON.stringify({ durationSec: 6, layers, fonts }));
589
+ run("npx", [
590
+ "remotion", "still", "Overlay", alpha,
591
+ "--props", propsFile,
592
+ "--image-format", "png",
593
+ "--frame", "45",
594
+ ]);
595
+ const blob = await upload(
596
+ `overlay/previews/${job.id}-base.png`,
597
+ readFileSync(alpha),
598
+ {
599
+ access: "public",
600
+ handleUploadUrl: `${BASE_URL}/api/overlay/upload`,
601
+ clientPayload: JSON.stringify({ workerToken: TOKEN }),
602
+ contentType: "image/png",
603
+ },
604
+ );
605
+ await api("complete", { jobId: job.id, ok: true, previewUrl: blob.url });
606
+ console.log(`✓ preview ${job.id} (one-off)`);
607
+ } catch (e) {
608
+ const message = e instanceof Error ? e.message : String(e);
609
+ console.error(`✗ preview ${job.id}: ${message}`);
610
+ await api("complete", { jobId: job.id, ok: false, error: message });
611
+ } finally {
612
+ rmSync(dir, { recursive: true, force: true });
613
+ }
614
+ return;
615
+ }
616
+
617
+ try {
618
+ let note: string | undefined;
619
+ if (job.lookPatch) {
620
+ patchLook(job.series, job.lookPatch as never);
621
+ note = "Moved.";
622
+ } else if (job.feedback?.trim()) {
623
+ console.log(` applying: ${job.feedback.trim().slice(0, 80)}`);
624
+ const applied = await applyFeedback({
625
+ slug: job.series,
626
+ feedback: job.feedback.trim(),
627
+ customFamilies: (job.brand?.customFonts ?? []).map((f) => f.family),
628
+ });
629
+ note = applied.note;
630
+ console.log(` ${note}`);
631
+ }
632
+
633
+ // The puzzle is only READ here — a preview must not consume a card from
634
+ // the library, or iterating on the look would burn the queue.
635
+ const puzzle = pickPuzzle(
636
+ (job.puzzle as Record<string, string> | null)?.key ?? null,
637
+ );
638
+ const cardRelPath = await cardForLook({
639
+ key: puzzle.key,
640
+ code: puzzle.code,
641
+ language: puzzle.language,
642
+ look: resolveLook(readLook(job.series)),
643
+ });
644
+ const layers = whatPrintsComposite(job, {
645
+ cardRelPath,
646
+ difficulty: puzzle.difficulty,
647
+ revealAtSec: 0,
648
+ });
649
+
650
+ const fonts: ResolvedFont[] = await resolveCustomFonts(
651
+ job.brand?.customFonts ?? [],
652
+ );
653
+ // Rendered in PIECES rather than as one baked frame.
654
+ //
655
+ // A single flattened still means every nudge of the card is a round trip
656
+ // to this machine — you move a thing, then wait to see it move. Sending
657
+ // the card and the badge as their own images lets the browser place them
658
+ // itself, so dragging is immediate and can be done as many times as you
659
+ // like; this machine only hears the final position.
660
+ //
661
+ // What's left in the base still is everything that isn't draggable — the
662
+ // title, the scrim — so it stays a render rather than a reimplementation
663
+ // of the overlay in CSS.
664
+ const draggable = new Set(["image", "meter"]);
665
+ const baseLayers = layers.filter(
666
+ (l) => !draggable.has((l as { type?: string }).type ?? ""),
667
+ );
668
+
669
+ const stillOf = (name: string, only: unknown[]) => {
670
+ const out = join(dir, `${name}.png`);
671
+ const props = join(dir, `${name}.props.json`);
672
+ writeFileSync(props, JSON.stringify({ durationSec: 3, layers: only, fonts }));
673
+ run("npx", [
674
+ "remotion", "still", "Overlay", out,
675
+ "--props", props,
676
+ "--image-format", "png",
677
+ "--frame", "45", // past the meter's entrance spring, so it's fully in
678
+ ]);
679
+ return out;
680
+ };
681
+
682
+ stillOf("alpha", baseLayers);
683
+ // The still keeps its ALPHA — it is uploaded as-is rather than flattened
684
+ // onto a neutral gradient here. The overlay is meant to be judged against
685
+ // the footage it will actually sit on, and the browser already holds the
686
+ // clip: stacking a transparent PNG over it there costs nothing, where
687
+ // doing it on this machine would mean shipping the clip up and back. With
688
+ // no clip picked yet the UI paints the same grey behind it.
689
+
690
+ // Where things actually landed, in 1080x1920 space, so the UI can put a
691
+ // drag handle exactly on top of them instead of guessing from the image.
692
+ const cardLayer = layers.find(
693
+ (l) => (l as { type?: string }).type === "image",
694
+ ) as { x?: number; y?: number; widthPct?: number } | undefined;
695
+ const meterLayer = layers.find(
696
+ (l) => (l as { type?: string }).type === "meter",
697
+ ) as { x?: number; y?: number } | undefined;
698
+
699
+ let boxes: Record<string, unknown> | undefined;
700
+ try {
701
+ const meta = await sharp(join(PUBLIC, cardRelPath)).metadata();
702
+ const box = cardBox(
703
+ meta.height! / meta.width!,
704
+ cardLayer?.widthPct ?? undefined,
705
+ cardLayer?.y ?? undefined,
706
+ );
707
+ const cardX =
708
+ cardLayer?.x === undefined ? box.x : cardLayer.x - box.width / 2;
709
+ // Where each piece sits when nothing has been placed by hand. For a
710
+ // preview that is not a decision this machine makes — there's no
711
+ // footage to look at, so it's the composition's own defaults. Sending
712
+ // them lets "reset to automatic" happen in the browser, immediately,
713
+ // instead of costing a render to find out something already known.
714
+ const autoBox = cardBox(meta.height! / meta.width!);
715
+ const auto = {
716
+ card: {
717
+ x: autoBox.x + autoBox.width / 2,
718
+ y: autoBox.y + autoBox.height / 2,
719
+ },
720
+ meter: { x: 90, y: 1400 },
721
+ };
722
+
723
+ boxes = {
724
+ auto,
725
+ // What the schema will actually accept, so a drag can't produce a
726
+ // position that fails to save.
727
+ bounds: LOOK_BOUNDS,
728
+ card: { x: cardX, y: box.y, w: box.width, h: box.height },
729
+ meter: meterLayer
730
+ ? {
731
+ x: meterLayer.x ?? 90,
732
+ y: meterLayer.y ?? 1400,
733
+ w: 400,
734
+ h: 150,
735
+ }
736
+ : null,
737
+ frame: { w: 1080, h: 1920 },
738
+ };
739
+ } catch {
740
+ // Handles are a convenience; a preview without them still works.
741
+ }
742
+
743
+ const put = async (name: string, file: string) => {
744
+ const b = await upload(
745
+ `overlay/previews/${job.id}-${name}.png`,
746
+ readFileSync(file),
747
+ {
748
+ access: "public",
749
+ handleUploadUrl: `${BASE_URL}/api/overlay/upload`,
750
+ clientPayload: JSON.stringify({ workerToken: TOKEN }),
751
+ contentType: "image/png",
752
+ },
753
+ );
754
+ return b.url;
755
+ };
756
+
757
+ const blob = { url: await put("base", alpha) };
758
+
759
+ // The pieces the browser will position. The card is already a PNG on disk
760
+ // — it's the post — so it goes up as-is. The badge is drawn on its own and
761
+ // cropped to its box, so the browser gets a badge rather than a
762
+ // mostly-empty 1080x1920 sheet with a badge in one corner.
763
+ let pieces: Record<string, string> | undefined;
764
+ try {
765
+ const cardUrl = await put("card", join(PUBLIC, cardRelPath));
766
+ let meterUrl: string | undefined;
767
+ const meterBox = (boxes?.meter ?? null) as
768
+ | { x: number; y: number; w: number; h: number }
769
+ | null;
770
+ if (meterLayer && meterBox) {
771
+ const sheet = stillOf("meter", [meterLayer]);
772
+ const cropped = join(dir, "meter-crop.png");
773
+ await sharp(sheet)
774
+ .extract({
775
+ left: Math.max(0, Math.round(meterBox.x)),
776
+ top: Math.max(0, Math.round(meterBox.y)),
777
+ width: Math.round(meterBox.w),
778
+ height: Math.round(meterBox.h),
779
+ })
780
+ .toFile(cropped);
781
+ meterUrl = await put("meter", cropped);
782
+ }
783
+ pieces = { card: cardUrl, ...(meterUrl ? { meter: meterUrl } : {}) };
784
+ } catch (e) {
785
+ // Without pieces the UI falls back to the old baked behaviour, which
786
+ // still works — it just costs a redraw per move.
787
+ console.warn(
788
+ ` ! draggable pieces skipped (${e instanceof Error ? e.message.split("\n")[0] : e})`,
789
+ );
790
+ }
791
+
792
+ await api("complete", {
793
+ jobId: job.id,
794
+ ok: true,
795
+ previewUrl: blob.url,
796
+ note,
797
+ boxes: boxes ? { ...boxes, pieces } : boxes,
798
+ // Which card this attempt drew, so a later nudge redraws the SAME one.
799
+ // Without it every redraw takes the next card off the library and the
800
+ // snippet changes under a change that was only ever about position.
801
+ puzzleKey: puzzle.key,
802
+ });
803
+ console.log(`✓ preview ${job.id}`);
804
+ } catch (e) {
805
+ const message = e instanceof Error ? e.message : String(e);
806
+ console.error(`✗ preview ${job.id}: ${message}`);
807
+ await api("complete", { jobId: job.id, ok: false, error: message });
808
+ } finally {
809
+ rmSync(dir, { recursive: true, force: true });
810
+ }
811
+ };
812
+
813
+ /**
814
+ * The card this job should draw, with the series' look baked in — or undefined
815
+ * to use the library PNG unchanged.
816
+ */
817
+ const lookCardFor = async (job: Job): Promise<string | undefined> => {
818
+ if (job.series !== "what-prints") return undefined;
819
+ try {
820
+ const puzzle = pickPuzzle(
821
+ (job.puzzle as Record<string, string> | null)?.key ?? null,
822
+ );
823
+ return await cardForLook({
824
+ key: puzzle.key,
825
+ code: puzzle.code,
826
+ language: puzzle.language,
827
+ look: resolveLook(readLook(job.series)),
828
+ });
829
+ } catch {
830
+ // Never fail a render over the look — the library card is always valid.
831
+ return undefined;
832
+ }
833
+ };
834
+
835
+ /**
836
+ * Write more puzzles if the library is running low. Never fails a job.
837
+ */
838
+ const topUpLibrary = async () => {
839
+ try {
840
+ const unused = listPuzzles().filter((p) => !p.used).length;
841
+ const done = await autoRestock(unused, Date.now());
842
+ if (done) {
843
+ console.log(
844
+ ` library topped up: +${done.accepted.length}` +
845
+ (done.rejected.length ? ` (${done.rejected.length} failed checking)` : ""),
846
+ );
847
+ }
848
+ } catch (e) {
849
+ console.warn(
850
+ ` ! auto top-up skipped (${e instanceof Error ? e.message.split("\n")[0] : e})`,
851
+ );
852
+ }
853
+ };
854
+
855
+ /**
856
+ * Not an error — the reason a render has no tile to donate.
857
+ *
858
+ * Thrown rather than branched around so the upload and the "never fail a
859
+ * render over tile art" catch stay in one piece.
860
+ */
861
+ class NoTile extends Error {}
862
+
863
+ /** Set by whatPrintsLayers so completion can report what was consumed. */
864
+ let consumedKey: string | undefined;
865
+
866
+ /** Swap any uploaded photo URLs for local files before rendering. */
867
+ const withLocalImages = async (
868
+ slug: string,
869
+ values: Record<string, string>,
870
+ ): Promise<Record<string, string>> => {
871
+ const spec = readSeriesSpec(slug);
872
+ const imageKeys = (spec?.fields ?? [])
873
+ .filter((f) => f.type === "image")
874
+ .map((f) => f.key);
875
+ return resolveFieldImages(values, imageKeys);
876
+ };
877
+
878
+ const runJob = async (job: Job) => {
879
+ consumedKey = undefined;
880
+ console.log(`→ job ${job.id} [${job.series}] (${job.sourceName ?? "clip"})`);
881
+ const dir = mkdtempSync(join(tmpdir(), `overlay-job-${job.id}-`));
882
+ const output = join(dir, "output.mp4");
883
+
884
+ // The clip is staged under public/ rather than in the temp dir because
885
+ // place-overlay.ts resolves b-roll relative to public/. Removed in `finally`
886
+ // — it's hundreds of megabytes and the result is what she wanted.
887
+ // .mov covers the iPhone camera roll's usual container; ffmpeg sniffs the
888
+ // real format from the bytes, so the extension is only a hint.
889
+ const brollRelPath = `assets/b-roll/job-${job.id}.mov`;
890
+ const source = join(PUBLIC, brollRelPath);
891
+
892
+ let layers: unknown[];
893
+ // A one-off's templates live under series/ just long enough to render.
894
+ let cleanupSingle: (() => void) | undefined;
895
+
896
+ try {
897
+ // A job with no clip is not a render — it's a kind this worker has never
898
+ // heard of, claimed before the machine picked up the code that handles it.
899
+ // Without this it dies inside fetch() on "Failed to parse URL from
900
+ // undefined", which says nothing about the actual problem and lands on her
901
+ // phone as the reason her post failed.
902
+ if (!job.sourceUrl) {
903
+ throw new Error(
904
+ `This computer doesn't know how to run a "${job.kind}" job — its code is behind. ` +
905
+ `It restarts itself when the code changes; try again in a minute.`,
906
+ );
907
+ }
908
+
909
+ mkdirSync(join(PUBLIC, "assets/b-roll"), { recursive: true });
910
+ const res = await fetch(job.sourceUrl);
911
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
912
+ const bytes = Buffer.from(await res.arrayBuffer());
913
+ writeFileSync(source, bytes);
914
+ console.log(` downloaded ${(bytes.length / 1024 / 1024).toFixed(0)} MB`);
915
+
916
+ // The overlay has to last as long as the clip, and the browser's estimate
917
+ // can be off (or absent for a codec it couldn't parse). ffprobe decides.
918
+ const durationSec = probeDuration(source) || job.durationSec || 6;
919
+
920
+ // A one-off: the overlay for THIS post is written now, from what she typed
921
+ // and whatever she attached, then thrown away. Same generation and same
922
+ // templates as a series — nothing is kept because nothing repeats.
923
+ if (job.kind === "single") {
924
+ console.log(" writing a one-off overlay");
925
+ const single = await generateSingle({
926
+ // The preview's slug when this burn is confirming one, so the overlay
927
+ // that was approved is the overlay that gets burned.
928
+ slug: singleSlug(job.singleFor ?? job.id),
929
+ say: job.single?.say ?? "",
930
+ refUrls: job.single?.refUrls ?? [],
931
+ durationSec,
932
+ brand: job.brand,
933
+ });
934
+ cleanupSingle = single.cleanup;
935
+ layers = single.layers;
936
+ } else {
937
+ layers =
938
+ job.series === "what-prints"
939
+ ? whatPrintsLayers(
940
+ job,
941
+ job.puzzle,
942
+ brollRelPath,
943
+ durationSec,
944
+ await lookCardFor(job),
945
+ )
946
+ : job.series && job.series !== "custom"
947
+ ? // A generated series: its templates, filled either with what she
948
+ // typed or with the next episode off its own queue. Photos are
949
+ // fetched to disk first — templates get a local path, never a URL.
950
+ seriesLayers(
951
+ job.series,
952
+ await withLocalImages(job.series, resolveValues(job)),
953
+ durationSec,
954
+ job.brand,
955
+ )
956
+ : job.layers;
957
+ }
958
+
959
+ const fonts = await resolveCustomFonts(job.brand?.customFonts ?? []);
960
+ const props = overlaySchema.parse({ durationSec, layers, fonts });
961
+
962
+ renderOverlay(source, props, output);
963
+
964
+ // The series tile has no art until something has been made with it, so
965
+ // the first finished render donates a frame. The server only accepts it
966
+ // when the series has no cover yet, and she can change it in the editor.
967
+ // A one-off belongs to no series, so it has no tile to donate to.
968
+ // A one-off belongs to no series, so it has no tile to donate to.
969
+ let seriesPreviewUrl: string | undefined;
970
+ const framePath = join(dir, "tile.png");
971
+ try {
972
+ if (job.kind === "single") throw new NoTile();
973
+ execFileSync(
974
+ "ffmpeg",
975
+ [
976
+ "-y", "-v", "error",
977
+ // A second in, so a clip that fades up doesn't donate a black frame.
978
+ "-ss", String(Math.min(1, Math.max(0, durationSec / 3))),
979
+ "-i", output,
980
+ "-frames:v", "1",
981
+ "-vf", "scale=540:-1",
982
+ framePath,
983
+ ],
984
+ { stdio: ["ignore", "pipe", "pipe"] },
985
+ );
986
+ const blob = await upload(
987
+ `overlay/series-previews/${job.series}.png`,
988
+ readFileSync(framePath),
989
+ {
990
+ access: "public",
991
+ handleUploadUrl: `${BASE_URL}/api/overlay/upload`,
992
+ clientPayload: JSON.stringify({ workerToken: TOKEN }),
993
+ contentType: "image/png",
994
+ },
995
+ );
996
+ seriesPreviewUrl = blob.url;
997
+ } catch (e) {
998
+ // Tile art is a nicety; never fail a finished render over it.
999
+ if (!(e instanceof NoTile)) {
1000
+ console.warn(
1001
+ ` ! tile frame skipped (${e instanceof Error ? e.message.split("\n")[0] : e})`,
1002
+ );
1003
+ }
1004
+ }
1005
+
1006
+ const result = readFileSync(output);
1007
+ const blob = await upload(`overlay/results/${job.id}/overlay.mp4`, result, {
1008
+ access: "public",
1009
+ handleUploadUrl: `${BASE_URL}/api/overlay/upload`,
1010
+ clientPayload: JSON.stringify({ workerToken: TOKEN }),
1011
+ contentType: "video/mp4",
1012
+ });
1013
+
1014
+ await api("complete", {
1015
+ jobId: job.id,
1016
+ ok: true,
1017
+ resultUrl: blob.url,
1018
+ resultBytes: statSync(output).size,
1019
+ seriesPreviewUrl,
1020
+ puzzleKey: consumedKey,
1021
+ });
1022
+ console.log(`✓ job ${job.id} uploaded`);
1023
+
1024
+ // A render just consumed a puzzle, so this is the moment the library
1025
+ // actually got shorter. Topping up here rather than on a schedule means
1026
+ // it tracks real use — and it happens while she's already waiting, not in
1027
+ // the middle of the next post.
1028
+ await topUpLibrary();
1029
+ } catch (e) {
1030
+ const message = e instanceof Error ? e.message : String(e);
1031
+ console.error(`✗ job ${job.id}: ${message}`);
1032
+ await api("complete", { jobId: job.id, ok: false, error: message });
1033
+ } finally {
1034
+ // Hundreds of megabytes per job. Cleaned up even on failure — the error is
1035
+ // already reported upstream, and a full disk breaks every later job. The
1036
+ // rendered card is deliberately NOT removed: it stays in assets/cards/ as
1037
+ // a reusable asset, the same as one made by hand.
1038
+ rmSync(dir, { recursive: true, force: true });
1039
+ rmSync(source, { force: true });
1040
+ // A one-off's templates were written for this render and nothing else.
1041
+ cleanupSingle?.();
1042
+ }
1043
+ };
1044
+
1045
+ // Wrapped rather than top-level: this package is CommonJS, where top-level
1046
+ // await doesn't compile.
1047
+ /**
1048
+ * Exit when this worker's own code changes.
1049
+ *
1050
+ * It runs under launchd with KeepAlive, so exiting IS restarting — with the
1051
+ * new code. Without this the agent keeps running whatever it loaded at boot,
1052
+ * and a job kind added later is claimed by a worker that has never heard of
1053
+ * it: it falls through to the video path and dies fetching a clip that isn't
1054
+ * there. That failure reads like a broken deploy and isn't one.
1055
+ */
1056
+ const watchOwnSource = () => {
1057
+ const files = [
1058
+ "scripts/overlay-worker.ts",
1059
+ "scripts/series.ts",
1060
+ "scripts/library.ts",
1061
+ "scripts/stock.ts",
1062
+ "scripts/look-store.ts",
1063
+ "scripts/look-card.ts",
1064
+ "scripts/render-overlay.ts",
1065
+ ].map((f) => join(ROOT, f));
1066
+ const stamp = () =>
1067
+ files
1068
+ .map((f) => (existsSync(f) ? statSync(f).mtimeMs : 0))
1069
+ .join(",");
1070
+ const at_start = stamp();
1071
+ setInterval(() => {
1072
+ if (stamp() !== at_start) {
1073
+ console.log("Worker code changed — restarting to pick it up.");
1074
+ process.exit(0);
1075
+ }
1076
+ }, 15_000).unref();
1077
+ };
1078
+
1079
+ const main = async () => {
1080
+ watchOwnSource();
1081
+ console.log(
1082
+ `Overlay Factory worker connected to ${BASE_URL} — waiting for jobs (Ctrl+C to stop)`,
1083
+ );
1084
+ // A library that's already thin shouldn't wait for the next post to notice.
1085
+ await topUpLibrary();
1086
+ const swept = sweepSingles();
1087
+ if (swept) console.log(`Cleared ${swept} abandoned one-off overlay(s).`);
1088
+
1089
+ for (;;) {
1090
+ try {
1091
+ // Report the stock with every poll: the library lives here, so the cloud
1092
+ // can only warn her it's running low if this tells it.
1093
+ let puzzlesLeft: number | undefined;
1094
+ try {
1095
+ puzzlesLeft = listPuzzles().filter((p) => !p.used).length;
1096
+ } catch {
1097
+ // A machine without the library still renders generated series.
1098
+ }
1099
+ const job = (await api("claim", { puzzlesLeft })) as Job | null;
1100
+ if (job) {
1101
+ if (job.kind === "series") await runSeriesJob(job);
1102
+ else if (job.kind === "restock") await runRestockJob(job);
1103
+ else if (job.kind === "preview") await runPreviewJob(job);
1104
+ // A saved position: nothing to draw, so it finishes in milliseconds.
1105
+ else if (job.kind === "look") await runLookJob(job);
1106
+ // A single goes down the video path like any other render — it writes
1107
+ // its overlay on the way through.
1108
+ else await runJob(job);
1109
+ lastJobAt = Date.now();
1110
+ continue; // Straight back for the next one rather than sleeping.
1111
+ }
1112
+ } catch (e) {
1113
+ console.error(`poll failed: ${e instanceof Error ? e.message : e}`);
1114
+ }
1115
+ await new Promise((r) => setTimeout(r, pollDelay()));
1116
+ }
1117
+ };
1118
+
1119
+ main();