@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/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 +224 -304
- package/dist/index.js.map +1 -1
- package/dist/styles.css +73 -253
- 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
|
@@ -1,114 +1,40 @@
|
|
|
1
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
|
-
// LOOP CAPTURE (why a 90s section screencasts in ~8s): the only motion in the frame is the edge
|
|
19
|
-
// pulse — ui-flow's FlowEdge draws it as an SVG <animateMotion dur="2.4s" repeatCount="indefinite">
|
|
20
|
-
// per edge — so the composition is PERIODIC with a 2.4s period. We screencast ONE window that is a
|
|
21
|
-
// whole number of those periods (default ONE, 2.4s — a pulse crosses its whole edge in exactly one
|
|
22
|
-
// period), which by construction shows every edge's flow end-to-end and joins back onto itself
|
|
23
|
-
// seamlessly *whatever phase the recording started in*,
|
|
24
|
-
// then LOOP that clip over the narration's length at encode time (-stream_loop -1). Capture is no
|
|
25
|
-
// longer realtime-bound by the wav. LOOP_MS is snapped to a whole period — an unsnapped window is
|
|
26
|
-
// exactly what makes a loop visible — and NO_LOOP=1 restores the old hold-for-the-whole-wav capture.
|
|
27
|
-
// Keep PULSE_S in sync with FlowEdge's dur; nothing else in the frame moves (the shell's stylesheet
|
|
28
|
-
// carries hover transitions only), which is what makes one period a complete picture.
|
|
29
|
-
//
|
|
30
|
-
// THE BELL = the section SEPARATOR: a synthesized three-note brand bell plays as a lead-in at the
|
|
31
|
-
// START of every section (the opening frame is held for STING_MS under the bell, then narration
|
|
32
|
-
// begins). Bell-at-each-start → a bell between all nine sections, combined into one video.
|
|
33
|
-
//
|
|
34
|
-
// Timing is driven by the wav length (ffprobe), never by playback: a missing clip falls back to 3s
|
|
35
|
-
// silence so the pipeline yields a video rather than hanging. Audio is read straight from
|
|
36
|
-
// public/audio/<course>/<id>.wav on disk (same-repo), not fetched over HTTP.
|
|
37
|
-
//
|
|
38
|
-
// Prerequisites: ffmpeg + ffprobe on PATH (Homebrew ffmpeg preferred — libx264 + gradfun deband).
|
|
39
2
|
|
|
40
|
-
import { execFile, spawn } from 'node:child_process'
|
|
41
3
|
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
|
|
42
4
|
import { createHash } from 'node:crypto'
|
|
43
|
-
import { promisify } from 'node:util'
|
|
44
5
|
import { join, resolve } from 'node:path'
|
|
45
6
|
|
|
46
|
-
|
|
47
|
-
import { loadPeer, repoDir, dataDir } from './_paths.mjs'
|
|
48
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
7
|
+
import { loadPeer, repoDir, dataDir, run, sleep, FFMPEG, ffprobeDuration, openApp, gotoSection, startKeepalive, stopKeepalive } from './_paths.mjs'
|
|
49
8
|
const sha = (data) => createHash('sha256').update(data).digest('hex').slice(0, 16)
|
|
50
9
|
const pad2 = (n) => String(n).padStart(2, '0')
|
|
51
10
|
const readJson = (f) => { try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null } }
|
|
52
11
|
|
|
53
|
-
// ---- 4K landscape frame -----------------------------------------------------------------
|
|
54
12
|
const CW = process.env.WIDTH ? +process.env.WIDTH : 3840
|
|
55
13
|
const CH = process.env.HEIGHT ? +process.env.HEIGHT : 2160
|
|
56
14
|
const FPS = process.env.FPS ? +process.env.FPS : 30
|
|
57
|
-
// A brief held tail after each section so it breathes and audio is never clipped at the join.
|
|
58
15
|
const TAIL_MS = process.env.TAIL_MS ? +process.env.TAIL_MS : 500
|
|
59
|
-
// The brand bell lead-in that opens (and so separates) each section. STING_MS=0 or NO_STING disables.
|
|
60
16
|
const STING_MS = process.env.NO_STING ? 0 : process.env.STING_MS ? +process.env.STING_MS : 2800
|
|
61
17
|
const STING_SIG = STING_MS > 0 ? `bell-arp:v1:${STING_MS}` : 'none'
|
|
62
18
|
|
|
63
|
-
// ---- the loop window --------------------------------------------------------------------
|
|
64
|
-
// The edge pulse's period, from ui-flow's FlowEdge (`animateMotion dur="2.4s"`).
|
|
65
19
|
const PULSE_S = process.env.PULSE_S ? +process.env.PULSE_S : 2.4
|
|
66
20
|
const NO_LOOP = !!process.env.NO_LOOP
|
|
67
|
-
// The recorded window, in WHOLE pulse periods: LOOP_MS (or LOOP_CYCLES) is snapped to the nearest
|
|
68
|
-
// one, because a window that is not a whole period ends on a different pulse position than it began
|
|
69
|
-
// and the loop join then jumps. Default 1 → ONE period, 2.4s: a pulse crosses its whole edge in
|
|
70
|
-
// exactly one period, so a single cycle already shows every edge's flow end-to-end and more cycles
|
|
71
|
-
// only record the same picture again. The cost of the short window is that a capture hiccup inside
|
|
72
|
-
// it (a dropped frame, a late re-fit) repeats for the whole section instead of a fifth of it — raise
|
|
73
|
-
// LOOP_CYCLES if a section ever shows one.
|
|
74
21
|
const LOOP_CYCLES = Math.max(1, Math.round(
|
|
75
22
|
(process.env.LOOP_MS ? +process.env.LOOP_MS / 1000 : +(process.env.LOOP_CYCLES ?? 1) * PULSE_S) / PULSE_S,
|
|
76
23
|
))
|
|
77
24
|
const LOOP_S = LOOP_CYCLES * PULSE_S
|
|
78
|
-
// LEAD_S is discarded ramp-up (screencast takes a moment to emit its first frame, and the keepalive
|
|
79
|
-
// starts just after the recorder); GUARD_S is recorded past the window so the exact trim can never
|
|
80
|
-
// run off the end of the webm.
|
|
81
25
|
const LEAD_S = process.env.LOOP_LEAD_S ? +process.env.LOOP_LEAD_S : 0.5
|
|
82
26
|
const GUARD_S = 0.5
|
|
83
27
|
const LOOP_SIG = NO_LOOP ? 'none' : `loop:v1:${LOOP_S.toFixed(2)}+${LEAD_S.toFixed(2)}`
|
|
84
|
-
// Intermediate quality for the loop clip (see makeLoopClip) when the codec is not CRF-based.
|
|
85
28
|
const LOOP_BITRATE = process.env.LOOP_BITRATE ?? '120M'
|
|
86
29
|
|
|
87
|
-
// Prefer a Homebrew ffmpeg (libx264 + gradfun deband kills dark-gradient banding on YouTube's codec);
|
|
88
|
-
// fall back to Apple hardware, then plain ffmpeg.
|
|
89
|
-
const FFMPEG =
|
|
90
|
-
process.env.FFMPEG ??
|
|
91
|
-
['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'].find(existsSync) ??
|
|
92
|
-
'ffmpeg'
|
|
93
30
|
const HAS_X264 = FFMPEG !== 'ffmpeg'
|
|
94
31
|
const VIDEO_CODEC = process.env.VIDEO_CODEC ?? (HAS_X264 ? 'libx264' : 'h264_videotoolbox')
|
|
95
32
|
const IS_X26X = /^libx26[45]$/.test(VIDEO_CODEC)
|
|
96
33
|
const CRF = process.env.VIDEO_CRF ?? '18'
|
|
97
34
|
const PRESET = process.env.VIDEO_PRESET ?? 'slow'
|
|
98
|
-
const BITRATE = process.env.VIDEO_BITRATE ?? '40M'
|
|
35
|
+
const BITRATE = process.env.VIDEO_BITRATE ?? '40M'
|
|
99
36
|
const ENCODE_SIG = IS_X26X ? `${VIDEO_CODEC}:crf${CRF}:${PRESET}` : `${VIDEO_CODEC}:b${BITRATE}`
|
|
100
37
|
|
|
101
|
-
// ---- ffmpeg helpers ---------------------------------------------------------------------
|
|
102
|
-
async function ffprobeDuration(file) {
|
|
103
|
-
const { stdout } = await run('ffprobe', [
|
|
104
|
-
'-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file,
|
|
105
|
-
])
|
|
106
|
-
return parseFloat(stdout.trim())
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Concatenate audio inputs into one mono 44.1 kHz WAV via the concat FILTER (per-input resample): the
|
|
110
|
-
// bell bed is 44.1 kHz but a narration clip may be another rate, and the concat DEMUXER corrupts the
|
|
111
|
-
// timeline on a mismatch. The filter resamples each input first, so the join is clean.
|
|
112
38
|
async function concatAudio(inputs, dst) {
|
|
113
39
|
const inArgs = inputs.flatMap((f) => ['-i', f])
|
|
114
40
|
const chains = inputs.map((_, i) => `[${i}:a]aresample=44100[a${i}]`).join(';')
|
|
@@ -117,8 +43,6 @@ async function concatAudio(inputs, dst) {
|
|
|
117
43
|
await run(FFMPEG, ['-y', ...inArgs, '-filter_complex', filter, '-map', '[out]', '-ar', '44100', '-ac', '1', dst])
|
|
118
44
|
}
|
|
119
45
|
|
|
120
|
-
// Render the brand bell once → bell.wav, and return a helper that pads/trims it to a lead of STING_MS
|
|
121
|
-
// onto `dst`. No-op (returns null) when the sting is disabled.
|
|
122
46
|
async function prepareBell(tmp) {
|
|
123
47
|
if (STING_MS <= 0) return null
|
|
124
48
|
const bell = join(tmp, 'bell.wav')
|
|
@@ -132,11 +56,6 @@ async function prepareBell(tmp) {
|
|
|
132
56
|
return (dst) => run(FFMPEG, ['-y', '-i', bell, '-af', `apad,atrim=0:${secs}`, '-ar', '44100', '-ac', '1', dst])
|
|
133
57
|
}
|
|
134
58
|
|
|
135
|
-
// Normalize the recording into the clip that gets looped: exactly LOOP_S starting LEAD_S in, at a
|
|
136
|
-
// forced CFR so the frame count is whole and the join lands on a frame boundary. The trim is
|
|
137
|
-
// OUTPUT-side (-ss after -i) — frame-accurate, and the source is only seconds long, so decoding it
|
|
138
|
-
// all is free. Near-lossless and ultrafast on purpose: this is an intermediate, and the segment's own
|
|
139
|
-
// encode below is what sets the final quality (and carries the gradfun deband).
|
|
140
59
|
async function makeLoopClip(webm, dst) {
|
|
141
60
|
const quality = IS_X26X ? ['-preset', 'ultrafast', '-crf', '14'] : ['-b:v', LOOP_BITRATE]
|
|
142
61
|
await run(FFMPEG, [
|
|
@@ -147,11 +66,6 @@ async function makeLoopClip(webm, dst) {
|
|
|
147
66
|
])
|
|
148
67
|
}
|
|
149
68
|
|
|
150
|
-
// Mux one section's video + audio → MP4 with CONCAT-SAFE settings so the final `-c copy` concat is
|
|
151
|
-
// glitch-free: forced CFR, fixed video timescale, gradfun deband, and identical codec/pix/audio params
|
|
152
|
-
// for every segment. `total` (bell lead + clip + tail) bounds both streams; apad fills the tail.
|
|
153
|
-
// `loop` repeats the input for as long as `total` asks for — that is what turns one 7.2s window into
|
|
154
|
-
// a full-length section — and `-t` is what stops the otherwise endless input.
|
|
155
69
|
async function encodeSegment(video, audio, total, outMp4, { loop = false } = {}) {
|
|
156
70
|
const quality = IS_X26X ? ['-preset', PRESET, '-crf', CRF] : ['-b:v', BITRATE]
|
|
157
71
|
await run(FFMPEG, [
|
|
@@ -166,67 +80,6 @@ async function encodeSegment(video, audio, total, outMp4, { loop = false } = {})
|
|
|
166
80
|
])
|
|
167
81
|
}
|
|
168
82
|
|
|
169
|
-
// ---- the app dev server -----------------------------------------------------------------
|
|
170
|
-
// Spawn `npm run dev` and resolve once Vite prints its Local URL. Set APP_URL to reuse a server.
|
|
171
|
-
async function startDevServer() {
|
|
172
|
-
console.log(`Starting dev server: ${repoDir} …`)
|
|
173
|
-
const child = spawn('npm', ['run', 'dev'], { cwd: repoDir, env: process.env })
|
|
174
|
-
const url = await new Promise((res, rej) => {
|
|
175
|
-
const to = setTimeout(() => rej(new Error('dev server did not print a URL within 60s')), 60000)
|
|
176
|
-
const onData = (buf) => {
|
|
177
|
-
const m = String(buf).match(/https?:\/\/localhost:\d+\/?/)
|
|
178
|
-
if (m) { clearTimeout(to); child.stdout.off('data', onData); res(m[0].replace(/\/?$/, '/')) }
|
|
179
|
-
}
|
|
180
|
-
child.stdout.on('data', onData)
|
|
181
|
-
child.stderr.on('data', (b) => process.env.DEBUG && process.stderr.write(b))
|
|
182
|
-
child.on('exit', (code) => rej(new Error(`dev server exited early (code ${code})`)))
|
|
183
|
-
})
|
|
184
|
-
for (let i = 0; i < 40; i++) {
|
|
185
|
-
try { if ((await fetch(url)).ok) break } catch { /* not up yet */ }
|
|
186
|
-
await sleep(250)
|
|
187
|
-
}
|
|
188
|
-
console.log(` dev server at ${url}`)
|
|
189
|
-
return { child, url }
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Navigate to a section and wait for its scene to be painted AND fitView-settled — the deterministic
|
|
193
|
-
// frame the reproducible-layout model depends on. A fresh goto per section forces a clean react-flow
|
|
194
|
-
// remount (it keys on scene id), so there is never a stale prior scene in the frame.
|
|
195
|
-
async function gotoSection(page, appBase, slug) {
|
|
196
|
-
await page.goto(`${appBase}?capture=1#/${slug}`, { waitUntil: 'networkidle2' })
|
|
197
|
-
await page.waitForSelector('.react-flow__node', { timeout: 15000 })
|
|
198
|
-
await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready })
|
|
199
|
-
await sleep(700) // fitView (instant) + ResizeObserver re-fit + edge-pulse settle
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// CDP screencast only emits a frame on a VISUAL CHANGE, so an otherwise-static section records an
|
|
203
|
-
// EMPTY webm (0 frames) and the mux fails. A 1px, ~1%-opacity speck nudged every animation frame
|
|
204
|
-
// keeps frames flowing; at 4K that sub-perceptible pixel is quantized away by x264. Removed on stop.
|
|
205
|
-
async function startKeepalive(page) {
|
|
206
|
-
await page.evaluate(() => {
|
|
207
|
-
const d = document.createElement('div')
|
|
208
|
-
d.id = '__cap_keepalive'
|
|
209
|
-
d.style.cssText =
|
|
210
|
-
'position:fixed;left:0;top:0;width:1px;height:1px;background:#888;opacity:0.01;' +
|
|
211
|
-
'pointer-events:none;z-index:2147483647;will-change:transform'
|
|
212
|
-
document.body.appendChild(d)
|
|
213
|
-
let x = 0
|
|
214
|
-
const loop = () => {
|
|
215
|
-
x = (x + 3) % 30
|
|
216
|
-
d.style.transform = `translate3d(${x}px,0,0)`
|
|
217
|
-
window.__cap_raf = requestAnimationFrame(loop)
|
|
218
|
-
}
|
|
219
|
-
loop()
|
|
220
|
-
})
|
|
221
|
-
}
|
|
222
|
-
async function stopKeepalive(page) {
|
|
223
|
-
await page.evaluate(() => {
|
|
224
|
-
if (window.__cap_raf) cancelAnimationFrame(window.__cap_raf)
|
|
225
|
-
document.getElementById('__cap_keepalive')?.remove()
|
|
226
|
-
})
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// ---- record -----------------------------------------------------------------------------
|
|
230
83
|
async function recordCourse(course, { force = false, only = [] } = {}) {
|
|
231
84
|
const tmp = join(dataDir, '.tmp', course)
|
|
232
85
|
const segDir = join(dataDir, 'segments', course)
|
|
@@ -235,13 +88,12 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
|
|
|
235
88
|
|
|
236
89
|
const bellBed = await prepareBell(tmp)
|
|
237
90
|
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
const appBase = base ?? (server = await startDevServer(), server.url)
|
|
91
|
+
const server = await openApp()
|
|
92
|
+
const appBase = server.url
|
|
241
93
|
|
|
242
94
|
const puppeteer = (await loadPeer('puppeteer')).default
|
|
243
95
|
let browser
|
|
244
|
-
const segments = []
|
|
96
|
+
const segments = []
|
|
245
97
|
try {
|
|
246
98
|
browser = await puppeteer.launch({
|
|
247
99
|
headless: true,
|
|
@@ -251,7 +103,6 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
|
|
|
251
103
|
const page = await browser.newPage()
|
|
252
104
|
await page.goto(`${appBase}?capture=1#/${course}`, { waitUntil: 'networkidle2' })
|
|
253
105
|
|
|
254
|
-
// The app lays out its own course for the recorder (slug + course + section id + scene per section).
|
|
255
106
|
await page.waitForFunction(() => !!window.__scene, { timeout: 20000 })
|
|
256
107
|
const plan = await page.evaluate(() => window.__scene.plan())
|
|
257
108
|
if (!plan?.length) throw new Error(`course "${course}" has no sections (bad id?)`)
|
|
@@ -261,14 +112,11 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
|
|
|
261
112
|
for (const sec of plan) {
|
|
262
113
|
n++
|
|
263
114
|
const tag = `${pad2(n)}-${sec.id}`
|
|
264
|
-
// --only RESTRICTS which segments are (re)recorded; a run with it refreshes those segments and
|
|
265
|
-
// SKIPS the final merge (a partial set can't concat into a whole video). A full run merges all.
|
|
266
115
|
if (only.length && !only.some((t) => sec.id.includes(t) || tag.includes(t))) continue
|
|
267
116
|
const segMp4 = join(segDir, `${tag}.mp4`)
|
|
268
117
|
const sidecar = join(segDir, `${tag}.json`)
|
|
269
118
|
const clip = join(tmp, `${tag}.wav`)
|
|
270
119
|
|
|
271
|
-
// Narration wav straight off disk (same repo). Missing → 3s silence so the run never hangs.
|
|
272
120
|
const wav = resolve(repoDir, 'public', 'audio', sec.course, `${sec.id}.wav`)
|
|
273
121
|
let dur, audioHash
|
|
274
122
|
if (existsSync(wav)) {
|
|
@@ -283,7 +131,6 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
|
|
|
283
131
|
console.warn(` §${n} ${sec.id}: no audio → 3s silence`)
|
|
284
132
|
}
|
|
285
133
|
|
|
286
|
-
// Incremental reuse: re-record iff missing/changed (or --force / --only match).
|
|
287
134
|
const fp = sha(JSON.stringify({
|
|
288
135
|
v: 2, audioHash, w: CW, h: CH, fps: FPS, tail: TAIL_MS, enc: ENCODE_SIG, sting: STING_SIG,
|
|
289
136
|
loop: LOOP_SIG,
|
|
@@ -295,9 +142,6 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
|
|
|
295
142
|
continue
|
|
296
143
|
}
|
|
297
144
|
|
|
298
|
-
// Position → wait for the painted, framed frame → roll. Loop capture rolls for the WINDOW
|
|
299
|
-
// (lead + whole pulse periods + guard) and lets the encode repeat it over the segment; NO_LOOP
|
|
300
|
-
// holds for the whole thing (bell lead + clip + tail) as it used to.
|
|
301
145
|
const total = STING_MS / 1000 + dur + TAIL_MS / 1000
|
|
302
146
|
const roll = NO_LOOP ? total : LEAD_S + LOOP_S + GUARD_S
|
|
303
147
|
await gotoSection(page, appBase, sec.slug)
|
|
@@ -314,14 +158,12 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
|
|
|
314
158
|
await recorder.stop()
|
|
315
159
|
await stopKeepalive(page)
|
|
316
160
|
|
|
317
|
-
// One exact, seamlessly-loopable window; -stream_loop repeats it to the segment's length.
|
|
318
161
|
let video = webm
|
|
319
162
|
if (!NO_LOOP) {
|
|
320
163
|
video = join(tmp, `${tag}-loop.mp4`)
|
|
321
164
|
await makeLoopClip(webm, video)
|
|
322
165
|
}
|
|
323
166
|
|
|
324
|
-
// Segment audio: bell lead + this section's clip; mirrors the video hold.
|
|
325
167
|
let segAudio = clip
|
|
326
168
|
if (STING_MS > 0 && bellBed) {
|
|
327
169
|
const stingWav = join(tmp, `${tag}-sting.wav`)
|
|
@@ -336,17 +178,14 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
|
|
|
336
178
|
}
|
|
337
179
|
} finally {
|
|
338
180
|
if (browser) await browser.close()
|
|
339
|
-
|
|
181
|
+
server.child?.kill('SIGTERM')
|
|
340
182
|
}
|
|
341
183
|
|
|
342
|
-
// A --only run refreshed just a subset of segments → there is no complete set to merge. Stop here;
|
|
343
|
-
// a later full run (no --only) reuses every unchanged segment and concatenates the whole course.
|
|
344
184
|
if (only.length) {
|
|
345
185
|
console.log(`\n✔ recorded ${segments.length} segment(s) (--only) — skipping merge. Run without --only to build ${course}.mp4.`)
|
|
346
186
|
return null
|
|
347
187
|
}
|
|
348
188
|
|
|
349
|
-
// Merge: concat demuxer + stream copy (uniform params → clean joins, seconds).
|
|
350
189
|
const listFile = join(tmp, 'concat.txt')
|
|
351
190
|
writeFileSync(listFile, segments.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join('\n') + '\n')
|
|
352
191
|
const out = join(outDir, `${course}.mp4`)
|
|
@@ -356,7 +195,6 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
|
|
|
356
195
|
return out
|
|
357
196
|
}
|
|
358
197
|
|
|
359
|
-
// ---- CLI --------------------------------------------------------------------------------
|
|
360
198
|
function parse(argv) {
|
|
361
199
|
const pos = []
|
|
362
200
|
const only = []
|
package/scripts/record-reels.mjs
CHANGED
|
@@ -1,100 +1,38 @@
|
|
|
1
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 → mux against the wav's duration (ffprobe) + a short tail.
|
|
15
|
-
//
|
|
16
|
-
// LOOP CAPTURE, same as the 4K recorder: the only motion in the frame is the edge pulse — ui-flow's
|
|
17
|
-
// FlowEdge draws it as an SVG <animateMotion dur="2.4s" repeatCount="indefinite"> per edge — so the
|
|
18
|
-
// composition is PERIODIC with a 2.4s period. We screencast ONE window that is a whole number of
|
|
19
|
-
// those periods (default ONE, 2.4s — a pulse crosses its whole edge in exactly one period), which by
|
|
20
|
-
// construction shows every edge's flow end-to-end and joins back onto itself seamlessly whatever
|
|
21
|
-
// phase the recording started in, then LOOP it over
|
|
22
|
-
// the narration's length at encode time (-stream_loop -1) instead of holding the browser for the
|
|
23
|
-
// whole wav. LOOP_MS snaps to a whole period; NO_LOOP=1 restores the old full-length capture.
|
|
24
|
-
//
|
|
25
|
-
// TRUE portrait pixels: the layout is fluid, so we set the puppeteer VIEWPORT to 1080×1920 directly
|
|
26
|
-
// and page.screencast() records at exactly that CSS size. Audio is read straight off disk from
|
|
27
|
-
// public/audio/<course>/<id>.wav; a missing clip falls back to 3s silence so the run never hangs.
|
|
28
|
-
//
|
|
29
|
-
// Prerequisites: ffmpeg + ffprobe on PATH (Homebrew ffmpeg preferred — libx264 + gradfun deband).
|
|
30
2
|
|
|
31
|
-
import { execFile, spawn } from 'node:child_process'
|
|
32
3
|
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
|
|
33
4
|
import { createHash } from 'node:crypto'
|
|
34
|
-
import { promisify } from 'node:util'
|
|
35
5
|
import { join, resolve } from 'node:path'
|
|
36
6
|
|
|
37
|
-
|
|
38
|
-
import { loadPeer, repoDir, dataDir } from './_paths.mjs'
|
|
39
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
7
|
+
import { loadPeer, repoDir, dataDir, run, sleep, FFMPEG, ffprobeDuration, openApp, gotoSection, startKeepalive, stopKeepalive } from './_paths.mjs'
|
|
40
8
|
const sha = (data) => createHash('sha256').update(data).digest('hex').slice(0, 16)
|
|
41
9
|
const pad2 = (n) => String(n).padStart(2, '0')
|
|
42
10
|
const readJson = (f) => { try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null } }
|
|
43
11
|
|
|
44
|
-
// ---- portrait reel frame ----------------------------------------------------------------
|
|
45
12
|
const CW = process.env.WIDTH ? +process.env.WIDTH : 1080
|
|
46
13
|
const CH = process.env.HEIGHT ? +process.env.HEIGHT : 1920
|
|
47
14
|
const FPS = process.env.FPS ? +process.env.FPS : 30
|
|
48
|
-
// A short held tail so the reel doesn't cut on the last syllable.
|
|
49
15
|
const TAIL_MS = process.env.TAIL_MS ? +process.env.TAIL_MS : 500
|
|
50
16
|
|
|
51
|
-
const FFMPEG =
|
|
52
|
-
process.env.FFMPEG ??
|
|
53
|
-
['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'].find(existsSync) ??
|
|
54
|
-
'ffmpeg'
|
|
55
17
|
const HAS_X264 = FFMPEG !== 'ffmpeg'
|
|
56
18
|
const VIDEO_CODEC = process.env.VIDEO_CODEC ?? (HAS_X264 ? 'libx264' : 'h264_videotoolbox')
|
|
57
19
|
const IS_X26X = /^libx26[45]$/.test(VIDEO_CODEC)
|
|
58
20
|
const CRF = process.env.VIDEO_CRF ?? '18'
|
|
59
21
|
const PRESET = process.env.VIDEO_PRESET ?? 'slow'
|
|
60
|
-
const BITRATE = process.env.VIDEO_BITRATE ?? '16M'
|
|
22
|
+
const BITRATE = process.env.VIDEO_BITRATE ?? '16M'
|
|
61
23
|
const ENCODE_SIG = IS_X26X ? `${VIDEO_CODEC}:crf${CRF}:${PRESET}` : `${VIDEO_CODEC}:b${BITRATE}`
|
|
62
24
|
|
|
63
|
-
// ---- the loop window --------------------------------------------------------------------
|
|
64
|
-
// The edge pulse's period, from ui-flow's FlowEdge (`animateMotion dur="2.4s"`).
|
|
65
25
|
const PULSE_S = process.env.PULSE_S ? +process.env.PULSE_S : 2.4
|
|
66
26
|
const NO_LOOP = !!process.env.NO_LOOP
|
|
67
|
-
// The recorded window, in WHOLE pulse periods: LOOP_MS (or LOOP_CYCLES) is snapped to the nearest
|
|
68
|
-
// one, because a window that is not a whole period ends on a different pulse position than it began
|
|
69
|
-
// and the loop join then jumps. Default 1 → ONE period, 2.4s: a pulse crosses its whole edge in
|
|
70
|
-
// exactly one period, so a single cycle already shows every edge's flow end-to-end and more cycles
|
|
71
|
-
// only record the same picture again. The cost of the short window is that a capture hiccup inside
|
|
72
|
-
// it (a dropped frame, a late re-fit) repeats for the whole section instead of a fifth of it — raise
|
|
73
|
-
// LOOP_CYCLES if a section ever shows one.
|
|
74
27
|
const LOOP_CYCLES = Math.max(1, Math.round(
|
|
75
28
|
(process.env.LOOP_MS ? +process.env.LOOP_MS / 1000 : +(process.env.LOOP_CYCLES ?? 1) * PULSE_S) / PULSE_S,
|
|
76
29
|
))
|
|
77
30
|
const LOOP_S = LOOP_CYCLES * PULSE_S
|
|
78
|
-
// LEAD_S is discarded ramp-up (screencast takes a moment to emit its first frame); GUARD_S is
|
|
79
|
-
// recorded past the window so the exact trim can never run off the end of the webm.
|
|
80
31
|
const LEAD_S = process.env.LOOP_LEAD_S ? +process.env.LOOP_LEAD_S : 0.5
|
|
81
32
|
const GUARD_S = 0.5
|
|
82
33
|
const LOOP_SIG = NO_LOOP ? 'none' : `loop:v1:${LOOP_S.toFixed(2)}+${LEAD_S.toFixed(2)}`
|
|
83
|
-
// Intermediate quality for the loop clip (see makeLoopClip) when the codec is not CRF-based.
|
|
84
34
|
const LOOP_BITRATE = process.env.LOOP_BITRATE ?? '40M'
|
|
85
35
|
|
|
86
|
-
// ---- ffmpeg helpers ---------------------------------------------------------------------
|
|
87
|
-
async function ffprobeDuration(file) {
|
|
88
|
-
const { stdout } = await run('ffprobe', [
|
|
89
|
-
'-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file,
|
|
90
|
-
])
|
|
91
|
-
return parseFloat(stdout.trim())
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Normalize the recording into the clip that gets looped: exactly LOOP_S starting LEAD_S in, at a
|
|
95
|
-
// forced CFR so the frame count is whole and the join lands on a frame boundary. The trim is
|
|
96
|
-
// OUTPUT-side (-ss after -i) — frame-accurate, and the source is only seconds long. Near-lossless and
|
|
97
|
-
// ultrafast on purpose: this is an intermediate, and the reel's own encode below sets final quality.
|
|
98
36
|
async function makeLoopClip(webm, dst) {
|
|
99
37
|
const quality = IS_X26X ? ['-preset', 'ultrafast', '-crf', '14'] : ['-b:v', LOOP_BITRATE]
|
|
100
38
|
await run(FFMPEG, [
|
|
@@ -105,11 +43,6 @@ async function makeLoopClip(webm, dst) {
|
|
|
105
43
|
])
|
|
106
44
|
}
|
|
107
45
|
|
|
108
|
-
// Mux one reel's video + clip → a standalone MP4. Same crisp encode as the 4K recorder (gradfun
|
|
109
|
-
// deband, yuv420p, faststart) but this is a FINAL file, not a concat segment, so it needs no uniform
|
|
110
|
-
// timescale. `total` (clip + tail) bounds both streams; apad extends the clip with silence to fill.
|
|
111
|
-
// `loop` repeats the input for as long as `total` asks for — that is what turns one 7.2s window into
|
|
112
|
-
// a full-length reel — and `-t` is what stops the otherwise endless input.
|
|
113
46
|
async function encodeReel(video, clip, total, outMp4, { loop = false } = {}) {
|
|
114
47
|
const quality = IS_X26X ? ['-preset', PRESET, '-crf', CRF] : ['-b:v', BITRATE]
|
|
115
48
|
await run(FFMPEG, [
|
|
@@ -125,73 +58,13 @@ async function encodeReel(video, clip, total, outMp4, { loop = false } = {}) {
|
|
|
125
58
|
])
|
|
126
59
|
}
|
|
127
60
|
|
|
128
|
-
// ---- the app dev server -----------------------------------------------------------------
|
|
129
|
-
async function startDevServer() {
|
|
130
|
-
console.log(`Starting dev server: ${repoDir} …`)
|
|
131
|
-
const child = spawn('npm', ['run', 'dev'], { cwd: repoDir, env: process.env })
|
|
132
|
-
const url = await new Promise((res, rej) => {
|
|
133
|
-
const to = setTimeout(() => rej(new Error('dev server did not print a URL within 60s')), 60000)
|
|
134
|
-
const onData = (buf) => {
|
|
135
|
-
const m = String(buf).match(/https?:\/\/localhost:\d+\/?/)
|
|
136
|
-
if (m) { clearTimeout(to); child.stdout.off('data', onData); res(m[0].replace(/\/?$/, '/')) }
|
|
137
|
-
}
|
|
138
|
-
child.stdout.on('data', onData)
|
|
139
|
-
child.stderr.on('data', (b) => process.env.DEBUG && process.stderr.write(b))
|
|
140
|
-
child.on('exit', (code) => rej(new Error(`dev server exited early (code ${code})`)))
|
|
141
|
-
})
|
|
142
|
-
for (let i = 0; i < 40; i++) {
|
|
143
|
-
try { if ((await fetch(url)).ok) break } catch { /* not up yet */ }
|
|
144
|
-
await sleep(250)
|
|
145
|
-
}
|
|
146
|
-
console.log(` dev server at ${url}`)
|
|
147
|
-
return { child, url }
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Navigate to a section and wait for its scene to be painted AND fitView-settled. A fresh goto per
|
|
151
|
-
// reel forces a clean react-flow remount (it keys on scene id), so no stale prior scene is in frame.
|
|
152
|
-
async function gotoSection(page, appBase, slug) {
|
|
153
|
-
await page.goto(`${appBase}?capture=1#/${slug}`, { waitUntil: 'networkidle2' })
|
|
154
|
-
await page.waitForSelector('.react-flow__node', { timeout: 15000 })
|
|
155
|
-
await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready })
|
|
156
|
-
await sleep(700) // fitView (instant) + ResizeObserver re-fit + edge-pulse settle
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// CDP screencast only emits a frame on a VISUAL CHANGE, so an otherwise-static reel records an EMPTY
|
|
160
|
-
// webm and the mux fails. A 1px, ~1%-opacity speck nudged every animation frame keeps frames flowing;
|
|
161
|
-
// it is quantized away by x264. Removed on stop.
|
|
162
|
-
async function startKeepalive(page) {
|
|
163
|
-
await page.evaluate(() => {
|
|
164
|
-
const d = document.createElement('div')
|
|
165
|
-
d.id = '__cap_keepalive'
|
|
166
|
-
d.style.cssText =
|
|
167
|
-
'position:fixed;left:0;top:0;width:1px;height:1px;background:#888;opacity:0.01;' +
|
|
168
|
-
'pointer-events:none;z-index:2147483647;will-change:transform'
|
|
169
|
-
document.body.appendChild(d)
|
|
170
|
-
let x = 0
|
|
171
|
-
const loop = () => {
|
|
172
|
-
x = (x + 3) % 30
|
|
173
|
-
d.style.transform = `translate3d(${x}px,0,0)`
|
|
174
|
-
window.__cap_raf = requestAnimationFrame(loop)
|
|
175
|
-
}
|
|
176
|
-
loop()
|
|
177
|
-
})
|
|
178
|
-
}
|
|
179
|
-
async function stopKeepalive(page) {
|
|
180
|
-
await page.evaluate(() => {
|
|
181
|
-
if (window.__cap_raf) cancelAnimationFrame(window.__cap_raf)
|
|
182
|
-
document.getElementById('__cap_keepalive')?.remove()
|
|
183
|
-
})
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// ---- record -----------------------------------------------------------------------------
|
|
187
61
|
async function recordReels(course, { force = false, only = [] } = {}) {
|
|
188
62
|
const tmp = join(dataDir, '.tmp', `${course}-reels`)
|
|
189
63
|
const outDir = join(dataDir, 'out', 'reels')
|
|
190
64
|
for (const d of [tmp, outDir]) mkdirSync(d, { recursive: true })
|
|
191
65
|
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
const appBase = base ?? (server = await startDevServer(), server.url)
|
|
66
|
+
const server = await openApp()
|
|
67
|
+
const appBase = server.url
|
|
195
68
|
|
|
196
69
|
const puppeteer = (await loadPeer('puppeteer')).default
|
|
197
70
|
let browser
|
|
@@ -214,13 +87,11 @@ async function recordReels(course, { force = false, only = [] } = {}) {
|
|
|
214
87
|
for (const sec of plan) {
|
|
215
88
|
n++
|
|
216
89
|
const tag = `${course}-${sec.id}`
|
|
217
|
-
// Each reel is its own file, so --only truly RESTRICTS the set: skip any section not listed.
|
|
218
90
|
if (only.length && !only.some((t) => sec.id.includes(t) || tag.includes(t))) continue
|
|
219
91
|
const outMp4 = join(outDir, `${tag}.mp4`)
|
|
220
92
|
const sidecar = join(tmp, `${pad2(n)}-${sec.id}.json`)
|
|
221
93
|
const clip = join(tmp, `${pad2(n)}-${sec.id}.wav`)
|
|
222
94
|
|
|
223
|
-
// Narration wav straight off disk. Missing → 3s silence so the run never hangs.
|
|
224
95
|
const wav = resolve(repoDir, 'public', 'audio', sec.course, `${sec.id}.wav`)
|
|
225
96
|
let dur, audioHash
|
|
226
97
|
if (existsSync(wav)) {
|
|
@@ -235,7 +106,6 @@ async function recordReels(course, { force = false, only = [] } = {}) {
|
|
|
235
106
|
console.warn(` §${n} ${sec.id}: no audio → 3s silence`)
|
|
236
107
|
}
|
|
237
108
|
|
|
238
|
-
// Incremental reuse: re-record iff missing/changed (or --force / --only match).
|
|
239
109
|
const fp = sha(JSON.stringify({
|
|
240
110
|
v: 2, audioHash, w: CW, h: CH, fps: FPS, tail: TAIL_MS, enc: ENCODE_SIG, loop: LOOP_SIG,
|
|
241
111
|
}))
|
|
@@ -245,8 +115,6 @@ async function recordReels(course, { force = false, only = [] } = {}) {
|
|
|
245
115
|
continue
|
|
246
116
|
}
|
|
247
117
|
|
|
248
|
-
// Loop capture rolls for the WINDOW (lead + whole pulse periods + guard) and lets the encode
|
|
249
|
-
// repeat it over the reel; NO_LOOP holds for the whole clip + tail as it used to.
|
|
250
118
|
const total = dur + TAIL_MS / 1000
|
|
251
119
|
const roll = NO_LOOP ? total : LEAD_S + LOOP_S + GUARD_S
|
|
252
120
|
await gotoSection(page, appBase, sec.slug)
|
|
@@ -261,7 +129,6 @@ async function recordReels(course, { force = false, only = [] } = {}) {
|
|
|
261
129
|
await recorder.stop()
|
|
262
130
|
await stopKeepalive(page)
|
|
263
131
|
|
|
264
|
-
// One exact, seamlessly-loopable window; -stream_loop repeats it to the reel's length.
|
|
265
132
|
let video = webm
|
|
266
133
|
if (!NO_LOOP) {
|
|
267
134
|
video = join(tmp, `${pad2(n)}-${sec.id}-loop.mp4`)
|
|
@@ -275,14 +142,13 @@ async function recordReels(course, { force = false, only = [] } = {}) {
|
|
|
275
142
|
}
|
|
276
143
|
} finally {
|
|
277
144
|
if (browser) await browser.close()
|
|
278
|
-
|
|
145
|
+
server.child?.kill('SIGTERM')
|
|
279
146
|
}
|
|
280
147
|
|
|
281
148
|
console.log(`\n✅ ${made.length} reel(s) → scripts/out/reels/`)
|
|
282
149
|
return made
|
|
283
150
|
}
|
|
284
151
|
|
|
285
|
-
// ---- CLI --------------------------------------------------------------------------------
|
|
286
152
|
function parse(argv) {
|
|
287
153
|
const pos = []
|
|
288
154
|
const only = []
|
package/scripts/shots-4k.mjs
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
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
2
|
import { spawn } from 'node:child_process'
|
|
6
3
|
import { mkdirSync } from 'node:fs'
|
|
7
4
|
import { join } from 'node:path'
|
|
@@ -10,13 +7,15 @@ import { loadPeer, repoDir, dataDir } from './_paths.mjs'
|
|
|
10
7
|
const outDir = join(dataDir, 'out', 'shots-4k')
|
|
11
8
|
mkdirSync(outDir, { recursive: true })
|
|
12
9
|
const CW = +(process.env.WIDTH ?? 3840), CH = +(process.env.HEIGHT ?? 2160)
|
|
13
|
-
const course = process.argv[2]
|
|
10
|
+
const course = process.argv[2]?.startsWith('--') ? undefined : process.argv[2]
|
|
11
|
+
if (!course) {
|
|
12
|
+
console.error('usage: graphl-shots-4k <course> [--only <id[,id]>]')
|
|
13
|
+
process.exit(2)
|
|
14
|
+
}
|
|
14
15
|
const onlyArg = process.argv.indexOf('--only')
|
|
15
16
|
const only = onlyArg !== -1 ? (process.argv[onlyArg + 1] ?? '').split(',').filter(Boolean) : []
|
|
16
17
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
17
18
|
|
|
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
19
|
let child = null, url = process.env.APP_URL ? process.env.APP_URL.replace(/\/?$/, '/') : null
|
|
21
20
|
if (!url) {
|
|
22
21
|
child = spawn('npm', ['run', 'dev'], { cwd: repoDir, env: process.env })
|
|
@@ -44,7 +43,7 @@ for (const p of plan) {
|
|
|
44
43
|
await page.goto(`${url}?capture=1#/${p.slug}`, { waitUntil: 'networkidle2' })
|
|
45
44
|
await page.waitForSelector('.react-flow__node', { timeout: 15000 })
|
|
46
45
|
await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready })
|
|
47
|
-
await sleep(900)
|
|
46
|
+
await sleep(900)
|
|
48
47
|
const file = join(outDir, `${String(++n).padStart(2, '0')}-${p.id}.png`)
|
|
49
48
|
await page.screenshot({ path: file })
|
|
50
49
|
console.log(` ${p.slug} → ${file}`)
|
|
@@ -1,9 +1,3 @@
|
|
|
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
1
|
<style>
|
|
8
2
|
html, body { margin: 0; padding: 0; height: 100%; background: #1e2127; }
|
|
9
3
|
.thumb {
|
|
@@ -11,19 +5,18 @@
|
|
|
11
5
|
width: 100vw;
|
|
12
6
|
height: 100vh;
|
|
13
7
|
overflow: hidden;
|
|
14
|
-
background: #1e2127;
|
|
8
|
+
background: #1e2127;
|
|
15
9
|
font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
|
|
16
10
|
}
|
|
17
|
-
|
|
18
|
-
left edge (poster look) while filling the panel. */
|
|
11
|
+
|
|
19
12
|
.thumb__scene { flex: 0 0 58%; height: 100%; position: relative; background: #1e2127; }
|
|
20
13
|
.thumb__scene img { width: 100%; height: 100%; object-fit: cover; object-position: left center; display: block; }
|
|
21
|
-
|
|
14
|
+
|
|
22
15
|
.thumb__scene::after {
|
|
23
16
|
content: ''; position: absolute; top: 0; right: 0; width: 8%; height: 100%;
|
|
24
17
|
background: linear-gradient(90deg, rgba(30,33,39,0), rgba(30,33,39,0.55));
|
|
25
18
|
}
|
|
26
|
-
|
|
19
|
+
|
|
27
20
|
.thumb__panel {
|
|
28
21
|
flex: 1; position: relative; display: flex; flex-direction: column;
|
|
29
22
|
padding: 6.4vh 3.8vw;
|