@spark-apps/quickpeek 1.2.2 → 1.2.4

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/index.mjs CHANGED
@@ -38,8 +38,19 @@ function parsePartial(partial) {
38
38
  function getErrorMessage(err) {
39
39
  return err instanceof Error ? err.message : String(err);
40
40
  }
41
+ function deStock(text) {
42
+ let out = text;
43
+ for (const [re, replacement] of STOCK_PHRASES) out = out.replace(re, replacement);
44
+ return out.replace(/\s{2,}/g, " ").replace(/\s+([,.!?])/g, "$1").trim();
45
+ }
46
+ function stripLongDashes(text) {
47
+ return text.replace(/(\d)\s*[\u2013\u2014]\s*(\d)/g, "$1-$2").replace(/\s*[\u2013\u2014]\s*/g, ", ").replace(/,\s*,/g, ",").replace(/\s+([,.!?])/g, "$1").replace(/\s{2,}/g, " ").trim();
48
+ }
41
49
  function toTitleCase(text) {
42
- return text.replace(/\w+(?:'\w+)*/g, (word, index) => {
50
+ return deStock(stripLongDashes(text)).replace(/\w+(?:'\w+)*/g, (word, index) => {
51
+ if (/[A-Z]/.test(word.slice(1))) {
52
+ return word;
53
+ }
43
54
  if (index === 0) {
44
55
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
45
56
  }
@@ -49,7 +60,17 @@ function toTitleCase(text) {
49
60
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
50
61
  });
51
62
  }
52
- var LANGUAGE_NAMES, LOWERCASE_WORDS;
63
+ async function runPooled(count, size, task) {
64
+ let cursor = 0;
65
+ const worker = async () => {
66
+ while (cursor < count) {
67
+ const index = cursor++;
68
+ await task(index);
69
+ }
70
+ };
71
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(size, count)) }, worker));
72
+ }
73
+ var LANGUAGE_NAMES, LOWERCASE_WORDS, STOCK_PHRASES;
53
74
  var init_utils = __esm({
54
75
  "src/lib/utils.ts"() {
55
76
  "use strict";
@@ -99,13 +120,21 @@ var init_utils = __esm({
99
120
  "under",
100
121
  "than"
101
122
  ]);
123
+ STOCK_PHRASES = [
124
+ [/\bfree to start\b/gi, "free to try"],
125
+ [/\bget started today\b/gi, "start now"],
126
+ [/\bsay goodbye to\b/gi, "no more"],
127
+ [/\bsupercharge\b/gi, "speed up"],
128
+ [/\beffortlessly\b/gi, "easily"],
129
+ [/\bseamlessly\b/gi, ""]
130
+ ];
102
131
  }
103
132
  });
104
133
 
105
134
  // src/lib/watermark.ts
