@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 +17 -0
- package/package.json +1 -1
- package/scripts/record-course.mjs +75 -8
- package/scripts/record-reels.mjs +70 -8
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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',
|
|
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:
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
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(
|
|
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`)
|
package/scripts/record-reels.mjs
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
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',
|
|
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({
|
|
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
|
|
205
|
-
|
|
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
|
-
|
|
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`)
|