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,34 @@
1
+ #!/bin/bash
2
+ # Ingest assets from ~/Desktop/Automate Editing into public/assets/.
3
+ # Uses Finder via osascript because macOS TCC blocks direct shell reads of ~/Desktop.
4
+ set -euo pipefail
5
+
6
+ REPO="$(cd "$(dirname "$0")/.." && pwd)"
7
+ ASSETS="$REPO/public/assets"
8
+ mkdir -p "$ASSETS/b-roll" "$ASSETS/cards-raw" "$ASSETS/captions" "$ASSETS/inspiration"
9
+
10
+ copy_folder() { # $1 = AppleScript folder ref, $2 = dest dir
11
+ osascript <<EOF
12
+ tell application "Finder"
13
+ set dest to POSIX file "$2" as alias
14
+ duplicate every file of $1 to dest with replacing
15
+ end tell
16
+ EOF
17
+ }
18
+
19
+ BASE='folder "Automate Editing" of desktop'
20
+
21
+ copy_folder "folder \"b-roll\" of $BASE" "$ASSETS/b-roll" >/dev/null
22
+ copy_folder "folder \"what prints?\" of $BASE" "$ASSETS/cards-raw" >/dev/null
23
+ copy_folder "folder \"captions\" of folder \"what prints?\" of $BASE" "$ASSETS/captions" >/dev/null
24
+ osascript >/dev/null <<EOF
25
+ tell application "Finder"
26
+ set dest to POSIX file "$ASSETS/inspiration" as alias
27
+ duplicate file "title inspiration.jpg" of $BASE to dest with replacing
28
+ end tell
29
+ EOF
30
+
31
+ echo "Ingested:"
32
+ echo " b-roll: $(ls "$ASSETS/b-roll" | wc -l | tr -d ' ') files"
33
+ echo " cards: $(ls "$ASSETS/cards-raw" | grep -ci 'jpe\?g$' || true) images"
34
+ echo " captions: $(ls "$ASSETS/captions" | wc -l | tr -d ' ') screenshots"
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The What Prints? puzzle library — the cards already written, verified and
3
+ * rendered on this machine.
4
+ *
5
+ * The Overlay Factory's job is to pair a clip with a puzzle, not to make
6
+ * someone retype a puzzle they already have. episodes/index.json is that
7
+ * library (31 entries and counting, most from the original caption
8
+ * screenshots), public/assets/cards holds their rendered PNGs, and
9
+ * episodes/used.json records which have been posted.
10
+ *
11
+ * Selection mirrors scripts/new-episode.ts deliberately: first unused card in
12
+ * filename order, never one the quality gates retired. Two paths that disagree
13
+ * about "next" would double-post a card.
14
+ */
15
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ const ROOT = join(__dirname, "..");
19
+ const EPISODES = join(ROOT, "episodes");
20
+ const CARDS = join(ROOT, "public/assets/cards");
21
+
22
+ type IndexEntry = {
23
+ name?: string;
24
+ language?: string;
25
+ code?: string;
26
+ answer?: string;
27
+ why?: string;
28
+ caption?: string;
29
+ card?: string; // e.g. "IMG_4030.jpg" — the SOURCE name, .png once rendered
30
+ difficulty?: "easy" | "medium" | "hard";
31
+ retired?: string;
32
+ };
33
+
34
+ type Used = { cards: Record<string, string>; broll: Record<string, string> };
35
+
36
+ const readJson = <T>(p: string, fallback: T): T =>
37
+ existsSync(p) ? (JSON.parse(readFileSync(p, "utf8")) as T) : fallback;
38
+
39
+ /** index.json stores the source screenshot name; the render is always .png. */
40
+ const asPng = (card: string) => card.replace(/\.(jpe?g|png)$/i, ".png");
41
+
42
+ export type Puzzle = {
43
+ /** Card PNG filename — the stable id for a puzzle across both scripts. */
44
+ key: string;
45
+ name: string;
46
+ /** Source, kept so the card can be re-rendered with the series' look. */
47
+ code: string | null;
48
+ language: string | null;
49
+ difficulty: "easy" | "medium" | "hard";
50
+ answer: string | null;
51
+ why: string | null;
52
+ caption: string | null;
53
+ used: boolean;
54
+ /** Path under public/, ready for an image layer. */
55
+ cardRelPath: string;
56
+ };
57
+
58
+ export const listPuzzles = (): Puzzle[] => {
59
+ const index = readJson<IndexEntry[]>(join(EPISODES, "index.json"), []);
60
+ const used = readJson<Used>(join(EPISODES, "used.json"), {
61
+ cards: {},
62
+ broll: {},
63
+ });
64
+
65
+ const retired = new Set(
66
+ index.filter((e) => e.retired && e.card).map((e) => asPng(e.card!)),
67
+ );
68
+ const byCard = new Map(
69
+ index.filter((e) => e.card).map((e) => [asPng(e.card!), e]),
70
+ );
71
+
72
+ // A machine that has never rendered a card has no cards directory at all.
73
+ // readdirSync throws there, which took down the whole listing — and since
74
+ // What Prints? is seeded for every account, a new user saw the series and
75
+ // watched every job fail.
76
+ if (!existsSync(CARDS)) return [];
77
+
78
+ return readdirSync(CARDS)
79
+ // "--look.png" files are per-look renders of a card that is already in the
80
+ // library; counting them would double every puzzle and inflate the stock.
81
+ .filter(
82
+ (f) => f.endsWith(".png") && !f.endsWith("--look.png") && !retired.has(f),
83
+ )
84
+ .sort()
85
+ .map((key) => {
86
+ const e = byCard.get(key);
87
+ return {
88
+ key,
89
+ name: e?.name ?? key.replace(/\.png$/, ""),
90
+ code: e?.code ?? null,
91
+ language: e?.language ?? null,
92
+ difficulty: e?.difficulty ?? "medium",
93
+ answer: e?.answer ?? null,
94
+ why: e?.why ?? null,
95
+ caption: e?.caption ?? null,
96
+ used: Boolean(used.cards[key]),
97
+ cardRelPath: `assets/cards/${key}`,
98
+ };
99
+ });
100
+ };
101
+
102
+ /**
103
+ * The next puzzle to post, or a specific one by key.
104
+ *
105
+ * Throws rather than silently reusing a card: quietly re-posting a puzzle that
106
+ * already went out is worse than a failed job with a clear reason.
107
+ */
108
+ export const pickPuzzle = (
109
+ key?: string | null,
110
+ /**
111
+ * Cards this ACCOUNT has already consumed, from the cloud.
112
+ *
113
+ * used.json only knows about this computer, so a second machine on the same
114
+ * account would pick the same card again and post it twice. The cloud is the
115
+ * authority; the local file stays as a fast path and an offline fallback.
116
+ */
117
+ usedElsewhere: string[] = [],
118
+ ): Puzzle => {
119
+ const all = listPuzzles();
120
+ if (key) {
121
+ const found = all.find((p) => p.key === key);
122
+ if (!found) throw new Error(`No puzzle named ${key} on this computer`);
123
+ return found;
124
+ }
125
+ const alsoUsed = new Set(usedElsewhere);
126
+ const next = all.find((p) => !p.used && !alsoUsed.has(p.key));
127
+ if (!next) {
128
+ throw new Error(
129
+ all.length === 0
130
+ ? "No puzzle library on this computer yet. Run: npx tsx scripts/make-card.ts puzzles/*.json"
131
+ : "Every puzzle in your library has been used — it will write more on its own shortly.",
132
+ );
133
+ }
134
+ return next;
135
+ };
136
+
137
+ /** Record a puzzle as posted, so the next pick moves on. */
138
+ export const markUsed = (key: string, label: string) => {
139
+ const file = join(EPISODES, "used.json");
140
+ const used = readJson<Used>(file, { cards: {}, broll: {} });
141
+ used.cards[key] = label;
142
+ writeFileSync(file, JSON.stringify(used, null, 2) + "\n");
143
+ };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Render a puzzle's card with the series' look applied.
3
+ *
4
+ * The library cards are baked PNGs, so look values that belong to the CARD —
5
+ * the code size, the language label, the fill, the corner radius — can't reach
6
+ * them. Feedback that said "make the code smaller" changed a number that never
7
+ * touched a pixel, which is worse than not offering the knob at all.
8
+ *
9
+ * So when the look differs from the shipped design, the card is re-rendered
10
+ * for this job from the puzzle's stored code. When it doesn't, the library PNG
11
+ * is used unchanged and nothing is spent.
12
+ */
13
+ import { execFileSync } from "node:child_process";
14
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { codeToTokens } from "shiki";
17
+ import {
18
+ CARD_THEME,
19
+ MAX_COLUMNS,
20
+ fitScale,
21
+ } from "../src/series/what-prints/codeCardTypes";
22
+ import type { ResolvedLook } from "../src/overlay/look";
23
+ import { DEFAULT_LOOK } from "../src/overlay/look";
24
+
25
+ const ROOT = join(__dirname, "..");
26
+ const CARDS = join(ROOT, "public/assets/cards");
27
+
28
+ /** Emoji occupy about 1.8 mono cells — same rule make-card.ts uses. */
29
+ const lineWidth = (line: string) => {
30
+ const emoji = (line.match(/\p{Extended_Pictographic}/gu) ?? []).length;
31
+ return line.length - emoji + emoji * 1.8;
32
+ };
33
+
34
+ const cardDiffersFromDefault = (look: ResolvedLook) =>
35
+ look.card.codeScale !== DEFAULT_LOOK.card.codeScale ||
36
+ look.card.showLanguage !== DEFAULT_LOOK.card.showLanguage ||
37
+ look.card.background !== DEFAULT_LOOK.card.background ||
38
+ look.card.radiusPct !== DEFAULT_LOOK.card.radiusPct;
39
+
40
+ /**
41
+ * Returns a path under public/ for the card to draw — either the library PNG
42
+ * or a freshly rendered one carrying this look.
43
+ */
44
+ export const cardForLook = async (opts: {
45
+ /** Card filename in the library, e.g. "IMG_4032.png". */
46
+ key: string;
47
+ /** The puzzle's source, needed only when re-rendering. */
48
+ code?: string | null;
49
+ language?: string | null;
50
+ look: ResolvedLook;
51
+ }): Promise<string> => {
52
+ const libraryPath = `assets/cards/${opts.key}`;
53
+ if (!cardDiffersFromDefault(opts.look)) return libraryPath;
54
+ if (!opts.code) {
55
+ // A screenshot-only card can't be re-rendered; its own look wins over the
56
+ // series look rather than failing the job.
57
+ return libraryPath;
58
+ }
59
+
60
+ const slug = opts.key.replace(/\.png$/, "");
61
+ const outName = `${slug}--look.png`;
62
+ const out = join(CARDS, outName);
63
+
64
+ const language = opts.language ?? "Python";
65
+ const widest = Math.max(...opts.code.split("\n").map(lineWidth));
66
+ // The look's scale rides on top of the fit, so a long snippet still shrinks
67
+ // to stay inside MAX_COLUMNS and the look only ever scales from there.
68
+ const codeScale = fitScale(widest) * opts.look.card.codeScale;
69
+
70
+ const { tokens } = await codeToTokens(opts.code, {
71
+ lang: language.toLowerCase() as Parameters<typeof codeToTokens>[1]["lang"],
72
+ theme: CARD_THEME,
73
+ });
74
+ const lines = tokens.map((line) =>
75
+ line.map((t) => ({ content: t.content, color: t.color ?? "#c5c8d4" })),
76
+ );
77
+
78
+ const props = {
79
+ language,
80
+ lines,
81
+ codeScale,
82
+ showLanguage: opts.look.card.showLanguage,
83
+ background: opts.look.card.background,
84
+ radiusPct: opts.look.card.radiusPct,
85
+ };
86
+ const propsFile = join(ROOT, `.look-card-${slug}.json`);
87
+ writeFileSync(propsFile, JSON.stringify(props));
88
+ try {
89
+ execFileSync(
90
+ "npx",
91
+ [
92
+ "remotion", "still", "CodeCard", out,
93
+ `--props=${propsFile}`,
94
+ "--image-format=png",
95
+ "--log=error",
96
+ ],
97
+ { cwd: ROOT, stdio: ["ignore", "pipe", "pipe"] },
98
+ );
99
+ } finally {
100
+ execFileSync("rm", ["-f", propsFile]);
101
+ }
102
+
103
+ return existsSync(out) ? `assets/cards/${outName}` : libraryPath;
104
+ };
105
+
106
+ /** MAX_COLUMNS is re-exported so callers can warn about over-long snippets. */
107
+ export { MAX_COLUMNS, readFileSync };
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The stored look for a series, and how feedback changes it.
3
+ *
4
+ * Feedback edits PARAMETERS, not code. The model is handed the current look,
5
+ * the schema's bounds, and the note, and returns a new look — which is then
6
+ * validated before it can affect a render. A note that asks for something the
7
+ * parameters can't express comes back unchanged with a reason, which is a much
8
+ * better failure than silently rendering something unrelated.
9
+ *
10
+ * Generated series don't come through here: they're HTML templates, so their
11
+ * feedback path regenerates the template (see scripts/series.ts).
12
+ */
13
+ import { execFile } from "node:child_process";
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { promisify } from "node:util";
17
+ import {
18
+ DEFAULT_LOOK,
19
+ TITLE_FAMILIES,
20
+ lookSchema,
21
+ resolveLook,
22
+ type Look,
23
+ } from "../src/overlay/look";
24
+
25
+ const execFileAsync = promisify(execFile);
26
+ const ROOT = join(__dirname, "..");
27
+ const SERIES_DIR = join(ROOT, "series");
28
+
29
+ const lookPath = (slug: string) => join(SERIES_DIR, slug, "look.json");
30
+
31
+ export const readLook = (slug: string): Look => {
32
+ const p = lookPath(slug);
33
+ if (!existsSync(p)) return {};
34
+ try {
35
+ return lookSchema.parse(JSON.parse(readFileSync(p, "utf8")));
36
+ } catch {
37
+ // A look that no longer validates must not break every render — the
38
+ // shipped design is always a safe answer.
39
+ return {};
40
+ }
41
+ };
42
+
43
+ export const writeLook = (slug: string, look: Look) => {
44
+ mkdirSync(join(SERIES_DIR, slug), { recursive: true });
45
+ writeFileSync(lookPath(slug), JSON.stringify(look, null, 2) + "\n");
46
+ };
47
+
48
+ /**
49
+ * Apply a note to a series' look.
50
+ *
51
+ * Returns the new look plus what the model said it did, so the UI can show
52
+ * that a note was understood rather than leaving the user guessing why the
53
+ * picture looks the same.
54
+ */
55
+ export const applyFeedback = async (opts: {
56
+ slug: string;
57
+ feedback: string;
58
+ /** Families the user uploaded, on top of the built-in list. */
59
+ customFamilies?: string[];
60
+ }): Promise<{ look: Look; note: string }> => {
61
+ const current = resolveLook(readLook(opts.slug));
62
+
63
+ const prompt = `You are adjusting the look of a short-form video overlay.
64
+
65
+ The overlay is a title over a code card over a difficulty badge, burned onto
66
+ vertical footage. You cannot rearrange it or add anything — you can only change
67
+ the values below. If the request needs something these values can't express,
68
+ change nothing and say so.
69
+
70
+ ## Current values
71
+ ${JSON.stringify(current, null, 2)}
72
+
73
+ ## What each one does, and its limits
74
+ card.widthPct 0.5-0.95 fraction of frame width the card occupies
75
+ card.radiusPct 0-0.12 corner radius as a fraction of card width
76
+ card.background hex the card's fill
77
+ card.opacity 0.3-1 1 is solid; lower lets footage through
78
+ card.showLanguage bool the "Python" label above the snippet
79
+ card.codeScale 0.6-1.4 multiplier on the code type size
80
+ title.family one of: ${[...TITLE_FAMILIES, ...(opts.customFamilies ?? [])].join(", ")}
81
+ — sets BOTH title lines, including the letterforms the dots
82
+ are made from
83
+ title.sansSize 60-160 the bold first line
84
+ title.rows 8-24 dot-matrix cap height in cells; MORE = FINER dots
85
+ title.cell 5-20 grid pitch in px; SMALLER = finer dots
86
+ title.gap 0-6 space inside the pitch, so the dot is cell - gap
87
+ title.weight 100-900 stroke thickness of the dot-matrix line
88
+ title.scrim bool dark wash behind the title
89
+ meter.show bool the difficulty badge
90
+
91
+ Notes on intent, so the obvious requests land correctly:
92
+ - "smaller code" → lower card.codeScale, not card.widthPct
93
+ - "smaller card" → lower card.widthPct
94
+ - "finer / more dots" → raise title.rows AND lower title.cell together
95
+ - "chunkier dots" → lower title.rows and raise title.cell
96
+ - "thicker letters" → raise title.weight
97
+ - "see through" / "less heavy" → lower card.opacity
98
+ - "serif title" → title.family to Instrument Serif, Fraunces or DM Serif Display
99
+ - a family not on the list can't be used; say so rather than picking a lookalike
100
+
101
+ ## The request
102
+ ${opts.feedback}
103
+
104
+ ## Output — JSON and nothing else
105
+ {"look": { ...the FULL new value set... }, "note": "one short sentence on what you changed, or why you couldn't"}`;
106
+
107
+ const { stdout } = await execFileAsync(
108
+ "claude",
109
+ ["-p", prompt, "--output-format", "text", "--allowedTools", ""],
110
+ { maxBuffer: 4 * 1024 * 1024, timeout: 5 * 60 * 1000 },
111
+ );
112
+
113
+ const text = stdout.trim();
114
+ const start = text.indexOf("{");
115
+ const end = text.lastIndexOf("}");
116
+ if (start === -1 || end === -1) throw new Error("No JSON in the look response");
117
+
118
+ let raw: { look?: unknown; note?: string };
119
+ try {
120
+ raw = JSON.parse(text.slice(start, end + 1));
121
+ } catch (e) {
122
+ throw new Error(`Look response wasn't valid JSON: ${(e as Error).message}`);
123
+ }
124
+
125
+ // Validated before it can reach a render: an out-of-range value here is the
126
+ // difference between "the card got smaller" and an unreadable post.
127
+ const parsed = lookSchema.safeParse(raw.look ?? {});
128
+ if (!parsed.success) {
129
+ throw new Error(
130
+ `That change would go outside what the look allows: ${parsed.error.issues[0]?.message ?? "invalid"}`,
131
+ );
132
+ }
133
+
134
+ // A family the renderer doesn't have would silently render as a system
135
+ // fallback, which looks like the change not working.
136
+ const allowed = new Set<string>([
137
+ ...TITLE_FAMILIES,
138
+ ...(opts.customFamilies ?? []),
139
+ ]);
140
+ const chosen = parsed.data.title?.family;
141
+ if (chosen && !allowed.has(chosen)) {
142
+ throw new Error(
143
+ `"${chosen}" isn't available. Upload it on the brand page, or pick one of: ${[...allowed].join(", ")}`,
144
+ );
145
+ }
146
+
147
+ writeLook(opts.slug, parsed.data);
148
+ return { look: parsed.data, note: raw.note?.slice(0, 300) ?? "Updated." };
149
+ };
150
+
151
+ /**
152
+ * Apply an exact change to the look, with no model in the loop.
153
+ *
154
+ * Dragging something to a position is not a request to be interpreted — it IS
155
+ * the value. Sending "move the card to y=1104" through a language model would
156
+ * be slower, cost a call, and could come back with 1100.
157
+ */
158
+ export const patchLook = (slug: string, patch: Look): Look => {
159
+ const current = readLook(slug);
160
+ const merged: Look = {
161
+ ...current,
162
+ card: { ...(current.card ?? {}), ...(patch.card ?? {}) },
163
+ title: { ...(current.title ?? {}), ...(patch.title ?? {}) },
164
+ meter: { ...(current.meter ?? {}), ...(patch.meter ?? {}) },
165
+ };
166
+ const parsed = lookSchema.safeParse(merged);
167
+ if (!parsed.success) {
168
+ throw new Error(
169
+ `That position is outside what the frame allows: ${parsed.error.issues[0]?.message ?? "invalid"}`,
170
+ );
171
+ }
172
+ writeLook(slug, parsed.data);
173
+ return parsed.data;
174
+ };
175
+
176
+ export { DEFAULT_LOOK, resolveLook };
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Generate a "What Prints?" code card from a puzzle JSON — no phone screenshot
3
+ * needed. Highlights the snippet with Shiki, renders the CodeCard composition
4
+ * to a transparent rounded PNG, and registers it in episodes/{cards,index}.json.
5
+ *
6
+ * Usage:
7
+ * npx tsx scripts/make-card.ts puzzles/nan-trap.json [more.json ...]
8
+ * cat puzzle.json | npx tsx scripts/make-card.ts -
9
+ *
10
+ * The snippet is executed and its output checked against "answer" before it
11
+ * renders, then put through scripts/quality.ts (deterministic? same on every
12
+ * Python? answerable? not a duplicate?). Either failing stops the render;
13
+ * --no-verify and --no-gates override.
14
+ *
15
+ * A puzzle file is one object or an array of them:
16
+ * {
17
+ * "slug": "nan-trap", // -> assets/cards/nan-trap.png
18
+ * "name": "Bowser's NaN Trap",
19
+ * "language": "Python", // card label; also picks the grammar
20
+ * "lang": "python", // optional Shiki id override
21
+ * "code": "a = float(\"nan\")\n...",
22
+ * "answer": "False",
23
+ * "why": "...", // the explanation for the caption/slide
24
+ * "difficulty": "easy|medium|hard",
25
+ * "caption": "..." // optional IG caption
26
+ * }
27
+ */
28
+ import { execFileSync } from "node:child_process";
29
+ import {
30
+ existsSync,
31
+ mkdirSync,
32
+ readFileSync,
33
+ writeFileSync,
34
+ rmSync,
35
+ } from "node:fs";
36
+ import { join } from "node:path";
37
+ import { codeToTokens } from "shiki";
38
+ import { verify } from "./verify";
39
+ import { runGates } from "./quality";
40
+ import {
41
+ CARD_THEME,
42
+ MAX_COLUMNS,
43
+ fitScale,
44
+ } from "../src/series/what-prints/codeCardTypes";
45
+
46
+ const ROOT = join(import.meta.dirname, "..");
47
+ const CARDS_DIR = join(ROOT, "public/assets/cards");
48
+ const CARDS_JSON = join(ROOT, "episodes/cards.json");
49
+ const INDEX_JSON = join(ROOT, "episodes/index.json");
50
+ const TMP = join(ROOT, ".make-card-props.json");
51
+
52
+ type CardEntry = { card: string; language: string; code: string };
53
+
54
+ type IndexEntry = {
55
+ card: string;
56
+ episode: number | null;
57
+ name: string;
58
+ code: string;
59
+ answer: string | null;
60
+ why: string | null;
61
+ caption: string | null;
62
+ sourceScreenshots: string[];
63
+ difficulty: string;
64
+ concept?: string;
65
+ generated?: boolean;
66
+ };
67
+
68
+ type Puzzle = {
69
+ slug: string;
70
+ name?: string;
71
+ language: string;
72
+ lang?: string;
73
+ code: string;
74
+ answer?: string;
75
+ why?: string;
76
+ difficulty?: "easy" | "medium" | "hard";
77
+ caption?: string;
78
+ concept?: string; // e.g. "mutation-during-iteration" — grouped by report.ts
79
+ format?: string; // "debate" opts out of output verification
80
+ };
81
+
82
+ const readJson = (file: string) =>
83
+ JSON.parse(readFileSync(file, "utf8")) as unknown;
84
+
85
+ const writeJson = (file: string, value: unknown) =>
86
+ writeFileSync(file, JSON.stringify(value, null, 2) + "\n");
87
+
88
+ /** Shiki tokens -> the flat {content,color} lines the composition renders. */
89
+ const highlight = async (code: string, lang: string) => {
90
+ const { tokens } = await codeToTokens(code, {
91
+ lang: lang as never,
92
+ theme: CARD_THEME as never,
93
+ });
94
+ return tokens.map((line) =>
95
+ line.map((t) => ({ content: t.content, color: t.color ?? "#c5c8d4" })),
96
+ );
97
+ };
98
+
99
+ /** Insert or replace an entry keyed by `card` so re-runs stay idempotent. */
100
+ const upsert = <T extends { card: string }>(list: T[], entry: T) => {
101
+ const i = list.findIndex((e) => e.card === entry.card);
102
+ if (i === -1) list.push(entry);
103
+ else list[i] = { ...list[i], ...entry };
104
+ return list;
105
+ };
106
+
107
+ /**
108
+ * Width of a line in monospace cells. Emoji render wider than one cell in
109
+ * Roboto Mono's fallback, so they count for ~1.8.
110
+ */
111
+ const EMOJI =
112
+ /\p{Extended_Pictographic}(?:️|[\u{1F3FB}-\u{1F3FF}]|‍\p{Extended_Pictographic})*/gu;
113
+
114
+ const lineWidth = (line: string) => {
115
+ const emoji = line.match(EMOJI)?.length ?? 0;
116
+ const rest = [...line.replace(EMOJI, "")].length;
117
+ return rest + emoji * 1.8;
118
+ };
119
+
120
+ const makeCard = async (p: Puzzle, skipVerify: boolean, skipGates: boolean) => {
121
+ if (!p.slug || !p.code || !p.language)
122
+ throw new Error("puzzle needs slug, language and code");
123
+
124
+ // Run it for real. A card that states the wrong output is wrong in public,
125
+ // so a mismatch stops the render rather than warning about it.
126
+ if (!skipVerify && p.format !== "debate") {
127
+ const v = verify(p.code, p.lang ?? p.language.toLowerCase(), p.answer);
128
+ if (v.status === "mismatch" || v.status === "error")
129
+ throw new Error(
130
+ `${p.slug}: the snippet does not print the stated answer.\n` +
131
+ ` stated: ${JSON.stringify(p.answer)}\n` +
132
+ ` actual: ${JSON.stringify(v.output)}`,
133
+ );
134
+ if (v.status === "skipped")
135
+ console.warn(` ! ${p.slug}: not verified — ${v.reason}`);
136
+ if (v.status === "loose")
137
+ console.warn(` ~ ${p.slug}: answer matches only after normalizing`);
138
+ }
139
+
140
+ // Correct isn't the same as fair. These catch cards nobody could answer:
141
+ // non-deterministic output, interpreter disagreements, duplicates.
142
+ if (!skipGates) {
143
+ const failed = runGates({ ...p, card: `${p.slug}.png` }).filter(
144
+ (g) => !g.pass,
145
+ );
146
+ if (failed.length)
147
+ throw new Error(
148
+ `${p.slug}: failed ${failed.length} quality gate(s).\n` +
149
+ failed.map((g) => ` ✗ ${g.gate}: ${g.detail}`).join("\n"),
150
+ );
151
+ }
152
+
153
+ const widest = Math.max(...p.code.split("\n").map(lineWidth));
154
+ const codeScale = fitScale(widest);
155
+ if (codeScale < 1)
156
+ console.warn(
157
+ ` ! ${p.slug}: longest line is ${Math.ceil(widest)} cells (max ${MAX_COLUMNS}) — ` +
158
+ `shrinking type to ${Math.round(codeScale * 100)}%. Shorten it if it reads small.`,
159
+ );
160
+
161
+ const lines = await highlight(p.code, p.lang ?? p.language.toLowerCase());
162
+ writeJson(TMP, { language: p.language, lines, codeScale });
163
+
164
+ const cardFile = `${p.slug}.png`;
165
+ const out = join(CARDS_DIR, cardFile);
166
+ mkdirSync(CARDS_DIR, { recursive: true });
167
+
168
+ execFileSync(
169
+ "npx",
170
+ [
171
+ "remotion",
172
+ "still",
173
+ "CodeCard",
174
+ out,
175
+ `--props=${TMP}`,
176
+ "--image-format=png",
177
+ "--log=error",
178
+ ],
179
+ { cwd: ROOT, stdio: "inherit" },
180
+ );
181
+
182
+ // cards.json: the raw snippet inventory.
183
+ const cards = readJson(CARDS_JSON) as CardEntry[];
184
+ writeJson(
185
+ CARDS_JSON,
186
+ upsert(cards, { card: cardFile, language: p.language, code: p.code }),
187
+ );
188
+
189
+ // index.json: the episode metadata the caption/answer slides read from.
190
+ const index = readJson(INDEX_JSON) as IndexEntry[];
191
+ writeJson(
192
+ INDEX_JSON,
193
+ upsert(index, {
194
+ episode: null,
195
+ name: p.name ?? p.slug,
196
+ code: p.code,
197
+ answer: p.answer ?? null,
198
+ why: p.why ?? null,
199
+ caption: p.caption ?? null,
200
+ sourceScreenshots: [],
201
+ ...(p.concept ? { concept: p.concept } : {}),
202
+ ...(p.format ? { format: p.format } : {}),
203
+ card: cardFile,
204
+ difficulty: p.difficulty ?? "medium",
205
+ generated: true,
206
+ }),
207
+ );
208
+
209
+ console.log(`${cardFile} (${lines.length} lines, ${p.language})`);
210
+ };
211
+
212
+ const main = async () => {
213
+ const argv = process.argv.slice(2);
214
+ const skipVerify = argv.includes("--no-verify");
215
+ const skipGates = argv.includes("--no-gates");
216
+ const args = argv.filter((a) => !a.startsWith("--"));
217
+ if (!args.length) {
218
+ console.error("usage: npx tsx scripts/make-card.ts <puzzle.json ...> | -");
219
+ process.exit(1);
220
+ }
221
+
222
+ const puzzles: Puzzle[] = args.flatMap((a) => {
223
+ const raw =
224
+ a === "-"
225
+ ? (JSON.parse(readFileSync(0, "utf8")) as unknown)
226
+ : readJson(a);
227
+ return (Array.isArray(raw) ? raw : [raw]) as Puzzle[];
228
+ });
229
+
230
+ try {
231
+ for (const p of puzzles) await makeCard(p, skipVerify, skipGates);
232
+ } finally {
233
+ if (existsSync(TMP)) rmSync(TMP);
234
+ }
235
+ };
236
+
237
+ main();