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,281 @@
1
+ /**
2
+ * Write new What Prints? puzzles.
3
+ *
4
+ * Three inputs, in order of authority:
5
+ *
6
+ * 1. The existing library — house style. The names are Nintendo-flavoured,
7
+ * the snippets are short, the answers are surprising, and the `why` does
8
+ * the teaching. New puzzles have to belong to that set.
9
+ * 2. What actually performed, by comments per 1,000 reach. Reach swings by
10
+ * orders of magnitude and likes track reach, so raw likes measure
11
+ * distribution rather than whether the puzzle landed.
12
+ * 3. What makes people argue. The best-performing episode so far is the one
13
+ * where a list mutates while you iterate it — people turn up in the
14
+ * comments to insist the language is broken. A puzzle that provokes
15
+ * disagreement outperforms one that merely surprises.
16
+ *
17
+ * Nothing written here is trusted. Every puzzle goes through make-card.ts,
18
+ * which EXECUTES the snippet, checks it prints exactly the stated answer, and
19
+ * runs the quality gates (deterministic, same on every installed Python, no
20
+ * duplicates, has a real explanation). A puzzle that fails is dropped, not
21
+ * shipped — a wrong answer is the one mistake this series can't survive.
22
+ */
23
+ import { execFileSync } from "node:child_process";
24
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+
27
+ const ROOT = join(__dirname, "..");
28
+ const EPISODES = join(ROOT, "episodes");
29
+ const PUZZLES = join(ROOT, "puzzles");
30
+
31
+ /** Top up when the library gets this thin. */
32
+ export const LOW_STOCK = 8;
33
+
34
+ /** How many to write when topping up unprompted. */
35
+ export const AUTO_BATCH = 6;
36
+
37
+ /**
38
+ * Don't retry a failing generation in a tight loop.
39
+ *
40
+ * Writing puzzles costs a Claude call and a Python run per candidate, and a
41
+ * generation that fails tends to keep failing for the same reason — an API
42
+ * problem, or a concept space that's genuinely exhausted. Backing off makes
43
+ * that a slow annoyance rather than a machine pinned at 100%.
44
+ */
45
+ const COOLDOWN_MS = 6 * 60 * 60 * 1000;
46
+ const stampFile = join(EPISODES, ".restock-attempted");
47
+
48
+ const lastAttempt = (): number => {
49
+ try {
50
+ return Number(readFileSync(stampFile, "utf8").trim()) || 0;
51
+ } catch {
52
+ return 0;
53
+ }
54
+ };
55
+
56
+ type Entry = {
57
+ name?: string;
58
+ code?: string;
59
+ answer?: string;
60
+ why?: string;
61
+ concept?: string;
62
+ difficulty?: string;
63
+ card?: string;
64
+ retired?: string;
65
+ metrics?: {
66
+ likes?: number;
67
+ comments?: number;
68
+ reach?: number;
69
+ saved?: number;
70
+ shares?: number;
71
+ };
72
+ };
73
+
74
+ const readJson = <T>(p: string, fallback: T): T =>
75
+ existsSync(p) ? (JSON.parse(readFileSync(p, "utf8")) as T) : fallback;
76
+
77
+ const slugify = (s: string) =>
78
+ s
79
+ .toLowerCase()
80
+ .replace(/[^a-z0-9]+/g, "-")
81
+ .replace(/^-|-$/g, "")
82
+ .slice(0, 48);
83
+
84
+ /**
85
+ * Comments per 1,000 reach — the metric the README argues for, and the only
86
+ * one that says whether the puzzle itself landed.
87
+ */
88
+ const engagement = (e: Entry): number | null => {
89
+ const c = e.metrics?.comments;
90
+ const r = e.metrics?.reach;
91
+ if (typeof c !== "number" || typeof r !== "number" || r < 200) return null;
92
+ return (c / r) * 1000;
93
+ };
94
+
95
+ /**
96
+ * Top the library up if it's running low, unprompted.
97
+ *
98
+ * Called after a render — the moment a puzzle is actually consumed — and once
99
+ * at worker start, so a library that's already thin doesn't wait for the next
100
+ * post to notice. Returns null when nothing was needed.
101
+ */
102
+ export const autoRestock = async (
103
+ unusedCount: number,
104
+ now: number,
105
+ ): Promise<{ accepted: string[]; rejected: string[] } | null> => {
106
+ if (unusedCount >= LOW_STOCK) return null;
107
+ if (now - lastAttempt() < COOLDOWN_MS) return null;
108
+
109
+ // Stamped BEFORE the attempt, not after: a crash mid-generation must still
110
+ // count as an attempt, or a reproducible failure retries on every job.
111
+ writeFileSync(stampFile, String(now));
112
+ console.log(
113
+ ` library down to ${unusedCount} — writing more (auto)`,
114
+ );
115
+ return whatPrintsRestock(AUTO_BATCH);
116
+ };
117
+
118
+ export const whatPrintsRestock = async (count: number) => {
119
+ const index = readJson<Entry[]>(join(EPISODES, "index.json"), []);
120
+ const live = index.filter((e) => !e.retired && e.code);
121
+
122
+ const scored = live
123
+ .map((e) => ({ e, score: engagement(e) }))
124
+ .filter((x): x is { e: Entry; score: number } => x.score !== null)
125
+ .sort((a, b) => b.score - a.score);
126
+
127
+ const existingConcepts = [
128
+ ...new Set(live.map((e) => e.concept).filter(Boolean)),
129
+ ];
130
+
131
+ // Style is taught by example rather than described — three real puzzles say
132
+ // more about the house voice than a paragraph about it would.
133
+ const examples = live
134
+ .slice(0, 3)
135
+ .map((e) =>
136
+ JSON.stringify(
137
+ {
138
+ name: e.name,
139
+ code: e.code,
140
+ answer: e.answer,
141
+ why: e.why,
142
+ difficulty: e.difficulty,
143
+ concept: e.concept,
144
+ },
145
+ null,
146
+ 2,
147
+ ),
148
+ )
149
+ .join("\n\n");
150
+
151
+ const perfSection = scored.length
152
+ ? `## What actually performed
153
+ Measured as comments per 1,000 reach — likes mostly track how far a post was
154
+ pushed, so they say little about whether the puzzle landed.
155
+
156
+ ${scored
157
+ .slice(0, 6)
158
+ .map(
159
+ (x) =>
160
+ `- ${x.score.toFixed(1)} per 1k · ${x.e.name} · ${x.e.concept ?? "?"} — ${(x.e.why ?? "").slice(0, 110)}`,
161
+ )
162
+ .join("\n")}
163
+
164
+ Look at what the top ones have in common and write more of THAT.`
165
+ : `## No performance data yet
166
+ Nothing has enough reach to judge, so go on the house style and the rule below.`;
167
+
168
+ const prompt = `You are writing new puzzles for "What Prints?", a short-form video series where
169
+ a code snippet is shown and viewers guess the output.
170
+
171
+ ## House style — match this
172
+ ${examples}
173
+
174
+ Names are playful and Nintendo-flavoured. Snippets are SHORT: under 8 lines and
175
+ under ~45 characters wide, or the card shrinks the type and stops being
176
+ readable on a phone. The answer must be surprising. The "why" does the actual
177
+ teaching and is the payoff.
178
+
179
+ ${perfSection}
180
+
181
+ ## What makes one work
182
+ The best-performing episode so far is a list being mutated while it's iterated:
183
+ people arrive in the comments to argue the language is broken. That's the
184
+ shape to aim for — a snippet where the correct answer feels WRONG, and where
185
+ someone confident will insist it's a bug.
186
+
187
+ Prefer behaviour people have opinions about: mutable default arguments, late
188
+ binding in closures, integer caching, float arithmetic, truthiness, copy vs
189
+ deepcopy, iterator exhaustion, operator precedence surprises, string interning.
190
+ Avoid anything that needs trivia rather than reasoning — the viewer should be
191
+ able to work it out and still get it wrong.
192
+
193
+ ## Already covered — do NOT repeat these concepts
194
+ ${existingConcepts.join(", ") || "(none yet)"}
195
+
196
+ ## Hard rules
197
+ - Python 3, standard library only, no imports beyond the stdlib.
198
+ - It must print EXACTLY what you say it prints. The snippet gets executed and
199
+ checked; a mismatch throws the puzzle away.
200
+ - Deterministic: same output every run, and on every Python 3.11-3.14. No dict
201
+ ordering luck, no memory addresses, no timing, no randomness.
202
+ - Answerable from the snippet alone.
203
+ - "why" must be a real explanation, 2-4 sentences, and include the fix where
204
+ there is one.
205
+
206
+ ## Output — a JSON array and nothing else
207
+ [
208
+ {
209
+ "slug": "kebab-case-name",
210
+ "name": "Bowser's NaN Trap",
211
+ "language": "Python",
212
+ "code": "a = float(\\"nan\\")\\nprint(a == a)",
213
+ "answer": "False",
214
+ "why": "...",
215
+ "difficulty": "easy" | "medium" | "hard",
216
+ "concept": "short-slug-for-the-idea"
217
+ }
218
+ ]
219
+
220
+ Write at most ${count}. Fewer good ones beats more weak ones.`;
221
+
222
+ const out = execFileSync(
223
+ "claude",
224
+ ["-p", prompt, "--output-format", "text", "--allowedTools", ""],
225
+ { encoding: "utf8", maxBuffer: 20 * 1024 * 1024, timeout: 20 * 60 * 1000 },
226
+ );
227
+
228
+ const start = out.indexOf("[");
229
+ const end = out.lastIndexOf("]");
230
+ if (start === -1 || end === -1) throw new Error("No JSON array came back");
231
+
232
+ let raw: Entry[];
233
+ try {
234
+ raw = JSON.parse(out.slice(start, end + 1));
235
+ } catch (e) {
236
+ throw new Error(`Puzzles weren't valid JSON: ${(e as Error).message}`);
237
+ }
238
+
239
+ const accepted: string[] = [];
240
+ const rejected: string[] = [];
241
+
242
+ for (const p of raw) {
243
+ const slug = slugify((p as { slug?: string }).slug ?? p.name ?? "puzzle");
244
+ if (!slug || !p.code || !p.answer) {
245
+ rejected.push(`${p.name ?? "unnamed"}: missing code or answer`);
246
+ continue;
247
+ }
248
+ const file = join(PUZZLES, `${slug}.json`);
249
+ writeFileSync(file, JSON.stringify({ ...p, slug }, null, 2) + "\n");
250
+
251
+ // make-card runs the snippet, checks the answer, applies the quality
252
+ // gates, renders the card and registers it. Anything it rejects never
253
+ // becomes a post.
254
+ try {
255
+ execFileSync("npx", ["tsx", "scripts/make-card.ts", file], {
256
+ cwd: ROOT,
257
+ stdio: ["ignore", "pipe", "pipe"],
258
+ encoding: "utf8",
259
+ });
260
+ accepted.push(slug);
261
+ console.log(` ✓ ${slug}`);
262
+ } catch (err) {
263
+ const e = err as { stdout?: string; stderr?: string };
264
+ const why =
265
+ `${e.stdout ?? ""}${e.stderr ?? ""}`
266
+ .split("\n")
267
+ .find((l) => /Error:/.test(l))
268
+ ?.replace(/^.*Error:\s*/, "") ?? "failed verification";
269
+ rejected.push(`${slug}: ${why}`);
270
+ console.log(` ✗ ${slug}: ${why}`);
271
+ execFileSync("rm", ["-f", file]);
272
+ }
273
+ }
274
+
275
+ if (accepted.length === 0) {
276
+ throw new Error(
277
+ `Nothing survived checking. ${rejected.slice(0, 3).join("; ")}`,
278
+ );
279
+ }
280
+ return { accepted, rejected };
281
+ };
package/src/Root.tsx ADDED
@@ -0,0 +1,112 @@
1
+ import React from "react";
2
+ import { Composition, CalculateMetadataFunction } from "remotion";
3
+ import { WhatPrints } from "./series/what-prints/Composition";
4
+ import { whatPrintsSchema, WhatPrintsProps } from "./series/what-prints/types";
5
+ import { FontLab } from "./lab/FontLab";
6
+ import { FontSheet } from "./lab/FontSheet";
7
+ import { PillLab } from "./lab/PillLab";
8
+ import { CodeCard } from "./series/what-prints/CodeCard";
9
+ import {
10
+ codeCardSchema,
11
+ CodeCardProps,
12
+ CARD_WIDTH,
13
+ cardHeight,
14
+ } from "./series/what-prints/codeCardTypes";
15
+ import { Overlay } from "./overlay/Composition";
16
+ import { overlaySchema, OverlayProps } from "./overlay/types";
17
+ import defaultEpisode from "../episodes/what-prints-001.json";
18
+
19
+ const calculateMetadata: CalculateMetadataFunction<WhatPrintsProps> = ({
20
+ props,
21
+ }) => {
22
+ return {
23
+ durationInFrames: Math.round(props.durationSec * 30),
24
+ };
25
+ };
26
+
27
+ const overlayMetadata: CalculateMetadataFunction<OverlayProps> = ({ props }) => ({
28
+ durationInFrames: Math.max(1, Math.round(props.durationSec * 30)),
29
+ });
30
+
31
+ export const RemotionRoot: React.FC = () => {
32
+ return (
33
+ <>
34
+ <Composition
35
+ id="WhatPrints"
36
+ component={WhatPrints}
37
+ fps={30}
38
+ width={1080}
39
+ height={1920}
40
+ schema={whatPrintsSchema}
41
+ defaultProps={defaultEpisode as WhatPrintsProps}
42
+ calculateMetadata={calculateMetadata}
43
+ />
44
+ {/* Overlay layers with nothing behind them — rendered alpha-only and
45
+ composited over untouched footage by scripts/render-overlay.ts. The
46
+ frame stays 1080x1920 at any output resolution; --scale handles 4K. */}
47
+ <Composition
48
+ id="Overlay"
49
+ component={Overlay}
50
+ fps={30}
51
+ width={1080}
52
+ height={1920}
53
+ schema={overlaySchema}
54
+ defaultProps={{
55
+ durationSec: 6,
56
+ layers: [
57
+ { type: "title", line1: "What", line2: "PRINTS?" },
58
+ { type: "meter", difficulty: "medium", fromSec: 2 },
59
+ ],
60
+ } as OverlayProps}
61
+ calculateMetadata={overlayMetadata}
62
+ />
63
+ {/* Still image only — rendered by scripts/make-card.ts, which sizes the
64
+ canvas to the snippet's line count. */}
65
+ <Composition
66
+ id="CodeCard"
67
+ component={CodeCard}
68
+ fps={30}
69
+ durationInFrames={1}
70
+ width={CARD_WIDTH}
71
+ height={cardHeight(5)}
72
+ schema={codeCardSchema}
73
+ defaultProps={{
74
+ language: "Python",
75
+ lines: [
76
+ [{ content: "npx tsx scripts/make-card.ts", color: "#c5c8d4" }],
77
+ ],
78
+ codeScale: 1,
79
+ }}
80
+ calculateMetadata={({ props }: { props: CodeCardProps }) => ({
81
+ width: CARD_WIDTH,
82
+ height: cardHeight(props.lines.length, props.codeScale ?? 1),
83
+ })}
84
+ />
85
+ <Composition
86
+ id="PillLab"
87
+ component={PillLab}
88
+ fps={30}
89
+ durationInFrames={1}
90
+ width={1080}
91
+ height={1920}
92
+ />
93
+ {/* Candidate faces for the title's second line, at real size. */}
94
+ <Composition
95
+ id="FontSheet"
96
+ component={FontSheet}
97
+ fps={30}
98
+ durationInFrames={1}
99
+ width={1500}
100
+ height={1500}
101
+ />
102
+ <Composition
103
+ id="FontLab"
104
+ component={FontLab}
105
+ fps={30}
106
+ durationInFrames={1}
107
+ width={1400}
108
+ height={1500}
109
+ />
110
+ </>
111
+ );
112
+ };
package/src/index.css ADDED
@@ -0,0 +1 @@
1
+ @import "tailwindcss";
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { registerRoot } from "remotion";
2
+ import { RemotionRoot } from "./Root";
3
+
4
+ registerRoot(RemotionRoot);
@@ -0,0 +1,50 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, Img, staticFile } from "remotion";
3
+ import { PixelText } from "../overlay/PixelText";
4
+ import { sansFamily } from "../overlay/fonts";
5
+
6
+ // Direct comparison against the inspiration: same words, so stroke thickness
7
+ // and pixel size can be matched by eye rather than guessed.
8
+ const SAMPLES = [
9
+ { rows: 10, cell: 15, gap: 3, weight: 500 },
10
+ { rows: 10, cell: 15, gap: 3, weight: 400 },
11
+ { rows: 10, cell: 15, gap: 3, weight: 300 },
12
+ { rows: 9, cell: 16, gap: 3, weight: 400 },
13
+ ];
14
+
15
+ export const FontLab: React.FC = () => (
16
+ <AbsoluteFill style={{ background: "#3a3128", justifyContent: "center", gap: 26 }}>
17
+ <div style={{ padding: "0 40px", fontFamily: "monospace", fontSize: 26, color: "#ffd08a" }}>
18
+ INSPIRATION (target)
19
+ </div>
20
+ <Img
21
+ src={staticFile("assets/inspiration/title inspiration.jpg")}
22
+ style={{
23
+ width: 1180,
24
+ objectFit: "none",
25
+ objectPosition: "-40px -410px",
26
+ height: 160,
27
+ alignSelf: "flex-start",
28
+ marginLeft: 40,
29
+ }}
30
+ />
31
+ <div style={{ padding: "0 40px", marginTop: 10, fontFamily: "monospace", fontSize: 26, color: "#8affc1" }}>
32
+ RASTERIZED — rows = cap height in cells
33
+ </div>
34
+ {SAMPLES.map((s) => (
35
+ <div key={`${s.rows}-${s.weight}`} style={{ display: "flex", alignItems: "center", gap: 24, padding: "0 40px" }}>
36
+ <div style={{ width: 130, fontFamily: "monospace", fontSize: 24, color: "#bbb" }}>
37
+ rows {s.rows} / w{s.weight}
38
+ </div>
39
+ <PixelText
40
+ text="Prints?"
41
+ fontFamily={sansFamily}
42
+ fontWeight={s.weight}
43
+ rows={s.rows}
44
+ cell={s.cell}
45
+ gap={s.gap}
46
+ />
47
+ </div>
48
+ ))}
49
+ </AbsoluteFill>
50
+ );
@@ -0,0 +1,188 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { AbsoluteFill, continueRender, delayRender, staticFile } from "remotion";
3
+ import { loadFont as poppins } from "@remotion/google-fonts/Poppins";
4
+ import { loadFont as handjet } from "@remotion/google-fonts/Handjet";
5
+ import { loadFont as silkscreen } from "@remotion/google-fonts/Silkscreen";
6
+ import { loadFont as pixelifySans } from "@remotion/google-fonts/PixelifySans";
7
+ import { loadFont as jersey15 } from "@remotion/google-fonts/Jersey15";
8
+ import { loadFont as micro5 } from "@remotion/google-fonts/Micro5";
9
+ import { loadFont as dotGothic16 } from "@remotion/google-fonts/DotGothic16";
10
+ import { PixelText } from "../overlay/PixelText";
11
+
12
+ /**
13
+ * Candidates for the title's second line, at the real size, so the choice is
14
+ * made by looking rather than by reasoning about it.
15
+ *
16
+ * The line this replaces is PixelText — a canvas rasterizer that draws Poppins
17
+ * onto a dot grid. It was written because "pixel fonts cap stroke weight at one
18
+ * grid cell", a conclusion reached with Handjet at weight 370. Handjet goes to
19
+ * 900, so the premise is worth retesting before keeping a bespoke rasterizer.
20
+ *
21
+ * Note @remotion/google-fonts serves one static instance per weight, so
22
+ * Handjet's ELGR/ELSH axes can't be varied — these are its default shapes.
23
+ */
24
+
25
+ const { fontFamily: sans } = poppins("normal", {
26
+ weights: ["700"],
27
+ subsets: ["latin"],
28
+ });
29
+
30
+ /**
31
+ * Handjet as a TRUE variable font.
32
+ *
33
+ * @remotion/google-fonts serves one static instance per weight, which pins
34
+ * ELGR/ELSH at their defaults — and those two axes are the whole reason to
35
+ * want Handjet. ELSH shapes the element (0 square … 16 round) and ELGR sets
36
+ * how coarse the grid is, which together are what make separated dots rather
37
+ * than the continuous strokes every other pixel face gives.
38
+ *
39
+ * Self-hosted from public/fonts so the render doesn't depend on the network.
40
+ */
41
+ const HANDJET_VAR = "HandjetVar";
42
+
43
+ const variableFace = `
44
+ @font-face {
45
+ font-family: "${HANDJET_VAR}";
46
+ src: url("${staticFile("fonts/Handjet-variable.woff2")}") format("woff2");
47
+ font-weight: 400 900;
48
+ font-display: block;
49
+ }`;
50
+
51
+ const VARIABLE: { label: string; weight: number; elgr: number; elsh: number }[] = [
52
+ { label: "Handjet var 700 · ELSH 4", weight: 700, elgr: 1, elsh: 4 },
53
+ { label: "Handjet var 900 · ELSH 8", weight: 900, elgr: 1, elsh: 8 },
54
+ ];
55
+
56
+ /**
57
+ * The rasterizer, retuned. `rows` is cap height in cells, so more rows = finer
58
+ * dots; `cell` is the pitch and `gap` the space inside it, so cell-gap is the
59
+ * dot. The current title is rows 10 / cell 15 / gap 3 at Poppins 300 — big
60
+ * sparse dots. The reference is finer and heavier than that.
61
+ */
62
+ const RASTER: { label: string; rows: number; cell: number; gap: number; weight: number }[] = [
63
+ { label: "raster NOW (10/15/3 w300)", rows: 10, cell: 15, gap: 3, weight: 300 },
64
+ { label: "raster finer (14/11/2 w500)", rows: 14, cell: 11, gap: 2, weight: 500 },
65
+ { label: "raster fine+bold (16/9/2 w700)", rows: 16, cell: 9, gap: 2, weight: 700 },
66
+ { label: "raster tight (18/8/1 w700)", rows: 18, cell: 8, gap: 1, weight: 700 },
67
+ ];
68
+
69
+ const CANDIDATES: { label: string; family: string; weight: number }[] = [
70
+ { label: "Handjet 800", family: handjet("normal", { weights: ["800"], subsets: ["latin"] }).fontFamily, weight: 800 },
71
+ { label: "Silkscreen 700", family: silkscreen("normal", { weights: ["700"], subsets: ["latin"] }).fontFamily, weight: 700 },
72
+ { label: "Pixelify Sans 700", family: pixelifySans("normal", { weights: ["700"], subsets: ["latin"] }).fontFamily, weight: 700 },
73
+ { label: "Jersey 15", family: jersey15("normal", { weights: ["400"], subsets: ["latin"] }).fontFamily, weight: 400 },
74
+ { label: "Micro 5", family: micro5("normal", { weights: ["400"], subsets: ["latin"] }).fontFamily, weight: 400 },
75
+ { label: "DotGothic16", family: dotGothic16("normal", { weights: ["400"], subsets: ["latin"] }).fontFamily, weight: 400 },
76
+ ];
77
+
78
+ // The reference sets a bold sans line over a dot-matrix line, so each sample
79
+ // shows the pair — a face that looks fine alone can still fight the Poppins.
80
+ const SAMPLE_1 = "What";
81
+ const SAMPLE_2 = "PRINTS?";
82
+
83
+ export const FontSheet: React.FC = () => {
84
+ // A hand-written @font-face is not covered by Remotion's font loading, so
85
+ // the frame gets captured while the face is still fetching and the row
86
+ // renders as ghost outlines. Hold the frame until it's actually usable.
87
+ const [handle] = useState(() => delayRender("Loading variable Handjet"));
88
+ useEffect(() => {
89
+ const done = () => continueRender(handle);
90
+ document.fonts.load(`700 104px "${HANDJET_VAR}"`).then(done, done);
91
+ }, [handle]);
92
+
93
+ return (
94
+ <AbsoluteFill
95
+ style={{
96
+ background: "#151515",
97
+ padding: 48,
98
+ display: "flex",
99
+ flexDirection: "column",
100
+ gap: 26,
101
+ }}
102
+ >
103
+ <style dangerouslySetInnerHTML={{ __html: variableFace }} />
104
+ {VARIABLE.map((v) => (
105
+ <div key={v.label} style={{ display: "flex", alignItems: "center", gap: 36 }}>
106
+ <div style={{ fontFamily: sans, fontSize: 20, fontWeight: 700, color: "#8ee3a1", width: 260, flexShrink: 0 }}>
107
+ {v.label}
108
+ </div>
109
+ <div style={{ display: "flex", alignItems: "baseline", gap: 20 }}>
110
+ <span style={{ fontFamily: sans, fontWeight: 700, fontSize: 78, color: "white" }}>
111
+ {SAMPLE_1}
112
+ </span>
113
+ <span
114
+ style={{
115
+ fontFamily: HANDJET_VAR,
116
+ fontWeight: v.weight,
117
+ fontVariationSettings: `"ELGR" ${v.elgr}, "ELSH" ${v.elsh}`,
118
+ fontSize: 104,
119
+ color: "white",
120
+ }}
121
+ >
122
+ {SAMPLE_2}
123
+ </span>
124
+ </div>
125
+ </div>
126
+ ))}
127
+ {RASTER.map((r) => (
128
+ <div key={r.label} style={{ display: "flex", alignItems: "center", gap: 36 }}>
129
+ <div style={{ fontFamily: sans, fontSize: 20, fontWeight: 700, color: "#ffd166", width: 260, flexShrink: 0 }}>
130
+ {r.label}
131
+ </div>
132
+ <div style={{ display: "flex", alignItems: "center", gap: 20 }}>
133
+ <span style={{ fontFamily: sans, fontWeight: 700, fontSize: 78, color: "white" }}>
134
+ {SAMPLE_1}
135
+ </span>
136
+ <PixelText text={SAMPLE_2} fontFamily={sans} fontWeight={r.weight} rows={r.rows} cell={r.cell} gap={r.gap} />
137
+ </div>
138
+ </div>
139
+ ))}
140
+ {CANDIDATES.map((c) => (
141
+ <div
142
+ key={c.label}
143
+ style={{ display: "flex", alignItems: "center", gap: 36 }}
144
+ >
145
+ <div
146
+ style={{
147
+ fontFamily: sans,
148
+ fontSize: 20,
149
+ fontWeight: 700,
150
+ color: "#6f7686",
151
+ width: 260,
152
+ flexShrink: 0,
153
+ }}
154
+ >
155
+ {c.label}
156
+ </div>
157
+ <div style={{ display: "flex", alignItems: "baseline", gap: 20 }}>
158
+ <span
159
+ style={{
160
+ fontFamily: sans,
161
+ fontWeight: 700,
162
+ fontSize: 78,
163
+ color: "white",
164
+ textShadow: "0 0 4px rgba(0,0,0,0.9), 0 4px 20px rgba(0,0,0,0.6)",
165
+ }}
166
+ >
167
+ {SAMPLE_1}
168
+ </span>
169
+ <span
170
+ style={{
171
+ fontFamily: c.family,
172
+ fontWeight: c.weight,
173
+ fontSize: 104,
174
+ color: "white",
175
+ // Same doubled shadow PixelText uses: a single wide blur tints
176
+ // the area and the dots stop reading against bright footage.
177
+ textShadow:
178
+ "0 0 3px rgba(0,0,0,0.95), 0 4px 16px rgba(0,0,0,0.6)",
179
+ }}
180
+ >
181
+ {SAMPLE_2}
182
+ </span>
183
+ </div>
184
+ </div>
185
+ ))}
186
+ </AbsoluteFill>
187
+ );
188
+ };