@graphlearning/shell 0.1.0 → 0.2.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 +40 -0
- 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
package/README.md
CHANGED
|
@@ -77,3 +77,43 @@ regression is visible here before it reaches any content repo.
|
|
|
77
77
|
second react-flow store) inside this package. The content repo pins the engine itself and must get
|
|
78
78
|
exactly one copy — and a wide peer range (`>=0.5.0 <1.0.0`) means an engine minor does not force a
|
|
79
79
|
republish of this package.
|
|
80
|
+
|
|
81
|
+
## The toolchain
|
|
82
|
+
|
|
83
|
+
The capture / record / publish scripts ship with this package too — they were byte-identical in all
|
|
84
|
+
seven repos and they drive the route contract above, so they version with the shell that defines it.
|
|
85
|
+
They are `bin` entries, wired through each repo's `npm run`:
|
|
86
|
+
|
|
87
|
+
| command | what it does |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `npm run record` | one course → a 3840×2160 MP4 |
|
|
90
|
+
| `npm run record:reels` | one course → portrait reels |
|
|
91
|
+
| `npm run shots:4k` | one PNG per section, no ffmpeg |
|
|
92
|
+
| `npm run thumb` | a branded YouTube thumbnail |
|
|
93
|
+
| `npm run gen:desc` | video descriptions + chapters |
|
|
94
|
+
| `npm run gen:audio` | the narration manifest |
|
|
95
|
+
|
|
96
|
+
**Where things live.** Scripts resolve two roots explicitly (`scripts/_paths.mjs`), because from
|
|
97
|
+
`node_modules` they can no longer use their own directory:
|
|
98
|
+
|
|
99
|
+
- `repoDir` = `process.cwd()` — the content repo. `npm run` sets cwd to the package root.
|
|
100
|
+
- `dataDir` = `repoDir/scripts` — the repo's script data (`concept.json`, `titles.json`,
|
|
101
|
+
`audio-manifest.json`) **and every output** (`out/`, `segments/`, `.tmp/`), at exactly the paths
|
|
102
|
+
they used before the extraction, so existing `.gitignore` entries still hold.
|
|
103
|
+
- `pkgDir` — machinery that ships here (`thumb-template.html`).
|
|
104
|
+
|
|
105
|
+
**Peer resolution.** `puppeteer` and `esbuild` are optional peers, loaded via `loadPeer()` which
|
|
106
|
+
resolves them **from the repo**, not from this package. A bare `import('puppeteer')` resolves by
|
|
107
|
+
walking up from the importing file, which under a local `file:` install lands in `ui-shell/` and
|
|
108
|
+
never reaches the repo's `node_modules`.
|
|
109
|
+
|
|
110
|
+
**`scripts/concept.json`** is the per-repo publishing identity — the values that used to be
|
|
111
|
+
hardcoded defaults in each repo's copy, which is precisely why they drifted:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{ "concept": "Apache Spark", "kicker": "SPARK", "site": "https://graphl.in",
|
|
115
|
+
"appPath": "/apache-spark", "hashtags": "#ApacheSpark …", "panelBg": "radial-gradient(…)" }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`concept` is the name used in descriptions; `kicker` is the thumbnail panel's label and defaults to
|
|
119
|
+
`concept` — they differ only where a long name will not fit the panel (apache-spark).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphlearning/shell",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The GraphL concept-app shell
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The GraphL concept-app shell \u2014 hash router, section view (scene left / slide right), slide panel, course catalog and narration channel, plus the capture/record/publish toolchain that drives them. A content repo supplies courses + scenes.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
11
|
"files": [
|
|
12
|
-
"dist"
|
|
12
|
+
"dist",
|
|
13
|
+
"scripts"
|
|
13
14
|
],
|
|
14
15
|
"publishConfig": {
|
|
15
16
|
"access": "public"
|
|
@@ -32,7 +33,9 @@
|
|
|
32
33
|
"react": "^19.2.0",
|
|
33
34
|
"react-dom": "^19.2.0",
|
|
34
35
|
"react-markdown": "^10.1.0",
|
|
35
|
-
"remark-gfm": "^4.0.1"
|
|
36
|
+
"remark-gfm": "^4.0.1",
|
|
37
|
+
"puppeteer": ">=24",
|
|
38
|
+
"esbuild": ">=0.20"
|
|
36
39
|
},
|
|
37
40
|
"devDependencies": {
|
|
38
41
|
"@graphlearning/flow": "^0.5.0",
|
|
@@ -47,5 +50,22 @@
|
|
|
47
50
|
"remark-gfm": "^4.0.1",
|
|
48
51
|
"typescript": "~5.7.2",
|
|
49
52
|
"vite": "^6.0.5"
|
|
53
|
+
},
|
|
54
|
+
"bin": {
|
|
55
|
+
"graphl-record": "scripts/record-course.mjs",
|
|
56
|
+
"graphl-record-reels": "scripts/record-reels.mjs",
|
|
57
|
+
"graphl-shots-4k": "scripts/shots-4k.mjs",
|
|
58
|
+
"graphl-capture-shots": "scripts/capture-shots.mjs",
|
|
59
|
+
"graphl-gen-audio": "scripts/gen-audio-manifest.mjs",
|
|
60
|
+
"graphl-gen-desc": "scripts/gen-descriptions.mjs",
|
|
61
|
+
"graphl-thumb": "scripts/thumb.mjs"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"puppeteer": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"esbuild": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
50
70
|
}
|
|
51
71
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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'
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { pathToFileURL } from 'node:url'
|
|
16
|
+
import { dirname, join, resolve } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
export const pkgDir = dirname(fileURLToPath(import.meta.url))
|
|
20
|
+
export const repoDir = process.cwd()
|
|
21
|
+
export const dataDir = join(repoDir, 'scripts')
|
|
22
|
+
|
|
23
|
+
if (!existsSync(join(repoDir, 'package.json'))) {
|
|
24
|
+
console.error(`No package.json in ${repoDir} — run this from a content repo's root (npm run …).`)
|
|
25
|
+
process.exit(1)
|
|
26
|
+
}
|
|
27
|
+
|
|
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
|
+
const CONFIG_PATH = join(dataDir, 'concept.json')
|
|
32
|
+
const raw = existsSync(CONFIG_PATH) ? JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) : {}
|
|
33
|
+
|
|
34
|
+
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
|
+
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
|
+
kicker: process.env.CONCEPT_KICKER ?? raw.kicker ?? process.env.CONCEPT ?? raw.concept ?? 'GraphL',
|
|
43
|
+
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
|
+
appPath: (process.env.APP_PATH ?? raw.appPath ?? '/').replace(/\/$/, ''),
|
|
46
|
+
hashtags: process.env.HASHTAGS ?? raw.hashtags ?? '#TechEducation',
|
|
47
|
+
// The thumbnail's right-hand panel gradient, anchored on the concept's --brand.
|
|
48
|
+
panelBg: process.env.PANEL_BG ?? raw.panelBg ?? 'radial-gradient(118% 104% at 70% 34%, #5b8cff 0%, #2a4fb8 44%, #0b1330 100%)',
|
|
49
|
+
}
|
|
50
|
+
|
|
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.)
|
|
60
|
+
const requireFromRepo = createRequire(join(repoDir, 'package.json'))
|
|
61
|
+
|
|
62
|
+
export async function loadPeer(name) {
|
|
63
|
+
try {
|
|
64
|
+
return await import(pathToFileURL(requireFromRepo.resolve(name)).href)
|
|
65
|
+
} 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}`)
|
|
68
|
+
process.exit(1)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { resolve }
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
|
|
16
|
+
console.error('capture-shots.mjs is a step-2 stub — not wired yet. See the header for the plan.')
|
|
17
|
+
process.exit(1)
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
|
|
16
|
+
import { writeFileSync } from 'node:fs'
|
|
17
|
+
import { resolve } from 'node:path'
|
|
18
|
+
|
|
19
|
+
import { loadPeer, repoDir, dataDir } from './_paths.mjs'
|
|
20
|
+
const { build } = await loadPeer('esbuild')
|
|
21
|
+
const entry = resolve(repoDir, 'src/content/index.ts')
|
|
22
|
+
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
|
|
36
|
+
|
|
37
|
+
const entries = []
|
|
38
|
+
for (const course of Object.values(COURSES)) {
|
|
39
|
+
course.sections.forEach((section) => {
|
|
40
|
+
entries.push({
|
|
41
|
+
course: course.id,
|
|
42
|
+
section: section.id,
|
|
43
|
+
file: `${course.id}/${section.id}.wav`,
|
|
44
|
+
narration: section.narration,
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const manifest = { count: entries.length, entries }
|
|
50
|
+
writeFileSync(outFile, JSON.stringify(manifest, null, 2) + '\n', 'utf-8')
|
|
51
|
+
|
|
52
|
+
// A short human summary — how many sections per course, so a bad fold is obvious at a glance.
|
|
53
|
+
const perCourse = {}
|
|
54
|
+
for (const e of entries) perCourse[e.course] = (perCourse[e.course] ?? 0) + 1
|
|
55
|
+
console.log(`Wrote ${entries.length} section(s) -> scripts/audio-manifest.json`)
|
|
56
|
+
for (const c of Object.values(COURSES))
|
|
57
|
+
console.log(` ${c.id.padEnd(12)} ${perCourse[c.id] ?? 0} section(s)`)
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,247 @@
|
|
|
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 for the wav's duration (ffprobe) + a short tail → mux.
|
|
15
|
+
//
|
|
16
|
+
// TRUE portrait pixels: the layout is fluid, so we set the puppeteer VIEWPORT to 1080×1920 directly
|
|
17
|
+
// and page.screencast() records at exactly that CSS size. Audio is read straight off disk from
|
|
18
|
+
// public/audio/<course>/<id>.wav; a missing clip falls back to 3s silence so the run never hangs.
|
|
19
|
+
//
|
|
20
|
+
// Prerequisites: ffmpeg + ffprobe on PATH (Homebrew ffmpeg preferred — libx264 + gradfun deband).
|
|
21
|
+
|
|
22
|
+
import { execFile, spawn } from 'node:child_process'
|
|
23
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
|
|
24
|
+
import { createHash } from 'node:crypto'
|
|
25
|
+
import { promisify } from 'node:util'
|
|
26
|
+
import { join, resolve } from 'node:path'
|
|
27
|
+
|
|
28
|
+
const run = promisify(execFile)
|
|
29
|
+
import { loadPeer, repoDir, dataDir } from './_paths.mjs'
|
|
30
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
31
|
+
const sha = (data) => createHash('sha256').update(data).digest('hex').slice(0, 16)
|
|
32
|
+
const pad2 = (n) => String(n).padStart(2, '0')
|
|
33
|
+
const readJson = (f) => { try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null } }
|
|
34
|
+
|
|
35
|
+
// ---- portrait reel frame ----------------------------------------------------------------
|
|
36
|
+
const CW = process.env.WIDTH ? +process.env.WIDTH : 1080
|
|
37
|
+
const CH = process.env.HEIGHT ? +process.env.HEIGHT : 1920
|
|
38
|
+
const FPS = process.env.FPS ? +process.env.FPS : 30
|
|
39
|
+
// A short held tail so the reel doesn't cut on the last syllable.
|
|
40
|
+
const TAIL_MS = process.env.TAIL_MS ? +process.env.TAIL_MS : 500
|
|
41
|
+
|
|
42
|
+
const FFMPEG =
|
|
43
|
+
process.env.FFMPEG ??
|
|
44
|
+
['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'].find(existsSync) ??
|
|
45
|
+
'ffmpeg'
|
|
46
|
+
const HAS_X264 = FFMPEG !== 'ffmpeg'
|
|
47
|
+
const VIDEO_CODEC = process.env.VIDEO_CODEC ?? (HAS_X264 ? 'libx264' : 'h264_videotoolbox')
|
|
48
|
+
const IS_X26X = /^libx26[45]$/.test(VIDEO_CODEC)
|
|
49
|
+
const CRF = process.env.VIDEO_CRF ?? '18'
|
|
50
|
+
const PRESET = process.env.VIDEO_PRESET ?? 'slow'
|
|
51
|
+
const BITRATE = process.env.VIDEO_BITRATE ?? '16M' // 1080×1920 ≈ 1080p pixel budget
|
|
52
|
+
const ENCODE_SIG = IS_X26X ? `${VIDEO_CODEC}:crf${CRF}:${PRESET}` : `${VIDEO_CODEC}:b${BITRATE}`
|
|
53
|
+
|
|
54
|
+
// ---- ffmpeg helpers ---------------------------------------------------------------------
|
|
55
|
+
async function ffprobeDuration(file) {
|
|
56
|
+
const { stdout } = await run('ffprobe', [
|
|
57
|
+
'-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file,
|
|
58
|
+
])
|
|
59
|
+
return parseFloat(stdout.trim())
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Mux one reel's webm + clip → a standalone MP4. Same crisp encode as the 4K recorder (gradfun
|
|
63
|
+
// deband, yuv420p, faststart) but this is a FINAL file, not a concat segment, so it needs no uniform
|
|
64
|
+
// timescale. `total` (clip + tail) bounds both streams; apad extends the clip with silence to fill.
|
|
65
|
+
async function encodeReel(webm, clip, total, outMp4) {
|
|
66
|
+
const quality = IS_X26X ? ['-preset', PRESET, '-crf', CRF] : ['-b:v', BITRATE]
|
|
67
|
+
await run(FFMPEG, [
|
|
68
|
+
'-y', '-i', webm, '-i', clip,
|
|
69
|
+
'-map', '0:v:0', '-map', '1:a:0',
|
|
70
|
+
'-vf', 'gradfun=strength=0.9:radius=16',
|
|
71
|
+
'-r', String(FPS), '-vsync', 'cfr',
|
|
72
|
+
'-c:v', VIDEO_CODEC, ...quality, '-pix_fmt', 'yuv420p',
|
|
73
|
+
'-af', 'apad', '-t', total.toFixed(3),
|
|
74
|
+
'-c:a', 'aac', '-b:a', '192k', '-ar', '44100', '-ac', '1',
|
|
75
|
+
'-movflags', '+faststart',
|
|
76
|
+
outMp4,
|
|
77
|
+
])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- the app dev server -----------------------------------------------------------------
|
|
81
|
+
async function startDevServer() {
|
|
82
|
+
console.log(`Starting dev server: ${repoDir} …`)
|
|
83
|
+
const child = spawn('npm', ['run', 'dev'], { cwd: repoDir, env: process.env })
|
|
84
|
+
const url = await new Promise((res, rej) => {
|
|
85
|
+
const to = setTimeout(() => rej(new Error('dev server did not print a URL within 60s')), 60000)
|
|
86
|
+
const onData = (buf) => {
|
|
87
|
+
const m = String(buf).match(/https?:\/\/localhost:\d+\/?/)
|
|
88
|
+
if (m) { clearTimeout(to); child.stdout.off('data', onData); res(m[0].replace(/\/?$/, '/')) }
|
|
89
|
+
}
|
|
90
|
+
child.stdout.on('data', onData)
|
|
91
|
+
child.stderr.on('data', (b) => process.env.DEBUG && process.stderr.write(b))
|
|
92
|
+
child.on('exit', (code) => rej(new Error(`dev server exited early (code ${code})`)))
|
|
93
|
+
})
|
|
94
|
+
for (let i = 0; i < 40; i++) {
|
|
95
|
+
try { if ((await fetch(url)).ok) break } catch { /* not up yet */ }
|
|
96
|
+
await sleep(250)
|
|
97
|
+
}
|
|
98
|
+
console.log(` dev server at ${url}`)
|
|
99
|
+
return { child, url }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Navigate to a section and wait for its scene to be painted AND fitView-settled. A fresh goto per
|
|
103
|
+
// reel forces a clean react-flow remount (it keys on scene id), so no stale prior scene is in frame.
|
|
104
|
+
async function gotoSection(page, appBase, slug) {
|
|
105
|
+
await page.goto(`${appBase}?capture=1#/${slug}`, { waitUntil: 'networkidle2' })
|
|
106
|
+
await page.waitForSelector('.react-flow__node', { timeout: 15000 })
|
|
107
|
+
await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready })
|
|
108
|
+
await sleep(700) // fitView (instant) + ResizeObserver re-fit + edge-pulse settle
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// CDP screencast only emits a frame on a VISUAL CHANGE, so an otherwise-static reel records an EMPTY
|
|
112
|
+
// webm and the mux fails. A 1px, ~1%-opacity speck nudged every animation frame keeps frames flowing;
|
|
113
|
+
// it is quantized away by x264. Removed on stop.
|
|
114
|
+
async function startKeepalive(page) {
|
|
115
|
+
await page.evaluate(() => {
|
|
116
|
+
const d = document.createElement('div')
|
|
117
|
+
d.id = '__cap_keepalive'
|
|
118
|
+
d.style.cssText =
|
|
119
|
+
'position:fixed;left:0;top:0;width:1px;height:1px;background:#888;opacity:0.01;' +
|
|
120
|
+
'pointer-events:none;z-index:2147483647;will-change:transform'
|
|
121
|
+
document.body.appendChild(d)
|
|
122
|
+
let x = 0
|
|
123
|
+
const loop = () => {
|
|
124
|
+
x = (x + 3) % 30
|
|
125
|
+
d.style.transform = `translate3d(${x}px,0,0)`
|
|
126
|
+
window.__cap_raf = requestAnimationFrame(loop)
|
|
127
|
+
}
|
|
128
|
+
loop()
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
async function stopKeepalive(page) {
|
|
132
|
+
await page.evaluate(() => {
|
|
133
|
+
if (window.__cap_raf) cancelAnimationFrame(window.__cap_raf)
|
|
134
|
+
document.getElementById('__cap_keepalive')?.remove()
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- record -----------------------------------------------------------------------------
|
|
139
|
+
async function recordReels(course, { force = false, only = [] } = {}) {
|
|
140
|
+
const tmp = join(dataDir, '.tmp', `${course}-reels`)
|
|
141
|
+
const outDir = join(dataDir, 'out', 'reels')
|
|
142
|
+
for (const d of [tmp, outDir]) mkdirSync(d, { recursive: true })
|
|
143
|
+
|
|
144
|
+
let server = null
|
|
145
|
+
const base = process.env.APP_URL ? process.env.APP_URL.replace(/\/?$/, '/') : null
|
|
146
|
+
const appBase = base ?? (server = await startDevServer(), server.url)
|
|
147
|
+
|
|
148
|
+
const puppeteer = (await loadPeer('puppeteer')).default
|
|
149
|
+
let browser
|
|
150
|
+
const made = []
|
|
151
|
+
try {
|
|
152
|
+
browser = await puppeteer.launch({
|
|
153
|
+
headless: true,
|
|
154
|
+
defaultViewport: { width: CW, height: CH, deviceScaleFactor: 1 },
|
|
155
|
+
args: [`--window-size=${CW},${CH}`],
|
|
156
|
+
})
|
|
157
|
+
const page = await browser.newPage()
|
|
158
|
+
await page.goto(`${appBase}?capture=1#/${course}`, { waitUntil: 'networkidle2' })
|
|
159
|
+
|
|
160
|
+
await page.waitForFunction(() => !!window.__scene, { timeout: 20000 })
|
|
161
|
+
const plan = await page.evaluate(() => window.__scene.plan())
|
|
162
|
+
if (!plan?.length) throw new Error(`course "${course}" has no sections (bad id?)`)
|
|
163
|
+
console.log(`Course ${course}: ${plan.length} reels @ ${CW}×${CH}\n`)
|
|
164
|
+
|
|
165
|
+
let n = 0
|
|
166
|
+
for (const sec of plan) {
|
|
167
|
+
n++
|
|
168
|
+
const tag = `${course}-${sec.id}`
|
|
169
|
+
// Each reel is its own file, so --only truly RESTRICTS the set: skip any section not listed.
|
|
170
|
+
if (only.length && !only.some((t) => sec.id.includes(t) || tag.includes(t))) continue
|
|
171
|
+
const outMp4 = join(outDir, `${tag}.mp4`)
|
|
172
|
+
const sidecar = join(tmp, `${pad2(n)}-${sec.id}.json`)
|
|
173
|
+
const clip = join(tmp, `${pad2(n)}-${sec.id}.wav`)
|
|
174
|
+
|
|
175
|
+
// Narration wav straight off disk. Missing → 3s silence so the run never hangs.
|
|
176
|
+
const wav = resolve(repoDir, 'public', 'audio', sec.course, `${sec.id}.wav`)
|
|
177
|
+
let dur, audioHash
|
|
178
|
+
if (existsSync(wav)) {
|
|
179
|
+
const buf = readFileSync(wav)
|
|
180
|
+
writeFileSync(clip, buf)
|
|
181
|
+
audioHash = sha(buf)
|
|
182
|
+
dur = await ffprobeDuration(clip)
|
|
183
|
+
} else {
|
|
184
|
+
await run(FFMPEG, ['-y', '-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono', '-t', '3', clip])
|
|
185
|
+
audioHash = 'silence3'
|
|
186
|
+
dur = 3
|
|
187
|
+
console.warn(` §${n} ${sec.id}: no audio → 3s silence`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Incremental reuse: re-record iff missing/changed (or --force / --only match).
|
|
191
|
+
const fp = sha(JSON.stringify({ v: 1, audioHash, w: CW, h: CH, fps: FPS, tail: TAIL_MS, enc: ENCODE_SIG }))
|
|
192
|
+
if (!(force || !existsSync(outMp4) || readJson(sidecar)?.fp !== fp)) {
|
|
193
|
+
console.log(` §${n} ${sec.id} reuse`)
|
|
194
|
+
made.push(outMp4)
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const total = dur + TAIL_MS / 1000
|
|
199
|
+
await gotoSection(page, appBase, sec.slug)
|
|
200
|
+
|
|
201
|
+
const webm = join(tmp, `${pad2(n)}-${sec.id}.webm`)
|
|
202
|
+
const recorder = await page.screencast({ path: webm })
|
|
203
|
+
await startKeepalive(page)
|
|
204
|
+
console.log(` §${n} ${sec.id} ▶ record (${dur.toFixed(1)}s)`)
|
|
205
|
+
await sleep(Math.round(total * 1000))
|
|
206
|
+
await recorder.stop()
|
|
207
|
+
await stopKeepalive(page)
|
|
208
|
+
|
|
209
|
+
await encodeReel(webm, clip, total, outMp4)
|
|
210
|
+
writeFileSync(sidecar, JSON.stringify({ fp, builtAt: new Date().toISOString() }, null, 2))
|
|
211
|
+
made.push(outMp4)
|
|
212
|
+
console.log(` §${n} ${sec.id} ✓ reels/${tag}.mp4`)
|
|
213
|
+
}
|
|
214
|
+
} finally {
|
|
215
|
+
if (browser) await browser.close()
|
|
216
|
+
if (server) server.child.kill('SIGTERM')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
console.log(`\n✅ ${made.length} reel(s) → scripts/out/reels/`)
|
|
220
|
+
return made
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---- CLI --------------------------------------------------------------------------------
|
|
224
|
+
function parse(argv) {
|
|
225
|
+
const pos = []
|
|
226
|
+
const only = []
|
|
227
|
+
let force = false
|
|
228
|
+
for (let i = 0; i < argv.length; i++) {
|
|
229
|
+
const a = argv[i]
|
|
230
|
+
if (a === '--force') force = true
|
|
231
|
+
else if (a === '--only') only.push(...(argv[++i] ?? '').split(',').filter(Boolean))
|
|
232
|
+
else if (a.startsWith('--only=')) only.push(...a.slice(7).split(',').filter(Boolean))
|
|
233
|
+
else pos.push(a)
|
|
234
|
+
}
|
|
235
|
+
return { pos, only, force }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const { pos, only, force } = parse(process.argv.slice(2))
|
|
239
|
+
const [course] = pos
|
|
240
|
+
if (!course) {
|
|
241
|
+
console.error('usage: node scripts/record-reels.mjs <course> [--force] [--only <id[,id]>]')
|
|
242
|
+
process.exit(2)
|
|
243
|
+
}
|
|
244
|
+
recordReels(course, { force, only }).catch((e) => {
|
|
245
|
+
console.error('\n✗', e.message)
|
|
246
|
+
process.exit(1)
|
|
247
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import { mkdirSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
import { loadPeer, repoDir, dataDir } from './_paths.mjs'
|
|
10
|
+
const outDir = join(dataDir, 'out', 'shots-4k')
|
|
11
|
+
mkdirSync(outDir, { recursive: true })
|
|
12
|
+
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'
|
|
14
|
+
const onlyArg = process.argv.indexOf('--only')
|
|
15
|
+
const only = onlyArg !== -1 ? (process.argv[onlyArg + 1] ?? '').split(',').filter(Boolean) : []
|
|
16
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
17
|
+
|
|
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
|
+
let child = null, url = process.env.APP_URL ? process.env.APP_URL.replace(/\/?$/, '/') : null
|
|
21
|
+
if (!url) {
|
|
22
|
+
child = spawn('npm', ['run', 'dev'], { cwd: repoDir, env: process.env })
|
|
23
|
+
url = await new Promise((res, rej) => {
|
|
24
|
+
const to = setTimeout(() => rej(new Error('no dev url in 60s')), 60000)
|
|
25
|
+
child.stdout.on('data', (b) => { const m = String(b).match(/https?:\/\/localhost:\d+\/?/); if (m) { clearTimeout(to); res(m[0].replace(/\/?$/, '/')) } })
|
|
26
|
+
child.on('exit', (c) => rej(new Error(`dev exited ${c}`)))
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
for (let i = 0; i < 40; i++) { try { if ((await fetch(url)).ok) break } catch {} await sleep(250) }
|
|
30
|
+
console.log(`server ${url} — shooting ${CW}×${CH}`)
|
|
31
|
+
|
|
32
|
+
const puppeteer = (await loadPeer('puppeteer')).default
|
|
33
|
+
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] })
|
|
34
|
+
const page = await browser.newPage()
|
|
35
|
+
await page.setViewport({ width: CW, height: CH, deviceScaleFactor: 1 })
|
|
36
|
+
|
|
37
|
+
await page.goto(`${url}?capture=1#/`, { waitUntil: 'networkidle2' })
|
|
38
|
+
await page.waitForFunction(() => !!window.__scene, { timeout: 20000 })
|
|
39
|
+
let plan = await page.evaluate(() => window.__scene.plan())
|
|
40
|
+
plan = plan.filter((p) => p.course === course && (only.length === 0 || only.includes(p.id)))
|
|
41
|
+
|
|
42
|
+
let n = 0
|
|
43
|
+
for (const p of plan) {
|
|
44
|
+
await page.goto(`${url}?capture=1#/${p.slug}`, { waitUntil: 'networkidle2' })
|
|
45
|
+
await page.waitForSelector('.react-flow__node', { timeout: 15000 })
|
|
46
|
+
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)
|
|
48
|
+
const file = join(outDir, `${String(++n).padStart(2, '0')}-${p.id}.png`)
|
|
49
|
+
await page.screenshot({ path: file })
|
|
50
|
+
console.log(` ${p.slug} → ${file}`)
|
|
51
|
+
}
|
|
52
|
+
await browser.close(); child?.kill('SIGTERM')
|
|
53
|
+
console.log(`\n✅ ${n} shots in ${outDir}`)
|
|
54
|
+
process.exit(0)
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
<style>
|
|
8
|
+
html, body { margin: 0; padding: 0; height: 100%; background: #1e2127; }
|
|
9
|
+
.thumb {
|
|
10
|
+
display: flex;
|
|
11
|
+
width: 100vw;
|
|
12
|
+
height: 100vh;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
background: #1e2127; /* letterbox surround (Zed-slate theme) */
|
|
15
|
+
font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
|
|
16
|
+
}
|
|
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. */
|
|
19
|
+
.thumb__scene { flex: 0 0 58%; height: 100%; position: relative; background: #1e2127; }
|
|
20
|
+
.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. */
|
|
22
|
+
.thumb__scene::after {
|
|
23
|
+
content: ''; position: absolute; top: 0; right: 0; width: 8%; height: 100%;
|
|
24
|
+
background: linear-gradient(90deg, rgba(30,33,39,0), rgba(30,33,39,0.55));
|
|
25
|
+
}
|
|
26
|
+
/* RIGHT — the branded panel. Gradient is substituted ({{PANEL_BG}}) so a course can override it. */
|
|
27
|
+
.thumb__panel {
|
|
28
|
+
flex: 1; position: relative; display: flex; flex-direction: column;
|
|
29
|
+
padding: 6.4vh 3.8vw;
|
|
30
|
+
color: #fff;
|
|
31
|
+
background: {{PANEL_BG}};
|
|
32
|
+
}
|
|
33
|
+
.thumb__kicker {
|
|
34
|
+
font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
35
|
+
text-transform: uppercase; letter-spacing: 0.26em; font-weight: 500;
|
|
36
|
+
font-size: 3.5vh; color: rgba(255, 255, 255, 0.94);
|
|
37
|
+
}
|
|
38
|
+
.thumb__titlewrap { margin-top: auto; margin-bottom: auto; }
|
|
39
|
+
.thumb__number { font-weight: 700; font-size: 14vh; line-height: 0.98; letter-spacing: -0.01em; }
|
|
40
|
+
.thumb__title { font-weight: 700; font-size: 9.4vh; line-height: 1.03; letter-spacing: -0.025em; margin-top: 0.6vh; }
|
|
41
|
+
.thumb__foot { display: flex; align-items: center; gap: 1.5vh; }
|
|
42
|
+
.thumb__logo { width: 5vh; height: 5vh; border-radius: 1.1vh; display: block; }
|
|
43
|
+
.thumb__brand { font-weight: 700; font-size: 3.1vh; letter-spacing: -0.01em; }
|
|
44
|
+
</style>
|
|
45
|
+
<div class="thumb">
|
|
46
|
+
<div class="thumb__scene"><img src="{{SCENE}}" alt=""></div>
|
|
47
|
+
<div class="thumb__panel">
|
|
48
|
+
<div class="thumb__kicker">{{KICKER}}</div>
|
|
49
|
+
<div class="thumb__titlewrap">{{NUMBER}}<div class="thumb__title">{{TITLE}}</div></div>
|
|
50
|
+
<div class="thumb__foot">{{LOGO}}<div class="thumb__brand">GraphL</div></div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// thumb.mjs — a crisp, branded YouTube thumbnail for a course.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/thumb.mjs <course> [--section N]
|
|
5
|
+
// [--title T] [--kicker K] [--number NN] [--panel css] [--out file] [--full4k] [--at ms]
|
|
6
|
+
//
|
|
7
|
+
// Adapted from ../../graphl-studio/aws/scripts/thumb.mjs for this SECTION-based repo. The reference
|
|
8
|
+
// was beat-based (--section/--beat, driven off window.__capture); here one SECTION = one scene, so it
|
|
9
|
+
// navigates the app's hash to a section's slug (like record-course.mjs) and there is no --beat. It
|
|
10
|
+
// reads the course layout from window.__scene.plan() (see src/App.tsx) to pick which section's scene
|
|
11
|
+
// to shoot (default: the first).
|
|
12
|
+
//
|
|
13
|
+
// The thumbnail is a TEMPLATE (thumb-template.html): LEFT = the course scene as it renders (a lossless
|
|
14
|
+
// screenshot of the .scene-area — no slide), RIGHT = an HTML/CSS panel driven by the concept + course
|
|
15
|
+
// (kicker = concept, title = course title) with the GraphL wordmark (+ an optional logo mark).
|
|
16
|
+
//
|
|
17
|
+
// It's composited in the app's OWN page so the panel inherits the app's self-hosted Plex fonts (no
|
|
18
|
+
// CDN, crisp text — never ffmpeg drawtext). Like record-course.mjs it drives the app in ?capture=1,
|
|
19
|
+
// spawns the app's `npm run dev` unless APP_URL reuses a server, supersamples to SCALE× (4K), then
|
|
20
|
+
// downscales to 1280×720 (Lanczos).
|
|
21
|
+
//
|
|
22
|
+
// Prerequisites: ffmpeg on PATH (Homebrew preferred, as with record-course.mjs).
|
|
23
|
+
|
|
24
|
+
import { execFile, spawn } from 'node:child_process'
|
|
25
|
+
import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'
|
|
26
|
+
import { promisify } from 'node:util'
|
|
27
|
+
import { tmpdir } from 'node:os'
|
|
28
|
+
import { dirname, join, resolve, isAbsolute } from 'node:path'
|
|
29
|
+
|
|
30
|
+
const run = promisify(execFile)
|
|
31
|
+
import { loadPeer, repoDir, dataDir, pkgDir, concept } from './_paths.mjs'
|
|
32
|
+
const { build } = await loadPeer('esbuild')
|
|
33
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
34
|
+
|
|
35
|
+
// Same supersampling as record-course.mjs: render the 1920×1080 stage at SCALE× device pixels so text
|
|
36
|
+
// re-rasterizes crisp, then downscale. SCALE=2 → a 3840×2160 master → 1280×720 thumbnail.
|
|
37
|
+
const W = 1920
|
|
38
|
+
const H = 1080
|
|
39
|
+
const SCALE = process.env.SCALE ? +process.env.SCALE : 2
|
|
40
|
+
const CW = W * SCALE
|
|
41
|
+
const CH = H * SCALE
|
|
42
|
+
|
|
43
|
+
// Prefer a Homebrew ffmpeg (same choice record-course.mjs makes) for the Lanczos downscale.
|
|
44
|
+
const FFMPEG =
|
|
45
|
+
process.env.FFMPEG ??
|
|
46
|
+
['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'].find(existsSync) ??
|
|
47
|
+
'ffmpeg'
|
|
48
|
+
|
|
49
|
+
const TEMPLATE = join(pkgDir, 'thumb-template.html') // ships with the package
|
|
50
|
+
// Optional GraphL mark for the panel foot. Drop an SVG at any of these paths to show it; otherwise the
|
|
51
|
+
// foot renders the "GraphL" wordmark alone. Override with LOGO_SVG=/path.
|
|
52
|
+
const LOGO_CANDIDATES = [
|
|
53
|
+
process.env.LOGO_SVG,
|
|
54
|
+
join(dataDir, 'logo.svg'),
|
|
55
|
+
join(repoDir, 'public', 'icon.svg'),
|
|
56
|
+
].filter(Boolean)
|
|
57
|
+
const LOGO_SVG = LOGO_CANDIDATES.find(existsSync) ?? null
|
|
58
|
+
|
|
59
|
+
// The concept shown as the panel kicker.
|
|
60
|
+
const CONCEPT = concept.kicker
|
|
61
|
+
// The right-side panel gradient (brand block). Python-blue by default — it echoes the language's
|
|
62
|
+
// brand and reads well against the Zed-slate scene on the left. Override per-thumb with --panel.
|
|
63
|
+
const DEFAULT_PANEL_BG = concept.panelBg
|
|
64
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
65
|
+
// A slug → display fallback ("data-engineering" → "Data Engineering") when the registry lacks it.
|
|
66
|
+
const titleCase = (slug) => slug.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
|
|
67
|
+
|
|
68
|
+
// ---- CLI --------------------------------------------------------------------------------
|
|
69
|
+
function parse(argv) {
|
|
70
|
+
const pos = []
|
|
71
|
+
const opt = { section: 0, at: 900, out: null, full4k: false, title: null, kicker: null, number: null, panel: null }
|
|
72
|
+
for (let i = 0; i < argv.length; i++) {
|
|
73
|
+
const a = argv[i]
|
|
74
|
+
if (a === '--section') opt.section = +argv[++i]
|
|
75
|
+
else if (a === '--at') opt.at = +argv[++i]
|
|
76
|
+
else if (a === '--out') opt.out = argv[++i]
|
|
77
|
+
else if (a === '--title') opt.title = argv[++i]
|
|
78
|
+
else if (a === '--kicker') opt.kicker = argv[++i]
|
|
79
|
+
else if (a === '--number') opt.number = argv[++i]
|
|
80
|
+
else if (a === '--panel') opt.panel = argv[++i]
|
|
81
|
+
else if (a === '--full4k') opt.full4k = true
|
|
82
|
+
else pos.push(a)
|
|
83
|
+
}
|
|
84
|
+
return { pos, opt }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- the app dev server (same handshake as record-course.mjs) ---------------------------
|
|
88
|
+
async function startDevServer(conceptDir) {
|
|
89
|
+
console.log(`Starting dev server: ${conceptDir} …`)
|
|
90
|
+
const child = spawn('npm', ['run', 'dev'], { cwd: conceptDir, env: process.env })
|
|
91
|
+
const url = await new Promise((res, rej) => {
|
|
92
|
+
const to = setTimeout(() => rej(new Error('dev server did not print a URL within 60s')), 60000)
|
|
93
|
+
const onData = (buf) => {
|
|
94
|
+
const m = String(buf).match(/https?:\/\/localhost:\d+\/?/)
|
|
95
|
+
if (m) { clearTimeout(to); child.stdout.off('data', onData); res(m[0].replace(/\/?$/, '/')) }
|
|
96
|
+
}
|
|
97
|
+
child.stdout.on('data', onData)
|
|
98
|
+
child.stderr.on('data', (b) => process.env.DEBUG && process.stderr.write(b))
|
|
99
|
+
child.on('exit', (code) => rej(new Error(`dev server exited early (code ${code})`)))
|
|
100
|
+
})
|
|
101
|
+
for (let i = 0; i < 40; i++) {
|
|
102
|
+
try { if ((await fetch(url)).ok) break } catch { /* not up yet */ }
|
|
103
|
+
await sleep(250)
|
|
104
|
+
}
|
|
105
|
+
console.log(` dev server at ${url}`)
|
|
106
|
+
return { child, url }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Curated PUBLISH titles (scripts/titles.json), keyed by course id — the header a thumbnail wears on
|
|
110
|
+
// YouTube ("Python Data Structures"), which is search-facing and so deliberately differs from the
|
|
111
|
+
// registry's narrative in-app title ("Data structures"). Optional: absent file → registry title.
|
|
112
|
+
const PUBLISH_TITLES = (() => {
|
|
113
|
+
const f = join(dataDir, 'titles.json')
|
|
114
|
+
if (!existsSync(f)) return {}
|
|
115
|
+
try {
|
|
116
|
+
const { _comment, ...titles } = JSON.parse(readFileSync(f, 'utf8'))
|
|
117
|
+
return titles
|
|
118
|
+
} catch {
|
|
119
|
+
return {}
|
|
120
|
+
}
|
|
121
|
+
})()
|
|
122
|
+
|
|
123
|
+
// course title: titles.json override first, else the typed COURSES registry, so the panel copy matches
|
|
124
|
+
// what ships. The content files import ONLY `../types` (erased) → the bundle has no runtime deps.
|
|
125
|
+
async function courseTitle(course) {
|
|
126
|
+
if (PUBLISH_TITLES[course]) return PUBLISH_TITLES[course]
|
|
127
|
+
try {
|
|
128
|
+
const result = await build({
|
|
129
|
+
entryPoints: [resolve(repoDir, 'src/content/index.ts')],
|
|
130
|
+
bundle: true, format: 'esm', platform: 'node', write: false,
|
|
131
|
+
})
|
|
132
|
+
const code = result.outputFiles[0].text
|
|
133
|
+
const reg = await import('data:text/javascript;base64,' + Buffer.from(code).toString('base64'))
|
|
134
|
+
return reg.COURSES?.[course]?.title ?? null
|
|
135
|
+
} catch {
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- thumbnail --------------------------------------------------------------------------
|
|
141
|
+
async function makeThumb(course, opt) {
|
|
142
|
+
if (!existsSync(TEMPLATE)) throw new Error(`template not found: ${TEMPLATE}`)
|
|
143
|
+
|
|
144
|
+
const outDir = join(dataDir, 'out')
|
|
145
|
+
mkdirSync(outDir, { recursive: true })
|
|
146
|
+
const outArg = opt.out ?? `${course}.png`
|
|
147
|
+
const out = isAbsolute(outArg) || outArg.includes('/') ? outArg : join(outDir, outArg)
|
|
148
|
+
|
|
149
|
+
// Spawn the dev server (unless APP_URL reuses an existing one).
|
|
150
|
+
let server = null
|
|
151
|
+
const base = process.env.APP_URL ? process.env.APP_URL.replace(/\/?$/, '/') : null
|
|
152
|
+
const appBase = base ?? (server = await startDevServer(repoDir), server.url)
|
|
153
|
+
|
|
154
|
+
const puppeteer = (await loadPeer('puppeteer')).default
|
|
155
|
+
let browser
|
|
156
|
+
try {
|
|
157
|
+
browser = await puppeteer.launch({
|
|
158
|
+
headless: true,
|
|
159
|
+
defaultViewport: { width: CW, height: CH, deviceScaleFactor: 1 },
|
|
160
|
+
args: [`--window-size=${CW},${CH}`, '--autoplay-policy=no-user-gesture-required'],
|
|
161
|
+
})
|
|
162
|
+
const page = await browser.newPage()
|
|
163
|
+
|
|
164
|
+
// Read the course layout (list of sections + their slugs) from the app's own capture contract.
|
|
165
|
+
await page.goto(`${appBase}?capture=1#/${course}`, { waitUntil: 'networkidle2' })
|
|
166
|
+
await page.waitForFunction(() => !!window.__scene, { timeout: 20000 })
|
|
167
|
+
const plan = await page.evaluate(() => window.__scene.plan())
|
|
168
|
+
if (!plan?.length) throw new Error(`course "${course}" has no sections (bad id?)`)
|
|
169
|
+
const sec = plan[opt.section]
|
|
170
|
+
if (!sec) throw new Error(`section ${opt.section} out of range (course has ${plan.length})`)
|
|
171
|
+
|
|
172
|
+
// Navigate to the chosen section's slug and wait for the painted, fitView-settled scene (a fresh
|
|
173
|
+
// goto forces a clean react-flow remount — no stale prior scene in the frame), same as the recorder.
|
|
174
|
+
await page.goto(`${appBase}?capture=1#/${sec.slug}`, { waitUntil: 'networkidle2' })
|
|
175
|
+
await page.waitForSelector('.react-flow__node', { timeout: 15000 })
|
|
176
|
+
await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready })
|
|
177
|
+
await sleep(opt.at) // fitView + ResizeObserver re-fit + edge-pulse settle
|
|
178
|
+
|
|
179
|
+
// LEFT — screenshot just the scene area (no slide) → data URI for the template.
|
|
180
|
+
const scenePane = await page.$('.scene-area')
|
|
181
|
+
if (!scenePane) throw new Error('.scene-area not found (SectionView markup changed?)')
|
|
182
|
+
const sceneB64 = await scenePane.screenshot({ encoding: 'base64' })
|
|
183
|
+
const sceneUri = `data:image/png;base64,${sceneB64}`
|
|
184
|
+
|
|
185
|
+
// Panel copy: kicker = concept, title = course title (registry/titles.json → flags override → slug).
|
|
186
|
+
const title = opt.title ?? (await courseTitle(course)) ?? titleCase(course)
|
|
187
|
+
const kicker = opt.kicker ?? CONCEPT
|
|
188
|
+
const numberHtml = opt.number ? `<div class="thumb__number">${esc(opt.number)}</div>` : ''
|
|
189
|
+
// Logo is optional: show the mark if one is present, else just the wordmark.
|
|
190
|
+
const logoSvg = LOGO_SVG ? readFileSync(LOGO_SVG, 'utf8').replace('<svg ', '<svg class="thumb__logo" ') : ''
|
|
191
|
+
const panelBg = opt.panel ?? DEFAULT_PANEL_BG
|
|
192
|
+
|
|
193
|
+
const html = readFileSync(TEMPLATE, 'utf8')
|
|
194
|
+
.replaceAll('{{SCENE}}', sceneUri)
|
|
195
|
+
.replaceAll('{{KICKER}}', esc(kicker))
|
|
196
|
+
.replaceAll('{{NUMBER}}', numberHtml)
|
|
197
|
+
.replaceAll('{{TITLE}}', esc(title))
|
|
198
|
+
.replaceAll('{{LOGO}}', logoSvg)
|
|
199
|
+
.replaceAll('{{PANEL_BG}}', panelBg)
|
|
200
|
+
|
|
201
|
+
// Composite IN the app page so the panel inherits the loaded Plex fonts: replace only the BODY
|
|
202
|
+
// (keeping the head's @fontsource @font-face rules), then wait for the fonts + a layout tick.
|
|
203
|
+
await page.evaluate((h) => { document.body.innerHTML = h }, html)
|
|
204
|
+
await page.evaluate(() => document.fonts.ready)
|
|
205
|
+
await sleep(200)
|
|
206
|
+
|
|
207
|
+
// Lossless 4K screenshot of the composed frame → downscale to 1280×720 (Lanczos).
|
|
208
|
+
mkdirSync(dirname(out), { recursive: true })
|
|
209
|
+
const shot = opt.full4k ? out : join(tmpdir(), `thumb-4k-${Date.now()}.png`)
|
|
210
|
+
await page.screenshot({ path: shot })
|
|
211
|
+
if (!opt.full4k) {
|
|
212
|
+
await run(FFMPEG, ['-y', '-loglevel', 'error', '-i', shot, '-vf', 'scale=1280:720:flags=lanczos', out])
|
|
213
|
+
rmSync(shot, { force: true })
|
|
214
|
+
}
|
|
215
|
+
console.log(`\n✅ ${out} (${kicker} — ${title})`)
|
|
216
|
+
return out
|
|
217
|
+
} finally {
|
|
218
|
+
if (browser) await browser.close()
|
|
219
|
+
if (server) server.child.kill('SIGTERM')
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const { pos, opt } = parse(process.argv.slice(2))
|
|
224
|
+
const [course] = pos
|
|
225
|
+
if (!course) {
|
|
226
|
+
console.error('usage: node scripts/thumb.mjs <course> [--section N] [--title T] [--kicker K] [--number NN] [--panel css-gradient] [--out file] [--full4k] [--at ms]')
|
|
227
|
+
process.exit(2)
|
|
228
|
+
}
|
|
229
|
+
makeThumb(course, opt).catch((e) => {
|
|
230
|
+
console.error('\n✗', e.message)
|
|
231
|
+
process.exit(1)
|
|
232
|
+
})
|