@graphlearning/shell 0.10.1 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ConceptApp.d.ts +1 -1
- package/dist/CourseIndex.d.ts +2 -2
- package/dist/SectionView.d.ts +11 -11
- package/dist/ThemeToggle.d.ts +1 -0
- package/dist/index.js +233 -313
- package/dist/index.js.map +1 -1
- package/dist/styles.css +70 -233
- package/dist/types.d.ts +4 -3
- package/dist/useTheme.d.ts +0 -12
- package/package.json +1 -1
- package/scripts/_paths.mjs +106 -38
- package/scripts/capture-shots.mjs +1 -14
- package/scripts/gen-audio-manifest.mjs +2 -30
- package/scripts/gen-descriptions.mjs +5 -73
- package/scripts/record-all.mjs +1 -53
- package/scripts/record-course.mjs +6 -168
- package/scripts/record-reels.mjs +5 -139
- package/scripts/shots-4k.mjs +6 -7
- package/scripts/thumb-template.html +4 -11
- package/scripts/thumb.mjs +10 -105
package/scripts/thumb.mjs
CHANGED
|
@@ -1,54 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
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'
|
|
2
|
+
|
|
25
3
|
import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'
|
|
26
|
-
import { promisify } from 'node:util'
|
|
27
4
|
import { tmpdir } from 'node:os'
|
|
28
|
-
import { dirname, join,
|
|
5
|
+
import { dirname, join, isAbsolute } from 'node:path'
|
|
29
6
|
|
|
30
|
-
|
|
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))
|
|
7
|
+
import { loadPeer, loadRegistry, publishTitles, titleCase, repoDir, dataDir, pkgDir, concept, run, sleep, FFMPEG, openApp } from './_paths.mjs'
|
|
34
8
|
|
|
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
9
|
const W = 1920
|
|
38
10
|
const H = 1080
|
|
39
11
|
const SCALE = process.env.SCALE ? +process.env.SCALE : 2
|
|
40
12
|
const CW = W * SCALE
|
|
41
13
|
const CH = H * SCALE
|
|
42
14
|
|
|
43
|
-
|
|
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.
|
|
15
|
+
const TEMPLATE = join(pkgDir, 'thumb-template.html')
|
|
52
16
|
const LOGO_CANDIDATES = [
|
|
53
17
|
process.env.LOGO_SVG,
|
|
54
18
|
join(dataDir, 'logo.svg'),
|
|
@@ -56,16 +20,10 @@ const LOGO_CANDIDATES = [
|
|
|
56
20
|
].filter(Boolean)
|
|
57
21
|
const LOGO_SVG = LOGO_CANDIDATES.find(existsSync) ?? null
|
|
58
22
|
|
|
59
|
-
// The concept shown as the panel kicker.
|
|
60
23
|
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
24
|
const DEFAULT_PANEL_BG = concept.panelBg
|
|
64
25
|
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
26
|
|
|
68
|
-
// ---- CLI --------------------------------------------------------------------------------
|
|
69
27
|
function parse(argv) {
|
|
70
28
|
const pos = []
|
|
71
29
|
const opt = { section: 0, at: 900, out: null, full4k: false, title: null, kicker: null, number: null, panel: null }
|
|
@@ -84,60 +42,18 @@ function parse(argv) {
|
|
|
84
42
|
return { pos, opt }
|
|
85
43
|
}
|
|
86
44
|
|
|
87
|
-
|
|
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
|
-
})()
|
|
45
|
+
const PUBLISH_TITLES = publishTitles()
|
|
122
46
|
|
|
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
47
|
async function courseTitle(course) {
|
|
126
48
|
if (PUBLISH_TITLES[course]) return PUBLISH_TITLES[course]
|
|
127
49
|
try {
|
|
128
|
-
const
|
|
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'))
|
|
50
|
+
const reg = await loadRegistry()
|
|
134
51
|
return reg.COURSES?.[course]?.title ?? null
|
|
135
52
|
} catch {
|
|
136
53
|
return null
|
|
137
54
|
}
|
|
138
55
|
}
|
|
139
56
|
|
|
140
|
-
// ---- thumbnail --------------------------------------------------------------------------
|
|
141
57
|
async function makeThumb(course, opt) {
|
|
142
58
|
if (!existsSync(TEMPLATE)) throw new Error(`template not found: ${TEMPLATE}`)
|
|
143
59
|
|
|
@@ -146,10 +62,8 @@ async function makeThumb(course, opt) {
|
|
|
146
62
|
const outArg = opt.out ?? `${course}.png`
|
|
147
63
|
const out = isAbsolute(outArg) || outArg.includes('/') ? outArg : join(outDir, outArg)
|
|
148
64
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const base = process.env.APP_URL ? process.env.APP_URL.replace(/\/?$/, '/') : null
|
|
152
|
-
const appBase = base ?? (server = await startDevServer(repoDir), server.url)
|
|
65
|
+
const server = await openApp()
|
|
66
|
+
const appBase = server.url
|
|
153
67
|
|
|
154
68
|
const puppeteer = (await loadPeer('puppeteer')).default
|
|
155
69
|
let browser
|
|
@@ -161,7 +75,6 @@ async function makeThumb(course, opt) {
|
|
|
161
75
|
})
|
|
162
76
|
const page = await browser.newPage()
|
|
163
77
|
|
|
164
|
-
// Read the course layout (list of sections + their slugs) from the app's own capture contract.
|
|
165
78
|
await page.goto(`${appBase}?capture=1#/${course}`, { waitUntil: 'networkidle2' })
|
|
166
79
|
await page.waitForFunction(() => !!window.__scene, { timeout: 20000 })
|
|
167
80
|
const plan = await page.evaluate(() => window.__scene.plan())
|
|
@@ -169,24 +82,19 @@ async function makeThumb(course, opt) {
|
|
|
169
82
|
const sec = plan[opt.section]
|
|
170
83
|
if (!sec) throw new Error(`section ${opt.section} out of range (course has ${plan.length})`)
|
|
171
84
|
|
|
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
85
|
await page.goto(`${appBase}?capture=1#/${sec.slug}`, { waitUntil: 'networkidle2' })
|
|
175
86
|
await page.waitForSelector('.react-flow__node', { timeout: 15000 })
|
|
176
87
|
await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready })
|
|
177
|
-
await sleep(opt.at)
|
|
88
|
+
await sleep(opt.at)
|
|
178
89
|
|
|
179
|
-
// LEFT — screenshot just the scene area (no slide) → data URI for the template.
|
|
180
90
|
const scenePane = await page.$('.scene-area')
|
|
181
91
|
if (!scenePane) throw new Error('.scene-area not found (SectionView markup changed?)')
|
|
182
92
|
const sceneB64 = await scenePane.screenshot({ encoding: 'base64' })
|
|
183
93
|
const sceneUri = `data:image/png;base64,${sceneB64}`
|
|
184
94
|
|
|
185
|
-
// Panel copy: kicker = concept, title = course title (registry/titles.json → flags override → slug).
|
|
186
95
|
const title = opt.title ?? (await courseTitle(course)) ?? titleCase(course)
|
|
187
96
|
const kicker = opt.kicker ?? CONCEPT
|
|
188
97
|
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
98
|
const logoSvg = LOGO_SVG ? readFileSync(LOGO_SVG, 'utf8').replace('<svg ', '<svg class="thumb__logo" ') : ''
|
|
191
99
|
const panelBg = opt.panel ?? DEFAULT_PANEL_BG
|
|
192
100
|
|
|
@@ -198,13 +106,10 @@ async function makeThumb(course, opt) {
|
|
|
198
106
|
.replaceAll('{{LOGO}}', logoSvg)
|
|
199
107
|
.replaceAll('{{PANEL_BG}}', panelBg)
|
|
200
108
|
|
|
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
109
|
await page.evaluate((h) => { document.body.innerHTML = h }, html)
|
|
204
110
|
await page.evaluate(() => document.fonts.ready)
|
|
205
111
|
await sleep(200)
|
|
206
112
|
|
|
207
|
-
// Lossless 4K screenshot of the composed frame → downscale to 1280×720 (Lanczos).
|
|
208
113
|
mkdirSync(dirname(out), { recursive: true })
|
|
209
114
|
const shot = opt.full4k ? out : join(tmpdir(), `thumb-4k-${Date.now()}.png`)
|
|
210
115
|
await page.screenshot({ path: shot })
|
|
@@ -216,7 +121,7 @@ async function makeThumb(course, opt) {
|
|
|
216
121
|
return out
|
|
217
122
|
} finally {
|
|
218
123
|
if (browser) await browser.close()
|
|
219
|
-
|
|
124
|
+
server.child?.kill('SIGTERM')
|
|
220
125
|
}
|
|
221
126
|
}
|
|
222
127
|
|