@graphlearning/shell 0.10.1 → 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,20 +1,9 @@
1
- // Where these scripts read and write, now that they live in a package rather than in each repo.
2
- //
3
- // They used to resolve everything relative to their own file (`here = dirname(import.meta.url)`),
4
- // which worked only because scripts/ sat inside the app. From node_modules that points at the
5
- // package, so the two roots are now explicit:
6
- //
7
- // repoDir — the content repo being built. npm run <script> sets cwd to the package root, so
8
- // process.cwd() IS the repo. Source, public/audio and the dev server live here.
9
- // dataDir — repoDir/scripts: the repo's own script DATA (titles.json, audio-manifest.json) and
10
- // every output (out/, segments/, .tmp/). Deliberately the same paths as before the
11
- // extraction, so .gitignore entries and muscle memory still hold.
12
- // pkgDir — this package's scripts/: machinery that ships with the shell (thumb-template.html).
13
- import { createRequire } from 'node:module'
1
+ import { execFile, spawn } from 'node:child_process'
14
2
  import { existsSync, readFileSync } from 'node:fs'
15
- import { pathToFileURL } from 'node:url'
3
+ import { createRequire } from 'node:module'
16
4
  import { dirname, join, resolve } from 'node:path'
17
- import { fileURLToPath } from 'node:url'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+ import { promisify } from 'node:util'
18
7
 
19
8
  export const pkgDir = dirname(fileURLToPath(import.meta.url))
20
9
  export const repoDir = process.cwd()
@@ -25,48 +14,127 @@ if (!existsSync(join(repoDir, 'package.json'))) {
25
14
  process.exit(1)
26
15
  }
27
16
 
28
- // scripts/concept.json — the per-repo publishing identity. These used to be hardcoded defaults in
29
- // each repo's copy of thumb.mjs / gen-descriptions.mjs, which is exactly why they drifted (aws's
30
- // panel gradient was still AWS-orange in sql's copy for a while). Env vars still override, as before.
31
17
  const CONFIG_PATH = join(dataDir, 'concept.json')
32
18
  const raw = existsSync(CONFIG_PATH) ? JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) : {}
33
19
 
34
20
  export const concept = {
35
- // The concept name used in video DESCRIPTIONS ("Apache Spark"). Not necessarily the app's
36
- // subject — databricks-data-engineer publishes as "Databricks".
37
21
  name: process.env.CONCEPT ?? raw.concept ?? 'GraphL',
38
- // The THUMBNAIL kicker, which is not always the same string: apache-spark's descriptions say
39
- // "Apache Spark" while its thumbnails say "SPARK" (a long name does not fit the panel). The two
40
- // lived in separate script copies before the extraction, so the divergence was invisible —
41
- // collapsing them into one field silently rebrands that concept's thumbnails.
42
22
  kicker: process.env.CONCEPT_KICKER ?? raw.kicker ?? process.env.CONCEPT ?? raw.concept ?? 'GraphL',
43
23
  site: process.env.SITE ?? raw.site ?? 'https://graphl.in',
44
- // The catalog path the app deploys under — usually /<repo>, but aws deploys at /aws-content.
45
24
  appPath: (process.env.APP_PATH ?? raw.appPath ?? '/').replace(/\/$/, ''),
46
25
  hashtags: process.env.HASHTAGS ?? raw.hashtags ?? '#TechEducation',
47
- // The thumbnail's right-hand panel gradient, anchored on the concept's --brand.
48
26
  panelBg: process.env.PANEL_BG ?? raw.panelBg ?? 'radial-gradient(118% 104% at 70% 34%, #5b8cff 0%, #2a4fb8 44%, #0b1330 100%)',
49
27
  }
50
28
 
