@graphlearning/shell 0.10.0 → 0.10.2

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.
@@ -1,114 +1,40 @@
1
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
- // LOOP CAPTURE (why a 90s section screencasts in ~8s): the only motion in the frame is the edge
19
- // pulse — ui-flow's FlowEdge draws it as an SVG <animateMotion dur="2.4s" repeatCount="indefinite">
20
- // per edge — so the composition is PERIODIC with a 2.4s period. We screencast ONE window that is a
21
- // whole number of those periods (default ONE, 2.4s — a pulse crosses its whole edge in exactly one
22
- // period), which by construction shows every edge's flow end-to-end and joins back onto itself
23
- // seamlessly *whatever phase the recording started in*,
24
- // then LOOP that clip over the narration's length at encode time (-stream_loop -1). Capture is no
25
- // longer realtime-bound by the wav. LOOP_MS is snapped to a whole period — an unsnapped window is
26
- // exactly what makes a loop visible — and NO_LOOP=1 restores the old hold-for-the-whole-wav capture.
27
- // Keep PULSE_S in sync with FlowEdge's dur; nothing else in the frame moves (the shell's stylesheet
28
- // carries hover transitions only), which is what makes one period a complete picture.
29
- //
30
- // THE BELL = the section SEPARATOR: a synthesized three-note brand bell plays as a lead-in at the
31
- // START of every section (the opening frame is held for STING_MS under the bell, then narration
32
- // begins). Bell-at-each-start → a bell between all nine sections, combined into one video.
33
- //
34
- // Timing is driven by the wav length (ffprobe), never by playback: a missing clip falls back to 3s
35
- // silence so the pipeline yields a video rather than hanging. Audio is read straight from
36
- // public/audio/<course>/<id>.wav on disk (same-repo), not fetched over HTTP.
37
- //
38
- // Prerequisites: ffmpeg + ffprobe on PATH (Homebrew ffmpeg preferred — libx264 + gradfun deband).
39
2
 
40
- import { execFile, spawn } from 'node:child_process'
41
3
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
42
4
  import { createHash } from 'node:crypto'
43
- import { promisify } from 'node:util'
44
5
  import { join, resolve } from 'node:path'
45
6
 
46
- const run = promisify(execFile)
47
- import { loadPeer, repoDir, dataDir } from './_paths.mjs'
48
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
7
+ import { loadPeer, repoDir, dataDir, run, sleep, FFMPEG, ffprobeDuration, openApp, gotoSection, startKeepalive, stopKeepalive } from './_paths.mjs'
49
8
  const sha = (data) => createHash('sha256').update(data).digest('hex').slice(0, 16)
50
9
  const pad2 = (n) => String(n).padStart(2, '0')
51
10
  const readJson = (f) => { try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null } }
52
11
 
53
- // ---- 4K landscape frame -----------------------------------------------------------------
54
12
  const CW = process.env.WIDTH ? +process.env.WIDTH : 3840
55
13
  const CH = process.env.HEIGHT ? +process.env.HEIGHT : 2160
56
14
  const FPS = process.env.FPS ? +process.env.FPS : 30
57
- // A brief held tail after each section so it breathes and audio is never clipped at the join.
58
15
  const TAIL_MS = process.env.TAIL_MS ? +process.env.TAIL_MS : 500
59
- // The brand bell lead-in that opens (and so separates) each section. STING_MS=0 or NO_STING disables.
60
16
  const STING_MS = process.env.NO_STING ? 0 : process.env.STING_MS ? +process.env.STING_MS : 2800
61
17
  const STING_SIG = STING_MS > 0 ? `bell-arp:v1:${STING_MS}` : 'none'
62
18
 
63
- // ---- the loop window --------------------------------------------------------------------
64
- // The edge pulse's period, from ui-flow's FlowEdge (`animateMotion dur="2.4s"`).
65
19
  const PULSE_S = process.env.PULSE_S ? +process.env.PULSE_S : 2.4
66
20
  const NO_LOOP = !!process.env.NO_LOOP
67
- // The recorded window, in WHOLE pulse periods: LOOP_MS (or LOOP_CYCLES) is snapped to the nearest
68
- // one, because a window that is not a whole period ends on a different pulse position than it began
69
- // and the loop join then jumps. Default 1 → ONE period, 2.4s: a pulse crosses its whole edge in
70
- // exactly one period, so a single cycle already shows every edge's flow end-to-end and more cycles
71
- // only record the same picture again. The cost of the short window is that a capture hiccup inside
72
- // it (a dropped frame, a late re-fit) repeats for the whole section instead of a fifth of it — raise
73
- // LOOP_CYCLES if a section ever shows one.
74
21
  const LOOP_CYCLES = Math.max(1, Math.round(
75
22
  (process.env.LOOP_MS ? +process.env.LOOP_MS / 1000 : +(process.env.LOOP_CYCLES ?? 1) * PULSE_S) / PULSE_S,
76
23
  ))
