@graphlearning/shell 0.10.0 → 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/scripts/thumb.mjs CHANGED
@@ -1,54 +1,18 @@
1
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'
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, resolve, isAbsolute } from 'node:path'
5
+ import { dirname, join, isAbsolute } from 'node:path'
29
6
 
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))
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
- // 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.
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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
- // ---- 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
- })()
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 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'))
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
- // 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)
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) // fitView + ResizeObserver re-fit + edge-pulse settle
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
- if (server) server.child.kill('SIGTERM')
124
+ server.child?.kill('SIGTERM')
220
125
  }
221
126
  }
222
127