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