@graphlearning/shell 0.4.0 → 0.5.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 CHANGED
@@ -119,6 +119,23 @@ They are `bin` entries, wired through each repo's `npm run`:
119
119
  | `npm run gen:desc` | video descriptions + chapters |
120
120
  | `npm run gen:audio` | the narration manifest |
121
121
 
122
+ **Capture is not realtime.** `record` and `record:reels` screencast one short window per section and
123
+ loop it over the narration (`-stream_loop`), because the only moving thing in a frame is the engine's
124
+ edge pulse and that pulse has a 2.4s period — and a pulse crosses its whole edge in exactly one
125
+ period, so one period is the whole picture. A 90-second section is captured in ~3.4s; the output's
126
+ timing is unchanged.
127
+
128
+ | env | default | what it does |
129
+ |---|---|---|
130
+ | `LOOP_CYCLES` | `1` | window length, in pulse periods → 2.4s |
131
+ | `LOOP_MS` | — | window length in ms, snapped to a whole period |
132
+ | `PULSE_S` | `2.4` | the engine's `animateMotion dur` — must match `ui-flow`'s `FlowEdge` |
133
+ | `LOOP_LEAD_S` | `0.5` | discarded screencast ramp-up before the window |
134
+ | `NO_LOOP` | — | set to hold for the whole wav, as before |
135
+
136
+ The window **must** be a whole multiple of `PULSE_S` or the loop join shows a jump — which is why
137
+ `LOOP_MS` is snapped rather than taken literally.
138
+
122
139
  **Where things live.** Scripts resolve two roots explicitly (`scripts/_paths.mjs`), because from
123
140
  `node_modules` they can no longer use their own directory:
124
141
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphlearning/shell",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "The GraphL concept-app shell — hash router, section view (scene left / slide right), slide panel, course catalog and narration channel, plus the capture/record/publish toolchain that drives them. A content repo supplies courses + scenes.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -15,6 +15,18 @@
15
15
  // the puppeteer VIEWPORT directly to 3840×2160. page.screencast() records at the CSS viewport size
16
16
  // (it ignores deviceScaleFactor), so the frames are exactly 3840×2160 = 2160p, natively.
17
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
+ //
18
30
  // THE BELL = the section SEPARATOR: a synthesized three-note brand bell plays as a lead-in at the
19
31
  // START of every section (the opening frame is held for STING_MS under the bell, then narration
20
32
  // begins). Bell-at-each-start → a bell between all nine sections, combined into one video.
@@ -48,6 +60,30 @@ const TAIL_MS = process.env.TAIL_MS ? +process.env.TAIL_MS : 500
48
60
  const STING_MS = process.env.NO_STING ? 0 : process.env.STING_MS ? +process.env.STING_MS : 2800
49
61
  const STING_SIG = STING_MS > 0 ? `bell-arp:v1:${STING_MS}` : 'none'
50
62
 
63
+ // ---- the loop window --------------------------------------------------------------------
64
+ // The edge pulse's period, from ui-flow's FlowEdge (`animateMotion dur="2.4s"`).
65
+ const PULSE_S = process.env.PULSE_S ? +process.env.PULSE_S : 2.4
66
+ 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
+ const LOOP_CYCLES = Math.max(1, Math.round(
75
+ (process.env.LOOP_MS ? +process.env.LOOP_MS / 1000 : +(process.env.LOOP_CYCLES ?? 1) * PULSE_S) / PULSE_S,
76
+ ))
77
+ 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
+ const LEAD_S = process.env.LOOP_LEAD_S ? +process.env.LOOP_LEAD_S : 0.5
82
+ const GUARD_S = 0.5
83
+ 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
+ const LOOP_BITRATE = process.env.LOOP_BITRATE ?? '120M'
86
+
51
87
  // Prefer a Homebrew ffmpeg (libx264 + gradfun deband kills dark-gradient banding on YouTube's codec);
52
88
  // fall back to Apple hardware, then plain ffmpeg.
