@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,155 @@
1
+ #!/usr/bin/env node
2
+ // gen-descriptions.mjs — write a YouTube description .txt per course.
3
+ //
4
+ // node scripts/gen-descriptions.mjs # all courses
5
+ // node scripts/gen-descriptions.mjs foundations # just one
6
+ //
7
+ // Adapted from ../../graphl-studio/aws/scripts/gen-descriptions.mjs for this SECTION-based repo. The
8
+ // reference was beat-based and read slide titles from the live DOM (driven in ?capture=1); here one
9
+ // SECTION = one scene + one slide + one narration wav, and every section already carries its own
10
+ // `title` in the typed registry — so we need NO browser at all. We evaluate the real COURSES registry
11
+ // via esbuild (the same bridge scripts/gen-audio-manifest.mjs uses) and read chapter titles straight
12
+ // off it; chapter TIMES are ffprobe'd off each section's wav and summed with record-course.mjs's own
13
+ // per-section timing (bell STING lead + clip + TAIL), so they line up with the concatenated MP4.
14
+ //
15
+ // Each description carries: a title + intro, CHAPTER timestamps (one per section, so YouTube
16
+ // auto-chapters the video), the full course series with deep links, and hashtags. Output lands at
17
+ // scripts/out/<course>.txt, next to the course's .mp4 / .png.
18
+ //
19
+ // Prerequisites: ffprobe on PATH; the app's audio present under public/audio/<course>/.
20
+
21
+ import { execFile } from 'node:child_process'
22
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
23
+ import { promisify } from 'node:util'
24
+ import { join, resolve } from 'node:path'
25
+
26
+ const run = promisify(execFile)
27
+ import { loadPeer, repoDir, dataDir, concept } from './_paths.mjs'
28
+ const { build } = await loadPeer('esbuild')
29
+
30
+ // Match record-course.mjs's timing so chapter marks align with the concatenated video.
31
+ const STING_MS = process.env.NO_STING ? 0 : process.env.STING_MS ? +process.env.STING_MS : 2800
32
+ const TAIL_MS = process.env.TAIL_MS ? +process.env.TAIL_MS : 500
33
+ const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫']
34
+
35
+ // The concept + where the app deploys, from the repo's scripts/concept.json (env vars still
36
+ // override — see _paths.mjs). Deep link is `${SITE}${APP_PATH}/#/<course>` (hash routing).
37
+ const CONCEPT = concept.name
38
+ const SITE = concept.site
39
+ const APP_PATH = concept.appPath
40
+ const HASHTAGS = concept.hashtags
41
+
42
+ // Curated PUBLISH titles (scripts/titles.json), keyed by course id — the search-facing name a course
43
+ // carries on YouTube ("SQL Queries"), deliberately distinct from the registry's narrative
44
+ // in-app title ("Data Ingestion"). Used for this video's headline AND every series entry, so the
45
+ // description names courses the same way the thumbnails and video titles do. Absent file → registry.
46
+ const PUBLISH_TITLES = (() => {
47
+ const f = join(dataDir, 'titles.json')
48
+ if (!existsSync(f)) return {}
49
+ try {
50
+ const { _comment, ...titles } = JSON.parse(readFileSync(f, 'utf8'))
51
+ return titles
52
+ } catch {
53
+ return {}
54
+ }
55
+ })()
56
+ const publishTitle = (course) => PUBLISH_TITLES[course.id] ?? course.title
57
+
58
+ const titleCase = (slug) => slug.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
59
+ // m:ss (or h:mm:ss past an hour) — YouTube chapter format; first chapter must be 0:00.
60
+ function stamp(sec) {
61
+ const s = Math.floor(sec), h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60
62
+ const p2 = (n) => String(n).padStart(2, '0')
63
+ return h > 0 ? `${h}:${p2(m)}:${p2(ss)}` : `${m}:${p2(ss)}`
64
+ }
65
+
66
+ async function ffprobeDuration(file) {
67
+ const { stdout } = await run('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file])
68
+ return parseFloat(stdout.trim())
69
+ }
70
+
71
+ // Evaluate the typed COURSES registry. The content files import ONLY `../types` (`import type` →
72
+ // erased), so the bundle has zero runtime deps and imports cleanly from memory.
73
+ async function loadRegistry() {
74
+ const result = await build({
75
+ entryPoints: [resolve(repoDir, 'src/content/index.ts')],
76
+ bundle: true, format: 'esm', platform: 'node', write: false,
77
+ })
78
+ const code = result.outputFiles[0].text
79
+ return import('data:text/javascript;base64,' + Buffer.from(code).toString('base64'))
80
+ }
81
+
82
+ // Build the description text for one course. Blocks are separated by a VISIBLE rule (not blank lines)
83
+ // so grouping survives even if YouTube trims empty lines on paste; chapters stay one-per-line
84
+ // (required for auto-chapters) and each series entry is a single line ending in its URL (clickable).
85
+ const RULE = '━━━━━━━━━━━━━━━━'
86
+ function compose({ course, chapters, series }) {
87
+ const L = []
88
+ // Headline: "<title> · <concept>", but drop the suffix when the publish title already leads with the
89
+ // concept ("SQL Queries · SQL" stammers; the publish title is the whole name).
90
+ const headline = publishTitle(course)
91
+ L.push(headline.toLowerCase().startsWith(CONCEPT.toLowerCase()) ? headline : `${headline} · ${CONCEPT}`)
92
+ L.push(RULE)
93
+ // NB: this used to claim "the diagram assembles top-to-bottom as the narration walks through each
94
+ // idea" — boilerplate inherited from the graphl-studio reveal-engine, where a camera really did
95
+ // build a scene up beat by beat. THIS engine draws each scene SOLID (see record-course.mjs: "no
96
+ // reveal fold, no seek/transition/pan machinery"), so nothing assembles and the sentence described
97
+ // a video that does not exist. Same wrong line is still in the other concept repos' copies.
98
+ L.push(
99
+ `Part of GraphL's ${CONCEPT} series — every section pairs one diagram with the idea it explains, ` +
100
+ `so the picture and the words land together.`,
101
+ )
102
+ L.push(RULE)
103
+ L.push('⏱ CHAPTERS')
104
+ for (const c of chapters) L.push(`${stamp(c.start)} ${c.title}`)
105
+ L.push(RULE)
106
+ L.push(`▶ ${CONCEPT.toUpperCase()} — THE SERIES`)
107
+ series.forEach((s, i) => {
108
+ const label = s.id === course.id ? `${publishTitle(s)} ◀ this video` : publishTitle(s)
109
+ L.push(`${CIRCLED[i] ?? '•'} ${label} → ${SITE}${APP_PATH}/#/${s.id}`)
110
+ })
111
+ L.push(RULE)
112
+ L.push(`🔗 Watch interactively on GraphL → ${SITE}${APP_PATH}/#/${course.id}`)
113
+ L.push(`🌐 More concepts → ${SITE}`)
114
+ L.push(RULE)
115
+ L.push(HASHTAGS)
116
+ return L.join('\n') + '\n'
117
+ }
118
+
119
+ async function main() {
120
+ const [oneCourse] = process.argv.slice(2)
121
+
122
+ const reg = await loadRegistry()
123
+ const series = Object.values(reg.COURSES) // catalog order (registry insertion order)
124
+ const targets = oneCourse ? series.filter((c) => c.id === oneCourse) : series
125
+ if (!targets.length) {
126
+ console.error(`✗ no such course "${oneCourse}" (have: ${series.map((c) => c.id).join(', ')})`)
127
+ process.exit(1)
128
+ }
129
+
130
+ const outDir = join(dataDir, 'out')
131
+ mkdirSync(outDir, { recursive: true })
132
+
133
+ for (const course of targets) {
134
+ const audioDir = join(repoDir, 'public', 'audio', course.id)
135
+ const chapters = []
136
+ let t = 0
137
+ let missing = 0
138
+ for (const section of course.sections) {
139
+ // Chapter starts at this section's bell lead-in (its first frame) — record-course.mjs holds the
140
+ // opening frame for STING_MS under the bell, then the narration clip, then a TAIL.
141
+ chapters.push({ start: t, title: section.title || titleCase(section.id) })
142
+ const wav = join(audioDir, `${section.id}.wav`)
143
+ const dur = existsSync(wav) ? await ffprobeDuration(wav) : (missing++, 3)
144
+ t += STING_MS / 1000 + dur + TAIL_MS / 1000
145
+ }
146
+
147
+ const text = compose({ course, chapters, series })
148
+ const out = join(outDir, `${course.id}.txt`)
149
+ writeFileSync(out, text)
150
+ const warn = missing ? ` ⚠ ${missing} section(s) had no wav (3s fallback)` : ''
151
+ console.log(`✅ ${out} (${chapters.length} chapters, ${stamp(t)} total)${warn}`)
152
+ }
153
+ }
154
+
155
+ main().catch((e) => { console.error('\n✗', e.message); process.exit(1) })
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+ // record-course.mjs — [STEP 3, 4K LANDSCAPE] one course → one 3840×2160 MP4 for YouTube.
3
+ //
4
+ // node scripts/record-course.mjs <course> [--force] [--only <id[,id]>]
5
+ //
6
+ // Blueprint: ../../graphl-studio/aws/scripts/record-course.mjs — the concat-safe encode contract
7
+ // (forced CFR + fixed timescale + gradfun deband + identical codec/pix/audio per segment so the
8
+ // final `-c copy` join is glitch-free) is lifted verbatim. What's DIFFERENT here: this app is
9
+ // SECTION-based, not beat-based — one section = one scene + one slide + one narration wav — so there
10
+ // is no reveal fold, no seek/transition/pan machinery. Each section is just: navigate the hash to
11
+ // its slug → wait for the painted, fitView-settled frame → hold for the clip → next.
12
+ //
13
+ // TRUE 4K (no fixed-stage trick): this app's layout is FLUID (react-flow fitView scales the scene
14
+ // into its pane; the slide's useSlideScale zooms its 806px design width to the live pane), so we set
15
+ // the puppeteer VIEWPORT directly to 3840×2160. page.screencast() records at the CSS viewport size
16
+ // (it ignores deviceScaleFactor), so the frames are exactly 3840×2160 = 2160p, natively.
17
+ //
18
+ // THE BELL = the section SEPARATOR: a synthesized three-note brand bell plays as a lead-in at the
19
+ // START of every section (the opening frame is held for STING_MS under the bell, then narration
20
+ // begins). Bell-at-each-start → a bell between all nine sections, combined into one video.
21
+ //
22
+ // Timing is driven by the wav length (ffprobe), never by playback: a missing clip falls back to 3s
23
+ // silence so the pipeline yields a video rather than hanging. Audio is read straight from
24
+ // public/audio/<course>/<id>.wav on disk (same-repo), not fetched over HTTP.
25
+ //
26
+ // Prerequisites: ffmpeg + ffprobe on PATH (Homebrew ffmpeg preferred — libx264 + gradfun deband).
27
+
28
+ import { execFile, spawn } from 'node:child_process'
29
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
30
+ import { createHash } from 'node:crypto'
31
+ import { promisify } from 'node:util'
32
+ import { join, resolve } from 'node:path'
33
+
34
+ const run = promisify(execFile)
35
+ import { loadPeer, repoDir, dataDir } from './_paths.mjs'
36
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
37
+ const sha = (data) => createHash('sha256').update(data).digest('hex').slice(0, 16)
38
+ const pad2 = (n) => String(n).padStart(2, '0')
39
+ const readJson = (f) => { try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null } }
40
+
41
+ // ---- 4K landscape frame -----------------------------------------------------------------
42
+ const CW = process.env.WIDTH ? +process.env.WIDTH : 3840
43
+ const CH = process.env.HEIGHT ? +process.env.HEIGHT : 2160
44
+ const FPS = process.env.FPS ? +process.env.FPS : 30
45
+ // A brief held tail after each section so it breathes and audio is never clipped at the join.
46
+ const TAIL_MS = process.env.TAIL_MS ? +process.env.TAIL_MS : 500
47
+ // The brand bell lead-in that opens (and so separates) each section. STING_MS=0 or NO_STING disables.
48
+ const STING_MS = process.env.NO_STING ? 0 : process.env.STING_MS ? +process.env.STING_MS : 2800
49
+ const STING_SIG = STING_MS > 0 ? `bell-arp:v1:${STING_MS}` : 'none'
50
+
51
+ // Prefer a Homebrew ffmpeg (libx264 + gradfun deband kills dark-gradient banding on YouTube's codec);
52
+ // fall back to Apple hardware, then plain ffmpeg.
53
+ const FFMPEG =
54
+ process.env.FFMPEG ??
55
+ ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'].find(existsSync) ??
56
+ 'ffmpeg'
57
+ const HAS_X264 = FFMPEG !== 'ffmpeg'
58
+ const VIDEO_CODEC = process.env.VIDEO_CODEC ?? (HAS_X264 ? 'libx264' : 'h264_videotoolbox')
59
+ const IS_X26X = /^libx26[45]$/.test(VIDEO_CODEC)
60
+ const CRF = process.env.VIDEO_CRF ?? '18'
61
+ const PRESET = process.env.VIDEO_PRESET ?? 'slow'
62
+ const BITRATE = process.env.VIDEO_BITRATE ?? '40M' // 4K needs more than 1080p's 16M
63
+ const ENCODE_SIG = IS_X26X ? `${VIDEO_CODEC}:crf${CRF}:${PRESET}` : `${VIDEO_CODEC}:b${BITRATE}`
64
+
65
+ // ---- ffmpeg helpers ---------------------------------------------------------------------
66
+ async function ffprobeDuration(file) {
67
+ const { stdout } = await run('ffprobe', [
68
+ '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file,
69
+ ])
70
+ return parseFloat(stdout.trim())
71
+ }
72
+
73
+ // Concatenate audio inputs into one mono 44.1 kHz WAV via the concat FILTER (per-input resample): the
74
+ // bell bed is 44.1 kHz but a narration clip may be another rate, and the concat DEMUXER corrupts the
75
+ // timeline on a mismatch. The filter resamples each input first, so the join is clean.
76
+ async function concatAudio(inputs, dst) {
77
+ const inArgs = inputs.flatMap((f) => ['-i', f])
78
+ const chains = inputs.map((_, i) => `[${i}:a]aresample=44100[a${i}]`).join(';')
79
+ const joins = inputs.map((_, i) => `[a${i}]`).join('')
80
+ const filter = `${chains};${joins}concat=n=${inputs.length}:v=0:a=1[out]`
81
+ await run(FFMPEG, ['-y', ...inArgs, '-filter_complex', filter, '-map', '[out]', '-ar', '44100', '-ac', '1', dst])
82
+ }
83
+
84
+ // Render the brand bell once → bell.wav, and return a helper that pads/trims it to a lead of STING_MS
85
+ // onto `dst`. No-op (returns null) when the sting is disabled.
86
+ async function prepareBell(tmp) {
87
+ if (STING_MS <= 0) return null
88
+ const bell = join(tmp, 'bell.wav')
89
+ await run(FFMPEG, ['-y', '-filter_complex',
90
+ 'sine=f=587.33:d=2.4:sample_rate=44100,afade=t=out:st=0:d=2.4:curve=exp[a];' +
91
+ 'sine=f=880:d=2.4:sample_rate=44100,afade=t=out:st=0:d=2.4:curve=exp,adelay=200[b];' +
92
+ 'sine=f=1174.66:d=2.4:sample_rate=44100,afade=t=out:st=0:d=2.4:curve=exp,adelay=400[c];' +
93
+ '[a][b][c]amix=inputs=3:normalize=0,volume=0.22,lowpass=f=3500,aformat=channel_layouts=mono',
94
+ '-t', '2.6', bell])
95
+ const secs = (STING_MS / 1000).toFixed(2)
96
+ return (dst) => run(FFMPEG, ['-y', '-i', bell, '-af', `apad,atrim=0:${secs}`, '-ar', '44100', '-ac', '1', dst])
97
+ }
98
+
99
+ // Mux one section's webm + audio → MP4 with CONCAT-SAFE settings so the final `-c copy` concat is
100
+ // glitch-free: forced CFR, fixed video timescale, gradfun deband, and identical codec/pix/audio params
101
+ // for every segment. `total` (bell lead + clip + tail) bounds both streams; apad fills the tail.
102
+ async function encodeSegment(webm, audio, total, outMp4) {
103
+ const quality = IS_X26X ? ['-preset', PRESET, '-crf', CRF] : ['-b:v', BITRATE]
104
+ await run(FFMPEG, [
105
+ '-y', '-i', webm, '-i', audio,
106
+ '-map', '0:v:0', '-map', '1:a:0',
107
+ '-vf', 'gradfun=strength=0.9:radius=16',
108
+ '-r', String(FPS), '-vsync', 'cfr', '-video_track_timescale', '90000',
109
+ '-c:v', VIDEO_CODEC, ...quality, '-pix_fmt', 'yuv420p',
110
+ '-af', 'apad', '-t', total.toFixed(3),
111
+ '-c:a', 'aac', '-b:a', '192k', '-ar', '44100', '-ac', '1',
112
+ outMp4,
113
+ ])
114
+ }
115
+
116
+ // ---- the app dev server -----------------------------------------------------------------
117
+ // Spawn `npm run dev` and resolve once Vite prints its Local URL. Set APP_URL to reuse a server.
118
+ async function startDevServer() {
119
+ console.log(`Starting dev server: ${repoDir} …`)
120
+ const child = spawn('npm', ['run', 'dev'], { cwd: repoDir, env: process.env })
121
+ const url = await new Promise((res, rej) => {
122
+ const to = setTimeout(() => rej(new Error('dev server did not print a URL within 60s')), 60000)
123
+ const onData = (buf) => {
124
+ const m = String(buf).match(/https?:\/\/localhost:\d+\/?/)
125
+ if (m) { clearTimeout(to); child.stdout.off('data', onData); res(m[0].replace(/\/?$/, '/')) }
126
+ }
127
+ child.stdout.on('data', onData)
128
+ child.stderr.on('data', (b) => process.env.DEBUG && process.stderr.write(b))
129
+ child.on('exit', (code) => rej(new Error(`dev server exited early (code ${code})`)))
130
+ })
131
+ for (let i = 0; i < 40; i++) {
132
+ try { if ((await fetch(url)).ok) break } catch { /* not up yet */ }
133
+ await sleep(250)
134
+ }
135
+ console.log(` dev server at ${url}`)
136
+ return { child, url }
137
+ }
138
+
139
+ // Navigate to a section and wait for its scene to be painted AND fitView-settled — the deterministic
140
+ // frame the reproducible-layout model depends on. A fresh goto per section forces a clean react-flow
141
+ // remount (it keys on scene id), so there is never a stale prior scene in the frame.
142
+ async function gotoSection(page, appBase, slug) {
143
+ await page.goto(`${appBase}?capture=1#/${slug}`, { waitUntil: 'networkidle2' })
144
+ await page.waitForSelector('.react-flow__node', { timeout: 15000 })
145
+ await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready })
146
+ await sleep(700) // fitView (instant) + ResizeObserver re-fit + edge-pulse settle
147
+ }
148
+
149
+ // CDP screencast only emits a frame on a VISUAL CHANGE, so an otherwise-static section records an
150
+ // EMPTY webm (0 frames) and the mux fails. A 1px, ~1%-opacity speck nudged every animation frame
151
+ // keeps frames flowing; at 4K that sub-perceptible pixel is quantized away by x264. Removed on stop.
152
+ async function startKeepalive(page) {
153
+ await page.evaluate(() => {
154
+ const d = document.createElement('div')
155
+ d.id = '__cap_keepalive'
156
+ d.style.cssText =
157
+ 'position:fixed;left:0;top:0;width:1px;height:1px;background:#888;opacity:0.01;' +
158
+ 'pointer-events:none;z-index:2147483647;will-change:transform'
159
+ document.body.appendChild(d)
160
+ let x = 0
161
+ const loop = () => {
162
+ x = (x + 3) % 30
163
+ d.style.transform = `translate3d(${x}px,0,0)`
164
+ window.__cap_raf = requestAnimationFrame(loop)
165
+ }
166
+ loop()
167
+ })
168
+ }
169
+ async function stopKeepalive(page) {
170
+ await page.evaluate(() => {
171
+ if (window.__cap_raf) cancelAnimationFrame(window.__cap_raf)
172
+ document.getElementById('__cap_keepalive')?.remove()
173
+ })
174
+ }
175
+
176
+ // ---- record -----------------------------------------------------------------------------
177
+ async function recordCourse(course, { force = false, only = [] } = {}) {
178
+ const tmp = join(dataDir, '.tmp', course)
179
+ const segDir = join(dataDir, 'segments', course)
180
+ const outDir = join(dataDir, 'out')
181
+ for (const d of [tmp, segDir, outDir]) mkdirSync(d, { recursive: true })
182
+
183
+ const bellBed = await prepareBell(tmp)
184
+
185
+ let server = null
186
+ const base = process.env.APP_URL ? process.env.APP_URL.replace(/\/?$/, '/') : null
187
+ const appBase = base ?? (server = await startDevServer(), server.url)
188
+
189
+ const puppeteer = (await loadPeer('puppeteer')).default
190
+ let browser
191
+ const segments = [] // ordered { mp4 } to concat
192
+ try {
193
+ browser = await puppeteer.launch({
194
+ headless: true,
195
+ defaultViewport: { width: CW, height: CH, deviceScaleFactor: 1 },
196
+ args: [`--window-size=${CW},${CH}`],
197
+ })
198
+ const page = await browser.newPage()
199
+ await page.goto(`${appBase}?capture=1#/${course}`, { waitUntil: 'networkidle2' })
200
+
201
+ // The app lays out its own course for the recorder (slug + course + section id + scene per section).
202
+ await page.waitForFunction(() => !!window.__scene, { timeout: 20000 })
203
+ const plan = await page.evaluate(() => window.__scene.plan())
204
+ if (!plan?.length) throw new Error(`course "${course}" has no sections (bad id?)`)
205
+ console.log(`Course ${course}: ${plan.length} sections @ ${CW}×${CH}\n`)
206
+
207
+ let n = 0
208
+ for (const sec of plan) {
209
+ n++
210
+ const tag = `${pad2(n)}-${sec.id}`
211
+ // --only RESTRICTS which segments are (re)recorded; a run with it refreshes those segments and
212
+ // SKIPS the final merge (a partial set can't concat into a whole video). A full run merges all.
213
+ if (only.length && !only.some((t) => sec.id.includes(t) || tag.includes(t))) continue
214
+ const segMp4 = join(segDir, `${tag}.mp4`)
215
+ const sidecar = join(segDir, `${tag}.json`)
216
+ const clip = join(tmp, `${tag}.wav`)
217
+
218
+ // Narration wav straight off disk (same repo). Missing → 3s silence so the run never hangs.
219
+ const wav = resolve(repoDir, 'public', 'audio', sec.course, `${sec.id}.wav`)
220
+ let dur, audioHash
221
+ if (existsSync(wav)) {
222
+ const buf = readFileSync(wav)
223
+ writeFileSync(clip, buf)
224
+ audioHash = sha(buf)
225
+ dur = await ffprobeDuration(clip)
226
+ } else {
227
+ await run(FFMPEG, ['-y', '-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono', '-t', '3', clip])
228
+ audioHash = 'silence3'
229
+ dur = 3
230
+ console.warn(` §${n} ${sec.id}: no audio → 3s silence`)
231
+ }
232
+
233
+ // Incremental reuse: re-record iff missing/changed (or --force / --only match).
234
+ const fp = sha(JSON.stringify({
235
+ v: 1, audioHash, w: CW, h: CH, fps: FPS, tail: TAIL_MS, enc: ENCODE_SIG, sting: STING_SIG,
236
+ }))
237
+ const have = existsSync(segMp4) && existsSync(sidecar)
238
+ if (!(force || !have || readJson(sidecar)?.fp !== fp)) {
239
+ console.log(` §${n} ${sec.id} reuse`)
240
+ segments.push(segMp4)
241
+ continue
242
+ }
243
+
244
+ // Position → wait for the painted, framed frame → roll → hold (bell lead + clip + tail).
245
+ const total = STING_MS / 1000 + dur + TAIL_MS / 1000
246
+ await gotoSection(page, appBase, sec.slug)
247
+
248
+ const webm = join(tmp, `${tag}.webm`)
249
+ const recorder = await page.screencast({ path: webm })
250
+ await startKeepalive(page)
251
+ const lead = STING_MS > 0 ? `♪ ${(STING_MS / 1000).toFixed(1)}s + ` : ''
252
+ console.log(` §${n} ${sec.id} ▶ record (${lead}${dur.toFixed(1)}s)`)
253
+ await sleep(Math.round(total * 1000))
254
+ await recorder.stop()
255
+ await stopKeepalive(page)
256
+
257
+ // Segment audio: bell lead + this section's clip; mirrors the video hold.
258
+ let segAudio = clip
259
+ if (STING_MS > 0 && bellBed) {
260
+ const stingWav = join(tmp, `${tag}-sting.wav`)
261
+ await bellBed(stingWav)
262
+ segAudio = join(tmp, `${tag}-audio.wav`)
263
+ await concatAudio([stingWav, clip], segAudio)
264
+ }
265
+ await encodeSegment(webm, segAudio, total, segMp4)
266
+ writeFileSync(sidecar, JSON.stringify({ fp, builtAt: new Date().toISOString() }, null, 2))
267
+ segments.push(segMp4)
268
+ console.log(` §${n} ${sec.id} ✓ ${tag}.mp4`)
269
+ }
270
+ } finally {
271
+ if (browser) await browser.close()
272
+ if (server) server.child.kill('SIGTERM')
273
+ }
274
+
275
+ // A --only run refreshed just a subset of segments → there is no complete set to merge. Stop here;
276
+ // a later full run (no --only) reuses every unchanged segment and concatenates the whole course.
277
+ if (only.length) {
278
+ console.log(`\n✔ recorded ${segments.length} segment(s) (--only) — skipping merge. Run without --only to build ${course}.mp4.`)
279
+ return null
280
+ }
281
+
282
+ // Merge: concat demuxer + stream copy (uniform params → clean joins, seconds).
283
+ const listFile = join(tmp, 'concat.txt')
284
+ writeFileSync(listFile, segments.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join('\n') + '\n')
285
+ const out = join(outDir, `${course}.mp4`)
286
+ console.log(`\nMerging ${segments.length} segments → scripts/out/${course}.mp4`)
287
+ await run(FFMPEG, ['-y', '-f', 'concat', '-safe', '0', '-i', listFile, '-c', 'copy', '-movflags', '+faststart', out])
288
+ console.log(`\n✅ ${out}`)
289
+ return out
290
+ }
291
+
292
+ // ---- CLI --------------------------------------------------------------------------------
293
+ function parse(argv) {
294
+ const pos = []
295
+ const only = []
296
+ let force = false
297
+ for (let i = 0; i < argv.length; i++) {
298
+ const a = argv[i]
299
+ if (a === '--force') force = true
300
+ else if (a === '--only') only.push(...(argv[++i] ?? '').split(',').filter(Boolean))
301
+ else if (a.startsWith('--only=')) only.push(...a.slice(7).split(',').filter(Boolean))
302
+ else pos.push(a)
303
+ }
304
+ return { pos, only, force }
305
+ }
306
+
307
+ const { pos, only, force } = parse(process.argv.slice(2))
308
+ const [course] = pos
309
+ if (!course) {
310
+ console.error('usage: node scripts/record-course.mjs <course> [--force] [--only <id[,id]>]')
311
+ process.exit(2)
312
+ }
313
+ recordCourse(course, { force, only }).catch((e) => {
314
+ console.error('\n✗', e.message)
315
+ process.exit(1)
316
+ })