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