77
24
  const LOOP_S = LOOP_CYCLES * PULSE_S
78
- // LEAD_S is discarded ramp-up (screencast takes a moment to emit its first frame, and the keepalive
79
- // starts just after the recorder); GUARD_S is recorded past the window so the exact trim can never
80
- // run off the end of the webm.
81
25
  const LEAD_S = process.env.LOOP_LEAD_S ? +process.env.LOOP_LEAD_S : 0.5
82
26
  const GUARD_S = 0.5
83
27
  const LOOP_SIG = NO_LOOP ? 'none' : `loop:v1:${LOOP_S.toFixed(2)}+${LEAD_S.toFixed(2)}`
84
- // Intermediate quality for the loop clip (see makeLoopClip) when the codec is not CRF-based.
85
28
  const LOOP_BITRATE = process.env.LOOP_BITRATE ?? '120M'
86
29
 
87
- // Prefer a Homebrew ffmpeg (libx264 + gradfun deband kills dark-gradient banding on YouTube's codec);
88
- // fall back to Apple hardware, then plain ffmpeg.
89
- const FFMPEG =
90
- process.env.FFMPEG ??
91
- ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'].find(existsSync) ??
92
- 'ffmpeg'
93
30
  const HAS_X264 = FFMPEG !== 'ffmpeg'
94
31
  const VIDEO_CODEC = process.env.VIDEO_CODEC ?? (HAS_X264 ? 'libx264' : 'h264_videotoolbox')
95
32
  const IS_X26X = /^libx26[45]$/.test(VIDEO_CODEC)
96
33
  const CRF = process.env.VIDEO_CRF ?? '18'
97
34
  const PRESET = process.env.VIDEO_PRESET ?? 'slow'
98
- const BITRATE = process.env.VIDEO_BITRATE ?? '40M' // 4K needs more than 1080p's 16M
35
+ const BITRATE = process.env.VIDEO_BITRATE ?? '40M'
99
36
  const ENCODE_SIG = IS_X26X ? `${VIDEO_CODEC}:crf${CRF}:${PRESET}` : `${VIDEO_CODEC}:b${BITRATE}`
100
37
 