53
89
  const FFMPEG =
@@ -96,13 +132,30 @@ async function prepareBell(tmp) {
96
132
  return (dst) => run(FFMPEG, ['-y', '-i', bell, '-af', `apad,atrim=0:${secs}`, '-ar', '44100', '-ac', '1', dst])
97
133
  }
98
134
 
99
- // Mux one section's webm + audio → MP4 with CONCAT-SAFE settings so the final `-c copy` concat is
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
+ async function makeLoopClip(webm, dst) {
141
+ const quality = IS_X26X ? ['-preset', 'ultrafast', '-crf', '14'] : ['-b:v', LOOP_BITRATE]
142
+ await run(FFMPEG, [
143
+ '-y', '-i', webm, '-ss', LEAD_S.toFixed(3), '-t', LOOP_S.toFixed(3),
144
+ '-an', '-r', String(FPS), '-vsync', 'cfr',
145
+ '-c:v', VIDEO_CODEC, ...quality, '-pix_fmt', 'yuv420p',
146
+ dst,
147
+ ])
148
+ }
149
+
150
+ // Mux one section's video + audio → MP4 with CONCAT-SAFE settings so the final `-c copy` concat is
100
151
  // glitch-free: forced CFR, fixed video timescale, gradfun deband, and identical codec/pix/audio params
101
152
  // for every segment. `total` (bell lead + clip + tail) bounds both streams; apad fills the tail.
