@spark-apps/quickpeek 1.2.3 → 1.2.5

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,170 @@ 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
+ // Sized for the outro window: the song has 5.5s, so a 2s fade at each end
438
+ // left 1.5s of music anyone could actually hear. Up fast, hold, out slow.
439
+ musicFadeIn = 1,
440
+ musicFadeOut = 1.5,
441
+ tempo = 1,
442
+ musicStartSecs = 0,
443
+ musicDurationSecs = 0,
444
+ width,
445
+ height,
446
+ fps,
447
+ preset,
448
+ quality,
449
+ fadeIn,
450
+ fadeOut,
451
+ videoDuration,
452
+ audioBitrate = "192k",
453
+ contrast = 0,
454
+ trimStart = 0,
455
+ userTier = "free",
456
+ endsOnCreditsCard = false
457
+ } = opts;
458
+ const crf = Math.round(28 - quality * 10);
459
+ const encoder = await resolveEncoder({
460
+ crf,
461
+ preset,
462
+ quality: Math.max(16, crf - 4),
463
+ allowHardware: opts.hwaccel !== "off",
464
+ // The race is only meaningful at the size actually being rendered: the
465
+ // GPU's per-frame upload is a fixed cost that a draft's ninth of the
466
+ // pixels cannot amortise.
467
+ width,
468
+ height
469
+ });
470
+ const args = ["-y", ...filterThreadArgs(), ...encoder.input];
471
+ if (trimStart > 0) {
472
+ args.push("-ss", String(trimStart));
473
+ }
474
+ args.push("-i", videoPath);
475
+ const effectiveDuration = videoDuration - trimStart;
476
+ const voiceoverDuration = opts.mainContentDuration ?? effectiveDuration;
477
+ if (voiceoverPath) args.push("-i", voiceoverPath);
478
+ if (musicPath) args.push("-i", musicPath);
479
+ const vf = [`scale=${width}:${height}`, `fps=${fps}`];
480
+ if (contrast > 0) {
481
+ const ffmpegContrast = 1 + contrast;
482
+ vf.push(`eq=contrast=${ffmpegContrast.toFixed(2)}`);
483
+ }
484
+ const watermarkFilters = buildWatermarkFilters({
485
+ tier: userTier,
486
+ videoDuration: effectiveDuration,
487
+ endsOnCreditsCard
488
+ });
489
+ if (watermarkFilters) {
490
+ vf.push(watermarkFilters);
491
+ }
492
+ if (assPath) {
220
493
  const assPathForFilter = toWSLPath(assPath).replace(/:/g, "\\:");
221
494
  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
- });
495
+ }
496
+ if (fadeIn > 0) vf.push(`fade=t=in:st=0:d=${fadeIn}:c=black`);
497
+ if (fadeOut > 0 && !endsOnCreditsCard) {
498
+ const videoFadeOutStart = Math.max(0, effectiveDuration - fadeOut);
499
+ vf.push(`fade=t=out:st=${videoFadeOutStart}:d=${fadeOut}:c=black`);
500
+ }
501
+ const clipAudioLabel = opts.hasClipAudio ? "[0:a]" : "";
502
+ const hasVoiceover = !!voiceoverPath;
503
+ const hasMusic = !!musicPath;
504
+ const bedStart = Math.max(0, Math.min(musicStartSecs, Math.max(0, effectiveDuration - 0.5)));
505
+ const bedLength = Math.max(0.5, effectiveDuration - bedStart);
506
+ const bedDelay = bedStart > 0 ? `adelay=${Math.round(bedStart * 1e3)}|${Math.round(bedStart * 1e3)},` : "";
507
+ const bedTempo = tempo > 1 ? 1 / tempo : 1;
508
+ const bedSlow = bedTempo === 1 ? "" : `atempo=${bedTempo.toFixed(6)},`;
509
+ const bedSource = bedLength * bedTempo;
510
+ const bedSeek = musicDurationSecs > bedSource + 0.05 ? musicDurationSecs - bedSource : 0;
511
+ const bedTrimIn = bedSeek > 0 ? `atrim=start=${bedSeek.toFixed(3)},asetpts=PTS-STARTPTS,` : "";
512
+ const bedFadeOut = bedSeek > 0 ? 0 : musicFadeOut * tempo;
513
+ const bedLoop = bedSeek > 0 ? "" : "aloop=loop=-1:size=2e+09,";
514
+ const bedFadeIn = musicFadeIn * tempo;
515
+ const musicFadeOutStart = Math.max(0, bedLength - bedFadeOut);
516
+ const bedFadeOutFilter = bedFadeOut > 0 ? `afade=t=out:st=${musicFadeOutStart}:d=${bedFadeOut},` : "";
517
+ const vfStr = [...vf, ...encoder.filter ? [encoder.filter] : []].join(",");
518
+ if (hasVoiceover && hasMusic) {
519
+ const mixInputs = clipAudioLabel ? 3 : 2;
520
+ const complexFilter = [
521
+ // Voiceover: fade in only (no fade out - stays clear until end). Split,
522
+ // because one copy is mixed and the other is only listened to, by the
523
+ // compressor that pulls the bed down under it.
524
+ `[1:a]adelay=${VOICE_LEAD_MS}|${VOICE_LEAD_MS},apad=whole_dur=${voiceoverDuration},asplit=2[voice][voicekey0]`,
525
+ // The detector's copy runs the length of the PICTURE, not the length of
526
+ // the narration. sidechaincompress is a framesync filter and ends at its
527
+ // shorter input, so a key that stops when the voice does takes the bed
528
+ // with it - and the stretch it would have taken is exactly the closing
529
+ // card, which is the one place the bed is the only thing playing.
530
+ `[voicekey0]apad=whole_dur=${effectiveDuration}[voicekey]`,
531
+ // Music: normalised to a known level, trimmed, then faded.
532
+ `[2:a]${bedTrimIn}${bedLoop}${bedSlow}atrim=0:${bedLength},loudnorm=I=${musicTargetLufs}:TP=-9:LRA=11,volume=${musicVolume},afade=t=in:st=0:d=${bedFadeIn},${bedFadeOutFilter}${bedDelay}apad=whole_dur=${effectiveDuration}[bed]`,
533
+ `[bed][voicekey]${DUCK}[music]`,
534
+ // normalize=0, because amix's default divides every input by the number
535
+ // of them: adding the bed used to quietly take 6 dB off the narration,
536
+ // and the master loudnorm then put it back by turning everything up,
537
+ // bed included. Levels set deliberately upstream should survive the mix.
538
+ //
539
+ // Normalise the MIX and hard-limit its true peak - ported from
540
+ // VidLet, where single-pass loudnorm alone measured +0.9 dBTP on
541
+ // sharp TTS consonants. -14 LUFS is the YouTube/IG/TikTok playback
542
+ // target, so the platform leaves the audio untouched.
543
+ `[voice][music]${clipAudioLabel}amix=inputs=${mixInputs}:duration=longest:normalize=0,${MASTER_CHAIN}[aout]`
544
+ ].join(";");
545
+ args.push("-vf", vfStr);
546
+ args.push("-filter_complex", complexFilter, "-map", "0:v", "-map", "[aout]");
547
+ args.push(...encoder.output);
548
+ args.push("-c:a", "aac", "-b:a", audioBitrate);
549
+ } else if (hasVoiceover) {
550
+ const af = `adelay=${VOICE_LEAD_MS}|${VOICE_LEAD_MS},apad=whole_dur=${voiceoverDuration},${MASTER_CHAIN}`;
551
+ args.push("-vf", vfStr);
552
+ args.push("-map", "0:v", "-map", "1:a", "-af", af);
553
+ args.push(...encoder.output);
554
+ args.push("-c:a", "aac", "-b:a", audioBitrate);
555
+ } else if (hasMusic) {
556
+ const musicFilter = `${bedTrimIn}${bedLoop}${bedSlow}atrim=0:${bedLength},volume=${musicVolume},afade=t=in:st=0:d=${bedFadeIn},${bedFadeOutFilter}${bedDelay}apad=whole_dur=${effectiveDuration},${MASTER_CHAIN}`;
557
+ args.push("-vf", vfStr);
558
+ args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
559
+ args.push(...encoder.output);
560
+ args.push("-c:a", "aac", "-b:a", "192k");
561
+ } else {
562
+ args.push("-vf", vfStr);
563
+ args.push(...encoder.output);
564
+ }
565
+ args.push("-movflags", "+faststart");
566
+ args.push(outputPath);
567
+ await runFFmpeg(args, "ffmpeg compose failed");
267
568
  }
569
+ var VOICE_LEAD_MS, MUSIC_BED_LUFS, DUCK;
268
570
  var init_compose = __esm({
269
571
  "src/lib/ffmpeg/compose.ts"() {
270
572
  "use strict";
271
573
  init_watermark();
574
+ init_hwaccel();
272
575
  init_spawn();
576
+ VOICE_LEAD_MS = 200;
577
+ MUSIC_BED_LUFS = -34;
578
+ DUCK = "sidechaincompress=threshold=0.05:ratio=8:attack=20:release=420:level_sc=4:makeup=1";
273
579
  }
274
580
  });
275
581
 
@@ -315,8 +621,130 @@ var init_compress = __esm({
315
621
  }
316
622
  });
317
623
 
624
+ // src/lib/ffmpeg/gif.ts
625
+ import { unlink } from "fs/promises";
626
+ import { tmpdir } from "os";
627
+ import { join } from "path";
628
+ function buildPaletteArgs(input, palettePath, fps, width, statsMode) {
629
+ return [
630
+ "-y",
631
+ "-i",
632
+ input,
633
+ "-vf",
634
+ `fps=${fps},scale=${width}:-1:flags=lanczos,palettegen=stats_mode=${statsMode}`,
635
+ palettePath
636
+ ];
637
+ }
638
+ function buildGifArgs(input, palettePath, output, fps, width, dither) {
639
+ return [
640
+ "-y",
641
+ "-i",
642
+ input,
643
+ "-i",
644
+ palettePath,
645
+ "-lavfi",
646
+ `fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=${dither}`,
647
+ output
648
+ ];
649
+ }
650
+ async function convertToGif(options) {
651
+ const { input, output } = options;
652
+ const fps = options.fps ?? GIF_DEFAULTS.fps;
653
+ const width = options.width ?? GIF_DEFAULTS.width;
654
+ const dither = options.dither ?? GIF_DEFAULTS.dither;
655
+ const statsMode = options.statsMode ?? GIF_DEFAULTS.statsMode;
656
+ const palettePath = join(tmpdir(), `quickpeek_palette_${Date.now()}.png`);
657
+ try {
658
+ await runFFmpeg(buildPaletteArgs(input, palettePath, fps, width, statsMode), "GIF palette generation failed");
659
+ await runFFmpeg(buildGifArgs(input, palettePath, output, fps, width, dither), "GIF conversion failed");
660
+ return output;
661
+ } finally {
662
+ try {
663
+ await unlink(palettePath);
664
+ } catch {
665
+ }
666
+ }
667
+ }
668
+ var GIF_DEFAULTS;
669
+ var init_gif = __esm({
670
+ "src/lib/ffmpeg/gif.ts"() {
671
+ "use strict";
672
+ init_spawn();
673
+ GIF_DEFAULTS = { fps: 15, width: 480, dither: "sierra2_4a", statsMode: "full" };
674
+ }
675
+ });
676
+
318
677
  // src/lib/ffmpeg/media.ts
678
+ import { statSync } from "fs";
679
+ function cacheKey(filePath) {
680
+ try {
681
+ const { size, mtimeMs } = statSync(filePath);
682
+ return `${filePath}:${size}:${mtimeMs}`;
683
+ } catch {
684
+ return null;
685
+ }
686
+ }
687
+ function getVideoDimensions(filePath) {
688
+ return new Promise((resolve3, reject) => {
689
+ const args = [
690
+ "-v",
691
+ "error",
692
+ "-select_streams",
693
+ "v:0",
694
+ "-show_entries",
695
+ "stream=width,height",
696
+ "-of",
697
+ "csv=p=0",
698
+ filePath
699
+ ];
700
+ const proc = spawnFFmpeg("ffprobe", args);
701
+ let stdout = "";
702
+ let stderr = "";
703
+ proc.stdout.on("data", (d) => {
704
+ stdout += d;
705
+ });
706
+ proc.stderr.on("data", (d) => {
707
+ stderr += d;
708
+ });
709
+ proc.on("close", (code) => {
710
+ if (code !== 0) {
711
+ reject(new Error(`ffprobe failed: ${stderr}`));
712
+ return;
713
+ }
714
+ const [w, h] = stdout.trim().split(",").map(Number);
715
+ if (!w || !h) {
716
+ reject(new Error(`ffprobe returned no dimensions for ${filePath}`));
717
+ return;
718
+ }
719
+ resolve3({ width: w, height: h });
720
+ });
721
+ });
722
+ }
723
+ function hasAudioStream(filePath) {
724
+ return new Promise((resolve3) => {
725
+ const proc = spawnFFmpeg("ffprobe", [
726
+ "-v",
727
+ "error",
728
+ "-select_streams",
729
+ "a:0",
730
+ "-show_entries",
731
+ "stream=codec_type",
732
+ "-of",
733
+ "csv=p=0",
734
+ filePath
735
+ ]);
736
+ let out = "";
737
+ proc.stdout.on("data", (d) => {
738
+ out += d;
739
+ });
740
+ proc.on("close", () => resolve3(out.trim().length > 0));
741
+ proc.on("error", () => resolve3(false));
742
+ });
743
+ }
319
744
  function getMediaDuration(filePath) {
745
+ const key = cacheKey(filePath);
746
+ const cached = key === null ? void 0 : durationCache.get(key);
747
+ if (cached !== void 0) return Promise.resolve(cached);
320
748
  return new Promise((resolve3, reject) => {
321
749
  const args = [
322
750
  "-v",
@@ -341,8 +769,10 @@ function getMediaDuration(filePath) {
341
769
  reject(new Error(`ffprobe failed: ${stderr}`));
342
770
  return;
343
771
  }
344
- const duration = parseFloat(stdout.trim());
345
- resolve3(Math.ceil(duration * 1e3));
772
+ const ms = Math.ceil(parseFloat(stdout.trim()) * 1e3);
773
+ const freshKey = cacheKey(filePath);
774
+ if (freshKey !== null && Number.isFinite(ms)) durationCache.set(freshKey, ms);
775
+ resolve3(ms);
346
776
  });
347
777
  proc.on("error", reject);
348
778
  });