51
- // Load a peer dependency (puppeteer, esbuild) from the CONTENT REPO, not from this package.
52
- //
53
- // A bare `import('puppeteer')` resolves by walking up from the importing FILE. These scripts live in
54
- // node_modules/@graphlearning/shell/scripts, and under a local `file:../ui-shell` install that path
55
- // is a symlink — node resolves it to the real ui-shell/ directory and walks up from there, never
56
- // reaching the repo's node_modules. Anchoring the resolution at repoDir works under both layouts,
57
- // and it is also the honest description of the dependency: these are the REPO's tools, which the
58
- // scripts borrow. (esbuild previously resolved only by accident, via ui-shell's own vite install —
59
- // that would have failed outright once this package was consumed from the registry.)
29
+ export const run = promisify(execFile)
30
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
31
+ export const titleCase = (slug) => slug.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
32
+
33
+ export const FFMPEG =
34
+ process.env.FFMPEG ?? ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'].find(existsSync) ?? 'ffmpeg'
35
+
36
+ export async function ffprobeDuration(file) {
37
+ const { stdout } = await run('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file])
38
+ return parseFloat(stdout.trim())
39
+ }
40
+
60
41
  const requireFromRepo = createRequire(join(repoDir, 'package.json'))
61
42
 
62
43
  export async function loadPeer(name) {
63
44
  try {
64
45
  return await import(pathToFileURL(requireFromRepo.resolve(name)).href)
65
46
  } catch {
66
- console.error(`Missing "${name}" in ${repoDir}. It is an optional peer of @graphlearning/shell, ` +
67
- `needed by the capture/record scripts — install it there: npm i -D ${name}`)
47
+ console.error(
48
+ `Missing "${name}" in ${repoDir}. It is an optional peer of @graphlearning/shell, ` +
49
+ `needed by the capture/record scripts — install it there: npm i -D ${name}`,
50
+ )
68
51
  process.exit(1)
69
52
  }
70
53
  }
71
54
 
72
- export { resolve }
55
+ export async function loadRegistry() {
56
+ const { build } = await loadPeer('esbuild')
57
+ const result = await build({
58
+ entryPoints: [resolve(repoDir, 'src/content/index.ts')],
59
+ bundle: true,
60
+ format: 'esm',
61
+ platform: 'node',
62
+ write: false,
63
+ })
64
+ return import('data:text/javascript;base64,' + Buffer.from(result.outputFiles[0].text).toString('base64'))
65
+ }
66
+
67
+ export function publishTitles() {
68
+ try {
69
+ const { _comment, ...titles } = JSON.parse(readFileSync(join(dataDir, 'titles.json'), 'utf8'))
70
+ return titles
71
+ } catch {
72
+ return {}
73
+ }
74
+ }
75
+
76
+ async function startDevServer() {
77
+ console.log(`Starting dev server: ${repoDir} …`)
78
+ const child = spawn('npm', ['run', 'dev'], { cwd: repoDir, env: process.env })
79
+ const url = await new Promise((res, rej) => {
80
+ const to = setTimeout(() => rej(new Error('dev server did not print a URL within 60s')), 60000)
81
+ const onData = (buf) => {
82
+ const m = String(buf).match(/https?:\/\/localhost:\d+\/?/)
83
+ if (m) {
84
+ clearTimeout(to)
85
+ child.stdout.off('data', onData)
86
+ res(m[0].replace(/\/?$/, '/'))
87
+ }
88
+ }
89
+ child.stdout.on('data', onData)
90
+ child.stderr.on('data', (b) => process.env.DEBUG && process.stderr.write(b))
91
+ child.on('exit', (code) => rej(new Error(`dev server exited early (code ${code})`)))
92
+ })
93
+ for (let i = 0; i < 40; i++) {
94
+ try {
95
+ if ((await fetch(url)).ok) break
96
+ } catch {}
97
+ await sleep(250)
98
+ }
99
+ console.log(` dev server at ${url}`)
100
+ return { child, url }
101
+ }
102
+
103
+ export const openApp = async () =>
104
+ process.env.APP_URL
105
+ ? { child: null, url: process.env.APP_URL.replace(/\/?$/, '/') }
106
+ : startDevServer()
107
+
108
+ export async function gotoSection(page, appBase, slug) {
109
+ await page.goto(`${appBase}?capture=1#/${slug}`, { waitUntil: 'networkidle2' })
110
+ await page.waitForSelector('.react-flow__node', { timeout: 15000 })
111
+ await page.evaluate(async () => {
112
+ if (document.fonts?.ready) await document.fonts.ready
113
+ })
114
+ await sleep(700)
115
+ }
116
+
117
+ export async function startKeepalive(page) {
118
+ await page.evaluate(() => {
119
+ const d = document.createElement('div')
120
+ d.id = '__cap_keepalive'
121
+ d.style.cssText =
122
+ 'position:fixed;left:0;top:0;width:1px;height:1px;background:#888;opacity:0.01;' +
123
+ 'pointer-events:none;z-index:2147483647;will-change:transform'
124
+ document.body.appendChild(d)
125
+ let x = 0
126
+ const loop = () => {
127
+ x = (x + 3) % 30
128
+ d.style.transform = `translate3d(${x}px,0,0)`
129
+ window.__cap_raf = requestAnimationFrame(loop)
130
+ }
131
+ loop()
132
+ })
133
+ }
134
+
135
+ export async function stopKeepalive(page) {
136
+ await page.evaluate(() => {
137
+ if (window.__cap_raf) cancelAnimationFrame(window.__cap_raf)
138
+ document.getElementById('__cap_keepalive')?.remove()
139
+ })
140
+ }
@@ -1,17 +1,4 @@
1
1
  #!/usr/bin/env node
2
- // capture-shots.mjs — [STEP 2, not yet wired] screenshot every slug's scene + record node coords.
3
- //
4
- // node scripts/capture-shots.mjs [--only <slug[,slug]>]
5
- //
6
- // Plan (blueprint: ../../graphl-studio/aws-lab/scripts/capture-shots.mjs):
7
- // 1. spawn `npm run dev`, open it headless at 4K (3840×2160) with ?capture=1.
8
- // 2. read window.__scene.plan() → the list of slugs (scene + focus node).
9
- // 3. for each slug: render its scene, wait for the painted frame, screenshot → images/<slug>.png
10
- // (full-frame background), and MEASURE node bounding boxes (react-flow node rects) → coords,
11
- // including the focus node's box. Coords are written to a sidecar so the compose step can float
12
- // the text panel clear of the narrating node.
13
- //
14
- // The full-frame PNG is the video background; the coords drive dynamic panel placement.
15
2
 
16
- console.error('capture-shots.mjs is a step-2 stub — not wired yet. See the header for the plan.')
3
+ console.error('capture-shots is not implemented — use graphl-shots-4k.')
17
4
  process.exit(1)
@@ -1,38 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // gen-audio-manifest.mjs — flatten the typed course catalog into the audio manifest.
3
- //
4
- // The Colab/Chatterbox notebook can't parse our TypeScript course files, so this is the bridge:
5
- // it evaluates the REAL `COURSES` registry (esbuild strips the type-only `../types` imports, so the
6
- // content files have no runtime deps) and folds every SECTION into one flat JSON entry, keyed to the
7
- // audio contract path `<courseId>/<section-id>.wav`.
8
- //
9
- // Unlike graphl-studio (one wav per BEAT → `<course>/<section>-<beat>.wav`), this repo's model is
10
- // one section = one unit = one narration, so there is NO beat index — just `<course>/<section>.wav`.
11
- //
12
- // Output: scripts/audio-manifest.json → { count, entries: [{ course, section, file, narration }] }
13
- //
14
- // Run: npm run gen:audio (then commit the json so the notebook sees it)
15
2
 
16
3
  import { writeFileSync } from 'node:fs'
17
4
  import { resolve } from 'node:path'
5
+ import { loadRegistry, dataDir } from './_paths.mjs'
18
6
 
19
- import { loadPeer, repoDir, dataDir } from './_paths.mjs'
20
- const { build } = await loadPeer('esbuild')
21
- const entry = resolve(repoDir, 'src/content/index.ts')
22
7
  const outFile = resolve(dataDir, 'audio-manifest.json')
23
-
24
- // Bundle the content registry to an in-memory ESM string. The content files import ONLY `../types`
25
- // (`import type` → erased), so the bundle has zero runtime deps and imports cleanly in Node.
26
- const result = await build({
27
- entryPoints: [entry],
28
- bundle: true,
29
- format: 'esm',
30
- platform: 'node',
31
- write: false,
32
- })
33
- const code = result.outputFiles[0].text
34
- const mod = await import('data:text/javascript;base64,' + Buffer.from(code).toString('base64'))
35
- const { COURSES } = mod
8
+ const { COURSES } = await loadRegistry()
36
9
 
37
10
  const entries = []
38
11
  for (const course of Object.values(COURSES)) {
@@ -49,7 +22,6 @@ for (const course of Object.values(COURSES)) {
49
22
  const manifest = { count: entries.length, entries }
50
23
  writeFileSync(outFile, JSON.stringify(manifest, null, 2) + '\n', 'utf-8')
51
24
 
52
- // A short human summary — how many sections per course, so a bad fold is obvious at a glance.
53
25
  const perCourse = {}
54
26
  for (const e of entries) perCourse[e.course] = (perCourse[e.course] ?? 0) + 1
55
27
  console.log(`Wrote ${entries.length} section(s) -> scripts/audio-manifest.json`)
@@ -1,100 +1,34 @@
1
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
2
 
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'
3
+ import { mkdirSync, writeFileSync, existsSync } from 'node:fs'
4
+ import { join } from 'node:path'
25
5
 
26
- const run = promisify(execFile)
27
- import { loadPeer, repoDir, dataDir, concept } from './_paths.mjs'
28
- const { build } = await loadPeer('esbuild')
6
+ import { loadRegistry, publishTitles, titleCase, ffprobeDuration, repoDir, dataDir, concept } from './_paths.mjs'
29
7
 
30
- // Match record-course.mjs's timing so chapter marks align with the concatenated video.
31
8
  const STING_MS = process.env.NO_STING ? 0 : process.env.STING_MS ? +process.env.STING_MS : 2800
32
9
  const TAIL_MS = process.env.TAIL_MS ? +process.env.TAIL_MS : 500
33
10
  const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫']
34
11
 
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
12
  const CONCEPT = concept.name
38
13
  const SITE = concept.site
39
14
  const APP_PATH = concept.appPath
40
15
  const HASHTAGS = concept.hashtags
41
16
 
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
- })()
17
+ const PUBLISH_TITLES = publishTitles()
56
18
  const publishTitle = (course) => PUBLISH_TITLES[course.id] ?? course.title
57
19
 
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
20
  function stamp(sec) {
61
21
  const s = Math.floor(sec), h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60
62
22
  const p2 = (n) => String(n).padStart(2, '0')
63
23
  return h > 0 ? `${h}:${p2(m)}:${p2(ss)}` : `${m}:${p2(ss)}`
64
24
  }
65
25
 
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
26
  const RULE = '━━━━━━━━━━━━━━━━'
86
27
  function compose({ course, chapters, series }) {
87
28
  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
29
  const headline = publishTitle(course)
91
30
  L.push(headline.toLowerCase().startsWith(CONCEPT.toLowerCase()) ? headline : `${headline} · ${CONCEPT}`)
92
31
  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
32
  L.push(
99
33
  `Part of GraphL's ${CONCEPT} series — every section pairs one diagram with the idea it explains, ` +
100
34
  `so the picture and the words land together.`,
@@ -120,7 +54,7 @@ async function main() {
120
54
  const [oneCourse] = process.argv.slice(2)
121
55
 
122
56
  const reg = await loadRegistry()
123
- const series = Object.values(reg.COURSES) // catalog order (registry insertion order)
57
+ const series = Object.values(reg.COURSES)
124
58
  const targets = oneCourse ? series.filter((c) => c.id === oneCourse) : series
125
59
  if (!targets.length) {
126
60
  console.error(`✗ no such course "${oneCourse}" (have: ${series.map((c) => c.id).join(', ')})`)
@@ -136,8 +70,6 @@ async function main() {
136
70
  let t = 0
137
71
  let missing = 0
138
72
  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
73
  chapters.push({ start: t, title: section.title || titleCase(section.id) })
142
74
  const wav = join(audioDir, `${section.id}.wav`)
143
75
  const dur = existsSync(wav) ? await ffprobeDuration(wav) : (missing++, 3)
@@ -1,31 +1,4 @@
1
1
  #!/usr/bin/env node
2
- // record-all.mjs — batch 4K capture across courses, pulling narration from GitHub as it lands.
3
- //
4
- // npm run record:all # every course, in syllabus order
5
- // npm run record:all -- --courses internals,performance
6
- // npm run record:all -- --wait 0 # never wait; skip any course missing audio
7
- // npm run record:all -- --no-pull # use the working tree as-is
8
- //
9
- // WHY THIS EXISTS: the narration wavs are produced by a Colab + Chatterbox pass that commits them
10
- // to the repo's branch one section at a time, so the audio for a course arrives WHILE this is
11
- // running. A plain `for c in …; do npm run record -- $c; done` would reach `internals` before its
12
- // nine wavs existed and silently record six sections of 3s silence (record-course.mjs's documented
13
- // fallback — it keeps the pipeline from hanging, which is right for one missing clip and wrong for a
14
- // whole chapter). So before each course this does: git pull → count that course's wavs against
15
- // scripts/audio-manifest.json → if any are missing, keep pulling every --poll until they land or
16
- // --wait expires. A course is only recorded when it is COMPLETE; an incomplete one is skipped and
17
- // named in the summary, never half-recorded.
18
- //
19
- // Everything else is record-course.mjs's job, unchanged: the 2.4s pulse-period loop window, the
20
- // bell lead-in, the per-segment fingerprint cache (so a re-run after a skip costs nothing for the
21
- // courses already done) and the final concat into scripts/out/<course>.mp4.
22
- //
23
- // SLEEP: the repo's `record:all` script wraps this in `caffeinate -ims` so a multi-hour batch
24
- // survives the machine going idle — `"record:all": "caffeinate -ims graphl-record-all"`. -i no idle
25
- // sleep, -m no disk sleep, -s no system sleep (AC power only). -d is deliberately NOT set: capture
26
- // is headless, so forcing the display on all night buys nothing. The wrapper stays in the repo
27
- // rather than inside this script because `caffeinate` is macOS-only and re-exec'ing ourselves under
28
- // it would hide a process layer from anyone reading the batch's output.
29
2
 
30
3
  import { execFileSync, spawn } from 'node:child_process'
31
4
  import { existsSync, readFileSync } from 'node:fs'
@@ -40,13 +13,8 @@ if (!existsSync(manifestPath)) {
40
13
  }
41
14
  const entries = JSON.parse(readFileSync(manifestPath, 'utf8')).entries ?? []
42
15
 
43
- // The recorder is PACKAGE-owned, so resolving it from this file is correct (see _paths.mjs: the rule
44
- // is never to resolve repo data that way). Spawned directly rather than through `npm run record` —
45
- // one less process between the batch and ffmpeg/puppeteer for signals to cross, and it does not
46
- // require the consuming repo to have declared a `record` script.
47
16
  const RECORDER = join(pkgDir, 'record-course.mjs')
48
17
 
49
- // ---- CLI ---------------------------------------------------------------------------------
50
18
  function parse(argv) {
51
19
  const o = { courses: [], waitMin: 240, pollS: 120, pull: true, force: false }
52
20
  for (let i = 0; i < argv.length; i++) {
@@ -65,7 +33,6 @@ function parse(argv) {
65
33
  }
66
34
  const opt = parse(process.argv.slice(2))
67
35
 
68
- // Syllabus order = the manifest's own order (it is generated from COURSES), not alphabetical.
69
36
  const allCourses = [...new Set(entries.map((e) => e.course))]
70
37
  const courses = opt.courses.length ? opt.courses : allCourses
71
38
  const unknown = courses.filter((c) => !allCourses.includes(c))
@@ -77,23 +44,14 @@ if (unknown.length) {
77
44
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
78
45
  const hhmm = () => new Date().toTimeString().slice(0, 8)
79
46
 
80
- // ---- narration state ---------------------------------------------------------------------
81
- // A course is recordable when every section the manifest lists for it has a wav on disk.
82
47
  function audioState(course) {
83
48
  const want = entries.filter((e) => e.course === course)
84
49
  const missing = want.filter((e) => !existsSync(join(repoDir, 'public', 'audio', e.file)))
85
50
  return { total: want.length, have: want.length - missing.length, missing: missing.map((e) => e.section) }
86
51
  }
87
52
 
88
- // ---- git ----------------------------------------------------------------------------------
89
- // The branch is read rather than assumed: most repos record from `main`, but not all of them sit
90
- // there (aws-lab is on `rebuild/atom-library`), and pulling a branch the repo is not on would
91
- // either fail or quietly fetch the wrong wavs. A detached HEAD has nothing to track, so pulling is
92
- // skipped instead of guessed at.
93
53
  function currentBranch() {
94
54
  try {
95
- // stderr ignored: outside a git repo this prints git's own "fatal: not a git repository" ahead
96
- // of the note below, which reads like the batch broke when it is a supported case.
97
55
  const b = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'],
98
56
  { cwd: repoDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
99
57
  return b && b !== 'HEAD' ? b : null
@@ -103,8 +61,6 @@ function currentBranch() {
103
61
  }
104
62
  const branch = opt.pull ? currentBranch() : null
105
63
 
106
- // Fast-forward only: this repo's working tree is never the place Colab's commits get merged, so a
107
- // non-ff situation means something unexpected and the batch should say so rather than resolve it.
108
64
  function pull() {
109
65
  if (!opt.pull) return { ok: true, note: 'skipped (--no-pull)' }
110
66
  if (!branch) return { ok: true, note: 'skipped (detached HEAD or not a git repo)' }
@@ -114,13 +70,11 @@ function pull() {
114
70
  const after = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoDir, encoding: 'utf8' }).trim()
115
71
  return { ok: true, note: before === after ? 'already current' : `${before.slice(0, 7)} → ${after.slice(0, 7)}` }
116
72
  } catch (e) {
117
- // Network blips and transient GitHub errors are expected over a multi-hour run — report and
118
- // carry on with whatever the working tree already has rather than aborting the batch.
119
73
  return { ok: false, note: (e.stderr?.toString() || e.message).trim().split('\n').pop() }
120
74
  }
121
75
  }
122
76
 
123
- // ---- record ---------------------------------------------------------------------------------
77
+ let current = null
124
78
  function record(course) {
125
79
  return new Promise((done) => {
126
80
  const args = [RECORDER, course, ...(opt.force ? ['--force'] : [])]
@@ -131,13 +85,10 @@ function record(course) {
131
85
  })
132
86
  }
133
87
 
134
- // Forward Ctrl-C to the running recorder so ffmpeg/puppeteer/vite do not outlive the batch.
135
- let current = null
136
88
  for (const sig of ['SIGINT', 'SIGTERM']) {
137
89
  process.on(sig, () => { current?.kill(sig); console.log(`\n${sig} — stopping batch.`); process.exit(130) })
138
90
  }
139
91
 
140
- // ---- the batch --------------------------------------------------------------------------------
141
92
  const results = []
142
93
  console.log(`Batch 4K capture — ${courses.length} course(s): ${courses.join(', ')}`)
143
94
  console.log(` pull=${opt.pull}${branch ? ` (origin/${branch})` : ''} wait=${opt.waitMin}min poll=${opt.pollS}s force=${opt.force}\n`)
@@ -149,8 +100,6 @@ for (const course of courses) {
149
100
  console.log(` git pull: ${p.ok ? p.note : `FAILED — ${p.note} (continuing with local tree)`}`)
150
101
 
151
102
  let st = audioState(course)
152
- // Colab commits one section at a time, so an incomplete course is usually just EARLY. Keep
153
- // pulling until it completes or the budget runs out; --wait 0 turns this into a plain skip.
154
103
  const deadline = Date.now() + opt.waitMin * 60_000
155
104
  while (st.missing.length && Date.now() < deadline) {
156
105
  console.log(` audio ${st.have}/${st.total} — waiting ${opt.pollS}s for: ${st.missing.join(', ')}`)
@@ -179,7 +128,6 @@ for (const course of courses) {
179
128
  }
180
129
  }
181
130
 
182
- // ---- summary ------------------------------------------------------------------------------------
183
131
  console.log(`\n${'═'.repeat(72)}\nSummary [${hhmm()}]`)
184
132
  for (const r of results) {
185
133
  const mark = r.status === 'ok' ? '✅' : r.status === 'skipped' ? '⊘ ' : '✗ '