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,816 @@
1
+ /**
2
+ * User-generated series: build one from references, and render one onto a clip.
3
+ *
4
+ * A series lives at series/<slug>/ and is entirely declarative:
5
+ *
6
+ * series.json what the form asks for, and where each template sits
7
+ * *.html self-contained HTML+CSS with {{field}} placeholders
8
+ *
9
+ * Nothing here is executed — templates are markup that the Overlay composition
10
+ * paints over the footage. That's the whole reason this is HTML and not
11
+ * generated components: a bad generation costs one job, not the render engine.
12
+ *
13
+ * Motion is NOT the template's job. Remotion renders by seeking to each frame
14
+ * and screenshotting, so CSS animations and transitions never advance — they'd
15
+ * freeze on whatever the first frame painted. Each layer instead declares an
16
+ * `enter`, which the composition drives with Remotion's own spring.
17
+ */
18
+ import { execFile, execFileSync } from "node:child_process";
19
+ import {
20
+ existsSync,
21
+ mkdirSync,
22
+ mkdtempSync,
23
+ readFileSync,
24
+ readdirSync,
25
+ rmSync,
26
+ statSync,
27
+ writeFileSync,
28
+ } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
31
+ import { promisify } from "node:util";
32
+
33
+ const execFileAsync = promisify(execFile);
34
+ const ROOT = join(__dirname, "..");
35
+ const SERIES_DIR = join(ROOT, "series");
36
+
37
+ export type SeriesField = {
38
+ key: string;
39
+ label: string;
40
+ type: "text" | "textarea" | "code" | "select" | "image";
41
+ required?: boolean;
42
+ placeholder?: string;
43
+ help?: string;
44
+ options?: string[];
45
+ };
46
+
47
+ type SeriesLayerSpec = {
48
+ template: string;
49
+ anchor?: "top" | "center" | "bottom";
50
+ x?: number;
51
+ y?: number;
52
+ widthPct?: number;
53
+ enter?: "none" | "fade" | "slide-up" | "slide-down" | "pop";
54
+ fromSec?: number;
55
+ toSec?: number;
56
+ };
57
+
58
+ type SeriesSpec = {
59
+ name: string;
60
+ fields: SeriesField[];
61
+ layers: SeriesLayerSpec[];
62
+ /** How the series sources what it says — see scripts/stock.ts. */
63
+ content?: {
64
+ what: string;
65
+ research?: string[];
66
+ rules?: string[];
67
+ };
68
+ };
69
+
70
+ /** The subset of the user's brand a template can reach. */
71
+ export type Brand = {
72
+ colors: Record<string, string>;
73
+ fonts: { heading: string; body: string; mono?: string };
74
+ handle?: string;
75
+ };
76
+
77
+ /**
78
+ * Brand as CSS custom properties, prepended to every template.
79
+ *
80
+ * Custom properties rather than substitution because a template should be able
81
+ * to fall back — `var(--brand-primary, #001AFF)` still renders when the series
82
+ * is set to ignore the brand, so one contract covers both.
83
+ *
84
+ * The font stacks stay SHORT on purpose. A longer, more "correct" stack ending
85
+ * in -apple-system silently breaks the whole declaration in this renderer:
86
+ * substituting a chain containing -apple-system makes font-family invalid at
87
+ * computed-value time, so the element inherits the page font and the brand
88
+ * appears not to apply at all. Verified by rendering both — the colours came
89
+ * through and only the type was wrong, which is a maddening thing to chase.
90
+ *
91
+ * The families themselves are loaded by src/overlay/brandFonts.ts; naming a
92
+ * family here does not load it.
93
+ */
94
+ export const brandVars = (brand: Brand | null): string => {
95
+ if (!brand) return "";
96
+ const vars = Object.entries(brand.colors)
97
+ .map(([k, v]) => `--brand-${k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())}: ${v};`)
98
+ .join("\n ");
99
+ return `<style>:root {
100
+ ${vars}
101
+ --brand-font-heading: "${brand.fonts.heading}", sans-serif;
102
+ --brand-font-body: "${brand.fonts.body}", sans-serif;
103
+ }</style>\n`;
104
+ };
105
+
106
+ // ── Generation ────────────────────────────────────────────────────────────
107
+
108
+ const FILE_RE = /^===\s*FILE:\s*(.+?)\s*===$/;
109
+
110
+ /** Parse the `=== FILE: name ===` blocks the prompt asks for. */
111
+ const extractFiles = (out: string): Record<string, string> => {
112
+ const files: Record<string, string> = {};
113
+ let current: string | null = null;
114
+ let buf: string[] = [];
115
+ for (const line of out.split("\n")) {
116
+ const m = line.match(FILE_RE);
117
+ if (m) {
118
+ if (current) files[current] = buf.join("\n").trim();
119
+ current = m[1];
120
+ buf = [];
121
+ } else if (current) {
122
+ buf.push(line);
123
+ }
124
+ }
125
+ if (current) files[current] = buf.join("\n").trim();
126
+ // Fenced code blocks are habit-forming; strip them if they wrapped a file.
127
+ for (const k of Object.keys(files)) {
128
+ files[k] = files[k]
129
+ .replace(/^```[a-z]*\n/i, "")
130
+ .replace(/\n```$/i, "")
131
+ .trim();
132
+ }
133
+ return files;
134
+ };
135
+
136
+ const validate = (files: Record<string, string>): SeriesSpec => {
137
+ if (!files["series.json"]) throw new Error("no series.json returned");
138
+
139
+ let spec: SeriesSpec;
140
+ try {
141
+ spec = JSON.parse(files["series.json"]);
142
+ } catch (e) {
143
+ throw new Error(`series.json is not valid JSON: ${(e as Error).message}`);
144
+ }
145
+
146
+ if (!Array.isArray(spec.fields)) throw new Error("series.json needs a fields array");
147
+ if (spec.content && typeof spec.content.what !== "string") {
148
+ throw new Error("series.json content needs a 'what' sentence");
149
+ }
150
+ if (!Array.isArray(spec.layers) || spec.layers.length === 0) {
151
+ throw new Error("series.json needs at least one layer");
152
+ }
153
+ for (const f of spec.fields) {
154
+ if (!f.key || !f.label) throw new Error("every field needs a key and a label");
155
+ if (!["text", "textarea", "code", "select", "image"].includes(f.type)) {
156
+ throw new Error(`field ${f.key}: unknown type ${f.type}`);
157
+ }
158
+ }
159
+ for (const l of spec.layers) {
160
+ if (!l.template) throw new Error("every layer needs a template");
161
+ if (!files[l.template]) throw new Error(`layer references missing file ${l.template}`);
162
+ }
163
+
164
+ for (const [name, body] of Object.entries(files)) {
165
+ if (name === "series.json") continue;
166
+ if (/<script/i.test(body)) throw new Error(`${name}: no <script> allowed`);
167
+ // An external URL in a render is a hang waiting to happen, and fonts are
168
+ // supplied by the engine.
169
+ if (/(src|href)\s*=\s*["']?https?:/i.test(body)) {
170
+ throw new Error(`${name}: no external URLs allowed`);
171
+ }
172
+ if (/@import|url\(\s*["']?https?:/i.test(body)) {
173
+ throw new Error(`${name}: no remote CSS or fonts`);
174
+ }
175
+ }
176
+ return spec;
177
+ };
178
+
179
+ const prompt = (opts: {
180
+ slug: string;
181
+ name: string;
182
+ instructions: string;
183
+ refPaths: string[];
184
+ brand: Brand | null;
185
+ }) => `You are designing a reusable overlay series for a vertical video engine.
186
+
187
+ An "episode" is a clip of b-roll with your overlay burned on top. The user
188
+ supplies a few short pieces of text each time; your templates turn those into
189
+ the look.
190
+
191
+ ## What the user asked for
192
+ ${opts.instructions}
193
+
194
+ ${
195
+ opts.refPaths.length
196
+ ? `## Reference images — THE SOURCE OF TRUTH
197
+ Read each of these image files closely (in order — the first matters most):
198
+ ${opts.refPaths.map((p) => `- ${p}`).join("\n")}
199
+
200
+ Sample the ACTUAL colors from them. Match their typography treatment (weight,
201
+ case, letter-spacing, serif vs sans), their shapes (radius, borders, bars),
202
+ their spacing and their composition. A finished frame should look like it
203
+ belongs in the same account as these references.`
204
+ : `## No reference images
205
+ Design from the description alone. Favour high contrast and heavy weight —
206
+ this sits over moving footage and has to read on a phone at arm's length.`
207
+ }
208
+
209
+ ## Engine contract (hard rules)
210
+ - The frame is 1080x1920. Your templates are drawn OVER video, so they must
211
+ stay legible against ANY footage: use solid fills, heavy scrims or strong
212
+ text shadows. Never rely on the footage being dark or light.
213
+ - Instagram covers the frame with its own UI. Keep content out of: the top
214
+ 250px (status bar + Reels header), the right 260px below y=1080 (like /
215
+ comment / share rail), and the bottom 420px (caption + audio). Anchors
216
+ already account for this — prefer them over absolute x/y.
217
+ - Each template file is self-contained HTML with its own <style> block, scoped
218
+ by unique class names so two layers can't collide. NO <script>. NO external
219
+ URLs of any kind — no font links, no imports, no remote images. A picture
220
+ comes from an "image" field, never from a URL you write.
221
+ - Fonts available: system sans (-apple-system, "Segoe UI", Roboto, sans-serif)
222
+ and monospace, plus the two brand font variables below. Nothing else loads,
223
+ so always end a font stack with a system fallback.
224
+ ${
225
+ opts.brand
226
+ ? `- The user's BRAND is available as CSS custom properties, already defined
227
+ for you. Use them for colour rather than hardcoding hex, so the series
228
+ follows their brand when it changes:
229
+ ${Object.keys(opts.brand.colors)
230
+ .map((k) => ` var(--brand-${k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())})`)
231
+ .join("\n")}
232
+ var(--brand-font-heading) var(--brand-font-body)
233
+ Their palette right now is ${Object.entries(opts.brand.colors)
234
+ .slice(0, 4)
235
+ .map(([k, v]) => `${k} ${v}`)
236
+ .join(", ")}, heading font ${opts.brand.fonts.heading}.
237
+ Give every var() a fallback — var(--brand-primary, #001AFF) — because a
238
+ series can be set to render without the brand.
239
+ Where the REFERENCES and the BRAND disagree on colour, follow the brand: the
240
+ references are there for layout, weight and composition.`
241
+ : "- No brand is set; choose colours from the references."
242
+ }
243
+ - DO NOT write CSS animations, transitions, or @keyframes. They do not run in
244
+ this engine — every frame is rendered by seeking, so an animation freezes on
245
+ its first frame. Motion comes from each layer's "enter" value instead.
246
+ - Placeholders: write {{fieldKey}} anywhere in a template and the user's input
247
+ for that field is substituted in (HTML-escaped). Use only keys you declared
248
+ in fields.
249
+ - A template's root element should size to its content — the engine gives it a
250
+ width and positions it.
251
+
252
+ ## The content brief — this is what stops the series running dry
253
+ Alongside the look, decide where this series' CONTENT comes from, and put it in
254
+ "content". A series is a format that has to keep producing episodes, so think
255
+ about what the hundredth one looks like, not just the first.
256
+
257
+ - "what": one sentence describing exactly what a single episode is.
258
+ - "research": if real-world material would make it better, say where to look and
259
+ what a good find looks like. Be specific about SOURCES — name the forums,
260
+ boards, subreddits, or sites where this material actually appears. A series
261
+ about interview questions should be researching what people report being
262
+ asked, not inventing questions that sound plausible. Omit "research" entirely
263
+ when the content genuinely comes from imagination or from the user each time.
264
+ - "rules": what would make an episode WRONG rather than weak. Include the
265
+ honesty rules — never attribute something to a company without a source,
266
+ never invent a statistic, never claim something is real when it isn't.
267
+
268
+ ## series.json
269
+ {
270
+ "name": ${JSON.stringify(opts.name)},
271
+ "fields": [
272
+ {"key":"claim","label":"The claim","type":"textarea","required":true,
273
+ "placeholder":"...","help":"optional hint shown under the input"}
274
+ ],
275
+ "layers": [
276
+ {"template":"claim.html","anchor":"top","widthPct":0.86,"enter":"slide-down"},
277
+ {"template":"verdict.html","anchor":"bottom","widthPct":0.6,
278
+ "enter":"pop","fromSec":2}
279
+ ],
280
+ "content": {
281
+ "what": "One question a software engineer was actually asked in a real interview, with the company when it is known.",
282
+ "research": [
283
+ "Search r/cscareerquestions, Blind, Glassdoor interview reports and levels.fyi for questions people report being asked recently",
284
+ "Prefer the specific and surprising over the canonical — 'reverse a linked list' is not worth watching",
285
+ "Keep the company attribution only when the source states it"
286
+ ],
287
+ "rules": [
288
+ "Never attribute a question to a company without a source that says so",
289
+ "The question must fit in about 12 words",
290
+ "No duplicates or near-duplicates of episodes already in the library"
291
+ ]
292
+ }
293
+ }
294
+
295
+ - content: the brief above. Include it whenever the series could generate its
296
+ own episodes; omit it only when every episode's text genuinely comes from the
297
+ user. Without it, the series can never restock itself.
298
+ - fields: what the form asks for. type is text | textarea | code | select
299
+ | image. An "image" field gives the user an upload; its {{placeholder}} is a
300
+ path, so use it ONLY inside a src attribute: <img src="{{photo}}" alt="">.
301
+ Style it with CSS (object-fit, border-radius, a fixed height) — the picture
302
+ could be any shape and an unconstrained <img> will blow the layout apart.
303
+ (select also needs "options": [...]). Keep it to the FEWEST fields that make
304
+ the series work — every field is something the user retypes for every post.
305
+ - layers: drawn in order, first at the back. anchor is top | center | bottom
306
+ ("center" means the band Instagram never covers). widthPct is 0-1 of frame
307
+ width. enter is none | fade | slide-up | slide-down | pop. fromSec / toSec
308
+ limit a layer to part of the clip; omit for the whole clip.
309
+
310
+ ## Output format — EXACTLY this, no commentary before or after
311
+ === FILE: series.json ===
312
+ { ... }
313
+ === FILE: claim.html ===
314
+ <div class="${opts.slug}-claim">{{claim}}</div>
315
+ <style>.${opts.slug}-claim { ... }</style>
316
+ `;
317
+
318
+ const VIDEO_EXT_RE = /\.(mp4|mov|m4v)$/i;
319
+
320
+ /**
321
+ * A video reference is only useful as pictures.
322
+ *
323
+ * Claude reads images, not clips, so a reel gets sampled into a handful of
324
+ * evenly spaced frames — enough to see the look AND to read wording that
325
+ * appears part way through, which is the usual reason someone attaches one.
326
+ * Scaled down because a 4K frame costs a lot to read and says nothing extra.
327
+ */
328
+ const framesFrom = (video: string, dir: string, prefix: string, count = 5) => {
329
+ let duration = 0;
330
+ try {
331
+ duration = Number(
332
+ execFileSync(
333
+ "ffprobe",
334
+ [
335
+ "-v", "error",
336
+ "-show_entries", "format=duration",
337
+ "-of", "default=nw=1:nk=1",
338
+ video,
339
+ ],
340
+ { encoding: "utf8" },
341
+ ).trim(),
342
+ );
343
+ } catch {
344
+ // No probe, no sampling plan — one frame from the start is still a look.
345
+ }
346
+ const paths: string[] = [];
347
+ for (let i = 0; i < count; i++) {
348
+ // Inside the clip at both ends: the first and last frames of a reel are
349
+ // often a fade, and a black frame is a wasted read.
350
+ const at = duration ? (duration * (i + 0.5)) / count : 0;
351
+ const out = join(dir, `${prefix}-frame-${i}.png`);
352
+ try {
353
+ execFileSync(
354
+ "ffmpeg",
355
+ [
356
+ "-y", "-v", "error",
357
+ "-ss", at.toFixed(2),
358
+ "-i", video,
359
+ "-frames:v", "1",
360
+ "-vf", "scale=720:-2",
361
+ out,
362
+ ],
363
+ { stdio: ["ignore", "pipe", "pipe"] },
364
+ );
365
+ if (existsSync(out)) paths.push(out);
366
+ } catch {
367
+ // A frame that won't decode isn't worth failing the generation over.
368
+ }
369
+ if (!duration) break; // Without a duration there's only one frame to take.
370
+ }
371
+ return paths;
372
+ };
373
+
374
+ /**
375
+ * Download the references so Claude can actually read them.
376
+ *
377
+ * Videos are allowed: they come back as sampled frames, in order, so the
378
+ * caller sees one flat list of image paths either way.
379
+ */
380
+ const fetchRefs = async (urls: string[], dir: string) => {
381
+ const paths: string[] = [];
382
+ for (const [i, url] of urls.entries()) {
383
+ try {
384
+ const res = await fetch(url);
385
+ if (!res.ok) continue;
386
+ const pathname = new URL(url).pathname;
387
+ const contentType = res.headers.get("content-type") ?? "";
388
+ const isVideo =
389
+ VIDEO_EXT_RE.test(pathname) || contentType.startsWith("video/");
390
+ const ext = isVideo
391
+ ? (pathname.match(VIDEO_EXT_RE)?.[0] ?? ".mp4")
392
+ : (pathname.match(/\.(png|jpe?g|webp)$/i)?.[0] ?? ".png");
393
+ const p = join(dir, `ref-${i}${ext}`);
394
+ writeFileSync(p, Buffer.from(await res.arrayBuffer()));
395
+ if (isVideo) {
396
+ paths.push(...framesFrom(p, dir, `ref-${i}`));
397
+ rmSync(p, { force: true }); // Hundreds of megabytes; the frames are the reference.
398
+ } else {
399
+ paths.push(p);
400
+ }
401
+ } catch {
402
+ // A reference that won't download shouldn't sink the generation.
403
+ }
404
+ }
405
+ return paths;
406
+ };
407
+
408
+ /**
409
+ * Ask for the files, check them, and write series/<slug>/.
410
+ *
411
+ * Shared by a series and a one-off because the output is the same thing in
412
+ * both cases: templates plus a spec saying where they sit. What differs is the
413
+ * prompt and whether anyone keeps the result.
414
+ */
415
+ const generateInto = async (
416
+ slug: string,
417
+ initialPrompt: string,
418
+ what: string,
419
+ ): Promise<SeriesSpec> => {
420
+ let p = initialPrompt;
421
+ let files: Record<string, string> | undefined;
422
+ let spec: SeriesSpec | undefined;
423
+
424
+ // Two attempts: the second is told exactly what was wrong with the first.
425
+ for (let attempt = 1; attempt <= 2; attempt++) {
426
+ const { stdout } = await execFileAsync(
427
+ "claude",
428
+ ["-p", p, "--output-format", "text", "--allowedTools", "Read"],
429
+ { maxBuffer: 20 * 1024 * 1024, timeout: 15 * 60 * 1000 },
430
+ );
431
+ try {
432
+ files = extractFiles(stdout);
433
+ spec = validate(files);
434
+ break;
435
+ } catch (e) {
436
+ const why = (e as Error).message;
437
+ console.error(` attempt ${attempt} rejected: ${why}`);
438
+ if (attempt === 2) throw new Error(`${what} failed: ${why}`);
439
+ p += `\n\nYour previous attempt was rejected: ${why}\nReturn the corrected files in full, in the same format.`;
440
+ }
441
+ }
442
+
443
+ const out = join(SERIES_DIR, slug);
444
+ rmSync(out, { recursive: true, force: true });
445
+ mkdirSync(out, { recursive: true });
446
+ for (const [f, content] of Object.entries(files!)) {
447
+ writeFileSync(join(out, f), content);
448
+ }
449
+ console.log(` wrote series/${slug}/ (${Object.keys(files!).length} files)`);
450
+ return spec!;
451
+ };
452
+
453
+ export const generateSeries = async (input: {
454
+ slug: string;
455
+ name: string;
456
+ instructions: string;
457
+ refUrls: string[];
458
+ brand?: Brand | null;
459
+ }): Promise<SeriesField[]> => {
460
+ const dir = mkdtempSync(join(tmpdir(), `series-${input.slug}-`));
461
+ try {
462
+ const refPaths = await fetchRefs(input.refUrls ?? [], dir);
463
+ console.log(` ${refPaths.length} reference image(s)`);
464
+ const spec = await generateInto(
465
+ input.slug,
466
+ prompt({ ...input, refPaths, brand: input.brand ?? null }),
467
+ "Series generation",
468
+ );
469
+ return spec.fields;
470
+ } finally {
471
+ rmSync(dir, { recursive: true, force: true });
472
+ }
473
+ };
474
+
475
+ // ── One-offs ──────────────────────────────────────────────────────────────
476
+
477
+ const singlePrompt = (opts: {
478
+ slug: string;
479
+ say: string;
480
+ refPaths: string[];
481
+ brand: Brand | null;
482
+ }) => `You are designing the overlay for ONE vertical video. Not a format, not
483
+ a template someone fills in later — this post, once.
484
+
485
+ ## What the user asked for
486
+ ${opts.say.trim() || "(nothing typed — the reference is the whole brief)"}
487
+
488
+ ${
489
+ opts.refPaths.length
490
+ ? `## Reference — READ THESE FILES
491
+ ${opts.refPaths.map((p) => `- ${p}`).join("\n")}
492
+
493
+ ${
494
+ opts.refPaths.some((p) => p.includes("-frame-"))
495
+ ? `Some of these are frames sampled from a video the user attached, in order.
496
+ TRANSCRIBE the on-screen text you can read in them. Unless the instructions say
497
+ otherwise, that wording IS the copy for this overlay — reproduce it faithfully,
498
+ including line breaks and capitalisation, and lay it out the same way.`
499
+ : `Read the wording and the look off these. Unless the instructions say
500
+ otherwise, the text in them IS the copy for this overlay.`
501
+ }
502
+
503
+ Sample the ACTUAL colors. Match the typography treatment (weight, case,
504
+ letter-spacing, serif vs sans), the shapes, the spacing, the composition.`
505
+ : `## No reference
506
+ Design from the description alone. Favour high contrast and heavy weight — this
507
+ sits over moving footage and has to read on a phone at arm's length.`
508
+ }
509
+
510
+ ## Engine contract (hard rules)
511
+ - The frame is 1080x1920. Your templates are drawn OVER video, so they must
512
+ stay legible against ANY footage: solid fills, heavy scrims or strong text
513
+ shadows. Never rely on the footage being dark or light.
514
+ - Instagram covers the frame with its own UI. Keep content out of: the top
515
+ 250px, the right 260px below y=1080, and the bottom 420px. Anchors already
516
+ account for this — prefer them over absolute x/y.
517
+ - Each template file is self-contained HTML with its own <style> block, scoped
518
+ by unique class names. NO <script>. NO external URLs of any kind — no font
519
+ links, no imports, no remote images.
520
+ - Fonts available: system sans (-apple-system, "Segoe UI", Roboto, sans-serif)
521
+ and monospace, plus the brand variables below. Nothing else loads, so always
522
+ end a font stack with a system fallback.
523
+ ${
524
+ opts.brand
525
+ ? `- The user asked for THEIR BRAND. It is available as CSS custom properties,
526
+ already defined for you — use them rather than hardcoded hex:
527
+ ${Object.keys(opts.brand.colors)
528
+ .map((k) => ` var(--brand-${k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())})`)
529
+ .join("\n")}
530
+ var(--brand-font-heading) var(--brand-font-body)
531
+ Their palette right now is ${Object.entries(opts.brand.colors)
532
+ .slice(0, 4)
533
+ .map(([k, v]) => `${k} ${v}`)
534
+ .join(", ")}, heading font ${opts.brand.fonts.heading}.
535
+ Give every var() a fallback — var(--brand-primary, #001AFF).
536
+ Where the REFERENCE and the BRAND disagree on colour or type, follow the
537
+ BRAND: the reference is there for wording, layout and composition.`
538
+ : `- The user did NOT ask for their brand, so choose colours and type yourself
539
+ — from the reference if there is one, or to suit the words if there isn't.`
540
+ }
541
+ - DO NOT write CSS animations, transitions, or @keyframes. They do not run in
542
+ this engine — every frame is rendered by seeking, so an animation freezes on
543
+ its first frame. Motion comes from each layer's "enter" value instead.
544
+ - Write the words DIRECTLY into the templates. This is a one-off: there is
545
+ nobody to fill in a placeholder later, so "fields" MUST be an empty array and
546
+ no {{...}} placeholder may appear anywhere.
547
+
548
+ ## series.json
549
+ {
550
+ "name": "One-off",
551
+ "fields": [],
552
+ "layers": [
553
+ {"template":"line.html","anchor":"top","widthPct":0.86,"enter":"slide-down"},
554
+ {"template":"kicker.html","anchor":"bottom","widthPct":0.6,"enter":"pop",
555
+ "fromSec":2}
556
+ ]
557
+ }
558
+
559
+ - layers: drawn in order, first at the back. anchor is top | center | bottom
560
+ ("center" is the band Instagram never covers). widthPct is 0-1 of frame
561
+ width. enter is none | fade | slide-up | slide-down | pop. fromSec / toSec
562
+ limit a layer to part of the clip; omit for the whole clip. Use fromSec when
563
+ a line should land later — but keep the main line up from the start, or a
564
+ scroller never sees it.
565
+ - Keep it to the fewest layers that say what was asked. One strong line usually
566
+ beats three.
567
+
568
+ ## Output format — EXACTLY this, no commentary before or after
569
+ === FILE: series.json ===
570
+ { ... }
571
+ === FILE: line.html ===
572
+ <div class="${opts.slug}-line">The actual words go here</div>
573
+ <style>.${opts.slug}-line { ... }</style>
574
+ `;
575
+
576
+ /**
577
+ * Write the overlay for a single post, and return its layers.
578
+ *
579
+ * It goes through the same generation, the same validation and the same
580
+ * templates a series does — the text is baked in instead of being a field, and
581
+ * the directory is thrown away afterwards. `cleanup` is the caller's job so
582
+ * the layers can be rendered before the templates disappear.
583
+ */
584
+ /** Whether this one-off has already been written and is still on disk. */
585
+ export const singleExists = (slug: string) =>
586
+ existsSync(join(SERIES_DIR, slug, "series.json"));
587
+
588
+ /**
589
+ * Throw away one-offs nobody came back for.
590
+ *
591
+ * A one-off's templates are kept between its preview and its burn — that's
592
+ * what makes the preview mean anything, since regenerating would produce
593
+ * something else. Anything older than a day was previewed and abandoned.
594
+ */
595
+ export const sweepSingles = (maxAgeMs = 24 * 60 * 60 * 1000) => {
596
+ if (!existsSync(SERIES_DIR)) return 0;
597
+ let removed = 0;
598
+ for (const d of readdirSync(SERIES_DIR, { withFileTypes: true })) {
599
+ if (!d.isDirectory() || !d.name.startsWith("single-")) continue;
600
+ const p = join(SERIES_DIR, d.name);
601
+ try {
602
+ if (Date.now() - statSync(p).mtimeMs > maxAgeMs) {
603
+ rmSync(p, { recursive: true, force: true });
604
+ removed++;
605
+ }
606
+ } catch {
607
+ // A directory that can't be read isn't one worth failing over.
608
+ }
609
+ }
610
+ return removed;
611
+ };
612
+
613
+ export const generateSingle = async (input: {
614
+ slug: string;
615
+ say: string;
616
+ refUrls: string[];
617
+ durationSec: number;
618
+ brand?: Brand | null;
619
+ /** Keep the templates for a later burn instead of deleting them. */
620
+ keep?: boolean;
621
+ }): Promise<{ layers: ReturnType<typeof seriesLayers>; cleanup: () => void }> => {
622
+ const dir = mkdtempSync(join(tmpdir(), `single-${input.slug}-`));
623
+ const cleanup = () => {
624
+ if (input.keep) return;
625
+ rmSync(join(SERIES_DIR, input.slug), { recursive: true, force: true });
626
+ };
627
+ try {
628
+ // Already written — by the preview this burn is confirming. Reusing it is
629
+ // the whole point: generating again would produce a different overlay
630
+ // from the one that was approved.
631
+ if (singleExists(input.slug)) {
632
+ console.log(` reusing the overlay from its preview`);
633
+ return {
634
+ layers: seriesLayers(input.slug, {}, input.durationSec, input.brand ?? null),
635
+ cleanup,
636
+ };
637
+ }
638
+ const refPaths = await fetchRefs(input.refUrls ?? [], dir);
639
+ console.log(` ${refPaths.length} reference image(s)`);
640
+ await generateInto(
641
+ input.slug,
642
+ singlePrompt({
643
+ slug: input.slug,
644
+ say: input.say,
645
+ refPaths,
646
+ brand: input.brand ?? null,
647
+ }),
648
+ "Overlay generation",
649
+ );
650
+ return {
651
+ layers: seriesLayers(input.slug, {}, input.durationSec, input.brand ?? null),
652
+ cleanup,
653
+ };
654
+ } catch (e) {
655
+ cleanup();
656
+ throw e;
657
+ } finally {
658
+ rmSync(dir, { recursive: true, force: true });
659
+ }
660
+ };
661
+
662
+ // ── Rendering ─────────────────────────────────────────────────────────────
663
+
664
+ /** Field values are user text going into markup — escape before substitution. */
665
+ const escapeHtml = (s: string) =>
666
+ s
667
+ .replace(/&/g, "&amp;")
668
+ .replace(/</g, "&lt;")
669
+ .replace(/>/g, "&gt;")
670
+ .replace(/"/g, "&quot;")
671
+ .replace(/'/g, "&#39;");
672
+
673
+ /**
674
+ * Newlines are meaningful in what the user typed but invisible in HTML, and a
675
+ * template can't know whether a field will be one line or five.
676
+ *
677
+ * Image fields are the exception: their value is a path sitting inside a src
678
+ * attribute, where a <br> would corrupt it. They're escaped for attribute
679
+ * safety and otherwise left alone.
680
+ */
681
+ const substitute = (
682
+ template: string,
683
+ values: Record<string, string>,
684
+ imageKeys: string[] = [],
685
+ ) => {
686
+ const images = new Set(imageKeys);
687
+ return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key: string) => {
688
+ const raw = values[key] ?? "";
689
+ if (images.has(key)) {
690
+ // Served from public/, so a leading slash resolves to the render's root.
691
+ return raw ? "/" + escapeHtml(raw).replace(/^\//, "") : "";
692
+ }
693
+ return escapeHtml(raw).replace(/\n/g, "<br>");
694
+ });
695
+ };
696
+
697
+ export const seriesLayers = (
698
+ slug: string,
699
+ values: Record<string, string>,
700
+ durationSec: number,
701
+ brand: Brand | null = null,
702
+ ) => {
703
+ const dir = join(SERIES_DIR, slug);
704
+ if (!existsSync(dir)) {
705
+ throw new Error(
706
+ `Series "${slug}" isn't built on this computer. Rebuild it from the tool.`,
707
+ );
708
+ }
709
+ const spec = JSON.parse(
710
+ readFileSync(join(dir, "series.json"), "utf8"),
711
+ ) as SeriesSpec;
712
+
713
+ const imageKeys = spec.fields
714
+ .filter((f) => f.type === "image")
715
+ .map((f) => f.key);
716
+
717
+ return spec.layers.map((l) => {
718
+ const template = readFileSync(join(dir, l.template), "utf8");
719
+ return {
720
+ type: "html" as const,
721
+ html: brandVars(brand) + substitute(template, values, imageKeys),
722
+ anchor: l.anchor,
723
+ x: l.x,
724
+ y: l.y,
725
+ widthPct: l.widthPct,
726
+ enter: l.enter,
727
+ // A layer told to start after the clip ends would never be seen.
728
+ fromSec: l.fromSec === undefined ? undefined : Math.min(l.fromSec, durationSec),
729
+ toSec: l.toSec,
730
+ };
731
+ });
732
+ };
733
+
734
+ /**
735
+ * A sample frame for the series tile.
736
+ *
737
+ * Rendered over a neutral grey rather than real footage: the tile is showing
738
+ * what the OVERLAY looks like, and a busy clip behind it makes two series hard
739
+ * to tell apart. Sample text comes from each field's own placeholder, so the
740
+ * preview shows the series doing its actual job rather than lorem.
741
+ */
742
+ export const renderSeriesPreview = (
743
+ slug: string,
744
+ fields: SeriesField[],
745
+ brand: Brand | null,
746
+ outPng: string,
747
+ ) => {
748
+ const sample: Record<string, string> = {};
749
+ for (const f of fields) {
750
+ sample[f.key] =
751
+ f.options?.[0] ?? f.placeholder ?? f.label;
752
+ }
753
+ const layers = seriesLayers(slug, sample, 3, brand).map((l) => ({
754
+ ...l,
755
+ // The tile is one frame, so entrance animation would only ever show the
756
+ // first moment of itself — mid-fade, mid-slide, wrong.
757
+ enter: "none" as const,
758
+ fromSec: undefined,
759
+ toSec: undefined,
760
+ }));
761
+
762
+ const propsFile = join(tmpdir(), `${slug}-preview-props.json`);
763
+ const alphaPng = join(tmpdir(), `${slug}-preview-alpha.png`);
764
+ writeFileSync(propsFile, JSON.stringify({ durationSec: 3, layers }));
765
+
766
+ try {
767
+ execFileSync(
768
+ "npx",
769
+ [
770
+ "remotion", "still", "Overlay", alphaPng,
771
+ "--props", propsFile,
772
+ "--image-format", "png",
773
+ "--frame", "0",
774
+ ],
775
+ { cwd: ROOT, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" },
776
+ );
777
+ // Flatten onto a neutral vertical gradient so light and dark overlays both
778
+ // read on the tile.
779
+ execFileSync(
780
+ "ffmpeg",
781
+ [
782
+ "-y", "-v", "error",
783
+ "-f", "lavfi",
784
+ "-i", "gradients=s=1080x1920:c0=0x3a3a3a:c1=0x6e6e6e:x0=0:y0=0:x1=1080:y1=1920:n=2",
785
+ "-i", alphaPng,
786
+ "-filter_complex", "[0:v][1:v]overlay=0:0,format=yuv420p[o]",
787
+ "-map", "[o]", "-frames:v", "1",
788
+ outPng,
789
+ ],
790
+ { cwd: ROOT, stdio: ["ignore", "pipe", "pipe"] },
791
+ );
792
+ return outPng;
793
+ } finally {
794
+ rmSync(propsFile, { force: true });
795
+ rmSync(alphaPng, { force: true });
796
+ }
797
+ };
798
+
799
+ /** A series' spec as built on this machine — fields, layers and content brief. */
800
+ export const readSeriesSpec = (slug: string): SeriesSpec | null => {
801
+ const f = join(SERIES_DIR, slug, "series.json");
802
+ if (!existsSync(f)) return null;
803
+ try {
804
+ return JSON.parse(readFileSync(f, "utf8")) as SeriesSpec;
805
+ } catch {
806
+ return null;
807
+ }
808
+ };
809
+
810
+ /** Which series this machine has actually built — used by the CLI helper. */
811
+ export const installedSeries = () =>
812
+ existsSync(SERIES_DIR)
813
+ ? readdirSync(SERIES_DIR, { withFileTypes: true })
814
+ .filter((d) => d.isDirectory())
815
+ .map((d) => d.name)
816
+ : [];