@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,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
|
+
})
|