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