101
- // ---- ffmpeg helpers ---------------------------------------------------------------------
102
- async function ffprobeDuration(file) {
103
- const { stdout } = await run('ffprobe', [
104
- '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file,
105
- ])
106
- return parseFloat(stdout.trim())
107
- }
108
-
109
- // Concatenate audio inputs into one mono 44.1 kHz WAV via the concat FILTER (per-input resample): the
110
- // bell bed is 44.1 kHz but a narration clip may be another rate, and the concat DEMUXER corrupts the
111
- // timeline on a mismatch. The filter resamples each input first, so the join is clean.
112
38
  async function concatAudio(inputs, dst) {
113
39
  const inArgs = inputs.flatMap((f) => ['-i', f])
114
40
  const chains = inputs.map((_, i) => `[${i}:a]aresample=44100[a${i}]`).join(';')
@@ -117,8 +43,6 @@ async function concatAudio(inputs, dst) {
117
43
  await run(FFMPEG, ['-y', ...inArgs, '-filter_complex', filter, '-map', '[out]', '-ar', '44100', '-ac', '1', dst])
118
44
  }
119
45
 
120
- // Render the brand bell once → bell.wav, and return a helper that pads/trims it to a lead of STING_MS
121
- // onto `dst`. No-op (returns null) when the sting is disabled.
122
46
  async function prepareBell(tmp) {
123
47
  if (STING_MS <= 0) return null
124
48
  const bell = join(tmp, 'bell.wav')
@@ -132,11 +56,6 @@ async function prepareBell(tmp) {
132
56
  return (dst) => run(FFMPEG, ['-y', '-i', bell, '-af', `apad,atrim=0:${secs}`, '-ar', '44100', '-ac', '1', dst])
133
57
  }
134
58
 
135
- // Normalize the recording into the clip that gets looped: exactly LOOP_S starting LEAD_S in, at a
136
- // forced CFR so the frame count is whole and the join lands on a frame boundary. The trim is
137
- // OUTPUT-side (-ss after -i) — frame-accurate, and the source is only seconds long, so decoding it
138
- // all is free. Near-lossless and ultrafast on purpose: this is an intermediate, and the segment's own
139
- // encode below is what sets the final quality (and carries the gradfun deband).
140
59
  async function makeLoopClip(webm, dst) {
141
60
  const quality = IS_X26X ? ['-preset', 'ultrafast', '-crf', '14'] : ['-b:v', LOOP_BITRATE]
142
61
  await run(FFMPEG, [
@@ -147,11 +66,6 @@ async function makeLoopClip(webm, dst) {
147
66
  ])
148
67
  }
149
68
 
150
- // Mux one section's video + audio → MP4 with CONCAT-SAFE settings so the final `-c copy` concat is
151
- // glitch-free: forced CFR, fixed video timescale, gradfun deband, and identical codec/pix/audio params
152
- // for every segment. `total` (bell lead + clip + tail) bounds both streams; apad fills the tail.
153
- // `loop` repeats the input for as long as `total` asks for — that is what turns one 7.2s window into
154
- // a full-length section — and `-t` is what stops the otherwise endless input.
155
69
  async function encodeSegment(video, audio, total, outMp4, { loop = false } = {}) {
156
70
  const quality = IS_X26X ? ['-preset', PRESET, '-crf', CRF] : ['-b:v', BITRATE]
157
71
  await run(FFMPEG, [
@@ -166,67 +80,6 @@ async function encodeSegment(video, audio, total, outMp4, { loop = false } = {})
166
80
  ])
167
81
  }
168
82
 
169
- // ---- the app dev server -----------------------------------------------------------------
170
- // Spawn `npm run dev` and resolve once Vite prints its Local URL. Set APP_URL to reuse a server.
171
- async function startDevServer() {
172
- console.log(`Starting dev server: ${repoDir} …`)
173
- const child = spawn('npm', ['run', 'dev'], { cwd: repoDir, env: process.env })
174
- const url = await new Promise((res, rej) => {
175
- const to = setTimeout(() => rej(new Error('dev server did not print a URL within 60s')), 60000)
176
- const onData = (buf) => {
177
- const m = String(buf).match(/https?:\/\/localhost:\d+\/?/)
178
- if (m) { clearTimeout(to); child.stdout.off('data', onData); res(m[0].replace(/\/?$/, '/')) }
179
- }
180
- child.stdout.on('data', onData)
181
- child.stderr.on('data', (b) => process.env.DEBUG && process.stderr.write(b))
182
- child.on('exit', (code) => rej(new Error(`dev server exited early (code ${code})`)))
183
- })
184
- for (let i = 0; i < 40; i++) {
185
- try { if ((await fetch(url)).ok) break } catch { /* not up yet */ }
186
- await sleep(250)
187
- }
188
- console.log(` dev server at ${url}`)
189
- return { child, url }
190
- }
191
-
192
- // Navigate to a section and wait for its scene to be painted AND fitView-settled — the deterministic
193
- // frame the reproducible-layout model depends on. A fresh goto per section forces a clean react-flow
194
- // remount (it keys on scene id), so there is never a stale prior scene in the frame.
195
- async function gotoSection(page, appBase, slug) {
196
- await page.goto(`${appBase}?capture=1#/${slug}`, { waitUntil: 'networkidle2' })
197
- await page.waitForSelector('.react-flow__node', { timeout: 15000 })
198
- await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready })
199
- await sleep(700) // fitView (instant) + ResizeObserver re-fit + edge-pulse settle
200
- }
201
-
202
- // CDP screencast only emits a frame on a VISUAL CHANGE, so an otherwise-static section records an
203
- // EMPTY webm (0 frames) and the mux fails. A 1px, ~1%-opacity speck nudged every animation frame
204
- // keeps frames flowing; at 4K that sub-perceptible pixel is quantized away by x264. Removed on stop.
205
- async function startKeepalive(page) {
206
- await page.evaluate(() => {
207
- const d = document.createElement('div')
208
- d.id = '__cap_keepalive'
209
- d.style.cssText =
210
- 'position:fixed;left:0;top:0;width:1px;height:1px;background:#888;opacity:0.01;' +
211
- 'pointer-events:none;z-index:2147483647;will-change:transform'
212
- document.body.appendChild(d)
213
- let x = 0
214
- const loop = () => {
215
- x = (x + 3) % 30
216
- d.style.transform = `translate3d(${x}px,0,0)`
217
- window.__cap_raf = requestAnimationFrame(loop)
218
- }
219
- loop()
220
- })
221
- }
222
- async function stopKeepalive(page) {
223
- await page.evaluate(() => {
224
- if (window.__cap_raf) cancelAnimationFrame(window.__cap_raf)
225
- document.getElementById('__cap_keepalive')?.remove()
226
- })
227
- }
228
-
229
- // ---- record -----------------------------------------------------------------------------
230
83
  async function recordCourse(course, { force = false, only = [] } = {}) {
231
84
  const tmp = join(dataDir, '.tmp', course)
232
85
  const segDir = join(dataDir, 'segments', course)
@@ -235,13 +88,12 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
235
88
 
236
89
  const bellBed = await prepareBell(tmp)
237
90
 
238
- let server = null
239
- const base = process.env.APP_URL ? process.env.APP_URL.replace(/\/?$/, '/') : null
240
- const appBase = base ?? (server = await startDevServer(), server.url)
91
+ const server = await openApp()
92
+ const appBase = server.url
241
93
 
242
94
  const puppeteer = (await loadPeer('puppeteer')).default
243
95
  let browser
244
- const segments = [] // ordered { mp4 } to concat
96
+ const segments = []
245
97
  try {
246
98
  browser = await puppeteer.launch({
247
99
  headless: true,
@@ -251,7 +103,6 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
251
103
  const page = await browser.newPage()
252
104
  await page.goto(`${appBase}?capture=1#/${course}`, { waitUntil: 'networkidle2' })
253
105
 
254
- // The app lays out its own course for the recorder (slug + course + section id + scene per section).
255
106
  await page.waitForFunction(() => !!window.__scene, { timeout: 20000 })
256
107
  const plan = await page.evaluate(() => window.__scene.plan())
257
108
  if (!plan?.length) throw new Error(`course "${course}" has no sections (bad id?)`)
@@ -261,14 +112,11 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
261
112
  for (const sec of plan) {
262
113
  n++
263
114
  const tag = `${pad2(n)}-${sec.id}`
264
- // --only RESTRICTS which segments are (re)recorded; a run with it refreshes those segments and
265
- // SKIPS the final merge (a partial set can't concat into a whole video). A full run merges all.
266
115
  if (only.length && !only.some((t) => sec.id.includes(t) || tag.includes(t))) continue
267
116
  const segMp4 = join(segDir, `${tag}.mp4`)
268
117
  const sidecar = join(segDir, `${tag}.json`)
269
118
  const clip = join(tmp, `${tag}.wav`)
270
119
 
271
- // Narration wav straight off disk (same repo). Missing → 3s silence so the run never hangs.
272
120
  const wav = resolve(repoDir, 'public', 'audio', sec.course, `${sec.id}.wav`)
273
121
  let dur, audioHash
274
122
  if (existsSync(wav)) {
@@ -283,7 +131,6 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
283
131
  console.warn(` §${n} ${sec.id}: no audio → 3s silence`)
284
132
  }
285
133
 
286
- // Incremental reuse: re-record iff missing/changed (or --force / --only match).
287
134
  const fp = sha(JSON.stringify({
288
135
  v: 2, audioHash, w: CW, h: CH, fps: FPS, tail: TAIL_MS, enc: ENCODE_SIG, sting: STING_SIG,
289
136
  loop: LOOP_SIG,
@@ -295,9 +142,6 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
295
142
  continue
296
143
  }
297
144
 
298
- // Position → wait for the painted, framed frame → roll. Loop capture rolls for the WINDOW
299
- // (lead + whole pulse periods + guard) and lets the encode repeat it over the segment; NO_LOOP
300
- // holds for the whole thing (bell lead + clip + tail) as it used to.
301
145
  const total = STING_MS / 1000 + dur + TAIL_MS / 1000
302
146
  const roll = NO_LOOP ? total : LEAD_S + LOOP_S + GUARD_S
303
147
  await gotoSection(page, appBase, sec.slug)
@@ -314,14 +158,12 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
314
158
  await recorder.stop()
315
159
  await stopKeepalive(page)
316
160
 
317
- // One exact, seamlessly-loopable window; -stream_loop repeats it to the segment's length.
318
161
  let video = webm
319
162
  if (!NO_LOOP) {
320
163
  video = join(tmp, `${tag}-loop.mp4`)
321
164
  await makeLoopClip(webm, video)
322
165
  }
323
166
 
324
- // Segment audio: bell lead + this section's clip; mirrors the video hold.
325
167
  let segAudio = clip
326
168
  if (STING_MS > 0 && bellBed) {
327
169
  const stingWav = join(tmp, `${tag}-sting.wav`)
@@ -336,17 +178,14 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
336
178
  }
337
179
  } finally {
338
180
  if (browser) await browser.close()
339
- if (server) server.child.kill('SIGTERM')
181
+ server.child?.kill('SIGTERM')
340
182
  }
341
183
 
342
- // A --only run refreshed just a subset of segments → there is no complete set to merge. Stop here;
343
- // a later full run (no --only) reuses every unchanged segment and concatenates the whole course.
344
184
  if (only.length) {
345
185
  console.log(`\n✔ recorded ${segments.length} segment(s) (--only) — skipping merge. Run without --only to build ${course}.mp4.`)
346
186
  return null
347
187
  }
348
188
 
349
- // Merge: concat demuxer + stream copy (uniform params → clean joins, seconds).
350
189
  const listFile = join(tmp, 'concat.txt')
351
190
  writeFileSync(listFile, segments.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join('\n') + '\n')
352
191
  const out = join(outDir, `${course}.mp4`)
@@ -356,7 +195,6 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
356
195
  return out
357
196
  }
358
197
 
359
- // ---- CLI --------------------------------------------------------------------------------
360
198
  function parse(argv) {
361
199
  const pos = []
362
200
  const only = []
@@ -1,100 +1,38 @@
1
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 → mux against the wav's duration (ffprobe) + a short tail.
15
- //
16
- // LOOP CAPTURE, same as the 4K recorder: the only motion in the frame is the edge pulse — ui-flow's
17
- // FlowEdge draws it as an SVG <animateMotion dur="2.4s" repeatCount="indefinite"> per edge — so the
18
- // composition is PERIODIC with a 2.4s period. We screencast ONE window that is a whole number of
19
- // those periods (default ONE, 2.4s — a pulse crosses its whole edge in exactly one period), which by
20
- // construction shows every edge's flow end-to-end and joins back onto itself seamlessly whatever
21
- // phase the recording started in, then LOOP it over
22
- // the narration's length at encode time (-stream_loop -1) instead of holding the browser for the
23
- // whole wav. LOOP_MS snaps to a whole period; NO_LOOP=1 restores the old full-length capture.
24
- //
25
- // TRUE portrait pixels: the layout is fluid, so we set the puppeteer VIEWPORT to 1080×1920 directly
26
- // and page.screencast() records at exactly that CSS size. Audio is read straight off disk from
27
- // public/audio/<course>/<id>.wav; a missing clip falls back to 3s silence so the run never hangs.
28
- //
29
- // Prerequisites: ffmpeg + ffprobe on PATH (Homebrew ffmpeg preferred — libx264 + gradfun deband).
30
2
 
31
- import { execFile, spawn } from 'node:child_process'
32
3
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
33
4
  import { createHash } from 'node:crypto'
34
- import { promisify } from 'node:util'
35
5
  import { join, resolve } from 'node:path'
36
6
 
37
- const run = promisify(execFile)
38
- import { loadPeer, repoDir, dataDir } from './_paths.mjs'
39
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
7
+ import { loadPeer, repoDir, dataDir, run, sleep, FFMPEG, ffprobeDuration, openApp, gotoSection, startKeepalive, stopKeepalive } from './_paths.mjs'
40
8
  const sha = (data) => createHash('sha256').update(data).digest('hex').slice(0, 16)
41
9
  const pad2 = (n) => String(n).padStart(2, '0')
42
10
  const readJson = (f) => { try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null } }
43
11
 
44
- // ---- portrait reel frame ----------------------------------------------------------------
45
12
  const CW = process.env.WIDTH ? +process.env.WIDTH : 1080
46
13
  const CH = process.env.HEIGHT ? +process.env.HEIGHT : 1920
47
14
  const FPS = process.env.FPS ? +process.env.FPS : 30
48
- // A short held tail so the reel doesn't cut on the last syllable.
49
15
  const TAIL_MS = process.env.TAIL_MS ? +process.env.TAIL_MS : 500
50
16
 
51
- const FFMPEG =
52
- process.env.FFMPEG ??
53
- ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'].find(existsSync) ??
54
- 'ffmpeg'
55
17
  const HAS_X264 = FFMPEG !== 'ffmpeg'
56
18
  const VIDEO_CODEC = process.env.VIDEO_CODEC ?? (HAS_X264 ? 'libx264' : 'h264_videotoolbox')
57
19
  const IS_X26X = /^libx26[45]$/.test(VIDEO_CODEC)
58
20
  const CRF = process.env.VIDEO_CRF ?? '18'
59
21
  const PRESET = process.env.VIDEO_PRESET ?? 'slow'
60
- const BITRATE = process.env.VIDEO_BITRATE ?? '16M' // 1080×1920 ≈ 1080p pixel budget
22
+ const BITRATE = process.env.VIDEO_BITRATE ?? '16M'
61
23
  const ENCODE_SIG = IS_X26X ? `${VIDEO_CODEC}:crf${CRF}:${PRESET}` : `${VIDEO_CODEC}:b${BITRATE}`
62
24
 
63
- // ---- the loop window --------------------------------------------------------------------
64
- // The edge pulse's period, from ui-flow's FlowEdge (`animateMotion dur="2.4s"`).
65
25
  const PULSE_S = process.env.PULSE_S ? +process.env.PULSE_S : 2.4
66
26
  const NO_LOOP = !!process.env.NO_LOOP
67
- // The recorded window, in WHOLE pulse periods: LOOP_MS (or LOOP_CYCLES) is snapped to the nearest
68
- // one, because a window that is not a whole period ends on a different pulse position than it began
69
- // and the loop join then jumps. Default 1 → ONE period, 2.4s: a pulse crosses its whole edge in
70
- // exactly one period, so a single cycle already shows every edge's flow end-to-end and more cycles
71
- // only record the same picture again. The cost of the short window is that a capture hiccup inside
72
- // it (a dropped frame, a late re-fit) repeats for the whole section instead of a fifth of it — raise
73
- // LOOP_CYCLES if a section ever shows one.
74
27
  const LOOP_CYCLES = Math.max(1, Math.round(
75
28
  (process.env.LOOP_MS ? +process.env.LOOP_MS / 1000 : +(process.env.LOOP_CYCLES ?? 1) * PULSE_S) / PULSE_S,
76
29
  ))
77
30
  const LOOP_S = LOOP_CYCLES * PULSE_S
78
- // LEAD_S is discarded ramp-up (screencast takes a moment to emit its first frame); GUARD_S is
79
- // recorded past the window so the exact trim can never run off the end of the webm.
80
31
  const LEAD_S = process.env.LOOP_LEAD_S ? +process.env.LOOP_LEAD_S : 0.5
81
32
  const GUARD_S = 0.5
82
33
  const LOOP_SIG = NO_LOOP ? 'none' : `loop:v1:${LOOP_S.toFixed(2)}+${LEAD_S.toFixed(2)}`
83
- // Intermediate quality for the loop clip (see makeLoopClip) when the codec is not CRF-based.
84
34
  const LOOP_BITRATE = process.env.LOOP_BITRATE ?? '40M'
85
35
 
86
- // ---- ffmpeg helpers ---------------------------------------------------------------------
87
- async function ffprobeDuration(file) {
88
- const { stdout } = await run('ffprobe', [
89
- '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file,
90
- ])
91
- return parseFloat(stdout.trim())
92
- }
93
-
94
- // Normalize the recording into the clip that gets looped: exactly LOOP_S starting LEAD_S in, at a
95
- // forced CFR so the frame count is whole and the join lands on a frame boundary. The trim is
96
- // OUTPUT-side (-ss after -i) — frame-accurate, and the source is only seconds long. Near-lossless and
97
- // ultrafast on purpose: this is an intermediate, and the reel's own encode below sets final quality.
98
36
  async function makeLoopClip(webm, dst) {
99
37
  const quality = IS_X26X ? ['-preset', 'ultrafast', '-crf', '14'] : ['-b:v', LOOP_BITRATE]
100
38
  await run(FFMPEG, [
@@ -105,11 +43,6 @@ async function makeLoopClip(webm, dst) {
105
43
  ])
106
44
  }
107
45
 
108
- // Mux one reel's video + clip → a standalone MP4. Same crisp encode as the 4K recorder (gradfun
109
- // deband, yuv420p, faststart) but this is a FINAL file, not a concat segment, so it needs no uniform
110
- // timescale. `total` (clip + tail) bounds both streams; apad extends the clip with silence to fill.
111
- // `loop` repeats the input for as long as `total` asks for — that is what turns one 7.2s window into
112
- // a full-length reel — and `-t` is what stops the otherwise endless input.
113
46
  async function encodeReel(video, clip, total, outMp4, { loop = false } = {}) {
114
47
  const quality = IS_X26X ? ['-preset', PRESET, '-crf', CRF] : ['-b:v', BITRATE]
115
48
  await run(FFMPEG, [
@@ -125,73 +58,13 @@ async function encodeReel(video, clip, total, outMp4, { loop = false } = {}) {
125
58
  ])
126
59
  }
127
60
 
128
- // ---- the app dev server -----------------------------------------------------------------
129
- async function startDevServer() {
130
- console.log(`Starting dev server: ${repoDir} …`)
131
- const child = spawn('npm', ['run', 'dev'], { cwd: repoDir, env: process.env })
132
- const url = await new Promise((res, rej) => {
133
- const to = setTimeout(() => rej(new Error('dev server did not print a URL within 60s')), 60000)
134
- const onData = (buf) => {
135
- const m = String(buf).match(/https?:\/\/localhost:\d+\/?/)
136
- if (m) { clearTimeout(to); child.stdout.off('data', onData); res(m[0].replace(/\/?$/, '/')) }
137
- }
138
- child.stdout.on('data', onData)
139
- child.stderr.on('data', (b) => process.env.DEBUG && process.stderr.write(b))
140
- child.on('exit', (code) => rej(new Error(`dev server exited early (code ${code})`)))
141
- })
142
- for (let i = 0; i < 40; i++) {
143
- try { if ((await fetch(url)).ok) break } catch { /* not up yet */ }
144
- await sleep(250)
145
- }
146
- console.log(` dev server at ${url}`)
147
- return { child, url }
148
- }
149
-
150
- // Navigate to a section and wait for its scene to be painted AND fitView-settled. A fresh goto per
151
- // reel forces a clean react-flow remount (it keys on scene id), so no stale prior scene is in frame.
152
- async function gotoSection(page, appBase, slug) {
153
- await page.goto(`${appBase}?capture=1#/${slug}`, { waitUntil: 'networkidle2' })
154
- await page.waitForSelector('.react-flow__node', { timeout: 15000 })
155
- await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready })
156
- await sleep(700) // fitView (instant) + ResizeObserver re-fit + edge-pulse settle
157
- }
158
-
159
- // CDP screencast only emits a frame on a VISUAL CHANGE, so an otherwise-static reel records an EMPTY
160
- // webm and the mux fails. A 1px, ~1%-opacity speck nudged every animation frame keeps frames flowing;
161
- // it is quantized away by x264. Removed on stop.
162
- async function startKeepalive(page) {
163
- await page.evaluate(() => {
164
- const d = document.createElement('div')
165
- d.id = '__cap_keepalive'
166
- d.style.cssText =
167
- 'position:fixed;left:0;top:0;width:1px;height:1px;background:#888;opacity:0.01;' +
168
- 'pointer-events:none;z-index:2147483647;will-change:transform'
169
- document.body.appendChild(d)
170
- let x = 0
171
- const loop = () => {
172
- x = (x + 3) % 30
173
- d.style.transform = `translate3d(${x}px,0,0)`
174
- window.__cap_raf = requestAnimationFrame(loop)
175
- }
176
- loop()
177
- })
178
- }
179
- async function stopKeepalive(page) {
180
- await page.evaluate(() => {
181
- if (window.__cap_raf) cancelAnimationFrame(window.__cap_raf)
182
- document.getElementById('__cap_keepalive')?.remove()
183
- })
184
- }
185
-
186
- // ---- record -----------------------------------------------------------------------------
187
61
  async function recordReels(course, { force = false, only = [] } = {}) {
188
62
  const tmp = join(dataDir, '.tmp', `${course}-reels`)
189
63
  const outDir = join(dataDir, 'out', 'reels')
190
64
  for (const d of [tmp, outDir]) mkdirSync(d, { recursive: true })
191
65
 
192
- let server = null
193
- const base = process.env.APP_URL ? process.env.APP_URL.replace(/\/?$/, '/') : null
194
- const appBase = base ?? (server = await startDevServer(), server.url)
66
+ const server = await openApp()
67
+ const appBase = server.url
195
68
 
196
69
  const puppeteer = (await loadPeer('puppeteer')).default
197
70
  let browser
@@ -214,13 +87,11 @@ async function recordReels(course, { force = false, only = [] } = {}) {
214
87
  for (const sec of plan) {
215
88
  n++
216
89
  const tag = `${course}-${sec.id}`
217
- // Each reel is its own file, so --only truly RESTRICTS the set: skip any section not listed.
218
90
  if (only.length && !only.some((t) => sec.id.includes(t) || tag.includes(t))) continue
219
91
  const outMp4 = join(outDir, `${tag}.mp4`)
220
92
  const sidecar = join(tmp, `${pad2(n)}-${sec.id}.json`)
221
93
  const clip = join(tmp, `${pad2(n)}-${sec.id}.wav`)
222
94
 
223
- // Narration wav straight off disk. Missing → 3s silence so the run never hangs.
224
95
  const wav = resolve(repoDir, 'public', 'audio', sec.course, `${sec.id}.wav`)
225
96
  let dur, audioHash
226
97
  if (existsSync(wav)) {
@@ -235,7 +106,6 @@ async function recordReels(course, { force = false, only = [] } = {}) {
235
106
  console.warn(` §${n} ${sec.id}: no audio → 3s silence`)
236
107
  }
237
108
 
238
- // Incremental reuse: re-record iff missing/changed (or --force / --only match).
239
109
  const fp = sha(JSON.stringify({
240
110
  v: 2, audioHash, w: CW, h: CH, fps: FPS, tail: TAIL_MS, enc: ENCODE_SIG, loop: LOOP_SIG,
241
111
  }))
@@ -245,8 +115,6 @@ async function recordReels(course, { force = false, only = [] } = {}) {
245
115
  continue
246
116
  }
247
117
 
248
- // Loop capture rolls for the WINDOW (lead + whole pulse periods + guard) and lets the encode
249
- // repeat it over the reel; NO_LOOP holds for the whole clip + tail as it used to.
250
118
  const total = dur + TAIL_MS / 1000
251
119
  const roll = NO_LOOP ? total : LEAD_S + LOOP_S + GUARD_S
252
120
  await gotoSection(page, appBase, sec.slug)
@@ -261,7 +129,6 @@ async function recordReels(course, { force = false, only = [] } = {}) {
261
129
  await recorder.stop()
262
130
  await stopKeepalive(page)
263
131
 
264
- // One exact, seamlessly-loopable window; -stream_loop repeats it to the reel's length.
265
132
  let video = webm
266
133
  if (!NO_LOOP) {
267
134
  video = join(tmp, `${pad2(n)}-${sec.id}-loop.mp4`)
@@ -275,14 +142,13 @@ async function recordReels(course, { force = false, only = [] } = {}) {
275
142
  }
276
143
  } finally {
277
144
  if (browser) await browser.close()
278
- if (server) server.child.kill('SIGTERM')
145
+ server.child?.kill('SIGTERM')
279
146
  }
280
147
 
281
148
  console.log(`\n✅ ${made.length} reel(s) → scripts/out/reels/`)
282
149
  return made
283
150
  }
284
151
 
285
- // ---- CLI --------------------------------------------------------------------------------
286
152
  function parse(argv) {
287
153
  const pos = []
288
154
  const only = []
@@ -1,7 +1,4 @@
1
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
2
  import { spawn } from 'node:child_process'
6
3
  import { mkdirSync } from 'node:fs'
7
4
  import { join } from 'node:path'
@@ -10,13 +7,15 @@ import { loadPeer, repoDir, dataDir } from './_paths.mjs'
10
7
  const outDir = join(dataDir, 'out', 'shots-4k')
11
8
  mkdirSync(outDir, { recursive: true })
12
9
  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'
10
+ const course = process.argv[2]?.startsWith('--') ? undefined : process.argv[2]
11
+ if (!course) {
12
+ console.error('usage: graphl-shots-4k <course> [--only <id[,id]>]')
13
+ process.exit(2)
14
+ }
14
15
  const onlyArg = process.argv.indexOf('--only')
15
16
  const only = onlyArg !== -1 ? (process.argv[onlyArg + 1] ?? '').split(',').filter(Boolean) : []
16
17
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
17
18
 
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
19
  let child = null, url = process.env.APP_URL ? process.env.APP_URL.replace(/\/?$/, '/') : null
21
20
  if (!url) {
22
21
  child = spawn('npm', ['run', 'dev'], { cwd: repoDir, env: process.env })
@@ -44,7 +43,7 @@ for (const p of plan) {
44
43
  await page.goto(`${url}?capture=1#/${p.slug}`, { waitUntil: 'networkidle2' })
45
44
  await page.waitForSelector('.react-flow__node', { timeout: 15000 })
46
45
  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)
46
+ await sleep(900)
48
47
  const file = join(outDir, `${String(++n).padStart(2, '0')}-${p.id}.png`)
49
48
  await page.screenshot({ path: file })
50
49
  console.log(` ${p.slug} → ${file}`)
@@ -1,9 +1,3 @@
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
1
  <style>
8
2
  html, body { margin: 0; padding: 0; height: 100%; background: #1e2127; }
9
3
  .thumb {
@@ -11,19 +5,18 @@
11
5
  width: 100vw;
12
6
  height: 100vh;
13
7
  overflow: hidden;
14
- background: #1e2127; /* letterbox surround (Zed-slate theme) */
8
+ background: #1e2127;
15
9
  font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
16
10
  }
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. */
11
+
19
12
  .thumb__scene { flex: 0 0 58%; height: 100%; position: relative; background: #1e2127; }
20
13
  .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. */
14
+
22
15
  .thumb__scene::after {
23
16
  content: ''; position: absolute; top: 0; right: 0; width: 8%; height: 100%;
24
17
  background: linear-gradient(90deg, rgba(30,33,39,0), rgba(30,33,39,0.55));
25
18
  }
26
- /* RIGHT — the branded panel. Gradient is substituted ({{PANEL_BG}}) so a course can override it. */
19
+
27
20
  .thumb__panel {
28
21
  flex: 1; position: relative; display: flex; flex-direction: column;
29
22
  padding: 6.4vh 3.8vw;