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,121 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, staticFile } from "remotion";
3
+ import { Video } from "@remotion/media";
4
+ import { PixelText } from "../overlay/PixelText";
5
+ import { sansFamily } from "../overlay/fonts";
6
+
7
+ const DOT = { easy: "#22c55e", medium: "#eab308", hard: "#ef4444" } as const;
8
+ const difficulty: keyof typeof DOT = "hard";
9
+ const label = "Hard";
10
+
11
+ // A — current: white pill, colored dot
12
+ const OptionA: React.FC = () => (
13
+ <div
14
+ style={{
15
+ display: "inline-flex",
16
+ alignItems: "center",
17
+ gap: 20,
18
+ background: "white",
19
+ borderRadius: 24,
20
+ padding: "26px 44px",
21
+ boxShadow: "0 6px 30px rgba(0,0,0,0.35)",
22
+ }}
23
+ >
24
+ <div style={{ width: 44, height: 44, borderRadius: "50%", background: DOT[difficulty] }} />
25
+ <div style={{ fontFamily: sansFamily, fontWeight: 700, fontSize: 52, color: "#111" }}>{label}</div>
26
+ </div>
27
+ );
28
+
29
+ // B — pixel word, no container: echoes the title treatment
30
+ const OptionB: React.FC = () => (
31
+ <PixelText text={label.toUpperCase()} fontFamily={sansFamily} fontWeight={500} rows={7} cell={11} gap={2} color={DOT[difficulty]} />
32
+ );
33
+
34
+ // C — pixel blocks as a difficulty meter, with a plain label
35
+ const OptionC: React.FC = () => {
36
+ const filled = { easy: 1, medium: 2, hard: 3 }[difficulty];
37
+ return (
38
+ <div style={{ display: "flex", alignItems: "center", gap: 18 }}>
39
+ <div style={{ display: "flex", gap: 8 }}>
40
+ {[0, 1, 2].map((i) => (
41
+ <div
42
+ key={i}
43
+ style={{
44
+ width: 34,
45
+ height: 52,
46
+ background: i < filled ? DOT[difficulty] : "rgba(255,255,255,0.28)",
47
+ boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
48
+ }}
49
+ />
50
+ ))}
51
+ </div>
52
+ <div
53
+ style={{
54
+ fontFamily: sansFamily,
55
+ fontWeight: 700,
56
+ fontSize: 50,
57
+ color: "white",
58
+ textShadow: "0 4px 18px rgba(0,0,0,0.6)",
59
+ }}
60
+ >
61
+ {label}
62
+ </div>
63
+ </div>
64
+ );
65
+ };
66
+
67
+ // D — dark glass chip, colored text
68
+ const OptionD: React.FC = () => (
69
+ <div
70
+ style={{
71
+ display: "inline-flex",
72
+ alignItems: "center",
73
+ gap: 16,
74
+ background: "rgba(20,20,22,0.72)",
75
+ border: "3px solid rgba(255,255,255,0.16)",
76
+ borderRadius: 20,
77
+ padding: "22px 40px",
78
+ backdropFilter: "blur(14px)",
79
+ }}
80
+ >
81
+ <div style={{ width: 20, height: 20, background: DOT[difficulty] }} />
82
+ <div
83
+ style={{
84
+ fontFamily: sansFamily,
85
+ fontWeight: 700,
86
+ fontSize: 48,
87
+ color: "white",
88
+ letterSpacing: 2,
89
+ textTransform: "uppercase",
90
+ }}
91
+ >
92
+ {label}
93
+ </div>
94
+ </div>
95
+ );
96
+
97
+ const OPTIONS = [
98
+ { key: "A — current white pill", node: <OptionA /> },
99
+ { key: "B — pixel word", node: <OptionB /> },
100
+ { key: "C — pixel meter", node: <OptionC /> },
101
+ { key: "D — dark glass chip", node: <OptionD /> },
102
+ ];
103
+
104
+ export const PillLab: React.FC = () => (
105
+ <AbsoluteFill>
106
+ <Video
107
+ src={staticFile("assets/b-roll/IMG_4858.MOV")}
108
+ muted
109
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
110
+ />
111
+ <AbsoluteFill style={{ background: "rgba(0,0,0,0.15)" }} />
112
+ <AbsoluteFill style={{ justifyContent: "center", gap: 90, paddingLeft: 90 }}>
113
+ {OPTIONS.map((o) => (
114
+ <div key={o.key} style={{ display: "flex", flexDirection: "column", gap: 14 }}>
115
+ <div style={{ fontFamily: "monospace", fontSize: 28, color: "#ffe6a0" }}>{o.key}</div>
116
+ <div style={{ display: "flex" }}>{o.node}</div>
117
+ </div>
118
+ ))}
119
+ </AbsoluteFill>
120
+ </AbsoluteFill>
121
+ );
@@ -0,0 +1,297 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import {
3
+ AbsoluteFill,
4
+ Img,
5
+ Sequence,
6
+ continueRender,
7
+ delayRender,
8
+ interpolate,
9
+ spring,
10
+ staticFile,
11
+ useCurrentFrame,
12
+ useVideoConfig,
13
+ } from "remotion";
14
+ import { Title } from "./Title";
15
+ import { DifficultyMeter } from "./DifficultyMeter";
16
+ import { SAFE_AREA, SAFE_CENTER_Y, CARD_MAX_HEIGHT } from "./safeArea";
17
+ import { CARD_WIDTH_PCT } from "./cardLayout";
18
+ import { PixelText } from "./PixelText";
19
+ import { sansFamily } from "./fonts";
20
+ import { brandFontsReady } from "./brandFonts";
21
+ import type { OverlayLayer, OverlayProps } from "./types";
22
+
23
+ /**
24
+ * The overlay layers with nothing behind them.
25
+ *
26
+ * There is no <Video> here on purpose. Rendering 4K footage through Remotion
27
+ * means screenshotting a 2160x3840 browser frame for every frame of the clip,
28
+ * which is slow and re-encodes footage that was already fine. Instead this
29
+ * renders alpha-only (ProRes 4444 via scripts/render-overlay.ts) and ffmpeg
30
+ * composites it over the untouched original — so the footage never enters
31
+ * Chrome and keeps its original quality.
32
+ *
33
+ * WhatPrints still composites its own <Video> directly; it's a fixed format
34
+ * where that's simpler. This composition is the one the Overlay Factory tool
35
+ * drives, where the footage is whatever the user just uploaded.
36
+ */
37
+
38
+ /** A URL passes through; anything else is a path under public/. */
39
+ const resolveSrc = (src: string) =>
40
+ /^https?:\/\//.test(src) ? src : staticFile(src);
41
+
42
+ /**
43
+ * Generated templates are HTML, and HTML can carry things that have no business
44
+ * running inside a render. The series contract already forbids scripts and
45
+ * external URLs; this enforces it rather than trusting it, because a template
46
+ * is written by a model from a user's own instructions and nobody reads it
47
+ * before it renders.
48
+ */
49
+ const sanitize = (html: string) =>
50
+ html
51
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
52
+ .replace(/<iframe[\s\S]*?<\/iframe>/gi, "")
53
+ .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, ""); // onclick=, onerror=
54
+
55
+ /**
56
+ * Point root-relative sources at Remotion's static server.
57
+ *
58
+ * The worker substitutes photos as "/assets/…" because it has no way to build
59
+ * a render URL — staticFile only exists inside the composition. A bare root
60
+ * path does NOT resolve during a render (the page isn't served from the public
61
+ * directory's root), so the image silently fails to load and the layout draws
62
+ * an empty hole. This rewrites those paths at the only point that can.
63
+ */
64
+ const localSrc = (html: string) =>
65
+ html.replace(
66
+ /(<img[^>]*\ssrc=")\/([^"]+)(")/gi,
67
+ (_, before: string, path: string, after: string) =>
68
+ before + staticFile(path) + after,
69
+ );
70
+
71
+ /** Entrance motion, driven by Remotion's clock — see htmlLayer in types.ts. */
72
+ const HtmlBlock: React.FC<{
73
+ html: string;
74
+ enter?: "none" | "fade" | "slide-up" | "slide-down" | "pop";
75
+ }> = ({ html, enter = "fade" }) => {
76
+ const frame = useCurrentFrame();
77
+ const { fps } = useVideoConfig();
78
+ const s = spring({ frame, fps, config: { damping: 14 } });
79
+
80
+ const style: React.CSSProperties =
81
+ enter === "none"
82
+ ? {}
83
+ : enter === "fade"
84
+ ? { opacity: s }
85
+ : enter === "pop"
86
+ ? { opacity: s, transform: `scale(${interpolate(s, [0, 1], [0.86, 1])})` }
87
+ : {
88
+ opacity: s,
89
+ transform: `translateY(${interpolate(
90
+ s,
91
+ [0, 1],
92
+ [enter === "slide-up" ? 60 : -60, 0],
93
+ )}px)`,
94
+ };
95
+
96
+ return (
97
+ <div
98
+ style={style}
99
+ dangerouslySetInnerHTML={{ __html: localSrc(sanitize(html)) }}
100
+ />
101
+ );
102
+ };
103
+
104
+ const Layer: React.FC<{ layer: OverlayLayer }> = ({ layer }) => {
105
+ const { width: layerWidth } = useVideoConfig();
106
+
107
+ switch (layer.type) {
108
+ case "title":
109
+ return (
110
+ <>
111
+ {layer.scrim !== false && (
112
+ <AbsoluteFill
113
+ style={{
114
+ background:
115
+ "linear-gradient(to bottom, rgba(0,0,0,0.62) 0%, rgba(0,0,0,0.45) 22%, rgba(0,0,0,0) 40%)",
116
+ }}
117
+ />
118
+ )}
119
+ <AbsoluteFill
120
+ style={{
121
+ alignItems: "center",
122
+ justifyContent: "flex-start",
123
+ // Clear of Instagram's status bar + Reels header, and of the 3:4
124
+ // crop the profile grid takes off the top.
125
+ paddingTop: layer.y ?? SAFE_AREA.top + 12,
126
+ }}
127
+ >
128
+ <Title
129
+ line1={layer.line1}
130
+ line2={layer.line2}
131
+ sansSize={layer.sansSize}
132
+ rows={layer.rows}
133
+ cell={layer.cell}
134
+ gap={layer.gap}
135
+ weight={layer.weight}
136
+ family={layer.family}
137
+ />
138
+ </AbsoluteFill>
139
+ </>
140
+ );
141
+
142
+ case "pixelText":
143
+ return (
144
+ <div
145
+ style={{
146
+ position: "absolute",
147
+ left: layer.x ?? SAFE_AREA.left,
148
+ top: layer.y ?? SAFE_CENTER_Y,
149
+ }}
150
+ >
151
+ <PixelText
152
+ text={layer.text}
153
+ fontFamily={sansFamily}
154
+ fontWeight={300}
155
+ rows={layer.rows ?? 10}
156
+ cell={layer.cell ?? 15}
157
+ gap={layer.gap ?? 3}
158
+ color={layer.color ?? "white"}
159
+ />
160
+ </div>
161
+ );
162
+
163
+ case "image": {
164
+ const img = (
165
+ <Img
166
+ src={resolveSrc(layer.src)}
167
+ style={{
168
+ width: "auto",
169
+ height: "auto",
170
+ // Bounded on both axes so a tall card shrinks to fit the uncovered
171
+ // band rather than growing into the action rail.
172
+ maxWidth: layerWidth * (layer.widthPct ?? CARD_WIDTH_PCT),
173
+ maxHeight: layer.maxHeight ?? CARD_MAX_HEIGHT,
174
+ filter: "drop-shadow(0 10px 40px rgba(0,0,0,0.45))",
175
+ opacity: layer.opacity ?? 1,
176
+ }}
177
+ />
178
+ );
179
+ // `y` on a card means its CENTRE, not its top — that's what
180
+ // place-overlay.ts computes and what "move it down off her face" means.
181
+ // Centring is done by shrinking the wrapper and centre-justifying, so
182
+ // the card's own height never has to be known here.
183
+ // Both axes are CENTRES — that's what place-overlay.ts computes, what
184
+ // "move it down off her face" means, and what a drag hands back. The
185
+ // card's own size never has to be known here: the wrapper is shrunk to
186
+ // twice the centre and the image is centred inside it.
187
+ const centerY = layer.y ?? SAFE_CENTER_Y;
188
+ return (
189
+ <AbsoluteFill
190
+ style={{
191
+ alignItems: "center",
192
+ justifyContent: "center",
193
+ height: centerY * 2,
194
+ ...(layer.x === undefined ? {} : { width: layer.x * 2 }),
195
+ }}
196
+ >
197
+ {img}
198
+ </AbsoluteFill>
199
+ );
200
+ }
201
+
202
+ case "html": {
203
+ const block = <HtmlBlock html={layer.html} enter={layer.enter} />;
204
+ const width = layerWidth * (layer.widthPct ?? 0.86);
205
+
206
+ if (layer.x !== undefined || layer.y !== undefined) {
207
+ return (
208
+ <div
209
+ style={{ position: "absolute", left: layer.x ?? 0, top: layer.y ?? 0, width }}
210
+ >
211
+ {block}
212
+ </div>
213
+ );
214
+ }
215
+
216
+ // Anchored. `center` is the band Instagram never covers, not the frame's
217
+ // middle — see safeArea.ts for why those differ.
218
+ const anchor = layer.anchor ?? "center";
219
+ return (
220
+ <AbsoluteFill
221
+ style={{
222
+ alignItems: "center",
223
+ justifyContent:
224
+ anchor === "top" ? "flex-start" : anchor === "bottom" ? "flex-end" : "center",
225
+ paddingTop: anchor === "top" ? SAFE_AREA.top + 12 : undefined,
226
+ paddingBottom: anchor === "bottom" ? SAFE_AREA.bottom : undefined,
227
+ height: anchor === "center" ? SAFE_CENTER_Y * 2 : undefined,
228
+ }}
229
+ >
230
+ <div style={{ width }}>{block}</div>
231
+ </AbsoluteFill>
232
+ );
233
+ }
234
+
235
+ case "meter":
236
+ return (
237
+ <div
238
+ style={{
239
+ position: "absolute",
240
+ left: layer.x ?? 90,
241
+ top: layer.y ?? 1400,
242
+ }}
243
+ >
244
+ <DifficultyMeter difficulty={layer.difficulty} />
245
+ </div>
246
+ );
247
+ }
248
+ };
249
+
250
+ export const Overlay: React.FC<OverlayProps> = ({ layers, fonts }) => {
251
+ const { fps, durationInFrames } = useVideoConfig();
252
+
253
+ // Hold the frame until the brand faces are usable. Without this the renderer
254
+ // captures while they're still loading and the text silently comes out in
255
+ // the fallback — which reads as "the brand didn't apply" with nothing to
256
+ // debug, because the colours land correctly and only the type is wrong.
257
+ const [fontHandle] = useState(() => delayRender("Loading brand fonts"));
258
+ useEffect(() => {
259
+ brandFontsReady.then(
260
+ () => continueRender(fontHandle),
261
+ () => continueRender(fontHandle), // a font that won't load must not hang the render
262
+ );
263
+ }, [fontHandle]);
264
+
265
+ // Uploaded faces, verified by the worker and served from public/. Declared
266
+ // here rather than loaded through @remotion/google-fonts because they aren't
267
+ // Google fonts — but the same rule applies: naming one isn't loading it, so
268
+ // font-display:block holds the frame until it's usable.
269
+ const faces = (fonts ?? [])
270
+ .map(
271
+ (f) =>
272
+ `@font-face{font-family:"${f.family}";src:url("${staticFile(f.file)}") format("${f.format}");font-display:block;}`,
273
+ )
274
+ .join("\n");
275
+
276
+ return (
277
+ // No background — the alpha channel is the whole point.
278
+ <AbsoluteFill>
279
+ {faces ? <style dangerouslySetInnerHTML={{ __html: faces }} /> : null}
280
+ {layers.map((layer, i) => {
281
+ const from = Math.round((layer.fromSec ?? 0) * fps);
282
+ // A layer's own animation (the meter's spring, for one) is written
283
+ // against frame 0 of its Sequence, so wrapping is what makes fromSec
284
+ // read as "pop in here" rather than "appear mid-animation".
285
+ const to =
286
+ layer.toSec === undefined
287
+ ? durationInFrames - from
288
+ : Math.round(layer.toSec * fps) - from;
289
+ return (
290
+ <Sequence key={i} from={from} durationInFrames={Math.max(1, to)} layout="none">
291
+ <Layer layer={layer} />
292
+ </Sequence>
293
+ );
294
+ })}
295
+ </AbsoluteFill>
296
+ );
297
+ };
@@ -0,0 +1,86 @@
1
+ import React from "react";
2
+ import { spring, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { sansFamily } from "./fonts";
4
+
5
+ const LOOKS = {
6
+ easy: { color: "#22c55e", label: "Easy", filled: 1 },
7
+ medium: { color: "#eab308", label: "Medium", filled: 2 },
8
+ hard: { color: "#ef4444", label: "Hard", filled: 3 },
9
+ } as const;
10
+
11
+ export type Difficulty = keyof typeof LOOKS;
12
+
13
+ const BLOCK_W = 34;
14
+ const BLOCK_H = 52;
15
+ const BLOCK_GAP = 8;
16
+
17
+ /** Approximate on-screen size, used by the overlay placement script. */
18
+ export const METER_SIZE = { width: 400, height: 112 };
19
+
20
+ // Taken from the code cards themselves: solid #212121 body, and the rounded
21
+ // corner prep-card.ts cuts (4.5% of card width) scaled to its on-screen size.
22
+ const CARD_BACKGROUND = "#212121";
23
+ const CARD_RADIUS = 42;
24
+
25
+ // Three blocks filled by difficulty, next to a plain label. Blocks pop in one
26
+ // after another; wrap in a <Sequence> so frame 0 is the reveal moment.
27
+ export const DifficultyMeter: React.FC<{ difficulty: Difficulty }> = ({
28
+ difficulty,
29
+ }) => {
30
+ const frame = useCurrentFrame();
31
+ const { fps } = useVideoConfig();
32
+ const { color, label, filled } = LOOKS[difficulty];
33
+
34
+ const labelIn = spring({ frame: frame - 8, fps, config: { damping: 14 } });
35
+
36
+ return (
37
+ <div
38
+ style={{
39
+ display: "inline-flex",
40
+ alignItems: "center",
41
+ gap: 22,
42
+ // Matches the code card exactly: same solid fill, same corner radius and
43
+ // shadow, so the two overlays read as one system.
44
+ background: CARD_BACKGROUND,
45
+ borderRadius: CARD_RADIUS,
46
+ padding: "26px 42px",
47
+ filter: "drop-shadow(0 10px 40px rgba(0,0,0,0.45))",
48
+ }}
49
+ >
50
+ <div style={{ display: "flex", gap: BLOCK_GAP }}>
51
+ {[0, 1, 2].map((i) => {
52
+ const on = i < filled;
53
+ const scale = spring({
54
+ frame: frame - i * 4,
55
+ fps,
56
+ config: { damping: 12, mass: 0.5 },
57
+ });
58
+ return (
59
+ <div
60
+ key={i}
61
+ style={{
62
+ width: BLOCK_W,
63
+ height: BLOCK_H,
64
+ background: on ? color : "rgba(255,255,255,0.22)",
65
+ borderRadius: 4,
66
+ transform: `scale(${scale})`,
67
+ transformOrigin: "bottom center",
68
+ }}
69
+ />
70
+ );
71
+ })}
72
+ </div>
73
+ <div
74
+ style={{
75
+ fontFamily: sansFamily,
76
+ fontWeight: 700,
77
+ fontSize: 54,
78
+ color: "white",
79
+ opacity: labelIn,
80
+ }}
81
+ >
82
+ {label}
83
+ </div>
84
+ </div>
85
+ );
86
+ };
@@ -0,0 +1,134 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { continueRender, delayRender } from "remotion";
3
+
4
+ /**
5
+ * Renders text as a dot-matrix: real bold type rasterized onto a coarse grid,
6
+ * then drawn back as separated squares.
7
+ *
8
+ * Using a pixel *font* (Handjet, Silkscreen) caps stroke weight at one cell.
9
+ * Rasterizing instead means stroke thickness comes from the source font, so a
10
+ * heavy face gives the 3-cells-thick stems the title inspiration has.
11
+ */
12
+ type Props = {
13
+ text: string;
14
+ fontFamily: string;
15
+ fontWeight?: number;
16
+ /** Cap height in grid cells — the main thickness control. More rows = thicker strokes. */
17
+ rows?: number;
18
+ /** On-screen size of one grid cell, in px. */
19
+ cell?: number;
20
+ /** Gap between squares, in px. */
21
+ gap?: number;
22
+ color?: string;
23
+ };
24
+
25
+ const rasterize = (
26
+ text: string,
27
+ fontFamily: string,
28
+ fontWeight: number,
29
+ rows: number,
30
+ ): { grid: boolean[][]; width: number; height: number } => {
31
+ // Cap height is ~0.72em in most geometric sans faces, so this puts the
32
+ // capitals across `rows` cells.
33
+ const fontSize = rows / 0.72;
34
+ const canvas = document.createElement("canvas");
35
+ const ctx = canvas.getContext("2d")!;
36
+ const font = `${fontWeight} ${fontSize}px ${fontFamily}`;
37
+
38
+ ctx.font = font;
39
+ const metrics = ctx.measureText(text);
40
+ const width = Math.ceil(metrics.width) + 2;
41
+ const height = Math.ceil(fontSize * 1.45);
42
+
43
+ canvas.width = width;
44
+ canvas.height = height;
45
+ const c = canvas.getContext("2d")!;
46
+ c.font = font;
47
+ c.textBaseline = "alphabetic";
48
+ c.fillStyle = "#fff";
49
+ c.fillText(text, 1, Math.round(fontSize));
50
+
51
+ const { data } = c.getImageData(0, 0, width, height);
52
+ const grid: boolean[][] = [];
53
+ for (let y = 0; y < height; y++) {
54
+ const row: boolean[] = [];
55
+ for (let x = 0; x < width; x++) {
56
+ row.push(data[(y * width + x) * 4 + 3] > 110);
57
+ }
58
+ grid.push(row);
59
+ }
60
+
61
+ // Trim fully empty rows/columns so the block sits tight.
62
+ const rowFilled = grid.map((r) => r.some(Boolean));
63
+ const top = rowFilled.indexOf(true);
64
+ const bottom = rowFilled.lastIndexOf(true);
65
+ const colFilled = Array.from({ length: width }, (_, x) =>
66
+ grid.some((r) => r[x]),
67
+ );
68
+ const left = colFilled.indexOf(true);
69
+ const right = colFilled.lastIndexOf(true);
70
+
71
+ const trimmed = grid.slice(top, bottom + 1).map((r) => r.slice(left, right + 1));
72
+ return {
73
+ grid: trimmed,
74
+ width: right - left + 1,
75
+ height: bottom - top + 1,
76
+ };
77
+ };
78
+
79
+ export const PixelText: React.FC<Props> = ({
80
+ text,
81
+ fontFamily,
82
+ fontWeight = 700,
83
+ rows = 14,
84
+ cell = 13,
85
+ gap = 3,
86
+ color = "white",
87
+ }) => {
88
+ const [handle] = useState(() => delayRender("Rasterizing pixel title"));
89
+ const [raster, setRaster] = useState<ReturnType<typeof rasterize> | null>(null);
90
+
91
+ useEffect(() => {
92
+ let cancelled = false;
93
+ document.fonts.ready.then(() => {
94
+ if (cancelled) return;
95
+ setRaster(rasterize(text, fontFamily, fontWeight, rows));
96
+ continueRender(handle);
97
+ });
98
+ return () => {
99
+ cancelled = true;
100
+ };
101
+ }, [text, fontFamily, fontWeight, rows, handle]);
102
+
103
+ if (!raster) return null;
104
+
105
+ const square = cell - gap;
106
+ return (
107
+ <svg
108
+ width={raster.width * cell}
109
+ height={raster.height * cell}
110
+ // Two shadows, not one: a tight dark halo gives every square its own
111
+ // edge against light footage, and the soft one keeps the block grounded.
112
+ // A single wide blur just tints the area and the dots still disappear.
113
+ style={{
114
+ filter:
115
+ "drop-shadow(0 0 3px rgba(0,0,0,0.95)) drop-shadow(0 4px 16px rgba(0,0,0,0.6))",
116
+ }}
117
+ >
118
+ {raster.grid.map((row, y) =>
119
+ row.map((on, x) =>
120
+ on ? (
121
+ <rect
122
+ key={`${x}-${y}`}
123
+ x={x * cell}
124
+ y={y * cell}
125
+ width={square}
126
+ height={square}
127
+ fill={color}
128
+ />
129
+ ) : null,
130
+ ),
131
+ )}
132
+ </svg>
133
+ );
134
+ };