@graphlearning/shell 0.1.0 → 0.3.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.
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env node
2
+ // record-reels.mjs — [STEP 3, PORTRAIT REELS] one course → NINE standalone 1080×1920 MP4s.
3
+ //
4
+ // node scripts/record-reels.mjs <course> [--force] [--only <id[,id]>]
5
+ //
6
+ // The vertical-video sibling of record-course.mjs. Deliberately SELF-CONTAINED (no shared module) so
7
+ // the two recorders can diverge freely. What's different from the 4K recorder:
8
+ // • PORTRAIT 1080×1920 (9:16, the standard Reels/Shorts frame). At this viewport the app's portrait
9
+ // CSS kicks in: the slide becomes an off-canvas drawer (hidden under ?capture=1), so the recorded
10
+ // frame is a SCENE-ONLY full-bleed 9:16 — the narration carries the words.
11
+ // • NINE INDEPENDENT files (out/reels/<course>-<id>.mp4), one per section — NOT concatenated. Each
12
+ // reel is its own upload, so there is NO separator bell and NO lead-in sting.
13
+ // • Otherwise the capture contract is identical: navigate the hash → wait for the painted,
14
+ // fitView-settled frame → screencast for the wav's duration (ffprobe) + a short tail → mux.
15
+ //
16
+ // TRUE portrait pixels: the layout is fluid, so we set the puppeteer VIEWPORT to 1080×1920 directly
17
+ // and page.screencast() records at exactly that CSS size. Audio is read straight off disk from
18
+ // public/audio/<course>/<id>.wav; a missing clip falls back to 3s silence so the run never hangs.
19
+ //
20
+ // Prerequisites: ffmpeg + ffprobe on PATH (Homebrew ffmpeg preferred — libx264 + gradfun deband).
21
+
22
+ import { execFile, spawn } from 'node:child_process'
23
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
24
+ import { createHash } from 'node:crypto'
25
+ import { promisify } from 'node:util'
26
+ import { join, resolve } from 'node:path'
27
+
28
+ const run = promisify(execFile)
29
+ import { loadPeer, repoDir, dataDir } from './_paths.mjs'
30
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
31
+ const sha = (data) => createHash('sha256').update(data).digest('hex').slice(0, 16)
32
+ const pad2 = (n) => String(n).padStart(2, '0')
33
+ const readJson = (f) => { try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null } }
34
+
35
+ // ---- portrait reel frame ----------------------------------------------------------------
36
+ const CW = process.env.WIDTH ? +process.env.WIDTH : 1080
37
+ const CH = process.env.HEIGHT ? +process.env.HEIGHT : 1920
38
+ const FPS = process.env.FPS ? +process.env.FPS : 30
39
+ // A short held tail so the reel doesn't cut on the last syllable.
40
+ const TAIL_MS = process.env.TAIL_MS ? +process.env.TAIL_MS : 500
41
+
42
+ const FFMPEG =
43
+ process.env.FFMPEG ??
44
+ ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'].find(existsSync) ??
45
+ 'ffmpeg'
46
+ const HAS_X264 = FFMPEG !== 'ffmpeg'
47
+ const VIDEO_CODEC = process.env.VIDEO_CODEC ?? (HAS_X264 ? 'libx264' : 'h264_videotoolbox')
48
+ const IS_X26X = /^libx26[45]$/.test(VIDEO_CODEC)
49
+ const CRF = process.env.VIDEO_CRF ?? '18'
50
+ const PRESET = process.env.VIDEO_PRESET ?? 'slow'
51
+ const BITRATE = process.env.VIDEO_BITRATE ?? '16M' // 1080×1920 ≈ 1080p pixel budget
52
+ const ENCODE_SIG = IS_X26X ? `${VIDEO_CODEC}:crf${CRF}:${PRESET}` : `${VIDEO_CODEC}:b${BITRATE}`
53
+
54
+ // ---- ffmpeg helpers ---------------------------------------------------------------------
55
+ async function ffprobeDuration(file) {
56
+ const { stdout } = await run('ffprobe', [
57
+ '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file,
58
+ ])
59
+ return parseFloat(stdout.trim())
60
+ }
61
+
62
+ // Mux one reel's webm + clip → a standalone MP4. Same crisp encode as the 4K recorder (gradfun
63
+ // deband, yuv420p, faststart) but this is a FINAL file, not a concat segment, so it needs no uniform
64
+ // timescale. `total` (clip + tail) bounds both streams; apad extends the clip with silence to fill.
65
+ async function encodeReel(webm, clip, total, outMp4) {
66
+ const quality = IS_X26X ? ['-preset', PRESET, '-crf', CRF] : ['-b:v', BITRATE]
67
+ await run(FFMPEG, [
68
+ '-y', '-i', webm, '-i', clip,
69
+ '-map', '0:v:0', '-map', '1:a:0',
70
+ '-vf', 'gradfun=strength=0.9:radius=16',
71
+ '-r', String(FPS), '-vsync', 'cfr',
72
+ '-c:v', VIDEO_CODEC, ...quality, '-pix_fmt', 'yuv420p',
73
+ '-af', 'apad', '-t', total.toFixed(3),
74
+ '-c:a', 'aac', '-b:a', '192k', '-ar', '44100', '-ac', '1',
75
+ '-movflags', '+faststart',
76
+ outMp4,
77
+ ])
78
+ }
79
+
80
+ // ---- the app dev server -----------------------------------------------------------------
81
+ async function startDevServer() {
82
+ console.log(`Starting dev server: ${repoDir} …`)
83
+ const child = spawn('npm', ['run', 'dev'], { cwd: repoDir, env: process.env })
84
+ const url = await new Promise((res, rej) => {
85
+ const to = setTimeout(() => rej(new Error('dev server did not print a URL within 60s')), 60000)
86
+ const onData = (buf) => {
87
+ const m = String(buf).match(/https?:\/\/localhost:\d+\/?/)
88
+ if (m) { clearTimeout(to); child.stdout.off('data', onData); res(m[0].replace(/\/?$/, '/')) }
89
+ }
90
+ child.stdout.on('data', onData)
91
+ child.stderr.on('data', (b) => process.env.DEBUG && process.stderr.write(b))
92
+ child.on('exit', (code) => rej(new Error(`dev server exited early (code ${code})`)))
93
+ })
94
+ for (let i = 0; i < 40; i++) {
95
+ try { if ((await fetch(url)).ok) break } catch { /* not up yet */ }
96
+ await sleep(250)
97
+ }
98
+ console.log(` dev server at ${url}`)
99
+ return { child, url }
100
+ }
101
+
102
+ // Navigate to a section and wait for its scene to be painted AND fitView-settled. A fresh goto per
103
+ // reel forces a clean react-flow remount (it keys on scene id), so no stale prior scene is in frame.
104
+ async function gotoSection(page, appBase, slug) {
105
+ await page.goto(`${appBase}?capture=1#/${slug}`, { waitUntil: 'networkidle2' })
106
+ await page.waitForSelector('.react-flow__node', { timeout: 15000 })
107
+ await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready })
108
+ await sleep(700) // fitView (instant) + ResizeObserver re-fit + edge-pulse settle
109
+ }
110
+
111
+ // CDP screencast only emits a frame on a VISUAL CHANGE, so an otherwise-static reel records an EMPTY
112
+ // webm and the mux fails. A 1px, ~1%-opacity speck nudged every animation frame keeps frames flowing;
113
+ // it is quantized away by x264. Removed on stop.
114
+ async function startKeepalive(page) {
115
+ await page.evaluate(() => {
116
+ const d = document.createElement('div')
117
+ d.id = '__cap_keepalive'
118
+ d.style.cssText =
119
+ 'position:fixed;left:0;top:0;width:1px;height:1px;background:#888;opacity:0.01;' +
120
+ 'pointer-events:none;z-index:2147483647;will-change:transform'
121
+ document.body.appendChild(d)
122
+ let x = 0
123
+ const loop = () => {
124
+ x = (x + 3) % 30
125
+ d.style.transform = `translate3d(${x}px,0,0)`
126
+ window.__cap_raf = requestAnimationFrame(loop)
127
+ }
128
+ loop()
129
+ })
130
+ }
131
+ async function stopKeepalive(page) {
132
+ await page.evaluate(() => {
133
+ if (window.__cap_raf) cancelAnimationFrame(window.__cap_raf)
134
+ document.getElementById('__cap_keepalive')?.remove()
135
+ })
136
+ }
137
+
138
+ // ---- record -----------------------------------------------------------------------------
139
+ async function recordReels(course, { force = false, only = [] } = {}) {
140
+ const tmp = join(dataDir, '.tmp', `${course}-reels`)
141
+ const outDir = join(dataDir, 'out', 'reels')
142
+ for (const d of [tmp, outDir]) mkdirSync(d, { recursive: true })
143
+
144
+ let server = null
145
+ const base = process.env.APP_URL ? process.env.APP_URL.replace(/\/?$/, '/') : null
146
+ const appBase = base ?? (server = await startDevServer(), server.url)
147
+
148
+ const puppeteer = (await loadPeer('puppeteer')).default
149
+ let browser
150
+ const made = []
151
+ try {
152
+ browser = await puppeteer.launch({
153
+ headless: true,
154
+ defaultViewport: { width: CW, height: CH, deviceScaleFactor: 1 },
155
+ args: [`--window-size=${CW},${CH}`],
156
+ })
157
+ const page = await browser.newPage()
158
+ await page.goto(`${appBase}?capture=1#/${course}`, { waitUntil: 'networkidle2' })
159
+
160
+ await page.waitForFunction(() => !!window.__scene, { timeout: 20000 })
161
+ const plan = await page.evaluate(() => window.__scene.plan())
162
+ if (!plan?.length) throw new Error(`course "${course}" has no sections (bad id?)`)
163
+ console.log(`Course ${course}: ${plan.length} reels @ ${CW}×${CH}\n`)
164
+
165
+ let n = 0
166
+ for (const sec of plan) {
167
+ n++
168
+ const tag = `${course}-${sec.id}`
169
+ // Each reel is its own file, so --only truly RESTRICTS the set: skip any section not listed.
170
+ if (only.length && !only.some((t) => sec.id.includes(t) || tag.includes(t))) continue
171
+ const outMp4 = join(outDir, `${tag}.mp4`)
172
+ const sidecar = join(tmp, `${pad2(n)}-${sec.id}.json`)
173
+ const clip = join(tmp, `${pad2(n)}-${sec.id}.wav`)
174
+
175
+ // Narration wav straight off disk. Missing → 3s silence so the run never hangs.
176
+ const wav = resolve(repoDir, 'public', 'audio', sec.course, `${sec.id}.wav`)
177
+ let dur, audioHash
178
+ if (existsSync(wav)) {
179
+ const buf = readFileSync(wav)
180
+ writeFileSync(clip, buf)
181
+ audioHash = sha(buf)
182
+ dur = await ffprobeDuration(clip)
183
+ } else {
184
+ await run(FFMPEG, ['-y', '-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono', '-t', '3', clip])
185
+ audioHash = 'silence3'
186
+ dur = 3
187
+ console.warn(` §${n} ${sec.id}: no audio → 3s silence`)
188
+ }
189
+
190
+ // Incremental reuse: re-record iff missing/changed (or --force / --only match).
191
+ const fp = sha(JSON.stringify({ v: 1, audioHash, w: CW, h: CH, fps: FPS, tail: TAIL_MS, enc: ENCODE_SIG }))
192
+ if (!(force || !existsSync(outMp4) || readJson(sidecar)?.fp !== fp)) {
193
+ console.log(` §${n} ${sec.id} reuse`)
194
+ made.push(outMp4)
195
+ continue
196
+ }
197
+
198
+ const total = dur + TAIL_MS / 1000
199
+ await gotoSection(page, appBase, sec.slug)
200
+
201
+ const webm = join(tmp, `${pad2(n)}-${sec.id}.webm`)
202
+ const recorder = await page.screencast({ path: webm })
203
+ await startKeepalive(page)
204
+ console.log(` §${n} ${sec.id} ▶ record (${dur.toFixed(1)}s)`)
205
+ await sleep(Math.round(total * 1000))
206
+ await recorder.stop()
207
+ await stopKeepalive(page)
208
+
209
+ await encodeReel(webm, clip, total, outMp4)
210
+ writeFileSync(sidecar, JSON.stringify({ fp, builtAt: new Date().toISOString() }, null, 2))
211
+ made.push(outMp4)
212
+ console.log(` §${n} ${sec.id} ✓ reels/${tag}.mp4`)
213
+ }
214
+ } finally {
215
+ if (browser) await browser.close()
216
+ if (server) server.child.kill('SIGTERM')
217
+ }
218
+
219
+ console.log(`\n✅ ${made.length} reel(s) → scripts/out/reels/`)
220
+ return made
221
+ }
222
+
223
+ // ---- CLI --------------------------------------------------------------------------------
224
+ function parse(argv) {
225
+ const pos = []
226
+ const only = []
227
+ let force = false
228
+ for (let i = 0; i < argv.length; i++) {
229
+ const a = argv[i]
230
+ if (a === '--force') force = true
231
+ else if (a === '--only') only.push(...(argv[++i] ?? '').split(',').filter(Boolean))
232
+ else if (a.startsWith('--only=')) only.push(...a.slice(7).split(',').filter(Boolean))
233
+ else pos.push(a)
234
+ }
235
+ return { pos, only, force }
236
+ }
237
+
238
+ const { pos, only, force } = parse(process.argv.slice(2))
239
+ const [course] = pos
240
+ if (!course) {
241
+ console.error('usage: node scripts/record-reels.mjs <course> [--force] [--only <id[,id]>]')
242
+ process.exit(2)
243
+ }
244
+ recordReels(course, { force, only }).catch((e) => {
245
+ console.error('\n✗', e.message)
246
+ process.exit(1)
247
+ })
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // Quick 4K visual check — one PNG per section at 3840×2160, no screencast/audio/ffmpeg.
3
+ // Mirrors record-course.mjs's viewport + capture route + fit-wait, so the framing matches the video.
4
+ // node scripts/shots-4k.mjs [course] [--only id[,id]]
5
+ import { spawn } from 'node:child_process'
6
+ import { mkdirSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+
9
+ import { loadPeer, repoDir, dataDir } from './_paths.mjs'
10
+ const outDir = join(dataDir, 'out', 'shots-4k')
11
+ mkdirSync(outDir, { recursive: true })
12
+ const CW = +(process.env.WIDTH ?? 3840), CH = +(process.env.HEIGHT ?? 2160)
13
+ const course = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : 'foundations'
14
+ const onlyArg = process.argv.indexOf('--only')
15
+ const only = onlyArg !== -1 ? (process.argv[onlyArg + 1] ?? '').split(',').filter(Boolean) : []
16
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
17
+
18
+ // Reuse an already-running server (e.g. the frozen `npm run capture` snapshot on :5181) via APP_URL so
19
+ // you can keep editing source meanwhile; otherwise spawn a throwaway dev server.
20
+ let child = null, url = process.env.APP_URL ? process.env.APP_URL.replace(/\/?$/, '/') : null
21
+ if (!url) {
22
+ child = spawn('npm', ['run', 'dev'], { cwd: repoDir, env: process.env })
23
+ url = await new Promise((res, rej) => {
24
+ const to = setTimeout(() => rej(new Error('no dev url in 60s')), 60000)
25
+ child.stdout.on('data', (b) => { const m = String(b).match(/https?:\/\/localhost:\d+\/?/); if (m) { clearTimeout(to); res(m[0].replace(/\/?$/, '/')) } })
26
+ child.on('exit', (c) => rej(new Error(`dev exited ${c}`)))
27
+ })
28
+ }
29
+ for (let i = 0; i < 40; i++) { try { if ((await fetch(url)).ok) break } catch {} await sleep(250) }
30
+ console.log(`server ${url} — shooting ${CW}×${CH}`)
31
+
32
+ const puppeteer = (await loadPeer('puppeteer')).default
33
+ const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] })
34
+ const page = await browser.newPage()
35
+ await page.setViewport({ width: CW, height: CH, deviceScaleFactor: 1 })
36
+
37
+ await page.goto(`${url}?capture=1#/`, { waitUntil: 'networkidle2' })
38
+ await page.waitForFunction(() => !!window.__scene, { timeout: 20000 })
39
+ let plan = await page.evaluate(() => window.__scene.plan())
40
+ plan = plan.filter((p) => p.course === course && (only.length === 0 || only.includes(p.id)))
41
+
42
+ let n = 0
43
+ for (const p of plan) {
44
+ await page.goto(`${url}?capture=1#/${p.slug}`, { waitUntil: 'networkidle2' })
45
+ await page.waitForSelector('.react-flow__node', { timeout: 15000 })
46
+ await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready })
47
+ await sleep(900) // fitView + ResizeObserver re-fit + edge-pulse settle (matches recorder)
48
+ const file = join(outDir, `${String(++n).padStart(2, '0')}-${p.id}.png`)
49
+ await page.screenshot({ path: file })
50
+ console.log(` ${p.slug} → ${file}`)
51
+ }
52
+ await browser.close(); child?.kill('SIGTERM')
53
+ console.log(`\n✅ ${n} shots in ${outDir}`)
54
+ process.exit(0)
@@ -0,0 +1,52 @@
1
+ <!-- thumb-template.html — the branded thumbnail layout, filled by thumb.mjs.
2
+ Left = the course scene (screenshot of the .scene-area, no slide); right = an HTML/CSS panel
3
+ driven by the concept + course (kicker + title) with the GraphL wordmark (+ optional logo mark).
4
+ Sizes are in vw/vh so the layout is resolution-independent: thumb.mjs injects this into the app's
5
+ own 4K viewport (fonts inherited), screenshots it, then downscales to 1280×720.
6
+ thumb.mjs substitutes the double-brace tokens below (SCENE, KICKER, NUMBER, TITLE, LOGO, PANEL_BG). -->
7
+ <style>
8
+ html, body { margin: 0; padding: 0; height: 100%; background: #1e2127; }
9
+ .thumb {
10
+ display: flex;
11
+ width: 100vw;
12
+ height: 100vh;
13
+ overflow: hidden;
14
+ background: #1e2127; /* letterbox surround (Zed-slate theme) */
15
+ font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
16
+ }
17
+ /* LEFT — the course scene, as it renders. cover + left anchor lets the diagram bleed off the
18
+ left edge (poster look) while filling the panel. */
19
+ .thumb__scene { flex: 0 0 58%; height: 100%; position: relative; background: #1e2127; }
20
+ .thumb__scene img { width: 100%; height: 100%; object-fit: cover; object-position: left center; display: block; }
21
+ /* A soft seam so the scene reads as continuing under the panel rather than a hard cut. */
22
+ .thumb__scene::after {
23
+ content: ''; position: absolute; top: 0; right: 0; width: 8%; height: 100%;
24
+ background: linear-gradient(90deg, rgba(30,33,39,0), rgba(30,33,39,0.55));
25
+ }
26
+ /* RIGHT — the branded panel. Gradient is substituted ({{PANEL_BG}}) so a course can override it. */
27
+ .thumb__panel {
28
+ flex: 1; position: relative; display: flex; flex-direction: column;
29
+ padding: 6.4vh 3.8vw;
30
+ color: #fff;
31
+ background: {{PANEL_BG}};
32
+ }
33
+ .thumb__kicker {
34
+ font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
35
+ text-transform: uppercase; letter-spacing: 0.26em; font-weight: 500;
36
+ font-size: 3.5vh; color: rgba(255, 255, 255, 0.94);
37
+ }
38
+ .thumb__titlewrap { margin-top: auto; margin-bottom: auto; }
39
+ .thumb__number { font-weight: 700; font-size: 14vh; line-height: 0.98; letter-spacing: -0.01em; }
40
+ .thumb__title { font-weight: 700; font-size: 9.4vh; line-height: 1.03; letter-spacing: -0.025em; margin-top: 0.6vh; }
41
+ .thumb__foot { display: flex; align-items: center; gap: 1.5vh; }
42
+ .thumb__logo { width: 5vh; height: 5vh; border-radius: 1.1vh; display: block; }
43
+ .thumb__brand { font-weight: 700; font-size: 3.1vh; letter-spacing: -0.01em; }
44
+ </style>
45
+ <div class="thumb">
46
+ <div class="thumb__scene"><img src="{{SCENE}}" alt=""></div>
47
+ <div class="thumb__panel">
48
+ <div class="thumb__kicker">{{KICKER}}</div>
49
+ <div class="thumb__titlewrap">{{NUMBER}}<div class="thumb__title">{{TITLE}}</div></div>
50
+ <div class="thumb__foot">{{LOGO}}<div class="thumb__brand">GraphL</div></div>
51
+ </div>
52
+ </div>
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ // thumb.mjs — a crisp, branded YouTube thumbnail for a course.
3
+ //
4
+ // node scripts/thumb.mjs <course> [--section N]
5
+ // [--title T] [--kicker K] [--number NN] [--panel css] [--out file] [--full4k] [--at ms]
6
+ //
7
+ // Adapted from ../../graphl-studio/aws/scripts/thumb.mjs for this SECTION-based repo. The reference
8
+ // was beat-based (--section/--beat, driven off window.__capture); here one SECTION = one scene, so it
9
+ // navigates the app's hash to a section's slug (like record-course.mjs) and there is no --beat. It
10
+ // reads the course layout from window.__scene.plan() (see src/App.tsx) to pick which section's scene
11
+ // to shoot (default: the first).
12
+ //
13
+ // The thumbnail is a TEMPLATE (thumb-template.html): LEFT = the course scene as it renders (a lossless
14
+ // screenshot of the .scene-area — no slide), RIGHT = an HTML/CSS panel driven by the concept + course
15
+ // (kicker = concept, title = course title) with the GraphL wordmark (+ an optional logo mark).
16
+ //
17
+ // It's composited in the app's OWN page so the panel inherits the app's self-hosted Plex fonts (no
18
+ // CDN, crisp text — never ffmpeg drawtext). Like record-course.mjs it drives the app in ?capture=1,
19
+ // spawns the app's `npm run dev` unless APP_URL reuses a server, supersamples to SCALE× (4K), then
20
+ // downscales to 1280×720 (Lanczos).
21
+ //
22
+ // Prerequisites: ffmpeg on PATH (Homebrew preferred, as with record-course.mjs).
23
+
24
+ import { execFile, spawn } from 'node:child_process'
25
+ import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'
26
+ import { promisify } from 'node:util'
27
+ import { tmpdir } from 'node:os'
28
+ import { dirname, join, resolve, isAbsolute } from 'node:path'
29
+
30
+ const run = promisify(execFile)
31
+ import { loadPeer, repoDir, dataDir, pkgDir, concept } from './_paths.mjs'
32
+ const { build } = await loadPeer('esbuild')
33
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
34
+
35
+ // Same supersampling as record-course.mjs: render the 1920×1080 stage at SCALE× device pixels so text
36
+ // re-rasterizes crisp, then downscale. SCALE=2 → a 3840×2160 master → 1280×720 thumbnail.
37
+ const W = 1920
38
+ const H = 1080
39
+ const SCALE = process.env.SCALE ? +process.env.SCALE : 2
40
+ const CW = W * SCALE
41
+ const CH = H * SCALE
42
+
43
+ // Prefer a Homebrew ffmpeg (same choice record-course.mjs makes) for the Lanczos downscale.
44
+ const FFMPEG =
45
+ process.env.FFMPEG ??
46
+ ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'].find(existsSync) ??
47
+ 'ffmpeg'
48
+
49
+ const TEMPLATE = join(pkgDir, 'thumb-template.html') // ships with the package
50
+ // Optional GraphL mark for the panel foot. Drop an SVG at any of these paths to show it; otherwise the
51
+ // foot renders the "GraphL" wordmark alone. Override with LOGO_SVG=/path.
52
+ const LOGO_CANDIDATES = [
53
+ process.env.LOGO_SVG,
54
+ join(dataDir, 'logo.svg'),
55
+ join(repoDir, 'public', 'icon.svg'),
56
+ ].filter(Boolean)
57
+ const LOGO_SVG = LOGO_CANDIDATES.find(existsSync) ?? null
58
+
59
+ // The concept shown as the panel kicker.
60
+ const CONCEPT = concept.kicker
61
+ // The right-side panel gradient (brand block). Python-blue by default — it echoes the language's
62
+ // brand and reads well against the Zed-slate scene on the left. Override per-thumb with --panel.
63
+ const DEFAULT_PANEL_BG = concept.panelBg
64
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
65
+ // A slug → display fallback ("data-engineering" → "Data Engineering") when the registry lacks it.
66
+ const titleCase = (slug) => slug.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
67
+
68
+ // ---- CLI --------------------------------------------------------------------------------
69
+ function parse(argv) {
70
+ const pos = []
71
+ const opt = { section: 0, at: 900, out: null, full4k: false, title: null, kicker: null, number: null, panel: null }
72
+ for (let i = 0; i < argv.length; i++) {
73
+ const a = argv[i]
74
+ if (a === '--section') opt.section = +argv[++i]
75
+ else if (a === '--at') opt.at = +argv[++i]
76
+ else if (a === '--out') opt.out = argv[++i]
77
+ else if (a === '--title') opt.title = argv[++i]
78
+ else if (a === '--kicker') opt.kicker = argv[++i]
79
+ else if (a === '--number') opt.number = argv[++i]
80
+ else if (a === '--panel') opt.panel = argv[++i]
81
+ else if (a === '--full4k') opt.full4k = true
82
+ else pos.push(a)
83
+ }
84
+ return { pos, opt }
85
+ }
86
+
87
+ // ---- the app dev server (same handshake as record-course.mjs) ---------------------------
88
+ async function startDevServer(conceptDir) {
89
+ console.log(`Starting dev server: ${conceptDir} …`)
90
+ const child = spawn('npm', ['run', 'dev'], { cwd: conceptDir, env: process.env })
91
+ const url = await new Promise((res, rej) => {
92
+ const to = setTimeout(() => rej(new Error('dev server did not print a URL within 60s')), 60000)
93
+ const onData = (buf) => {
94
+ const m = String(buf).match(/https?:\/\/localhost:\d+\/?/)
95
+ if (m) { clearTimeout(to); child.stdout.off('data', onData); res(m[0].replace(/\/?$/, '/')) }
96
+ }
97
+ child.stdout.on('data', onData)
98
+ child.stderr.on('data', (b) => process.env.DEBUG && process.stderr.write(b))
99
+ child.on('exit', (code) => rej(new Error(`dev server exited early (code ${code})`)))
100
+ })
101
+ for (let i = 0; i < 40; i++) {
102
+ try { if ((await fetch(url)).ok) break } catch { /* not up yet */ }
103
+ await sleep(250)
104
+ }
105
+ console.log(` dev server at ${url}`)
106
+ return { child, url }
107
+ }
108
+
109
+ // Curated PUBLISH titles (scripts/titles.json), keyed by course id — the header a thumbnail wears on
110
+ // YouTube ("Python Data Structures"), which is search-facing and so deliberately differs from the
111
+ // registry's narrative in-app title ("Data structures"). Optional: absent file → registry title.
112
+ const PUBLISH_TITLES = (() => {
113
+ const f = join(dataDir, 'titles.json')
114
+ if (!existsSync(f)) return {}
115
+ try {
116
+ const { _comment, ...titles } = JSON.parse(readFileSync(f, 'utf8'))
117
+ return titles
118
+ } catch {
119
+ return {}
120
+ }
121
+ })()
122
+
123
+ // course title: titles.json override first, else the typed COURSES registry, so the panel copy matches
124
+ // what ships. The content files import ONLY `../types` (erased) → the bundle has no runtime deps.
125
+ async function courseTitle(course) {
126
+ if (PUBLISH_TITLES[course]) return PUBLISH_TITLES[course]
127
+ try {
128
+ const result = await build({
129
+ entryPoints: [resolve(repoDir, 'src/content/index.ts')],
130
+ bundle: true, format: 'esm', platform: 'node', write: false,
131
+ })
132
+ const code = result.outputFiles[0].text
133
+ const reg = await import('data:text/javascript;base64,' + Buffer.from(code).toString('base64'))
134
+ return reg.COURSES?.[course]?.title ?? null
135
+ } catch {
136
+ return null
137
+ }
138
+ }
139
+
140
+ // ---- thumbnail --------------------------------------------------------------------------
141
+ async function makeThumb(course, opt) {
142
+ if (!existsSync(TEMPLATE)) throw new Error(`template not found: ${TEMPLATE}`)
143
+
144
+ const outDir = join(dataDir, 'out')
145
+ mkdirSync(outDir, { recursive: true })
146
+ const outArg = opt.out ?? `${course}.png`
147
+ const out = isAbsolute(outArg) || outArg.includes('/') ? outArg : join(outDir, outArg)
148
+
149
+ // Spawn the dev server (unless APP_URL reuses an existing one).
150
+ let server = null
151
+ const base = process.env.APP_URL ? process.env.APP_URL.replace(/\/?$/, '/') : null
152
+ const appBase = base ?? (server = await startDevServer(repoDir), server.url)
153
+
154
+ const puppeteer = (await loadPeer('puppeteer')).default
155
+ let browser
156
+ try {
157
+ browser = await puppeteer.launch({
158
+ headless: true,
159
+ defaultViewport: { width: CW, height: CH, deviceScaleFactor: 1 },
160
+ args: [`--window-size=${CW},${CH}`, '--autoplay-policy=no-user-gesture-required'],
161
+ })
162
+ const page = await browser.newPage()
163
+
164
+ // Read the course layout (list of sections + their slugs) from the app's own capture contract.
165
+ await page.goto(`${appBase}?capture=1#/${course}`, { waitUntil: 'networkidle2' })
166
+ await page.waitForFunction(() => !!window.__scene, { timeout: 20000 })
167
+ const plan = await page.evaluate(() => window.__scene.plan())
168
+ if (!plan?.length) throw new Error(`course "${course}" has no sections (bad id?)`)
169
+ const sec = plan[opt.section]
170
+ if (!sec) throw new Error(`section ${opt.section} out of range (course has ${plan.length})`)
171
+
172
+ // Navigate to the chosen section's slug and wait for the painted, fitView-settled scene (a fresh
173
+ // goto forces a clean react-flow remount — no stale prior scene in the frame), same as the recorder.
174
+ await page.goto(`${appBase}?capture=1#/${sec.slug}`, { waitUntil: 'networkidle2' })
175
+ await page.waitForSelector('.react-flow__node', { timeout: 15000 })
176
+ await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready })
177
+ await sleep(opt.at) // fitView + ResizeObserver re-fit + edge-pulse settle
178
+
179
+ // LEFT — screenshot just the scene area (no slide) → data URI for the template.
180
+ const scenePane = await page.$('.scene-area')
181
+ if (!scenePane) throw new Error('.scene-area not found (SectionView markup changed?)')
182
+ const sceneB64 = await scenePane.screenshot({ encoding: 'base64' })
183
+ const sceneUri = `data:image/png;base64,${sceneB64}`
184
+
185
+ // Panel copy: kicker = concept, title = course title (registry/titles.json → flags override → slug).
186
+ const title = opt.title ?? (await courseTitle(course)) ?? titleCase(course)
187
+ const kicker = opt.kicker ?? CONCEPT
188
+ const numberHtml = opt.number ? `<div class="thumb__number">${esc(opt.number)}</div>` : ''
189
+ // Logo is optional: show the mark if one is present, else just the wordmark.
190
+ const logoSvg = LOGO_SVG ? readFileSync(LOGO_SVG, 'utf8').replace('<svg ', '<svg class="thumb__logo" ') : ''
191
+ const panelBg = opt.panel ?? DEFAULT_PANEL_BG
192
+
193
+ const html = readFileSync(TEMPLATE, 'utf8')
194
+ .replaceAll('{{SCENE}}', sceneUri)
195
+ .replaceAll('{{KICKER}}', esc(kicker))
196
+ .replaceAll('{{NUMBER}}', numberHtml)
197
+ .replaceAll('{{TITLE}}', esc(title))
198
+ .replaceAll('{{LOGO}}', logoSvg)
199
+ .replaceAll('{{PANEL_BG}}', panelBg)
200
+
201
+ // Composite IN the app page so the panel inherits the loaded Plex fonts: replace only the BODY
202
+ // (keeping the head's @fontsource @font-face rules), then wait for the fonts + a layout tick.
203
+ await page.evaluate((h) => { document.body.innerHTML = h }, html)
204
+ await page.evaluate(() => document.fonts.ready)
205
+ await sleep(200)
206
+
207
+ // Lossless 4K screenshot of the composed frame → downscale to 1280×720 (Lanczos).
208
+ mkdirSync(dirname(out), { recursive: true })
209
+ const shot = opt.full4k ? out : join(tmpdir(), `thumb-4k-${Date.now()}.png`)
210
+ await page.screenshot({ path: shot })
211
+ if (!opt.full4k) {
212
+ await run(FFMPEG, ['-y', '-loglevel', 'error', '-i', shot, '-vf', 'scale=1280:720:flags=lanczos', out])
213
+ rmSync(shot, { force: true })
214
+ }
215
+ console.log(`\n✅ ${out} (${kicker} — ${title})`)
216
+ return out
217
+ } finally {
218
+ if (browser) await browser.close()
219
+ if (server) server.child.kill('SIGTERM')
220
+ }
221
+ }
222
+
223
+ const { pos, opt } = parse(process.argv.slice(2))
224
+ const [course] = pos
225
+ if (!course) {
226
+ console.error('usage: node scripts/thumb.mjs <course> [--section N] [--title T] [--kicker K] [--number NN] [--panel css-gradient] [--out file] [--full4k] [--at ms]')
227
+ process.exit(2)
228
+ }
229
+ makeThumb(course, opt).catch((e) => {
230
+ console.error('\n✗', e.message)
231
+ process.exit(1)
232
+ })