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,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, "&")
|
|
668
|
+
.replace(/</g, "<")
|
|
669
|
+
.replace(/>/g, ">")
|
|
670
|
+
.replace(/"/g, """)
|
|
671
|
+
.replace(/'/g, "'");
|
|
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
|
+
: [];
|