@@ -381,6 +811,12 @@ function buildConcatArgs(listPath, output) {
381
811
  function concatMedia(listPath, output) {
382
812
  return runFFmpeg(buildConcatArgs(listPath, output), "ffmpeg concat failed");
383
813
  }
814
+ async function trimEdgeSilence(filePath) {
815
+ const trimmed = filePath.replace(/\.mp3$/i, "-trim.mp3");
816
+ await runFFmpeg(["-y", "-i", filePath, "-af", TRIM_EDGE_SILENCE, trimmed], "ffmpeg silence trim failed");
817
+ const { rename } = await import("fs/promises");
818
+ await rename(trimmed, filePath);
819
+ }
384
820
  function generateSilence(outputPath, durationMs) {
385
821
  return runFFmpeg(
386
822
  [
@@ -411,7 +847,23 @@ function speedUpVideo(opts) {
411
847
  remaining /= tempo;
412
848
  }
413
849
  return runFFmpeg(
414
- ["-y", "-i", inputPath, "-vf", videoFilter, "-af", audioFilters.join(","), "-preset", preset, outputPath],
850
+ // faststart for the same reason as compose: this pass can be the last
851
+ // thing to touch the delivered file, and a moov atom written after the
852
+ // payload makes it unplayable in a browser preview.
853
+ [
854
+ "-y",
855
+ "-i",
856
+ inputPath,
857
+ "-vf",
858
+ videoFilter,
859
+ "-af",
860
+ audioFilters.join(","),
861
+ "-preset",
862
+ preset,
863
+ "-movflags",
864
+ "+faststart",
865
+ outputPath
866
+ ],
415
867
  "ffmpeg speedup failed"
416
868
  );
417
869
  }
@@ -434,43 +886,581 @@ function concatVideoWithOutro(opts) {
434
886
  "-y",
435
887
  "-i",
436
888
  inputPath,
437
- "-i",
438
- outroPath,
889
+ "-i",
890
+ outroPath,
891
+ "-filter_complex",
892
+ filterComplex,
893
+ "-map",
894
+ "[outv]",
895
+ "-c:v",
896
+ "libx264",
897
+ "-preset",
898
+ preset,
899
+ "-crf",
900
+ "18",
901
+ "-pix_fmt",
902
+ "yuv420p",
903
+ "-an",
904
+ outputPath
905
+ ],
906
+ "ffmpeg concat video failed"
907
+ );
908
+ }
909
+ function concatVideoWithFullFrameOutro(opts) {
910
+ const { inputPath, outroPath, outputPath, width, height, preset } = opts;
911
+ const filterComplex = [
912
+ `[0:v]scale=${width}:${height},setsar=1,fps=30[v0]`,
913
+ `[1:v]scale=${width}:${height},setsar=1,fps=30[v1]`,
914
+ "[v0][v1]concat=n=2:v=1:a=0[outv]"
915
+ ].join(";");
916
+ return runFFmpeg(
917
+ [
918
+ "-y",
919
+ "-i",
920
+ inputPath,
921
+ "-i",
922
+ outroPath,
923
+ "-filter_complex",
924
+ filterComplex,
925
+ "-map",
926
+ "[outv]",
927
+ "-c:v",
928
+ "libx264",
929
+ "-preset",
930
+ preset,
931
+ "-crf",
932
+ "18",
933
+ "-pix_fmt",
934
+ "yuv420p",
935
+ "-an",
936
+ outputPath
937
+ ],
938
+ "ffmpeg full-frame outro concat failed"
939
+ );
940
+ }
941
+ var durationCache, TRIM_EDGE_SILENCE;
942
+ var init_media = __esm({
943
+ "src/lib/ffmpeg/media.ts"() {
944
+ "use strict";
945
+ init_spawn();
946
+ durationCache = /* @__PURE__ */ new Map();
947
+ 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";
948
+ }
949
+ });
950
+
951
+ // src/lib/ffmpeg/silence.ts
952
+ function getVideoStreamDuration(inputPath) {
953
+ return new Promise((resolve3) => {
954
+ const proc = spawnFFmpeg("ffprobe", [
955
+ "-v",
956
+ "error",
957
+ "-select_streams",
958
+ "v:0",
959
+ "-show_entries",
960
+ "stream=duration",
961
+ "-of",
962
+ "csv=p=0",
963
+ inputPath
964
+ ]);
965
+ proc.stderr?.resume();
966
+ let out = "";
967
+ proc.stdout.on("data", (d) => {
968
+ out += d;
969
+ });
970
+ proc.on("error", () => resolve3(0));
971
+ proc.on("close", () => resolve3(Number.parseFloat(out.trim()) || 0));
972
+ });
973
+ }
974
+ function detectSilences(inputPath, minDurSecs) {
975
+ return new Promise((resolve3) => {
976
+ const proc = spawnFFmpeg("ffmpeg", [
977
+ "-hide_banner",
978
+ "-nostats",
979
+ // Audio-only analysis: without -vn ffmpeg's default stream selection
980
+ // decodes the video too, for a filter that cannot see it.
981
+ "-vn",
982
+ "-i",
983
+ inputPath,
984
+ "-af",
985
+ `silencedetect=noise=-40dB:d=${minDurSecs}`,
986
+ "-f",
987
+ "null",
988
+ "-"
989
+ ]);
990
+ let err = "";
991
+ proc.stderr.on("data", (d) => {
992
+ err += d;
993
+ });
994
+ proc.on("error", () => resolve3([]));
995
+ proc.on("close", () => {
996
+ const spans = [];
997
+ let start = null;
998
+ for (const line of err.split("\n")) {
999
+ const s = line.match(/silence_start:\s*(-?[\d.]+)/);
1000
+ if (s?.[1]) start = Math.max(0, Number.parseFloat(s[1]));
1001
+ const e = line.match(/silence_end:\s*([\d.]+)/);
1002
+ if (e?.[1] && start !== null) {
1003
+ const from = start + SPEECH_GUARD_SECS;
1004
+ const to = Number.parseFloat(e[1]) - SPEECH_GUARD_SECS;
1005
+ if (to > from) spans.push({ start: from, end: to });
1006
+ start = null;
1007
+ }
1008
+ }
1009
+ resolve3(spans);
1010
+ });
1011
+ });
1012
+ }
1013
+ function detectFrozenSpans(inputPath, minDurSecs) {
1014
+ return new Promise((resolve3) => {
1015
+ const proc = spawnFFmpeg("ffmpeg", [
1016
+ "-hide_banner",
1017
+ "-nostats",
1018
+ // The mirror of detectSilences: a video-only filter, so skip the audio.
1019
+ "-an",
1020
+ "-sn",
1021
+ "-i",
1022
+ inputPath,
1023
+ "-vf",
1024
+ // A generous noise floor: video is lossy, so a "still" frame still
1025
+ // differs from the last one by a little.
1026
+ `freezedetect=n=-55dB:d=${minDurSecs}`,
1027
+ "-f",
1028
+ "null",
1029
+ "-"
1030
+ ]);
1031
+ let err = "";
1032
+ proc.stderr.on("data", (d) => {
1033
+ err += d;
1034
+ });
1035
+ proc.on("error", () => resolve3([]));
1036
+ proc.on("close", () => {
1037
+ const spans = [];
1038
+ let start = null;
1039
+ for (const line of err.split("\n")) {
1040
+ const s = line.match(/freeze_start:\s*(-?[\d.]+)/);
1041
+ if (s?.[1]) start = Math.max(0, Number.parseFloat(s[1]));
1042
+ const e = line.match(/freeze_end:\s*([\d.]+)/);
1043
+ if (e?.[1] && start !== null) {
1044
+ spans.push({ start, end: Number.parseFloat(e[1]) });
1045
+ start = null;
1046
+ }
1047
+ }
1048
+ resolve3(spans);
1049
+ });
1050
+ });
1051
+ }
1052
+ function detectBlackSpans(inputPath, minDurSecs) {
1053
+ return new Promise((resolve3) => {
1054
+ const proc = spawnFFmpeg("ffmpeg", [
1055
+ "-hide_banner",
1056
+ "-nostats",
1057
+ "-an",
1058
+ "-sn",
1059
+ "-i",
1060
+ inputPath,
1061
+ "-vf",
1062
+ // Strict on purpose. Loosening these to catch a lingering spinner
1063
+ // (pic_th 0.96, pix_th 0.14) cut 37 seconds out of a 95 second
1064
+ // recording: it was matching real dark-themed pages, and 80s of
1065
+ // narration was left with 58s of picture to play over. A missed second
1066
+ // of loading screen is a blemish; a false positive desyncs the video.
1067
+ `blackdetect=d=${minDurSecs}:pic_th=0.98:pix_th=0.10`,
1068
+ "-f",
1069
+ "null",
1070
+ "-"
1071
+ ]);
1072
+ let err = "";
1073
+ proc.stderr.on("data", (d) => {
1074
+ err += d;
1075
+ });
1076
+ proc.on("error", () => resolve3([]));
1077
+ proc.on("close", () => {
1078
+ const spans = [];
1079
+ for (const line of err.split("\n")) {
1080
+ const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
1081
+ if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
1082
+ }
1083
+ resolve3(spans);
1084
+ });
1085
+ });
1086
+ }
1087
+ function detectWhiteSpans(inputPath, minDurSecs) {
1088
+ return new Promise((resolve3) => {
1089
+ const proc = spawnFFmpeg("ffmpeg", [
1090
+ "-hide_banner",
1091
+ "-nostats",
1092
+ "-an",
1093
+ "-sn",
1094
+ "-i",
1095
+ inputPath,
1096
+ "-vf",
1097
+ `negate,blackdetect=d=${minDurSecs}:pic_th=0.98:pix_th=0.10`,
1098
+ "-f",
1099
+ "null",
1100
+ "-"
1101
+ ]);
1102
+ let err = "";
1103
+ proc.stderr.on("data", (d) => {
1104
+ err += d;
1105
+ });
1106
+ proc.on("error", () => resolve3([]));
1107
+ proc.on("close", () => {
1108
+ const spans = [];
1109
+ for (const line of err.split("\n")) {
1110
+ const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
1111
+ if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
1112
+ }
1113
+ resolve3(spans);
1114
+ });
1115
+ });
1116
+ }
1117
+ function intersectSpans(a, b) {
1118
+ const out = [];
1119
+ for (const x of a) {
1120
+ for (const y of b) {
1121
+ const start = Math.max(x.start, y.start);
1122
+ const end = Math.min(x.end, y.end);
1123
+ if (end > start) out.push({ start, end });
1124
+ }
1125
+ }
1126
+ return out.sort((p, q) => p.start - q.start);
1127
+ }
1128
+ function mergeSpans(spans) {
1129
+ const sorted = [...spans].sort((a, b) => a.start - b.start);
1130
+ const out = [];
1131
+ for (const span of sorted) {
1132
+ const last = out[out.length - 1];
1133
+ if (last && span.start <= last.end + 0.01) {
1134
+ last.end = Math.max(last.end, span.end);
1135
+ if (span.cap !== void 0) last.cap = Math.min(last.cap ?? span.cap, span.cap);
1136
+ } else out.push({ ...span });
1137
+ }
1138
+ return out;
1139
+ }
1140
+ function atempoChain(factor) {
1141
+ const steps = [];
1142
+ let remaining = factor;
1143
+ while (remaining > 2) {
1144
+ steps.push(2);
1145
+ remaining /= 2;
1146
+ }
1147
+ if (remaining > 1.001) steps.push(remaining);
1148
+ return steps.map((s) => `atempo=${s.toFixed(4)}`).join(",");
1149
+ }
1150
+ function buildCompressionFilter(spans, totalSecs, maxSecs, protectAfterSecs, protectBeforeSecs) {
1151
+ const limit = protectAfterSecs ?? totalSecs;
1152
+ const floor = Math.max(0, protectBeforeSecs ?? 0);
1153
+ const usable = spans.map((s) => ({
1154
+ start: Math.max(floor, s.start),
1155
+ end: Math.min(totalSecs, Math.min(s.end, limit)),
1156
+ cap: Math.min(s.cap ?? maxSecs, maxSecs)
1157
+ })).filter((s) => s.start < limit && s.end - s.start > s.cap + 0.05).sort((a, b) => a.start - b.start);
1158
+ if (usable.length === 0) return null;
1159
+ const parts = [];
1160
+ const labels = [];
1161
+ let cursor = 0;
1162
+ let n = 0;
1163
+ const keep = (from, to) => {
1164
+ if (to - from < 0.04) return;
1165
+ parts.push(`[0:v]trim=${from.toFixed(3)}:${to.toFixed(3)},setpts=PTS-STARTPTS[v${n}]`);
1166
+ parts.push(`[0:a]atrim=${from.toFixed(3)}:${to.toFixed(3)},asetpts=PTS-STARTPTS[a${n}]`);
1167
+ labels.push(`[v${n}][a${n}]`);
1168
+ n++;
1169
+ };
1170
+ for (const span of usable) {
1171
+ keep(cursor, span.start);
1172
+ const factor = (span.end - span.start) / span.cap;
1173
+ const tempo = atempoChain(factor);
1174
+ parts.push(
1175
+ `[0:v]trim=${span.start.toFixed(3)}:${span.end.toFixed(3)},setpts=(PTS-STARTPTS)/${factor.toFixed(4)}[v${n}]`
1176
+ );
1177
+ parts.push(
1178
+ `[0:a]atrim=${span.start.toFixed(3)}:${span.end.toFixed(3)},asetpts=PTS-STARTPTS${tempo ? `,${tempo}` : ""}[a${n}]`
1179
+ );
1180
+ labels.push(`[v${n}][a${n}]`);
1181
+ n++;
1182
+ cursor = span.end;
1183
+ }
1184
+ keep(cursor, totalSecs);
1185
+ if (n < 2) return null;
1186
+ return `${parts.join(";")};${labels.join("")}concat=n=${n}:v=1:a=1[outv][outa]`;
1187
+ }
1188
+ async function passEncoder(preset) {
1189
+ const encoder = await resolveEncoder({ crf: 18, preset, quality: 20 });
1190
+ return {
1191
+ input: encoder.input,
1192
+ output: encoder.output,
1193
+ filterSuffix: encoder.filter ? `;[outv]${encoder.filter}[outvhw]` : "",
1194
+ videoLabel: encoder.filter ? "[outvhw]" : "[outv]"
1195
+ };
1196
+ }
1197
+ async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
1198
+ const keep = mergeSpans(spans.map((s) => ({ ...s }))).filter((s) => s.end - s.start > 0.15);
1199
+ if (keep.length === 0) return false;
1200
+ const drop = keep.map((s) => `between(t,${s.start.toFixed(3)},${s.end.toFixed(3)})`).join("+");
1201
+ const filter = `[0:v]select='not(${drop})',setpts=N/FRAME_RATE/TB[outv]`;
1202
+ const enc = await passEncoder(preset);
1203
+ return new Promise((resolve3) => {
1204
+ const proc = spawnFFmpeg("ffmpeg", [
1205
+ "-y",
1206
+ ...filterThreadArgs(),
1207
+ ...enc.input,
1208
+ "-i",
1209
+ inputPath,
1210
+ "-filter_complex",
1211
+ enc.filterSuffix ? filter + enc.filterSuffix : filter,
1212
+ "-map",
1213
+ enc.videoLabel,
1214
+ "-an",
1215
+ ...enc.output,
1216
+ "-movflags",
1217
+ "+faststart",
1218
+ outputPath
1219
+ ]);
1220
+ proc.stderr.resume();
1221
+ proc.on("error", () => resolve3(false));
1222
+ proc.on("close", (code) => resolve3(code === 0));
1223
+ });
1224
+ }
1225
+ function buildRetimeFilter(spans, stepBounds, speed, fps) {
1226
+ const all = mergeSpans(spans.map((s) => ({ ...s })));
1227
+ const totalSecs = stepBounds[stepBounds.length - 1]?.to ?? 0;
1228
+ const frame = 1 / Math.max(1, fps);
1229
+ const parts = [];
1230
+ const labels = [];
1231
+ let n = 0;
1232
+ let reclaimed = 0;
1233
+ let pendingFrom = null;
1234
+ const flush = (to) => {
1235
+ if (pendingFrom === null) return;
1236
+ const from = pendingFrom;
1237
+ pendingFrom = null;
1238
+ if (to - from < 0.04) return;
1239
+ parts.push(`[0:v]trim=${from.toFixed(3)}:${to.toFixed(3)},setpts=PTS-STARTPTS,fps=${fps}[v${n}]`);
1240
+ labels.push(`[v${n}]`);
1241
+ n++;
1242
+ };
1243
+ const segment = (from, to, factor) => {
1244
+ if (to - from < 0.04) return;
1245
+ const pts = factor === 1 ? "PTS-STARTPTS" : `(PTS-STARTPTS)*${factor.toFixed(5)}`;
1246
+ parts.push(`[0:v]trim=${from.toFixed(3)}:${to.toFixed(3)},setpts=${pts},fps=${fps}[v${n}]`);
1247
+ labels.push(`[v${n}]`);
1248
+ n++;
1249
+ };
1250
+ const freeze = (from, to, sourceAt) => {
1251
+ const dur = to - from;
1252
+ if (dur < 0.04) return;
1253
+ const at = sourceAt + frame <= totalSecs ? sourceAt : Math.max(0, from - frame);
1254
+ parts.push(
1255
+ `[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}]`
1256
+ );
1257
+ labels.push(`[v${n}]`);
1258
+ n++;
1259
+ };
1260
+ for (const step of stepBounds) {
1261
+ 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);
1262
+ if (local.length === 0) {
1263
+ if (pendingFrom === null) pendingFrom = step.from;
1264
+ continue;
1265
+ }
1266
+ const window = step.to - step.from;
1267
+ const emptyTotal = local.reduce((a, s) => a + (s.end - s.start), 0);
1268
+ const keepTotal = window - emptyTotal;
1269
+ let recovered = emptyTotal * (1 - 1 / speed);
1270
+ let stretch = keepTotal > 0 ? (keepTotal + recovered) / keepTotal : Number.POSITIVE_INFINITY;
1271
+ if (stretch > MAX_STRETCH) {
1272
+ recovered = keepTotal * (MAX_STRETCH - 1);
1273
+ stretch = MAX_STRETCH;
1274
+ }
1275
+ const effective = emptyTotal / Math.max(0.01, emptyTotal - recovered);
1276
+ flush(step.from);
1277
+ let cursor = step.from;
1278
+ if (keepTotal >= MIN_KEEP_SECS && effective >= MIN_EFFECTIVE_SPEED) {
1279
+ for (const span of local) {
1280
+ segment(cursor, span.start, stretch);
1281
+ segment(span.start, span.end, 1 / effective);
1282
+ cursor = span.end;
1283
+ }
1284
+ segment(cursor, step.to, stretch);
1285
+ reclaimed += emptyTotal - emptyTotal / effective;
1286
+ } else {
1287
+ for (const span of local) {
1288
+ segment(cursor, span.start, 1);
1289
+ freeze(span.start, span.end, span.sourceAt);
1290
+ cursor = span.end;
1291
+ }
1292
+ segment(cursor, step.to, 1);
1293
+ reclaimed += emptyTotal;
1294
+ }
1295
+ }
1296
+ flush(totalSecs);
1297
+ if (n < 2 || reclaimed < 0.5) return null;
1298
+ return `${parts.join(";")};${labels.join("")}concat=n=${n}:v=1:a=0[outv]`;
1299
+ }
1300
+ async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, speed, fps, preset) {
1301
+ const filter = buildRetimeFilter(spans, stepBounds, speed, fps);
1302
+ if (!filter) return false;
1303
+ const enc = await passEncoder(preset);
1304
+ return new Promise((resolve3) => {
1305
+ const proc = spawnFFmpeg("ffmpeg", [
1306
+ "-y",
1307
+ ...filterThreadArgs(),
1308
+ ...enc.input,
1309
+ "-i",
1310
+ inputPath,
1311
+ "-filter_complex",
1312
+ enc.filterSuffix ? filter + enc.filterSuffix : filter,
1313
+ "-map",
1314
+ enc.videoLabel,
1315
+ "-an",
1316
+ ...enc.output,
1317
+ "-movflags",
1318
+ "+faststart",
1319
+ outputPath
1320
+ ]);
1321
+ proc.stderr.resume();
1322
+ proc.on("error", () => resolve3(false));
1323
+ proc.on("close", (code) => resolve3(code === 0));
1324
+ });
1325
+ }
1326
+ async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, preset, protectAfterSecs, narrationPath, protectBeforeSecs, leadSpans, keepSpans) {
1327
+ const audioSource = narrationPath || inputPath;
1328
+ const frozenMax = Math.min(maxSecs, FROZEN_MAX_SECS);
1329
+ const [detected, frozen, black, white] = await Promise.all([
1330
+ detectSilences(audioSource, frozenMax + 0.05),
1331
+ detectFrozenSpans(inputPath, frozenMax + 0.05),
1332
+ // Shorter threshold than the other two: a page transition is often only a
1333
+ // second, and a second of black in a 60s Short is a second too many.
1334
+ detectBlackSpans(inputPath, 0.3),
1335
+ detectWhiteSpans(inputPath, 0.3)
1336
+ ]);
1337
+ const silent = detected.filter((span) => span.end - span.start > maxSecs + 0.05);
1338
+ const stuck = intersectSpans(frozen, detected).filter((span) => span.end - span.start > frozenMax).map((span) => ({ ...span, cap: frozenMax }));
1339
+ const cardFloor = Math.max(0, protectBeforeSecs ?? 0);
1340
+ const cardCeiling = protectAfterSecs ?? totalSecs;
1341
+ 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 }));
1342
+ const known = (leadSpans ?? []).filter((span) => span.end - span.start > BLACK_KEEP_SECS * 2).map((span) => ({ ...span, cap: BLACK_KEEP_SECS }));
1343
+ const merged = mergeSpans([...silent, ...stuck, ...empty, ...known]);
1344
+ const overlapsContent = (span) => (keepSpans ?? []).some((keep) => span.start < keep.end && span.end > keep.start);
1345
+ const spans = merged.filter((span) => !overlapsContent(span));
1346
+ const filter = buildCompressionFilter(spans, totalSecs, maxSecs, protectAfterSecs, protectBeforeSecs);
1347
+ if (!filter) return false;
1348
+ const enc = await passEncoder(preset);
1349
+ return new Promise((resolve3) => {
1350
+ const proc = spawnFFmpeg("ffmpeg", [
1351
+ "-y",
1352
+ ...filterThreadArgs(),
1353
+ ...enc.input,
1354
+ "-i",
1355
+ inputPath,
1356
+ "-filter_complex",
1357
+ filter + enc.filterSuffix,
1358
+ "-map",
1359
+ enc.videoLabel,
1360
+ "-map",
1361
+ "[outa]",
1362
+ ...enc.output,
1363
+ "-c:a",
1364
+ "aac",
1365
+ // No -shortest here. The overhang it was added for is already handled by
1366
+ // bounding every segment to the VIDEO stream's length, and the flag cut
1367
+ // the other way once an outro existed: a credits card carries no
1368
+ // narration, so the audio runs out before the picture does and the card
1369
+ // was truncated away.
1370
+ "-movflags",
1371
+ "+faststart",
1372
+ outputPath
1373
+ ]);
1374
+ proc.stderr.resume();
1375
+ proc.on("error", () => resolve3(false));
1376
+ proc.on("close", (code) => resolve3(code === 0));
1377
+ });
1378
+ }
1379
+ async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, introSecs, outroSecs, maxFactor, preset) {
1380
+ const ends = introSecs + outroSecs;
1381
+ const middle = totalSecs - ends;
1382
+ const budget = targetSecs - ends - FIT_UNDERSHOOT_SECS;
1383
+ if (middle <= 0.5 || budget <= 0.5 || middle <= budget) {
1384
+ return { ok: false, factor: 1, reachedTarget: totalSecs <= targetSecs };
1385
+ }
1386
+ const needed = middle / budget;
1387
+ const factor = Math.min(needed, maxFactor);
1388
+ const tempo = atempoChain(factor);
1389
+ const midEnd = totalSecs - outroSecs;
1390
+ const filter = [
1391
+ `[0:v]trim=0:${introSecs.toFixed(3)},setpts=PTS-STARTPTS[v0]`,
1392
+ `[0:a]atrim=0:${introSecs.toFixed(3)},asetpts=PTS-STARTPTS[a0]`,
1393
+ `[0:v]trim=${introSecs.toFixed(3)}:${midEnd.toFixed(3)},setpts=(PTS-STARTPTS)/${factor.toFixed(4)}[v1]`,
1394
+ `[0:a]atrim=${introSecs.toFixed(3)}:${midEnd.toFixed(3)},asetpts=PTS-STARTPTS${tempo ? `,${tempo}` : ""}[a1]`,
1395
+ `[0:v]trim=${midEnd.toFixed(3)}:${totalSecs.toFixed(3)},setpts=PTS-STARTPTS[v2]`,
1396
+ `[0:a]atrim=${midEnd.toFixed(3)}:${totalSecs.toFixed(3)},asetpts=PTS-STARTPTS[a2]`,
1397
+ "[v0][a0][v1][a1][v2][a2]concat=n=3:v=1:a=1[outv][outa]"
1398
+ ].join(";");
1399
+ const enc = await passEncoder(preset);
1400
+ const ok = await new Promise((resolve3) => {
1401
+ const proc = spawnFFmpeg("ffmpeg", [
1402
+ "-y",
1403
+ ...filterThreadArgs(),
1404
+ ...enc.input,
1405
+ "-i",
1406
+ inputPath,
439
1407
  "-filter_complex",
440
- filterComplex,
1408
+ filter + enc.filterSuffix,
441
1409
  "-map",
442
- "[outv]",
443
- "-c:v",
444
- "libx264",
445
- "-preset",
446
- preset,
447
- "-crf",
448
- "18",
449
- "-pix_fmt",
450
- "yuv420p",
451
- "-an",
1410
+ enc.videoLabel,
1411
+ "-map",
1412
+ "[outa]",
1413
+ ...enc.output,
1414
+ "-c:a",
1415
+ "aac",
1416
+ "-movflags",
1417
+ "+faststart",
452
1418
  outputPath
453
- ],
454
- "ffmpeg concat video failed"
455
- );
1419
+ ]);
1420
+ proc.stderr.resume();
1421
+ proc.on("error", () => resolve3(false));
1422
+ proc.on("close", (code) => resolve3(code === 0));
1423
+ });
1424
+ return { ok, factor, reachedTarget: needed <= maxFactor };
456
1425
  }
