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,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,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
|
+
};
|