@shipvo/mcp 0.0.8 → 0.0.10

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.
@@ -0,0 +1,410 @@
1
+ // Records the phone frame page to an MP4. Playwright takes screenshots while a session runs; ffmpeg encodes them.
2
+ // Waits longer than 0.6 s between agent actions are cut at stop. Each frame is saved with the time it was taken and ffmpeg runs once at stop,
3
+ // so the video lasts as long as the recording even when screenshots are slow or the machine is busy.
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ export const SIZES = {
9
+ "9:16": { width: 1080, height: 1920 }, "16:9": { width: 1920, height: 1080 }, "1:1": { width: 1080, height: 1080 },
10
+ };
11
+ export class RecorderError extends Error {
12
+ code;
13
+ constructor(code, message) {
14
+ super(message);
15
+ this.code = code;
16
+ }
17
+ }
18
+ export const MAX_IDLE_S = 0.6;
19
+ // Runs in the recorded page. Draws on top of the phone (the first iframe, else the whole page); points are 0..1 of it.
20
+ // A dot holds for 0.5 s so it shows in the video, then fades. Elements remove themselves.
21
+ const overlayScript = (kind, a, b) => `(() => {
22
+ const f = document.querySelector("iframe"); const r = f ? f.getBoundingClientRect() : { left: 0, top: 0, width: innerWidth, height: innerHeight };
23
+ const at = (p) => ({ x: r.left + p.x * r.width, y: r.top + p.y * r.height });
24
+ const add = (css, ms) => { const e = document.createElement("div"); e.setAttribute("data-shipvo-touch", "");
25
+ e.style.cssText = "position:fixed;z-index:2147483647;pointer-events:none;border-radius:50%;" + css; document.body.appendChild(e); setTimeout(() => e.remove(), ms); return e; };
26
+ if (!document.getElementById("shipvo-touch-css")) { const st = document.createElement("style"); st.id = "shipvo-touch-css";
27
+ st.textContent = "@keyframes shipvo-hold{0%,60%{opacity:1}100%{opacity:0}}@keyframes shipvo-ripple{0%{transform:translate(-50%,-50%) scale(.6);opacity:.9}100%{transform:translate(-50%,-50%) scale(2.4);opacity:0}}"; document.head.appendChild(st); }
28
+ const dot = (p) => add("left:" + p.x + "px;top:" + p.y + "px;width:44px;height:44px;margin:-22px 0 0 -22px;background:rgba(255,255,255,.75);box-shadow:0 0 14px rgba(0,0,0,.35);animation:shipvo-hold 900ms ease-out forwards", 950);
29
+ const a = at(${JSON.stringify(a)});
30
+ ${kind === "tap" ? `dot(a); add("left:" + a.x + "px;top:" + a.y + "px;width:60px;height:60px;border:3px solid rgba(255,255,255,.9);animation:shipvo-ripple 800ms ease-out forwards", 850);`
31
+ : `const b = at(${JSON.stringify(b)}); const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy);
32
+ const t = add("left:" + a.x + "px;top:" + a.y + "px;width:" + len + "px;height:18px;margin-top:-9px;border-radius:9px;transform-origin:0 50%;transform:rotate(" + Math.atan2(dy, dx) + "rad);background:linear-gradient(90deg,rgba(255,255,255,0),rgba(255,255,255,.7));animation:shipvo-hold 900ms ease-out forwards", 950);
33
+ dot(a); dot(b);`}
34
+ })()`;
35
+ const BRAND_BG = "linear-gradient(160deg,#0b1f3a,#1d4ed8)";
36
+ // Page background behind the phone. "brand" and "plain" are fixed; anything else must be a CSS colour, checked in the page with CSS.supports.
37
+ // Returns false (and changes nothing) if the value is not a colour, so url(...) and injected rules never reach the style rule.
38
+ const backgroundScript = (b) => `(() => { const v = ${JSON.stringify(b === "brand" ? BRAND_BG : b === "plain" ? "#ffffff" : b)};
39
+ if (${b === "brand" || b === "plain" ? "false" : "true"} && !CSS.supports("color", v)) return false;
40
+ let s = document.getElementById("shipvo-bg-css"); if (!s) { s = document.createElement("style"); s.id = "shipvo-bg-css"; document.head.appendChild(s); }
41
+ s.textContent = "html,body{background:" + v + " !important}"; return true; })()`;
42
+ // Lower third: replaces the previous caption, stays until the next marker.
43
+ const captionScript = (text) => `(() => { let e = document.getElementById("shipvo-caption"); if (!e) { e = document.createElement("div"); e.id = "shipvo-caption";
44
+ e.style.cssText = "position:fixed;z-index:2147483647;pointer-events:none;left:50%;bottom:6%;transform:translateX(-50%);max-width:80%;padding:14px 28px;border-radius:14px;background:rgba(0,0,0,.72);color:#fff;font:600 44px/1.2 system-ui,sans-serif;text-align:center"; document.body.appendChild(e); }
45
+ e.textContent = ${JSON.stringify(text)}; })()`;
46
+ // Lumi in the recorded frame. The observer logs only caption changes, so the same text shown twice in a row is logged once. Hidden with a style rule when off. When on, a small observer in the page logs each caption Lumi shows, with the time, so the steps can be spoken.
47
+ const lumiScript = (on) => `(() => { if (!${on}) { const s = document.createElement("style"); s.id = "shipvo-lumi-off"; s.textContent = '[data-part="lumi"],[data-part="lumi-caption"],.sf-lumi,.sf-lumi-cap{display:none !important}'; document.head.appendChild(s); return false; }
48
+ const has = !!document.querySelector('[data-part="lumi"]'); const log = window.__shipvoSteps = window.__shipvoSteps || [];
49
+ const c = document.querySelector('[data-part="lumi-caption"]');
50
+ if (c) new MutationObserver(() => { const t = (c.textContent || "").trim(); if (t && !c.hidden && t !== (log.at(-1) || {}).text) log.push({ at: Date.now(), text: t }); }).observe(c, { childList: true, characterData: true, subtree: true, attributes: true });
51
+ return has; })()`;
52
+ /** Lines Lumi speaks: one per step caption, dropped when the previous line would still be playing (about 0.09 s a character plus a 0.6 s gap). Times are seconds on the final video. */
53
+ export function voiceCues(steps, t0, shift, total) {
54
+ const out = [];
55
+ let free = 0;
56
+ for (const s of steps) {
57
+ const t = shift(s.at - t0) / 1000, text = s.text.replace(/\s+/g, " ").trim().slice(0, 80);
58
+ if (!text || t < 0 || t > total - 0.5 || t < free)
59
+ continue;
60
+ out.push([Math.round(t * 1000) / 1000, "lumi", text]);
61
+ free = t + 0.6 + text.length * 0.09;
62
+ }
63
+ return out;
64
+ }
65
+ export class Recorder {
66
+ browser;
67
+ page;
68
+ loop;
69
+ t0 = 0;
70
+ running = false;
71
+ starting = false;
72
+ stopping = false;
73
+ ffErr = "";
74
+ frames = [];
75
+ framesDir = "";
76
+ chapters = [];
77
+ file = "";
78
+ raw = "";
79
+ size = SIZES["9:16"];
80
+ fps = 30;
81
+ showTaps = true;
82
+ lumi = true;
83
+ lumiVoice = false;
84
+ lumiOn = false;
85
+ acts = [];
86
+ get active() { return this.running || this.starting || this.stopping; }
87
+ async start(o) {
88
+ if (this.active)
89
+ throw new RecorderError("recording_active", "a recording is already running: call record_stop first");
90
+ this.starting = true;
91
+ try {
92
+ this.size = SIZES[o.format ?? "9:16"];
93
+ this.fps = o.fps ?? 30;
94
+ this.chapters = [];
95
+ this.acts = [];
96
+ this.ffErr = "";
97
+ this.frames = [];
98
+ this.showTaps = o.showTaps !== false;
99
+ this.lumi = o.lumi !== false;
100
+ this.lumiVoice = this.lumi && o.lumiVoice === true;
101
+ this.lumiOn = false;
102
+ const dir = resolve(o.dir ?? join(process.cwd(), ".shipvo", "recordings"));
103
+ mkdirSync(dir, { recursive: true });
104
+ const slug = (o.title ?? "recording").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "recording";
105
+ this.file = join(dir, `${slug}-${new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14)}.mp4`);
106
+ this.raw = this.file.replace(/\.mp4$/, ".raw.mp4");
107
+ this.framesDir = this.file.replace(/\.mp4$/, ".frames");
108
+ rmSync(this.framesDir, { recursive: true, force: true });
109
+ mkdirSync(this.framesDir);
110
+ let pw;
111
+ try {
112
+ const mod = "playwright";
113
+ pw = await import(mod);
114
+ }
115
+ catch {
116
+ throw new RecorderError("recorder_unavailable", "recording needs Playwright: run `npm i -D playwright && npx playwright install chromium`");
117
+ }
118
+ try {
119
+ this.browser = await pw.chromium.launch(process.env.SHIPVO_CHROMIUM ? { executablePath: process.env.SHIPVO_CHROMIUM } : {});
120
+ }
121
+ catch (e) {
122
+ throw new RecorderError("recorder_unavailable", `could not launch Chromium: run \`npx playwright install chromium\` (${e.message.split("\n")[0]})`);
123
+ }
124
+ this.page = await this.browser.newPage({ viewport: this.size });
125
+ try {
126
+ await this.page.goto(o.url, { waitUntil: "load" });
127
+ }
128
+ catch (e) {
129
+ throw new RecorderError("frame_unreachable", `could not open the phone frame page ${o.url}: ${e.message.split("\n")[0]}`);
130
+ }
131
+ if (o.background && this.page.evaluate) {
132
+ let good;
133
+ try {
134
+ good = await this.page.evaluate(backgroundScript(o.background));
135
+ }
136
+ catch (e) {
137
+ throw new RecorderError("background_failed", `could not set the background: ${e.message.split("\n")[0]}`);
138
+ }
139
+ if (good !== true)
140
+ throw new RecorderError("invalid_background", `background must be "brand", "plain" or a CSS colour such as "#102030"; got ${JSON.stringify(o.background).slice(0, 60)}`);
141
+ }
142
+ if (this.page.evaluate) {
143
+ try {
144
+ this.lumiOn = (await this.page.evaluate(lumiScript(this.lumi))) === true;
145
+ }
146
+ catch { }
147
+ }
148
+ await this.grab();
149
+ // The video timeline starts at the first frame, not before the first (slow) screenshot.
150
+ this.t0 = this.frames[0] ?? Date.now();
151
+ this.running = true;
152
+ this.starting = false;
153
+ this.loop = (async () => { while (this.running)
154
+ await this.grab(); })();
155
+ return { file: this.file, ...this.size };
156
+ }
157
+ catch (e) {
158
+ await this.cleanup();
159
+ if (this.framesDir)
160
+ rmSync(this.framesDir, { recursive: true, force: true });
161
+ throw e;
162
+ }
163
+ }
164
+ async grab() {
165
+ try {
166
+ const b = await this.page.screenshot({ type: "jpeg", quality: 90 });
167
+ const t = Date.now();
168
+ writeFileSync(join(this.framesDir, `${this.frames.length}.jpg`), b);
169
+ this.frames.push(t);
170
+ }
171
+ catch { }
172
+ }
173
+ /** True while a recording is running and taps should be drawn: callers use it to skip the extra locate call. */
174
+ get drawing() { return this.running && this.showTaps; }
175
+ /** Soft finger dot and ripple at p (0..1 of the phone). No-op when not recording or show_taps is false. */
176
+ async tap(p) { await this.draw("tap", p); }
177
+ /** Finger trail from a to b (0..1 of the phone), for swipes and scrolls. */
178
+ async trail(a, b) { await this.draw("trail", a, b); }
179
+ async draw(kind, a, b) {
180
+ if (!this.drawing || !this.page?.evaluate)
181
+ return;
182
+ try {
183
+ await this.page.evaluate(overlayScript(kind, a, b));
184
+ }
185
+ catch { }
186
+ }
187
+ /** The agent did something now. Gaps between actions longer than MAX_IDLE_S are cut from the video. */
188
+ action() { if (this.running) {
189
+ const t = Date.now() - this.t0;
190
+ this.acts.push([t, t]);
191
+ } }
192
+ /** An action that takes time. Its whole span is kept; call the returned function when it ends. */
193
+ actionSpan() { if (!this.running)
194
+ return () => { }; const a = Date.now() - this.t0; return () => { if (this.running)
195
+ this.acts.push([a, Date.now() - this.t0]); }; }
196
+ marker(caption) {
197
+ if (!this.running)
198
+ throw new RecorderError("no_recording", "no recording is running: call record_start first");
199
+ this.action();
200
+ void this.page?.evaluate?.(captionScript(caption))?.catch?.(() => { });
201
+ const last = this.chapters.at(-1)?.t ?? -0.001;
202
+ const c = { t: Math.max(Math.round(Date.now() - this.t0) / 1000, Math.round((last + 0.001) * 1000) / 1000), caption };
203
+ this.chapters.push(c);
204
+ return c;
205
+ }
206
+ async stop(o = {}) {
207
+ if (!this.running)
208
+ throw new RecorderError("no_recording", "no recording is running: call record_start first");
209
+ this.running = false;
210
+ this.stopping = true;
211
+ try {
212
+ const { file, raw, size, t0, fps } = this;
213
+ let chapters = this.chapters;
214
+ let drop = (_t) => 0;
215
+ await this.loop;
216
+ let steps = [];
217
+ if (this.lumiVoice && this.page?.evaluate) {
218
+ try {
219
+ const r = await this.page.evaluate("window.__shipvoSteps || []");
220
+ if (Array.isArray(r))
221
+ steps = r;
222
+ }
223
+ catch { }
224
+ }
225
+ await this.grab();
226
+ const total = Date.now() - t0;
227
+ let duration_s = Math.round(total) / 1000;
228
+ const end = t0 + total;
229
+ await this.cleanup();
230
+ let code = await this.encode(end);
231
+ let err = this.ffErr;
232
+ if (code === 0) {
233
+ // Cut every wait longer than MAX_IDLE_S: keep it after each action, drop the rest up to the next one.
234
+ // Keep MAX_IDLE_S after each action ends and drop the rest up to the next one starts. Time inside an action and the tail after the last one are kept.
235
+ const spans = [[0, 0], ...this.acts].sort((x, y) => x[0] - y[0]);
236
+ const cuts = [];
237
+ let busy = 0;
238
+ for (const [a, e] of spans) {
239
+ if (a - busy > MAX_IDLE_S * 1000 + 50)
240
+ cuts.push([busy + MAX_IDLE_S * 1000, a]);
241
+ busy = Math.max(busy, e);
242
+ }
243
+ if (cuts.length) {
244
+ drop = (t) => cuts.reduce((n, [a, b]) => n + Math.max(0, Math.min(t, b) - a), 0);
245
+ const expr = cuts.map(([a, b]) => `between(t,${a / 1000},${b / 1000})`).join("+");
246
+ const r = spawnSync("ffmpeg", ["-y", "-loglevel", "error", "-i", raw, "-vf", `select='not(${expr})',setpts=N/(${fps}*TB)`, "-r", String(fps), "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-movflags", "+faststart", file]);
247
+ if (r.status !== 0) {
248
+ code = r.status ?? -1;
249
+ err = String(r.stderr);
250
+ }
251
+ else {
252
+ chapters = chapters.map((c) => ({ ...c, t: Math.round(c.t * 1000 - drop(c.t * 1000)) / 1000 }));
253
+ duration_s = Math.round(total - drop(total)) / 1000;
254
+ }
255
+ }
256
+ else if (existsSync(raw))
257
+ renameSync(raw, file);
258
+ rmSync(raw, { force: true });
259
+ }
260
+ const out = { file, duration_s, ...size, chapters, lumi: this.lumiOn };
261
+ if (code !== 0)
262
+ throw new RecorderError("encode_failed", `ffmpeg failed${code === -1 ? " to start (is it installed?)" : ""}: ${err.trim().slice(0, 300)}`);
263
+ let vo;
264
+ let narration = o.narration;
265
+ let voice;
266
+ if (this.lumiVoice && !narration) {
267
+ const cues = voiceCues(steps, t0, (ms) => ms - drop(ms), duration_s);
268
+ if (cues.length) {
269
+ // A failed voice must not throw away the finished recording: keep the silent video (music still applies) and say why.
270
+ try {
271
+ voice = narration = lumiVoice(file, cues, duration_s);
272
+ out.lumi_lines = cues.length;
273
+ }
274
+ catch (e) {
275
+ if (!(e instanceof RecorderError) || e.code !== "voice_failed")
276
+ throw e;
277
+ (out.notes ??= []).push(e.message);
278
+ }
279
+ }
280
+ else
281
+ (out.notes ??= []).push("lumi_voice was on but Lumi showed no step lines to speak");
282
+ }
283
+ else if (this.lumiVoice)
284
+ (out.notes ??= []).push("lumi_voice was ignored because narration was given");
285
+ try {
286
+ if (narration) {
287
+ const r = addNarration(file, narration, !!o.music);
288
+ vo = r.vo;
289
+ if (r.trimmed)
290
+ (out.notes ??= []).push(`narration was ${r.alen.toFixed(1)} s and the video is ${r.vlen.toFixed(1)} s: the narration was trimmed to the video length`);
291
+ }
292
+ }
293
+ finally {
294
+ if (voice)
295
+ rmSync(voice, { force: true });
296
+ }
297
+ try {
298
+ if (o.music)
299
+ out.credits = addMusic(file, vo);
300
+ }
301
+ finally {
302
+ if (vo)
303
+ rmSync(vo, { force: true });
304
+ }
305
+ return out;
306
+ }
307
+ finally {
308
+ rmSync(this.framesDir, { recursive: true, force: true });
309
+ this.stopping = false;
310
+ }
311
+ }
312
+ /** Encode the saved frames, each shown from the time it was taken until the next one. Resolves to ffmpeg's exit code (-1 if it did not start). */
313
+ encode(end) {
314
+ const n = this.frames.length;
315
+ if (!n) {
316
+ this.ffErr = "no frames were captured";
317
+ return Promise.resolve(-1);
318
+ }
319
+ // Names are slugged to [a-z0-9-] in start(), so the quoted paths in this list never hold a single quote.
320
+ const lines = this.frames.map((t, i) => `file '${i}.jpg'\nduration ${(Math.max((this.frames[i + 1] ?? end) - t, 1) / 1000).toFixed(3)}`);
321
+ lines.push(`file '${n - 1}.jpg'`);
322
+ const list = join(this.framesDir, "list.txt");
323
+ writeFileSync(list, lines.join("\n") + "\n");
324
+ const ff = spawn("ffmpeg", ["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", list,
325
+ "-vf", `fps=${this.fps},scale=${this.size.width}:${this.size.height},format=yuv420p`, "-c:v", "libx264", "-preset", "veryfast", "-movflags", "+faststart", this.raw], { stdio: ["ignore", "ignore", "pipe"] });
326
+ ff.stderr.on("data", (d) => { this.ffErr += d; });
327
+ return new Promise((r) => { ff.on("error", (e) => { this.ffErr += e.message; r(-1); }); ff.on("close", r); });
328
+ }
329
+ async cleanup() {
330
+ this.running = false;
331
+ this.starting = false;
332
+ try {
333
+ await this.loop;
334
+ }
335
+ catch { }
336
+ try {
337
+ await this.browser?.close();
338
+ }
339
+ catch { }
340
+ this.browser = this.page = this.loop = undefined;
341
+ }
342
+ }
343
+ /** Mix a ducked music bed under the finished MP4 with scripts/video/bgmusic.py and write <name>.credits.json next to it. Returns the credits path.
344
+ * SHIPVO_BGMUSIC points at the script; SHIPVO_MUSIC_FILE uses a local track instead of picking and downloading one. */
345
+ function addMusic(file, narration) {
346
+ const script = process.env.SHIPVO_BGMUSIC ?? join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "scripts", "video", "bgmusic.py");
347
+ if (!existsSync(script))
348
+ throw new RecorderError("music_unavailable", `music needs bgmusic.py, not found at ${script}: set SHIPVO_BGMUSIC`);
349
+ const credits = file.replace(/\.mp4$/, ".credits.json"), work = file.replace(/\.mp4$/, ".music"), tmp = join(work, "mixed.mp4");
350
+ mkdirSync(work, { recursive: true });
351
+ try {
352
+ const track = process.env.SHIPVO_MUSIC_FILE;
353
+ const args = track ? ["mix", "--music", track] : ["auto", "--want", "cinematic,documentary,corporate", "--outdir", join(work, "tracks")];
354
+ const r = spawnSync("python3", [script, ...args, "--video", file, ...(narration ? ["--narration", narration] : []), "--out", tmp, "--work", work, "--credits", credits], { encoding: "utf8" });
355
+ if (r.error || r.status !== 0 || !existsSync(tmp))
356
+ throw new RecorderError("music_failed", `could not mix music${r.error ? " (is python3 installed?)" : ""}: ${String(r.error?.message ?? r.stderr).trim().split("\n").slice(-2).join(" ").slice(0, 300)}. The silent video is at ${file}`);
357
+ renameSync(tmp, file);
358
+ return credits;
359
+ }
360
+ finally {
361
+ rmSync(work, { recursive: true, force: true });
362
+ }
363
+ }
364
+ /** Speak the cues with Lumi's voice: scripts/video/dialogue.py with the `energetic` preset. SHIPVO_DIALOGUE points at the script. Returns the wav path (next to the video). */
365
+ export function lumiVoice(file, cues, total) {
366
+ const script = process.env.SHIPVO_DIALOGUE ?? join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "scripts", "video", "dialogue.py");
367
+ const bad = (why) => new RecorderError("voice_failed", `${why}. The silent video is at ${file}`);
368
+ if (!existsSync(script))
369
+ throw bad(`lumi_voice needs dialogue.py, not found at ${script}: set SHIPVO_DIALOGUE`);
370
+ const work = file.replace(/\.mp4$/, ".voice");
371
+ mkdirSync(work, { recursive: true });
372
+ try {
373
+ const spec = join(work, "dialogue.json");
374
+ writeFileSync(spec, JSON.stringify({ cast: { lumi: { voice: "af_jessica", speed: 1.1, fx: "energetic" } }, cues }));
375
+ const r = spawnSync("python3", ["-I", script, spec, "--total", total.toFixed(3), "--out", work], { encoding: "utf8" });
376
+ const wav = join(work, "vo.wav");
377
+ if (r.error || r.status !== 0 || !existsSync(wav))
378
+ throw bad(`could not make Lumi's voice${r.error ? " (is python3 installed?)" : ""}: ${String(r.error?.message ?? r.stderr).trim().split("\n").slice(-2).join(" ").slice(0, 300)}`);
379
+ const out = file.replace(/\.mp4$/, ".lumi-voice.wav");
380
+ renameSync(wav, out);
381
+ return out;
382
+ }
383
+ finally {
384
+ rmSync(work, { recursive: true, force: true });
385
+ }
386
+ }
387
+ const probeDur = (f) => { const r = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", f], { encoding: "utf8" }); const d = parseFloat(r.stdout); return r.status === 0 && d > 0 ? d : NaN; };
388
+ /** Add a narration wav to the silent MP4. The wav is cut to the video length and padded with silence if shorter, so the audio matches the video.
389
+ * With `forMusic` the trimmed wav is left next to the video for the music mixer (the caller deletes it) and the video is not changed. */
390
+ function addNarration(file, wav, forMusic) {
391
+ const bad = (why) => new RecorderError("narration_unreadable", `${why}. The silent video is at ${file}`);
392
+ if (!existsSync(wav))
393
+ throw bad(`narration file not found: ${wav}`);
394
+ const alen = probeDur(wav), vlen = probeDur(file);
395
+ if (!(alen > 0))
396
+ throw bad(`narration file is not readable audio: ${wav}`);
397
+ const trimmed = alen > vlen + 0.05;
398
+ const tmp = file.replace(/\.mp4$/, forMusic ? ".narration.wav" : ".narrated.mp4");
399
+ const args = forMusic ? ["-i", wav, "-t", vlen.toFixed(3), tmp]
400
+ : ["-i", file, "-i", wav, "-map", "0:v", "-map", "1:a", "-af", "apad", "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-t", vlen.toFixed(3), "-movflags", "+faststart", tmp];
401
+ const r = spawnSync("ffmpeg", ["-y", "-loglevel", "error", ...args]);
402
+ if (r.status !== 0 || !existsSync(tmp)) {
403
+ rmSync(tmp, { force: true });
404
+ throw bad(`could not add narration: ${String(r.stderr).trim().split("\n").slice(-1)[0].slice(0, 200)}`);
405
+ }
406
+ if (forMusic)
407
+ return { trimmed, alen, vlen, vo: tmp };
408
+ renameSync(tmp, file);
409
+ return { trimmed, alen, vlen };
410
+ }