457
- var init_media = __esm({
458
- "src/lib/ffmpeg/media.ts"() {
1426
+ 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;
1427
+ var init_silence = __esm({
1428
+ "src/lib/ffmpeg/silence.ts"() {
459
1429
  "use strict";
1430
+ init_hwaccel();
460
1431
  init_spawn();
1432
+ SPEECH_GUARD_SECS = 0.12;
1433
+ BLACK_KEEP_SECS = 0.12;
1434
+ FROZEN_MAX_SECS = 0.6;
1435
+ EMPTY_SPAN_SPEED = 5;
1436
+ MAX_STRETCH = 2.5;
1437
+ MIN_EFFECTIVE_SPEED = 1.3;
1438
+ MIN_KEEP_SECS = 0.3;
1439
+ MIN_RETIME_SPAN = 0.2;
1440
+ FIT_UNDERSHOOT_SECS = 0.35;
461
1441
  }
462
1442
  });
463
1443
 
464
1444
  // src/lib/ffmpeg/splice.ts
465
- function spliceVideos(opts) {
1445
+ function coverFilterChain(inputIdx, width, height) {
1446
+ const bg = `cov${inputIdx}bg`;
1447
+ const fg = `cov${inputIdx}fg`;
1448
+ 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`;
1449
+ }
1450
+ async function spliceVideos(opts) {
1451
+ const { inputPath, outputPath, overlays, width, height, preset } = opts;
1452
+ if (overlays.length === 0) {
1453
+ return runFFmpeg(["-y", "-i", inputPath, "-c", "copy", outputPath], "ffmpeg copy failed");
1454
+ }
1455
+ const audioPresent = await Promise.all(
1456
+ overlays.map((o) => o.still ? Promise.resolve(false) : hasAudioStream(o.path))
1457
+ );
466
1458
  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
1459
  const args = ["-y", "-i", inputPath];
473
1460
  for (const overlay of overlays) {
1461
+ if (overlay.still) {
1462
+ args.push("-loop", "1", "-framerate", String(STILL_FRAMERATE), "-t", overlay.duration.toFixed(3));
1463
+ }
474
1464
  args.push("-i", overlay.path);
475
1465
  }
476
1466
  const filterParts = [];
@@ -490,13 +1480,18 @@ function spliceVideos(opts) {
490
1480
  }
491
1481
  }
492
1482
  if (overlay.mode === "stretch") {
493
- filters.push(`scale=${width}:${height}:force_original_aspect_ratio=increase`);
1483
+ filters.push(`scale=${width}:-2`);
1484
+ filters.push(`pad=${width}:max(ih\\,${height}):0:(oh-ih)/2:color=#1a1a2e`);
494
1485
  filters.push(`crop=${width}:${height}`);
1486
+ } else if (overlay.mode === "cover") {
1487
+ filters.push(coverFilterChain(inputIdx, width, height));
495
1488
  } else {
496
- filters.push(`scale=${width}:${height}:force_original_aspect_ratio=decrease`);
1489
+ filters.push(
1490
+ `scale=${Math.round(width * 0.9)}:${Math.round(height * 0.9)}:force_original_aspect_ratio=decrease`
1491
+ );
497
1492
  filters.push(`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=#1a1a2e`);
498
1493
  }
499
- filters.push("setpts=PTS-STARTPTS");
1494
+ filters.push(`setpts=PTS-STARTPTS+${overlay.startTime.toFixed(3)}/TB`);
500
1495
  filterParts.push(`[${inputIdx}:v]${filters.join(",")}[v${inputIdx}]`);
501
1496
  const nextStream = i === overlays.length - 1 ? "outv" : `tmp${i}`;
502
1497
  filterParts.push(
@@ -504,19 +1499,39 @@ function spliceVideos(opts) {
504
1499
  );
505
1500
  currentStream = nextStream;
506
1501
  }
1502
+ const clipAudio = overlays.map((overlay, i) => ({ overlay, inputIdx: i + 1 })).filter(({ inputIdx }) => audioPresent[inputIdx - 1]);
1503
+ for (const { overlay, inputIdx } of clipAudio) {
1504
+ const delayMs = Math.round(overlay.startTime * 1e3);
1505
+ filterParts.push(
1506
+ `[${inputIdx}:a]atrim=0:${overlay.duration.toFixed(3)},asetpts=PTS-STARTPTS,adelay=${delayMs}|${delayMs},volume=${CLIP_AUDIO_GAIN}[a${inputIdx}]`
1507
+ );
1508
+ }
1509
+ if (clipAudio.length === 1) {
1510
+ filterParts.push(`[a${clipAudio[0].inputIdx}]anull[outa]`);
1511
+ } else if (clipAudio.length > 1) {
1512
+ const labels = clipAudio.map(({ inputIdx }) => `[a${inputIdx}]`).join("");
1513
+ filterParts.push(`${labels}amix=inputs=${clipAudio.length}:normalize=0:dropout_transition=0[outa]`);
1514
+ }
507
1515
  args.push("-filter_complex", filterParts.join(";"));
508
1516
  args.push("-map", "[outv]");
509
- args.push("-map", "0:a?");
1517
+ if (clipAudio.length > 0) args.push("-map", "[outa]");
510
1518
  args.push("-c:v", "libx264", "-preset", preset, "-crf", "18", "-pix_fmt", "yuv420p");
511
- args.push("-c:a", "copy");
1519
+ if (clipAudio.length > 0) args.push("-c:a", "aac");
512
1520
  args.push(outputPath);
513
1521
  runFFmpeg(args, "ffmpeg splice failed").then(resolve3, reject);
514
1522
  });
515
1523
  }
1524
+ var CLIP_AUDIO_GAIN, STILL_FRAMERATE, COVER_WASH_W, COVER_WASH_H, COVER_BLUR_SIGMA;
516
1525
  var init_splice = __esm({
517
1526
  "src/lib/ffmpeg/splice.ts"() {
518
1527
  "use strict";
1528
+ init_media();
519
1529
  init_spawn();
1530
+ CLIP_AUDIO_GAIN = 0.55;
1531
+ STILL_FRAMERATE = 30;
1532
+ COVER_WASH_W = 6;
1533
+ COVER_WASH_H = 10;
1534
+ COVER_BLUR_SIGMA = 40;
520
1535
  }
521
1536
  });
522
1537
 
@@ -613,7 +1628,9 @@ var init_ffmpeg = __esm({
613
1628
  "use strict";
614
1629
  init_compose();
615
1630
  init_compress();
1631
+ init_gif();
616
1632
  init_media();
1633
+ init_silence();
617
1634
  init_splice();
618
1635
  init_transitions();
619
1636
  init_types();
@@ -630,8 +1647,8 @@ var init_ffmpeg2 = __esm({
630
1647
 
631
1648
  // src/lib/tts/helpers.ts
632
1649
  import { existsSync } from "fs";
633
- import { readFile, unlink, writeFile } from "fs/promises";
634
- import { isAbsolute, join, resolve } from "path";
1650
+ import { readFile, unlink as unlink2, writeFile } from "fs/promises";
1651
+ import { isAbsolute, join as join2, resolve } from "path";
635
1652
  function toWSLPathForTTS(path) {
636
1653
  return windowsDrivePathToWsl(path) ?? path;
637
1654
  }
@@ -684,7 +1701,7 @@ async function getMediaStepDuration(step, baseDir) {
684
1701
  }
685
1702
  }
686
1703
  async function loadVoiceoverMeta(outputDir) {
687
- const metaPath = join(outputDir, "voiceover.json");
1704
+ const metaPath = join2(outputDir, "voiceover.json");
688
1705
  try {
689
1706
  if (!existsSync(metaPath)) return null;
690
1707
  const content = await readFile(metaPath, "utf-8");
@@ -694,7 +1711,7 @@ async function loadVoiceoverMeta(outputDir) {
694
1711
  }
695
1712
  }
696
1713
  async function saveVoiceoverMeta(outputDir, meta) {
697
- const metaPath = join(outputDir, "voiceover.json");
1714
+ const metaPath = join2(outputDir, "voiceover.json");
698
1715
  await writeFile(metaPath, JSON.stringify(meta, null, 2));
699
1716
  }
700
1717
  async function concatenateAudio(inputs, output, workDir) {
@@ -703,19 +1720,21 @@ async function concatenateAudio(inputs, output, workDir) {
703
1720
  return isWindows ? windowsDrivePathToWsl(resolved) ?? resolved : resolved;
704
1721
  };
705
1722
  const listContent = inputs.map((f) => `file '${formatPath(f)}'`).join("\n");
706
- const listPath = join(workDir, "concat.txt");
1723
+ const listPath = join2(workDir, "concat.txt");
707
1724
  await writeFile(listPath, listContent);
708
1725
  await concatMedia(listPath, output);
709
- await unlink(listPath).catch(() => {
1726
+ await unlink2(listPath).catch(() => {
710
1727
  });
711
1728
  }
712
- var isWindows;
1729
+ var isWindows, NARRATION_LEAD_IN_MS, LEAD_IN_CLIP;
713
1730
  var init_helpers = __esm({
714
1731
  "src/lib/tts/helpers.ts"() {
715
1732
  "use strict";
716
1733
  init_ffmpeg2();
717
1734
  init_utils();
718
1735
  isWindows = process.platform === "win32";
1736
+ NARRATION_LEAD_IN_MS = 300;
1737
+ LEAD_IN_CLIP = "lead-in.mp3";
719
1738
  }
720
1739
  });
721
1740
 
@@ -723,10 +1742,12 @@ var init_helpers = __esm({
723
1742
  var timing_exports = {};
724
1743
  __export(timing_exports, {
725
1744
  alignVoiceoverToRecording: () => alignVoiceoverToRecording,
726
- estimateWordTimings: () => estimateWordTimings
1745
+ alignWordsToCaption: () => alignWordsToCaption,
1746
+ estimateWordTimings: () => estimateWordTimings,
1747
+ spokenWordTimings: () => spokenWordTimings
727
1748
  });
728
1749
  import { existsSync as existsSync2 } from "fs";
729
- import { join as join2 } from "path";
1750
+ import { join as join3 } from "path";
730
1751
  function estimateWordTimings(caption, duration) {
731
1752
  const words = caption.split(/\s+/).filter((w) => w.length > 0);
732
1753
  const wordDuration = duration / words.length;
@@ -736,6 +1757,36 @@ function estimateWordTimings(caption, duration) {
736
1757
  end: Math.round((i + 1) * wordDuration)
737
1758
  }));
738
1759
  }
1760
+ function alignWordsToCaption(caption, spoken) {
1761
+ const captionWords = caption.split(/\s+/).filter((w) => w.length > 0);
1762
+ if (captionWords.length === 0 || spoken.length === 0) return [];
1763
+ if (spoken.length === captionWords.length) {
1764
+ return captionWords.map((word, i) => ({
1765
+ word,
1766
+ start: Math.round(spoken[i].start),
1767
+ end: Math.round(spoken[i].end)
1768
+ }));
1769
+ }
1770
+ const speechStart = spoken[0].start;
1771
+ const speechEnd = spoken[spoken.length - 1].end;
1772
+ const wordDuration = (speechEnd - speechStart) / captionWords.length;
1773
+ return captionWords.map((word, i) => ({
1774
+ word,
1775
+ start: Math.round(speechStart + i * wordDuration),
1776
+ end: Math.round(speechStart + (i + 1) * wordDuration)
1777
+ }));
1778
+ }
1779
+ function spokenWordTimings(caption, spoken) {
1780
+ if (!spoken || spoken.length === 0) return null;
1781
+ const leadTrim = spoken[0].start;
1782
+ const rebased = spoken.map((w) => ({
1783
+ word: w.word,
1784
+ start: Math.max(0, w.start - leadTrim),
1785
+ end: Math.max(0, w.end - leadTrim)
1786
+ }));
1787
+ const aligned = alignWordsToCaption(caption, rebased);
1788
+ return aligned.length > 0 ? aligned : null;
1789
+ }
739
1790
  async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, workDir, outputDir) {
740
1791
  let needsRealignment = false;
741
1792
  for (let i = 0; i < actualRecordingDurations.length; i++) {
@@ -750,31 +1801,35 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
750
1801
  if (!needsRealignment) {
751
1802
  return stepAudio;
752
1803
  }
753
- const audioDir = join2(workDir, "audio");
1804
+ const audioDir = join3(workDir, "audio");
754
1805
  const segments = [];
755
1806
  const alignedStepAudio = /* @__PURE__ */ new Map();
1807
+ const leadInPath = join3(audioDir, LEAD_IN_CLIP);
1808
+ const leadIn = existsSync2(leadInPath) ? NARRATION_LEAD_IN_MS : 0;
1809
+ if (leadIn) segments.push(leadInPath);
756
1810
  for (let i = 0; i < actualRecordingDurations.length; i++) {
757
1811
  const stepNum = i + 1;
758
1812
  const voiceData = stepAudio.get(stepNum);
759
1813
  const voiceDuration = voiceData?.duration ?? 0;
760
1814
  const recordingDuration = actualRecordingDurations[i] ?? 0;
761
1815
  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`);
1816
+ const audioPath = join3(audioDir, `step-${stepNum}.mp3`);
1817
+ const originalPaddingPath = join3(audioDir, `step-${stepNum}-padding.mp3`);
1818
+ const silencePath = join3(audioDir, `step-${stepNum}-silence.mp3`);
765
1819
  if (existsSync2(audioPath)) {
766
1820
  segments.push(audioPath);
767
1821
  if (existsSync2(originalPaddingPath)) {
768
1822
  segments.push(originalPaddingPath);
769
1823
  }
770
1824
  if (recordingDuration > voiceDuration) {
771
- const alignPaddingPath = join2(audioDir, `step-${stepNum}-align-padding.mp3`);
1825
+ const alignPaddingPath = join3(audioDir, `step-${stepNum}-align-padding.mp3`);
772
1826
  await generateSilence(alignPaddingPath, recordingDuration - voiceDuration);
773
1827
  segments.push(alignPaddingPath);
774
1828
  }
775
1829
  } else if (existsSync2(silencePath)) {
776
- if (targetDuration !== voiceDuration) {
777
- await generateSilence(silencePath, targetDuration);
1830
+ const own = targetDuration - (stepNum === 1 ? leadIn : 0);
1831
+ if (targetDuration !== voiceDuration && own > 0) {
1832
+ await generateSilence(silencePath, own);
778
1833
  }
779
1834
  segments.push(silencePath);
780
1835
  }
@@ -783,10 +1838,14 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
783
1838
  words: voiceData?.words ?? []
784
1839
  });
785
1840
  }
786
- if (segments.length > 0) {
787
- const voiceoverPath = join2(outputDir, "voiceover.wav");
788
- await concatenateAudio(segments, voiceoverPath, workDir);
1841
+ if (segments.length === 0) {
1842
+ console.log(
1843
+ ` No per-step audio in ${audioDir}: keeping the narration's own timeline, so the captions still follow the voice.`
1844
+ );
1845
+ return stepAudio;
789
1846
  }
1847
+ const voiceoverPath = join3(outputDir, "voiceover.wav");
1848
+ await concatenateAudio(segments, voiceoverPath, workDir);
790
1849
  const existingMeta = await loadVoiceoverMeta(outputDir);
791
1850
  if (existingMeta) {
792
1851
  existingMeta.stepDurations = Object.fromEntries(
@@ -806,6 +1865,7 @@ var init_timing = __esm({
806
1865
 
807
1866
  // src/lib/api-client.ts
808
1867
  init_utils();
1868
+ var PLAN_MAX_TOKENS = 1500;
809
1869
  var SERVER_BACKOFF_MS = [2e3, 4e3];
810
1870
  var CLI_BACKOFF_MS = [1e4, 3e4];
811
1871
  var SPARK_AI_BASE = "https://sparkbrain.app";
@@ -885,7 +1945,7 @@ async function getOrRefreshRelayToken(email) {
885
1945
  }
886
1946
  }
887
1947
  async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier, opts = {}) {
888
- const { onRetry, backoffMs = SERVER_BACKOFF_MS } = opts;
1948
+ const { onRetry, backoffMs = SERVER_BACKOFF_MS, maxTokens } = opts;
889
1949
  let lastRetriableError = null;
890
1950
  const host = hostOf(chatUrl);
891
1951
  for (let attempt = 1; attempt <= 3; attempt++) {
@@ -900,7 +1960,11 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
900
1960
  messages: [
901
1961
  { role: "system", content: systemPrompt },
902
1962
  { role: "user", content: userPrompt }
903
- ]
1963
+ ],
1964
+ // Explicit numeric test, not truthiness: a 0 or negative budget is a
1965
+ // caller bug, and silently falling back to the tiny backend default
1966
+ // reproduces exactly the failure this option exists to prevent.
1967
+ ...typeof maxTokens === "number" && maxTokens > 0 ? { max_tokens: Math.floor(maxTokens) } : {}
904
1968
  })
905
1969
  });
906
1970
  const bodyText = await response.text().catch(() => "");
@@ -1039,26 +2103,23 @@ var APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
1039
2103
  var OWNER_SLUG = process.env.SPARK_OWNER_SLUG ?? "spark";
1040
2104
  var BASE = "https://sparkpay.dev";
1041
2105
  var TIERS = {
1042
- price_1TADnPEf9opkuWid9NtqOYON: "pro",
1043
- price_1TADnPEf9opkuWidgbvvX9ma: "pro",
2106
+ // Essentials
2107
+ price_1TCREmEf9opkuWidvhuqzqDM: "pro",
2108
+ // live monthly
2109
+ price_1TCREmEf9opkuWidacf1tiC7: "pro",
2110
+ // live yearly
1044
2111
  price_1TADm7Ef9opkuWid3I9tquIB: "pro",
2112
+ // test monthly
1045
2113
  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
- ]
2114
+ // test yearly
2115
+ // Elite. These were missing entirely, so an Elite subscriber fell through
2116
+ // the `?? 'pro'` fallback below and got Essentials limits for $29.
2117
+ price_1TrHz8Ef9opkuWid6lh2opD8: "elite",
2118
+ // live monthly
2119
+ price_1TrHz9Ef9opkuWid1qS26iMu: "elite",
2120
+ // live yearly
2121
+ price_1TGPCREf9opkuWidR0iJeJNe: "elite"
2122
+ // test monthly
1062
2123
  };
1063
2124
  async function _fetch(email, init) {
1064
2125
  const r = await fetch(
@@ -1083,7 +2144,6 @@ function getTier(d) {
1083
2144
  return TIERS[d.subscription.price_id] ?? "pro";
1084
2145
  }
1085
2146
  var isPaid = (d) => getTier(d) !== "free";
1086
- var hasAccess = (d, min) => (RANK[getTier(d)] ?? 0) >= (RANK[min] ?? 0);
1087
2147
  var hasTrialRemaining = (d) => isPaid(d) || !!d.trial && !d.trial.is_expired && d.trial.uses_remaining > 0;
1088
2148
  var showPaywall = (d) => !isPaid(d) && !hasTrialRemaining(d);
1089
2149
  var isCanceling = (d) => !!d.subscription && d.subscription.cancel_at_period_end && d.subscription.status !== "canceled";
@@ -1109,21 +2169,26 @@ function pricingUrl(opts) {
1109
2169
  }
1110
2170
 
1111
2171
  // src/lib/billing.ts
1112
- function shouldWatermark(tier, videoDurationSeconds) {
1113
- return tier === "free" && videoDurationSeconds > 20;
1114
- }
1115
2172
  function getUpgradeUrl(userEmail) {
1116
2173
  return pricingUrl(userEmail ? { email: userEmail } : void 0);
1117
2174
  }
1118
2175
 
1119
2176
  // src/lib/config.ts
1120
- var VERSION = "1.2.3";
2177
+ var VERSION = "1.2.5";
1121
2178
  var CONFIG_FILE = "quickpeek.config.json";
2179
+ var SIZE_PRESETS = {
2180
+ wide: { width: 1920, height: 1080, zoom: 1 },
2181
+ short: { width: 1080, height: 1920, zoom: 2 }
2182
+ };
2183
+ var RATE_FACTOR = { slow: 0.8, normal: 1, fast: 1.25 };
2184
+ var NO_CLIP_OUTRO = "@card-step";
1122
2185
  var DEFAULT_CONFIG = {
1123
2186
  plan: { maxSteps: 8 },
1124
2187
  video: {
1125
- width: 1080,
1126
- height: 1920,
2188
+ size: "short",
2189
+ width: SIZE_PRESETS.short.width,
2190
+ height: SIZE_PRESETS.short.height,
2191
+ zoom: SIZE_PRESETS.short.zoom,
1127
2192
  format: "mp4",
1128
2193
  preset: "fast",
1129
2194
  quality: 0.5,
@@ -1136,20 +2201,71 @@ var DEFAULT_CONFIG = {
1136
2201
  // ms
1137
2202
  transitions: "none",
1138
2203
  contrast: 0.25,
2204
+ // The recording plays at the pace it was filmed at. A tempo above 1 is
2205
+ // multiplied by the narration speed in VIDEO_PROFILES and by the fit
2206
+ // pass's squeeze, and the three together read as frantic rather than
2207
+ // brisk. Speed belongs in one place, so it lives in audioSpeed; set a
2208
+ // tempo in quickpeek.config.json to push the picture on top of that.
1139
2209
  tempo: 1,
1140
2210
  stepDelay: 200
1141
2211
  // ms delay between steps
1142
2212
  },
1143
- audio: { format: "mp3", bitrate: "192k" },
2213
+ audio: { gender: "female", rate: "normal", format: "mp3", bitrate: "192k" },
1144
2214
  timing: { minStepDuration: 1500 },
1145
2215
  output: { dir: "./demo" }
1146
2216
  };
2217
+ var VIDEO_PROFILES = {
2218
+ // preset 'veryfast' over 'fast': measured ~20s faster on a 77s 1080p
2219
+ // compose, with identical duration and audio and no visible difference at
2220
+ // these bitrates - the encode is pure wall-clock tax on every run.
2221
+ wide: {
2222
+ video: { width: 1920, height: 1080, zoom: 1.5, fitLimitSecs: 0, maxSpeedup: 1, preset: "veryfast" },
2223
+ // A tutorial is watched to learn something, so the narrator is allowed to
2224
+ // be brisk but never hurried.
2225
+ audioSpeed: 1.15
2226
+ },
2227
+ short: {
2228
+ // fadeOut 0: the video ends on a card the viewer is meant to read,
2229
+ // and a fade takes it away mid-sentence.
2230
+ video: { width: 1080, height: 1920, zoom: 3, fitLimitSecs: 60, maxSpeedup: 1.7, preset: "veryfast", fadeOut: 0 },
2231
+ // 1.8 was unwatchable: stacked on tempo 1.2 and the outro speedup it ran
2232
+ // the voice at better than twice its natural rate. A short wants energy,
2233
+ // which is the voice and the cutting, not the tape running fast.
2234
+ audioSpeed: 1,
2235
+ // A short lives or dies on energy. Ava is the warmest, most animated of
2236
+ // the free neural voices; Sonia reads a short like the shipping forecast.
2237
+ voice: "en-US-AvaMultilingualNeural"
2238
+ }
2239
+ };
2240
+ function applyProfile(config, profile) {
2241
+ const preset = VIDEO_PROFILES[profile];
2242
+ return {
2243
+ ...config,
2244
+ profile,
2245
+ video: { ...config.video, ...preset.video },
2246
+ audio: {
2247
+ format: "mp3",
2248
+ bitrate: "192k",
2249
+ ...config.audio,
2250
+ // outroOnly is on unless turned off, matching the video phase. The
2251
+ // speedup buys the song its window, so it applies to the profile's own
2252
+ // pace only: a speed somebody set by hand is the speed they wanted.
2253
+ speed: config.audio?.speed ?? preset.audioSpeed * (config.music?.outroOnly !== false ? OUTRO_SONG_SPEEDUP : 1),
2254
+ ...config.audio?.voice || !preset.voice ? {} : { voice: preset.voice }
2255
+ }
2256
+ };
2257
+ }
2258
+ var OUTRO_SONG_SPEEDUP = 1.13;
1147
2259
  var DEFAULT_CAPTIONS = {
1148
2260
  font: "Arial",
1149
2261
  outline: "#000000",
1150
2262
  outlineWidth: 3,
1151
2263
  shadow: 2,
1152
2264
  marginBottom: 80,
2265
+ // The IG/TikTok look is what a demo short is for; karaoke remains available
2266
+ // by name for anyone who wants the sweep instead.
2267
+ preset: "shorts",
2268
+ position: "bottom",
1153
2269
  regular: {
1154
2270
  color: "#FFFFFF",
1155
2271
  size: 56,
@@ -1159,8 +2275,41 @@ var DEFAULT_CAPTIONS = {
1159
2275
  color: "#FFFF00",
1160
2276
  size: 64,
1161
2277
  bold: true
1162
- }
2278
+ },
2279
+ maxChars: 28
1163
2280
  };
2281
+ function defaultCaptionsFor(width, height) {
2282
+ const portrait = height > width;
2283
+ if (!portrait) {
2284
+ return {
2285
+ ...DEFAULT_CAPTIONS,
2286
+ preset: "karaoke",
2287
+ position: "bottom",
2288
+ marginBottom: Math.round(height * 0.083),
2289
+ regular: { color: "#FFFFFF", size: Math.round(width * 0.033), bold: true },
2290
+ highlight: { color: "#FFE100", size: Math.round(width * 0.036), bold: true },
2291
+ outlineWidth: 4,
2292
+ shadow: 2,
2293
+ maxChars: 34
2294
+ };
2295
+ }
2296
+ const size = Math.round(width * 0.104);
2297
+ return {
2298
+ ...DEFAULT_CAPTIONS,
2299
+ // The whole line stays up and the spoken word recolours: the social-video
2300
+ // convention, and the one that survives being watched muted.
2301
+ preset: "hormozi",
2302
+ position: "bottom",
2303
+ marginBottom: Math.round(height * 0.156),
2304
+ // Equal sizes on purpose. A larger highlight reflows the line every time
2305
+ // the spoken word moves, which on a full-line preset is a visible twitch.
2306
+ regular: { color: "#FFFFFF", size, bold: true },
2307
+ highlight: { color: "#FFE100", size, bold: true },
2308
+ outlineWidth: 8,
2309
+ shadow: 5,
2310
+ maxChars: 20
2311
+ };
2312
+ }
1164
2313
 
1165
2314
  // src/lib/crawl.ts
1166
2315
  import { chromium } from "playwright";
@@ -1262,9 +2411,81 @@ function shouldSkipLink(text, href, currentPath) {
1262
2411
  }
1263
2412
 
1264
2413
  // src/lib/crawl.ts
2414
+ var SETTLE_POLL_MS = 400;
2415
+ var SETTLE_STABLE_POLLS = 2;
2416
+ var SETTLE_CAP_MS = 2e4;
2417
+ async function waitForDomStable(page, cap = SETTLE_CAP_MS) {
2418
+ const deadline = Date.now() + cap;
2419
+ let previous = -1;
2420
+ let unchanged = 0;
2421
+ while (Date.now() < deadline) {
2422
+ const count = await page.evaluate(() => document.getElementsByTagName("*").length).catch(() => -1);
2423
+ if (count < 0) return;
2424
+ if (count === previous) {
2425
+ if (++unchanged >= SETTLE_STABLE_POLLS) return;
2426
+ } else {
2427
+ unchanged = 0;
2428
+ previous = count;
2429
+ }
2430
+ await page.waitForTimeout(SETTLE_POLL_MS);
2431
+ }
2432
+ }
2433
+ var PLACEHOLDER_POLL_MS = 250;
2434
+ var PLACEHOLDER_CAP_MS = 8e3;
2435
+ var PLACEHOLDER_STATIC_POLLS = 4;
2436
+ var PLACEHOLDER_CLEAR_POLLS = 3;
2437
+ var PLACEHOLDER_SELECTOR = [
2438
+ '[aria-busy="true"]',
2439
+ '[role="progressbar"]',
2440
+ '[data-loading="true"]',
2441
+ '[class*="skeleton" i]',
2442
+ '[class*="animate-pulse" i]',
2443
+ '[class*="spinner" i]'
2444
+ ].join(",");
2445
+ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
2446
+ const deadline = Date.now() + cap;
2447
+ let previous = -1;
2448
+ let unchanged = 0;
2449
+ let clear = 0;
2450
+ while (Date.now() < deadline) {
2451
+ const count = await page.evaluate((selector) => {
2452
+ let visible = 0;
2453
+ for (const el of Array.from(document.querySelectorAll(selector))) {
2454
+ const rect = el.getBoundingClientRect();
2455
+ if (rect.width > 0 && rect.height > 0) visible++;
2456
+ }
2457
+ return visible;
2458
+ }, PLACEHOLDER_SELECTOR).catch(() => 0);
2459
+ if (count === 0) {
2460
+ if (++clear >= PLACEHOLDER_CLEAR_POLLS) return;
2461
+ await page.waitForTimeout(PLACEHOLDER_POLL_MS);
2462
+ continue;
2463
+ }
2464
+ clear = 0;
2465
+ if (count === previous) {
2466
+ if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
2467
+ } else {
2468
+ unchanged = 0;
2469
+ previous = count;
2470
+ }
2471
+ await page.waitForTimeout(PLACEHOLDER_POLL_MS);
2472
+ }
2473
+ }
2474
+ async function gotoSettled(page, url, timeout = 3e4) {
2475
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout });
2476
+ await page.waitForLoadState("networkidle", { timeout: 2500 }).catch(() => {
2477
+ });
2478
+ await waitForDomStable(page).catch(() => {
2479
+ });
2480
+ await waitForPlaceholdersGone(page).catch(() => {
2481
+ });
2482
+ }
1265
2483
  function idSelector(id) {
1266
2484
  return /^[A-Za-z_-][\w-]*$/.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
1267
2485
  }
2486
+ function ariaLabelSelector(label) {
2487
+ return `[aria-label="${label.replace(/(["\\])/g, "\\$1")}"]`;
2488
+ }
1268
2489
  function getActionForType(type) {
1269
2490
  switch (type) {
1270
2491
  case "button":
@@ -1282,169 +2503,228 @@ function getActionForType(type) {
1282
2503
  return "type";
1283
2504
  }
1284
2505
  }
2506
+ async function sitemapRoutes(pageUrl) {
2507
+ let origin;
2508
+ try {
2509
+ origin = new URL(pageUrl).origin;
2510
+ } catch {
2511
+ return [];
2512
+ }
2513
+ try {
2514
+ const res = await fetch(`${origin}/sitemap.xml`, { signal: AbortSignal.timeout(4e3) });
2515
+ if (!res.ok) return [];
2516
+ const xml = await res.text();
2517
+ const seen = /* @__PURE__ */ new Set();
2518
+ const routes = [];
2519
+ for (const m of xml.matchAll(/<loc>([^<]+)<\/loc>/g)) {
2520
+ const raw = (m[1] ?? "").trim();
2521
+ let u;
2522
+ try {
2523
+ u = new URL(raw);
2524
+ } catch {
2525
+ continue;
2526
+ }
2527
+ if (u.origin !== origin) continue;
2528
+ if (/\.(txt|xml|json|png|jpg|svg|ico|pdf)$/i.test(u.pathname)) continue;
2529
+ const path = u.pathname.replace(/\/+$/, "");
2530
+ if (!path || seen.has(u.href)) continue;
2531
+ seen.add(u.href);
2532
+ routes.push({ text: path.slice(1), href: u.href });
2533
+ if (routes.length >= 20) break;
2534
+ }
2535
+ return routes;
2536
+ } catch {
2537
+ return [];
2538
+ }
2539
+ }
1285
2540
  var noopTask = () => ({ succeed: () => {
1286
2541
  } });
1287
2542
  var CRAWL_CACHE_TTL_MS = 5 * 60 * 1e3;
1288
2543
  var CRAWL_CACHE_MAX_ENTRIES = 50;
1289
2544
  var crawlCache = /* @__PURE__ */ new Map();
1290
2545
  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);
2546
+ const cacheKey2 = viewport ? `${url}@${viewport.width}x${viewport.height}` : url;
2547
+ const cached = crawlCache.get(cacheKey2);
1293
2548
  if (cached && Date.now() - cached.timestamp < CRAWL_CACHE_TTL_MS) {
1294
2549
  task("Using cached crawl").succeed(`Found ${cached.elements.length} interactive elements (cached)`);
1295
2550
  return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
1296
2551
  }
1297
2552
  const browserTask = task("Launching browser");
1298
2553
  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();
2554
+ try {
2555
+ const context = await browser.newContext({
2556
+ ...storageStatePath ? { storageState: storageStatePath } : {},
2557
+ // Crawl at the size we will record at, or the measurements below describe
2558
+ // a page nobody will ever see.
2559
+ ...viewport ? { viewport } : {}
2560
+ });
2561
+ const page = await context.newPage();
2562
+ browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
2563
+ const crawlTask = task("Crawling page");
2564
+ await gotoSettled(page, url, 3e4);
2565
+ const title = await page.title();
2566
+ const scrollable = await page.evaluate(
2567
+ "Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
2568
+ );
2569
+ const rawElements = await page.evaluate(`
2570
+ (() => {
2571
+ const items = [];
2572
+
2573
+ // Helper to get parent context
2574
+ const getCtx = (el) => {
2575
+ let p = el.parentElement;
2576
+ while (p && p !== document.body) {
2577
+ if (p.id) return '#' + p.id;
2578
+ const cls = Array.from(p.classList).find(c =>
2579
+ /toolbar|header|footer|sidebar|panel|modal|dialog|nav|menu|controls|actions/i.test(c)
2580
+ );
2581
+ if (cls) return '.' + cls;
2582
+ if (['header', 'footer', 'nav', 'main', 'aside', 'form'].includes(p.tagName.toLowerCase())) {
2583
+ return p.tagName.toLowerCase();
2584
+ }
2585
+ p = p.parentElement;
1328
2586
  }
1329
- p = p.parentElement;
1330
- }
1331
- return '';
1332
- };
2587
+ return '';
2588
+ };
1333
2589
 
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
- });
2590
+ // Buttons (use textContent to catch hidden elements too)
2591
+ document.querySelectorAll('button, [role="button"]').forEach(el => {
2592
+ const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
2593
+ const testId = el.getAttribute('data-testid');
2594
+ const ariaLabel = el.getAttribute('aria-label');
2595
+ if (!text && !testId && !ariaLabel) return;
2596
+ items.push({ type: 'button', text: text || ariaLabel || testId, testId, ctx: getCtx(el) });
2597
+ });
1342
2598
 
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
- }
2599
+ // Inputs (including file inputs, range sliders, checkboxes, selects)
2600
+ document.querySelectorAll('input, textarea, select').forEach(el => {
2601
+ if (el.type === 'hidden') return;
2602
+ const testId = el.getAttribute('data-testid');
2603
+ const inputType = el.type || 'text';
2604
+
2605
+ // Get label text for any input type
2606
+ let labelText = '';
2607
+ if (el.labels?.[0]) {
2608
+ labelText = el.labels[0].textContent?.trim() || '';
2609
+ } else if (el.id) {
2610
+ const label = document.querySelector('label[for="' + el.id + '"]');
2611
+ if (label) labelText = label.textContent?.trim() || '';
2612
+ }
1357
2613
 
1358
- const text = labelText || el.placeholder || el.name || el.id || '';
2614
+ const text = labelText || el.placeholder || el.name || el.id || '';
1359
2615
 
1360
- // Include file, range, and checkbox inputs even without text
1361
- const alwaysInclude = ['file', 'range', 'checkbox'].includes(inputType);
1362
- if (!text && !testId && !alwaysInclude) return;
2616
+ // Include file, range, and checkbox inputs even without text
2617
+ const alwaysInclude = ['file', 'range', 'checkbox'].includes(inputType);
2618
+ if (!text && !testId && !alwaysInclude) return;
1363
2619
 
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'
2620
+ items.push({
2621
+ type: inputType,
2622
+ text: text || (inputType === 'file' ? 'file upload' : inputType === 'range' ? 'slider' : inputType === 'checkbox' ? 'checkbox' : testId),
2623
+ testId,
2624
+ id: el.id,
2625
+ name: el.name,
2626
+ placeholder: el.placeholder,
2627
+ isFile: inputType === 'file'
2628
+ });
1372
2629
  });
1373
- });
1374
2630
 
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
- });
2631
+ // Links (use textContent to catch hidden elements too)
2632
+ document.querySelectorAll('a[href]').forEach(el => {
2633
+ const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
2634
+ if (!text || text.length < 2) return;
2635
+ // Keep the destination, not just the label. Without it the planner
2636
+ // can name a page in a caption but has no way to go there, so every
2637
+ // line gets forced onto whatever is already on screen.
2638
+ let href = '';
2639
+ try {
2640
+ const u = new URL(el.getAttribute('href') || '', location.href);
2641
+ if (u.origin === location.origin) href = u.href;
2642
+ } catch { /* a javascript: or mailto: href is not somewhere to go */ }
2643
+ items.push({ type: 'link', text, href });
2644
+ });
1380
2645
 
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) {
2646
+ return items.slice(0, 40);
2647
+ })()
2648
+ `);
2649
+ const filteredElements = rawElements.filter((el) => {
2650
+ if (!el.text) return true;
2651
+ const text = el.text.toLowerCase().trim();
2652
+ return !EXCLUDED_LINK_PATTERNS.some((pattern) => text.includes(pattern));
2653
+ });
2654
+ const btnTexts = {};
2655
+ for (const el of filteredElements) {
2656
+ if (el.type === "button" && el.text) {
2657
+ btnTexts[el.text] = (btnTexts[el.text] || 0) + 1;
2658
+ }
2659
+ }
2660
+ const elements = filteredElements.map((el) => {
2661
+ let selector;
2662
+ if (el.testId) {
2663
+ selector = `[data-testid="${el.testId}"]`;
2664
+ } else if (el.isFile || el.type === "file") {
2665
+ selector = 'input[type="file"]';
2666
+ } else if (el.type === "range") {
2667
+ selector = el.id ? idSelector(el.id) : 'input[type="range"]';
2668
+ } else if (el.type === "checkbox") {
2669
+ selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
2670
+ } else if (el.type === "button") {
2671
+ if (btnTexts[el.text] === 1) {
2672
+ selector = `text="${el.text}"`;
2673
+ } else {
2674
+ selector = el.ctx ? `${el.ctx} >> text="${el.text}"` : `button:has-text("${el.text}")`;
2675
+ }
2676
+ } else if (el.type === "link") {
1407
2677
  selector = `text="${el.text}"`;
2678
+ } else if (el.id) {
2679
+ selector = idSelector(el.id);
2680
+ } else if (el.name) {
2681
+ selector = `[name="${el.name}"]`;
2682
+ } else if (el.placeholder) {
2683
+ selector = `[placeholder="${el.placeholder}"]`;
1408
2684
  } else {
1409
- selector = el.ctx ? `${el.ctx} >> text="${el.text}"` : `button:has-text("${el.text}")`;
2685
+ selector = `text="${el.text}"`;
1410
2686
  }
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";
2687
+ let normalizedType;
2688
+ if (el.isFile || el.type === "file") {
2689
+ normalizedType = "file";
2690
+ } else if (el.type === "range") {
2691
+ normalizedType = "range";
2692
+ } else if (el.type === "checkbox") {
2693
+ normalizedType = "checkbox";
2694
+ } else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
2695
+ normalizedType = "select";
2696
+ } else if (el.type === "button" || el.type === "link") {
2697
+ normalizedType = el.type;
2698
+ } else {
2699
+ normalizedType = "input";
2700
+ }
2701
+ const action = getActionForType(normalizedType);
2702
+ const target = normalizedType === "file" ? selector : (el.text || "").toLowerCase();
2703
+ return { type: normalizedType, text: el.text, selector, action, target, href: el.href };
2704
+ });
2705
+ await browser.close();
2706
+ for (const route of await sitemapRoutes(url)) {
2707
+ if (elements.some((e) => e.href === route.href)) continue;
2708
+ elements.push({
2709
+ type: "link",
2710
+ text: route.text,
2711
+ selector: "",
2712
+ action: "click",
2713
+ target: route.text,
2714
+ href: route.href
2715
+ });
1435
2716
  }
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);
2717
+ crawlTask.succeed(`Found ${elements.length} interactive elements`);
2718
+ crawlCache.set(cacheKey2, { timestamp: Date.now(), title, elements: [...elements], scrollable });
2719
+ if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
2720
+ const oldest = crawlCache.keys().next().value;
2721
+ if (oldest !== void 0) crawlCache.delete(oldest);
2722
+ }
2723
+ return { title, elements, scrollable };
2724
+ } finally {
2725
+ await browser.close().catch(() => {
2726
+ });
1446
2727
  }
1447
- return { title, elements, scrollable };
1448
2728
  }
1449
2729
 
1450
2730
  // src/lib/cursor.ts
@@ -1557,9 +2837,13 @@ function parseAIJson(content) {
1557
2837
 
1558
2838
  // src/lib/prompts.ts
1559
2839
  init_utils();
1560
- function buildSystemPrompt(maxSteps) {
2840
+ function buildSystemPrompt(maxSteps, profile) {
1561
2841
  let scope;
1562
- if (maxSteps <= 5) {
2842
+ if (profile === "wide") {
2843
+ scope = `SCOPE: Educational tutorial (${maxSteps} steps max) - comprehensive, structured coverage of every major feature area, in a logical teaching order`;
2844
+ } else if (profile === "short") {
2845
+ 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`;
2846
+ } else if (maxSteps <= 5) {
1563
2847
  scope = `SCOPE: Minimal demo (${maxSteps} steps max) - ONE complete user journey only`;
1564
2848
  } else if (maxSteps <= 8) {
1565
2849
  scope = `SCOPE: Standard demo (${maxSteps} steps max) - One journey with 1-2 options`;
@@ -1571,9 +2855,47 @@ function buildSystemPrompt(maxSteps) {
1571
2855
  ${scope}
1572
2856
 
1573
2857
  CORE FLOW (always follow this structure):
1574
- 1. wait - Intro (what does this app do?)
2858
+ 1. wait - the hook, over the app's own card (see OPENING AND CLOSING)
1575
2859
  2. Actions - Complete ONE user journey (upload\u2192process\u2192result)
1576
- 3. wait - Outro (what was accomplished?)
2860
+ 3. Stop. The closing card is appended for you - do not write one
2861
+
2862
+ NARRATION STYLE:
2863
+ \u2022 Professional and direct, like a good tutorial - never condescending, never explaining as if to a child
2864
+ \u2022 NEVER mention step numbers or sequencing ("step one", "first step", "in this step") - just say what is happening
2865
+ \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
2866
+ \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
2867
+ \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
2868
+ \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
2869
+ \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
2870
+ \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
2871
+ \u2022 Caption length drives step length - keep captions tight so the video moves
2872
+
2873
+ OPENING AND CLOSING (a fixed template - both cards are built for you):
2874
+ \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
2875
+ \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
2876
+ \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
2877
+ \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
2878
+ \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
2879
+
2880
+ HOW IT SHOULD SOUND (this is read aloud, not printed on a slide):
2881
+ \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
2882
+ \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
2883
+ \u2022 The free tier is "try it free", or just "free". Never "free to start", which reads as a pricing table talking
2884
+ \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
2885
+ \u2022 Say the thing, do not build up to it. No "in this video we will", no "let's take a look at"
2886
+
2887
+ WHAT TO COVER (a tour of tabs is a menu, not a demo):
2888
+ \u2022 Lead with the outcome the app produces, not the screens it has
2889
+ \u2022 Say where that outcome ENDS UP when the app puts it somewhere - search results, AI assistant answers, directories, an app store
2890
+ \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
2891
+ \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
2892
+ \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
2893
+ \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
2894
+ \u2022 Prefer one feature shown working over three named in passing
2895
+ \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
2896
+ \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
2897
+ \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
2898
+ \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
2899
 
1578
2900
  TARGET SYNTAX (simplified):
1579
2901
  \u2022 Buttons/Links: Use plain text like "extract frame", "download" (case insensitive, partial match OK)
@@ -1596,9 +2918,12 @@ FORBIDDEN:
1596
2918
 
1597
2919
  ACTION FORMATS (STRICT - follow exactly):
1598
2920
  \u2022 click: { "action": "click", "target": "extract frame" } - use simplified button text
2921
+ \u2022 hover: { "action": "hover", "target": "pricing card" } - point the cursor at a feature while the caption explains it, without clicking
1599
2922
  \u2022 type: { "action": "type", "target": "input[name=\\"field\\"]", "value": "text to type" }
1600
2923
  \u2022 upload: { "action": "upload", "target": "input[type=\\"file\\"]", "value": "[file]" }
2924
+ \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
2925
  \u2022 wait: { "action": "wait" } - for intro/outro captions
2926
+ \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
2927
  \u2022 scroll: { "action": "scroll", "value": "-80" } - negative=zoom out, positive=zoom in
1603
2928
  \u2022 drag: { "action": "drag", "target": "input[type=\\"range\\"]", "value": "50" }
1604
2929
  \u2022 select: { "action": "select", "target": "select[name=\\"..\\"]", "value": "option" }
@@ -1623,6 +2948,17 @@ Purpose: ${description}` : "";
1623
2948
  const selectables = elements.filter((e) => e.action === "select");
1624
2949
  const checkables = elements.filter((e) => e.action === "check");
1625
2950
  const elementDict = [];
2951
+ const navigables = elements.filter((e) => e.href);
2952
+ const seenHref = /* @__PURE__ */ new Set();
2953
+ const linkTargets = navigables.filter((e) => {
2954
+ if (!e.href || seenHref.has(e.href)) return false;
2955
+ seenHref.add(e.href);
2956
+ return true;
2957
+ });
2958
+ if (linkTargets.length > 0) {
2959
+ elementDict.push(`NAVIGATE targets (links found on the page; same-origin paths not listed here are also allowed):
2960
+ ${linkTargets.map((e) => `\u2022 "${e.text}" -> ${e.href}`).join("\n")}`);
2961
+ }
1626
2962
  if (clickables.length > 0) {
1627
2963
  elementDict.push(`CLICK targets (use simplified text):
1628
2964
  ${clickables.map((e) => `\u2022 "${e.target || e.text.toLowerCase()}"`).join("\n")}`);
@@ -1689,7 +3025,16 @@ TARGET SYNTAX:
1689
3025
  }
1690
3026
 
1691
3027
  // src/lib/styles.ts
1692
- import { buildAssHeader } from "@spark-apps/video-kit";
3028
+ import {
3029
+ assTimeFromMs,
3030
+ buildAssHeader,
3031
+ chunkWordsToLines,
3032
+ fittingMaxChars,
3033
+ hexToAss as hexToAss2,
3034
+ mergePunctuationTokens,
3035
+ scaleToFit,
3036
+ toCaptionCase
3037
+ } from "@spark-apps/video-kit";
1693
3038
  var CURSOR_MOVE_DURATION = 800;
1694
3039
  var CURSOR_CSS = `
1695
3040
  #qp-cursor {
@@ -1728,16 +3073,250 @@ var CURSOR_SCRIPT = `
1728
3073
  cursor.style.top = window.innerHeight / 2 + 'px';
1729
3074
  })();
1730
3075
  `;
1731
- function generateASSHeader(width, height, font, size, primaryColor, secondaryColor, outlineColor, backColor, bold, outlineWidth, shadow, marginBottom) {
3076
+ function generateASSHeader(width, height, font, size, primaryColor, secondaryColor, outlineColor, backColor, bold, outlineWidth, shadow, marginBottom, alignment = 2) {
1732
3077
  return buildAssHeader({
1733
3078
  title: "QuickPeek Demo",
1734
3079
  width,
1735
3080
  height,
1736
3081
  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`
3082
+ `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
3083
  ]
1739
3084
  });
1740
3085
  }
3086
+ var SIDE_MARGIN = 40;
3087
+ function resolveCaptionFont(font) {
3088
+ if (process.platform === "linux" && font === "Arial Black") return "DejaVu Sans";
3089
+ return font;
3090
+ }
3091
+ function boldOverride(captions) {
3092
+ if (captions.highlight.bold === captions.regular.bold) return { lit: "", rest: "" };
3093
+ return {
3094
+ lit: captions.highlight.bold ? "\\b1" : "\\b0",
3095
+ rest: captions.regular.bold ? "\\b1" : "\\b0"
3096
+ };
3097
+ }
3098
+ function positionToAlignment(position, leftAligned = false) {
3099
+ const base = leftAligned ? 1 : 2;
3100
+ if (position === "center") return base + 3;
3101
+ if (position === "top") return base + 6;
3102
+ return base;
3103
+ }
3104
+ function positionMarginV(position, margin) {
3105
+ return position === "center" ? 0 : margin;
3106
+ }
3107
+ function packLines(words, maxChars) {
3108
+ return chunkWordsToLines(mergePunctuationTokens(words), maxChars).filter((line) => line.length > 0);
3109
+ }
3110
+ function renderKaraokeAss(ctx) {
3111
+ const { captions, width, height } = ctx;
3112
+ const regularColor = hexToAss2(captions.regular.color);
3113
+ const highlightColor = hexToAss2(captions.highlight.color);
3114
+ const outline = hexToAss2(captions.outline);
3115
+ const back = hexToAss2(captions.outline, 128);
3116
+ const highlightScale = Math.round(captions.highlight.size / captions.regular.size * 100);
3117
+ const header = generateASSHeader(
3118
+ width,
3119
+ height,
3120
+ resolveCaptionFont(captions.font),
3121
+ captions.regular.size,
3122
+ highlightColor,
3123
+ regularColor,
3124
+ outline,
3125
+ back,
3126
+ captions.regular.bold,
3127
+ captions.outlineWidth,
3128
+ captions.shadow,
3129
+ positionMarginV(captions.position, captions.marginBottom),
3130
+ positionToAlignment(captions.position)
3131
+ );
3132
+ const usable = width - SIDE_MARGIN * 2;
3133
+ const maxChars = fittingMaxChars(width, captions.regular.size, SIDE_MARGIN, captions.maxChars, true);
3134
+ const lines = [];
3135
+ for (const cue of ctx.cues) {
3136
+ for (const line of packLines(cue.words, maxChars)) {
3137
+ const plain = line.map((w) => w.word).join(" ");
3138
+ const lineScale = scaleToFit(plain, captions.regular.size, usable, true);
3139
+ const popScale = Math.round(highlightScale * lineScale / 100);
3140
+ const boldTags = boldOverride(captions);
3141
+ let karaokeText = "";
3142
+ for (const word of line) {
3143
+ const duration = Math.round((word.end - word.start) / 10);
3144
+ karaokeText += `{\\kf${duration}\\fscx${popScale}\\fscy${popScale}${boldTags.lit}}${word.word}{\\fscx${lineScale}\\fscy${lineScale}${boldTags.rest}} `;
3145
+ }
3146
+ const start = assTimeFromMs(line[0].start);
3147
+ const end = assTimeFromMs(line[line.length - 1].end);
3148
+ lines.push(`Dialogue: 0,${start},${end},Karaoke,,0,0,0,,${karaokeText.trim()}`);
3149
+ }
3150
+ }
3151
+ return `${header}${lines.join("\n")}
3152
+ `;
3153
+ }
3154
+ function renderHormoziAss(ctx) {
3155
+ const { captions, width, height } = ctx;
3156
+ const font = resolveCaptionFont(captions.font);
3157
+ const primary = hexToAss2(captions.regular.color);
3158
+ const highlight = hexToAss2(captions.highlight.color);
3159
+ const outline = hexToAss2(captions.outline);
3160
+ const back = hexToAss2(captions.outline, 128);
3161
+ const alignment = positionToAlignment(captions.position);
3162
+ const marginV = positionMarginV(captions.position, captions.marginBottom);
3163
+ const bold = captions.regular.bold ? -1 : 0;
3164
+ const header = buildAssHeader({
3165
+ title: "QuickPeek Demo",
3166
+ width,
3167
+ height,
3168
+ styles: [
3169
+ `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`
3170
+ ],
3171
+ scaledBorderAndShadow: true
3172
+ });
3173
+ const maxChars = fittingMaxChars(width, captions.regular.size, SIDE_MARGIN, captions.maxChars, true);
3174
+ const lines = [];
3175
+ for (const cue of ctx.cues) {
3176
+ for (const line of packLines(cue.words, maxChars)) {
3177
+ const plain = line.map((w) => w.word).join(" ");
3178
+ const lineScale = scaleToFit(plain, captions.regular.size, width - SIDE_MARGIN * 2, true);
3179
+ const scaleTag = lineScale < 100 ? `{\\fscx${lineScale}\\fscy${lineScale}}` : "";
3180
+ const boldTags = boldOverride(captions);
3181
+ for (let i = 0; i < line.length; i++) {
3182
+ const word = line[i];
3183
+ const nextStart = i + 1 < line.length ? line[i + 1].start : line[line.length - 1].end;
3184
+ const text = line.map(
3185
+ (other, j) => j === i ? `{\\1c${highlight}${boldTags.lit}}${other.word}{\\1c${primary}${boldTags.rest}}` : other.word
3186
+ ).join(" ");
3187
+ lines.push(
3188
+ `Dialogue: 0,${assTimeFromMs(word.start)},${assTimeFromMs(nextStart)},Default,,0,0,0,,${scaleTag}${text}`
3189
+ );
3190
+ }
3191
+ }
3192
+ }
3193
+ return `${header}${lines.join("\n")}
3194
+ `;
3195
+ }
3196
+ function looksLikeAddress(token) {
3197
+ return /^(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?[.,!?]?$/i.test(token);
3198
+ }
3199
+ function renderShortsAss(ctx) {
3200
+ const { captions, width, height } = ctx;
3201
+ const font = resolveCaptionFont(captions.font === "Arial" ? "Arial Black" : captions.font);
3202
+ const primary = hexToAss2(captions.regular.color);
3203
+ const highlight = hexToAss2(captions.highlight.color);
3204
+ const fontSize = Math.max(captions.regular.size, Math.round(height / 20));
3205
+ const marginV = captions.position === "bottom" ? Math.round(height * 0.24) : captions.position === "top" ? Math.round(height * 0.1) : 0;
3206
+ const alignment = positionToAlignment(captions.position);
3207
+ const outline = Math.max(2, Math.round(fontSize / 22));
3208
+ const upper = true;
3209
+ const caseOf = (t) => looksLikeAddress(t) ? t : toCaptionCase(t, upper);
3210
+ const maxChars = fittingMaxChars(width, fontSize, SIDE_MARGIN, captions.maxChars, upper);
3211
+ const header = `[Script Info]
3212
+ Title: QuickPeek Demo
3213
+ ScriptType: v4.00+
3214
+ PlayResX: ${width}
3215
+ PlayResY: ${height}
3216
+ ScaledBorderAndShadow: yes
3217
+ WrapStyle: 0
3218
+
3219
+ [V4+ Styles]
3220
+ Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
3221
+ 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
3222
+
3223
+ [Events]
3224
+ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
3225
+ `;
3226
+ const lines = [];
3227
+ for (const cue of ctx.cues) {
3228
+ for (const lineWords of packLines(cue.words, maxChars)) {
3229
+ const plain = lineWords.map((w) => caseOf(w.word)).join(" ");
3230
+ const scale = scaleToFit(plain, fontSize, width - SIDE_MARGIN * 2, upper);
3231
+ const scaleTag = scale < 100 ? `{\\fscx${scale}\\fscy${scale}}` : "";
3232
+ const boldTags = boldOverride(captions);
3233
+ for (let i = 0; i < lineWords.length; i++) {
3234
+ const text = lineWords.map(
3235
+ (w, j) => j === i ? `{\\c${highlight}${boldTags.lit}}${caseOf(w.word)}{\\c${primary}${boldTags.rest}}` : caseOf(w.word)
3236
+ ).join(" ");
3237
+ const end = i === lineWords.length - 1 ? lineWords[i].end : lineWords[i + 1].start;
3238
+ lines.push(
3239
+ `Dialogue: 0,${assTimeFromMs(lineWords[i].start)},${assTimeFromMs(end)},Shorts,,0,0,0,,${scaleTag}${text}`
3240
+ );
3241
+ }
3242
+ }
3243
+ }
3244
+ return `${header}${lines.join("\n")}
3245
+ `;
3246
+ }
3247
+ function renderMinimalAss(ctx) {
3248
+ const { captions, width, height } = ctx;
3249
+ const font = resolveCaptionFont(captions.font);
3250
+ const primary = hexToAss2(captions.regular.color);
3251
+ const fontSize = Math.round(captions.regular.size * 0.6);
3252
+ const alignment = positionToAlignment(captions.position, true);
3253
+ const marginV = positionMarginV(captions.position, 30);
3254
+ const header = buildAssHeader({
3255
+ title: "QuickPeek Demo",
3256
+ width,
3257
+ height,
3258
+ styles: [
3259
+ // BorderStyle 3 draws an opaque box (&HB0...: mostly-opaque black) behind the text
3260
+ `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`
3261
+ ],
3262
+ scaledBorderAndShadow: true
3263
+ });
3264
+ const maxChars = fittingMaxChars(width, fontSize, 20, captions.maxChars * 2, true);
3265
+ const lines = [];
3266
+ for (const cue of ctx.cues) {
3267
+ for (const line of packLines(cue.words, maxChars)) {
3268
+ const start = assTimeFromMs(line[0].start);
3269
+ const end = assTimeFromMs(line[line.length - 1].end);
3270
+ lines.push(`Dialogue: 0,${start},${end},MinimalBg,,0,0,0,,${line.map((w) => w.word).join(" ")}`);
3271
+ }
3272
+ }
3273
+ return `${header}${lines.join("\n")}
3274
+ `;
3275
+ }
3276
+ var CAPTION_DESIGN_LONG_EDGE = 1920;
3277
+ function captionCanvas(width, height) {
3278
+ const longEdge = Math.max(width, height);
3279
+ if (!Number.isFinite(longEdge) || longEdge <= 0) return { width, height };
3280
+ const scale = CAPTION_DESIGN_LONG_EDGE / longEdge;
3281
+ return { width: Math.round(width * scale), height: Math.round(height * scale) };
3282
+ }
3283
+ function resolveCaptions(css, video) {
3284
+ const { width, height } = captionCanvas(video.width, video.height);
3285
+ const canvas = defaultCaptionsFor(width, height);
3286
+ const base = {
3287
+ ...canvas,
3288
+ font: css.font,
3289
+ outline: css.outline,
3290
+ regular: { ...canvas.regular, color: css.regular.color },
3291
+ highlight: { ...canvas.highlight, color: css.highlight.color }
3292
+ };
3293
+ const over = video.captions;
3294
+ if (!over) return base;
3295
+ return {
3296
+ ...base,
3297
+ ...over,
3298
+ regular: { ...base.regular, ...over.regular },
3299
+ highlight: { ...base.highlight, ...over.highlight }
3300
+ };
3301
+ }
3302
+ function renderCaptionsAss(context) {
3303
+ const { width, height } = captionCanvas(context.width, context.height);
3304
+ const cues = context.cues.map((cue) => ({
3305
+ ...cue,
3306
+ words: cue.words.map((w) => ({ ...w, word: toCaptionCase(w.word, true) }))
3307
+ }));
3308
+ const ctx = { ...context, width, height, cues };
3309
+ switch (ctx.captions.preset) {
3310
+ case "hormozi":
3311
+ return renderHormoziAss(ctx);
3312
+ case "shorts":
3313
+ return renderShortsAss(ctx);
3314
+ case "minimal":
3315
+ return renderMinimalAss(ctx);
3316
+ default:
3317
+ return renderKaraokeAss(ctx);
3318
+ }
3319
+ }
1741
3320
 
1742
3321
  // src/lib/subscription-client.ts
1743
3322
  async function registerFreeUser(email) {
@@ -1774,14 +3353,17 @@ async function registerFreeUser(email) {
1774
3353
 
1775
3354
  // src/lib/tts/cache.ts
1776
3355
  init_helpers();
1777
- import { join as join3 } from "path";
1778
- function canReuseVoiceover(steps, voice, existingMeta) {
3356
+ import { join as join4 } from "path";
3357
+ function canReuseVoiceover(steps, voice, rate, existingMeta) {
1779
3358
  if (!existingMeta) {
1780
3359
  return { canReuse: false, reason: "No existing voiceover found" };
1781
3360
  }
1782
3361
  if (existingMeta.voice !== voice) {
1783
3362
  return { canReuse: false, reason: `Voice changed (${existingMeta.voice} \u2192 ${voice})` };
1784
3363
  }
3364
+ if (existingMeta.rate !== rate) {
3365
+ return { canReuse: false, reason: `Narration speed changed (${existingMeta.rate ?? "unknown"} \u2192 ${rate})` };
3366
+ }
1785
3367
  if (existingMeta.stepCount !== steps.length) {
1786
3368
  return { canReuse: false, reason: `Step count changed (${existingMeta.stepCount} \u2192 ${steps.length})` };
1787
3369
  }
@@ -1789,37 +3371,54 @@ function canReuseVoiceover(steps, voice, existingMeta) {
1789
3371
  if (existingMeta.captionsHash === currentHash) {
1790
3372
  return { canReuse: true };
1791
3373
  }
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" };
3374
+ return { canReuse: false, reason: "Captions changed" };
1799
3375
  }
1800
3376
  async function loadExistingVoiceover(steps, outputDir, meta, config) {
1801
3377
  const { estimateWordTimings: estimateWordTimings2 } = await Promise.resolve().then(() => (init_timing(), timing_exports));
1802
- const voiceoverPath = join3(outputDir, "voiceover.wav");
3378
+ const voiceoverPath = join4(outputDir, "voiceover.wav");
1803
3379
  const stepAudio = /* @__PURE__ */ new Map();
3380
+ const captionsUnchanged = meta.captionsHash === getCaptionsHash(steps);
1804
3381
  for (let i = 0; i < steps.length; i++) {
1805
3382
  const step = steps[i];
1806
3383
  const stepNum = i + 1;
1807
3384
  const duration = meta.stepDurations[stepNum] || config.timing.minStepDuration;
3385
+ const savedWords = captionsUnchanged ? meta.stepWords?.[stepNum] : void 0;
1808
3386
  stepAudio.set(stepNum, {
1809
3387
  duration,
1810
- words: estimateWordTimings2(step.caption || "", duration)
3388
+ words: savedWords && savedWords.length > 0 ? savedWords : estimateWordTimings2(step.caption || "", duration)
1811
3389
  });
1812
3390
  }
1813
3391
  return { voiceoverPath, stepAudio };
1814
3392
  }
1815
3393
 
1816
3394
  // src/lib/tts/generate.ts
3395
+ import { createHash } from "crypto";
3396
+ import { existsSync as existsSync3 } from "fs";
3397
+ import { copyFile, mkdir, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
3398
+ import { join as join6 } from "path";
3399
+ import { toSpokenForm } from "@spark-apps/video-kit";
1817
3400
  init_ffmpeg2();
1818
- import { mkdir } from "fs/promises";
1819
- import { join as join5 } from "path";
3401
+ init_utils();
1820
3402
 
1821
3403
  // src/lib/voices.ts
1822
- import { WIDE_VOICES } from "@spark-apps/video-kit";
3404
+ import { MALE_VOICES, MULTILINGUAL_VOICES, resolveVoice, WIDE_VOICES } from "@spark-apps/video-kit";
3405
+ import { MALE_VOICES as MALE_VOICES2, MULTILINGUAL_VOICES as MULTILINGUAL_VOICES2, WIDE_VOICES as WIDE_VOICES2 } from "@spark-apps/video-kit";
3406
+ var INTERNATIONAL_VOICE = "international";
3407
+ function internationalVoiceFor(lang, gender = "female") {
3408
+ if (!hasVoice(lang)) return null;
3409
+ const raw = lang.trim().toLowerCase();
3410
+ const multilingual = MULTILINGUAL_VOICES[raw] ?? MULTILINGUAL_VOICES[raw.slice(0, 2)];
3411
+ if (gender === "male") return voiceFor(lang, gender);
3412
+ return multilingual ?? voiceFor(lang, gender);
3413
+ }
3414
+ function hasVoice(lang) {
3415
+ const raw = lang.trim().toLowerCase();
3416
+ return Boolean(WIDE_VOICES[raw] ?? WIDE_VOICES[raw.slice(0, 2)]);
3417
+ }
3418
+ function voiceFor(lang, gender = "female") {
3419
+ if (!hasVoice(lang)) return null;
3420
+ return resolveVoice(lang, { gender, table: WIDE_VOICES, maleTable: MALE_VOICES });
3421
+ }
1823
3422
 
1824
3423
  // src/lib/tts/generate.ts
1825
3424
  init_helpers();
@@ -1828,39 +3427,78 @@ init_helpers();
1828
3427
  init_helpers();
1829
3428
  import { exec } from "child_process";
1830
3429
  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";
3430
+ import { readFile as readFile2, stat, unlink as unlink3, writeFile as writeFile2 } from "fs/promises";
3431
+ import { join as join5, resolve as resolve2 } from "path";
1833
3432
  import { pipeline } from "stream/promises";
1834
3433
  import { promisify } from "util";
1835
3434
  var execAsync = promisify(exec);
1836
3435
  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));
3436
+ var TICKS_PER_MS = 1e4;
3437
+ var wordsSidecar = (audioPath) => `${audioPath}.words.json`;
3438
+ var pythonEdgeTts = null;
3439
+ function pythonEdgeTtsAvailable() {
3440
+ pythonEdgeTts ??= execAsync(isWindows ? 'wsl python3 -c "import edge_tts"' : 'python3 -c "import edge_tts"', {
3441
+ timeout: 1e4
3442
+ }).then(
3443
+ () => true,
3444
+ () => false
3445
+ );
3446
+ return pythonEdgeTts;
3447
+ }
3448
+ var lockedEngine = null;
3449
+ function resetVoiceEngine() {
3450
+ lockedEngine = null;
3451
+ }
3452
+ function xmlSafe(text) {
3453
+ return text.replace(/[&<>]/g, (c) => c === "&" ? " and " : " ").replace(/\s{2,}/g, " ");
3454
+ }
3455
+ async function synthesizeSpeech(rawText, outputPath, voice, rate) {
3456
+ const text = xmlSafe(rawText);
3457
+ let pythonMsg = "python edge-tts not installed";
3458
+ const tryPython = lockedEngine !== "node" && await pythonEdgeTtsAvailable();
3459
+ if (tryPython) {
3460
+ const attempts = lockedEngine === "python" ? PYTHON_ATTEMPTS * 2 : PYTHON_ATTEMPTS;
3461
+ for (let attempt = 0; attempt < attempts; attempt++) {
3462
+ try {
3463
+ const words2 = await synthesizeWithPython(text, outputPath, voice, rate);
3464
+ lockedEngine = "python";
3465
+ return words2;
3466
+ } catch (err) {
3467
+ pythonMsg = err instanceof Error ? err.message : String(err);
3468
+ if (attempt < attempts - 1) await new Promise((r) => setTimeout(r, 500));
3469
+ }
1846
3470
  }
1847
3471
  }
1848
- await synthesizeWithNode(text, outputPath, voice, pythonMsg);
3472
+ const words = await synthesizeWithNode(text, outputPath, voice, pythonMsg, rate);
3473
+ lockedEngine = "node";
3474
+ return words;
1849
3475
  }
1850
- async function synthesizeWithPython(text, outputPath, voice) {
1851
- const tempScriptPath = join4(resolve2(outputPath, ".."), `tts-${Date.now()}.py`);
3476
+ async function synthesizeWithPython(text, outputPath, voice, rate) {
3477
+ const tempScriptPath = join5(resolve2(outputPath, ".."), `tts-${Date.now()}.py`);
1852
3478
  const wslScriptPath = isWindows ? toWSLPathForTTS(resolve2(tempScriptPath)) : tempScriptPath;
1853
3479
  const wslOutputPath = isWindows ? toWSLPathForTTS(resolve2(outputPath)) : outputPath;
1854
3480
  const pythonScript = `
1855
- import asyncio
3481
+ import asyncio, json
1856
3482
  from edge_tts import Communicate
1857
3483
 
1858
3484
  async def main():
1859
3485
  text = ${JSON.stringify(text)}
1860
3486
  voice = ${JSON.stringify(voice)}
1861
3487
  output = ${JSON.stringify(wslOutputPath)}
1862
- communicate = Communicate(text, voice)
1863
- await communicate.save(output)
3488
+ communicate = Communicate(text, voice${rate ? `, rate=${JSON.stringify(rate)}` : ""})
3489
+ words = []
3490
+ with open(output, "wb") as f:
3491
+ async for chunk in communicate.stream():
3492
+ if chunk["type"] == "audio":
3493
+ f.write(chunk["data"])
3494
+ elif chunk["type"] == "WordBoundary":
3495
+ words.append({
3496
+ "word": chunk["text"],
3497
+ "start": chunk["offset"] / ${TICKS_PER_MS},
3498
+ "end": (chunk["offset"] + chunk["duration"]) / ${TICKS_PER_MS},
3499
+ })
3500
+ with open(output + ".words.json", "w") as f:
3501
+ json.dump(words, f)
1864
3502
 
1865
3503
  asyncio.run(main())
1866
3504
  `;
@@ -1868,24 +3506,42 @@ asyncio.run(main())
1868
3506
  await writeFile2(tempScriptPath, pythonScript);
1869
3507
  const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
1870
3508
  await execAsync(pythonCmd, { timeout: 3e4 });
3509
+ const written = await stat(outputPath).catch(() => null);
3510
+ if (!written?.size) throw new Error("edge-tts produced no audio");
3511
+ return await readWordsSidecar(outputPath);
3512
+ } finally {
3513
+ await unlink3(tempScriptPath).catch(() => {
3514
+ });
3515
+ }
3516
+ }
3517
+ async function readWordsSidecar(outputPath) {
3518
+ const sidecar = wordsSidecar(outputPath);
3519
+ try {
3520
+ const parsed = JSON.parse(await readFile2(sidecar, "utf-8"));
3521
+ return parsed.length > 0 ? parsed : null;
3522
+ } catch {
3523
+ return null;
1871
3524
  } finally {
1872
- await unlink2(tempScriptPath).catch(() => {
3525
+ await unlink3(sidecar).catch(() => {
1873
3526
  });
1874
3527
  }
1875
3528
  }
1876
- async function synthesizeWithNode(text, outputPath, voice, pythonMsg, maxRetries = 2) {
3529
+ async function synthesizeWithNode(text, outputPath, voice, pythonMsg, rate, maxRetries = 2) {
1877
3530
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1878
3531
  try {
1879
3532
  const { MsEdgeTTS, OUTPUT_FORMAT } = await import("msedge-tts");
1880
3533
  const tts = new MsEdgeTTS();
1881
- await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3);
1882
- const { audioStream } = tts.toStream(text);
3534
+ await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3, { wordBoundaryEnabled: true });
3535
+ const { audioStream, metadataStream } = tts.toStream(text, rate ? { rate } : void 0);
3536
+ const metadata = [];
3537
+ metadataStream?.on("data", (chunk) => metadata.push(chunk.toString()));
1883
3538
  await pipeline(audioStream, createWriteStream(outputPath));
3539
+ await new Promise((r) => setTimeout(r, 250));
1884
3540
  const { size } = await stat(outputPath);
1885
3541
  if (size === 0) throw new Error("No audio data received");
1886
- return;
3542
+ return parseWordBoundaries(metadata.join(""));
1887
3543
  } catch (nodeErr) {
1888
- await unlink2(outputPath).catch(() => {
3544
+ await unlink3(outputPath).catch(() => {
1889
3545
  });
1890
3546
  if (attempt === maxRetries) {
1891
3547
  const nodeMsg = nodeErr instanceof Error ? nodeErr.message : String(nodeErr);
@@ -1894,21 +3550,206 @@ async function synthesizeWithNode(text, outputPath, voice, pythonMsg, maxRetries
1894
3550
  await new Promise((r) => setTimeout(r, 500));
1895
3551
  }
1896
3552
  }
3553
+ return null;
3554
+ }
3555
+ function parseWordBoundaries(raw) {
3556
+ const words = [];
3557
+ for (const frame of splitJsonObjects(raw)) {
3558
+ let parsed;
3559
+ try {
3560
+ parsed = JSON.parse(frame);
3561
+ } catch {
3562
+ continue;
3563
+ }
3564
+ for (const entry of parsed.Metadata ?? []) {
3565
+ if (entry.Type !== "WordBoundary") continue;
3566
+ const word = entry.Data?.text?.Text;
3567
+ const offset = entry.Data?.Offset;
3568
+ const duration = entry.Data?.Duration;
3569
+ if (!word || offset === void 0 || duration === void 0) continue;
3570
+ words.push({
3571
+ word,
3572
+ start: offset / TICKS_PER_MS,
3573
+ end: (offset + duration) / TICKS_PER_MS
3574
+ });
3575
+ }
3576
+ }
3577
+ return words.length > 0 ? words : null;
3578
+ }
3579
+ function splitJsonObjects(raw) {
3580
+ const out = [];
3581
+ let depth = 0;
3582
+ let start = -1;
3583
+ let inString = false;
3584
+ let escaped = false;
3585
+ for (let i = 0; i < raw.length; i++) {
3586
+ const ch = raw[i];
3587
+ if (escaped) {
3588
+ escaped = false;
3589
+ continue;
3590
+ }
3591
+ if (inString) {
3592
+ if (ch === "\\") escaped = true;
3593
+ else if (ch === '"') inString = false;
3594
+ continue;
3595
+ }
3596
+ if (ch === '"') inString = true;
3597
+ else if (ch === "{") {
3598
+ if (depth === 0) start = i;
3599
+ depth++;
3600
+ } else if (ch === "}") {
3601
+ depth--;
3602
+ if (depth === 0 && start >= 0) {
3603
+ out.push(raw.slice(start, i + 1));
3604
+ start = -1;
3605
+ }
3606
+ }
3607
+ }
3608
+ return out;
1897
3609
  }
1898
3610
 
1899
3611
  // src/lib/tts/generate.ts
1900
3612
  init_timing();
3613
+ init_helpers();
3614
+ function clipKey(spokenText, voice, rate) {
3615
+ return createHash("sha1").update(`${spokenText}\0${voice}\0${rate}`).digest("hex");
3616
+ }
3617
+ async function reuseClip(cacheDir, key, audioPath) {
3618
+ const mp3 = join6(cacheDir, `${key}.mp3`);
3619
+ const meta = join6(cacheDir, `${key}.json`);
3620
+ if (!existsSync3(mp3) || !existsSync3(meta)) return null;
3621
+ try {
3622
+ const boundaries = JSON.parse(await readFile3(meta, "utf-8"));
3623
+ await copyFile(mp3, audioPath);
3624
+ return boundaries;
3625
+ } catch {
3626
+ return null;
3627
+ }
3628
+ }
3629
+ async function storeClip(cacheDir, key, audioPath, spoken) {
3630
+ try {
3631
+ await mkdir(cacheDir, { recursive: true });
3632
+ await copyFile(audioPath, join6(cacheDir, `${key}.mp3`));
3633
+ await writeFile3(join6(cacheDir, `${key}.json`), JSON.stringify(spoken ?? null));
3634
+ } catch {
3635
+ }
3636
+ }
3637
+ var TTS_POOL = 4;
3638
+ function capStep(config, ms) {
3639
+ const cap = config.timing.maxStepDuration;
3640
+ return cap && cap > 0 ? Math.min(ms, cap) : ms;
3641
+ }
1901
3642
  function estimateDurationFromText(text, wordsPerMinute = 150) {
1902
3643
  const words = text.split(/\s+/).filter((w) => w.length > 0).length;
1903
3644
  return Math.max(Math.round(words / wordsPerMinute * 60 * 1e3), 1500);
1904
3645
  }
1905
- function resolveVoice(config) {
1906
- if (config.audio?.voice) return config.audio.voice;
1907
- if (config.lang) return WIDE_VOICES[config.lang] || null;
3646
+ function resolveVoice2(config) {
3647
+ const named = config.audio?.voice;
3648
+ if (named === INTERNATIONAL_VOICE) {
3649
+ return internationalVoiceFor(config.lang ?? "en", config.audio?.gender);
3650
+ }
3651
+ if (named) return named;
3652
+ if (config.lang) return voiceFor(config.lang, config.audio?.gender);
1908
3653
  return null;
1909
3654
  }
3655
+ function resolveRate(config) {
3656
+ return config.audio?.rate ?? "normal";
3657
+ }
3658
+ var ONES = [
3659
+ "zero",
3660
+ "one",
3661
+ "two",
3662
+ "three",
3663
+ "four",
3664
+ "five",
3665
+ "six",
3666
+ "seven",
3667
+ "eight",
3668
+ "nine",
3669
+ "ten",
3670
+ "eleven",
3671
+ "twelve",
3672
+ "thirteen",
3673
+ "fourteen",
3674
+ "fifteen",
3675
+ "sixteen",
3676
+ "seventeen",
3677
+ "eighteen",
3678
+ "nineteen"
3679
+ ];
3680
+ var TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
3681
+ function speakInteger(n) {
3682
+ if (!Number.isInteger(n) || n < 0 || n > 999) return null;
3683
+ if (n < 20) return ONES[n];
3684
+ if (n < 100) {
3685
+ const t = TENS[Math.floor(n / 10)];
3686
+ const r2 = n % 10;
3687
+ return r2 ? `${t}-${ONES[r2]}` : t;
3688
+ }
3689
+ const h = `${ONES[Math.floor(n / 100)]} hundred`;
3690
+ const r = n % 100;
3691
+ return r ? `${h} and ${speakInteger(r)}` : h;
3692
+ }
3693
+ function speakNumbers(text) {
3694
+ return text.replace(/(\d+)\s*%/g, (m, d) => {
3695
+ const spoken = speakInteger(Number(d));
3696
+ return spoken ? `${spoken} percent` : m;
3697
+ }).replace(/(?<![\d.,£$€])\b(\d{1,3})\b(?![\d.,%])/g, (m, d) => speakInteger(Number(d)) ?? m);
3698
+ }
3699
+ var SPELLED_OUT = /\b([A-Z]{2,5})\b/g;
3700
+ var LETTER_SOUNDS = {
3701
+ A: "ay",
3702
+ B: "bee",
3703
+ C: "see",
3704
+ D: "dee",
3705
+ E: "ee",
3706
+ F: "ef",
3707
+ G: "gee",
3708
+ H: "aitch",
3709
+ I: "eye",
3710
+ J: "jay",
3711
+ K: "kay",
3712
+ L: "el",
3713
+ M: "em",
3714
+ N: "en",
3715
+ O: "oh",
3716
+ P: "pee",
3717
+ Q: "cue",
3718
+ R: "ar",
3719
+ S: "ess",
3720
+ T: "tee",
3721
+ U: "you",
3722
+ V: "vee",
3723
+ W: "double-you",
3724
+ X: "ex",
3725
+ Y: "why",
3726
+ Z: "zed"
3727
+ };
3728
+ function spellAcronyms(text) {
3729
+ return text.replace(
3730
+ SPELLED_OUT,
3731
+ (m) => m.split("").map((c) => LETTER_SOUNDS[c] ?? c).join(" ")
3732
+ );
3733
+ }
3734
+ var SPOKEN_TLD = {
3735
+ ai: "ay-eye"
3736
+ };
3737
+ function bindDomains(text) {
3738
+ return text.replace(/\b([a-z0-9][a-z0-9._+-]*)@(?=[a-z0-9][a-z0-9-]*\.[a-z]{2,24}\b)/gi, "$1-at-").replace(
3739
+ /\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi,
3740
+ (_m, host, tld) => `${host}-dot-${SPOKEN_TLD[tld.toLowerCase()] ?? tld}`
3741
+ );
3742
+ }
3743
+ function speakSymbols(text) {
3744
+ return text.replace(/\s*&\s*/g, " and ").replace(/[<>]/g, " ").replace(/\s{2,}/g, " ").trim();
3745
+ }
3746
+ function spokenForm(caption) {
3747
+ return speakNumbers(spellAcronyms(toSpokenForm(bindDomains(speakSymbols(caption)))));
3748
+ }
1910
3749
  async function generateVoiceover(steps, config, workDir, outputDir, logError, task, info) {
1911
- const voice = resolveVoice(config);
3750
+ resetVoiceEngine();
3751
+ const voice = resolveVoice2(config);
3752
+ const rate = resolveRate(config);
1912
3753
  if (!voice) {
1913
3754
  const stepAudio2 = /* @__PURE__ */ new Map();
1914
3755
  for (let i = 0; i < steps.length; i++) {
@@ -1917,72 +3758,105 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError, ta
1917
3758
  const actionDuration = estimateActionDuration(step);
1918
3759
  const manualDuration = (step.duration ?? 0) * 1e3;
1919
3760
  const mediaDuration = await getMediaStepDuration(step, outputDir);
1920
- const duration = Math.max(
1921
- textDuration,
1922
- actionDuration,
1923
- manualDuration,
1924
- mediaDuration,
1925
- config.timing.minStepDuration
3761
+ const duration = capStep(
3762
+ config,
3763
+ Math.max(textDuration, actionDuration, manualDuration, mediaDuration, config.timing.minStepDuration)
1926
3764
  );
1927
3765
  stepAudio2.set(i + 1, { duration, words: step.caption ? estimateWordTimings(step.caption, duration) : [] });
1928
3766
  }
1929
3767
  info("No voice configured");
1930
3768
  return { voiceoverPath: "", stepAudio: stepAudio2 };
1931
3769
  }
1932
- const audioDir = join5(workDir, "audio");
3770
+ const audioDir = join6(workDir, "audio");
1933
3771
  await mkdir(audioDir, { recursive: true });
1934
- const segments = [];
1935
- const stepAudio = /* @__PURE__ */ new Map();
1936
3772
  const ttsTask = task("Generating voiceover");
1937
- for (let idx = 0; idx < steps.length; idx++) {
3773
+ const speed = config.audio?.speed ?? 1;
3774
+ const factor = speed * RATE_FACTOR[rate];
3775
+ const ratePercent = Math.abs(factor - 1) < 5e-3 ? void 0 : `${factor > 1 ? "+" : ""}${Math.round((factor - 1) * 100)}%`;
3776
+ const perStep = new Array(steps.length);
3777
+ const buildStep = async (idx) => {
1938
3778
  const step = steps[idx];
1939
3779
  const stepNum = idx + 1;
1940
3780
  if (!step.caption) {
1941
3781
  const actionDuration = estimateActionDuration(step);
1942
3782
  const manualDuration = (step.duration ?? 0) * 1e3;
1943
3783
  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`);
3784
+ const silenceDuration = capStep(
3785
+ config,
3786
+ Math.max(actionDuration, manualDuration, mediaDuration, config.timing.minStepDuration)
3787
+ );
3788
+ const silencePath = join6(audioDir, `step-${stepNum}-silence.mp3`);
1946
3789
  await generateSilence(silencePath, silenceDuration);
1947
- segments.push(silencePath);
1948
- stepAudio.set(stepNum, { duration: silenceDuration, words: [] });
1949
- continue;
3790
+ perStep[idx] = { segments: [silencePath], audio: { duration: silenceDuration, words: [] } };
3791
+ return;
1950
3792
  }
1951
- const audioPath = join5(audioDir, `step-${stepNum}.mp3`);
1952
- if (segments.length > 0) await new Promise((r) => setTimeout(r, 200));
3793
+ const audioPath = join6(audioDir, `step-${stepNum}.mp3`);
1953
3794
  try {
1954
- await synthesizeSpeech(step.caption, audioPath, voice);
1955
- segments.push(audioPath);
3795
+ const spokenText = spokenForm(step.caption);
3796
+ const key = clipKey(spokenText, voice, ratePercent ?? "");
3797
+ const cacheDir = join6(outputDir, ".tts-clips");
3798
+ const cached = await reuseClip(cacheDir, key, audioPath);
3799
+ let spoken;
3800
+ if (cached) {
3801
+ spoken = cached;
3802
+ } else {
3803
+ spoken = await synthesizeSpeech(spokenText, audioPath, voice, ratePercent);
3804
+ await trimEdgeSilence(audioPath).catch(() => {
3805
+ });
3806
+ await storeClip(cacheDir, key, audioPath, spoken);
3807
+ }
3808
+ const clips = [audioPath];
1956
3809
  const audioDuration = await getMediaDuration(audioPath);
1957
3810
  const actionDuration = estimateActionDuration(step);
1958
3811
  const manualDuration = (step.duration ?? 0) * 1e3;
1959
3812
  const mediaDuration = await getMediaStepDuration(step, outputDir);
1960
- const stepDuration = Math.max(
1961
- audioDuration,
1962
- config.timing.minStepDuration,
1963
- actionDuration,
1964
- manualDuration,
1965
- mediaDuration
3813
+ const stepDuration = capStep(
3814
+ config,
3815
+ Math.max(audioDuration, config.timing.minStepDuration, actionDuration, manualDuration, mediaDuration)
1966
3816
  );
1967
3817
  if (stepDuration > audioDuration) {
1968
- const paddingPath = join5(audioDir, `step-${stepNum}-padding.mp3`);
3818
+ const paddingPath = join6(audioDir, `step-${stepNum}-padding.mp3`);
1969
3819
  await generateSilence(paddingPath, stepDuration - audioDuration);
1970
- segments.push(paddingPath);
3820
+ clips.push(paddingPath);
1971
3821
  }
1972
- stepAudio.set(stepNum, { duration: stepDuration, words: estimateWordTimings(step.caption, audioDuration) });
3822
+ const words = (config.audio?.transcribe === false ? null : spokenWordTimings(step.caption, spoken)) ?? estimateWordTimings(step.caption, audioDuration);
3823
+ perStep[idx] = { segments: clips, audio: { duration: stepDuration, words } };
1973
3824
  } catch (err) {
1974
3825
  await logError(`TTS Step ${stepNum}`, err);
1975
- stepAudio.set(stepNum, { duration: config.timing.minStepDuration, words: [] });
3826
+ perStep[idx] = { segments: [], audio: { duration: config.timing.minStepDuration, words: [] } };
1976
3827
  }
3828
+ };
3829
+ await runPooled(steps.length, TTS_POOL, buildStep);
3830
+ const segments = [];
3831
+ const stepAudio = /* @__PURE__ */ new Map();
3832
+ for (let idx = 0; idx < steps.length; idx++) {
3833
+ const built = perStep[idx];
3834
+ if (!built) continue;
3835
+ segments.push(...built.segments);
3836
+ stepAudio.set(idx + 1, built.audio);
1977
3837
  }
1978
3838
  const stepsWithCaptions = steps.filter((s) => s.caption).length;
1979
3839
  ttsTask.succeed(`${segments.length}/${stepsWithCaptions} clips`);
1980
3840
  if (segments.length === 0) {
1981
3841
  return { voiceoverPath: "", stepAudio };
1982
3842
  }
3843
+ const leadInPath = join6(audioDir, LEAD_IN_CLIP);
3844
+ await generateSilence(leadInPath, NARRATION_LEAD_IN_MS);
3845
+ segments.unshift(leadInPath);
3846
+ const firstStep = stepAudio.get(1);
3847
+ if (firstStep) {
3848
+ stepAudio.set(1, {
3849
+ duration: firstStep.duration + NARRATION_LEAD_IN_MS,
3850
+ words: firstStep.words.map((w) => ({
3851
+ ...w,
3852
+ start: w.start + NARRATION_LEAD_IN_MS,
3853
+ end: w.end + NARRATION_LEAD_IN_MS
3854
+ }))
3855
+ });
3856
+ }
1983
3857
  const concatTask = task("Combining audio");
1984
3858
  await mkdir(outputDir, { recursive: true });
1985
- const voiceoverPath = join5(outputDir, "voiceover.wav");
3859
+ const voiceoverPath = join6(outputDir, "voiceover.wav");
1986
3860
  await concatenateAudio(segments, voiceoverPath, workDir);
1987
3861
  concatTask.succeed("Done");
1988
3862
  const meta = {
@@ -1990,7 +3864,11 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError, ta
1990
3864
  totalCaptionLength: steps.reduce((sum, s) => sum + (s.caption?.length || 0), 0),
1991
3865
  stepCount: steps.length,
1992
3866
  voice,
1993
- stepDurations: Object.fromEntries(Array.from(stepAudio.entries()).map(([k, v]) => [k, v.duration]))
3867
+ rate,
3868
+ stepDurations: Object.fromEntries(Array.from(stepAudio.entries()).map(([k, v]) => [k, v.duration])),
3869
+ stepWords: Object.fromEntries(
3870
+ Array.from(stepAudio.entries()).filter(([, v]) => v.words.length > 0).map(([k, v]) => [k, v.words])
3871
+ )
1994
3872
  };
1995
3873
  await saveVoiceoverMeta(outputDir, meta);
1996
3874
  return { voiceoverPath, stepAudio };
@@ -2010,16 +3888,38 @@ export {
2010
3888
  CURSOR_SCRIPT,
2011
3889
  DEFAULT_CAPTIONS,
2012
3890
  DEFAULT_CONFIG,
3891
+ EMPTY_SPAN_SPEED,
2013
3892
  EXCLUDED_LINK_PATTERNS,
2014
- FEATURES,
3893
+ GIF_DEFAULTS,
2015
3894
  INTERACTIVE_SELECTORS,
2016
- WIDE_VOICES as LANGUAGE_VOICES,
3895
+ INTERNATIONAL_VOICE,
3896
+ MALE_VOICES2 as MALE_VOICES,
3897
+ MULTILINGUAL_VOICES2 as MULTILINGUAL_VOICES,
3898
+ MUSIC_BED_LUFS,
3899
+ NARRATION_LEAD_IN_MS,
3900
+ NO_CLIP_OUTRO,
3901
+ PLAN_MAX_TOKENS,
3902
+ RATE_FACTOR,
3903
+ RELAY_BASE,
2017
3904
  SERVER_BACKOFF_MS,
3905
+ SIZE_PRESETS,
3906
+ TRIM_EDGE_SILENCE,
2018
3907
  VERSION,
3908
+ VIDEO_PROFILES,
3909
+ VOICE_LEAD_MS,
3910
+ WIDE_VOICES2 as WIDE_VOICES,
2019
3911
  alignVoiceoverToRecording,
3912
+ alignWordsToCaption,
3913
+ applyProfile,
2020
3914
  applyTransitions,
3915
+ ariaLabelSelector,
3916
+ atempoChain,
3917
+ buildCompressionFilter,
2021
3918
  buildConcatArgs,
3919
+ buildGifArgs,
2022
3920
  buildKeepSegments,
3921
+ buildPaletteArgs,
3922
+ buildRetimeFilter,
2023
3923
  buildStepTransitions,
2024
3924
  buildSystemPrompt,
2025
3925
  buildUserPrompt,
@@ -2027,10 +3927,22 @@ export {
2027
3927
  callAI,
2028
3928
  callAIViaRelay,
2029
3929
  canReuseVoiceover,
3930
+ captionCanvas,
2030
3931
  composeVideo,
3932
+ compressSilentSpans,
2031
3933
  concatMedia,
3934
+ concatVideoWithFullFrameOutro,
2032
3935
  concatVideoWithOutro,
3936
+ convertToGif,
3937
+ coverFilterChain,
2033
3938
  crawlPage,
3939
+ cutSpansFromVideo,
3940
+ deStock,
3941
+ defaultCaptionsFor,
3942
+ detectBlackSpans,
3943
+ detectFrozenSpans,
3944
+ detectSilences,
3945
+ detectWhiteSpans,
2034
3946
  estimateWordTimings,
2035
3947
  extractJsonCandidates,
2036
3948
  generateASSHeader,
@@ -2040,14 +3952,21 @@ export {
2040
3952
  getErrorMessage,
2041
3953
  getLanguageName,
2042
3954
  getMediaDuration,
3955
+ getOrRefreshRelayToken,
2043
3956
  getStatus,
2044
3957
  getStatusCached,
2045
3958
  getTierByEmail,
2046
3959
  getUpgradeUrl,
2047
- hasAccess,
3960
+ getVideoDimensions,
3961
+ getVideoStreamDuration,
3962
+ gotoSettled,
3963
+ hasAudioStream,
2048
3964
  hasTrialRemaining,
3965
+ hasVoice,
2049
3966
  hexToAss as hexToASS,
2050
3967
  idSelector,
3968
+ internationalVoiceFor,
3969
+ intersectSpans,
2051
3970
  isCanceling,
2052
3971
  isExcludedLink,
2053
3972
  isHonestWav,
@@ -2056,22 +3975,39 @@ export {
2056
3975
  loadExistingVoiceover,
2057
3976
  loadVoiceoverMeta,
2058
3977
  mapTimestampsAfterCompression,
3978
+ mergeSpans,
2059
3979
  normalizeUrl,
2060
3980
  parseAIJson,
2061
3981
  parseAIPlanResponse,
2062
3982
  parsePartial,
3983
+ parseWordBoundaries,
3984
+ positionToAlignment,
2063
3985
  pricingUrl,
2064
3986
  registerFreeUser,
3987
+ renderCaptionsAss,
2065
3988
  repairUnicodeEscapes,
2066
- resolveVoice,
3989
+ resetVoiceEngine,
3990
+ resolveCaptionFont,
3991
+ resolveCaptions,
3992
+ resolveRate,
3993
+ resolveVoice2 as resolveVoice,
3994
+ retimeSpansInVideo,
2067
3995
  rgbaToHex,
3996
+ runPooled,
2068
3997
  salvageTruncatedJson,
2069
3998
  shouldSkipLink,
2070
- shouldWatermark,
2071
3999
  showPaywall,
4000
+ speakSymbols,
4001
+ speedUpMiddle,
2072
4002
  speedUpVideo,
2073
4003
  spliceVideos,
4004
+ spokenForm,
4005
+ spokenWordTimings,
4006
+ stripLongDashes,
2074
4007
  synthesizeSpeech,
2075
4008
  toTitleCase,
4009
+ trimEdgeSilence,
4010
+ voiceFor,
4011
+ waitForPlaceholdersGone,
2076
4012
  windowsDrivePathToWsl as windowsPathToWSL
2077
4013
  };