@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.
- package/dist/ConceptApp.d.ts +1 -1
- package/dist/CourseIndex.d.ts +2 -2
- package/dist/SectionView.d.ts +11 -11
- package/dist/ThemeToggle.d.ts +1 -0
- package/dist/index.js +233 -313
- package/dist/index.js.map +1 -1
- package/dist/styles.css +70 -233
- package/dist/types.d.ts +4 -3
- package/dist/useTheme.d.ts +0 -12
- package/package.json +1 -1
- package/scripts/_paths.mjs +106 -38
- package/scripts/capture-shots.mjs +1 -14
- package/scripts/gen-audio-manifest.mjs +2 -30
- package/scripts/gen-descriptions.mjs +5 -73
- package/scripts/record-all.mjs +1 -53
- package/scripts/record-course.mjs +6 -168
- package/scripts/record-reels.mjs +5 -139
- package/scripts/shots-4k.mjs +6 -7
- package/scripts/thumb-template.html +4 -11
- package/scripts/thumb.mjs +10 -105
package/scripts/_paths.mjs
CHANGED
|
@@ -1,20 +1,9 @@
|
|
|
1
|
-
|
|
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 {
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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(
|
|
67
|
-
|
|
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
|
|
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
|
|
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 {
|
|
22
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
package/scripts/record-all.mjs
CHANGED
|
@@ -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
|
-
|
|
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' ? '⊘ ' : '✗ '
|