102
- async function encodeSegment(webm, audio, total, outMp4) {
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
+ async function encodeSegment(video, audio, total, outMp4, { loop = false } = {}) {
103
156
  const quality = IS_X26X ? ['-preset', PRESET, '-crf', CRF] : ['-b:v', BITRATE]
104
157
  await run(FFMPEG, [
105
- '-y', '-i', webm, '-i', audio,
158
+ '-y', ...(loop ? ['-stream_loop', '-1'] : []), '-i', video, '-i', audio,
106
159
  '-map', '0:v:0', '-map', '1:a:0',
107
160
  '-vf', 'gradfun=strength=0.9:radius=16',
108
161
  '-r', String(FPS), '-vsync', 'cfr', '-video_track_timescale', '90000',
@@ -232,7 +285,8 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
232
285
 
233
286
  // Incremental reuse: re-record iff missing/changed (or --force / --only match).
234
287
  const fp = sha(JSON.stringify({
235
- v: 1, audioHash, w: CW, h: CH, fps: FPS, tail: TAIL_MS, enc: ENCODE_SIG, sting: STING_SIG,
288
+ v: 2, audioHash, w: CW, h: CH, fps: FPS, tail: TAIL_MS, enc: ENCODE_SIG, sting: STING_SIG,
289
+ loop: LOOP_SIG,
236
290
  }))
237
291
  const have = existsSync(segMp4) && existsSync(sidecar)
238
292
  if (!(force || !have || readJson(sidecar)?.fp !== fp)) {
@@ -241,19 +295,32 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
241
295
  continue
242
296
  }
243
297
 
244
- // Position → wait for the painted, framed frame → roll → hold (bell lead + clip + tail).
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.
245
301
  const total = STING_MS / 1000 + dur + TAIL_MS / 1000
302
+ const roll = NO_LOOP ? total : LEAD_S + LOOP_S + GUARD_S
246
303
  await gotoSection(page, appBase, sec.slug)
247
304
 
248
305
  const webm = join(tmp, `${tag}.webm`)
249
306
  const recorder = await page.screencast({ path: webm })
250
307
  await startKeepalive(page)
251
308
  const lead = STING_MS > 0 ? `♪ ${(STING_MS / 1000).toFixed(1)}s + ` : ''
252
- console.log(` §${n} ${sec.id} ▶ record (${lead}${dur.toFixed(1)}s)`)
253
- await sleep(Math.round(total * 1000))
309
+ const how = NO_LOOP
310
+ ? `${lead}${dur.toFixed(1)}s`
311
+ : `${roll.toFixed(1)}s window → ${lead}${dur.toFixed(1)}s looped`
312
+ console.log(` §${n} ${sec.id} ▶ record ${how}`)
313
+ await sleep(Math.round(roll * 1000))
254
314
  await recorder.stop()
255
315
  await stopKeepalive(page)
256
316
 
317
+ // One exact, seamlessly-loopable window; -stream_loop repeats it to the segment's length.
318
+ let video = webm
319
+ if (!NO_LOOP) {
320
+ video = join(tmp, `${tag}-loop.mp4`)
321
+ await makeLoopClip(webm, video)
322
+ }
323
+
257
324
  // Segment audio: bell lead + this section's clip; mirrors the video hold.
258
325
  let segAudio = clip
259
326
  if (STING_MS > 0 && bellBed) {
@@ -262,7 +329,7 @@ async function recordCourse(course, { force = false, only = [] } = {}) {
262
329
  segAudio = join(tmp, `${tag}-audio.wav`)
263
330
  await concatAudio([stingWav, clip], segAudio)
264
331
  }
265
- await encodeSegment(webm, segAudio, total, segMp4)
332
+ await encodeSegment(video, segAudio, total, segMp4, { loop: !NO_LOOP })
266
333
  writeFileSync(sidecar, JSON.stringify({ fp, builtAt: new Date().toISOString() }, null, 2))
267
334
  segments.push(segMp4)
268
335
  console.log(` §${n} ${sec.id} ✓ ${tag}.mp4`)
@@ -11,7 +11,16 @@
11
11
  // • NINE INDEPENDENT files (out/reels/<course>-<id>.mp4), one per section — NOT concatenated. Each
12
12
  // reel is its own upload, so there is NO separator bell and NO lead-in sting.
13
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.
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.
15
24
  //
16
25
  // TRUE portrait pixels: the layout is fluid, so we set the puppeteer VIEWPORT to 1080×1920 directly
17
26
  // and page.screencast() records at exactly that CSS size. Audio is read straight off disk from
@@ -51,6 +60,29 @@ const PRESET = process.env.VIDEO_PRESET ?? 'slow'
51
60
  const BITRATE = process.env.VIDEO_BITRATE ?? '16M' // 1080×1920 ≈ 1080p pixel budget
52
61
  const ENCODE_SIG = IS_X26X ? `${VIDEO_CODEC}:crf${CRF}:${PRESET}` : `${VIDEO_CODEC}:b${BITRATE}`
53
62
 
63
+ // ---- the loop window --------------------------------------------------------------------
64
+ // The edge pulse's period, from ui-flow's FlowEdge (`animateMotion dur="2.4s"`).
65
+ const PULSE_S = process.env.PULSE_S ? +process.env.PULSE_S : 2.4
66
+ 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
+ const LOOP_CYCLES = Math.max(1, Math.round(
75
+ (process.env.LOOP_MS ? +process.env.LOOP_MS / 1000 : +(process.env.LOOP_CYCLES ?? 1) * PULSE_S) / PULSE_S,
76
+ ))
77
+ 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
+ const LEAD_S = process.env.LOOP_LEAD_S ? +process.env.LOOP_LEAD_S : 0.5
81
+ const GUARD_S = 0.5
82
+ 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
+ const LOOP_BITRATE = process.env.LOOP_BITRATE ?? '40M'
85
+
54
86
  // ---- ffmpeg helpers ---------------------------------------------------------------------
55
87
  async function ffprobeDuration(file) {
56
88
  const { stdout } = await run('ffprobe', [
@@ -59,13 +91,29 @@ async function ffprobeDuration(file) {
59
91
  return parseFloat(stdout.trim())
60
92
  }
61
93
 
62
- // Mux one reel's webm + clip → a standalone MP4. Same crisp encode as the 4K recorder (gradfun
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
+ async function makeLoopClip(webm, dst) {
99
+ const quality = IS_X26X ? ['-preset', 'ultrafast', '-crf', '14'] : ['-b:v', LOOP_BITRATE]
100
+ await run(FFMPEG, [
101
+ '-y', '-i', webm, '-ss', LEAD_S.toFixed(3), '-t', LOOP_S.toFixed(3),
102
+ '-an', '-r', String(FPS), '-vsync', 'cfr',
103
+ '-c:v', VIDEO_CODEC, ...quality, '-pix_fmt', 'yuv420p',
104
+ dst,
105
+ ])
106
+ }
107
+
108
+ // Mux one reel's video + clip → a standalone MP4. Same crisp encode as the 4K recorder (gradfun
63
109
  // deband, yuv420p, faststart) but this is a FINAL file, not a concat segment, so it needs no uniform
64
110
  // timescale. `total` (clip + tail) bounds both streams; apad extends the clip with silence to fill.
65
- async function encodeReel(webm, clip, total, outMp4) {
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
+ async function encodeReel(video, clip, total, outMp4, { loop = false } = {}) {
66
114
  const quality = IS_X26X ? ['-preset', PRESET, '-crf', CRF] : ['-b:v', BITRATE]
67
115
  await run(FFMPEG, [
68
- '-y', '-i', webm, '-i', clip,
116
+ '-y', ...(loop ? ['-stream_loop', '-1'] : []), '-i', video, '-i', clip,
69
117
  '-map', '0:v:0', '-map', '1:a:0',
70
118
  '-vf', 'gradfun=strength=0.9:radius=16',
71
119
  '-r', String(FPS), '-vsync', 'cfr',
@@ -188,25 +236,39 @@ async function recordReels(course, { force = false, only = [] } = {}) {
188
236
  }
189
237
 
190
238
  // 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 }))
239
+ const fp = sha(JSON.stringify({
240
+ v: 2, audioHash, w: CW, h: CH, fps: FPS, tail: TAIL_MS, enc: ENCODE_SIG, loop: LOOP_SIG,
241
+ }))
192
242
  if (!(force || !existsSync(outMp4) || readJson(sidecar)?.fp !== fp)) {
193
243
  console.log(` §${n} ${sec.id} reuse`)
194
244
  made.push(outMp4)
195
245
  continue
196
246
  }
197
247
 
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.
198
250
  const total = dur + TAIL_MS / 1000
251
+ const roll = NO_LOOP ? total : LEAD_S + LOOP_S + GUARD_S
199
252
  await gotoSection(page, appBase, sec.slug)
200
253
 
201
254
  const webm = join(tmp, `${pad2(n)}-${sec.id}.webm`)
202
255
  const recorder = await page.screencast({ path: webm })
203
256
  await startKeepalive(page)
204
- console.log(` §${n} ${sec.id} ▶ record (${dur.toFixed(1)}s)`)
205
- await sleep(Math.round(total * 1000))
257
+ console.log(` §${n} ${sec.id} ▶ record ${NO_LOOP
258
+ ? `${dur.toFixed(1)}s`
259
+ : `${roll.toFixed(1)}s window → ${dur.toFixed(1)}s looped`}`)
260
+ await sleep(Math.round(roll * 1000))
206
261
  await recorder.stop()
207
262
  await stopKeepalive(page)
208
263
 
209
- await encodeReel(webm, clip, total, outMp4)
264
+ // One exact, seamlessly-loopable window; -stream_loop repeats it to the reel's length.
265
+ let video = webm
266
+ if (!NO_LOOP) {
267
+ video = join(tmp, `${pad2(n)}-${sec.id}-loop.mp4`)
268
+ await makeLoopClip(webm, video)
269
+ }
270
+
271
+ await encodeReel(video, clip, total, outMp4, { loop: !NO_LOOP })
210
272
  writeFileSync(sidecar, JSON.stringify({ fp, builtAt: new Date().toISOString() }, null, 2))
211
273
  made.push(outMp4)
212
274
  console.log(` §${n} ${sec.id} ✓ reels/${tag}.mp4`)