106
135
  function buildWatermarkFilters(opts) {
107
- const { tier, videoDuration } = opts;
108
- if (tier === "pro") {
136
+ const { tier, videoDuration, endsOnCreditsCard = false } = opts;
137
+ if (tier !== "free") {
109
138
  return "";
110
139
  }
111
140
  if (videoDuration <= WATERMARK_THRESHOLD_SECONDS) {
@@ -113,6 +142,7 @@ function buildWatermarkFilters(opts) {
113
142
  }
114
143
  const filters = [];
115
144
  filters.push(`drawtext=text='qp':fontsize=24:fontcolor=white@0.3:x=w-tw-20:y=h-th-20`);
145
+ if (endsOnCreditsCard) return filters.join(",");
116
146
  const endTextStart = Math.max(0, videoDuration - END_TEXT_DURATION);
117
147
  const fadeEndTime = endTextStart + END_TEXT_FADE_IN;
118
148
  const alphaExpr = `if(lt(t,${endTextStart}),0,if(lt(t,${fadeEndTime}),(t-${endTextStart})*0.6,0.6))`;
@@ -131,8 +161,226 @@ var init_watermark = __esm({
131
161
  }
132
162
  });
133
163
 
134
- // src/lib/ffmpeg/spawn.ts
164
+ // src/lib/lifecycle.ts
165
+ function trackChild(child) {
166
+ installLifecycle();
167
+ children.add(child);
168
+ const drop = () => children.delete(child);
169
+ child.once("close", drop);
170
+ child.once("error", drop);
171
+ return child;
172
+ }
173
+ function teardown() {
174
+ tearingDown ??= (async () => {
175
+ for (const child of children) {
176
+ try {
177
+ child.kill("SIGKILL");
178
+ } catch {
179
+ }
180
+ }
181
+ children.clear();
182
+ const pending = [...cleanups];
183
+ cleanups.clear();
184
+ await Promise.allSettled(pending.map((fn) => fn()));
185
+ })();
186
+ return tearingDown;
187
+ }
188
+ function installLifecycle() {
189
+ if (installed) return;
190
+ installed = true;
191
+ process.once("beforeExit", () => {
192
+ void teardown();
193
+ });
194
+ process.on("exit", () => {
195
+ for (const child of children) {
196
+ try {
197
+ child.kill("SIGKILL");
198
+ } catch {
199
+ }
200
+ }
201
+ });
202
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
203
+ process.once(signal, () => {
204
+ void teardown().finally(() => {
205
+ process.removeAllListeners(signal);
206
+ process.kill(process.pid, signal);
207
+ });
208
+ });
209
+ }
210
+ process.once("uncaughtException", (err) => {
211
+ void teardown().finally(() => {
212
+ console.error(err);
213
+ process.exit(1);
214
+ });
215
+ });
216
+ process.once("unhandledRejection", (err) => {
217
+ void teardown().finally(() => {
218
+ console.error(err);
219
+ process.exit(1);
220
+ });
221
+ });
222
+ }
223
+ var cleanups, children, installed, tearingDown;
224
+ var init_lifecycle = __esm({
225
+ "src/lib/lifecycle.ts"() {
226
+ "use strict";
227
+ cleanups = /* @__PURE__ */ new Set();
228
+ children = /* @__PURE__ */ new Set();
229
+ installed = false;
230
+ tearingDown = null;
231
+ }
232
+ });
233
+
234
+ // src/lib/ffmpeg/hwaccel.ts
235
+ import { cpus } from "os";
135
236
  import { spawn } from "child_process";
237
+ function softwareEncoder(crf, preset) {
238
+ return {
239
+ input: [],
240
+ output: ["-c:v", "libx264", "-preset", preset, "-crf", String(crf), "-pix_fmt", "yuv420p"],
241
+ filter: null,
242
+ name: `libx264 ${preset}`
243
+ };
244
+ }
245
+ function detectHardwareEncoder(quality = 24) {
246
+ probed ??= (async () => {
247
+ for (const build of CANDIDATES) {
248
+ const encoder = build(quality);
249
+ if (await canEncode(encoder)) return encoder;
250
+ }
251
+ return null;
252
+ })();
253
+ return probed;
254
+ }
255
+ async function resolveEncoder(opts = {
256
+ crf: 23,
257
+ preset: "medium"
258
+ }) {
259
+ if (opts.allowHardware !== false) {
260
+ const hw = await detectHardwareEncoder(opts.quality ?? opts.crf);
261
+ if (hw) {
262
+ if (opts.width && opts.height) {
263
+ const sw = softwareEncoder(opts.crf, opts.preset);
264
+ const won = await faster(hw, sw, opts.width, opts.height);
265
+ return announce(won, won === hw ? "hardware" : "software (faster than the GPU at this size)");
266
+ }
267
+ return announce(hw, "hardware");
268
+ }
269
+ }
270
+ return announce(softwareEncoder(opts.crf, opts.preset), "software (no usable GPU encoder found)");
271
+ }
272
+ async function faster(hw, sw, width, height) {
273
+ raced ??= (async () => {
274
+ const decided = Promise.all([timeEncode(hw, width, height), timeEncode(sw, width, height)]).then(
275
+ ([hwMs, swMs]) => hwMs === null ? false : swMs === null ? true : hwMs <= swMs
276
+ );
277
+ const bailout = new Promise((r) => setTimeout(() => r(true), 8e3).unref?.());
278
+ return Promise.race([decided, bailout]);
279
+ })();
280
+ return await raced ? hw : sw;
281
+ }
282
+ async function timeEncode(encoder, width, height) {
283
+ const args = [
284
+ "-hide_banner",
285
+ "-loglevel",
286
+ "error",
287
+ ...encoder.input,
288
+ "-f",
289
+ "lavfi",
290
+ "-i",
291
+ `testsrc2=size=${width}x${height}:rate=30:duration=1`,
292
+ ...encoder.filter ? ["-vf", encoder.filter] : [],
293
+ ...encoder.output,
294
+ "-frames:v",
295
+ "12",
296
+ "-f",
297
+ "null",
298
+ "-"
299
+ ];
300
+ const began = Date.now();
301
+ const ok = await run(args);
302
+ return ok ? Date.now() - began : null;
303
+ }
304
+ function announce(encoder, how) {
305
+ if (announced) return encoder;
306
+ announced = true;
307
+ console.log(` Video encoder: ${encoder.name} - ${how}`);
308
+ return encoder;
309
+ }
310
+ function canEncode(encoder) {
311
+ return run([
312
+ "-hide_banner",
313
+ "-loglevel",
314
+ "error",
315
+ ...encoder.input,
316
+ "-f",
317
+ "lavfi",
318
+ "-i",
319
+ "testsrc2=size=128x128:rate=2:duration=1",
320
+ ...encoder.filter ? ["-vf", encoder.filter] : [],
321
+ ...encoder.output,
322
+ "-frames:v",
323
+ "2",
324
+ "-f",
325
+ "null",
326
+ "-"
327
+ ]);
328
+ }
329
+ function run(args) {
330
+ return new Promise((resolve3) => {
331
+ let settled = false;
332
+ const done = (ok) => {
333
+ if (settled) return;
334
+ settled = true;
335
+ clearTimeout(timer);
336
+ resolve3(ok);
337
+ };
338
+ const proc = trackChild(spawn("ffmpeg", args, { stdio: "ignore" }));
339
+ const timer = setTimeout(() => {
340
+ proc.kill("SIGKILL");
341
+ done(false);
342
+ }, 1e4);
343
+ proc.on("close", (code) => done(code === 0));
344
+ proc.on("error", () => done(false));
345
+ });
346
+ }
347
+ function filterThreadArgs() {
348
+ const cores = Math.max(1, cpus().length);
349
+ return ["-filter_threads", String(cores), "-filter_complex_threads", String(cores)];
350
+ }
351
+ var CANDIDATES, probed, raced, announced;
352
+ var init_hwaccel = __esm({
353
+ "src/lib/ffmpeg/hwaccel.ts"() {
354
+ "use strict";
355
+ init_lifecycle();
356
+ CANDIDATES = [
357
+ (q) => ({
358
+ input: ["-init_hw_device", "vaapi=hw:/dev/dri/renderD128", "-filter_hw_device", "hw"],
359
+ output: ["-c:v", "h264_vaapi", "-qp", String(q)],
360
+ filter: "format=nv12,hwupload",
361
+ name: "h264_vaapi"
362
+ }),
363
+ (q) => ({
364
+ input: [],
365
+ output: ["-c:v", "h264_nvenc", "-preset", "p1", "-cq", String(q), "-pix_fmt", "yuv420p"],
366
+ filter: null,
367
+ name: "h264_nvenc"
368
+ }),
369
+ (q) => ({
370
+ input: [],
371
+ output: ["-c:v", "h264_qsv", "-global_quality", String(q), "-pix_fmt", "nv12"],
372
+ filter: null,
373
+ name: "h264_qsv"
374
+ })
375
+ ];
376
+ probed = null;
377
+ raced = null;
378
+ announced = false;
379
+ }
380
+ });
381
+
382
+ // src/lib/ffmpeg/spawn.ts
383
+ import { spawn as spawn2 } from "child_process";
136
384
  function toWSLPath(path) {
137
385
  if (!isWindowsRuntime) return path;
138
386
  return windowsDrivePathToWsl(path) ?? path.replace(/\\/g, "/");
@@ -146,16 +394,16 @@ function convertArgToWSL(arg) {
146
394
  function spawnFFmpeg(cmd, args) {
147
395
  if (isWindowsRuntime) {
148
396
  const wslArgs = args.map(convertArgToWSL);
149
- return spawn("wsl", ["-e", cmd, ...wslArgs]);
397
+ return trackChild(spawn2("wsl", ["-e", cmd, ...wslArgs]));
150
398
  }
151
- return spawn(cmd, args);
399
+ return trackChild(spawn2(cmd, args));
152
400
  }
153
401
  function runFFmpeg(args, errorLabel) {
154
402
  return new Promise((resolve3, reject) => {
155
403
  const proc = spawnFFmpeg("ffmpeg", args);
156
404
  let stderr = "";
157
405
  proc.stderr.on("data", (d) => {
158
- stderr += d.toString();
406
+ stderr = (stderr + d.toString()).slice(-STDERR_TAIL);
159
407
  });
160
408
  proc.on("close", (code) => {
161
409
  if (code !== 0) reject(new Error(`${errorLabel}: ${stderr.slice(-500)}`));
@@ -164,112 +412,153 @@ function runFFmpeg(args, errorLabel) {
164
412
  proc.on("error", reject);
165
413
  });
166
414
  }
167
- var isWindowsRuntime;
415
+ var isWindowsRuntime, STDERR_TAIL;
168
416
  var init_spawn = __esm({
169
417
  "src/lib/ffmpeg/spawn.ts"() {
170
418
  "use strict";
419
+ init_lifecycle();
171
420
  init_utils();
172
421
  isWindowsRuntime = process.platform === "win32";
422
+ STDERR_TAIL = 4e3;
173
423
  }
174
424
  });
175
425
 
176
426
  // src/lib/ffmpeg/compose.ts
177
427
  import { MASTER_CHAIN } from "@spark-apps/video-kit";
178
- function composeVideo(opts) {
179
- return new Promise((resolve3, reject) => {
180
- const {
181
- videoPath,
182
- assPath,
183
- outputPath,
184
- voiceoverPath,
185
- musicPath,
186
- musicVolume = 0.3,
187
- musicFadeIn = 2,
188
- musicFadeOut = 2,
189
- width,
190
- height,
191
- fps,
192
- preset,
193
- quality,
194
- fadeIn,
195
- fadeOut,
196
- videoDuration,
197
- audioBitrate = "192k",
198
- contrast = 0,
199
- trimStart = 0,
200
- userTier = "free"
201
- } = opts;
202
- const args = ["-y"];
203
- if (trimStart > 0) {
204
- args.push("-ss", String(trimStart));
205
- }
206
- args.push("-i", videoPath);
207
- const effectiveDuration = videoDuration - trimStart;
208
- const voiceoverDuration = opts.mainContentDuration ?? effectiveDuration;
209
- if (voiceoverPath) args.push("-i", voiceoverPath);
210
- if (musicPath) args.push("-i", musicPath);
211
- const vf = [`scale=${width}:${height}`, `fps=${fps}`];
212
- if (contrast > 0) {
213
- const ffmpegContrast = 1 + contrast;
214
- vf.push(`eq=contrast=${ffmpegContrast.toFixed(2)}`);
215
- }
216
- const watermarkFilters = buildWatermarkFilters({ tier: userTier, videoDuration: effectiveDuration });
217
- if (watermarkFilters) {
218
- vf.push(watermarkFilters);
219
- }
428
+ async function composeVideo(opts) {
429
+ const {
430
+ videoPath,
431
+ assPath,
432
+ outputPath,
433
+ voiceoverPath,
434
+ musicPath,
435
+ musicVolume = 0.3,
436
+ musicTargetLufs = MUSIC_BED_LUFS,
437
+ musicFadeIn = 2,
438
+ musicFadeOut = 2,
439
+ width,
440
+ height,
441
+ fps,
442
+ preset,
443
+ quality,
444
+ fadeIn,
445
+ fadeOut,
446
+ videoDuration,
447
+ audioBitrate = "192k",
448
+ contrast = 0,
449
+ trimStart = 0,
450
+ userTier = "free",
451
+ endsOnCreditsCard = false
452
+ } = opts;
453
+ const crf = Math.round(28 - quality * 10);
454
+ const encoder = await resolveEncoder({
455
+ crf,
456
+ preset,
457
+ quality: Math.max(16, crf - 4),
458
+ allowHardware: opts.hwaccel !== "off",
459
+ // The race is only meaningful at the size actually being rendered: the
460
+ // GPU's per-frame upload is a fixed cost that a draft's ninth of the
461
+ // pixels cannot amortise.
462
+ width,
463
+ height
464
+ });
465
+ const args = ["-y", ...filterThreadArgs(), ...encoder.input];
466
+ if (trimStart > 0) {
467
+ args.push("-ss", String(trimStart));
468
+ }
469
+ args.push("-i", videoPath);
470
+ const effectiveDuration = videoDuration - trimStart;
471
+ const voiceoverDuration = opts.mainContentDuration ?? effectiveDuration;
472
+ if (voiceoverPath) args.push("-i", voiceoverPath);
473
+ if (musicPath) args.push("-i", musicPath);
474
+ const vf = [`scale=${width}:${height}`, `fps=${fps}`];
475
+ if (contrast > 0) {
476
+ const ffmpegContrast = 1 + contrast;
477
+ vf.push(`eq=contrast=${ffmpegContrast.toFixed(2)}`);
478
+ }
479
+ const watermarkFilters = buildWatermarkFilters({
480
+ tier: userTier,
481
+ videoDuration: effectiveDuration,
482
+ endsOnCreditsCard
483
+ });
484
+ if (watermarkFilters) {
485
+ vf.push(watermarkFilters);
486
+ }
487
+ if (assPath) {
220
488
  const assPathForFilter = toWSLPath(assPath).replace(/:/g, "\\:");
221
489
  vf.push(`ass=${assPathForFilter}`);
222
- if (fadeIn > 0) vf.push(`fade=t=in:st=0:d=${fadeIn}:c=black`);
223
- if (fadeOut > 0) {
224
- const videoFadeOutStart = Math.max(0, effectiveDuration - fadeOut);
225
- vf.push(`fade=t=out:st=${videoFadeOutStart}:d=${fadeOut}:c=black`);
226
- }
227
- const crf = Math.round(28 - quality * 10);
228
- const hasVoiceover = !!voiceoverPath;
229
- const hasMusic = !!musicPath;
230
- const musicFadeOutStart = Math.max(0, effectiveDuration - musicFadeOut);
231
- const vfStr = vf.join(",");
232
- if (hasVoiceover && hasMusic) {
233
- const complexFilter = [
234
- // Voiceover: fade in only (no fade out - stays clear until end)
235
- `[1:a]apad=whole_dur=${voiceoverDuration},afade=t=in:st=0:d=${fadeIn}[voice]`,
236
- // Music: fade in and fade out
237
- `[2:a]aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut}[music]`,
238
- // Normalise the MIX and hard-limit its true peak - ported from
239
- // VidLet, where single-pass loudnorm alone measured +0.9 dBTP on
240
- // sharp TTS consonants. -14 LUFS is the YouTube/IG/TikTok playback
241
- // target, so the platform leaves the audio untouched.
242
- `[voice][music]amix=inputs=2:duration=longest,${MASTER_CHAIN}[aout]`
243
- ].join(";");
244
- args.push("-vf", vfStr);
245
- args.push("-filter_complex", complexFilter, "-map", "0:v", "-map", "[aout]");
246
- args.push("-c:v", "libx264", "-preset", preset, "-crf", String(crf), "-pix_fmt", "yuv420p");
247
- args.push("-c:a", "aac", "-b:a", audioBitrate);
248
- } else if (hasVoiceover) {
249
- const af = `apad=whole_dur=${voiceoverDuration},afade=t=in:st=0:d=${fadeIn},${MASTER_CHAIN}`;
250
- args.push("-vf", vfStr);
251
- args.push("-map", "0:v", "-map", "1:a", "-af", af);
252
- args.push("-c:v", "libx264", "-preset", preset, "-crf", String(crf), "-pix_fmt", "yuv420p");
253
- args.push("-c:a", "aac", "-b:a", audioBitrate);
254
- } else if (hasMusic) {
255
- const musicFilter = `aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut},${MASTER_CHAIN}`;
256
- args.push("-vf", vfStr);
257
- args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
258
- args.push("-c:v", "libx264", "-preset", preset, "-crf", String(crf), "-pix_fmt", "yuv420p");
259
- args.push("-c:a", "aac", "-b:a", "192k");
260
- } else {
261
- args.push("-vf", vfStr);
262
- args.push("-c:v", "libx264", "-preset", preset, "-crf", String(crf), "-pix_fmt", "yuv420p");
263
- }
264
- args.push(outputPath);
265
- runFFmpeg(args, "ffmpeg compose failed").then(resolve3, reject);
266
- });
490
+ }
491
+ if (fadeIn > 0) vf.push(`fade=t=in:st=0:d=${fadeIn}:c=black`);
492
+ if (fadeOut > 0 && !endsOnCreditsCard) {
493
+ const videoFadeOutStart = Math.max(0, effectiveDuration - fadeOut);
494
+ vf.push(`fade=t=out:st=${videoFadeOutStart}:d=${fadeOut}:c=black`);
495
+ }
496
+ const clipAudioLabel = opts.hasClipAudio ? "[0:a]" : "";
497
+ const hasVoiceover = !!voiceoverPath;
498
+ const hasMusic = !!musicPath;
499
+ const musicFadeOutStart = Math.max(0, effectiveDuration - musicFadeOut);
500
+ const vfStr = [...vf, ...encoder.filter ? [encoder.filter] : []].join(",");
501
+ if (hasVoiceover && hasMusic) {
502
+ const mixInputs = clipAudioLabel ? 3 : 2;
503
+ const complexFilter = [
504
+ // Voiceover: fade in only (no fade out - stays clear until end). Split,
505
+ // because one copy is mixed and the other is only listened to, by the
506
+ // compressor that pulls the bed down under it.
507
+ `[1:a]adelay=${VOICE_LEAD_MS}|${VOICE_LEAD_MS},apad=whole_dur=${voiceoverDuration},asplit=2[voice][voicekey0]`,
508
+ // The detector's copy runs the length of the PICTURE, not the length of
509
+ // the narration. sidechaincompress is a framesync filter and ends at its
510
+ // shorter input, so a key that stops when the voice does takes the bed
511
+ // with it - and the stretch it would have taken is exactly the closing
512
+ // card, which is the one place the bed is the only thing playing.
513
+ `[voicekey0]apad=whole_dur=${effectiveDuration}[voicekey]`,
514
+ // Music: normalised to a known level, trimmed, then faded.
515
+ `[2:a]aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},loudnorm=I=${musicTargetLufs}:TP=-9:LRA=11,volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut}[bed]`,
516
+ `[bed][voicekey]${DUCK}[music]`,
517
+ // normalize=0, because amix's default divides every input by the number
518
+ // of them: adding the bed used to quietly take 6 dB off the narration,
519
+ // and the master loudnorm then put it back by turning everything up,
520
+ // bed included. Levels set deliberately upstream should survive the mix.
521
+ //
522
+ // Normalise the MIX and hard-limit its true peak - ported from
523
+ // VidLet, where single-pass loudnorm alone measured +0.9 dBTP on
524
+ // sharp TTS consonants. -14 LUFS is the YouTube/IG/TikTok playback
525
+ // target, so the platform leaves the audio untouched.
526
+ `[voice][music]${clipAudioLabel}amix=inputs=${mixInputs}:duration=longest:normalize=0,${MASTER_CHAIN}[aout]`
527
+ ].join(";");
528
+ args.push("-vf", vfStr);
529
+ args.push("-filter_complex", complexFilter, "-map", "0:v", "-map", "[aout]");
530
+ args.push(...encoder.output);
531
+ args.push("-c:a", "aac", "-b:a", audioBitrate);
532
+ } else if (hasVoiceover) {
533
+ const af = `adelay=${VOICE_LEAD_MS}|${VOICE_LEAD_MS},apad=whole_dur=${voiceoverDuration},${MASTER_CHAIN}`;
534
+ args.push("-vf", vfStr);
535
+ args.push("-map", "0:v", "-map", "1:a", "-af", af);
536
+ args.push(...encoder.output);
537
+ args.push("-c:a", "aac", "-b:a", audioBitrate);
538
+ } else if (hasMusic) {
539
+ const musicFilter = `aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut},${MASTER_CHAIN}`;
540
+ args.push("-vf", vfStr);
541
+ args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
542
+ args.push(...encoder.output);
543
+ args.push("-c:a", "aac", "-b:a", "192k");
544
+ } else {
545
+ args.push("-vf", vfStr);
546
+ args.push(...encoder.output);
547
+ }
548
+ args.push("-movflags", "+faststart");
549
+ args.push(outputPath);
550
+ await runFFmpeg(args, "ffmpeg compose failed");
267
551
  }
552
+ var VOICE_LEAD_MS, MUSIC_BED_LUFS, DUCK;
268
553
  var init_compose = __esm({
269
554
  "src/lib/ffmpeg/compose.ts"() {
270
555
  "use strict";
271
556
  init_watermark();
557
+ init_hwaccel();
272
558
  init_spawn();
559
+ VOICE_LEAD_MS = 200;
560
+ MUSIC_BED_LUFS = -34;
561
+ DUCK = "sidechaincompress=threshold=0.05:ratio=8:attack=20:release=420:level_sc=4:makeup=1";
273
562
  }
274
563
  });
275
564
 
@@ -315,8 +604,130 @@ var init_compress = __esm({
315
604
  }
316
605
  });
317
606
 
607
+ // src/lib/ffmpeg/gif.ts
608
+ import { unlink } from "fs/promises";
609
+ import { tmpdir } from "os";
610
+ import { join } from "path";
611
+ function buildPaletteArgs(input, palettePath, fps, width, statsMode) {
612
+ return [
613
+ "-y",
614
+ "-i",
615
+ input,
616
+ "-vf",
617
+ `fps=${fps},scale=${width}:-1:flags=lanczos,palettegen=stats_mode=${statsMode}`,
618
+ palettePath
619
+ ];
620
+ }
621
+ function buildGifArgs(input, palettePath, output, fps, width, dither) {
622
+ return [
623
+ "-y",
624
+ "-i",
625
+ input,
626
+ "-i",
627
+ palettePath,
628
+ "-lavfi",
629
+ `fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=${dither}`,
630
+ output
631
+ ];
632
+ }
633
+ async function convertToGif(options) {
634
+ const { input, output } = options;
635
+ const fps = options.fps ?? GIF_DEFAULTS.fps;
636
+ const width = options.width ?? GIF_DEFAULTS.width;
637
+ const dither = options.dither ?? GIF_DEFAULTS.dither;
638
+ const statsMode = options.statsMode ?? GIF_DEFAULTS.statsMode;
639
+ const palettePath = join(tmpdir(), `quickpeek_palette_${Date.now()}.png`);
640
+ try {
641
+ await runFFmpeg(buildPaletteArgs(input, palettePath, fps, width, statsMode), "GIF palette generation failed");
642
+ await runFFmpeg(buildGifArgs(input, palettePath, output, fps, width, dither), "GIF conversion failed");
643
+ return output;
644
+ } finally {
645
+ try {
646
+ await unlink(palettePath);
647
+ } catch {
648
+ }
649
+ }
650
+ }
651
+ var GIF_DEFAULTS;
652
+ var init_gif = __esm({
653
+ "src/lib/ffmpeg/gif.ts"() {
654
+ "use strict";
655
+ init_spawn();
656
+ GIF_DEFAULTS = { fps: 15, width: 480, dither: "sierra2_4a", statsMode: "full" };
657
+ }
658
+ });
659
+
318
660
  // src/lib/ffmpeg/media.ts
661
+ import { statSync } from "fs";
662
+ function cacheKey(filePath) {
663
+ try {
664
+ const { size, mtimeMs } = statSync(filePath);
665
+ return `${filePath}:${size}:${mtimeMs}`;
666
+ } catch {
667
+ return null;
668
+ }
669
+ }
670
+ function getVideoDimensions(filePath) {
671
+ return new Promise((resolve3, reject) => {
672
+ const args = [
673
+ "-v",
674
+ "error",
675
+ "-select_streams",
676
+ "v:0",
677
+ "-show_entries",
678
+ "stream=width,height",
679
+ "-of",
680
+ "csv=p=0",
681
+ filePath
682
+ ];
683
+ const proc = spawnFFmpeg("ffprobe", args);
684
+ let stdout = "";
685
+ let stderr = "";
686
+ proc.stdout.on("data", (d) => {
687
+ stdout += d;
688
+ });
689
+ proc.stderr.on("data", (d) => {
690
+ stderr += d;
691
+ });
692
+ proc.on("close", (code) => {
693
+ if (code !== 0) {
694
+ reject(new Error(`ffprobe failed: ${stderr}`));
695
+ return;
696
+ }
697
+ const [w, h] = stdout.trim().split(",").map(Number);
698
+ if (!w || !h) {
699
+ reject(new Error(`ffprobe returned no dimensions for ${filePath}`));
700
+ return;
701
+ }
702
+ resolve3({ width: w, height: h });
703
+ });
704
+ });
705
+ }
706
+ function hasAudioStream(filePath) {
707
+ return new Promise((resolve3) => {
708
+ const proc = spawnFFmpeg("ffprobe", [
709
+ "-v",
710
+ "error",
711
+ "-select_streams",
712
+ "a:0",
713
+ "-show_entries",
714
+ "stream=codec_type",
715
+ "-of",
716
+ "csv=p=0",
717
+ filePath
718
+ ]);
719
+ let out = "";
720
+ proc.stdout.on("data", (d) => {
721
+ out += d;
722
+ });
723
+ proc.on("close", () => resolve3(out.trim().length > 0));
724
+ proc.on("error", () => resolve3(false));
725
+ });
726
+ }
319
727
  function getMediaDuration(filePath) {
728
+ const key = cacheKey(filePath);
729
+ const cached = key === null ? void 0 : durationCache.get(key);
730
+ if (cached !== void 0) return Promise.resolve(cached);
320
731
  return new Promise((resolve3, reject) => {
321
732
  const args = [
322
733
  "-v",
@@ -341,8 +752,10 @@ function getMediaDuration(filePath) {
341
752
  reject(new Error(`ffprobe failed: ${stderr}`));
342
753
  return;
343
754
  }
344
- const duration = parseFloat(stdout.trim());
345
- resolve3(Math.ceil(duration * 1e3));
755
+ const ms = Math.ceil(parseFloat(stdout.trim()) * 1e3);
756
+ const freshKey = cacheKey(filePath);
757
+ if (freshKey !== null && Number.isFinite(ms)) durationCache.set(freshKey, ms);
758
+ resolve3(ms);
346
759
  });
347
760
  proc.on("error", reject);
348
761
  });
@@ -381,6 +794,12 @@ function buildConcatArgs(listPath, output) {
381
794
  function concatMedia(listPath, output) {
382
795
  return runFFmpeg(buildConcatArgs(listPath, output), "ffmpeg concat failed");
383
796
  }
797
+ async function trimEdgeSilence(filePath) {
798
+ const trimmed = filePath.replace(/\.mp3$/i, "-trim.mp3");
799
+ await runFFmpeg(["-y", "-i", filePath, "-af", TRIM_EDGE_SILENCE, trimmed], "ffmpeg silence trim failed");
800
+ const { rename } = await import("fs/promises");
801
+ await rename(trimmed, filePath);
802
+ }
384
803
  function generateSilence(outputPath, durationMs) {
385
804
  return runFFmpeg(
386
805
  [
@@ -411,7 +830,23 @@ function speedUpVideo(opts) {
411
830
  remaining /= tempo;
412
831
  }
413
832
  return runFFmpeg(
414
- ["-y", "-i", inputPath, "-vf", videoFilter, "-af", audioFilters.join(","), "-preset", preset, outputPath],
833
+ // faststart for the same reason as compose: this pass can be the last
834
+ // thing to touch the delivered file, and a moov atom written after the
835
+ // payload makes it unplayable in a browser preview.
836
+ [
837
+ "-y",
838
+ "-i",
839
+ inputPath,
840
+ "-vf",
841
+ videoFilter,
842
+ "-af",
843
+ audioFilters.join(","),
844
+ "-preset",
845
+ preset,
846
+ "-movflags",
847
+ "+faststart",
848
+ outputPath
849
+ ],
415
850
  "ffmpeg speedup failed"
416
851
  );
417
852
  }
@@ -426,51 +861,589 @@ function buildOutroFilterComplex(width, height) {
426
861
  "[v0][v1]concat=n=2:v=1:a=0[outv]"
427
862
  ].join(";");
428
863
  }
429
- function concatVideoWithOutro(opts) {
430
- const { inputPath, outroPath, outputPath, width, height, preset } = opts;
431
- const filterComplex = buildOutroFilterComplex(width, height);
432
- return runFFmpeg(
433
- [
864
+ function concatVideoWithOutro(opts) {
865
+ const { inputPath, outroPath, outputPath, width, height, preset } = opts;
866
+ const filterComplex = buildOutroFilterComplex(width, height);
867
+ return runFFmpeg(
868
+ [
869
+ "-y",
870
+ "-i",
871
+ inputPath,
872
+ "-i",
873
+ outroPath,
874
+ "-filter_complex",
875
+ filterComplex,
876
+ "-map",
877
+ "[outv]",
878
+ "-c:v",
879
+ "libx264",
880
+ "-preset",
881
+ preset,
882
+ "-crf",
883
+ "18",
884
+ "-pix_fmt",
885
+ "yuv420p",
886
+ "-an",
887
+ outputPath
888
+ ],
889
+ "ffmpeg concat video failed"
890
+ );
891
+ }
892
+ function concatVideoWithFullFrameOutro(opts) {
893
+ const { inputPath, outroPath, outputPath, width, height, preset } = opts;
894
+ const filterComplex = [
895
+ `[0:v]scale=${width}:${height},setsar=1,fps=30[v0]`,
896
+ `[1:v]scale=${width}:${height},setsar=1,fps=30[v1]`,
897
+ "[v0][v1]concat=n=2:v=1:a=0[outv]"
898
+ ].join(";");
899
+ return runFFmpeg(
900
+ [
901
+ "-y",
902
+ "-i",
903
+ inputPath,
904
+ "-i",
905
+ outroPath,
906
+ "-filter_complex",
907
+ filterComplex,
908
+ "-map",
909
+ "[outv]",
910
+ "-c:v",
911
+ "libx264",
912
+ "-preset",
913
+ preset,
914
+ "-crf",
915
+ "18",
916
+ "-pix_fmt",
917
+ "yuv420p",
918
+ "-an",
919
+ outputPath
920
+ ],
921
+ "ffmpeg full-frame outro concat failed"
922
+ );
923
+ }
924
+ var durationCache, TRIM_EDGE_SILENCE;
925
+ var init_media = __esm({
926
+ "src/lib/ffmpeg/media.ts"() {
927
+ "use strict";
928
+ init_spawn();
929
+ durationCache = /* @__PURE__ */ new Map();
930
+ TRIM_EDGE_SILENCE = "silenceremove=start_periods=1:start_silence=0:start_threshold=-45dB:detection=peak,areverse,silenceremove=start_periods=1:start_silence=0:start_threshold=-45dB:detection=peak,areverse";
931
+ }
932
+ });
933
+
934
+ // src/lib/ffmpeg/silence.ts
935
+ function getVideoStreamDuration(inputPath) {
936
+ return new Promise((resolve3) => {
937
+ const proc = spawnFFmpeg("ffprobe", [
938
+ "-v",
939
+ "error",
940
+ "-select_streams",
941
+ "v:0",
942
+ "-show_entries",
943
+ "stream=duration",
944
+ "-of",
945
+ "csv=p=0",
946
+ inputPath
947
+ ]);
948
+ proc.stderr?.resume();
949
+ let out = "";
950
+ proc.stdout.on("data", (d) => {
951
+ out += d;
952
+ });
953
+ proc.on("error", () => resolve3(0));
954
+ proc.on("close", () => resolve3(Number.parseFloat(out.trim()) || 0));
955
+ });
956
+ }
957
+ function detectSilences(inputPath, minDurSecs) {
958
+ return new Promise((resolve3) => {
959
+ const proc = spawnFFmpeg("ffmpeg", [
960
+ "-hide_banner",
961
+ "-nostats",
962
+ // Audio-only analysis: without -vn ffmpeg's default stream selection
963
+ // decodes the video too, for a filter that cannot see it.
964
+ "-vn",
965
+ "-i",
966
+ inputPath,
967
+ "-af",
968
+ `silencedetect=noise=-40dB:d=${minDurSecs}`,
969
+ "-f",
970
+ "null",
971
+ "-"
972
+ ]);
973
+ let err = "";
974
+ proc.stderr.on("data", (d) => {
975
+ err += d;
976
+ });
977
+ proc.on("error", () => resolve3([]));
978
+ proc.on("close", () => {
979
+ const spans = [];
980
+ let start = null;
981
+ for (const line of err.split("\n")) {
982
+ const s = line.match(/silence_start:\s*(-?[\d.]+)/);
983
+ if (s?.[1]) start = Math.max(0, Number.parseFloat(s[1]));
984
+ const e = line.match(/silence_end:\s*([\d.]+)/);
985
+ if (e?.[1] && start !== null) {
986
+ const from = start + SPEECH_GUARD_SECS;
987
+ const to = Number.parseFloat(e[1]) - SPEECH_GUARD_SECS;
988
+ if (to > from) spans.push({ start: from, end: to });
989
+ start = null;
990
+ }
991
+ }
992
+ resolve3(spans);
993
+ });
994
+ });
995
+ }
996
+ function detectFrozenSpans(inputPath, minDurSecs) {
997
+ return new Promise((resolve3) => {
998
+ const proc = spawnFFmpeg("ffmpeg", [
999
+ "-hide_banner",
1000
+ "-nostats",
1001
+ // The mirror of detectSilences: a video-only filter, so skip the audio.
1002
+ "-an",
1003
+ "-sn",
1004
+ "-i",
1005
+ inputPath,
1006
+ "-vf",
1007
+ // A generous noise floor: video is lossy, so a "still" frame still
1008
+ // differs from the last one by a little.
1009
+ `freezedetect=n=-55dB:d=${minDurSecs}`,
1010
+ "-f",
1011
+ "null",
1012
+ "-"
1013
+ ]);
1014
+ let err = "";
1015
+ proc.stderr.on("data", (d) => {
1016
+ err += d;
1017
+ });
1018
+ proc.on("error", () => resolve3([]));
1019
+ proc.on("close", () => {
1020
+ const spans = [];
1021
+ let start = null;
1022
+ for (const line of err.split("\n")) {
1023
+ const s = line.match(/freeze_start:\s*(-?[\d.]+)/);
1024
+ if (s?.[1]) start = Math.max(0, Number.parseFloat(s[1]));
1025
+ const e = line.match(/freeze_end:\s*([\d.]+)/);
1026
+ if (e?.[1] && start !== null) {
1027
+ spans.push({ start, end: Number.parseFloat(e[1]) });
1028
+ start = null;
1029
+ }
1030
+ }
1031
+ resolve3(spans);
1032
+ });
1033
+ });
1034
+ }
1035
+ function detectBlackSpans(inputPath, minDurSecs) {
1036
+ return new Promise((resolve3) => {
1037
+ const proc = spawnFFmpeg("ffmpeg", [
1038
+ "-hide_banner",
1039
+ "-nostats",
1040
+ "-an",
1041
+ "-sn",
1042
+ "-i",
1043
+ inputPath,
1044
+ "-vf",
1045
+ // Strict on purpose. Loosening these to catch a lingering spinner
1046
+ // (pic_th 0.96, pix_th 0.14) cut 37 seconds out of a 95 second
1047
+ // recording: it was matching real dark-themed pages, and 80s of
1048
+ // narration was left with 58s of picture to play over. A missed second
1049
+ // of loading screen is a blemish; a false positive desyncs the video.
1050
+ `blackdetect=d=${minDurSecs}:pic_th=0.98:pix_th=0.10`,
1051
+ "-f",
1052
+ "null",
1053
+ "-"
1054
+ ]);
1055
+ let err = "";
1056
+ proc.stderr.on("data", (d) => {
1057
+ err += d;
1058
+ });
1059
+ proc.on("error", () => resolve3([]));
1060
+ proc.on("close", () => {
1061
+ const spans = [];
1062
+ for (const line of err.split("\n")) {
1063
+ const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
1064
+ if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
1065
+ }
1066
+ resolve3(spans);
1067
+ });
1068
+ });
1069
+ }
1070
+ function detectWhiteSpans(inputPath, minDurSecs) {
1071
+ return new Promise((resolve3) => {
1072
+ const proc = spawnFFmpeg("ffmpeg", [
1073
+ "-hide_banner",
1074
+ "-nostats",
1075
+ "-an",
1076
+ "-sn",
1077
+ "-i",
1078
+ inputPath,
1079
+ "-vf",
1080
+ `negate,blackdetect=d=${minDurSecs}:pic_th=0.98:pix_th=0.10`,
1081
+ "-f",
1082
+ "null",
1083
+ "-"
1084
+ ]);
1085
+ let err = "";
1086
+ proc.stderr.on("data", (d) => {
1087
+ err += d;
1088
+ });
1089
+ proc.on("error", () => resolve3([]));
1090
+ proc.on("close", () => {
1091
+ const spans = [];
1092
+ for (const line of err.split("\n")) {
1093
+ const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
1094
+ if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
1095
+ }
1096
+ resolve3(spans);
1097
+ });
1098
+ });
1099
+ }
1100
+ function intersectSpans(a, b) {
1101
+ const out = [];
1102
+ for (const x of a) {
1103
+ for (const y of b) {
1104
+ const start = Math.max(x.start, y.start);
1105
+ const end = Math.min(x.end, y.end);
1106
+ if (end > start) out.push({ start, end });
1107
+ }
1108
+ }
1109
+ return out.sort((p, q) => p.start - q.start);
1110
+ }
1111
+ function mergeSpans(spans) {
1112
+ const sorted = [...spans].sort((a, b) => a.start - b.start);
1113
+ const out = [];
1114
+ for (const span of sorted) {
1115
+ const last = out[out.length - 1];
1116
+ if (last && span.start <= last.end + 0.01) {
1117
+ last.end = Math.max(last.end, span.end);
1118
+ if (span.cap !== void 0) last.cap = Math.min(last.cap ?? span.cap, span.cap);
1119
+ } else out.push({ ...span });
1120
+ }
1121
+ return out;
1122
+ }
1123
+ function atempoChain(factor) {
1124
+ const steps = [];
1125
+ let remaining = factor;
1126
+ while (remaining > 2) {
1127
+ steps.push(2);
1128
+ remaining /= 2;
1129
+ }
1130
+ if (remaining > 1.001) steps.push(remaining);
1131
+ return steps.map((s) => `atempo=${s.toFixed(4)}`).join(",");
1132
+ }
1133
+ function buildCompressionFilter(spans, totalSecs, maxSecs, protectAfterSecs, protectBeforeSecs) {
1134
+ const limit = protectAfterSecs ?? totalSecs;
1135
+ const floor = Math.max(0, protectBeforeSecs ?? 0);
1136
+ const usable = spans.map((s) => ({
1137
+ start: Math.max(floor, s.start),
1138
+ end: Math.min(totalSecs, Math.min(s.end, limit)),
1139
+ cap: Math.min(s.cap ?? maxSecs, maxSecs)
1140
+ })).filter((s) => s.start < limit && s.end - s.start > s.cap + 0.05).sort((a, b) => a.start - b.start);
1141
+ if (usable.length === 0) return null;
1142
+ const parts = [];
1143
+ const labels = [];
1144
+ let cursor = 0;
1145
+ let n = 0;
1146
+ const keep = (from, to) => {
1147
+ if (to - from < 0.04) return;
1148
+ parts.push(`[0:v]trim=${from.toFixed(3)}:${to.toFixed(3)},setpts=PTS-STARTPTS[v${n}]`);
1149
+ parts.push(`[0:a]atrim=${from.toFixed(3)}:${to.toFixed(3)},asetpts=PTS-STARTPTS[a${n}]`);
1150
+ labels.push(`[v${n}][a${n}]`);
1151
+ n++;
1152
+ };
1153
+ for (const span of usable) {
1154
+ keep(cursor, span.start);
1155
+ const factor = (span.end - span.start) / span.cap;
1156
+ const tempo = atempoChain(factor);
1157
+ parts.push(
1158
+ `[0:v]trim=${span.start.toFixed(3)}:${span.end.toFixed(3)},setpts=(PTS-STARTPTS)/${factor.toFixed(4)}[v${n}]`
1159
+ );
1160
+ parts.push(
1161
+ `[0:a]atrim=${span.start.toFixed(3)}:${span.end.toFixed(3)},asetpts=PTS-STARTPTS${tempo ? `,${tempo}` : ""}[a${n}]`
1162
+ );
1163
+ labels.push(`[v${n}][a${n}]`);
1164
+ n++;
1165
+ cursor = span.end;
1166
+ }
1167
+ keep(cursor, totalSecs);
1168
+ if (n < 2) return null;
1169
+ return `${parts.join(";")};${labels.join("")}concat=n=${n}:v=1:a=1[outv][outa]`;
1170
+ }
1171
+ async function passEncoder(preset) {
1172
+ const encoder = await resolveEncoder({ crf: 18, preset, quality: 20 });
1173
+ return {
1174
+ input: encoder.input,
1175
+ output: encoder.output,
1176
+ filterSuffix: encoder.filter ? `;[outv]${encoder.filter}[outvhw]` : "",
1177
+ videoLabel: encoder.filter ? "[outvhw]" : "[outv]"
1178
+ };
1179
+ }
1180
+ async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
1181
+ const keep = mergeSpans(spans.map((s) => ({ ...s }))).filter((s) => s.end - s.start > 0.15);
1182
+ if (keep.length === 0) return false;
1183
+ const drop = keep.map((s) => `between(t,${s.start.toFixed(3)},${s.end.toFixed(3)})`).join("+");
1184
+ const filter = `[0:v]select='not(${drop})',setpts=N/FRAME_RATE/TB[outv]`;
1185
+ const enc = await passEncoder(preset);
1186
+ return new Promise((resolve3) => {
1187
+ const proc = spawnFFmpeg("ffmpeg", [
1188
+ "-y",
1189
+ ...filterThreadArgs(),
1190
+ ...enc.input,
1191
+ "-i",
1192
+ inputPath,
1193
+ "-filter_complex",
1194
+ enc.filterSuffix ? filter + enc.filterSuffix : filter,
1195
+ "-map",
1196
+ enc.videoLabel,
1197
+ "-an",
1198
+ ...enc.output,
1199
+ "-movflags",
1200
+ "+faststart",
1201
+ outputPath
1202
+ ]);
1203
+ proc.stderr.resume();
1204
+ proc.on("error", () => resolve3(false));
1205
+ proc.on("close", (code) => resolve3(code === 0));
1206
+ });
1207
+ }
1208
+ function buildRetimeFilter(spans, stepBounds, speed, fps) {
1209
+ const all = mergeSpans(spans.map((s) => ({ ...s })));
1210
+ const totalSecs = stepBounds[stepBounds.length - 1]?.to ?? 0;
1211
+ const frame = 1 / Math.max(1, fps);
1212
+ const parts = [];
1213
+ const labels = [];
1214
+ let n = 0;
1215
+ let reclaimed = 0;
1216
+ let pendingFrom = null;
1217
+ const flush = (to) => {
1218
+ if (pendingFrom === null) return;
1219
+ const from = pendingFrom;
1220
+ pendingFrom = null;
1221
+ if (to - from < 0.04) return;
1222
+ parts.push(`[0:v]trim=${from.toFixed(3)}:${to.toFixed(3)},setpts=PTS-STARTPTS,fps=${fps}[v${n}]`);
1223
+ labels.push(`[v${n}]`);
1224
+ n++;
1225
+ };
1226
+ const segment = (from, to, factor) => {
1227
+ if (to - from < 0.04) return;
1228
+ const pts = factor === 1 ? "PTS-STARTPTS" : `(PTS-STARTPTS)*${factor.toFixed(5)}`;
1229
+ parts.push(`[0:v]trim=${from.toFixed(3)}:${to.toFixed(3)},setpts=${pts},fps=${fps}[v${n}]`);
1230
+ labels.push(`[v${n}]`);
1231
+ n++;
1232
+ };
1233
+ const freeze = (from, to, sourceAt) => {
1234
+ const dur = to - from;
1235
+ if (dur < 0.04) return;
1236
+ const at = sourceAt + frame <= totalSecs ? sourceAt : Math.max(0, from - frame);
1237
+ parts.push(
1238
+ `[0:v]trim=${at.toFixed(3)}:${(at + frame).toFixed(3)},setpts=PTS-STARTPTS,loop=loop=${Math.ceil(dur * fps) + 1}:size=1:start=0,setpts=N/FRAME_RATE/TB,trim=0:${dur.toFixed(3)},setpts=PTS-STARTPTS,fps=${fps}[v${n}]`
1239
+ );
1240
+ labels.push(`[v${n}]`);
1241
+ n++;
1242
+ };
1243
+ for (const step of stepBounds) {
1244
+ const local = all.map((s) => ({ start: Math.max(s.start, step.from), end: Math.min(s.end, step.to), sourceAt: s.end })).filter((s) => s.end - s.start >= MIN_RETIME_SPAN);
1245
+ if (local.length === 0) {
1246
+ if (pendingFrom === null) pendingFrom = step.from;
1247
+ continue;
1248
+ }
1249
+ const window = step.to - step.from;
1250
+ const emptyTotal = local.reduce((a, s) => a + (s.end - s.start), 0);
1251
+ const keepTotal = window - emptyTotal;
1252
+ let recovered = emptyTotal * (1 - 1 / speed);
1253
+ let stretch = keepTotal > 0 ? (keepTotal + recovered) / keepTotal : Number.POSITIVE_INFINITY;
1254
+ if (stretch > MAX_STRETCH) {
1255
+ recovered = keepTotal * (MAX_STRETCH - 1);
1256
+ stretch = MAX_STRETCH;
1257
+ }
1258
+ const effective = emptyTotal / Math.max(0.01, emptyTotal - recovered);
1259
+ flush(step.from);
1260
+ let cursor = step.from;
1261
+ if (keepTotal >= MIN_KEEP_SECS && effective >= MIN_EFFECTIVE_SPEED) {
1262
+ for (const span of local) {
1263
+ segment(cursor, span.start, stretch);
1264
+ segment(span.start, span.end, 1 / effective);
1265
+ cursor = span.end;
1266
+ }
1267
+ segment(cursor, step.to, stretch);
1268
+ reclaimed += emptyTotal - emptyTotal / effective;
1269
+ } else {
1270
+ for (const span of local) {
1271
+ segment(cursor, span.start, 1);
1272
+ freeze(span.start, span.end, span.sourceAt);
1273
+ cursor = span.end;
1274
+ }
1275
+ segment(cursor, step.to, 1);
1276
+ reclaimed += emptyTotal;
1277
+ }
1278
+ }
1279
+ flush(totalSecs);
1280
+ if (n < 2 || reclaimed < 0.5) return null;
1281
+ return `${parts.join(";")};${labels.join("")}concat=n=${n}:v=1:a=0[outv]`;
1282
+ }
1283
+ async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, speed, fps, preset) {
1284
+ const filter = buildRetimeFilter(spans, stepBounds, speed, fps);
1285
+ if (!filter) return false;
1286
+ const enc = await passEncoder(preset);
1287
+ return new Promise((resolve3) => {
1288
+ const proc = spawnFFmpeg("ffmpeg", [
1289
+ "-y",
1290
+ ...filterThreadArgs(),
1291
+ ...enc.input,
1292
+ "-i",
1293
+ inputPath,
1294
+ "-filter_complex",
1295
+ enc.filterSuffix ? filter + enc.filterSuffix : filter,
1296
+ "-map",
1297
+ enc.videoLabel,
1298
+ "-an",
1299
+ ...enc.output,
1300
+ "-movflags",
1301
+ "+faststart",
1302
+ outputPath
1303
+ ]);
1304
+ proc.stderr.resume();
1305
+ proc.on("error", () => resolve3(false));
1306
+ proc.on("close", (code) => resolve3(code === 0));
1307
+ });
1308
+ }
1309
+ async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, preset, protectAfterSecs, narrationPath, protectBeforeSecs, leadSpans, keepSpans) {
1310
+ const audioSource = narrationPath || inputPath;
1311
+ const frozenMax = Math.min(maxSecs, FROZEN_MAX_SECS);
1312
+ const [detected, frozen, black, white] = await Promise.all([
1313
+ detectSilences(audioSource, frozenMax + 0.05),
1314
+ detectFrozenSpans(inputPath, frozenMax + 0.05),
1315
+ // Shorter threshold than the other two: a page transition is often only a
1316
+ // second, and a second of black in a 60s Short is a second too many.
1317
+ detectBlackSpans(inputPath, 0.3),
1318
+ detectWhiteSpans(inputPath, 0.3)
1319
+ ]);
1320
+ const silent = detected.filter((span) => span.end - span.start > maxSecs + 0.05);
1321
+ const stuck = intersectSpans(frozen, detected).filter((span) => span.end - span.start > frozenMax).map((span) => ({ ...span, cap: frozenMax }));
1322
+ const cardFloor = Math.max(0, protectBeforeSecs ?? 0);
1323
+ const cardCeiling = protectAfterSecs ?? totalSecs;
1324
+ const empty = intersectSpans(mergeSpans([...black, ...white]), detected).filter((span) => span.end - span.start > BLACK_KEEP_SECS * 2).filter((span) => span.start >= cardFloor && span.end <= cardCeiling).map((span) => ({ ...span, cap: BLACK_KEEP_SECS }));
1325
+ const known = (leadSpans ?? []).filter((span) => span.end - span.start > BLACK_KEEP_SECS * 2).map((span) => ({ ...span, cap: BLACK_KEEP_SECS }));
1326
+ const merged = mergeSpans([...silent, ...stuck, ...empty, ...known]);
1327
+ const overlapsContent = (span) => (keepSpans ?? []).some((keep) => span.start < keep.end && span.end > keep.start);
1328
+ const spans = merged.filter((span) => !overlapsContent(span));
1329
+ const filter = buildCompressionFilter(spans, totalSecs, maxSecs, protectAfterSecs, protectBeforeSecs);
1330
+ if (!filter) return false;
1331
+ const enc = await passEncoder(preset);
1332
+ return new Promise((resolve3) => {
1333
+ const proc = spawnFFmpeg("ffmpeg", [
1334
+ "-y",
1335
+ ...filterThreadArgs(),
1336
+ ...enc.input,
1337
+ "-i",
1338
+ inputPath,
1339
+ "-filter_complex",
1340
+ filter + enc.filterSuffix,
1341
+ "-map",
1342
+ enc.videoLabel,
1343
+ "-map",
1344
+ "[outa]",
1345
+ ...enc.output,
1346
+ "-c:a",
1347
+ "aac",
1348
+ // No -shortest here. The overhang it was added for is already handled by
1349
+ // bounding every segment to the VIDEO stream's length, and the flag cut
1350
+ // the other way once an outro existed: a credits card carries no
1351
+ // narration, so the audio runs out before the picture does and the card
1352
+ // was truncated away.
1353
+ "-movflags",
1354
+ "+faststart",
1355
+ outputPath
1356
+ ]);
1357
+ proc.stderr.resume();
1358
+ proc.on("error", () => resolve3(false));
1359
+ proc.on("close", (code) => resolve3(code === 0));
1360
+ });
1361
+ }
1362
+ async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, introSecs, outroSecs, maxFactor, preset) {
1363
+ const ends = introSecs + outroSecs;
1364
+ const middle = totalSecs - ends;
1365
+ const budget = targetSecs - ends - FIT_UNDERSHOOT_SECS;
1366
+ if (middle <= 0.5 || budget <= 0.5 || middle <= budget) {
1367
+ return { ok: false, factor: 1, reachedTarget: totalSecs <= targetSecs };
1368
+ }
1369
+ const needed = middle / budget;
1370
+ const factor = Math.min(needed, maxFactor);
1371
+ const tempo = atempoChain(factor);
1372
+ const midEnd = totalSecs - outroSecs;
1373
+ const filter = [
1374
+ `[0:v]trim=0:${introSecs.toFixed(3)},setpts=PTS-STARTPTS[v0]`,
1375
+ `[0:a]atrim=0:${introSecs.toFixed(3)},asetpts=PTS-STARTPTS[a0]`,
1376
+ `[0:v]trim=${introSecs.toFixed(3)}:${midEnd.toFixed(3)},setpts=(PTS-STARTPTS)/${factor.toFixed(4)}[v1]`,
1377
+ `[0:a]atrim=${introSecs.toFixed(3)}:${midEnd.toFixed(3)},asetpts=PTS-STARTPTS${tempo ? `,${tempo}` : ""}[a1]`,
1378
+ `[0:v]trim=${midEnd.toFixed(3)}:${totalSecs.toFixed(3)},setpts=PTS-STARTPTS[v2]`,
1379
+ `[0:a]atrim=${midEnd.toFixed(3)}:${totalSecs.toFixed(3)},asetpts=PTS-STARTPTS[a2]`,
1380
+ "[v0][a0][v1][a1][v2][a2]concat=n=3:v=1:a=1[outv][outa]"
1381
+ ].join(";");
1382
+ const enc = await passEncoder(preset);
1383
+ const ok = await new Promise((resolve3) => {
1384
+ const proc = spawnFFmpeg("ffmpeg", [
434
1385
  "-y",
1386
+ ...filterThreadArgs(),
1387
+ ...enc.input,
435
1388
  "-i",
436
1389
  inputPath,
437
- "-i",
438
- outroPath,
439
1390
  "-filter_complex",
440
- filterComplex,
1391
+ filter + enc.filterSuffix,
441
1392
  "-map",
442
- "[outv]",
443
- "-c:v",
444
- "libx264",
445
- "-preset",
446
- preset,
447
- "-crf",
448
- "18",
449
- "-pix_fmt",
450
- "yuv420p",
451
- "-an",
1393
+ enc.videoLabel,
1394
+ "-map",
1395
+ "[outa]",
1396
+ ...enc.output,
1397
+ "-c:a",
1398
+ "aac",
1399
+ "-movflags",
1400
+ "+faststart",
452
1401
  outputPath
453
- ],
454
- "ffmpeg concat video failed"
455
- );
1402
+ ]);
1403
+ proc.stderr.resume();
1404
+ proc.on("error", () => resolve3(false));
1405
+ proc.on("close", (code) => resolve3(code === 0));
1406
+ });
1407
+ return { ok, factor, reachedTarget: needed <= maxFactor };
456
1408
  }
457
- var init_media = __esm({
458
- "src/lib/ffmpeg/media.ts"() {
1409
+ var SPEECH_GUARD_SECS, BLACK_KEEP_SECS, FROZEN_MAX_SECS, EMPTY_SPAN_SPEED, MAX_STRETCH, MIN_EFFECTIVE_SPEED, MIN_KEEP_SECS, MIN_RETIME_SPAN, FIT_UNDERSHOOT_SECS;
1410
+ var init_silence = __esm({
1411
+ "src/lib/ffmpeg/silence.ts"() {
459
1412
  "use strict";
1413
+ init_hwaccel();
460
1414
  init_spawn();
1415
+ SPEECH_GUARD_SECS = 0.12;
1416
+ BLACK_KEEP_SECS = 0.12;
1417
+ FROZEN_MAX_SECS = 0.6;
1418
+ EMPTY_SPAN_SPEED = 5;
1419
+ MAX_STRETCH = 2.5;
1420
+ MIN_EFFECTIVE_SPEED = 1.3;
1421
+ MIN_KEEP_SECS = 0.3;
1422
+ MIN_RETIME_SPAN = 0.2;
1423
+ FIT_UNDERSHOOT_SECS = 0.35;
461
1424
  }
462
1425
  });
463
1426
 
464
1427
  // src/lib/ffmpeg/splice.ts
465
- function spliceVideos(opts) {
1428
+ function coverFilterChain(inputIdx, width, height) {
1429
+ const bg = `cov${inputIdx}bg`;
1430
+ const fg = `cov${inputIdx}fg`;
1431
+ return `split=2[${bg}][${fg}];[${bg}]scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},scale=${COVER_WASH_W}:${COVER_WASH_H},scale=${width}:${height},gblur=sigma=${COVER_BLUR_SIGMA},eq=brightness=-0.12:saturation=0.75[${bg}b];[${fg}]scale=${width}:${height}:force_original_aspect_ratio=decrease[${fg}s];[${bg}b][${fg}s]overlay=(W-w)/2:(H-h)/2`;
1432
+ }
1433
+ async function spliceVideos(opts) {
1434
+ const { inputPath, outputPath, overlays, width, height, preset } = opts;
1435
+ if (overlays.length === 0) {
1436
+ return runFFmpeg(["-y", "-i", inputPath, "-c", "copy", outputPath], "ffmpeg copy failed");
1437
+ }
1438
+ const audioPresent = await Promise.all(
1439
+ overlays.map((o) => o.still ? Promise.resolve(false) : hasAudioStream(o.path))
1440
+ );
466
1441
  return new Promise((resolve3, reject) => {
467
- const { inputPath, outputPath, overlays, width, height, preset } = opts;
468
- if (overlays.length === 0) {
469
- runFFmpeg(["-y", "-i", inputPath, "-c", "copy", outputPath], "ffmpeg copy failed").then(resolve3, reject);
470
- return;
471
- }
472
1442
  const args = ["-y", "-i", inputPath];
473
1443
  for (const overlay of overlays) {
1444
+ if (overlay.still) {
1445
+ args.push("-loop", "1", "-framerate", String(STILL_FRAMERATE), "-t", overlay.duration.toFixed(3));
1446
+ }
474
1447
  args.push("-i", overlay.path);
475
1448
  }
476
1449
  const filterParts = [];
@@ -490,13 +1463,18 @@ function spliceVideos(opts) {
490
1463
  }
491
1464
  }
492
1465
  if (overlay.mode === "stretch") {
493
- filters.push(`scale=${width}:${height}:force_original_aspect_ratio=increase`);
1466
+ filters.push(`scale=${width}:-2`);
1467
+ filters.push(`pad=${width}:max(ih\\,${height}):0:(oh-ih)/2:color=#1a1a2e`);
494
1468
  filters.push(`crop=${width}:${height}`);
1469
+ } else if (overlay.mode === "cover") {
1470
+ filters.push(coverFilterChain(inputIdx, width, height));
495
1471
  } else {
496
- filters.push(`scale=${width}:${height}:force_original_aspect_ratio=decrease`);
1472
+ filters.push(
1473
+ `scale=${Math.round(width * 0.9)}:${Math.round(height * 0.9)}:force_original_aspect_ratio=decrease`
1474
+ );
497
1475
  filters.push(`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=#1a1a2e`);
498
1476
  }
499
- filters.push("setpts=PTS-STARTPTS");
1477
+ filters.push(`setpts=PTS-STARTPTS+${overlay.startTime.toFixed(3)}/TB`);
500
1478
  filterParts.push(`[${inputIdx}:v]${filters.join(",")}[v${inputIdx}]`);
501
1479
  const nextStream = i === overlays.length - 1 ? "outv" : `tmp${i}`;
502
1480
  filterParts.push(
@@ -504,19 +1482,39 @@ function spliceVideos(opts) {
504
1482
  );
505
1483
  currentStream = nextStream;
506
1484
  }
1485
+ const clipAudio = overlays.map((overlay, i) => ({ overlay, inputIdx: i + 1 })).filter(({ inputIdx }) => audioPresent[inputIdx - 1]);
1486
+ for (const { overlay, inputIdx } of clipAudio) {
1487
+ const delayMs = Math.round(overlay.startTime * 1e3);
1488
+ filterParts.push(
1489
+ `[${inputIdx}:a]atrim=0:${overlay.duration.toFixed(3)},asetpts=PTS-STARTPTS,adelay=${delayMs}|${delayMs},volume=${CLIP_AUDIO_GAIN}[a${inputIdx}]`
1490
+ );
1491
+ }
1492
+ if (clipAudio.length === 1) {
1493
+ filterParts.push(`[a${clipAudio[0].inputIdx}]anull[outa]`);
1494
+ } else if (clipAudio.length > 1) {
1495
+ const labels = clipAudio.map(({ inputIdx }) => `[a${inputIdx}]`).join("");
1496
+ filterParts.push(`${labels}amix=inputs=${clipAudio.length}:normalize=0:dropout_transition=0[outa]`);
1497
+ }
507
1498
  args.push("-filter_complex", filterParts.join(";"));
508
1499
  args.push("-map", "[outv]");
509
- args.push("-map", "0:a?");
1500
+ if (clipAudio.length > 0) args.push("-map", "[outa]");
510
1501
  args.push("-c:v", "libx264", "-preset", preset, "-crf", "18", "-pix_fmt", "yuv420p");
511
- args.push("-c:a", "copy");
1502
+ if (clipAudio.length > 0) args.push("-c:a", "aac");
512
1503
  args.push(outputPath);
513
1504
  runFFmpeg(args, "ffmpeg splice failed").then(resolve3, reject);
514
1505
  });
515
1506
  }
1507
+ var CLIP_AUDIO_GAIN, STILL_FRAMERATE, COVER_WASH_W, COVER_WASH_H, COVER_BLUR_SIGMA;
516
1508
  var init_splice = __esm({
517
1509
  "src/lib/ffmpeg/splice.ts"() {
518
1510
  "use strict";
1511
+ init_media();
519
1512
  init_spawn();
1513
+ CLIP_AUDIO_GAIN = 0.55;
1514
+ STILL_FRAMERATE = 30;
1515
+ COVER_WASH_W = 6;
1516
+ COVER_WASH_H = 10;
1517
+ COVER_BLUR_SIGMA = 40;
520
1518
  }
521
1519
  });
522
1520
 
@@ -613,7 +1611,9 @@ var init_ffmpeg = __esm({
613
1611
  "use strict";
614
1612
  init_compose();
615
1613
  init_compress();
1614
+ init_gif();
616
1615
  init_media();
1616
+ init_silence();
617
1617
  init_splice();
618
1618
  init_transitions();
619
1619
  init_types();
@@ -630,8 +1630,8 @@ var init_ffmpeg2 = __esm({
630
1630
 
631
1631
  // src/lib/tts/helpers.ts
632
1632
  import { existsSync } from "fs";
633
- import { readFile, unlink, writeFile } from "fs/promises";
634
- import { isAbsolute, join, resolve } from "path";
1633
+ import { readFile, unlink as unlink2, writeFile } from "fs/promises";
1634
+ import { isAbsolute, join as join2, resolve } from "path";
635
1635
  function toWSLPathForTTS(path) {
636
1636
  return windowsDrivePathToWsl(path) ?? path;
637
1637
  }
@@ -684,7 +1684,7 @@ async function getMediaStepDuration(step, baseDir) {
684
1684
  }
685
1685
  }
686
1686
  async function loadVoiceoverMeta(outputDir) {
687
- const metaPath = join(outputDir, "voiceover.json");
1687
+ const metaPath = join2(outputDir, "voiceover.json");
688
1688
  try {
689
1689
  if (!existsSync(metaPath)) return null;
690
1690
  const content = await readFile(metaPath, "utf-8");
@@ -694,7 +1694,7 @@ async function loadVoiceoverMeta(outputDir) {
694
1694
  }
695
1695
  }
696
1696
  async function saveVoiceoverMeta(outputDir, meta) {
697
- const metaPath = join(outputDir, "voiceover.json");
1697
+ const metaPath = join2(outputDir, "voiceover.json");
698
1698
  await writeFile(metaPath, JSON.stringify(meta, null, 2));
699
1699
  }
700
1700
  async function concatenateAudio(inputs, output, workDir) {
@@ -703,19 +1703,21 @@ async function concatenateAudio(inputs, output, workDir) {
703
1703
  return isWindows ? windowsDrivePathToWsl(resolved) ?? resolved : resolved;
704
1704
  };
705
1705
  const listContent = inputs.map((f) => `file '${formatPath(f)}'`).join("\n");
706
- const listPath = join(workDir, "concat.txt");
1706
+ const listPath = join2(workDir, "concat.txt");
707
1707
  await writeFile(listPath, listContent);
708
1708
  await concatMedia(listPath, output);
709
- await unlink(listPath).catch(() => {
1709
+ await unlink2(listPath).catch(() => {
710
1710
  });
711
1711
  }
712
- var isWindows;
1712
+ var isWindows, NARRATION_LEAD_IN_MS, LEAD_IN_CLIP;
713
1713
  var init_helpers = __esm({
714
1714
  "src/lib/tts/helpers.ts"() {
715
1715
  "use strict";
716
1716
  init_ffmpeg2();
717
1717
  init_utils();
718
1718
  isWindows = process.platform === "win32";
1719
+ NARRATION_LEAD_IN_MS = 300;
1720
+ LEAD_IN_CLIP = "lead-in.mp3";
719
1721
  }
720
1722
  });
721
1723
 
@@ -723,10 +1725,12 @@ var init_helpers = __esm({
723
1725
  var timing_exports = {};
724
1726
  __export(timing_exports, {
725
1727
  alignVoiceoverToRecording: () => alignVoiceoverToRecording,
726
- estimateWordTimings: () => estimateWordTimings
1728
+ alignWordsToCaption: () => alignWordsToCaption,
1729
+ estimateWordTimings: () => estimateWordTimings,
1730
+ spokenWordTimings: () => spokenWordTimings
727
1731
  });
728
1732
  import { existsSync as existsSync2 } from "fs";
729
- import { join as join2 } from "path";
1733
+ import { join as join3 } from "path";
730
1734
  function estimateWordTimings(caption, duration) {
731
1735
  const words = caption.split(/\s+/).filter((w) => w.length > 0);
732
1736
  const wordDuration = duration / words.length;
@@ -736,6 +1740,36 @@ function estimateWordTimings(caption, duration) {
736
1740
  end: Math.round((i + 1) * wordDuration)
737
1741
  }));
738
1742
  }
1743
+ function alignWordsToCaption(caption, spoken) {
1744
+ const captionWords = caption.split(/\s+/).filter((w) => w.length > 0);
1745
+ if (captionWords.length === 0 || spoken.length === 0) return [];
1746
+ if (spoken.length === captionWords.length) {
1747
+ return captionWords.map((word, i) => ({
1748
+ word,
1749
+ start: Math.round(spoken[i].start),
1750
+ end: Math.round(spoken[i].end)
1751
+ }));
1752
+ }
1753
+ const speechStart = spoken[0].start;
1754
+ const speechEnd = spoken[spoken.length - 1].end;
1755
+ const wordDuration = (speechEnd - speechStart) / captionWords.length;
1756
+ return captionWords.map((word, i) => ({
1757
+ word,
1758
+ start: Math.round(speechStart + i * wordDuration),
1759
+ end: Math.round(speechStart + (i + 1) * wordDuration)
1760
+ }));
1761
+ }
1762
+ function spokenWordTimings(caption, spoken) {
1763
+ if (!spoken || spoken.length === 0) return null;
1764
+ const leadTrim = spoken[0].start;
1765
+ const rebased = spoken.map((w) => ({
1766
+ word: w.word,
1767
+ start: Math.max(0, w.start - leadTrim),
1768
+ end: Math.max(0, w.end - leadTrim)
1769
+ }));
1770
+ const aligned = alignWordsToCaption(caption, rebased);
1771
+ return aligned.length > 0 ? aligned : null;
1772
+ }
739
1773
  async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, workDir, outputDir) {
740
1774
  let needsRealignment = false;
741
1775
  for (let i = 0; i < actualRecordingDurations.length; i++) {
@@ -750,31 +1784,35 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
750
1784
  if (!needsRealignment) {
751
1785
  return stepAudio;
752
1786
  }
753
- const audioDir = join2(workDir, "audio");
1787
+ const audioDir = join3(workDir, "audio");
754
1788
  const segments = [];
755
1789
  const alignedStepAudio = /* @__PURE__ */ new Map();
1790
+ const leadInPath = join3(audioDir, LEAD_IN_CLIP);
1791
+ const leadIn = existsSync2(leadInPath) ? NARRATION_LEAD_IN_MS : 0;
1792
+ if (leadIn) segments.push(leadInPath);
756
1793
  for (let i = 0; i < actualRecordingDurations.length; i++) {
757
1794
  const stepNum = i + 1;
758
1795
  const voiceData = stepAudio.get(stepNum);
759
1796
  const voiceDuration = voiceData?.duration ?? 0;
760
1797
  const recordingDuration = actualRecordingDurations[i] ?? 0;
761
1798
  const targetDuration = Math.max(voiceDuration, recordingDuration);
762
- const audioPath = join2(audioDir, `step-${stepNum}.mp3`);
763
- const originalPaddingPath = join2(audioDir, `step-${stepNum}-padding.mp3`);
764
- const silencePath = join2(audioDir, `step-${stepNum}-silence.mp3`);
1799
+ const audioPath = join3(audioDir, `step-${stepNum}.mp3`);
1800
+ const originalPaddingPath = join3(audioDir, `step-${stepNum}-padding.mp3`);
1801
+ const silencePath = join3(audioDir, `step-${stepNum}-silence.mp3`);
765
1802
  if (existsSync2(audioPath)) {
766
1803
  segments.push(audioPath);
767
1804
  if (existsSync2(originalPaddingPath)) {
768
1805
  segments.push(originalPaddingPath);
769
1806
  }
770
1807
  if (recordingDuration > voiceDuration) {
771
- const alignPaddingPath = join2(audioDir, `step-${stepNum}-align-padding.mp3`);
1808
+ const alignPaddingPath = join3(audioDir, `step-${stepNum}-align-padding.mp3`);
772
1809
  await generateSilence(alignPaddingPath, recordingDuration - voiceDuration);
773
1810
  segments.push(alignPaddingPath);
774
1811
  }
775
1812
  } else if (existsSync2(silencePath)) {
776
- if (targetDuration !== voiceDuration) {
777
- await generateSilence(silencePath, targetDuration);
1813
+ const own = targetDuration - (stepNum === 1 ? leadIn : 0);
1814
+ if (targetDuration !== voiceDuration && own > 0) {
1815
+ await generateSilence(silencePath, own);
778
1816
  }
779
1817
  segments.push(silencePath);
780
1818
  }
@@ -783,10 +1821,14 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
783
1821
  words: voiceData?.words ?? []
784
1822
  });
785
1823
  }
786
- if (segments.length > 0) {
787
- const voiceoverPath = join2(outputDir, "voiceover.wav");
788
- await concatenateAudio(segments, voiceoverPath, workDir);
1824
+ if (segments.length === 0) {
1825
+ console.log(
1826
+ ` No per-step audio in ${audioDir}: keeping the narration's own timeline, so the captions still follow the voice.`
1827
+ );
1828
+ return stepAudio;
789
1829
  }
1830
+ const voiceoverPath = join3(outputDir, "voiceover.wav");
1831
+ await concatenateAudio(segments, voiceoverPath, workDir);
790
1832
  const existingMeta = await loadVoiceoverMeta(outputDir);
791
1833
  if (existingMeta) {
792
1834
  existingMeta.stepDurations = Object.fromEntries(
@@ -806,6 +1848,7 @@ var init_timing = __esm({
806
1848
 
807
1849
  // src/lib/api-client.ts
808
1850
  init_utils();
1851
+ var PLAN_MAX_TOKENS = 1500;
809
1852
  var SERVER_BACKOFF_MS = [2e3, 4e3];
810
1853
  var CLI_BACKOFF_MS = [1e4, 3e4];
811
1854
  var SPARK_AI_BASE = "https://sparkbrain.app";
@@ -885,7 +1928,7 @@ async function getOrRefreshRelayToken(email) {
885
1928
  }
886
1929
  }
887
1930
  async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier, opts = {}) {
888
- const { onRetry, backoffMs = SERVER_BACKOFF_MS } = opts;
1931
+ const { onRetry, backoffMs = SERVER_BACKOFF_MS, maxTokens } = opts;
889
1932
  let lastRetriableError = null;
890
1933
  const host = hostOf(chatUrl);
891
1934
  for (let attempt = 1; attempt <= 3; attempt++) {
@@ -900,7 +1943,11 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
900
1943
  messages: [
901
1944
  { role: "system", content: systemPrompt },
902
1945
  { role: "user", content: userPrompt }
903
- ]
1946
+ ],
1947
+ // Explicit numeric test, not truthiness: a 0 or negative budget is a
1948
+ // caller bug, and silently falling back to the tiny backend default
1949
+ // reproduces exactly the failure this option exists to prevent.
1950
+ ...typeof maxTokens === "number" && maxTokens > 0 ? { max_tokens: Math.floor(maxTokens) } : {}
904
1951
  })
905
1952
  });
906
1953
  const bodyText = await response.text().catch(() => "");
@@ -1039,26 +2086,23 @@ var APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
1039
2086
  var OWNER_SLUG = process.env.SPARK_OWNER_SLUG ?? "spark";
1040
2087
  var BASE = "https://sparkpay.dev";
1041
2088
  var TIERS = {
1042
- price_1TADnPEf9opkuWid9NtqOYON: "pro",
1043
- price_1TADnPEf9opkuWidgbvvX9ma: "pro",
2089
+ // Essentials
2090
+ price_1TCREmEf9opkuWidvhuqzqDM: "pro",
2091
+ // live monthly
2092
+ price_1TCREmEf9opkuWidacf1tiC7: "pro",
2093
+ // live yearly
1044
2094
  price_1TADm7Ef9opkuWid3I9tquIB: "pro",
2095
+ // test monthly
1045
2096
  price_1TADm8Ef9opkuWid1RSA2wCa: "pro",
1046
- price_1TCREmEf9opkuWidvhuqzqDM: "pro",
1047
- price_1TCREmEf9opkuWidacf1tiC7: "pro"
1048
- };
1049
- var RANK = {
1050
- free: 0,
1051
- pro: 1
1052
- };
1053
- var FEATURES = {
1054
- free: [],
1055
- pro: [
1056
- "Everything in Free",
1057
- "No watermark",
1058
- "Full video composition (voiceover + captions + music + transitions)",
1059
- "Unlimited videos",
1060
- "Priority support"
1061
- ]
2097
+ // test yearly
2098
+ // Elite. These were missing entirely, so an Elite subscriber fell through
2099
+ // the `?? 'pro'` fallback below and got Essentials limits for $29.
2100
+ price_1TrHz8Ef9opkuWid6lh2opD8: "elite",
2101
+ // live monthly
2102
+ price_1TrHz9Ef9opkuWid1qS26iMu: "elite",
2103
+ // live yearly
2104
+ price_1TGPCREf9opkuWidR0iJeJNe: "elite"
2105
+ // test monthly
1062
2106
  };
1063
2107
  async function _fetch(email, init) {
1064
2108
  const r = await fetch(
@@ -1083,7 +2127,6 @@ function getTier(d) {
1083
2127
  return TIERS[d.subscription.price_id] ?? "pro";
1084
2128
  }
1085
2129
  var isPaid = (d) => getTier(d) !== "free";
1086
- var hasAccess = (d, min) => (RANK[getTier(d)] ?? 0) >= (RANK[min] ?? 0);
1087
2130
  var hasTrialRemaining = (d) => isPaid(d) || !!d.trial && !d.trial.is_expired && d.trial.uses_remaining > 0;
1088
2131
  var showPaywall = (d) => !isPaid(d) && !hasTrialRemaining(d);
1089
2132
  var isCanceling = (d) => !!d.subscription && d.subscription.cancel_at_period_end && d.subscription.status !== "canceled";
@@ -1117,13 +2160,21 @@ function getUpgradeUrl(userEmail) {
1117
2160
  }
1118
2161
 
1119
2162
  // src/lib/config.ts
1120
- var VERSION = "1.2.1";
2163
+ var VERSION = "1.2.3";
1121
2164
  var CONFIG_FILE = "quickpeek.config.json";
2165
+ var SIZE_PRESETS = {
2166
+ wide: { width: 1920, height: 1080, zoom: 1 },
2167
+ short: { width: 1080, height: 1920, zoom: 2 }
2168
+ };
2169
+ var RATE_FACTOR = { slow: 0.8, normal: 1, fast: 1.25 };
2170
+ var NO_CLIP_OUTRO = "@card-step";
1122
2171
  var DEFAULT_CONFIG = {
1123
2172
  plan: { maxSteps: 8 },
1124
2173
  video: {
1125
- width: 1080,
1126
- height: 1920,
2174
+ size: "short",
2175
+ width: SIZE_PRESETS.short.width,
2176
+ height: SIZE_PRESETS.short.height,
2177
+ zoom: SIZE_PRESETS.short.zoom,
1127
2178
  format: "mp4",
1128
2179
  preset: "fast",
1129
2180
  quality: 0.5,
@@ -1136,20 +2187,61 @@ var DEFAULT_CONFIG = {
1136
2187
  // ms
1137
2188
  transitions: "none",
1138
2189
  contrast: 0.25,
1139
- tempo: 1,
2190
+ // A demo plays a touch faster than it was recorded, because a recording
2191
+ // paced for the narration reads as slow next to anything else on the same
2192
+ // feed. 1.2 is the most that still sounds like a person talking; set 1 in
2193
+ // quickpeek.config.json for the raw pace.
2194
+ tempo: 1.2,
1140
2195
  stepDelay: 200
1141
2196
  // ms delay between steps
1142
2197
  },
1143
- audio: { format: "mp3", bitrate: "192k" },
2198
+ audio: { gender: "female", rate: "normal", format: "mp3", bitrate: "192k" },
1144
2199
  timing: { minStepDuration: 1500 },
1145
2200
  output: { dir: "./demo" }
1146
2201
  };
2202
+ var VIDEO_PROFILES = {
2203
+ // preset 'veryfast' over 'fast': measured ~20s faster on a 77s 1080p
2204
+ // compose, with identical duration and audio and no visible difference at
2205
+ // these bitrates - the encode is pure wall-clock tax on every run.
2206
+ wide: {
2207
+ video: { width: 1920, height: 1080, zoom: 1.5, fitLimitSecs: 0, maxSpeedup: 1, preset: "veryfast" },
2208
+ audioSpeed: 1.6
2209
+ },
2210
+ short: {
2211
+ // fadeOut 0: the video ends on a card the viewer is meant to read,
2212
+ // and a fade takes it away mid-sentence.
2213
+ video: { width: 1080, height: 1920, zoom: 3, fitLimitSecs: 60, maxSpeedup: 1.7, preset: "veryfast", fadeOut: 0 },
2214
+ audioSpeed: 1.8,
2215
+ // A short lives or dies on energy. Ava is the warmest, most animated of
2216
+ // the free neural voices; Sonia reads a short like the shipping forecast.
2217
+ voice: "en-US-AvaMultilingualNeural"
2218
+ }
2219
+ };
2220
+ function applyProfile(config, profile) {
2221
+ const preset = VIDEO_PROFILES[profile];
2222
+ return {
2223
+ ...config,
2224
+ profile,
2225
+ video: { ...config.video, ...preset.video },
2226
+ audio: {
2227
+ format: "mp3",
2228
+ bitrate: "192k",
2229
+ ...config.audio,
2230
+ speed: config.audio?.speed ?? preset.audioSpeed,
2231
+ ...config.audio?.voice || !preset.voice ? {} : { voice: preset.voice }
2232
+ }
2233
+ };
2234
+ }
1147
2235
  var DEFAULT_CAPTIONS = {
1148
2236
  font: "Arial",
1149
2237
  outline: "#000000",
1150
2238
  outlineWidth: 3,
1151
2239
  shadow: 2,
1152
2240
  marginBottom: 80,
2241
+ // The IG/TikTok look is what a demo short is for; karaoke remains available
2242
+ // by name for anyone who wants the sweep instead.
2243
+ preset: "shorts",
2244
+ position: "bottom",
1153
2245
  regular: {
1154
2246
  color: "#FFFFFF",
1155
2247
  size: 56,
@@ -1159,8 +2251,41 @@ var DEFAULT_CAPTIONS = {
1159
2251
  color: "#FFFF00",
1160
2252
  size: 64,
1161
2253
  bold: true
1162
- }
2254
+ },
2255
+ maxChars: 28
1163
2256
  };
2257
+ function defaultCaptionsFor(width, height) {
2258
+ const portrait = height > width;
2259
+ if (!portrait) {
2260
+ return {
2261
+ ...DEFAULT_CAPTIONS,
2262
+ preset: "karaoke",
2263
+ position: "bottom",
2264
+ marginBottom: Math.round(height * 0.083),
2265
+ regular: { color: "#FFFFFF", size: Math.round(width * 0.033), bold: true },
2266
+ highlight: { color: "#FFE100", size: Math.round(width * 0.036), bold: true },
2267
+ outlineWidth: 4,
2268
+ shadow: 2,
2269
+ maxChars: 34
2270
+ };
2271
+ }
2272
+ const size = Math.round(width * 0.104);
2273
+ return {
2274
+ ...DEFAULT_CAPTIONS,
2275
+ // The whole line stays up and the spoken word recolours: the social-video
2276
+ // convention, and the one that survives being watched muted.
2277
+ preset: "hormozi",
2278
+ position: "bottom",
2279
+ marginBottom: Math.round(height * 0.156),
2280
+ // Equal sizes on purpose. A larger highlight reflows the line every time
2281
+ // the spoken word moves, which on a full-line preset is a visible twitch.
2282
+ regular: { color: "#FFFFFF", size, bold: true },
2283
+ highlight: { color: "#FFE100", size, bold: true },
2284
+ outlineWidth: 8,
2285
+ shadow: 5,
2286
+ maxChars: 20
2287
+ };
2288
+ }
1164
2289
 
1165
2290
  // src/lib/crawl.ts
1166
2291
  import { chromium } from "playwright";
@@ -1262,9 +2387,74 @@ function shouldSkipLink(text, href, currentPath) {
1262
2387
  }
1263
2388
 
1264
2389
  // src/lib/crawl.ts
2390
+ var SETTLE_POLL_MS = 400;
2391
+ var SETTLE_STABLE_POLLS = 2;
2392
+ var SETTLE_CAP_MS = 2e4;
2393
+ async function waitForDomStable(page, cap = SETTLE_CAP_MS) {
2394
+ const deadline = Date.now() + cap;
2395
+ let previous = -1;
2396
+ let unchanged = 0;
2397
+ while (Date.now() < deadline) {
2398
+ const count = await page.evaluate(() => document.getElementsByTagName("*").length).catch(() => -1);
2399
+ if (count < 0) return;
2400
+ if (count === previous) {
2401
+ if (++unchanged >= SETTLE_STABLE_POLLS) return;
2402
+ } else {
2403
+ unchanged = 0;
2404
+ previous = count;
2405
+ }
2406
+ await page.waitForTimeout(SETTLE_POLL_MS);
2407
+ }
2408
+ }
2409
+ var PLACEHOLDER_POLL_MS = 250;
2410
+ var PLACEHOLDER_CAP_MS = 8e3;
2411
+ var PLACEHOLDER_STATIC_POLLS = 4;
2412
+ var PLACEHOLDER_SELECTOR = [
2413
+ '[aria-busy="true"]',
2414
+ '[role="progressbar"]',
2415
+ '[data-loading="true"]',
2416
+ '[class*="skeleton" i]',
2417
+ '[class*="animate-pulse" i]',
2418
+ '[class*="spinner" i]'
2419
+ ].join(",");
2420
+ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
2421
+ const deadline = Date.now() + cap;
2422
+ let previous = -1;
2423
+ let unchanged = 0;
2424
+ while (Date.now() < deadline) {
2425
+ const count = await page.evaluate((selector) => {
2426
+ let visible = 0;
2427
+ for (const el of Array.from(document.querySelectorAll(selector))) {
2428
+ const rect = el.getBoundingClientRect();
2429
+ if (rect.width > 0 && rect.height > 0) visible++;
2430
+ }
2431
+ return visible;
2432
+ }, PLACEHOLDER_SELECTOR).catch(() => 0);
2433
+ if (count === 0) return;
2434
+ if (count === previous) {
2435
+ if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
2436
+ } else {
2437
+ unchanged = 0;
2438
+ previous = count;
2439
+ }
2440
+ await page.waitForTimeout(PLACEHOLDER_POLL_MS);
2441
+ }
2442
+ }
2443
+ async function gotoSettled(page, url, timeout = 3e4) {
2444
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout });
2445
+ await page.waitForLoadState("networkidle", { timeout: 2500 }).catch(() => {
2446
+ });
2447
+ await waitForDomStable(page).catch(() => {
2448
+ });
2449
+ await waitForPlaceholdersGone(page).catch(() => {
2450
+ });
2451
+ }
1265
2452
  function idSelector(id) {
1266
2453
  return /^[A-Za-z_-][\w-]*$/.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
1267
2454
  }
2455
+ function ariaLabelSelector(label) {
2456
+ return `[aria-label="${label.replace(/(["\\])/g, "\\$1")}"]`;
2457
+ }
1268
2458
  function getActionForType(type) {
1269
2459
  switch (type) {
1270
2460
  case "button":
@@ -1282,169 +2472,228 @@ function getActionForType(type) {
1282
2472
  return "type";
1283
2473
  }
1284
2474
  }
2475
+ async function sitemapRoutes(pageUrl) {
2476
+ let origin;
2477
+ try {
2478
+ origin = new URL(pageUrl).origin;
2479
+ } catch {
2480
+ return [];
2481
+ }
2482
+ try {
2483
+ const res = await fetch(`${origin}/sitemap.xml`, { signal: AbortSignal.timeout(4e3) });
2484
+ if (!res.ok) return [];
2485
+ const xml = await res.text();
2486
+ const seen = /* @__PURE__ */ new Set();
2487
+ const routes = [];
2488
+ for (const m of xml.matchAll(/<loc>([^<]+)<\/loc>/g)) {
2489
+ const raw = (m[1] ?? "").trim();
2490
+ let u;
2491
+ try {
2492
+ u = new URL(raw);
2493
+ } catch {
2494
+ continue;
2495
+ }
2496
+ if (u.origin !== origin) continue;
2497
+ if (/\.(txt|xml|json|png|jpg|svg|ico|pdf)$/i.test(u.pathname)) continue;
2498
+ const path = u.pathname.replace(/\/+$/, "");
2499
+ if (!path || seen.has(u.href)) continue;
2500
+ seen.add(u.href);
2501
+ routes.push({ text: path.slice(1), href: u.href });
2502
+ if (routes.length >= 20) break;
2503
+ }
2504
+ return routes;
2505
+ } catch {
2506
+ return [];
2507
+ }
2508
+ }
1285
2509
  var noopTask = () => ({ succeed: () => {
1286
2510
  } });
1287
2511
  var CRAWL_CACHE_TTL_MS = 5 * 60 * 1e3;
1288
2512
  var CRAWL_CACHE_MAX_ENTRIES = 50;
1289
2513
  var crawlCache = /* @__PURE__ */ new Map();
1290
2514
  async function crawlPage(url, task = noopTask, storageStatePath, viewport) {
1291
- const cacheKey = viewport ? `${url}@${viewport.width}x${viewport.height}` : url;
1292
- const cached = crawlCache.get(cacheKey);
2515
+ const cacheKey2 = viewport ? `${url}@${viewport.width}x${viewport.height}` : url;
2516
+ const cached = crawlCache.get(cacheKey2);
1293
2517
  if (cached && Date.now() - cached.timestamp < CRAWL_CACHE_TTL_MS) {
1294
2518
  task("Using cached crawl").succeed(`Found ${cached.elements.length} interactive elements (cached)`);
1295
2519
  return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
1296
2520
  }
1297
2521
  const browserTask = task("Launching browser");
1298
2522
  const browser = await chromium.launch({ headless: true });
1299
- const context = await browser.newContext({
1300
- ...storageStatePath ? { storageState: storageStatePath } : {},
1301
- // Crawl at the size we will record at, or the measurements below describe
1302
- // a page nobody will ever see.
1303
- ...viewport ? { viewport } : {}
1304
- });
1305
- const page = await context.newPage();
1306
- browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
1307
- const crawlTask = task("Crawling page");
1308
- await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
1309
- const title = await page.title();
1310
- const scrollable = await page.evaluate(
1311
- "Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
1312
- );
1313
- const rawElements = await page.evaluate(`
1314
- (() => {
1315
- const items = [];
1316
-
1317
- // Helper to get parent context
1318
- const getCtx = (el) => {
1319
- let p = el.parentElement;
1320
- while (p && p !== document.body) {
1321
- if (p.id) return '#' + p.id;
1322
- const cls = Array.from(p.classList).find(c =>
1323
- /toolbar|header|footer|sidebar|panel|modal|dialog|nav|menu|controls|actions/i.test(c)
1324
- );
1325
- if (cls) return '.' + cls;
1326
- if (['header', 'footer', 'nav', 'main', 'aside', 'form'].includes(p.tagName.toLowerCase())) {
1327
- return p.tagName.toLowerCase();
2523
+ try {
2524
+ const context = await browser.newContext({
2525
+ ...storageStatePath ? { storageState: storageStatePath } : {},
2526
+ // Crawl at the size we will record at, or the measurements below describe
2527
+ // a page nobody will ever see.
2528
+ ...viewport ? { viewport } : {}
2529
+ });
2530
+ const page = await context.newPage();
2531
+ browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
2532
+ const crawlTask = task("Crawling page");
2533
+ await gotoSettled(page, url, 3e4);
2534
+ const title = await page.title();
2535
+ const scrollable = await page.evaluate(
2536
+ "Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
2537
+ );
2538
+ const rawElements = await page.evaluate(`
2539
+ (() => {
2540
+ const items = [];
2541
+
2542
+ // Helper to get parent context
2543
+ const getCtx = (el) => {
2544
+ let p = el.parentElement;
2545
+ while (p && p !== document.body) {
2546
+ if (p.id) return '#' + p.id;
2547
+ const cls = Array.from(p.classList).find(c =>
2548
+ /toolbar|header|footer|sidebar|panel|modal|dialog|nav|menu|controls|actions/i.test(c)
2549
+ );
2550
+ if (cls) return '.' + cls;
2551
+ if (['header', 'footer', 'nav', 'main', 'aside', 'form'].includes(p.tagName.toLowerCase())) {
2552
+ return p.tagName.toLowerCase();
2553
+ }
2554
+ p = p.parentElement;
1328
2555
  }
1329
- p = p.parentElement;
1330
- }
1331
- return '';
1332
- };
2556
+ return '';
2557
+ };
1333
2558
 
1334
- // Buttons (use textContent to catch hidden elements too)
1335
- document.querySelectorAll('button, [role="button"]').forEach(el => {
1336
- const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
1337
- const testId = el.getAttribute('data-testid');
1338
- const ariaLabel = el.getAttribute('aria-label');
1339
- if (!text && !testId && !ariaLabel) return;
1340
- items.push({ type: 'button', text: text || ariaLabel || testId, testId, ctx: getCtx(el) });
1341
- });
2559
+ // Buttons (use textContent to catch hidden elements too)
2560
+ document.querySelectorAll('button, [role="button"]').forEach(el => {
2561
+ const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
2562
+ const testId = el.getAttribute('data-testid');
2563
+ const ariaLabel = el.getAttribute('aria-label');
2564
+ if (!text && !testId && !ariaLabel) return;
2565
+ items.push({ type: 'button', text: text || ariaLabel || testId, testId, ctx: getCtx(el) });
2566
+ });
1342
2567
 
1343
- // Inputs (including file inputs, range sliders, checkboxes, selects)
1344
- document.querySelectorAll('input, textarea, select').forEach(el => {
1345
- if (el.type === 'hidden') return;
1346
- const testId = el.getAttribute('data-testid');
1347
- const inputType = el.type || 'text';
1348
-
1349
- // Get label text for any input type
1350
- let labelText = '';
1351
- if (el.labels?.[0]) {
1352
- labelText = el.labels[0].textContent?.trim() || '';
1353
- } else if (el.id) {
1354
- const label = document.querySelector('label[for="' + el.id + '"]');
1355
- if (label) labelText = label.textContent?.trim() || '';
1356
- }
2568
+ // Inputs (including file inputs, range sliders, checkboxes, selects)
2569
+ document.querySelectorAll('input, textarea, select').forEach(el => {
2570
+ if (el.type === 'hidden') return;
2571
+ const testId = el.getAttribute('data-testid');
2572
+ const inputType = el.type || 'text';
1357
2573
 
1358
- const text = labelText || el.placeholder || el.name || el.id || '';
2574
+ // Get label text for any input type
2575
+ let labelText = '';
2576
+ if (el.labels?.[0]) {
2577
+ labelText = el.labels[0].textContent?.trim() || '';
2578
+ } else if (el.id) {
2579
+ const label = document.querySelector('label[for="' + el.id + '"]');
2580
+ if (label) labelText = label.textContent?.trim() || '';
2581
+ }
2582
+
2583
+ const text = labelText || el.placeholder || el.name || el.id || '';
1359
2584
 
1360
- // Include file, range, and checkbox inputs even without text
1361
- const alwaysInclude = ['file', 'range', 'checkbox'].includes(inputType);
1362
- if (!text && !testId && !alwaysInclude) return;
2585
+ // Include file, range, and checkbox inputs even without text
2586
+ const alwaysInclude = ['file', 'range', 'checkbox'].includes(inputType);
2587
+ if (!text && !testId && !alwaysInclude) return;
1363
2588
 
1364
- items.push({
1365
- type: inputType,
1366
- text: text || (inputType === 'file' ? 'file upload' : inputType === 'range' ? 'slider' : inputType === 'checkbox' ? 'checkbox' : testId),
1367
- testId,
1368
- id: el.id,
1369
- name: el.name,
1370
- placeholder: el.placeholder,
1371
- isFile: inputType === 'file'
2589
+ items.push({
2590
+ type: inputType,
2591
+ text: text || (inputType === 'file' ? 'file upload' : inputType === 'range' ? 'slider' : inputType === 'checkbox' ? 'checkbox' : testId),
2592
+ testId,
2593
+ id: el.id,
2594
+ name: el.name,
2595
+ placeholder: el.placeholder,
2596
+ isFile: inputType === 'file'
2597
+ });
1372
2598
  });
1373
- });
1374
2599
 
1375
- // Links (use textContent to catch hidden elements too)
1376
- document.querySelectorAll('a[href]').forEach(el => {
1377
- const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
1378
- if (text && text.length >= 2) items.push({ type: 'link', text });
1379
- });
2600
+ // Links (use textContent to catch hidden elements too)
2601
+ document.querySelectorAll('a[href]').forEach(el => {
2602
+ const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
2603
+ if (!text || text.length < 2) return;
2604
+ // Keep the destination, not just the label. Without it the planner
2605
+ // can name a page in a caption but has no way to go there, so every
2606
+ // line gets forced onto whatever is already on screen.
2607
+ let href = '';
2608
+ try {
2609
+ const u = new URL(el.getAttribute('href') || '', location.href);
2610
+ if (u.origin === location.origin) href = u.href;
2611
+ } catch { /* a javascript: or mailto: href is not somewhere to go */ }
2612
+ items.push({ type: 'link', text, href });
2613
+ });
1380
2614
 
1381
- return items.slice(0, 40);
1382
- })()
1383
- `);
1384
- const filteredElements = rawElements.filter((el) => {
1385
- if (!el.text) return true;
1386
- const text = el.text.toLowerCase().trim();
1387
- return !EXCLUDED_LINK_PATTERNS.some((pattern) => text.includes(pattern));
1388
- });
1389
- const btnTexts = {};
1390
- for (const el of filteredElements) {
1391
- if (el.type === "button" && el.text) {
1392
- btnTexts[el.text] = (btnTexts[el.text] || 0) + 1;
1393
- }
1394
- }
1395
- const elements = filteredElements.map((el) => {
1396
- let selector;
1397
- if (el.testId) {
1398
- selector = `[data-testid="${el.testId}"]`;
1399
- } else if (el.isFile || el.type === "file") {
1400
- selector = 'input[type="file"]';
1401
- } else if (el.type === "range") {
1402
- selector = el.id ? idSelector(el.id) : 'input[type="range"]';
1403
- } else if (el.type === "checkbox") {
1404
- selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
1405
- } else if (el.type === "button") {
1406
- if (btnTexts[el.text] === 1) {
2615
+ return items.slice(0, 40);
2616
+ })()
2617
+ `);
2618
+ const filteredElements = rawElements.filter((el) => {
2619
+ if (!el.text) return true;
2620
+ const text = el.text.toLowerCase().trim();
2621
+ return !EXCLUDED_LINK_PATTERNS.some((pattern) => text.includes(pattern));
2622
+ });
2623
+ const btnTexts = {};
2624
+ for (const el of filteredElements) {
2625
+ if (el.type === "button" && el.text) {
2626
+ btnTexts[el.text] = (btnTexts[el.text] || 0) + 1;
2627
+ }
2628
+ }
2629
+ const elements = filteredElements.map((el) => {
2630
+ let selector;
2631
+ if (el.testId) {
2632
+ selector = `[data-testid="${el.testId}"]`;
2633
+ } else if (el.isFile || el.type === "file") {
2634
+ selector = 'input[type="file"]';
2635
+ } else if (el.type === "range") {
2636
+ selector = el.id ? idSelector(el.id) : 'input[type="range"]';
2637
+ } else if (el.type === "checkbox") {
2638
+ selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
2639
+ } else if (el.type === "button") {
2640
+ if (btnTexts[el.text] === 1) {
2641
+ selector = `text="${el.text}"`;
2642
+ } else {
2643
+ selector = el.ctx ? `${el.ctx} >> text="${el.text}"` : `button:has-text("${el.text}")`;
2644
+ }
2645
+ } else if (el.type === "link") {
2646
+ selector = `text="${el.text}"`;
2647
+ } else if (el.id) {
2648
+ selector = idSelector(el.id);
2649
+ } else if (el.name) {
2650
+ selector = `[name="${el.name}"]`;
2651
+ } else if (el.placeholder) {
2652
+ selector = `[placeholder="${el.placeholder}"]`;
2653
+ } else {
1407
2654
  selector = `text="${el.text}"`;
2655
+ }
2656
+ let normalizedType;
2657
+ if (el.isFile || el.type === "file") {
2658
+ normalizedType = "file";
2659
+ } else if (el.type === "range") {
2660
+ normalizedType = "range";
2661
+ } else if (el.type === "checkbox") {
2662
+ normalizedType = "checkbox";
2663
+ } else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
2664
+ normalizedType = "select";
2665
+ } else if (el.type === "button" || el.type === "link") {
2666
+ normalizedType = el.type;
1408
2667
  } else {
1409
- selector = el.ctx ? `${el.ctx} >> text="${el.text}"` : `button:has-text("${el.text}")`;
2668
+ normalizedType = "input";
1410
2669
  }
1411
- } else if (el.type === "link") {
1412
- selector = `text="${el.text}"`;
1413
- } else if (el.id) {
1414
- selector = idSelector(el.id);
1415
- } else if (el.name) {
1416
- selector = `[name="${el.name}"]`;
1417
- } else if (el.placeholder) {
1418
- selector = `[placeholder="${el.placeholder}"]`;
1419
- } else {
1420
- selector = `text="${el.text}"`;
1421
- }
1422
- let normalizedType;
1423
- if (el.isFile || el.type === "file") {
1424
- normalizedType = "file";
1425
- } else if (el.type === "range") {
1426
- normalizedType = "range";
1427
- } else if (el.type === "checkbox") {
1428
- normalizedType = "checkbox";
1429
- } else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
1430
- normalizedType = "select";
1431
- } else if (el.type === "button" || el.type === "link") {
1432
- normalizedType = el.type;
1433
- } else {
1434
- normalizedType = "input";
2670
+ const action = getActionForType(normalizedType);
2671
+ const target = normalizedType === "file" ? selector : (el.text || "").toLowerCase();
2672
+ return { type: normalizedType, text: el.text, selector, action, target, href: el.href };
2673
+ });
2674
+ await browser.close();
2675
+ for (const route of await sitemapRoutes(url)) {
2676
+ if (elements.some((e) => e.href === route.href)) continue;
2677
+ elements.push({
2678
+ type: "link",
2679
+ text: route.text,
2680
+ selector: "",
2681
+ action: "click",
2682
+ target: route.text,
2683
+ href: route.href
2684
+ });
1435
2685
  }
1436
- const action = getActionForType(normalizedType);
1437
- const target = normalizedType === "file" ? selector : (el.text || "").toLowerCase();
1438
- return { type: normalizedType, text: el.text, selector, action, target };
1439
- });
1440
- await browser.close();
1441
- crawlTask.succeed(`Found ${elements.length} interactive elements`);
1442
- crawlCache.set(cacheKey, { timestamp: Date.now(), title, elements: [...elements], scrollable });
1443
- if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
1444
- const oldest = crawlCache.keys().next().value;
1445
- if (oldest !== void 0) crawlCache.delete(oldest);
2686
+ crawlTask.succeed(`Found ${elements.length} interactive elements`);
2687
+ crawlCache.set(cacheKey2, { timestamp: Date.now(), title, elements: [...elements], scrollable });
2688
+ if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
2689
+ const oldest = crawlCache.keys().next().value;
2690
+ if (oldest !== void 0) crawlCache.delete(oldest);
2691
+ }
2692
+ return { title, elements, scrollable };
2693
+ } finally {
2694
+ await browser.close().catch(() => {
2695
+ });
1446
2696
  }
1447
- return { title, elements, scrollable };
1448
2697
  }
1449
2698
 
1450
2699
  // src/lib/cursor.ts
@@ -1557,9 +2806,13 @@ function parseAIJson(content) {
1557
2806
 
1558
2807
  // src/lib/prompts.ts
1559
2808
  init_utils();
1560
- function buildSystemPrompt(maxSteps) {
2809
+ function buildSystemPrompt(maxSteps, profile) {
1561
2810
  let scope;
1562
- if (maxSteps <= 5) {
2811
+ if (profile === "wide") {
2812
+ scope = `SCOPE: Educational tutorial (${maxSteps} steps max) - comprehensive, structured coverage of every major feature area, in a logical teaching order`;
2813
+ } else if (profile === "short") {
2814
+ scope = `SCOPE: Quick-paced short (${maxSteps} steps max) - cover every interesting detail with fast cuts and terse captions; running long is fine, the video is compressed afterwards`;
2815
+ } else if (maxSteps <= 5) {
1563
2816
  scope = `SCOPE: Minimal demo (${maxSteps} steps max) - ONE complete user journey only`;
1564
2817
  } else if (maxSteps <= 8) {
1565
2818
  scope = `SCOPE: Standard demo (${maxSteps} steps max) - One journey with 1-2 options`;
@@ -1571,9 +2824,47 @@ function buildSystemPrompt(maxSteps) {
1571
2824
  ${scope}
1572
2825
 
1573
2826
  CORE FLOW (always follow this structure):
1574
- 1. wait - Intro (what does this app do?)
2827
+ 1. wait - the hook, over the app's own card (see OPENING AND CLOSING)
1575
2828
  2. Actions - Complete ONE user journey (upload\u2192process\u2192result)
1576
- 3. wait - Outro (what was accomplished?)
2829
+ 3. Stop. The closing card is appended for you - do not write one
2830
+
2831
+ NARRATION STYLE:
2832
+ \u2022 Professional and direct, like a good tutorial - never condescending, never explaining as if to a child
2833
+ \u2022 NEVER mention step numbers or sequencing ("step one", "first step", "in this step") - just say what is happening
2834
+ \u2022 NEVER build the demo out of marketing surfaces. The landing page and the PRICING page are not the product: a viewer wants to see the thing working, not read plans and prices. If the app is behind a sign-in, demo the signed-in app; if that is impossible, demo its functional public pages (tools, docs, editors), not its sales pages
2835
+ \u2022 A marketing/landing page earns ONE brief opening caption of about a SECOND - a title card, nothing more. Never scroll it, never hover or highlight anything on it, never describe its sections. Optionally return to it for the closing call to action. The demo lives in the app itself, behind the login when a session exists. Never return to the landing page mid-demo to describe a feature the app can show you doing
2836
+ \u2022 NARRATE ONLY WHAT IS ON SCREEN: every caption must describe what the viewer can see at that moment. To talk about a specific component, make it the step's target (hover it, click it, or scroll with that target) so the recorder brings it into frame first
2837
+ \u2022 A blind scroll (no target) lands wherever it lands - use it only for "there is more below", never to reveal something the caption names
2838
+ \u2022 Never combine scrolling with pointing: a step either moves the page or holds still on a component, so the highlight is never dragged across the screen mid-scroll
2839
+ \u2022 A step that starts something asynchronous (processing, generating, analyzing) must WAIT for the result on screen before moving on: give the wait step a "duration" in seconds long enough to show the outcome
2840
+ \u2022 Caption length drives step length - keep captions tight so the video moves
2841
+
2842
+ OPENING AND CLOSING (a fixed template - both cards are built for you):
2843
+ \u2022 Step 1 is ALWAYS a bare { "action": "wait" } with no target. Its caption is the HOOK, and the recorder puts the app's own OG card behind it
2844
+ \u2022 The hook names the VIEWER'S problem in the viewer's words - the cost of not having this app, in one line someone would recognise as their own situation. It is the reason to keep watching
2845
+ \u2022 NEVER open the hook with the product: no "This is X", no "Meet X", no "X helps you..." and no feature. The problem comes first
2846
+ \u2022 Then, in that SAME caption, say the app's address once - "<problem>. <domain> fixes that." A viewer who leaves halfway has to have heard where to go, and the closing card is too late for them. The opening card is both spoken AND captioned, so write the hook to read as well as it sounds: short words, no spelled-out punctuation, and the domain written the way it is typed
2847
+ \u2022 The closing card is appended for you: the app's name, tagline, domain and any live offer, narrated. Never write a sign-off step and never end back on the landing page. Apart from the hook, keep the domain and any discount code out of captions - the card carries both, and repeating them mid-demo is the most common way these scripts read as padding
2848
+
2849
+ HOW IT SHOULD SOUND (this is read aloud, not printed on a slide):
2850
+ \u2022 Write the way a person talks about something they actually like. Warm, quick, a bit of a grin. Never the voice of a landing page
2851
+ \u2022 BANNED, because they are what marketing copy sounds like when nobody meant it: "free to start", "get started today", "unlock", "empower", "seamlessly", "effortlessly", "revolutionise", "supercharge", "take your X to the next level", "say goodbye to". If a line could sit on any product's homepage unchanged, it is not about this product
2852
+ \u2022 The free tier is "try it free", or just "free". Never "free to start", which reads as a pricing table talking
2853
+ \u2022 Short sentences beat long ones out loud. A comma is a breath; a full stop is a beat. Read it back before shipping it
2854
+ \u2022 Say the thing, do not build up to it. No "in this video we will", no "let's take a look at"
2855
+
2856
+ WHAT TO COVER (a tour of tabs is a menu, not a demo):
2857
+ \u2022 Lead with the outcome the app produces, not the screens it has
2858
+ \u2022 Say where that outcome ENDS UP when the app puts it somewhere - search results, AI assistant answers, directories, an app store
2859
+ \u2022 Name any integration that removes work rather than adding a screen - an MCP server, a browser extension that fills the forms, an API, a CLI. An app that can be driven from an assistant is a headline feature, not a footnote
2860
+ \u2022 Do NOT write a pricing step. When the app has a free tier the closing card says so for you, narrated, at the end where it belongs. Never navigate to the pricing page for it
2861
+ \u2022 Cover what surrounds the product as well as the product: a community, a network its users list each other on, a support channel, anything published on the user's behalf. These are reasons to sign up and they are never on screen by themselves
2862
+ \u2022 Say what a feature DOES, never what it is implemented as. Filenames, formats and protocols are not benefits: "so AI assistants can read your product" lands, "it writes llms.txt and robots.txt" is a changelog line read aloud. Same for sitemap.xml, JSON-LD, schema markup, API endpoints and anything ending in .txt, .xml or .json. If a viewer would have to already know what it is, it is not a selling point
2863
+ \u2022 Prefer one feature shown working over three named in passing
2864
+ \u2022 When a step types into a URL or domain field, type a REAL domain, never example.com or example.org. A demo that audits "example.com" on camera shows the tool working on nothing. Use a real site the viewer might recognise, or the demo subject's own domain if nothing better is on the page
2865
+ \u2022 A plan that is mostly bare wait steps is a failed plan. One real run came back 22 waits out of 26: a slideshow of one static screen, narrating pages it never visited. If a caption names a page, NAVIGATE to it; if it names a control, CLICK it. Use wait only for the opening and closing cards
2866
+ \u2022 NEVER narrate a screen you are not on. If a line describes a page the demo has not reached, navigate to it first. A caption saying "start with a free roast" over the dashboard is worse than cutting the line: it makes the whole video look faked
2867
+ \u2022 Cover everything worth covering and let it run long. Length is fixed afterwards by playing the demo faster, never by dropping a feature - a plan trimmed to hit a target is the one mistake the compressor cannot undo
1577
2868
 
1578
2869
  TARGET SYNTAX (simplified):
1579
2870
  \u2022 Buttons/Links: Use plain text like "extract frame", "download" (case insensitive, partial match OK)
@@ -1596,9 +2887,12 @@ FORBIDDEN:
1596
2887
 
1597
2888
  ACTION FORMATS (STRICT - follow exactly):
1598
2889
  \u2022 click: { "action": "click", "target": "extract frame" } - use simplified button text
2890
+ \u2022 hover: { "action": "hover", "target": "pricing card" } - point the cursor at a feature while the caption explains it, without clicking
1599
2891
  \u2022 type: { "action": "type", "target": "input[name=\\"field\\"]", "value": "text to type" }
1600
2892
  \u2022 upload: { "action": "upload", "target": "input[type=\\"file\\"]", "value": "[file]" }
2893
+ \u2022 navigate: { "action": "navigate", "target": "https://example.com/roast" } - GO TO ANOTHER PAGE. Use it whenever the caption describes something that is not on the current screen. Any path on the SAME ORIGIN as the demo URL is allowed, not only the NAVIGATE targets listed below: an app's own routes are frequently reached by client-side buttons rather than links, so the list is a hint, not the limit. Never navigate off-origin.
1601
2894
  \u2022 wait: { "action": "wait" } - for intro/outro captions
2895
+ \u2022 scroll to a component: { "action": "scroll", "target": "seo files", "value": "down" } - brings that element into frame; PREFER this over a blind scroll whenever the caption names something
1602
2896
  \u2022 scroll: { "action": "scroll", "value": "-80" } - negative=zoom out, positive=zoom in
1603
2897
  \u2022 drag: { "action": "drag", "target": "input[type=\\"range\\"]", "value": "50" }
1604
2898
  \u2022 select: { "action": "select", "target": "select[name=\\"..\\"]", "value": "option" }
@@ -1623,6 +2917,17 @@ Purpose: ${description}` : "";
1623
2917
  const selectables = elements.filter((e) => e.action === "select");
1624
2918
  const checkables = elements.filter((e) => e.action === "check");
1625
2919
  const elementDict = [];
2920
+ const navigables = elements.filter((e) => e.href);
2921
+ const seenHref = /* @__PURE__ */ new Set();
2922
+ const linkTargets = navigables.filter((e) => {
2923
+ if (!e.href || seenHref.has(e.href)) return false;
2924
+ seenHref.add(e.href);
2925
+ return true;
2926
+ });
2927
+ if (linkTargets.length > 0) {
2928
+ elementDict.push(`NAVIGATE targets (links found on the page; same-origin paths not listed here are also allowed):
2929
+ ${linkTargets.map((e) => `\u2022 "${e.text}" -> ${e.href}`).join("\n")}`);
2930
+ }
1626
2931
  if (clickables.length > 0) {
1627
2932
  elementDict.push(`CLICK targets (use simplified text):
1628
2933
  ${clickables.map((e) => `\u2022 "${e.target || e.text.toLowerCase()}"`).join("\n")}`);
@@ -1689,7 +2994,16 @@ TARGET SYNTAX:
1689
2994
  }
1690
2995
 
1691
2996
  // src/lib/styles.ts
1692
- import { buildAssHeader } from "@spark-apps/video-kit";
2997
+ import {
2998
+ assTimeFromMs,
2999
+ buildAssHeader,
3000
+ chunkWordsToLines,
3001
+ fittingMaxChars,
3002
+ hexToAss as hexToAss2,
3003
+ mergePunctuationTokens,
3004
+ scaleToFit,
3005
+ toCaptionCase
3006
+ } from "@spark-apps/video-kit";
1693
3007
  var CURSOR_MOVE_DURATION = 800;
1694
3008
  var CURSOR_CSS = `
1695
3009
  #qp-cursor {
@@ -1728,16 +3042,247 @@ var CURSOR_SCRIPT = `
1728
3042
  cursor.style.top = window.innerHeight / 2 + 'px';
1729
3043
  })();
1730
3044
  `;
1731
- function generateASSHeader(width, height, font, size, primaryColor, secondaryColor, outlineColor, backColor, bold, outlineWidth, shadow, marginBottom) {
3045
+ function generateASSHeader(width, height, font, size, primaryColor, secondaryColor, outlineColor, backColor, bold, outlineWidth, shadow, marginBottom, alignment = 2) {
1732
3046
  return buildAssHeader({
1733
3047
  title: "QuickPeek Demo",
1734
3048
  width,
1735
3049
  height,
1736
3050
  styles: [
1737
- `Style: Karaoke,${font},${size},${primaryColor},${secondaryColor},${outlineColor},${backColor},${bold ? 1 : 0},0,0,0,100,100,0,0,1,${outlineWidth},${shadow},2,40,40,${marginBottom},1`
3051
+ `Style: Karaoke,${font},${size},${primaryColor},${secondaryColor},${outlineColor},${backColor},${bold ? 1 : 0},0,0,0,100,100,0,0,1,${outlineWidth},${shadow},${alignment},40,40,${marginBottom},1`
1738
3052
  ]
1739
3053
  });
1740
3054
  }
3055
+ var SIDE_MARGIN = 40;
3056
+ function resolveCaptionFont(font) {
3057
+ if (process.platform === "linux" && font === "Arial Black") return "DejaVu Sans";
3058
+ return font;
3059
+ }
3060
+ function boldOverride(captions) {
3061
+ if (captions.highlight.bold === captions.regular.bold) return { lit: "", rest: "" };
3062
+ return {
3063
+ lit: captions.highlight.bold ? "\\b1" : "\\b0",
3064
+ rest: captions.regular.bold ? "\\b1" : "\\b0"
3065
+ };
3066
+ }
3067
+ function positionToAlignment(position, leftAligned = false) {
3068
+ const base = leftAligned ? 1 : 2;
3069
+ if (position === "center") return base + 3;
3070
+ if (position === "top") return base + 6;
3071
+ return base;
3072
+ }
3073
+ function positionMarginV(position, margin) {
3074
+ return position === "center" ? 0 : margin;
3075
+ }
3076
+ function packLines(words, maxChars) {
3077
+ return chunkWordsToLines(mergePunctuationTokens(words), maxChars).filter((line) => line.length > 0);
3078
+ }
3079
+ function renderKaraokeAss(ctx) {
3080
+ const { captions, width, height } = ctx;
3081
+ const regularColor = hexToAss2(captions.regular.color);
3082
+ const highlightColor = hexToAss2(captions.highlight.color);
3083
+ const outline = hexToAss2(captions.outline);
3084
+ const back = hexToAss2(captions.outline, 128);
3085
+ const highlightScale = Math.round(captions.highlight.size / captions.regular.size * 100);
3086
+ const header = generateASSHeader(
3087
+ width,
3088
+ height,
3089
+ resolveCaptionFont(captions.font),
3090
+ captions.regular.size,
3091
+ highlightColor,
3092
+ regularColor,
3093
+ outline,
3094
+ back,
3095
+ captions.regular.bold,
3096
+ captions.outlineWidth,
3097
+ captions.shadow,
3098
+ positionMarginV(captions.position, captions.marginBottom),
3099
+ positionToAlignment(captions.position)
3100
+ );
3101
+ const usable = width - SIDE_MARGIN * 2;
3102
+ const maxChars = fittingMaxChars(width, captions.regular.size, SIDE_MARGIN, captions.maxChars, true);
3103
+ const lines = [];
3104
+ for (const cue of ctx.cues) {
3105
+ for (const line of packLines(cue.words, maxChars)) {
3106
+ const plain = line.map((w) => w.word).join(" ");
3107
+ const lineScale = scaleToFit(plain, captions.regular.size, usable, true);
3108
+ const popScale = Math.round(highlightScale * lineScale / 100);
3109
+ const boldTags = boldOverride(captions);
3110
+ let karaokeText = "";
3111
+ for (const word of line) {
3112
+ const duration = Math.round((word.end - word.start) / 10);
3113
+ karaokeText += `{\\kf${duration}\\fscx${popScale}\\fscy${popScale}${boldTags.lit}}${word.word}{\\fscx${lineScale}\\fscy${lineScale}${boldTags.rest}} `;
3114
+ }
3115
+ const start = assTimeFromMs(line[0].start);
3116
+ const end = assTimeFromMs(line[line.length - 1].end);
3117
+ lines.push(`Dialogue: 0,${start},${end},Karaoke,,0,0,0,,${karaokeText.trim()}`);
3118
+ }
3119
+ }
3120
+ return `${header}${lines.join("\n")}
3121
+ `;
3122
+ }
3123
+ function renderHormoziAss(ctx) {
3124
+ const { captions, width, height } = ctx;
3125
+ const font = resolveCaptionFont(captions.font);
3126
+ const primary = hexToAss2(captions.regular.color);
3127
+ const highlight = hexToAss2(captions.highlight.color);
3128
+ const outline = hexToAss2(captions.outline);
3129
+ const back = hexToAss2(captions.outline, 128);
3130
+ const alignment = positionToAlignment(captions.position);
3131
+ const marginV = positionMarginV(captions.position, captions.marginBottom);
3132
+ const bold = captions.regular.bold ? -1 : 0;
3133
+ const header = buildAssHeader({
3134
+ title: "QuickPeek Demo",
3135
+ width,
3136
+ height,
3137
+ styles: [
3138
+ `Style: Default,${font},${captions.regular.size},${primary},${highlight},${outline},${back},${bold},0,0,0,100,100,0,0,1,${captions.outlineWidth},${captions.shadow},${alignment},${SIDE_MARGIN},${SIDE_MARGIN},${marginV},1`
3139
+ ],
3140
+ scaledBorderAndShadow: true
3141
+ });
3142
+ const maxChars = fittingMaxChars(width, captions.regular.size, SIDE_MARGIN, captions.maxChars, true);
3143
+ const lines = [];
3144
+ for (const cue of ctx.cues) {
3145
+ for (const line of packLines(cue.words, maxChars)) {
3146
+ const plain = line.map((w) => w.word).join(" ");
3147
+ const lineScale = scaleToFit(plain, captions.regular.size, width - SIDE_MARGIN * 2, true);
3148
+ const scaleTag = lineScale < 100 ? `{\\fscx${lineScale}\\fscy${lineScale}}` : "";
3149
+ const boldTags = boldOverride(captions);
3150
+ for (let i = 0; i < line.length; i++) {
3151
+ const word = line[i];
3152
+ const nextStart = i + 1 < line.length ? line[i + 1].start : line[line.length - 1].end;
3153
+ const text = line.map(
3154
+ (other, j) => j === i ? `{\\1c${highlight}${boldTags.lit}}${other.word}{\\1c${primary}${boldTags.rest}}` : other.word
3155
+ ).join(" ");
3156
+ lines.push(
3157
+ `Dialogue: 0,${assTimeFromMs(word.start)},${assTimeFromMs(nextStart)},Default,,0,0,0,,${scaleTag}${text}`
3158
+ );
3159
+ }
3160
+ }
3161
+ }
3162
+ return `${header}${lines.join("\n")}
3163
+ `;
3164
+ }
3165
+ function renderShortsAss(ctx) {
3166
+ const { captions, width, height } = ctx;
3167
+ const font = resolveCaptionFont(captions.font === "Arial" ? "Arial Black" : captions.font);
3168
+ const primary = hexToAss2(captions.regular.color);
3169
+ const highlight = hexToAss2(captions.highlight.color);
3170
+ const fontSize = Math.max(captions.regular.size, Math.round(height / 20));
3171
+ const marginV = captions.position === "bottom" ? Math.round(height * 0.24) : captions.position === "top" ? Math.round(height * 0.1) : 0;
3172
+ const alignment = positionToAlignment(captions.position);
3173
+ const outline = Math.max(2, Math.round(fontSize / 22));
3174
+ const upper = true;
3175
+ const caseOf = (t) => toCaptionCase(t, upper);
3176
+ const maxChars = fittingMaxChars(width, fontSize, SIDE_MARGIN, captions.maxChars, upper);
3177
+ const header = `[Script Info]
3178
+ Title: QuickPeek Demo
3179
+ ScriptType: v4.00+
3180
+ PlayResX: ${width}
3181
+ PlayResY: ${height}
3182
+ ScaledBorderAndShadow: yes
3183
+ WrapStyle: 0
3184
+
3185
+ [V4+ Styles]
3186
+ Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
3187
+ Style: Shorts,${font},${fontSize},${primary},${primary},&H00000000,&H00000000,${captions.regular.bold ? -1 : 0},0,0,0,100,100,0,0,1,${outline},2,${alignment},${SIDE_MARGIN},${SIDE_MARGIN},${marginV},1
3188
+
3189
+ [Events]
3190
+ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
3191
+ `;
3192
+ const lines = [];
3193
+ for (const cue of ctx.cues) {
3194
+ for (const lineWords of packLines(cue.words, maxChars)) {
3195
+ const plain = lineWords.map((w) => caseOf(w.word)).join(" ");
3196
+ const scale = scaleToFit(plain, fontSize, width - SIDE_MARGIN * 2, upper);
3197
+ const scaleTag = scale < 100 ? `{\\fscx${scale}\\fscy${scale}}` : "";
3198
+ const boldTags = boldOverride(captions);
3199
+ for (let i = 0; i < lineWords.length; i++) {
3200
+ const text = lineWords.map(
3201
+ (w, j) => j === i ? `{\\c${highlight}${boldTags.lit}}${caseOf(w.word)}{\\c${primary}${boldTags.rest}}` : caseOf(w.word)
3202
+ ).join(" ");
3203
+ const end = i === lineWords.length - 1 ? lineWords[i].end : lineWords[i + 1].start;
3204
+ lines.push(
3205
+ `Dialogue: 0,${assTimeFromMs(lineWords[i].start)},${assTimeFromMs(end)},Shorts,,0,0,0,,${scaleTag}${text}`
3206
+ );
3207
+ }
3208
+ }
3209
+ }
3210
+ return `${header}${lines.join("\n")}
3211
+ `;
3212
+ }
3213
+ function renderMinimalAss(ctx) {
3214
+ const { captions, width, height } = ctx;
3215
+ const font = resolveCaptionFont(captions.font);
3216
+ const primary = hexToAss2(captions.regular.color);
3217
+ const fontSize = Math.round(captions.regular.size * 0.6);
3218
+ const alignment = positionToAlignment(captions.position, true);
3219
+ const marginV = positionMarginV(captions.position, 30);
3220
+ const header = buildAssHeader({
3221
+ title: "QuickPeek Demo",
3222
+ width,
3223
+ height,
3224
+ styles: [
3225
+ // BorderStyle 3 draws an opaque box (&HB0...: mostly-opaque black) behind the text
3226
+ `Style: MinimalBg,${font},${fontSize},${primary},${primary},&H00000000,&HB0000000,${captions.regular.bold ? -1 : 0},0,0,0,100,100,0,0,3,0,0,${alignment},20,20,${marginV},1`
3227
+ ],
3228
+ scaledBorderAndShadow: true
3229
+ });
3230
+ const maxChars = fittingMaxChars(width, fontSize, 20, captions.maxChars * 2, true);
3231
+ const lines = [];
3232
+ for (const cue of ctx.cues) {
3233
+ for (const line of packLines(cue.words, maxChars)) {
3234
+ const start = assTimeFromMs(line[0].start);
3235
+ const end = assTimeFromMs(line[line.length - 1].end);
3236
+ lines.push(`Dialogue: 0,${start},${end},MinimalBg,,0,0,0,,${line.map((w) => w.word).join(" ")}`);
3237
+ }
3238
+ }
3239
+ return `${header}${lines.join("\n")}
3240
+ `;
3241
+ }
3242
+ var CAPTION_DESIGN_LONG_EDGE = 1920;
3243
+ function captionCanvas(width, height) {
3244
+ const longEdge = Math.max(width, height);
3245
+ if (!Number.isFinite(longEdge) || longEdge <= 0) return { width, height };
3246
+ const scale = CAPTION_DESIGN_LONG_EDGE / longEdge;
3247
+ return { width: Math.round(width * scale), height: Math.round(height * scale) };
3248
+ }
3249
+ function resolveCaptions(css, video) {
3250
+ const { width, height } = captionCanvas(video.width, video.height);
3251
+ const canvas = defaultCaptionsFor(width, height);
3252
+ const base = {
3253
+ ...canvas,
3254
+ font: css.font,
3255
+ outline: css.outline,
3256
+ regular: { ...canvas.regular, color: css.regular.color },
3257
+ highlight: { ...canvas.highlight, color: css.highlight.color }
3258
+ };
3259
+ const over = video.captions;
3260
+ if (!over) return base;
3261
+ return {
3262
+ ...base,
3263
+ ...over,
3264
+ regular: { ...base.regular, ...over.regular },
3265
+ highlight: { ...base.highlight, ...over.highlight }
3266
+ };
3267
+ }
3268
+ function renderCaptionsAss(context) {
3269
+ const { width, height } = captionCanvas(context.width, context.height);
3270
+ const cues = context.cues.map((cue) => ({
3271
+ ...cue,
3272
+ words: cue.words.map((w) => ({ ...w, word: toCaptionCase(w.word, true) }))
3273
+ }));
3274
+ const ctx = { ...context, width, height, cues };
3275
+ switch (ctx.captions.preset) {
3276
+ case "hormozi":
3277
+ return renderHormoziAss(ctx);
3278
+ case "shorts":
3279
+ return renderShortsAss(ctx);
3280
+ case "minimal":
3281
+ return renderMinimalAss(ctx);
3282
+ default:
3283
+ return renderKaraokeAss(ctx);
3284
+ }
3285
+ }
1741
3286
 
1742
3287
  // src/lib/subscription-client.ts
1743
3288
  async function registerFreeUser(email) {
@@ -1774,14 +3319,17 @@ async function registerFreeUser(email) {
1774
3319
 
1775
3320
  // src/lib/tts/cache.ts
1776
3321
  init_helpers();
1777
- import { join as join3 } from "path";
1778
- function canReuseVoiceover(steps, voice, existingMeta) {
3322
+ import { join as join4 } from "path";
3323
+ function canReuseVoiceover(steps, voice, rate, existingMeta) {
1779
3324
  if (!existingMeta) {
1780
3325
  return { canReuse: false, reason: "No existing voiceover found" };
1781
3326
  }
1782
3327
  if (existingMeta.voice !== voice) {
1783
3328
  return { canReuse: false, reason: `Voice changed (${existingMeta.voice} \u2192 ${voice})` };
1784
3329
  }
3330
+ if (existingMeta.rate !== rate) {
3331
+ return { canReuse: false, reason: `Narration speed changed (${existingMeta.rate ?? "unknown"} \u2192 ${rate})` };
3332
+ }
1785
3333
  if (existingMeta.stepCount !== steps.length) {
1786
3334
  return { canReuse: false, reason: `Step count changed (${existingMeta.stepCount} \u2192 ${steps.length})` };
1787
3335
  }
@@ -1789,37 +3337,54 @@ function canReuseVoiceover(steps, voice, existingMeta) {
1789
3337
  if (existingMeta.captionsHash === currentHash) {
1790
3338
  return { canReuse: true };
1791
3339
  }
1792
- const currentLength = steps.reduce((sum, s) => sum + (s.caption?.length || 0), 0);
1793
- const lengthDiff = Math.abs(currentLength - existingMeta.totalCaptionLength);
1794
- const threshold = existingMeta.totalCaptionLength * 0.1;
1795
- if (lengthDiff <= threshold) {
1796
- return { canReuse: true, reason: "Captions similar enough" };
1797
- }
1798
- return { canReuse: false, reason: "Captions changed significantly" };
3340
+ return { canReuse: false, reason: "Captions changed" };
1799
3341
  }
1800
3342
  async function loadExistingVoiceover(steps, outputDir, meta, config) {
1801
3343
  const { estimateWordTimings: estimateWordTimings2 } = await Promise.resolve().then(() => (init_timing(), timing_exports));
1802
- const voiceoverPath = join3(outputDir, "voiceover.wav");
3344
+ const voiceoverPath = join4(outputDir, "voiceover.wav");
1803
3345
  const stepAudio = /* @__PURE__ */ new Map();
3346
+ const captionsUnchanged = meta.captionsHash === getCaptionsHash(steps);
1804
3347
  for (let i = 0; i < steps.length; i++) {
1805
3348
  const step = steps[i];
1806
3349
  const stepNum = i + 1;
1807
3350
  const duration = meta.stepDurations[stepNum] || config.timing.minStepDuration;
3351
+ const savedWords = captionsUnchanged ? meta.stepWords?.[stepNum] : void 0;
1808
3352
  stepAudio.set(stepNum, {
1809
3353
  duration,
1810
- words: estimateWordTimings2(step.caption || "", duration)
3354
+ words: savedWords && savedWords.length > 0 ? savedWords : estimateWordTimings2(step.caption || "", duration)
1811
3355
  });
1812
3356
  }
1813
3357
  return { voiceoverPath, stepAudio };
1814
3358
  }
1815
3359
 
1816
3360
  // src/lib/tts/generate.ts
3361
+ import { createHash } from "crypto";
3362
+ import { existsSync as existsSync3 } from "fs";
3363
+ import { copyFile, mkdir, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
3364
+ import { join as join6 } from "path";
3365
+ import { toSpokenForm } from "@spark-apps/video-kit";
1817
3366
  init_ffmpeg2();
1818
- import { mkdir } from "fs/promises";
1819
- import { join as join5 } from "path";
3367
+ init_utils();
1820
3368
 
1821
3369
  // src/lib/voices.ts
1822
- import { WIDE_VOICES } from "@spark-apps/video-kit";
3370
+ import { MALE_VOICES, MULTILINGUAL_VOICES, resolveVoice, WIDE_VOICES } from "@spark-apps/video-kit";
3371
+ import { MALE_VOICES as MALE_VOICES2, MULTILINGUAL_VOICES as MULTILINGUAL_VOICES2, WIDE_VOICES as WIDE_VOICES2 } from "@spark-apps/video-kit";
3372
+ var INTERNATIONAL_VOICE = "international";
3373
+ function internationalVoiceFor(lang, gender = "female") {
3374
+ if (!hasVoice(lang)) return null;
3375
+ const raw = lang.trim().toLowerCase();
3376
+ const multilingual = MULTILINGUAL_VOICES[raw] ?? MULTILINGUAL_VOICES[raw.slice(0, 2)];
3377
+ if (gender === "male") return voiceFor(lang, gender);
3378
+ return multilingual ?? voiceFor(lang, gender);
3379
+ }
3380
+ function hasVoice(lang) {
3381
+ const raw = lang.trim().toLowerCase();
3382
+ return Boolean(WIDE_VOICES[raw] ?? WIDE_VOICES[raw.slice(0, 2)]);
3383
+ }
3384
+ function voiceFor(lang, gender = "female") {
3385
+ if (!hasVoice(lang)) return null;
3386
+ return resolveVoice(lang, { gender, table: WIDE_VOICES, maleTable: MALE_VOICES });
3387
+ }
1823
3388
 
1824
3389
  // src/lib/tts/generate.ts
1825
3390
  init_helpers();
@@ -1828,39 +3393,78 @@ init_helpers();
1828
3393
  init_helpers();
1829
3394
  import { exec } from "child_process";
1830
3395
  import { createWriteStream } from "fs";
1831
- import { stat, unlink as unlink2, writeFile as writeFile2 } from "fs/promises";
1832
- import { join as join4, resolve as resolve2 } from "path";
3396
+ import { readFile as readFile2, stat, unlink as unlink3, writeFile as writeFile2 } from "fs/promises";
3397
+ import { join as join5, resolve as resolve2 } from "path";
1833
3398
  import { pipeline } from "stream/promises";
1834
3399
  import { promisify } from "util";
1835
3400
  var execAsync = promisify(exec);
1836
3401
  var PYTHON_ATTEMPTS = 2;
1837
- async function synthesizeSpeech(text, outputPath, voice) {
1838
- let pythonMsg = "";
1839
- for (let attempt = 0; attempt < PYTHON_ATTEMPTS; attempt++) {
1840
- try {
1841
- await synthesizeWithPython(text, outputPath, voice);
1842
- return;
1843
- } catch (err) {
1844
- pythonMsg = err instanceof Error ? err.message : String(err);
1845
- if (attempt < PYTHON_ATTEMPTS - 1) await new Promise((r) => setTimeout(r, 500));
3402
+ var TICKS_PER_MS = 1e4;
3403
+ var wordsSidecar = (audioPath) => `${audioPath}.words.json`;
3404
+ var pythonEdgeTts = null;
3405
+ function pythonEdgeTtsAvailable() {
3406
+ pythonEdgeTts ??= execAsync(isWindows ? 'wsl python3 -c "import edge_tts"' : 'python3 -c "import edge_tts"', {
3407
+ timeout: 1e4
3408
+ }).then(
3409
+ () => true,
3410
+ () => false
3411
+ );
3412
+ return pythonEdgeTts;
3413
+ }
3414
+ var lockedEngine = null;
3415
+ function resetVoiceEngine() {
3416
+ lockedEngine = null;
3417
+ }
3418
+ function xmlSafe(text) {
3419
+ return text.replace(/[&<>]/g, (c) => c === "&" ? " and " : " ").replace(/\s{2,}/g, " ");
3420
+ }
3421
+ async function synthesizeSpeech(rawText, outputPath, voice, rate) {
3422
+ const text = xmlSafe(rawText);
3423
+ let pythonMsg = "python edge-tts not installed";
3424
+ const tryPython = lockedEngine !== "node" && await pythonEdgeTtsAvailable();
3425
+ if (tryPython) {
3426
+ const attempts = lockedEngine === "python" ? PYTHON_ATTEMPTS * 2 : PYTHON_ATTEMPTS;
3427
+ for (let attempt = 0; attempt < attempts; attempt++) {
3428
+ try {
3429
+ const words2 = await synthesizeWithPython(text, outputPath, voice, rate);
3430
+ lockedEngine = "python";
3431
+ return words2;
3432
+ } catch (err) {
3433
+ pythonMsg = err instanceof Error ? err.message : String(err);
3434
+ if (attempt < attempts - 1) await new Promise((r) => setTimeout(r, 500));
3435
+ }
1846
3436
  }
1847
3437
  }
1848
- await synthesizeWithNode(text, outputPath, voice, pythonMsg);
3438
+ const words = await synthesizeWithNode(text, outputPath, voice, pythonMsg, rate);
3439
+ lockedEngine = "node";
3440
+ return words;
1849
3441
  }
1850
- async function synthesizeWithPython(text, outputPath, voice) {
1851
- const tempScriptPath = join4(resolve2(outputPath, ".."), `tts-${Date.now()}.py`);
3442
+ async function synthesizeWithPython(text, outputPath, voice, rate) {
3443
+ const tempScriptPath = join5(resolve2(outputPath, ".."), `tts-${Date.now()}.py`);
1852
3444
  const wslScriptPath = isWindows ? toWSLPathForTTS(resolve2(tempScriptPath)) : tempScriptPath;
1853
3445
  const wslOutputPath = isWindows ? toWSLPathForTTS(resolve2(outputPath)) : outputPath;
1854
3446
  const pythonScript = `
1855
- import asyncio
3447
+ import asyncio, json
1856
3448
  from edge_tts import Communicate
1857
3449
 
1858
3450
  async def main():
1859
3451
  text = ${JSON.stringify(text)}
1860
3452
  voice = ${JSON.stringify(voice)}
1861
3453
  output = ${JSON.stringify(wslOutputPath)}
1862
- communicate = Communicate(text, voice)
1863
- await communicate.save(output)
3454
+ communicate = Communicate(text, voice${rate ? `, rate=${JSON.stringify(rate)}` : ""})
3455
+ words = []
3456
+ with open(output, "wb") as f:
3457
+ async for chunk in communicate.stream():
3458
+ if chunk["type"] == "audio":
3459
+ f.write(chunk["data"])
3460
+ elif chunk["type"] == "WordBoundary":
3461
+ words.append({
3462
+ "word": chunk["text"],
3463
+ "start": chunk["offset"] / ${TICKS_PER_MS},
3464
+ "end": (chunk["offset"] + chunk["duration"]) / ${TICKS_PER_MS},
3465
+ })
3466
+ with open(output + ".words.json", "w") as f:
3467
+ json.dump(words, f)
1864
3468
 
1865
3469
  asyncio.run(main())
1866
3470
  `;
@@ -1868,24 +3472,40 @@ asyncio.run(main())
1868
3472
  await writeFile2(tempScriptPath, pythonScript);
1869
3473
  const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
1870
3474
  await execAsync(pythonCmd, { timeout: 3e4 });
3475
+ return await readWordsSidecar(outputPath);
1871
3476
  } finally {
1872
- await unlink2(tempScriptPath).catch(() => {
3477
+ await unlink3(tempScriptPath).catch(() => {
1873
3478
  });
1874
3479
  }
1875
3480
  }
1876
- async function synthesizeWithNode(text, outputPath, voice, pythonMsg, maxRetries = 2) {
3481
+ async function readWordsSidecar(outputPath) {
3482
+ const sidecar = wordsSidecar(outputPath);
3483
+ try {
3484
+ const parsed = JSON.parse(await readFile2(sidecar, "utf-8"));
3485
+ return parsed.length > 0 ? parsed : null;
3486
+ } catch {
3487
+ return null;
3488
+ } finally {
3489
+ await unlink3(sidecar).catch(() => {
3490
+ });
3491
+ }
3492
+ }
3493
+ async function synthesizeWithNode(text, outputPath, voice, pythonMsg, rate, maxRetries = 2) {
1877
3494
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1878
3495
  try {
1879
3496
  const { MsEdgeTTS, OUTPUT_FORMAT } = await import("msedge-tts");
1880
3497
  const tts = new MsEdgeTTS();
1881
- await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3);
1882
- const { audioStream } = tts.toStream(text);
3498
+ await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3, { wordBoundaryEnabled: true });
3499
+ const { audioStream, metadataStream } = tts.toStream(text, rate ? { rate } : void 0);
3500
+ const metadata = [];
3501
+ metadataStream?.on("data", (chunk) => metadata.push(chunk.toString()));
1883
3502
  await pipeline(audioStream, createWriteStream(outputPath));
3503
+ await new Promise((r) => setTimeout(r, 250));
1884
3504
  const { size } = await stat(outputPath);
1885
3505
  if (size === 0) throw new Error("No audio data received");
1886
- return;
3506
+ return parseWordBoundaries(metadata.join(""));
1887
3507
  } catch (nodeErr) {
1888
- await unlink2(outputPath).catch(() => {
3508
+ await unlink3(outputPath).catch(() => {
1889
3509
  });
1890
3510
  if (attempt === maxRetries) {
1891
3511
  const nodeMsg = nodeErr instanceof Error ? nodeErr.message : String(nodeErr);
@@ -1894,21 +3514,197 @@ async function synthesizeWithNode(text, outputPath, voice, pythonMsg, maxRetries
1894
3514
  await new Promise((r) => setTimeout(r, 500));
1895
3515
  }
1896
3516
  }
3517
+ return null;
3518
+ }
3519
+ function parseWordBoundaries(raw) {
3520
+ const words = [];
3521
+ for (const frame of splitJsonObjects(raw)) {
3522
+ let parsed;
3523
+ try {
3524
+ parsed = JSON.parse(frame);
3525
+ } catch {
3526
+ continue;
3527
+ }
3528
+ for (const entry of parsed.Metadata ?? []) {
3529
+ if (entry.Type !== "WordBoundary") continue;
3530
+ const word = entry.Data?.text?.Text;
3531
+ const offset = entry.Data?.Offset;
3532
+ const duration = entry.Data?.Duration;
3533
+ if (!word || offset === void 0 || duration === void 0) continue;
3534
+ words.push({
3535
+ word,
3536
+ start: offset / TICKS_PER_MS,
3537
+ end: (offset + duration) / TICKS_PER_MS
3538
+ });
3539
+ }
3540
+ }
3541
+ return words.length > 0 ? words : null;
3542
+ }
3543
+ function splitJsonObjects(raw) {
3544
+ const out = [];
3545
+ let depth = 0;
3546
+ let start = -1;
3547
+ let inString = false;
3548
+ let escaped = false;
3549
+ for (let i = 0; i < raw.length; i++) {
3550
+ const ch = raw[i];
3551
+ if (escaped) {
3552
+ escaped = false;
3553
+ continue;
3554
+ }
3555
+ if (inString) {
3556
+ if (ch === "\\") escaped = true;
3557
+ else if (ch === '"') inString = false;
3558
+ continue;
3559
+ }
3560
+ if (ch === '"') inString = true;
3561
+ else if (ch === "{") {
3562
+ if (depth === 0) start = i;
3563
+ depth++;
3564
+ } else if (ch === "}") {
3565
+ depth--;
3566
+ if (depth === 0 && start >= 0) {
3567
+ out.push(raw.slice(start, i + 1));
3568
+ start = -1;
3569
+ }
3570
+ }
3571
+ }
3572
+ return out;
1897
3573
  }
1898
3574
 
1899
3575
  // src/lib/tts/generate.ts
1900
3576
  init_timing();
3577
+ init_helpers();
3578
+ function clipKey(spokenText, voice, rate) {
3579
+ return createHash("sha1").update(`${spokenText}\0${voice}\0${rate}`).digest("hex");
3580
+ }
3581
+ async function reuseClip(cacheDir, key, audioPath) {
3582
+ const mp3 = join6(cacheDir, `${key}.mp3`);
3583
+ const meta = join6(cacheDir, `${key}.json`);
3584
+ if (!existsSync3(mp3) || !existsSync3(meta)) return null;
3585
+ try {
3586
+ const boundaries = JSON.parse(await readFile3(meta, "utf-8"));
3587
+ await copyFile(mp3, audioPath);
3588
+ return boundaries;
3589
+ } catch {
3590
+ return null;
3591
+ }
3592
+ }
3593
+ async function storeClip(cacheDir, key, audioPath, spoken) {
3594
+ try {
3595
+ await mkdir(cacheDir, { recursive: true });
3596
+ await copyFile(audioPath, join6(cacheDir, `${key}.mp3`));
3597
+ await writeFile3(join6(cacheDir, `${key}.json`), JSON.stringify(spoken ?? null));
3598
+ } catch {
3599
+ }
3600
+ }
3601
+ var TTS_POOL = 4;
3602
+ function capStep(config, ms) {
3603
+ const cap = config.timing.maxStepDuration;
3604
+ return cap && cap > 0 ? Math.min(ms, cap) : ms;
3605
+ }
1901
3606
  function estimateDurationFromText(text, wordsPerMinute = 150) {
1902
3607
  const words = text.split(/\s+/).filter((w) => w.length > 0).length;
1903
3608
  return Math.max(Math.round(words / wordsPerMinute * 60 * 1e3), 1500);
1904
3609
  }
1905
- function resolveVoice(config) {
1906
- if (config.audio?.voice) return config.audio.voice;
1907
- if (config.lang) return WIDE_VOICES[config.lang] || null;
3610
+ function resolveVoice2(config) {
3611
+ const named = config.audio?.voice;
3612
+ if (named === INTERNATIONAL_VOICE) {
3613
+ return internationalVoiceFor(config.lang ?? "en", config.audio?.gender);
3614
+ }
3615
+ if (named) return named;
3616
+ if (config.lang) return voiceFor(config.lang, config.audio?.gender);
1908
3617
  return null;
1909
3618
  }
3619
+ function resolveRate(config) {
3620
+ return config.audio?.rate ?? "normal";
3621
+ }
3622
+ var ONES = [
3623
+ "zero",
3624
+ "one",
3625
+ "two",
3626
+ "three",
3627
+ "four",
3628
+ "five",
3629
+ "six",
3630
+ "seven",
3631
+ "eight",
3632
+ "nine",
3633
+ "ten",
3634
+ "eleven",
3635
+ "twelve",
3636
+ "thirteen",
3637
+ "fourteen",
3638
+ "fifteen",
3639
+ "sixteen",
3640
+ "seventeen",
3641
+ "eighteen",
3642
+ "nineteen"
3643
+ ];
3644
+ var TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
3645
+ function speakInteger(n) {
3646
+ if (!Number.isInteger(n) || n < 0 || n > 999) return null;
3647
+ if (n < 20) return ONES[n];
3648
+ if (n < 100) {
3649
+ const t = TENS[Math.floor(n / 10)];
3650
+ const r2 = n % 10;
3651
+ return r2 ? `${t}-${ONES[r2]}` : t;
3652
+ }
3653
+ const h = `${ONES[Math.floor(n / 100)]} hundred`;
3654
+ const r = n % 100;
3655
+ return r ? `${h} and ${speakInteger(r)}` : h;
3656
+ }
3657
+ function speakNumbers(text) {
3658
+ return text.replace(/(\d+)\s*%/g, (m, d) => {
3659
+ const spoken = speakInteger(Number(d));
3660
+ return spoken ? `${spoken} percent` : m;
3661
+ }).replace(/(?<![\d.,£$€])\b(\d{1,3})\b(?![\d.,%])/g, (m, d) => speakInteger(Number(d)) ?? m);
3662
+ }
3663
+ var SPELLED_OUT = /\b([A-Z]{2,5})\b/g;
3664
+ var LETTER_SOUNDS = {
3665
+ A: "ay",
3666
+ B: "bee",
3667
+ C: "see",
3668
+ D: "dee",
3669
+ E: "ee",
3670
+ F: "ef",
3671
+ G: "gee",
3672
+ H: "aitch",
3673
+ I: "eye",
3674
+ J: "jay",
3675
+ K: "kay",
3676
+ L: "el",
3677
+ M: "em",
3678
+ N: "en",
3679
+ O: "oh",
3680
+ P: "pee",
3681
+ Q: "cue",
3682
+ R: "ar",
3683
+ S: "ess",
3684
+ T: "tee",
3685
+ U: "you",
3686
+ V: "vee",
3687
+ W: "double-you",
3688
+ X: "ex",
3689
+ Y: "why",
3690
+ Z: "zed"
3691
+ };
3692
+ function spellAcronyms(text) {
3693
+ return text.replace(
3694
+ SPELLED_OUT,
3695
+ (m) => m.split("").map((c) => LETTER_SOUNDS[c] ?? c).join(" ")
3696
+ );
3697
+ }
3698
+ function bindDomains(text) {
3699
+ return text.replace(/\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi, "$1-dot-$2");
3700
+ }
3701
+ function speakSymbols(text) {
3702
+ return text.replace(/\s*&\s*/g, " and ").replace(/[<>]/g, " ").replace(/\s{2,}/g, " ").trim();
3703
+ }
1910
3704
  async function generateVoiceover(steps, config, workDir, outputDir, logError, task, info) {
1911
- const voice = resolveVoice(config);
3705
+ resetVoiceEngine();
3706
+ const voice = resolveVoice2(config);
3707
+ const rate = resolveRate(config);
1912
3708
  if (!voice) {
1913
3709
  const stepAudio2 = /* @__PURE__ */ new Map();
1914
3710
  for (let i = 0; i < steps.length; i++) {
@@ -1917,72 +3713,105 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError, ta
1917
3713
  const actionDuration = estimateActionDuration(step);
1918
3714
  const manualDuration = (step.duration ?? 0) * 1e3;
1919
3715
  const mediaDuration = await getMediaStepDuration(step, outputDir);
1920
- const duration = Math.max(
1921
- textDuration,
1922
- actionDuration,
1923
- manualDuration,
1924
- mediaDuration,
1925
- config.timing.minStepDuration
3716
+ const duration = capStep(
3717
+ config,
3718
+ Math.max(textDuration, actionDuration, manualDuration, mediaDuration, config.timing.minStepDuration)
1926
3719
  );
1927
3720
  stepAudio2.set(i + 1, { duration, words: step.caption ? estimateWordTimings(step.caption, duration) : [] });
1928
3721
  }
1929
3722
  info("No voice configured");
1930
3723
  return { voiceoverPath: "", stepAudio: stepAudio2 };
1931
3724
  }
1932
- const audioDir = join5(workDir, "audio");
3725
+ const audioDir = join6(workDir, "audio");
1933
3726
  await mkdir(audioDir, { recursive: true });
1934
- const segments = [];
1935
- const stepAudio = /* @__PURE__ */ new Map();
1936
3727
  const ttsTask = task("Generating voiceover");
1937
- for (let idx = 0; idx < steps.length; idx++) {
3728
+ const speed = config.audio?.speed ?? 1;
3729
+ const factor = speed * RATE_FACTOR[rate];
3730
+ const ratePercent = Math.abs(factor - 1) < 5e-3 ? void 0 : `${factor > 1 ? "+" : ""}${Math.round((factor - 1) * 100)}%`;
3731
+ const perStep = new Array(steps.length);
3732
+ const buildStep = async (idx) => {
1938
3733
  const step = steps[idx];
1939
3734
  const stepNum = idx + 1;
1940
3735
  if (!step.caption) {
1941
3736
  const actionDuration = estimateActionDuration(step);
1942
3737
  const manualDuration = (step.duration ?? 0) * 1e3;
1943
3738
  const mediaDuration = await getMediaStepDuration(step, outputDir);
1944
- const silenceDuration = Math.max(actionDuration, manualDuration, mediaDuration, config.timing.minStepDuration);
1945
- const silencePath = join5(audioDir, `step-${stepNum}-silence.mp3`);
3739
+ const silenceDuration = capStep(
3740
+ config,
3741
+ Math.max(actionDuration, manualDuration, mediaDuration, config.timing.minStepDuration)
3742
+ );
3743
+ const silencePath = join6(audioDir, `step-${stepNum}-silence.mp3`);
1946
3744
  await generateSilence(silencePath, silenceDuration);
1947
- segments.push(silencePath);
1948
- stepAudio.set(stepNum, { duration: silenceDuration, words: [] });
1949
- continue;
3745
+ perStep[idx] = { segments: [silencePath], audio: { duration: silenceDuration, words: [] } };
3746
+ return;
1950
3747
  }
1951
- const audioPath = join5(audioDir, `step-${stepNum}.mp3`);
1952
- if (segments.length > 0) await new Promise((r) => setTimeout(r, 200));
3748
+ const audioPath = join6(audioDir, `step-${stepNum}.mp3`);
1953
3749
  try {
1954
- await synthesizeSpeech(step.caption, audioPath, voice);
1955
- segments.push(audioPath);
3750
+ const spokenText = speakNumbers(spellAcronyms(toSpokenForm(bindDomains(speakSymbols(step.caption)))));
3751
+ const key = clipKey(spokenText, voice, ratePercent ?? "");
3752
+ const cacheDir = join6(outputDir, ".tts-clips");
3753
+ const cached = await reuseClip(cacheDir, key, audioPath);
3754
+ let spoken;
3755
+ if (cached) {
3756
+ spoken = cached;
3757
+ } else {
3758
+ spoken = await synthesizeSpeech(spokenText, audioPath, voice, ratePercent);
3759
+ await trimEdgeSilence(audioPath).catch(() => {
3760
+ });
3761
+ await storeClip(cacheDir, key, audioPath, spoken);
3762
+ }
3763
+ const clips = [audioPath];
1956
3764
  const audioDuration = await getMediaDuration(audioPath);
1957
3765
  const actionDuration = estimateActionDuration(step);
1958
3766
  const manualDuration = (step.duration ?? 0) * 1e3;
1959
3767
  const mediaDuration = await getMediaStepDuration(step, outputDir);
1960
- const stepDuration = Math.max(
1961
- audioDuration,
1962
- config.timing.minStepDuration,
1963
- actionDuration,
1964
- manualDuration,
1965
- mediaDuration
3768
+ const stepDuration = capStep(
3769
+ config,
3770
+ Math.max(audioDuration, config.timing.minStepDuration, actionDuration, manualDuration, mediaDuration)
1966
3771
  );
1967
3772
  if (stepDuration > audioDuration) {
1968
- const paddingPath = join5(audioDir, `step-${stepNum}-padding.mp3`);
3773
+ const paddingPath = join6(audioDir, `step-${stepNum}-padding.mp3`);
1969
3774
  await generateSilence(paddingPath, stepDuration - audioDuration);
1970
- segments.push(paddingPath);
3775
+ clips.push(paddingPath);
1971
3776
  }
1972
- stepAudio.set(stepNum, { duration: stepDuration, words: estimateWordTimings(step.caption, audioDuration) });
3777
+ const words = (config.audio?.transcribe === false ? null : spokenWordTimings(step.caption, spoken)) ?? estimateWordTimings(step.caption, audioDuration);
3778
+ perStep[idx] = { segments: clips, audio: { duration: stepDuration, words } };
1973
3779
  } catch (err) {
1974
3780
  await logError(`TTS Step ${stepNum}`, err);
1975
- stepAudio.set(stepNum, { duration: config.timing.minStepDuration, words: [] });
3781
+ perStep[idx] = { segments: [], audio: { duration: config.timing.minStepDuration, words: [] } };
1976
3782
  }
3783
+ };
3784
+ await runPooled(steps.length, TTS_POOL, buildStep);
3785
+ const segments = [];
3786
+ const stepAudio = /* @__PURE__ */ new Map();
3787
+ for (let idx = 0; idx < steps.length; idx++) {
3788
+ const built = perStep[idx];
3789
+ if (!built) continue;
3790
+ segments.push(...built.segments);
3791
+ stepAudio.set(idx + 1, built.audio);
1977
3792
  }
1978
3793
  const stepsWithCaptions = steps.filter((s) => s.caption).length;
1979
3794
  ttsTask.succeed(`${segments.length}/${stepsWithCaptions} clips`);
1980
3795
  if (segments.length === 0) {
1981
3796
  return { voiceoverPath: "", stepAudio };
1982
3797
  }
3798
+ const leadInPath = join6(audioDir, LEAD_IN_CLIP);
3799
+ await generateSilence(leadInPath, NARRATION_LEAD_IN_MS);
3800
+ segments.unshift(leadInPath);
3801
+ const firstStep = stepAudio.get(1);
3802
+ if (firstStep) {
3803
+ stepAudio.set(1, {
3804
+ duration: firstStep.duration + NARRATION_LEAD_IN_MS,
3805
+ words: firstStep.words.map((w) => ({
3806
+ ...w,
3807
+ start: w.start + NARRATION_LEAD_IN_MS,
3808
+ end: w.end + NARRATION_LEAD_IN_MS
3809
+ }))
3810
+ });
3811
+ }
1983
3812
  const concatTask = task("Combining audio");
1984
3813
  await mkdir(outputDir, { recursive: true });
1985
- const voiceoverPath = join5(outputDir, "voiceover.wav");
3814
+ const voiceoverPath = join6(outputDir, "voiceover.wav");
1986
3815
  await concatenateAudio(segments, voiceoverPath, workDir);
1987
3816
  concatTask.succeed("Done");
1988
3817
  const meta = {
@@ -1990,7 +3819,11 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError, ta
1990
3819
  totalCaptionLength: steps.reduce((sum, s) => sum + (s.caption?.length || 0), 0),
1991
3820
  stepCount: steps.length,
1992
3821
  voice,
1993
- stepDurations: Object.fromEntries(Array.from(stepAudio.entries()).map(([k, v]) => [k, v.duration]))
3822
+ rate,
3823
+ stepDurations: Object.fromEntries(Array.from(stepAudio.entries()).map(([k, v]) => [k, v.duration])),
3824
+ stepWords: Object.fromEntries(
3825
+ Array.from(stepAudio.entries()).filter(([, v]) => v.words.length > 0).map(([k, v]) => [k, v.words])
3826
+ )
1994
3827
  };
1995
3828
  await saveVoiceoverMeta(outputDir, meta);
1996
3829
  return { voiceoverPath, stepAudio };
@@ -2010,16 +3843,37 @@ export {
2010
3843
  CURSOR_SCRIPT,
2011
3844
  DEFAULT_CAPTIONS,
2012
3845
  DEFAULT_CONFIG,
3846
+ EMPTY_SPAN_SPEED,
2013
3847
  EXCLUDED_LINK_PATTERNS,
2014
- FEATURES,
3848
+ GIF_DEFAULTS,
2015
3849
  INTERACTIVE_SELECTORS,
2016
- WIDE_VOICES as LANGUAGE_VOICES,
3850
+ INTERNATIONAL_VOICE,
3851
+ MALE_VOICES2 as MALE_VOICES,
3852
+ MULTILINGUAL_VOICES2 as MULTILINGUAL_VOICES,
3853
+ MUSIC_BED_LUFS,
3854
+ NARRATION_LEAD_IN_MS,
3855
+ NO_CLIP_OUTRO,
3856
+ PLAN_MAX_TOKENS,
3857
+ RATE_FACTOR,
2017
3858
  SERVER_BACKOFF_MS,
3859
+ SIZE_PRESETS,
3860
+ TRIM_EDGE_SILENCE,
2018
3861
  VERSION,
3862
+ VIDEO_PROFILES,
3863
+ VOICE_LEAD_MS,
3864
+ WIDE_VOICES2 as WIDE_VOICES,
2019
3865
  alignVoiceoverToRecording,
3866
+ alignWordsToCaption,
3867
+ applyProfile,
2020
3868
  applyTransitions,
3869
+ ariaLabelSelector,
3870
+ atempoChain,
3871
+ buildCompressionFilter,
2021
3872
  buildConcatArgs,
3873
+ buildGifArgs,
2022
3874
  buildKeepSegments,
3875
+ buildPaletteArgs,
3876
+ buildRetimeFilter,
2023
3877
  buildStepTransitions,
2024
3878
  buildSystemPrompt,
2025
3879
  buildUserPrompt,
@@ -2027,10 +3881,22 @@ export {
2027
3881
  callAI,
2028
3882
  callAIViaRelay,
2029
3883
  canReuseVoiceover,
3884
+ captionCanvas,
2030
3885
  composeVideo,
3886
+ compressSilentSpans,
2031
3887
  concatMedia,
3888
+ concatVideoWithFullFrameOutro,
2032
3889
  concatVideoWithOutro,
3890
+ convertToGif,
3891
+ coverFilterChain,
2033
3892
  crawlPage,
3893
+ cutSpansFromVideo,
3894
+ deStock,
3895
+ defaultCaptionsFor,
3896
+ detectBlackSpans,
3897
+ detectFrozenSpans,
3898
+ detectSilences,
3899
+ detectWhiteSpans,
2034
3900
  estimateWordTimings,
2035
3901
  extractJsonCandidates,
2036
3902
  generateASSHeader,
@@ -2044,10 +3910,16 @@ export {
2044
3910
  getStatusCached,
2045
3911
  getTierByEmail,
2046
3912
  getUpgradeUrl,
2047
- hasAccess,
3913
+ getVideoDimensions,
3914
+ getVideoStreamDuration,
3915
+ gotoSettled,
3916
+ hasAudioStream,
2048
3917
  hasTrialRemaining,
3918
+ hasVoice,
2049
3919
  hexToAss as hexToASS,
2050
3920
  idSelector,
3921
+ internationalVoiceFor,
3922
+ intersectSpans,
2051
3923
  isCanceling,
2052
3924
  isExcludedLink,
2053
3925
  isHonestWav,
@@ -2056,22 +3928,39 @@ export {
2056
3928
  loadExistingVoiceover,
2057
3929
  loadVoiceoverMeta,
2058
3930
  mapTimestampsAfterCompression,
3931
+ mergeSpans,
2059
3932
  normalizeUrl,
2060
3933
  parseAIJson,
2061
3934
  parseAIPlanResponse,
2062
3935
  parsePartial,
3936
+ parseWordBoundaries,
3937
+ positionToAlignment,
2063
3938
  pricingUrl,
2064
3939
  registerFreeUser,
3940
+ renderCaptionsAss,
2065
3941
  repairUnicodeEscapes,
2066
- resolveVoice,
3942
+ resetVoiceEngine,
3943
+ resolveCaptionFont,
3944
+ resolveCaptions,
3945
+ resolveRate,
3946
+ resolveVoice2 as resolveVoice,
3947
+ retimeSpansInVideo,
2067
3948
  rgbaToHex,
3949
+ runPooled,
2068
3950
  salvageTruncatedJson,
2069
3951
  shouldSkipLink,
2070
3952
  shouldWatermark,
2071
3953
  showPaywall,
3954
+ speakSymbols,
3955
+ speedUpMiddle,
2072
3956
  speedUpVideo,
2073
3957
  spliceVideos,
3958
+ spokenWordTimings,
3959
+ stripLongDashes,
2074
3960
  synthesizeSpeech,
2075
3961
  toTitleCase,
3962
+ trimEdgeSilence,
3963
+ voiceFor,
3964
+ waitForPlaceholdersGone,
2076
3965
  windowsDrivePathToWsl as windowsPathToWSL
2077
3966
  };