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
|
@@ -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();
|