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,107 @@
1
+ import React from "react";
2
+ import { AbsoluteFill } from "remotion";
3
+ import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
4
+ import { loadFont as loadRobotoMono } from "@remotion/google-fonts/RobotoMono";
5
+ import {
6
+ CodeCardProps,
7
+ CARD_WIDTH,
8
+ cardHeight,
9
+ PAD,
10
+ LABEL_SIZE,
11
+ CODE_SIZE,
12
+ LINE_HEIGHT,
13
+ } from "./codeCardTypes";
14
+
15
+ // Söhne / Söhne Mono stand-ins — the chat-app code block the original cards
16
+ // were screenshotted from uses a grotesk label over a neutral mono.
17
+ const { fontFamily: uiFamily } = loadInter("normal", {
18
+ weights: ["500", "600"],
19
+ subsets: ["latin"],
20
+ });
21
+ const { fontFamily: monoFamily } = loadRobotoMono("normal", {
22
+ weights: ["400"],
23
+ subsets: ["latin"],
24
+ });
25
+
26
+ const EMOJI_STACK =
27
+ '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif';
28
+
29
+ // Sampled off the original screenshots: body #212121, label pure white,
30
+ // One Dark-family syntax colors (see codeCardTypes.ts for the theme name).
31
+ const BG = "#212121";
32
+ const LABEL = "#ffffff";
33
+
34
+ const RADIUS_PCT = 0.045;
35
+
36
+ export const CodeCard: React.FC<CodeCardProps> = ({
37
+ showLanguage = true,
38
+ background,
39
+ radiusPct,
40
+ language,
41
+ lines,
42
+ codeScale = 1,
43
+ }) => {
44
+ const codeSize = CODE_SIZE * codeScale;
45
+ return (
46
+ <AbsoluteFill style={{ backgroundColor: "transparent" }}>
47
+ <div
48
+ style={{
49
+ width: CARD_WIDTH,
50
+ height: cardHeight(lines.length, codeScale),
51
+ backgroundColor: background ?? BG,
52
+ borderRadius: Math.round(CARD_WIDTH * (radiusPct ?? RADIUS_PCT)),
53
+ padding: PAD,
54
+ boxSizing: "border-box",
55
+ display: "flex",
56
+ flexDirection: "column",
57
+ }}
58
+ >
59
+ {/* The language label is the whole header now. `space-between` only
60
+ existed to push a decorative copy glyph to the right edge — it was
61
+ a screenshot artefact of the chat app these cards imitate, and it
62
+ invites a tap that does nothing. */}
63
+ {showLanguage ? (
64
+ <div
65
+ style={{
66
+ display: "flex",
67
+ alignItems: "center",
68
+ }}
69
+ >
70
+ <span
71
+ style={{
72
+ fontFamily: uiFamily,
73
+ fontWeight: 500,
74
+ fontSize: LABEL_SIZE,
75
+ color: LABEL,
76
+ lineHeight: 1,
77
+ }}
78
+ >
79
+ {language}
80
+ </span>
81
+ </div>
82
+ ) : null}
83
+
84
+ <pre
85
+ style={{
86
+ margin: 0,
87
+ marginTop: PAD * 0.95,
88
+ fontFamily: `${monoFamily}, ${EMOJI_STACK}`,
89
+ fontSize: codeSize,
90
+ lineHeight: LINE_HEIGHT,
91
+ whiteSpace: "pre",
92
+ }}
93
+ >
94
+ {lines.map((tokens, i) => (
95
+ <div key={i} style={{ minHeight: codeSize * LINE_HEIGHT }}>
96
+ {tokens.map((t, j) => (
97
+ <span key={j} style={{ color: t.color }}>
98
+ {t.content}
99
+ </span>
100
+ ))}
101
+ </div>
102
+ ))}
103
+ </pre>
104
+ </div>
105
+ </AbsoluteFill>
106
+ );
107
+ };
@@ -0,0 +1,106 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, Img, Sequence, staticFile, useVideoConfig } from "remotion";
3
+ import { Video } from "@remotion/media";
4
+ import { Title } from "../../overlay/Title";
5
+ import { DifficultyMeter } from "../../overlay/DifficultyMeter";
6
+ import { SAFE_AREA, SAFE_CENTER_Y, CARD_MAX_HEIGHT } from "../../overlay/safeArea";
7
+ import { CARD_WIDTH_PCT } from "../../overlay/cardLayout";
8
+ import type { WhatPrintsProps } from "./types";
9
+
10
+ export const WhatPrints: React.FC<WhatPrintsProps> = ({
11
+ broll,
12
+ card,
13
+ titleLine1,
14
+ titleLine2,
15
+ difficulty,
16
+ revealAtSec,
17
+ trimBeforeSec,
18
+ meterX,
19
+ meterY,
20
+ hideOverlays,
21
+ }) => {
22
+ const { fps, width } = useVideoConfig();
23
+
24
+ return (
25
+ <AbsoluteFill style={{ background: "black" }}>
26
+ <Video
27
+ src={staticFile(broll)}
28
+ muted
29
+ loop
30
+ trimBefore={Math.round(trimBeforeSec * fps)}
31
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
32
+ />
33
+
34
+ {/*
35
+ Scrim under the title. The dot-matrix line is mostly gaps, so over
36
+ bright footage (a lit desk, a white keyboard) the squares stop reading
37
+ as letters — a soft gradient buys back contrast without touching the
38
+ typography. Kept out of the `hideOverlays` branch on purpose: it's the
39
+ background the title actually sits on, so the legibility check has to
40
+ measure against it.
41
+ */}
42
+ <AbsoluteFill
43
+ style={{
44
+ background:
45
+ "linear-gradient(to bottom, rgba(0,0,0,0.62) 0%, rgba(0,0,0,0.45) 22%, rgba(0,0,0,0) 40%)",
46
+ }}
47
+ />
48
+
49
+ {!hideOverlays && (
50
+ <>
51
+ {/* Title — upper area */}
52
+ <AbsoluteFill
53
+ style={{
54
+ alignItems: "center",
55
+ justifyContent: "flex-start",
56
+ // Clear of Instagram's status bar + Reels header, and of the 3:4
57
+ // crop the profile grid takes off the top.
58
+ paddingTop: SAFE_AREA.top + 12,
59
+ }}
60
+ >
61
+ <Title line1={titleLine1} line2={titleLine2} />
62
+ </AbsoluteFill>
63
+
64
+ {/*
65
+ Centred on the *uncovered* band, not the frame. Dead-centring puts
66
+ the card's lower edge under the like/comment rail, which starts
67
+ around y=1080 — visually centred, partly behind a button.
68
+ */}
69
+ <AbsoluteFill
70
+ style={{
71
+ alignItems: "center",
72
+ justifyContent: "center",
73
+ height: SAFE_CENTER_Y * 2,
74
+ }}
75
+ >
76
+ <Img
77
+ src={staticFile(card)}
78
+ style={{
79
+ // Bounded on both axes so a tall card shrinks to fit the
80
+ // uncovered band rather than growing into the action rail.
81
+ width: "auto",
82
+ height: "auto",
83
+ maxWidth: width * CARD_WIDTH_PCT,
84
+ maxHeight: CARD_MAX_HEIGHT,
85
+ filter: "drop-shadow(0 10px 40px rgba(0,0,0,0.45))",
86
+ }}
87
+ />
88
+ </AbsoluteFill>
89
+
90
+ {/* Difficulty meter — timed reveal, placed by scripts/place-overlay.ts */}
91
+ <Sequence from={Math.round(revealAtSec * fps)} layout="none">
92
+ <div
93
+ style={{
94
+ position: "absolute",
95
+ left: meterX ?? 90,
96
+ top: meterY ?? 1400,
97
+ }}
98
+ >
99
+ <DifficultyMeter difficulty={difficulty} />
100
+ </div>
101
+ </Sequence>
102
+ </>
103
+ )}
104
+ </AbsoluteFill>
105
+ );
106
+ };
@@ -0,0 +1,105 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Geometry + theme shared by the CodeCard composition and scripts/make-card.ts.
5
+ * Everything scales off CARD_WIDTH, so bumping it just renders crisper cards at
6
+ * the same proportions as the original screenshots (1083x480 for 5 lines).
7
+ */
8
+ export const CARD_WIDTH = 1300;
9
+
10
+ export const PAD = Math.round(CARD_WIDTH * 0.044);
11
+ export const LABEL_SIZE = Math.round(CARD_WIDTH * 0.034);
12
+ export const CODE_SIZE = Math.round(CARD_WIDTH * 0.033);
13
+ export const LINE_HEIGHT = 1.36;
14
+
15
+ /** Roboto Mono advance width, as a fraction of font size. */
16
+ const CHAR_RATIO = 0.6;
17
+
18
+ /** How many characters fit on one line at full size. */
19
+ export const MAX_COLUMNS = Math.floor(
20
+ (CARD_WIDTH - PAD * 2) / (CODE_SIZE * CHAR_RATIO),
21
+ );
22
+
23
+ /**
24
+ * Shrink factor for snippets whose longest line overruns the card. Cards stay a
25
+ * fixed width so the video overlay lands consistently, so the type gives way.
26
+ */
27
+ export const fitScale = (widestLine: number) =>
28
+ widestLine <= MAX_COLUMNS ? 1 : MAX_COLUMNS / widestLine;
29
+
30
+ /** Height needed for a snippet with `lineCount` code lines (blank lines count). */
31
+ export const cardHeight = (lineCount: number, scale = 1) =>
32
+ Math.round(
33
+ PAD * 2 +
34
+ LABEL_SIZE +
35
+ PAD * 0.95 +
36
+ lineCount * CODE_SIZE * scale * LINE_HEIGHT,
37
+ );
38
+
39
+ /**
40
+ * Sampled off the original cards: a deliberately quiet One Dark variant.
41
+ * Only keywords (purple), strings (green), numbers/constants (orange) and
42
+ * comments get color — function calls, operators and punctuation stay plain,
43
+ * which is what makes the cards read as prose instead of a rainbow.
44
+ */
45
+ export const CARD_THEME = {
46
+ name: "what-prints",
47
+ type: "dark" as const,
48
+ colors: { "editor.background": "#212121", "editor.foreground": "#c5c8d4" },
49
+ settings: [
50
+ { settings: { foreground: "#c5c8d4", background: "#212121" } },
51
+ {
52
+ scope: ["comment", "punctuation.definition.comment"],
53
+ settings: { foreground: "#7c828f", fontStyle: "italic" },
54
+ },
55
+ {
56
+ scope: [
57
+ "keyword",
58
+ "storage",
59
+ "storage.type",
60
+ "keyword.control",
61
+ "keyword.operator.new",
62
+ "keyword.operator.expression",
63
+ "keyword.operator.logical",
64
+ "variable.language",
65
+ ],
66
+ settings: { foreground: "#c678dd" },
67
+ },
68
+ {
69
+ // Plain again: `=`, `*`, `+` read as punctuation on the originals.
70
+ // Listed after the keyword rule and more specific, so it wins.
71
+ scope: ["keyword.operator", "punctuation"],
72
+ settings: { foreground: "#c5c8d4" },
73
+ },
74
+ {
75
+ scope: ["string", "punctuation.definition.string", "string.quoted"],
76
+ settings: { foreground: "#98c379" },
77
+ },
78
+ {
79
+ scope: [
80
+ "constant.numeric",
81
+ "constant.language",
82
+ "constant.character",
83
+ "support.constant",
84
+ ],
85
+ settings: { foreground: "#d19a66" },
86
+ },
87
+ ],
88
+ };
89
+
90
+ const tokenSchema = z.object({
91
+ content: z.string(),
92
+ color: z.string(),
93
+ });
94
+
95
+ export const codeCardSchema = z.object({
96
+ /** Look overrides — see src/overlay/look.ts. Absent = the shipped design. */
97
+ showLanguage: z.boolean().optional(),
98
+ background: z.string().optional(),
99
+ radiusPct: z.number().optional(),
100
+ language: z.string(), // the label drawn top-left, e.g. "Python"
101
+ lines: z.array(z.array(tokenSchema)), // pre-highlighted by scripts/make-card.ts
102
+ codeScale: z.number().default(1), // < 1 when a long line has to be shrunk
103
+ });
104
+
105
+ export type CodeCardProps = z.infer<typeof codeCardSchema>;
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+
3
+ export const whatPrintsSchema = z.object({
4
+ broll: z.string(), // path under public/, e.g. "assets/b-roll/IMG_4858.MOV"
5
+ card: z.string(), // processed card PNG, e.g. "assets/cards/IMG_4030.png"
6
+ titleLine1: z.string(),
7
+ titleLine2: z.string(),
8
+ difficulty: z.enum(["easy", "medium", "hard"]),
9
+ durationSec: z.number(), // total video length
10
+ revealAtSec: z.number(), // when the difficulty pill pops in
11
+ trimBeforeSec: z.number(), // skip this much of the b-roll clip
12
+ // Difficulty meter position, chosen by scripts/place-overlay.ts against the
13
+ // actual footage so it never lands on Erin or a busy part of the frame.
14
+ meterX: z.number().optional(),
15
+ meterY: z.number().optional(),
16
+ // Renders the background (b-roll + scrim) without the title, card or meter.
17
+ // scripts/check-legibility.ts diffs this against the full frame to work out
18
+ // which pixels are overlay and what each one is sitting on top of.
19
+ hideOverlays: z.boolean().optional(),
20
+ });
21
+
22
+ export type WhatPrintsProps = z.infer<typeof whatPrintsSchema>;
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2018",
4
+ "module": "Preserve",
5
+ "moduleResolution": "Bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "lib": ["es2015"],
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "noUnusedLocals": true,
14
+ "resolveJsonModule": true
15
+ },
16
+ "exclude": ["remotion.config.ts"]
17
+ }
package/worker/cli.mjs ADDED
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ // overlay-factory-worker entrypoint — the Overlay Factory's half of "connect
3
+ // your computer". Same verbs as the other three workers, so `npx goosetools
4
+ // install` can run all four in a row:
5
+ //
6
+ // install save token + register a login service (worker runs forever)
7
+ // update pull the newest code into the background copy + restart
8
+ // status what's installed, whether it's running, recent log lines
9
+ // doctor check everything a render needs, and say what's missing
10
+ // uninstall remove the service (keeps your puzzles, looks and cards)
11
+ // run run the worker loop in this window (default)
12
+ //
13
+ // The worker itself is TypeScript — the compositions it renders are TSX, and
14
+ // the render pipeline shares their types. It runs through tsx rather than a
15
+ // build step, which is also how it runs from a checkout.
16
+
17
+ import { execFileSync, execSync, spawnSync } from "node:child_process";
18
+ import { existsSync } from "node:fs";
19
+ import { createRequire } from "node:module";
20
+ import { dirname, join, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
24
+ const WORKER_TS = join(ROOT, "scripts", "overlay-worker.ts");
25
+ const require = createRequire(join(ROOT, "package.json"));
26
+
27
+ const command = [
28
+ "install",
29
+ "update",
30
+ "status",
31
+ "uninstall",
32
+ "run",
33
+ "doctor",
34
+ ].includes(process.argv[2])
35
+ ? process.argv[2]
36
+ : "run";
37
+
38
+ function flag(name) {
39
+ const i = process.argv.indexOf(name);
40
+ return i !== -1 ? process.argv[i + 1] : undefined;
41
+ }
42
+
43
+ function has(cmd) {
44
+ try {
45
+ execSync(`command -v ${cmd}`, { stdio: "ignore", shell: true });
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ function tsxCli() {
53
+ const cli = join(dirname(require.resolve("tsx/package.json")), "dist", "cli.mjs");
54
+ if (!existsSync(cli)) {
55
+ throw new Error(`tsx not found at ${cli} — reinstall the worker package.`);
56
+ }
57
+ return cli;
58
+ }
59
+
60
+ // Remotion renders in a headless Chrome it downloads itself. Doing it here
61
+ // means the first reel doesn't stall for several minutes on a download that
62
+ // looks like a hung job — and if it fails, this is a warning, not a wall:
63
+ // Remotion will fetch it again at render time.
64
+ function ensureRenderBrowser({ fatal = false } = {}) {
65
+ try {
66
+ console.log("Checking the render browser (first run can take a few minutes)…");
67
+ execFileSync("npx", ["--yes", "remotion", "browser", "ensure"], {
68
+ cwd: ROOT,
69
+ stdio: ["ignore", "pipe", "pipe"],
70
+ shell: process.platform === "win32",
71
+ });
72
+ return true;
73
+ } catch (err) {
74
+ const detail = err?.stderr?.toString().trim();
75
+ if (detail) console.error(detail);
76
+ console[fatal ? "error" : "warn"](
77
+ "Couldn't set up the render browser — Remotion will try again on the first reel.",
78
+ );
79
+ if (fatal) process.exit(1);
80
+ return false;
81
+ }
82
+ }
83
+
84
+ // These only inspect or tear down what's already there, so they skip the
85
+ // preflight below — `status` in particular has to work precisely when
86
+ // something IS wrong.
87
+ if (command === "uninstall") {
88
+ const { uninstall } = await import("./service.mjs");
89
+ uninstall();
90
+ process.exit(0);
91
+ }
92
+
93
+ if (command === "status") {
94
+ const { status } = await import("./service.mjs");
95
+ status();
96
+ process.exit(0);
97
+ }
98
+
99
+ if (command === "doctor") {
100
+ const { hasFfmpeg } = await import("./service.mjs");
101
+ const checks = [
102
+ ["macOS", process.platform === "darwin", "reels render with Apple's videotoolbox encoder"],
103
+ ["Node 20+", Number(process.versions.node.split(".")[0]) >= 20, "install a newer Node"],
104
+ ["ffmpeg + ffprobe", hasFfmpeg(), "brew install ffmpeg"],
105
+ ["Claude Code", has("claude"), "npm install -g @anthropic-ai/claude-code"],
106
+ ];
107
+ for (const [label, pass, hint] of checks) {
108
+ console.log(`${pass ? "✓" : "✗"} ${label}${pass ? "" : ` — ${hint}`}`);
109
+ }
110
+ process.exit(checks.every(([, pass]) => pass) ? 0 : 1);
111
+ }
112
+
113
+ if (command === "run") {
114
+ // Hand the process over to the worker loop. stdio is inherited so launchd's
115
+ // log files get the worker's own output, and the exit code is passed through
116
+ // so KeepAlive restarts on a crash the way it always has.
117
+ const result = spawnSync(process.execPath, [tsxCli(), WORKER_TS], {
118
+ cwd: ROOT,
119
+ stdio: "inherit",
120
+ });
121
+ process.exit(result.status ?? 1);
122
+ }
123
+
124
+ // ── install / update ────────────────────────────────────────────────────────
125
+
126
+ // Claude Code writes the puzzles, series and looks this worker renders. The
127
+ // other agent CLIs are picked per-account in Setup and checked per job; this
128
+ // is the one that has to be here before "connected" means anything.
129
+ if (!has("claude")) {
130
+ console.error(
131
+ "\nClaude Code isn't installed yet (it writes the puzzles and looks).\n" +
132
+ "Install it with: npm install -g @anthropic-ai/claude-code\n" +
133
+ "Then run: claude (once, to sign in — type /exit to leave)\n" +
134
+ "Then run this command again.\n",
135
+ );
136
+ process.exit(1);
137
+ }
138
+
139
+ ensureRenderBrowser();
140
+
141
+ if (command === "install") {
142
+ const { install } = await import("./service.mjs");
143
+ install({
144
+ url: (flag("--url") ?? "https://goosetools.com").replace(/\/$/, ""),
145
+ token: flag("--token"),
146
+ root: ROOT,
147
+ });
148
+ } else {
149
+ const { update } = await import("./service.mjs");
150
+ update({ root: ROOT });
151
+ }