@spark-apps/quickpeek 1.2.4 → 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/qp.js CHANGED
@@ -105,6 +105,48 @@ var init_lifecycle = __esm({
105
105
  }
106
106
  });
107
107
 
108
+ // src/lib/image-kind.ts
109
+ function mimeForKind(kind) {
110
+ return MIME[kind];
111
+ }
112
+ function sniffImageKind(buf) {
113
+ if (buf.length < 12) return null;
114
+ if (buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "png";
115
+ if (buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "jpeg";
116
+ if (buf.subarray(0, 3).toString("latin1") === "GIF") return "gif";
117
+ if (buf.subarray(0, 4).toString("latin1") === "RIFF" && buf.subarray(8, 12).toString("latin1") === "WEBP") {
118
+ return "webp";
119
+ }
120
+ const head = buf.subarray(0, 1024).toString("utf8").replace(/^/, "").trimStart();
121
+ if (/^<(\?xml|!--|!doctype svg|svg)[\s>]/i.test(head) && /<svg[\s>]/i.test(head)) return "svg";
122
+ return null;
123
+ }
124
+ function imageDataUri(path) {
125
+ try {
126
+ const buf = (0, import_node_fs2.readFileSync)(path);
127
+ const kind = sniffImageKind(buf);
128
+ if (!kind) return null;
129
+ return `data:${mimeForKind(kind)};base64,${buf.toString("base64")}`;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ var import_node_fs2, MIME, MIN_ICON_PX;
135
+ var init_image_kind = __esm({
136
+ "src/lib/image-kind.ts"() {
137
+ "use strict";
138
+ import_node_fs2 = require("fs");
139
+ MIME = {
140
+ png: "image/png",
141
+ jpeg: "image/jpeg",
142
+ gif: "image/gif",
143
+ webp: "image/webp",
144
+ svg: "image/svg+xml"
145
+ };
146
+ MIN_ICON_PX = 48;
147
+ }
148
+ });
149
+
108
150
  // src/lib/utils.ts
109
151
  function getLanguageName(lang) {
110
152
  if (!lang || lang === "en") return null;
@@ -344,7 +386,7 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
344
386
  ...detail ? { detail } : {},
345
387
  ...retryAfter2 ? { retryAfterSeconds: parseInt(retryAfter2, 10) } : {}
346
388
  };
347
- await new Promise((resolve10) => setTimeout(resolve10, Math.min(delayMs, 3e4)));
389
+ await new Promise((resolve11) => setTimeout(resolve11, Math.min(delayMs, 3e4)));
348
390
  continue;
349
391
  }
350
392
  const retryAfter = response.headers.get("Retry-After");
@@ -384,7 +426,7 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
384
426
  return { success: false, error: `${host} unreachable: ${getErrorMessage(err)}`, host };
385
427
  }
386
428
  const delayMs = 1e3 * 2 ** (attempt - 1);
387
- await new Promise((resolve10) => setTimeout(resolve10, delayMs));
429
+ await new Promise((resolve11) => setTimeout(resolve11, delayMs));
388
430
  }
389
431
  }
390
432
  return lastRetriableError || { success: false, error: `${host}: request failed after retries`, host };
@@ -546,9 +588,6 @@ var init_SparkStripe_feature_gating = __esm({
546
588
  });
547
589
 
548
590
  // src/lib/billing.ts
549
- function shouldWatermark(tier, videoDurationSeconds) {
550
- return tier === "free" && videoDurationSeconds > 20;
551
- }
552
591
  function getUpgradeUrl(userEmail) {
553
592
  return pricingUrl(userEmail ? { email: userEmail } : void 0);
554
593
  }
@@ -583,7 +622,10 @@ function applyProfile(config, profile) {
583
622
  format: "mp3",
584
623
  bitrate: "192k",
585
624
  ...config.audio,
586
- speed: config.audio?.speed ?? preset.audioSpeed,
625
+ // outroOnly is on unless turned off, matching the video phase. The
626
+ // speedup buys the song its window, so it applies to the profile's own
627
+ // pace only: a speed somebody set by hand is the speed they wanted.
628
+ speed: config.audio?.speed ?? preset.audioSpeed * (config.music?.outroOnly !== false ? OUTRO_SONG_SPEEDUP : 1),
587
629
  ...config.audio?.voice || !preset.voice ? {} : { voice: preset.voice }
588
630
  }
589
631
  };
@@ -620,11 +662,11 @@ function defaultCaptionsFor(width, height) {
620
662
  maxChars: 20
621
663
  };
622
664
  }
623
- var VERSION, CONFIG_FILE, SIZE_PRESETS, RATE_FACTOR, NO_CLIP_OUTRO, DEFAULT_CONFIG, VIDEO_PROFILES, DEFAULT_CAPTIONS;
665
+ var VERSION, CONFIG_FILE, SIZE_PRESETS, RATE_FACTOR, NO_CLIP_OUTRO, DEFAULT_CONFIG, VIDEO_PROFILES, OUTRO_SONG_SPEEDUP, DEFAULT_CAPTIONS;
624
666
  var init_config = __esm({
625
667
  "src/lib/config.ts"() {
626
668
  "use strict";
627
- VERSION = "1.2.3";
669
+ VERSION = "1.2.5";
628
670
  CONFIG_FILE = "quickpeek.config.json";
629
671
  SIZE_PRESETS = {
630
672
  wide: { width: 1920, height: 1080, zoom: 1 },
@@ -651,11 +693,12 @@ var init_config = __esm({
651
693
  // ms
652
694
  transitions: "none",
653
695
  contrast: 0.25,
654
- // A demo plays a touch faster than it was recorded, because a recording
655
- // paced for the narration reads as slow next to anything else on the same
656
- // feed. 1.2 is the most that still sounds like a person talking; set 1 in
657
- // quickpeek.config.json for the raw pace.
658
- tempo: 1.2,
696
+ // The recording plays at the pace it was filmed at. A tempo above 1 is
697
+ // multiplied by the narration speed in VIDEO_PROFILES and by the fit
698
+ // pass's squeeze, and the three together read as frantic rather than
699
+ // brisk. Speed belongs in one place, so it lives in audioSpeed; set a
700
+ // tempo in quickpeek.config.json to push the picture on top of that.
701
+ tempo: 1,
659
702
  stepDelay: 200
660
703
  // ms delay between steps
661
704
  },
@@ -669,18 +712,24 @@ var init_config = __esm({
669
712
  // these bitrates - the encode is pure wall-clock tax on every run.
670
713
  wide: {
671
714
  video: { width: 1920, height: 1080, zoom: 1.5, fitLimitSecs: 0, maxSpeedup: 1, preset: "veryfast" },
672
- audioSpeed: 1.6
715
+ // A tutorial is watched to learn something, so the narrator is allowed to
716
+ // be brisk but never hurried.
717
+ audioSpeed: 1.15
673
718
  },
674
719
  short: {
675
720
  // fadeOut 0: the video ends on a card the viewer is meant to read,
676
721
  // and a fade takes it away mid-sentence.
677
722
  video: { width: 1080, height: 1920, zoom: 3, fitLimitSecs: 60, maxSpeedup: 1.7, preset: "veryfast", fadeOut: 0 },
678
- audioSpeed: 1.8,
723
+ // 1.8 was unwatchable: stacked on tempo 1.2 and the outro speedup it ran
724
+ // the voice at better than twice its natural rate. A short wants energy,
725
+ // which is the voice and the cutting, not the tape running fast.
726
+ audioSpeed: 1,
679
727
  // A short lives or dies on energy. Ava is the warmest, most animated of
680
728
  // the free neural voices; Sonia reads a short like the shipping forecast.
681
729
  voice: "en-US-AvaMultilingualNeural"
682
730
  }
683
731
  };
732
+ OUTRO_SONG_SPEEDUP = 1.13;
684
733
  DEFAULT_CAPTIONS = {
685
734
  font: "Arial",
686
735
  outline: "#000000",
@@ -829,6 +878,7 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
829
878
  const deadline = Date.now() + cap;
830
879
  let previous = -1;
831
880
  let unchanged = 0;
881
+ let clear = 0;
832
882
  while (Date.now() < deadline) {
833
883
  const count = await page.evaluate((selector) => {
834
884
  let visible = 0;
@@ -838,7 +888,12 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
838
888
  }
839
889
  return visible;
840
890
  }, PLACEHOLDER_SELECTOR).catch(() => 0);
841
- if (count === 0) return;
891
+ if (count === 0) {
892
+ if (++clear >= PLACEHOLDER_CLEAR_POLLS) return;
893
+ await page.waitForTimeout(PLACEHOLDER_POLL_MS);
894
+ continue;
895
+ }
896
+ clear = 0;
842
897
  if (count === previous) {
843
898
  if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
844
899
  } else {
@@ -1098,7 +1153,7 @@ async function crawlPage(url, task2 = noopTask, storageStatePath, viewport) {
1098
1153
  });
1099
1154
  }
1100
1155
  }
1101
- var import_playwright4, SETTLE_POLL_MS, SETTLE_STABLE_POLLS, SETTLE_CAP_MS, PLACEHOLDER_POLL_MS, PLACEHOLDER_CAP_MS, PLACEHOLDER_STATIC_POLLS, PLACEHOLDER_SELECTOR, noopTask, CRAWL_CACHE_TTL_MS, CRAWL_CACHE_MAX_ENTRIES, crawlCache;
1156
+ var import_playwright4, SETTLE_POLL_MS, SETTLE_STABLE_POLLS, SETTLE_CAP_MS, PLACEHOLDER_POLL_MS, PLACEHOLDER_CAP_MS, PLACEHOLDER_STATIC_POLLS, PLACEHOLDER_CLEAR_POLLS, PLACEHOLDER_SELECTOR, noopTask, CRAWL_CACHE_TTL_MS, CRAWL_CACHE_MAX_ENTRIES, crawlCache;
1102
1157
  var init_crawl = __esm({
1103
1158
  "src/lib/crawl.ts"() {
1104
1159
  "use strict";
@@ -1110,6 +1165,7 @@ var init_crawl = __esm({
1110
1165
  PLACEHOLDER_POLL_MS = 250;
1111
1166
  PLACEHOLDER_CAP_MS = 8e3;
1112
1167
  PLACEHOLDER_STATIC_POLLS = 4;
1168
+ PLACEHOLDER_CLEAR_POLLS = 3;
1113
1169
  PLACEHOLDER_SELECTOR = [
1114
1170
  '[aria-busy="true"]',
1115
1171
  '[role="progressbar"]',
@@ -1259,13 +1315,13 @@ function canEncode(encoder) {
1259
1315
  ]);
1260
1316
  }
1261
1317
  function run(args) {
1262
- return new Promise((resolve10) => {
1318
+ return new Promise((resolve11) => {
1263
1319
  let settled = false;
1264
1320
  const done = (ok) => {
1265
1321
  if (settled) return;
1266
1322
  settled = true;
1267
1323
  clearTimeout(timer);
1268
- resolve10(ok);
1324
+ resolve11(ok);
1269
1325
  };
1270
1326
  const proc = trackChild((0, import_node_child_process2.spawn)("ffmpeg", args, { stdio: "ignore" }));
1271
1327
  const timer = setTimeout(() => {
@@ -1332,7 +1388,7 @@ function spawnFFmpeg(cmd2, args) {
1332
1388
  return trackChild((0, import_node_child_process3.spawn)(cmd2, args));
1333
1389
  }
1334
1390
  function runFFmpeg(args, errorLabel) {
1335
- return new Promise((resolve10, reject) => {
1391
+ return new Promise((resolve11, reject) => {
1336
1392
  const proc = spawnFFmpeg("ffmpeg", args);
1337
1393
  let stderr = "";
1338
1394
  proc.stderr.on("data", (d) => {
@@ -1340,7 +1396,7 @@ function runFFmpeg(args, errorLabel) {
1340
1396
  });
1341
1397
  proc.on("close", (code) => {
1342
1398
  if (code !== 0) reject(new Error(`${errorLabel}: ${stderr.slice(-500)}`));
1343
- else resolve10();
1399
+ else resolve11();
1344
1400
  });
1345
1401
  proc.on("error", reject);
1346
1402
  });
@@ -1367,8 +1423,13 @@ async function composeVideo(opts) {
1367
1423
  musicPath,
1368
1424
  musicVolume = 0.3,
1369
1425
  musicTargetLufs = MUSIC_BED_LUFS,
1370
- musicFadeIn = 2,
1371
- musicFadeOut = 2,
1426
+ // Sized for the outro window: the song has 5.5s, so a 2s fade at each end
1427
+ // left 1.5s of music anyone could actually hear. Up fast, hold, out slow.
1428
+ musicFadeIn = 1,
1429
+ musicFadeOut = 1.5,
1430
+ tempo = 1,
1431
+ musicStartSecs = 0,
1432
+ musicDurationSecs = 0,
1372
1433
  width,
1373
1434
  height,
1374
1435
  fps,
@@ -1429,7 +1490,19 @@ async function composeVideo(opts) {
1429
1490
  const clipAudioLabel = opts.hasClipAudio ? "[0:a]" : "";
1430
1491
  const hasVoiceover = !!voiceoverPath;
1431
1492
  const hasMusic = !!musicPath;
1432
- const musicFadeOutStart = Math.max(0, effectiveDuration - musicFadeOut);
1493
+ const bedStart = Math.max(0, Math.min(musicStartSecs, Math.max(0, effectiveDuration - 0.5)));
1494
+ const bedLength = Math.max(0.5, effectiveDuration - bedStart);
1495
+ const bedDelay = bedStart > 0 ? `adelay=${Math.round(bedStart * 1e3)}|${Math.round(bedStart * 1e3)},` : "";
1496
+ const bedTempo = tempo > 1 ? 1 / tempo : 1;
1497
+ const bedSlow = bedTempo === 1 ? "" : `atempo=${bedTempo.toFixed(6)},`;
1498
+ const bedSource = bedLength * bedTempo;
1499
+ const bedSeek = musicDurationSecs > bedSource + 0.05 ? musicDurationSecs - bedSource : 0;
1500
+ const bedTrimIn = bedSeek > 0 ? `atrim=start=${bedSeek.toFixed(3)},asetpts=PTS-STARTPTS,` : "";
1501
+ const bedFadeOut = bedSeek > 0 ? 0 : musicFadeOut * tempo;
1502
+ const bedLoop = bedSeek > 0 ? "" : "aloop=loop=-1:size=2e+09,";
1503
+ const bedFadeIn = musicFadeIn * tempo;
1504
+ const musicFadeOutStart = Math.max(0, bedLength - bedFadeOut);
1505
+ const bedFadeOutFilter = bedFadeOut > 0 ? `afade=t=out:st=${musicFadeOutStart}:d=${bedFadeOut},` : "";
1433
1506
  const vfStr = [...vf, ...encoder.filter ? [encoder.filter] : []].join(",");
1434
1507
  if (hasVoiceover && hasMusic) {
1435
1508
  const mixInputs = clipAudioLabel ? 3 : 2;
@@ -1445,7 +1518,7 @@ async function composeVideo(opts) {
1445
1518
  // card, which is the one place the bed is the only thing playing.
1446
1519
  `[voicekey0]apad=whole_dur=${effectiveDuration}[voicekey]`,
1447
1520
  // Music: normalised to a known level, trimmed, then faded.
1448
- `[2:a]aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},loudnorm=I=${musicTargetLufs}:TP=-9:LRA=11,volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut}[bed]`,
1521
+ `[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]`,
1449
1522
  `[bed][voicekey]${DUCK}[music]`,
1450
1523
  // normalize=0, because amix's default divides every input by the number
1451
1524
  // of them: adding the bed used to quietly take 6 dB off the narration,
@@ -1469,7 +1542,7 @@ async function composeVideo(opts) {
1469
1542
  args.push(...encoder.output);
1470
1543
  args.push("-c:a", "aac", "-b:a", audioBitrate);
1471
1544
  } else if (hasMusic) {
1472
- 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},${import_video_kit3.MASTER_CHAIN}`;
1545
+ const musicFilter = `${bedTrimIn}${bedLoop}${bedSlow}atrim=0:${bedLength},volume=${musicVolume},afade=t=in:st=0:d=${bedFadeIn},${bedFadeOutFilter}${bedDelay}apad=whole_dur=${effectiveDuration},${import_video_kit3.MASTER_CHAIN}`;
1473
1546
  args.push("-vf", vfStr);
1474
1547
  args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
1475
1548
  args.push(...encoder.output);
@@ -1594,14 +1667,14 @@ var init_gif = __esm({
1594
1667
  // src/lib/ffmpeg/media.ts
1595
1668
  function cacheKey(filePath) {
1596
1669
  try {
1597
- const { size, mtimeMs } = (0, import_node_fs4.statSync)(filePath);
1670
+ const { size, mtimeMs } = (0, import_node_fs5.statSync)(filePath);
1598
1671
  return `${filePath}:${size}:${mtimeMs}`;
1599
1672
  } catch {
1600
1673
  return null;
1601
1674
  }
1602
1675
  }
1603
1676
  function getVideoDimensions(filePath) {
1604
- return new Promise((resolve10, reject) => {
1677
+ return new Promise((resolve11, reject) => {
1605
1678
  const args = [
1606
1679
  "-v",
1607
1680
  "error",
@@ -1632,12 +1705,12 @@ function getVideoDimensions(filePath) {
1632
1705
  reject(new Error(`ffprobe returned no dimensions for ${filePath}`));
1633
1706
  return;
1634
1707
  }
1635
- resolve10({ width: w, height: h });
1708
+ resolve11({ width: w, height: h });
1636
1709
  });
1637
1710
  });
1638
1711
  }
1639
1712
  function hasAudioStream(filePath) {
1640
- return new Promise((resolve10) => {
1713
+ return new Promise((resolve11) => {
1641
1714
  const proc = spawnFFmpeg("ffprobe", [
1642
1715
  "-v",
1643
1716
  "error",
@@ -1653,15 +1726,15 @@ function hasAudioStream(filePath) {
1653
1726
  proc.stdout.on("data", (d) => {
1654
1727
  out += d;
1655
1728
  });
1656
- proc.on("close", () => resolve10(out.trim().length > 0));
1657
- proc.on("error", () => resolve10(false));
1729
+ proc.on("close", () => resolve11(out.trim().length > 0));
1730
+ proc.on("error", () => resolve11(false));
1658
1731
  });
1659
1732
  }
1660
1733
  function getMediaDuration(filePath) {
1661
1734
  const key = cacheKey(filePath);
1662
1735
  const cached = key === null ? void 0 : durationCache.get(key);
1663
1736
  if (cached !== void 0) return Promise.resolve(cached);
1664
- return new Promise((resolve10, reject) => {
1737
+ return new Promise((resolve11, reject) => {
1665
1738
  const args = [
1666
1739
  "-v",
1667
1740
  "error",
@@ -1688,13 +1761,13 @@ function getMediaDuration(filePath) {
1688
1761
  const ms = Math.ceil(parseFloat(stdout.trim()) * 1e3);
1689
1762
  const freshKey = cacheKey(filePath);
1690
1763
  if (freshKey !== null && Number.isFinite(ms)) durationCache.set(freshKey, ms);
1691
- resolve10(ms);
1764
+ resolve11(ms);
1692
1765
  });
1693
1766
  proc.on("error", reject);
1694
1767
  });
1695
1768
  }
1696
1769
  function getAudioCodec(filePath) {
1697
- return new Promise((resolve10) => {
1770
+ return new Promise((resolve11) => {
1698
1771
  const args = [
1699
1772
  "-v",
1700
1773
  "error",
@@ -1711,8 +1784,8 @@ function getAudioCodec(filePath) {
1711
1784
  proc.stdout.on("data", (data) => {
1712
1785
  stdout += data;
1713
1786
  });
1714
- proc.on("close", (code) => resolve10(code === 0 ? stdout.trim() : ""));
1715
- proc.on("error", () => resolve10(""));
1787
+ proc.on("close", (code) => resolve11(code === 0 ? stdout.trim() : ""));
1788
+ proc.on("error", () => resolve11(""));
1716
1789
  });
1717
1790
  }
1718
1791
  async function isHonestWav(filePath) {
@@ -1854,11 +1927,11 @@ function concatVideoWithFullFrameOutro(opts) {
1854
1927
  "ffmpeg full-frame outro concat failed"
1855
1928
  );
1856
1929
  }
1857
- var import_node_fs4, durationCache, TRIM_EDGE_SILENCE;
1930
+ var import_node_fs5, durationCache, TRIM_EDGE_SILENCE;
1858
1931
  var init_media = __esm({
1859
1932
  "src/lib/ffmpeg/media.ts"() {
1860
1933
  "use strict";
1861
- import_node_fs4 = require("fs");
1934
+ import_node_fs5 = require("fs");
1862
1935
  init_spawn();
1863
1936
  durationCache = /* @__PURE__ */ new Map();
1864
1937
  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";
@@ -1867,7 +1940,7 @@ var init_media = __esm({
1867
1940
 
1868
1941
  // src/lib/ffmpeg/silence.ts
1869
1942
  function getVideoStreamDuration(inputPath) {
1870
- return new Promise((resolve10) => {
1943
+ return new Promise((resolve11) => {
1871
1944
  const proc = spawnFFmpeg("ffprobe", [
1872
1945
  "-v",
1873
1946
  "error",
@@ -1884,12 +1957,12 @@ function getVideoStreamDuration(inputPath) {
1884
1957
  proc.stdout.on("data", (d) => {
1885
1958
  out += d;
1886
1959
  });
1887
- proc.on("error", () => resolve10(0));
1888
- proc.on("close", () => resolve10(Number.parseFloat(out.trim()) || 0));
1960
+ proc.on("error", () => resolve11(0));
1961
+ proc.on("close", () => resolve11(Number.parseFloat(out.trim()) || 0));
1889
1962
  });
1890
1963
  }
1891
1964
  function detectSilences(inputPath, minDurSecs) {
1892
- return new Promise((resolve10) => {
1965
+ return new Promise((resolve11) => {
1893
1966
  const proc = spawnFFmpeg("ffmpeg", [
1894
1967
  "-hide_banner",
1895
1968
  "-nostats",
@@ -1908,7 +1981,7 @@ function detectSilences(inputPath, minDurSecs) {
1908
1981
  proc.stderr.on("data", (d) => {
1909
1982
  err += d;
1910
1983
  });
1911
- proc.on("error", () => resolve10([]));
1984
+ proc.on("error", () => resolve11([]));
1912
1985
  proc.on("close", () => {
1913
1986
  const spans = [];
1914
1987
  let start = null;
@@ -1923,12 +1996,12 @@ function detectSilences(inputPath, minDurSecs) {
1923
1996
  start = null;
1924
1997
  }
1925
1998
  }
1926
- resolve10(spans);
1999
+ resolve11(spans);
1927
2000
  });
1928
2001
  });
1929
2002
  }
1930
2003
  function detectFrozenSpans(inputPath, minDurSecs) {
1931
- return new Promise((resolve10) => {
2004
+ return new Promise((resolve11) => {
1932
2005
  const proc = spawnFFmpeg("ffmpeg", [
1933
2006
  "-hide_banner",
1934
2007
  "-nostats",
@@ -1949,7 +2022,7 @@ function detectFrozenSpans(inputPath, minDurSecs) {
1949
2022
  proc.stderr.on("data", (d) => {
1950
2023
  err += d;
1951
2024
  });
1952
- proc.on("error", () => resolve10([]));
2025
+ proc.on("error", () => resolve11([]));
1953
2026
  proc.on("close", () => {
1954
2027
  const spans = [];
1955
2028
  let start = null;
@@ -1962,12 +2035,12 @@ function detectFrozenSpans(inputPath, minDurSecs) {
1962
2035
  start = null;
1963
2036
  }
1964
2037
  }
1965
- resolve10(spans);
2038
+ resolve11(spans);
1966
2039
  });
1967
2040
  });
1968
2041
  }
1969
2042
  function detectBlackSpans(inputPath, minDurSecs) {
1970
- return new Promise((resolve10) => {
2043
+ return new Promise((resolve11) => {
1971
2044
  const proc = spawnFFmpeg("ffmpeg", [
1972
2045
  "-hide_banner",
1973
2046
  "-nostats",
@@ -1990,19 +2063,19 @@ function detectBlackSpans(inputPath, minDurSecs) {
1990
2063
  proc.stderr.on("data", (d) => {
1991
2064
  err += d;
1992
2065
  });
1993
- proc.on("error", () => resolve10([]));
2066
+ proc.on("error", () => resolve11([]));
1994
2067
  proc.on("close", () => {
1995
2068
  const spans = [];
1996
2069
  for (const line of err.split("\n")) {
1997
2070
  const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
1998
2071
  if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
1999
2072
  }
2000
- resolve10(spans);
2073
+ resolve11(spans);
2001
2074
  });
2002
2075
  });
2003
2076
  }
2004
2077
  function detectWhiteSpans(inputPath, minDurSecs) {
2005
- return new Promise((resolve10) => {
2078
+ return new Promise((resolve11) => {
2006
2079
  const proc = spawnFFmpeg("ffmpeg", [
2007
2080
  "-hide_banner",
2008
2081
  "-nostats",
@@ -2020,14 +2093,14 @@ function detectWhiteSpans(inputPath, minDurSecs) {
2020
2093
  proc.stderr.on("data", (d) => {
2021
2094
  err += d;
2022
2095
  });
2023
- proc.on("error", () => resolve10([]));
2096
+ proc.on("error", () => resolve11([]));
2024
2097
  proc.on("close", () => {
2025
2098
  const spans = [];
2026
2099
  for (const line of err.split("\n")) {
2027
2100
  const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
2028
2101
  if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
2029
2102
  }
2030
- resolve10(spans);
2103
+ resolve11(spans);
2031
2104
  });
2032
2105
  });
2033
2106
  }
@@ -2117,7 +2190,7 @@ async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
2117
2190
  const drop = keep.map((s) => `between(t,${s.start.toFixed(3)},${s.end.toFixed(3)})`).join("+");
2118
2191
  const filter = `[0:v]select='not(${drop})',setpts=N/FRAME_RATE/TB[outv]`;
2119
2192
  const enc = await passEncoder(preset);
2120
- return new Promise((resolve10) => {
2193
+ return new Promise((resolve11) => {
2121
2194
  const proc = spawnFFmpeg("ffmpeg", [
2122
2195
  "-y",
2123
2196
  ...filterThreadArgs(),
@@ -2135,8 +2208,8 @@ async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
2135
2208
  outputPath
2136
2209
  ]);
2137
2210
  proc.stderr.resume();
2138
- proc.on("error", () => resolve10(false));
2139
- proc.on("close", (code) => resolve10(code === 0));
2211
+ proc.on("error", () => resolve11(false));
2212
+ proc.on("close", (code) => resolve11(code === 0));
2140
2213
  });
2141
2214
  }
2142
2215
  function buildRetimeFilter(spans, stepBounds, speed, fps) {
@@ -2218,7 +2291,7 @@ async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, spee
2218
2291
  const filter = buildRetimeFilter(spans, stepBounds, speed, fps);
2219
2292
  if (!filter) return false;
2220
2293
  const enc = await passEncoder(preset);
2221
- return new Promise((resolve10) => {
2294
+ return new Promise((resolve11) => {
2222
2295
  const proc = spawnFFmpeg("ffmpeg", [
2223
2296
  "-y",
2224
2297
  ...filterThreadArgs(),
@@ -2236,8 +2309,8 @@ async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, spee
2236
2309
  outputPath
2237
2310
  ]);
2238
2311
  proc.stderr.resume();
2239
- proc.on("error", () => resolve10(false));
2240
- proc.on("close", (code) => resolve10(code === 0));
2312
+ proc.on("error", () => resolve11(false));
2313
+ proc.on("close", (code) => resolve11(code === 0));
2241
2314
  });
2242
2315
  }
2243
2316
  async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, preset, protectAfterSecs, narrationPath, protectBeforeSecs, leadSpans, keepSpans) {
@@ -2263,7 +2336,7 @@ async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, pr
2263
2336
  const filter = buildCompressionFilter(spans, totalSecs, maxSecs, protectAfterSecs, protectBeforeSecs);
2264
2337
  if (!filter) return false;
2265
2338
  const enc = await passEncoder(preset);
2266
- return new Promise((resolve10) => {
2339
+ return new Promise((resolve11) => {
2267
2340
  const proc = spawnFFmpeg("ffmpeg", [
2268
2341
  "-y",
2269
2342
  ...filterThreadArgs(),
@@ -2289,8 +2362,8 @@ async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, pr
2289
2362
  outputPath
2290
2363
  ]);
2291
2364
  proc.stderr.resume();
2292
- proc.on("error", () => resolve10(false));
2293
- proc.on("close", (code) => resolve10(code === 0));
2365
+ proc.on("error", () => resolve11(false));
2366
+ proc.on("close", (code) => resolve11(code === 0));
2294
2367
  });
2295
2368
  }
2296
2369
  async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, introSecs, outroSecs, maxFactor, preset) {
@@ -2314,7 +2387,7 @@ async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, intro
2314
2387
  "[v0][a0][v1][a1][v2][a2]concat=n=3:v=1:a=1[outv][outa]"
2315
2388
  ].join(";");
2316
2389
  const enc = await passEncoder(preset);
2317
- const ok = await new Promise((resolve10) => {
2390
+ const ok = await new Promise((resolve11) => {
2318
2391
  const proc = spawnFFmpeg("ffmpeg", [
2319
2392
  "-y",
2320
2393
  ...filterThreadArgs(),
@@ -2335,8 +2408,8 @@ async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, intro
2335
2408
  outputPath
2336
2409
  ]);
2337
2410
  proc.stderr.resume();
2338
- proc.on("error", () => resolve10(false));
2339
- proc.on("close", (code) => resolve10(code === 0));
2411
+ proc.on("error", () => resolve11(false));
2412
+ proc.on("close", (code) => resolve11(code === 0));
2340
2413
  });
2341
2414
  return { ok, factor, reachedTarget: needed <= maxFactor };
2342
2415
  }
@@ -2372,7 +2445,7 @@ async function spliceVideos(opts) {
2372
2445
  const audioPresent = await Promise.all(
2373
2446
  overlays.map((o) => o.still ? Promise.resolve(false) : hasAudioStream(o.path))
2374
2447
  );
2375
- return new Promise((resolve10, reject) => {
2448
+ return new Promise((resolve11, reject) => {
2376
2449
  const args = ["-y", "-i", inputPath];
2377
2450
  for (const overlay of overlays) {
2378
2451
  if (overlay.still) {
@@ -2435,7 +2508,7 @@ async function spliceVideos(opts) {
2435
2508
  args.push("-c:v", "libx264", "-preset", preset, "-crf", "18", "-pix_fmt", "yuv420p");
2436
2509
  if (clipAudio.length > 0) args.push("-c:a", "aac");
2437
2510
  args.push(outputPath);
2438
- runFFmpeg(args, "ffmpeg splice failed").then(resolve10, reject);
2511
+ runFFmpeg(args, "ffmpeg splice failed").then(resolve11, reject);
2439
2512
  });
2440
2513
  }
2441
2514
  var CLIP_AUDIO_GAIN, STILL_FRAMERATE, COVER_WASH_W, COVER_WASH_H, COVER_BLUR_SIGMA;
@@ -2471,10 +2544,10 @@ function buildStepTransitions(steps, stepDurations, defaultTransition) {
2471
2544
  return transitions;
2472
2545
  }
2473
2546
  function applyTransitions(opts) {
2474
- return new Promise((resolve10, reject) => {
2547
+ return new Promise((resolve11, reject) => {
2475
2548
  const { inputVideo, outputVideo, transitions, videoDuration, preset = "fast" } = opts;
2476
2549
  if (transitions.length === 0) {
2477
- runFFmpeg(["-y", "-i", inputVideo, "-c", "copy", outputVideo], "FFmpeg copy failed").then(resolve10, reject);
2550
+ runFFmpeg(["-y", "-i", inputVideo, "-c", "copy", outputVideo], "FFmpeg copy failed").then(resolve11, reject);
2478
2551
  return;
2479
2552
  }
2480
2553
  const sorted = [...transitions].sort((a, b) => a.timestamp - b.timestamp);
@@ -2522,7 +2595,7 @@ function applyTransitions(opts) {
2522
2595
  "yuv420p",
2523
2596
  outputVideo
2524
2597
  ];
2525
- runFFmpeg(args, "FFmpeg transitions failed").then(resolve10, reject);
2598
+ runFFmpeg(args, "FFmpeg transitions failed").then(resolve11, reject);
2526
2599
  });
2527
2600
  }
2528
2601
  var init_transitions = __esm({
@@ -2985,6 +3058,9 @@ function renderHormoziAss(ctx) {
2985
3058
  return `${header2}${lines.join("\n")}
2986
3059
  `;
2987
3060
  }
3061
+ function looksLikeAddress(token) {
3062
+ return /^(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?[.,!?]?$/i.test(token);
3063
+ }
2988
3064
  function renderShortsAss(ctx) {
2989
3065
  const { captions, width, height } = ctx;
2990
3066
  const font = resolveCaptionFont(captions.font === "Arial" ? "Arial Black" : captions.font);
@@ -2995,7 +3071,7 @@ function renderShortsAss(ctx) {
2995
3071
  const alignment = positionToAlignment(captions.position);
2996
3072
  const outline = Math.max(2, Math.round(fontSize / 22));
2997
3073
  const upper = true;
2998
- const caseOf = (t) => (0, import_video_kit4.toCaptionCase)(t, upper);
3074
+ const caseOf = (t) => looksLikeAddress(t) ? t : (0, import_video_kit4.toCaptionCase)(t, upper);
2999
3075
  const maxChars = (0, import_video_kit4.fittingMaxChars)(width, fontSize, SIDE_MARGIN, captions.maxChars, upper);
3000
3076
  const header2 = `[Script Info]
3001
3077
  Title: QuickPeek Demo
@@ -3226,7 +3302,7 @@ async function getMediaStepDuration(step, baseDir) {
3226
3302
  return 0;
3227
3303
  }
3228
3304
  const mediaPath = (0, import_node_path4.isAbsolute)(step.value) ? step.value : (0, import_node_path4.resolve)(baseDir, step.value);
3229
- if (!(0, import_node_fs5.existsSync)(mediaPath)) {
3305
+ if (!(0, import_node_fs6.existsSync)(mediaPath)) {
3230
3306
  return 0;
3231
3307
  }
3232
3308
  try {
@@ -3248,7 +3324,7 @@ async function getMediaStepDuration(step, baseDir) {
3248
3324
  async function loadVoiceoverMeta(outputDir) {
3249
3325
  const metaPath = (0, import_node_path4.join)(outputDir, "voiceover.json");
3250
3326
  try {
3251
- if (!(0, import_node_fs5.existsSync)(metaPath)) return null;
3327
+ if (!(0, import_node_fs6.existsSync)(metaPath)) return null;
3252
3328
  const content = await (0, import_promises4.readFile)(metaPath, "utf-8");
3253
3329
  return JSON.parse(content);
3254
3330
  } catch {
@@ -3271,11 +3347,11 @@ async function concatenateAudio(inputs, output, workDir) {
3271
3347
  await (0, import_promises4.unlink)(listPath).catch(() => {
3272
3348
  });
3273
3349
  }
3274
- var import_node_fs5, import_promises4, import_node_path4, isWindows, NARRATION_LEAD_IN_MS, LEAD_IN_CLIP;
3350
+ var import_node_fs6, import_promises4, import_node_path4, isWindows, NARRATION_LEAD_IN_MS, LEAD_IN_CLIP;
3275
3351
  var init_helpers = __esm({
3276
3352
  "src/lib/tts/helpers.ts"() {
3277
3353
  "use strict";
3278
- import_node_fs5 = require("fs");
3354
+ import_node_fs6 = require("fs");
3279
3355
  import_promises4 = require("fs/promises");
3280
3356
  import_node_path4 = require("path");
3281
3357
  init_ffmpeg2();
@@ -3351,7 +3427,7 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
3351
3427
  const segments = [];
3352
3428
  const alignedStepAudio = /* @__PURE__ */ new Map();
3353
3429
  const leadInPath = (0, import_node_path5.join)(audioDir, LEAD_IN_CLIP);
3354
- const leadIn = (0, import_node_fs6.existsSync)(leadInPath) ? NARRATION_LEAD_IN_MS : 0;
3430
+ const leadIn = (0, import_node_fs7.existsSync)(leadInPath) ? NARRATION_LEAD_IN_MS : 0;
3355
3431
  if (leadIn) segments.push(leadInPath);
3356
3432
  for (let i = 0; i < actualRecordingDurations.length; i++) {
3357
3433
  const stepNum = i + 1;
@@ -3362,9 +3438,9 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
3362
3438
  const audioPath = (0, import_node_path5.join)(audioDir, `step-${stepNum}.mp3`);
3363
3439
  const originalPaddingPath = (0, import_node_path5.join)(audioDir, `step-${stepNum}-padding.mp3`);
3364
3440
  const silencePath = (0, import_node_path5.join)(audioDir, `step-${stepNum}-silence.mp3`);
3365
- if ((0, import_node_fs6.existsSync)(audioPath)) {
3441
+ if ((0, import_node_fs7.existsSync)(audioPath)) {
3366
3442
  segments.push(audioPath);
3367
- if ((0, import_node_fs6.existsSync)(originalPaddingPath)) {
3443
+ if ((0, import_node_fs7.existsSync)(originalPaddingPath)) {
3368
3444
  segments.push(originalPaddingPath);
3369
3445
  }
3370
3446
  if (recordingDuration > voiceDuration) {
@@ -3372,7 +3448,7 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
3372
3448
  await generateSilence(alignPaddingPath, recordingDuration - voiceDuration);
3373
3449
  segments.push(alignPaddingPath);
3374
3450
  }
3375
- } else if ((0, import_node_fs6.existsSync)(silencePath)) {
3451
+ } else if ((0, import_node_fs7.existsSync)(silencePath)) {
3376
3452
  const own = targetDuration - (stepNum === 1 ? leadIn : 0);
3377
3453
  if (targetDuration !== voiceDuration && own > 0) {
3378
3454
  await generateSilence(silencePath, own);
@@ -3401,11 +3477,11 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
3401
3477
  }
3402
3478
  return alignedStepAudio;
3403
3479
  }
3404
- var import_node_fs6, import_node_path5;
3480
+ var import_node_fs7, import_node_path5;
3405
3481
  var init_timing = __esm({
3406
3482
  "src/lib/tts/timing.ts"() {
3407
3483
  "use strict";
3408
- import_node_fs6 = require("fs");
3484
+ import_node_fs7 = require("fs");
3409
3485
  import_node_path5 = require("path");
3410
3486
  init_ffmpeg2();
3411
3487
  init_helpers();
@@ -3554,6 +3630,8 @@ asyncio.run(main())
3554
3630
  await (0, import_promises5.writeFile)(tempScriptPath, pythonScript);
3555
3631
  const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
3556
3632
  await execAsync(pythonCmd, { timeout: 3e4 });
3633
+ const written = await (0, import_promises5.stat)(outputPath).catch(() => null);
3634
+ if (!written?.size) throw new Error("edge-tts produced no audio");
3557
3635
  return await readWordsSidecar(outputPath);
3558
3636
  } finally {
3559
3637
  await (0, import_promises5.unlink)(tempScriptPath).catch(() => {
@@ -3581,7 +3659,7 @@ async function synthesizeWithNode(text, outputPath, voice, pythonMsg, rate, maxR
3581
3659
  const { audioStream, metadataStream } = tts.toStream(text, rate ? { rate } : void 0);
3582
3660
  const metadata = [];
3583
3661
  metadataStream?.on("data", (chunk) => metadata.push(chunk.toString()));
3584
- await (0, import_promises6.pipeline)(audioStream, (0, import_node_fs7.createWriteStream)(outputPath));
3662
+ await (0, import_promises6.pipeline)(audioStream, (0, import_node_fs8.createWriteStream)(outputPath));
3585
3663
  await new Promise((r) => setTimeout(r, 250));
3586
3664
  const { size } = await (0, import_promises5.stat)(outputPath);
3587
3665
  if (size === 0) throw new Error("No audio data received");
@@ -3653,12 +3731,12 @@ function splitJsonObjects(raw) {
3653
3731
  }
3654
3732
  return out;
3655
3733
  }
3656
- var import_node_child_process4, import_node_fs7, import_promises5, import_node_path7, import_promises6, import_node_util, execAsync, PYTHON_ATTEMPTS, TICKS_PER_MS, wordsSidecar, pythonEdgeTts, lockedEngine;
3734
+ var import_node_child_process4, import_node_fs8, import_promises5, import_node_path7, import_promises6, import_node_util, execAsync, PYTHON_ATTEMPTS, TICKS_PER_MS, wordsSidecar, pythonEdgeTts, lockedEngine;
3657
3735
  var init_synthesize = __esm({
3658
3736
  "src/lib/tts/synthesize.ts"() {
3659
3737
  "use strict";
3660
3738
  import_node_child_process4 = require("child_process");
3661
- import_node_fs7 = require("fs");
3739
+ import_node_fs8 = require("fs");
3662
3740
  import_promises5 = require("fs/promises");
3663
3741
  import_node_path7 = require("path");
3664
3742
  import_promises6 = require("stream/promises");
@@ -3680,7 +3758,7 @@ function clipKey(spokenText, voice, rate) {
3680
3758
  async function reuseClip(cacheDir, key, audioPath) {
3681
3759
  const mp3 = (0, import_node_path8.join)(cacheDir, `${key}.mp3`);
3682
3760
  const meta = (0, import_node_path8.join)(cacheDir, `${key}.json`);
3683
- if (!(0, import_node_fs8.existsSync)(mp3) || !(0, import_node_fs8.existsSync)(meta)) return null;
3761
+ if (!(0, import_node_fs9.existsSync)(mp3) || !(0, import_node_fs9.existsSync)(meta)) return null;
3684
3762
  try {
3685
3763
  const boundaries = JSON.parse(await (0, import_promises7.readFile)(meta, "utf-8"));
3686
3764
  await (0, import_promises7.copyFile)(mp3, audioPath);
@@ -3742,11 +3820,17 @@ function spellAcronyms(text) {
3742
3820
  );
3743
3821
  }
3744
3822
  function bindDomains(text) {
3745
- return text.replace(/\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi, "$1-dot-$2");
3823
+ 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(
3824
+ /\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi,
3825
+ (_m, host, tld) => `${host}-dot-${SPOKEN_TLD[tld.toLowerCase()] ?? tld}`
3826
+ );
3746
3827
  }
3747
3828
  function speakSymbols(text) {
3748
3829
  return text.replace(/\s*&\s*/g, " and ").replace(/[<>]/g, " ").replace(/\s{2,}/g, " ").trim();
3749
3830
  }
3831
+ function spokenForm(caption) {
3832
+ return speakNumbers(spellAcronyms((0, import_video_kit7.toSpokenForm)(bindDomains(speakSymbols(caption)))));
3833
+ }
3750
3834
  async function generateVoiceover(steps, config, workDir, outputDir, logError2, task2, info2) {
3751
3835
  resetVoiceEngine();
3752
3836
  const voice = resolveVoice2(config);
@@ -3793,7 +3877,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
3793
3877
  }
3794
3878
  const audioPath = (0, import_node_path8.join)(audioDir, `step-${stepNum}.mp3`);
3795
3879
  try {
3796
- const spokenText = speakNumbers(spellAcronyms((0, import_video_kit7.toSpokenForm)(bindDomains(speakSymbols(step.caption)))));
3880
+ const spokenText = spokenForm(step.caption);
3797
3881
  const key = clipKey(spokenText, voice, ratePercent ?? "");
3798
3882
  const cacheDir = (0, import_node_path8.join)(outputDir, ".tts-clips");
3799
3883
  const cached = await reuseClip(cacheDir, key, audioPath);
@@ -3874,12 +3958,12 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
3874
3958
  await saveVoiceoverMeta(outputDir, meta);
3875
3959
  return { voiceoverPath, stepAudio };
3876
3960
  }
3877
- var import_node_crypto, import_node_fs8, import_promises7, import_node_path8, import_video_kit7, TTS_POOL, ONES, TENS, SPELLED_OUT, LETTER_SOUNDS;
3961
+ var import_node_crypto, import_node_fs9, import_promises7, import_node_path8, import_video_kit7, TTS_POOL, ONES, TENS, SPELLED_OUT, LETTER_SOUNDS, SPOKEN_TLD;
3878
3962
  var init_generate = __esm({
3879
3963
  "src/lib/tts/generate.ts"() {
3880
3964
  "use strict";
3881
3965
  import_node_crypto = require("crypto");
3882
- import_node_fs8 = require("fs");
3966
+ import_node_fs9 = require("fs");
3883
3967
  import_promises7 = require("fs/promises");
3884
3968
  import_node_path8 = require("path");
3885
3969
  import_video_kit7 = require("@spark-apps/video-kit");
@@ -3944,6 +4028,9 @@ var init_generate = __esm({
3944
4028
  Y: "why",
3945
4029
  Z: "zed"
3946
4030
  };
4031
+ SPOKEN_TLD = {
4032
+ ai: "ay-eye"
4033
+ };
3947
4034
  }
3948
4035
  });
3949
4036
 
@@ -4003,6 +4090,7 @@ __export(lib_exports, {
4003
4090
  NO_CLIP_OUTRO: () => NO_CLIP_OUTRO,
4004
4091
  PLAN_MAX_TOKENS: () => PLAN_MAX_TOKENS,
4005
4092
  RATE_FACTOR: () => RATE_FACTOR,
4093
+ RELAY_BASE: () => RELAY_BASE,
4006
4094
  SERVER_BACKOFF_MS: () => SERVER_BACKOFF_MS,
4007
4095
  SIZE_PRESETS: () => SIZE_PRESETS,
4008
4096
  TRIM_EDGE_SILENCE: () => TRIM_EDGE_SILENCE,
@@ -4054,6 +4142,7 @@ __export(lib_exports, {
4054
4142
  getErrorMessage: () => getErrorMessage,
4055
4143
  getLanguageName: () => getLanguageName,
4056
4144
  getMediaDuration: () => getMediaDuration,
4145
+ getOrRefreshRelayToken: () => getOrRefreshRelayToken,
4057
4146
  getStatus: () => getStatus,
4058
4147
  getStatusCached: () => getStatusCached,
4059
4148
  getTierByEmail: () => getTierByEmail,
@@ -4097,12 +4186,12 @@ __export(lib_exports, {
4097
4186
  runPooled: () => runPooled,
4098
4187
  salvageTruncatedJson: () => salvageTruncatedJson,
4099
4188
  shouldSkipLink: () => shouldSkipLink,
4100
- shouldWatermark: () => shouldWatermark,
4101
4189
  showPaywall: () => showPaywall,
4102
4190
  speakSymbols: () => speakSymbols,
4103
4191
  speedUpMiddle: () => speedUpMiddle,
4104
4192
  speedUpVideo: () => speedUpVideo,
4105
4193
  spliceVideos: () => spliceVideos,
4194
+ spokenForm: () => spokenForm,
4106
4195
  spokenWordTimings: () => spokenWordTimings,
4107
4196
  stripLongDashes: () => stripLongDashes,
4108
4197
  synthesizeSpeech: () => synthesizeSpeech,
@@ -4241,54 +4330,16 @@ var init_ambience = __esm({
4241
4330
  }
4242
4331
  });
4243
4332
 
4244
- // src/lib/image-kind.ts
4245
- function mimeForKind(kind) {
4246
- return MIME[kind];
4247
- }
4248
- function sniffImageKind(buf) {
4249
- if (buf.length < 12) return null;
4250
- if (buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "png";
4251
- if (buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "jpeg";
4252
- if (buf.subarray(0, 3).toString("latin1") === "GIF") return "gif";
4253
- if (buf.subarray(0, 4).toString("latin1") === "RIFF" && buf.subarray(8, 12).toString("latin1") === "WEBP") {
4254
- return "webp";
4255
- }
4256
- const head = buf.subarray(0, 1024).toString("utf8").replace(/^/, "").trimStart();
4257
- if (/^<(\?xml|!--|!doctype svg|svg)[\s>]/i.test(head) && /<svg[\s>]/i.test(head)) return "svg";
4258
- return null;
4259
- }
4260
- var MIME, MIN_ICON_PX;
4261
- var init_image_kind = __esm({
4262
- "src/lib/image-kind.ts"() {
4263
- "use strict";
4264
- MIME = {
4265
- png: "image/png",
4266
- jpeg: "image/jpeg",
4267
- gif: "image/gif",
4268
- webp: "image/webp",
4269
- svg: "image/svg+xml"
4270
- };
4271
- MIN_ICON_PX = 48;
4272
- }
4273
- });
4274
-
4275
4333
  // src/lib/outro-card.ts
4276
- function dataUri(path) {
4277
- try {
4278
- const buf = (0, import_node_fs17.readFileSync)(path);
4279
- const kind = sniffImageKind(buf);
4280
- if (!kind) return null;
4281
- return `data:${mimeForKind(kind)};base64,${buf.toString("base64")}`;
4282
- } catch {
4283
- return null;
4284
- }
4285
- }
4286
4334
  function escapeHtml(text) {
4287
4335
  return text.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
4288
4336
  }
4289
4337
  function buildOutroCardHtml(options) {
4290
4338
  const { subject, credits = [], promo, width, height } = options;
4291
4339
  const unit = Math.min(width, height);
4340
+ const markFraction = width > height ? 0.28 : 0.46;
4341
+ const markSize = unit * markFraction;
4342
+ const padTop = width > height ? unit * 0.08 : unit * 0.23;
4292
4343
  const creditRows = credits.map((credit) => {
4293
4344
  const icon = credit.iconPath ? dataUri(credit.iconPath) : null;
4294
4345
  return `<div class="credit">
@@ -4297,7 +4348,8 @@ function buildOutroCardHtml(options) {
4297
4348
  ${icon ? `<img class="cicon" src="${icon}" alt="" />` : ""}
4298
4349
  </div>`;
4299
4350
  }).join("");
4300
- const subjectIcon = subject.iconPath ? dataUri(subject.iconPath) : null;
4351
+ const animated = subject.animatedMark;
4352
+ const subjectIcon = animated ? null : subject.iconPath ? dataUri(subject.iconPath) : null;
4301
4353
  return `<!doctype html>
4302
4354
  <html><head><meta charset="utf-8" /><style>
4303
4355
  * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -4315,7 +4367,7 @@ function buildOutroCardHtml(options) {
4315
4367
  position: relative;
4316
4368
  display: flex; flex-direction: column;
4317
4369
  align-items: center; justify-content: flex-start;
4318
- padding-top: ${unit * 0.23}px;
4370
+ padding-top: ${padTop}px;
4319
4371
  gap: ${unit * 0.028}px;
4320
4372
  }
4321
4373
  /* Toolchain credits: top right, deliberately small. */
@@ -4333,7 +4385,7 @@ function buildOutroCardHtml(options) {
4333
4385
  /* The app the video is about. */
4334
4386
  /* The app's mark leads the card, and it is meant to be seen: at 46% of
4335
4387
  the short edge it reads at a glance on a phone. */
4336
- .mark { width: ${unit * 0.46}px; height: ${unit * 0.46}px; object-fit: contain; border-radius: ${unit * 0.09}px; }
4388
+ .mark { width: ${markSize}px; height: ${markSize}px; object-fit: contain; border-radius: ${unit * 0.09}px; }
4337
4389
  /* A dark mark on a dark card is invisible, which looks exactly like a
4338
4390
  failed image. Sites that publish a black-on-transparent logo are common
4339
4391
  enough to be worth catching, and a light plate is what their own site
@@ -4355,8 +4407,12 @@ function buildOutroCardHtml(options) {
4355
4407
  }
4356
4408
 
4357
4409
  /* Today's offer, along the very bottom edge. */
4410
+ /* In the flow, not pinned to the bottom edge. Pinned, it was laid out
4411
+ without reference to the text above it, so on a short canvas it simply
4412
+ covered whatever had grown into its space. margin-top pushes it clear of
4413
+ the domain instead. */
4358
4414
  .promo {
4359
- position: absolute; bottom: ${unit * 0.2}px;
4415
+ margin-top: ${unit * 0.05}px;
4360
4416
  display: flex; align-items: baseline; gap: ${unit * 0.022}px;
4361
4417
  padding: ${unit * 0.026}px ${unit * 0.055}px;
4362
4418
  border: 2px solid rgba(255, 209, 64, .85); border-radius: ${unit * 0.024}px;
@@ -4364,9 +4420,60 @@ function buildOutroCardHtml(options) {
4364
4420
  }
4365
4421
  .ppct { font-size: ${unit * 0.056}px; font-weight: 700; color: #ffd140; }
4366
4422
  .pcode { font-size: ${unit * 0.048}px; font-weight: 700; letter-spacing: .05em; }
4423
+
4424
+ /* The animated mark, sized to the same 46% slot the still one occupies so
4425
+ the rest of the card's rhythm is unchanged. Ported from VidLet's
4426
+ src/gui/css/loader.css: the amber tip flies off and snaps back into the
4427
+ cut - a jump cut, on a loop. Pure CSS, because the renderer steps these
4428
+ animations frame by frame and a JS-driven one could not be stepped.
4429
+
4430
+ No prefers-reduced-motion fallback here, unlike the original: this runs
4431
+ in a headless Chromium rendering a video, where there is no viewer whose
4432
+ preference could be read, and honouring the default would silently flatten
4433
+ the animation to a fade in CI. */
4434
+ .amark { width: ${markSize}px; height: ${markSize}px; display: block; }
4435
+ .amark path {
4436
+ /* Without fill-box the origin is the whole SVG viewport, which swings the
4437
+ tip off its own centre on the scale steps. */
4438
+ transform-box: fill-box;
4439
+ transform-origin: center;
4440
+ }
4441
+ .amark .vl-body { fill: #fff6e5; stroke: #fff6e5; animation: vl-nudge 1.4s cubic-bezier(.4,0,.2,1) infinite; }
4442
+ .amark .vl-tip { fill: #ffc53d; stroke: #ffc53d; animation: vl-cut 1.4s cubic-bezier(.4,0,.2,1) infinite; }
4443
+ .amark .vl-ghost { opacity: .35; }
4444
+ .amark .vl-ghost .vl-tip { animation-delay: -.1s; }
4445
+
4446
+ /* Translations are in viewBox units, so the motion scales with the mark. */
4447
+ @keyframes vl-cut {
4448
+ 0%, 30% { transform: translateX(0) scale(1); opacity: 1; }
4449
+ 48% { transform: translateX(62px) scale(.94); opacity: 1; }
4450
+ 60% { transform: translateX(88px) scale(.86); opacity: 0; }
4451
+ 61%, 74% { transform: translateX(-58px) scale(.86); opacity: 0; }
4452
+ 86% { transform: translateX(6px) scale(1.06); opacity: 1; }
4453
+ 100% { transform: translateX(0) scale(1); opacity: 1; }
4454
+ }
4455
+ /* The body takes the recoil, so the cut lands rather than just sliding. */
4456
+ @keyframes vl-nudge {
4457
+ 0%, 30% { transform: scaleX(1); }
4458
+ 55% { transform: scaleX(1.04); }
4459
+ 86% { transform: scaleX(.97); }
4460
+ 100% { transform: scaleX(1); }
4461
+ }
4462
+ ${/* The card normally reads its palette off the mark's pixels, and an SVG
4463
+ drawn by this stylesheet has none to read - the measuring script finds
4464
+ no <img class="mark"> and leaves the defaults, which would put VidLet's
4465
+ amber on a teal card. Stated instead of measured, last so it wins. */
4466
+ animated === "vidlet" ? ":root { --bg-in: #1f1a10; --bg-out: #07060a; --accent: #ffc53d; }" : ""}
4367
4467
  </style></head>
4368
4468
  <body>
4369
4469
  ${credits.length ? `<div class="credits">${creditRows}</div>` : ""}
4470
+ ${animated === "vidlet" ? `<svg class="amark" viewBox="0 0 512 512" aria-hidden="true">
4471
+ <g stroke-linejoin="round" stroke-width="20">
4472
+ <g class="vl-ghost"><path class="vl-tip" d="M318 246 L410 302 L318 358 Z"/></g>
4473
+ <path class="vl-body" d="M176 124 L300 200 L300 312 L176 388 Z"/>
4474
+ <path class="vl-tip" d="M318 246 L410 302 L318 358 Z"/>
4475
+ </g>
4476
+ </svg>` : ""}
4370
4477
  ${subjectIcon ? `<img class="mark" src="${subjectIcon}" alt="" />` : ""}
4371
4478
  <div class="name">${escapeHtml(subject.name)}</div>
4372
4479
  ${subject.tagline ? `<div class="tagline">${escapeHtml(subject.tagline)}</div>` : ""}
@@ -4469,12 +4576,18 @@ function buildOutroCardHtml(options) {
4469
4576
  </script>
4470
4577
  </body></html>`;
4471
4578
  }
4472
- var import_node_fs17;
4579
+ var ANIMATED_MARK_LOOP_SECONDS, dataUri;
4473
4580
  var init_outro_card = __esm({
4474
4581
  "src/lib/outro-card.ts"() {
4475
4582
  "use strict";
4476
- import_node_fs17 = require("fs");
4477
4583
  init_image_kind();
4584
+ ANIMATED_MARK_LOOP_SECONDS = {
4585
+ // Matches `vl-cut` / `vl-nudge` in VidLet's src/gui/css/loader.css. Changing
4586
+ // it there without changing it here makes the encoded loop stutter at the
4587
+ // seam, because the clip is cut to exactly this length and then repeated.
4588
+ vidlet: 1.4
4589
+ };
4590
+ dataUri = imageDataUri;
4478
4591
  }
4479
4592
  });
4480
4593
 
@@ -4505,6 +4618,9 @@ async function renderOutroClip(options) {
4505
4618
  const htmlPath = (0, import_node_path22.join)(workDir, "outro-card.html");
4506
4619
  const pngPath = (0, import_node_path22.join)(workDir, "outro-card.png");
4507
4620
  const clipPath = (0, import_node_path22.join)(workDir, "outro-card.mp4");
4621
+ if (options.subject.animatedMark) {
4622
+ return renderAnimatedOutroClip(options, { htmlPath, clipPath, seconds, fps });
4623
+ }
4508
4624
  await (0, import_promises16.writeFile)(htmlPath, buildOutroCardHtml(options));
4509
4625
  const browser = await import_playwright7.chromium.launch({ headless: true });
4510
4626
  try {
@@ -4547,6 +4663,70 @@ async function renderOutroClip(options) {
4547
4663
  );
4548
4664
  return clipPath;
4549
4665
  }
4666
+ async function renderAnimatedOutroClip(options, paths) {
4667
+ const { htmlPath, clipPath, seconds, fps } = paths;
4668
+ const { workDir, width, height } = options;
4669
+ const loopSeconds = ANIMATED_MARK_LOOP_SECONDS[options.subject.animatedMark];
4670
+ const frameCount = Math.max(1, Math.round(loopSeconds * fps));
4671
+ const framesDir = (0, import_node_path22.join)(workDir, "outro-frames");
4672
+ await (0, import_promises16.rm)(framesDir, { recursive: true, force: true });
4673
+ await (0, import_promises16.mkdir)(framesDir, { recursive: true });
4674
+ await (0, import_promises16.writeFile)(htmlPath, buildOutroCardHtml(options));
4675
+ const browser = await import_playwright7.chromium.launch({ headless: true });
4676
+ try {
4677
+ const page = await browser.newPage({ viewport: { width, height } });
4678
+ await page.goto((0, import_node_url2.pathToFileURL)((0, import_node_path22.resolve)(htmlPath)).href, { waitUntil: "load" });
4679
+ await page.waitForSelector('body[data-cards-ready="1"]', { timeout: 3e3 }).catch(() => {
4680
+ });
4681
+ await page.waitForTimeout(300);
4682
+ await page.evaluate(() => {
4683
+ for (const animation of document.getAnimations()) animation.pause();
4684
+ });
4685
+ for (let frame = 0; frame < frameCount; frame++) {
4686
+ const elapsedMs = frame / frameCount * loopSeconds * 1e3;
4687
+ await page.evaluate((ms) => {
4688
+ for (const animation of document.getAnimations()) animation.currentTime = ms;
4689
+ }, elapsedMs);
4690
+ await page.screenshot({ path: (0, import_node_path22.join)(framesDir, `f-${String(frame).padStart(4, "0")}.png`) });
4691
+ }
4692
+ } finally {
4693
+ await browser.close();
4694
+ }
4695
+ await runFFmpeg(
4696
+ [
4697
+ "-y",
4698
+ // -stream_loop applies to the input that FOLLOWS it, so it has to sit
4699
+ // before -framerate/-i, not after.
4700
+ "-stream_loop",
4701
+ "-1",
4702
+ "-framerate",
4703
+ String(fps),
4704
+ "-i",
4705
+ (0, import_node_path22.join)(framesDir, "f-%04d.png"),
4706
+ "-f",
4707
+ "lavfi",
4708
+ "-i",
4709
+ "anullsrc=channel_layout=stereo:sample_rate=44100",
4710
+ "-t",
4711
+ String(seconds),
4712
+ "-r",
4713
+ String(fps),
4714
+ "-pix_fmt",
4715
+ "yuv420p",
4716
+ "-c:v",
4717
+ "libx264",
4718
+ "-preset",
4719
+ "veryfast",
4720
+ "-c:a",
4721
+ "aac",
4722
+ "-shortest",
4723
+ clipPath
4724
+ ],
4725
+ "ffmpeg animated outro card render failed"
4726
+ );
4727
+ await (0, import_promises16.rm)(framesDir, { recursive: true, force: true });
4728
+ return clipPath;
4729
+ }
4550
4730
  var import_promises16, import_node_path22, import_node_url2, import_playwright7;
4551
4731
  var init_render_outro = __esm({
4552
4732
  "src/browser/render-outro.ts"() {
@@ -4860,9 +5040,100 @@ var import_promises2 = require("fs/promises");
4860
5040
  var import_playwright2 = require("playwright");
4861
5041
  init_lifecycle();
4862
5042
 
5043
+ // src/browser/brand-watermark.ts
5044
+ init_image_kind();
5045
+ function watermarkInitScript(payload) {
5046
+ return `(() => {
5047
+ var ID = '__quickpeek_brand_watermark';
5048
+ var P = ${embedJson(payload)};
5049
+ var vertical = P.position.indexOf('top') === 0 ? 'top:22px' : 'bottom:22px';
5050
+ var horizontal = P.position.indexOf('right') > -1 ? 'right:24px' : 'left:24px';
5051
+ var SHELL = [
5052
+ 'position:fixed',
5053
+ vertical,
5054
+ horizontal,
5055
+ 'z-index:2147483646',
5056
+ 'display:flex',
5057
+ 'align-items:center',
5058
+ 'gap:10px',
5059
+ 'padding:8px 16px 8px 10px',
5060
+ 'border-radius:999px',
5061
+ // A hairline and a blur rather than a solid plate: the mark sits on top of
5062
+ // whatever the app is showing, and a hard box reads as part of the UI.
5063
+ // The alpha is high enough to stay dark over a LIGHT app: at 0.55 the blur
5064
+ // averaged a pale page up into grey and the white label sat on it at about
5065
+ // 2:1, which photographs as a smudge.
5066
+ 'background:rgba(12,12,18,0.78)',
5067
+ 'border:1px solid rgba(255,255,255,0.14)',
5068
+ 'backdrop-filter:blur(14px) saturate(1.3)',
5069
+ '-webkit-backdrop-filter:blur(14px) saturate(1.3)',
5070
+ 'box-shadow:0 6px 24px rgba(0,0,0,0.30)',
5071
+ 'opacity:' + P.opacity,
5072
+ 'pointer-events:none',
5073
+ 'user-select:none'
5074
+ ].join(';');
5075
+ var ICON = 'width:26px;height:26px;border-radius:7px;object-fit:contain;display:block';
5076
+ var NAME = [
5077
+ 'font:600 15px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
5078
+ 'letter-spacing:0.015em',
5079
+ 'color:#ffffff',
5080
+ 'white-space:nowrap'
5081
+ ].join(';');
5082
+
5083
+ function mount() {
5084
+ try {
5085
+ if (!document.body || document.getElementById(ID)) return;
5086
+ var el = document.createElement('div');
5087
+ el.id = ID;
5088
+ el.setAttribute('style', SHELL);
5089
+ if (P.icon) {
5090
+ var img = document.createElement('img');
5091
+ img.setAttribute('style', ICON);
5092
+ img.src = P.icon;
5093
+ img.alt = '';
5094
+ el.appendChild(img);
5095
+ }
5096
+ var label = document.createElement('span');
5097
+ label.setAttribute('style', NAME);
5098
+ label.textContent = P.name;
5099
+ el.appendChild(label);
5100
+ document.body.appendChild(el);
5101
+ } catch (e) {
5102
+ // A page that refuses the node still records; the mark is cosmetic.
5103
+ }
5104
+ }
5105
+
5106
+ if (document.body) mount();
5107
+ else document.addEventListener('DOMContentLoaded', mount, { once: true });
5108
+ setInterval(mount, 400);
5109
+ })();`;
5110
+ }
5111
+ async function applyBrandWatermark(context, watermark) {
5112
+ if (!watermark?.name) return false;
5113
+ const payload = {
5114
+ name: watermark.name,
5115
+ icon: watermark.iconPath ? imageDataUri(watermark.iconPath) : null,
5116
+ position: watermark.position ?? "top-right",
5117
+ opacity: clampOpacity(watermark.opacity)
5118
+ };
5119
+ try {
5120
+ await context.addInitScript(watermarkInitScript(payload));
5121
+ return true;
5122
+ } catch {
5123
+ return false;
5124
+ }
5125
+ }
5126
+ function embedJson(value) {
5127
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
5128
+ }
5129
+ function clampOpacity(value) {
5130
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0.92;
5131
+ return Math.min(1, Math.max(0.35, value));
5132
+ }
5133
+
4863
5134
  // src/browser/wsl.ts
4864
5135
  var import_node_child_process = require("child_process");
4865
- var import_node_fs2 = require("fs");
5136
+ var import_node_fs3 = require("fs");
4866
5137
  var import_node_http = require("http");
4867
5138
  var import_playwright = require("playwright");
4868
5139
  function isWSL() {
@@ -4875,7 +5146,7 @@ function isWSL() {
4875
5146
  }
4876
5147
  }
4877
5148
  function tryEnableWSLInterop() {
4878
- return (0, import_node_fs2.existsSync)("/proc/sys/fs/binfmt_misc/WSLInterop");
5149
+ return (0, import_node_fs3.existsSync)("/proc/sys/fs/binfmt_misc/WSLInterop");
4879
5150
  }
4880
5151
  function wslToWindowsPath(wslPath) {
4881
5152
  if (!wslPath) return wslPath;
@@ -4884,7 +5155,7 @@ function wslToWindowsPath(wslPath) {
4884
5155
  return `${m[1].toUpperCase()}:\\${m[2].replace(/\//g, "\\")}`;
4885
5156
  }
4886
5157
  function findExe(whereExeQuery, ...hardcoded) {
4887
- for (const p of hardcoded) if ((0, import_node_fs2.existsSync)(p)) return p;
5158
+ for (const p of hardcoded) if ((0, import_node_fs3.existsSync)(p)) return p;
4888
5159
  try {
4889
5160
  const line = (0, import_node_child_process.execSync)(`where.exe ${whereExeQuery}`, { encoding: "utf-8", timeout: 3e3 }).split("\n")[0]?.trim();
4890
5161
  if (line) return line.replace(/^([A-Za-z]):\\/, (_, d) => `/mnt/${d.toLowerCase()}/`).replace(/\\/g, "/");
@@ -4907,7 +5178,7 @@ function getWindowsHostIP() {
4907
5178
  } catch {
4908
5179
  }
4909
5180
  try {
4910
- const m = (0, import_node_fs2.readFileSync)("/etc/resolv.conf", "utf-8").match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
5181
+ const m = (0, import_node_fs3.readFileSync)("/etc/resolv.conf", "utf-8").match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
4911
5182
  if (m?.[1]) return m[1];
4912
5183
  } catch {
4913
5184
  }
@@ -4915,7 +5186,7 @@ function getWindowsHostIP() {
4915
5186
  }
4916
5187
  function startRelay(nodePath, windowsHost, targetPort) {
4917
5188
  const script = `var n=require('net'),s=n.createServer(function(c){var r=n.createConnection(${targetPort},'127.0.0.1');c.pipe(r);r.pipe(c);r.on('error',function(){c.destroy()});c.on('error',function(){r.destroy()});});s.listen(0,'${windowsHost}',function(){process.stdout.write('PORT:'+s.address().port+'\\n')});`;
4918
- return new Promise((resolve10, reject) => {
5189
+ return new Promise((resolve11, reject) => {
4919
5190
  const proc = (0, import_node_child_process.spawn)(nodePath, ["-e", script], { stdio: ["ignore", "pipe", "ignore"], detached: true });
4920
5191
  const timer = setTimeout(() => reject(new Error("relay startup timed out")), 6e3);
4921
5192
  let buf = "";
@@ -4924,7 +5195,7 @@ function startRelay(nodePath, windowsHost, targetPort) {
4924
5195
  const m = buf.match(/PORT:(\d+)/);
4925
5196
  if (m) {
4926
5197
  clearTimeout(timer);
4927
- resolve10({ proc, port: +m[1] });
5198
+ resolve11({ proc, port: +m[1] });
4928
5199
  }
4929
5200
  });
4930
5201
  proc.on("error", (e) => {
@@ -4934,7 +5205,7 @@ function startRelay(nodePath, windowsHost, targetPort) {
4934
5205
  });
4935
5206
  }
4936
5207
  function waitForChrome(host, port) {
4937
- return new Promise((resolve10, reject) => {
5208
+ return new Promise((resolve11, reject) => {
4938
5209
  const deadline = Date.now() + 2e4;
4939
5210
  const poll = () => {
4940
5211
  if (Date.now() > deadline) {
@@ -4943,7 +5214,7 @@ function waitForChrome(host, port) {
4943
5214
  }
4944
5215
  const req = (0, import_node_http.request)({ host, port, path: "/json/version", method: "GET" }, (res) => {
4945
5216
  res.resume();
4946
- resolve10();
5217
+ resolve11();
4947
5218
  });
4948
5219
  req.on("error", () => setTimeout(poll, 600));
4949
5220
  req.setTimeout(1e3, () => {
@@ -5086,6 +5357,7 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
5086
5357
  });
5087
5358
  }
5088
5359
  await hideDevOverlays(context);
5360
+ await applyBrandWatermark(context, config.video.watermark);
5089
5361
  const session = { context, browser, chromeSession };
5090
5362
  session.release = onCleanup(() => closeBrowserSession(session));
5091
5363
  return session;
@@ -5178,7 +5450,7 @@ async function cancelSignIn() {
5178
5450
  }
5179
5451
 
5180
5452
  // src/browser/chrome-import.ts
5181
- var import_node_fs3 = require("fs");
5453
+ var import_node_fs4 = require("fs");
5182
5454
  var import_node_os2 = require("os");
5183
5455
  var import_node_path2 = require("path");
5184
5456
  var import_playwright3 = require("playwright");
@@ -5215,7 +5487,7 @@ var COOKIE_SIBLINGS = ["", "-wal", "-shm", "-journal"];
5215
5487
  function findCookieDb(userDataDir, profile) {
5216
5488
  for (const rel of [(0, import_node_path2.join)(profile, "Network", "Cookies"), (0, import_node_path2.join)(profile, "Cookies")]) {
5217
5489
  const abs = (0, import_node_path2.join)(userDataDir, rel);
5218
- if ((0, import_node_fs3.existsSync)(abs)) return { abs, rel };
5490
+ if ((0, import_node_fs4.existsSync)(abs)) return { abs, rel };
5219
5491
  }
5220
5492
  return null;
5221
5493
  }
@@ -5223,7 +5495,7 @@ function detectImportSources() {
5223
5495
  const found = [];
5224
5496
  for (const browser of BROWSERS) {
5225
5497
  for (const userDataDir of userDataRoots(browser)) {
5226
- if (!(0, import_node_fs3.existsSync)(userDataDir)) continue;
5498
+ if (!(0, import_node_fs4.existsSync)(userDataDir)) continue;
5227
5499
  const db = findCookieDb(userDataDir, "Default");
5228
5500
  if (db) found.push({ browser, userDataDir, profile: "Default", cookiesPath: db.abs, cookiesRel: db.rel });
5229
5501
  }
@@ -5252,16 +5524,16 @@ function passwordStores() {
5252
5524
  async function importFromSource(url, source) {
5253
5525
  const host = hostOf2(url);
5254
5526
  if (!host) throw new Error(`Not a valid URL: ${url}`);
5255
- const scratch = (0, import_node_fs3.mkdtempSync)((0, import_node_path2.join)((0, import_node_os2.tmpdir)(), "qp-import-"));
5527
+ const scratch = (0, import_node_fs4.mkdtempSync)((0, import_node_path2.join)((0, import_node_os2.tmpdir)(), "qp-import-"));
5256
5528
  try {
5257
5529
  const localState = (0, import_node_path2.join)(source.userDataDir, "Local State");
5258
- if ((0, import_node_fs3.existsSync)(localState)) (0, import_node_fs3.copyFileSync)(localState, (0, import_node_path2.join)(scratch, "Local State"));
5530
+ if ((0, import_node_fs4.existsSync)(localState)) (0, import_node_fs4.copyFileSync)(localState, (0, import_node_path2.join)(scratch, "Local State"));
5259
5531
  for (const suffix of COOKIE_SIBLINGS) {
5260
5532
  const from = source.cookiesPath + suffix;
5261
- if (!(0, import_node_fs3.existsSync)(from)) continue;
5533
+ if (!(0, import_node_fs4.existsSync)(from)) continue;
5262
5534
  const to = (0, import_node_path2.join)(scratch, source.cookiesRel + suffix);
5263
- (0, import_node_fs3.mkdirSync)((0, import_node_path2.dirname)(to), { recursive: true });
5264
- (0, import_node_fs3.copyFileSync)(from, to);
5535
+ (0, import_node_fs4.mkdirSync)((0, import_node_path2.dirname)(to), { recursive: true });
5536
+ (0, import_node_fs4.copyFileSync)(from, to);
5265
5537
  }
5266
5538
  for (const store2 of passwordStores()) {
5267
5539
  const state = await readState(scratch, source.browser.channel, store2);
@@ -5277,7 +5549,7 @@ async function importFromSource(url, source) {
5277
5549
  }
5278
5550
  return null;
5279
5551
  } finally {
5280
- (0, import_node_fs3.rmSync)(scratch, { recursive: true, force: true });
5552
+ (0, import_node_fs4.rmSync)(scratch, { recursive: true, force: true });
5281
5553
  }
5282
5554
  }
5283
5555
  async function readState(profileDir, channel, store2) {
@@ -5334,7 +5606,7 @@ function renderLogo(config = {}) {
5334
5606
  }
5335
5607
 
5336
5608
  // src/utils/config-loader.ts
5337
- var import_node_fs9 = require("fs");
5609
+ var import_node_fs10 = require("fs");
5338
5610
  var import_promises8 = require("fs/promises");
5339
5611
  var import_node_os5 = require("os");
5340
5612
  var import_node_path9 = require("path");
@@ -5351,7 +5623,7 @@ function runRoot() {
5351
5623
  async function resolveRunFile(name) {
5352
5624
  const target = (0, import_node_path9.join)(runRoot(), name);
5353
5625
  const source = (0, import_node_path9.join)(process.cwd(), name);
5354
- if (target !== source && !(0, import_node_fs9.existsSync)(target) && (0, import_node_fs9.existsSync)(source)) {
5626
+ if (target !== source && !(0, import_node_fs10.existsSync)(target) && (0, import_node_fs10.existsSync)(source)) {
5355
5627
  await (0, import_promises8.mkdir)((0, import_node_path9.dirname)(target), { recursive: true });
5356
5628
  await (0, import_promises8.copyFile)(source, target);
5357
5629
  }
@@ -5359,7 +5631,7 @@ async function resolveRunFile(name) {
5359
5631
  }
5360
5632
  async function loadConfig(_url, langOverride) {
5361
5633
  const configPath = await resolveRunFile(CONFIG_FILE);
5362
- if ((0, import_node_fs9.existsSync)(configPath)) {
5634
+ if ((0, import_node_fs10.existsSync)(configPath)) {
5363
5635
  try {
5364
5636
  const content = await (0, import_promises8.readFile)(configPath, "utf-8");
5365
5637
  const userConfig = JSON.parse(content);
@@ -5420,7 +5692,7 @@ async function prepareWorkDir(scope = "run") {
5420
5692
  const safe = scope.replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "run";
5421
5693
  const root = (0, import_node_path9.join)(runRoot(), WORK_DIR);
5422
5694
  const workDir = (0, import_node_path9.join)(root, `${safe}-${process.pid}`);
5423
- if ((0, import_node_fs9.existsSync)(workDir)) {
5695
+ if ((0, import_node_fs10.existsSync)(workDir)) {
5424
5696
  await (0, import_promises8.rm)(workDir, { recursive: true });
5425
5697
  }
5426
5698
  await (0, import_promises8.mkdir)(workDir, { recursive: true });
@@ -5447,7 +5719,7 @@ var USER_DATA_DIR = (0, import_node_path9.join)((0, import_node_os5.homedir)(),
5447
5719
  var USER_DATA_FILE = (0, import_node_path9.join)(USER_DATA_DIR, "user.json");
5448
5720
  async function loadUserData() {
5449
5721
  try {
5450
- if ((0, import_node_fs9.existsSync)(USER_DATA_FILE)) {
5722
+ if ((0, import_node_fs10.existsSync)(USER_DATA_FILE)) {
5451
5723
  return JSON.parse(await (0, import_promises8.readFile)(USER_DATA_FILE, "utf-8"));
5452
5724
  }
5453
5725
  } catch {
@@ -5455,7 +5727,7 @@ async function loadUserData() {
5455
5727
  return {};
5456
5728
  }
5457
5729
  async function saveUserData(userData) {
5458
- if (!(0, import_node_fs9.existsSync)(USER_DATA_DIR)) {
5730
+ if (!(0, import_node_fs10.existsSync)(USER_DATA_DIR)) {
5459
5731
  await (0, import_promises8.mkdir)(USER_DATA_DIR, { recursive: true });
5460
5732
  }
5461
5733
  const existing = await loadUserData();
@@ -5524,7 +5796,7 @@ function parseCaptionCSS(css) {
5524
5796
  }
5525
5797
  async function loadCaptions() {
5526
5798
  const captionsPath = await resolveRunFile("captions.css");
5527
- if (!(0, import_node_fs9.existsSync)(captionsPath)) {
5799
+ if (!(0, import_node_fs10.existsSync)(captionsPath)) {
5528
5800
  await (0, import_promises8.writeFile)(captionsPath, DEFAULT_CAPTIONS_CSS);
5529
5801
  return DEFAULT_CAPTIONS;
5530
5802
  }
@@ -5538,7 +5810,10 @@ async function loadCaptions() {
5538
5810
  async function savePlan(plan, planPath) {
5539
5811
  const cleanPlan = {
5540
5812
  ...plan,
5541
- steps: plan.steps.map(({ duration, ...step }) => step)
5813
+ steps: plan.steps.map(({ duration, ...step }) => ({
5814
+ ...step,
5815
+ ...typeof duration === "number" && duration > 0 ? { duration: Math.min(duration, 30) } : {}
5816
+ }))
5542
5817
  };
5543
5818
  await (0, import_promises8.mkdir)((0, import_node_path9.dirname)(planPath), { recursive: true });
5544
5819
  await (0, import_promises8.writeFile)(planPath, JSON.stringify(cleanPlan, null, 2));
@@ -5647,6 +5922,11 @@ async function showHelp() {
5647
5922
  `-s, --script <file>`,
5648
5923
  "Narration script (one caption per line)"
5649
5924
  ],
5925
+ [
5926
+ `${opt("--draft")}, ${opt("-dr")}`,
5927
+ `--draft, -dr`,
5928
+ "Rough cut first: small, silent, quick. Re-run without it for the real one"
5929
+ ],
5650
5930
  [`${opt("-y")}, ${opt("--yes")}`, `-y, --yes`, "Skip confirmation prompts"],
5651
5931
  [`${opt("--skip-voice")}, ${opt("-sv")}`, `--skip-voice, -sv`, "Reuse existing voiceover"],
5652
5932
  [`${opt("-f")}, ${opt("--fresh")}`, `-f, --fresh`, "Clear cached voiceover and recording"],
@@ -5744,7 +6024,7 @@ function waitForEnter(prompt2 = `${INDENT}Press Enter when done...`) {
5744
6024
  )
5745
6025
  );
5746
6026
  }
5747
- return new Promise((resolve10, reject) => {
6027
+ return new Promise((resolve11, reject) => {
5748
6028
  const rl = (0, import_node_readline.createInterface)({ input: process.stdin, output: process.stdout });
5749
6029
  let done = false;
5750
6030
  rl.on("close", () => {
@@ -5755,7 +6035,7 @@ function waitForEnter(prompt2 = `${INDENT}Press Enter when done...`) {
5755
6035
  rl.question(prompt2, () => {
5756
6036
  done = true;
5757
6037
  rl.close();
5758
- resolve10();
6038
+ resolve11();
5759
6039
  });
5760
6040
  });
5761
6041
  }
@@ -5897,8 +6177,8 @@ function editorBaseUrl() {
5897
6177
  function openInBrowser(url) {
5898
6178
  const isWSL2 = process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME);
5899
6179
  const command = isWSL2 ? `cmd.exe /c start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
5900
- return new Promise((resolve10) => {
5901
- (0, import_node_child_process5.exec)(command, () => resolve10());
6180
+ return new Promise((resolve11) => {
6181
+ (0, import_node_child_process5.exec)(command, () => resolve11());
5902
6182
  });
5903
6183
  }
5904
6184
 
@@ -5906,13 +6186,13 @@ function openInBrowser(url) {
5906
6186
  init_SparkStripe_feature_gating();
5907
6187
  var TIMEOUT_MS = 12e4;
5908
6188
  async function browserLogin() {
5909
- return new Promise((resolve10) => {
6189
+ return new Promise((resolve11) => {
5910
6190
  let server;
5911
6191
  let timer;
5912
6192
  const cleanup = (email) => {
5913
6193
  clearTimeout(timer);
5914
6194
  server?.close();
5915
- resolve10(email);
6195
+ resolve11(email);
5916
6196
  };
5917
6197
  server = (0, import_node_http2.createServer)((req, res) => {
5918
6198
  const url = new import_node_url.URL(req.url || "/", `http://localhost`);
@@ -6063,10 +6343,10 @@ function getLastRateLimitInfo() {
6063
6343
  }
6064
6344
  function prompt(question) {
6065
6345
  const rl = (0, import_node_readline2.createInterface)({ input: process.stdin, output: process.stdout });
6066
- return new Promise((resolve10) => {
6346
+ return new Promise((resolve11) => {
6067
6347
  rl.question(question, (answer) => {
6068
6348
  rl.close();
6069
- resolve10(answer.trim());
6349
+ resolve11(answer.trim());
6070
6350
  });
6071
6351
  });
6072
6352
  }
@@ -6075,8 +6355,10 @@ async function setupUserEmail() {
6075
6355
  console.log(import_chalk5.default.yellow(" \u2709 Account Setup"));
6076
6356
  console.log(import_chalk5.default.dim(" \u2500".repeat(20)));
6077
6357
  console.log();
6078
- console.log(import_chalk5.default.white(" Sign in with your Google account to track usage"));
6079
- console.log(import_chalk5.default.white(" and unlock Pro features."));
6358
+ console.log(import_chalk5.default.white(" The AI that plans your demo runs on our servers, so it"));
6359
+ console.log(import_chalk5.default.white(" needs an address to meter. No card, and no verification"));
6360
+ console.log(import_chalk5.default.white(" needed for your first 5 videos."));
6361
+ console.log(import_chalk5.default.dim(" After that, signing in gets you 10 videos a day."));
6080
6362
  const browserEmail = await browserLogin();
6081
6363
  if (browserEmail) {
6082
6364
  console.log();
@@ -6085,11 +6367,15 @@ async function setupUserEmail() {
6085
6367
  return browserEmail;
6086
6368
  }
6087
6369
  console.log();
6088
- console.log(import_chalk5.default.dim(" Or enter your email manually:"));
6370
+ console.log(import_chalk5.default.dim(" Or enter your email (Enter to skip):"));
6089
6371
  const email = await prompt(import_chalk5.default.white(" Email: "));
6090
6372
  if (!email) {
6091
6373
  console.log();
6092
- console.log(import_chalk5.default.red(" No email provided. Exiting."));
6374
+ console.log(import_chalk5.default.yellow(" No email given, so the AI planner cannot be reached."));
6375
+ console.log(import_chalk5.default.dim(" Nothing has been recorded. Run qp again when you are ready."));
6376
+ console.log(import_chalk5.default.dim(" To record with no account at all, use the MCP server:"));
6377
+ console.log(import_chalk5.default.dim(" https://quickpeek.co/mcp"));
6378
+ console.log();
6093
6379
  process.exit(1);
6094
6380
  }
6095
6381
  if (!email.includes("@") || !email.includes(".")) {
@@ -6097,6 +6383,9 @@ async function setupUserEmail() {
6097
6383
  console.log(import_chalk5.default.yellow(" \u26A0 That doesn't look like a valid email address."));
6098
6384
  const proceed = await prompt(import_chalk5.default.white(" Continue anyway? ") + import_chalk5.default.dim("[y/N] "));
6099
6385
  if (proceed.toLowerCase() !== "y") {
6386
+ console.log();
6387
+ console.log(import_chalk5.default.dim(" Nothing has been recorded. Run qp again when you are ready."));
6388
+ console.log();
6100
6389
  process.exit(1);
6101
6390
  }
6102
6391
  }
@@ -6570,15 +6859,15 @@ async function launchCaptureChrome() {
6570
6859
  if (!nodePath) throw new Error("Windows Node.js not found. Install Node.js for Windows.");
6571
6860
  const windowsHost = getWindowsHostIP();
6572
6861
  const probe = await startRelay(nodePath, windowsHost, debugPort);
6573
- const alreadyUp = await new Promise((resolve10) => {
6862
+ const alreadyUp = await new Promise((resolve11) => {
6574
6863
  const req = (0, import_node_http3.request)({ host: windowsHost, port: probe.port, path: "/json/version", method: "GET" }, (res) => {
6575
6864
  res.resume();
6576
- resolve10(true);
6865
+ resolve11(true);
6577
6866
  });
6578
- req.on("error", () => resolve10(false));
6867
+ req.on("error", () => resolve11(false));
6579
6868
  req.setTimeout(2e3, () => {
6580
6869
  req.destroy();
6581
- resolve10(false);
6870
+ resolve11(false);
6582
6871
  });
6583
6872
  req.end();
6584
6873
  });
@@ -6784,7 +7073,7 @@ async function hasHeadroom() {
6784
7073
  async function withRenderSlot(render, onWait) {
6785
7074
  if (active >= MAX_CONCURRENT_RENDERS) {
6786
7075
  onWait?.(`waiting for a render slot (${active}/${MAX_CONCURRENT_RENDERS} busy)`);
6787
- await new Promise((resolve10) => waiting.push(resolve10));
7076
+ await new Promise((resolve11) => waiting.push(resolve11));
6788
7077
  }
6789
7078
  active++;
6790
7079
  try {
@@ -6813,19 +7102,19 @@ init_lib();
6813
7102
  init_helpers();
6814
7103
 
6815
7104
  // src/record.ts
6816
- var import_node_fs14 = require("fs");
7105
+ var import_node_fs15 = require("fs");
6817
7106
  var import_promises13 = require("fs/promises");
6818
7107
  var import_node_path17 = require("path");
6819
7108
  var import_chalk6 = __toESM(require("chalk"));
6820
7109
 
6821
7110
  // src/browser/actions.ts
6822
- var import_node_fs11 = require("fs");
7111
+ var import_node_fs12 = require("fs");
6823
7112
  var import_promises11 = require("fs/promises");
6824
7113
  var import_node_path13 = require("path");
6825
7114
  init_lib();
6826
7115
 
6827
7116
  // src/browser/effects.ts
6828
- var import_node_fs10 = require("fs");
7117
+ var import_node_fs11 = require("fs");
6829
7118
  var import_node_path12 = require("path");
6830
7119
  init_lib();
6831
7120
 
@@ -6846,7 +7135,7 @@ function loadHighlightCSS() {
6846
7135
  // src/browser/ → src/
6847
7136
  ];
6848
7137
  for (const p of candidates) {
6849
- if ((0, import_node_fs10.existsSync)(p)) return (0, import_node_fs10.readFileSync)(p, "utf-8");
7138
+ if ((0, import_node_fs11.existsSync)(p)) return (0, import_node_fs11.readFileSync)(p, "utf-8");
6850
7139
  }
6851
7140
  return "";
6852
7141
  }
@@ -7059,6 +7348,7 @@ function locatorFor(page, keyword) {
7059
7348
  var WAIT_MS = 450;
7060
7349
  var HANDLE_MS = 3e3;
7061
7350
  var RESOLVE_BUDGET_MS2 = 2e4;
7351
+ var DOM_POLL_MS = 150;
7062
7352
  function remaining(deadline) {
7063
7353
  return Math.max(1, deadline - Date.now());
7064
7354
  }
@@ -7169,7 +7459,7 @@ async function resolveElementSettled(page, target, deadlineMs = RESOLVE_DEADLINE
7169
7459
  }
7170
7460
  }
7171
7461
  async function resolvePlainText(page, text, deadline) {
7172
- const scanned = await domTextMatch(page, text);
7462
+ let scanned = await domTextMatch(page, text);
7173
7463
  const stages = plainTextStages(page, text);
7174
7464
  while (Date.now() < deadline) {
7175
7465
  for (const { locator, state } of stages) {
@@ -7180,6 +7470,12 @@ async function resolvePlainText(page, text, deadline) {
7180
7470
  }
7181
7471
  }
7182
7472
  if (scanned) return scanned;
7473
+ while (Date.now() < deadline) {
7474
+ await page.waitForTimeout(Math.min(DOM_POLL_MS, remaining(deadline)));
7475
+ scanned = await domTextMatch(page, text);
7476
+ if (scanned) break;
7477
+ }
7478
+ if (!scanned) return null;
7183
7479
  }
7184
7480
  return scanned ?? null;
7185
7481
  }
@@ -7605,7 +7901,7 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
7605
7901
  if (!isPlaceholder) {
7606
7902
  const normalizedPath = rawPath.startsWith("mnt/") ? `/${rawPath}` : rawPath;
7607
7903
  const filePath = (0, import_node_path13.isAbsolute)(normalizedPath) ? normalizedPath : (0, import_node_path13.resolve)(baseDir, normalizedPath);
7608
- if ((0, import_node_fs11.existsSync)(filePath)) {
7904
+ if ((0, import_node_fs12.existsSync)(filePath)) {
7609
7905
  await fileInput.setInputFiles(filePath);
7610
7906
  await page.waitForTimeout(2e3);
7611
7907
  break;
@@ -7722,7 +8018,7 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
7722
8018
  break;
7723
8019
  case "image": {
7724
8020
  const imagePath = step.value ? (0, import_node_path13.isAbsolute)(step.value) ? step.value : (0, import_node_path13.resolve)(baseDir, step.value) : null;
7725
- if (imagePath && (0, import_node_fs11.existsSync)(imagePath)) {
8021
+ if (imagePath && (0, import_node_fs12.existsSync)(imagePath)) {
7726
8022
  const imgStyle = step.target === "stretch" ? "width: 100vw; height: 100vh; object-fit: cover;" : "max-width: 90%; max-height: 90%;";
7727
8023
  if (await showImagePage(page, imagePath, imgStyle, config, cursorInjected, false)) injected = true;
7728
8024
  }
@@ -7751,7 +8047,7 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
7751
8047
  await pendingDownload;
7752
8048
  pendingDownload = null;
7753
8049
  }
7754
- if (lastDownloadPath && (0, import_node_fs11.existsSync)(lastDownloadPath)) {
8050
+ if (lastDownloadPath && (0, import_node_fs12.existsSync)(lastDownloadPath)) {
7755
8051
  const imgStyle = step.target === "stretch" ? "width: 100%; height: 100%; object-fit: contain;" : "max-width: 90%; max-height: 90%; object-fit: contain;";
7756
8052
  if (await showImagePage(page, lastDownloadPath, imgStyle, config, cursorInjected)) injected = true;
7757
8053
  }
@@ -7857,7 +8153,7 @@ async function getPageElementSelectors(page, query, limit, keyword = "") {
7857
8153
  }
7858
8154
 
7859
8155
  // src/browser/step-validator.ts
7860
- var import_node_fs12 = require("fs");
8156
+ var import_node_fs13 = require("fs");
7861
8157
  var import_node_path14 = require("path");
7862
8158
  init_crawl();
7863
8159
  init_utils();
@@ -7865,9 +8161,9 @@ function findMediaFile(filename, baseDir) {
7865
8161
  const name = (0, import_node_path14.basename)(filename);
7866
8162
  const searchDirs = [baseDir, (0, import_node_path14.join)(baseDir, "res"), (0, import_node_path14.join)(baseDir, "assets"), (0, import_node_path14.join)(baseDir, "media")];
7867
8163
  for (const dir of searchDirs) {
7868
- if (!(0, import_node_fs12.existsSync)(dir)) continue;
8164
+ if (!(0, import_node_fs13.existsSync)(dir)) continue;
7869
8165
  try {
7870
- const files = (0, import_node_fs12.readdirSync)(dir);
8166
+ const files = (0, import_node_fs13.readdirSync)(dir);
7871
8167
  if (files.includes(name)) return (0, import_node_path14.join)(dir, name);
7872
8168
  const match = files.find((f) => f.toLowerCase() === name.toLowerCase());
7873
8169
  if (match) return (0, import_node_path14.join)(dir, match);
@@ -7919,7 +8215,7 @@ function resolveMediaStep(step, baseDir, label, startTime) {
7919
8215
  return { success: false, error: `Missing ${label} path`, executionTimeMs: Date.now() - startTime };
7920
8216
  }
7921
8217
  const specifiedPath = (0, import_node_path14.isAbsolute)(step.value) ? step.value : (0, import_node_path14.resolve)(baseDir, step.value);
7922
- if ((0, import_node_fs12.existsSync)(specifiedPath)) return { success: true, executionTimeMs: Date.now() - startTime };
8218
+ if ((0, import_node_fs13.existsSync)(specifiedPath)) return { success: true, executionTimeMs: Date.now() - startTime };
7923
8219
  const foundPath = findMediaFile(step.value, baseDir);
7924
8220
  if (foundPath) {
7925
8221
  step.value = `./${foundPath.replace(`${baseDir}/`, "").replace(`${baseDir}\\`, "")}`;
@@ -8048,7 +8344,7 @@ async function validateAndExecuteStep(page, step, plan, baseDir) {
8048
8344
  if (!isPlaceholder) {
8049
8345
  const normalizedPath = rawPath.startsWith("mnt/") ? `/${rawPath}` : rawPath;
8050
8346
  let filePath = (0, import_node_path14.isAbsolute)(normalizedPath) ? normalizedPath : (0, import_node_path14.resolve)(baseDir, normalizedPath);
8051
- if (!(0, import_node_fs12.existsSync)(filePath)) {
8347
+ if (!(0, import_node_fs13.existsSync)(filePath)) {
8052
8348
  const foundPath = findMediaFile(rawPath, baseDir);
8053
8349
  if (foundPath) {
8054
8350
  const relativePath = `./${foundPath.replace(`${baseDir}/`, "").replace(`${baseDir}\\`, "")}`;
@@ -8190,7 +8486,7 @@ async function validateStepsOnPage(plan, config, logError2, baseDir, onStepStart
8190
8486
  }
8191
8487
 
8192
8488
  // src/browser/meta.ts
8193
- var import_node_fs13 = require("fs");
8489
+ var import_node_fs14 = require("fs");
8194
8490
  var import_promises12 = require("fs/promises");
8195
8491
  var import_node_path16 = require("path");
8196
8492
  var RECORDING_FILE = "recording.mp4";
@@ -8200,7 +8496,7 @@ function getRecordingPath(outputDir) {
8200
8496
  }
8201
8497
  async function loadRecordingMeta(outputDir) {
8202
8498
  const metaPath = (0, import_node_path16.join)(outputDir, RECORDING_META_FILE);
8203
- if (!(0, import_node_fs13.existsSync)(metaPath)) return null;
8499
+ if (!(0, import_node_fs14.existsSync)(metaPath)) return null;
8204
8500
  try {
8205
8501
  const content = await (0, import_promises12.readFile)(metaPath, "utf-8");
8206
8502
  return JSON.parse(content);
@@ -8318,11 +8614,33 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
8318
8614
  const authState = findAuthState(plan.url);
8319
8615
  const { context, browser, chromeSession } = await launchBrowserContext(config, userDataDir, recordVideo, authState);
8320
8616
  try {
8617
+ const isExtensionPage = plan.url.startsWith("chrome-extension://");
8618
+ if (!isExtensionPage) {
8619
+ const warm = await context.newPage();
8620
+ const warmVideo = warm.video();
8621
+ const warmUrls = [plan.url];
8622
+ for (const step of plan.steps) {
8623
+ if (step.action !== "navigate" || !step.target) continue;
8624
+ if (!warmUrls.includes(step.target)) warmUrls.push(step.target);
8625
+ }
8626
+ try {
8627
+ for (const url of warmUrls) {
8628
+ await gotoSettled(warm, url).catch(() => {
8629
+ });
8630
+ }
8631
+ } catch {
8632
+ } finally {
8633
+ await warm.close().catch(() => {
8634
+ });
8635
+ const stray = await warmVideo?.path().catch(() => void 0);
8636
+ if (stray) await (0, import_promises13.rm)(stray, { force: true }).catch(() => {
8637
+ });
8638
+ }
8639
+ }
8321
8640
  const page = context.pages()[0] || await context.newPage();
8322
8641
  setupDownloadTracking(page);
8323
8642
  context.on("page", (p) => setupDownloadTracking(p));
8324
8643
  const firstStepIsMedia = ["media", "image", "video"].includes(plan.steps[0]?.action ?? "");
8325
- const isExtensionPage = plan.url.startsWith("chrome-extension://");
8326
8644
  let signInChecked = false;
8327
8645
  const guardSignedIn = async () => {
8328
8646
  if (signInChecked || opts.allowLoggedOut || isExtensionPage) return;
@@ -8399,7 +8717,7 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
8399
8717
  if (!targetEl) {
8400
8718
  console.log(
8401
8719
  `
8402
- \u26A0 target "${step.target}" not found on ${page.url()} \u2014 step ${stepNum} recorded without the action`
8720
+ \u26A0 target "${step.target}" not found on ${page.url()}; step ${stepNum} recorded without the action`
8403
8721
  );
8404
8722
  }
8405
8723
  }
@@ -8538,7 +8856,7 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
8538
8856
  if (!pageClosedEarly) await page.close();
8539
8857
  const videoPath = await video.path();
8540
8858
  await cleanupBrowserSession({ context, browser, chromeSession });
8541
- if (!videoPath || !(0, import_node_fs14.existsSync)(videoPath)) {
8859
+ if (!videoPath || !(0, import_node_fs15.existsSync)(videoPath)) {
8542
8860
  throw new Error("Video file was not created");
8543
8861
  }
8544
8862
  saveTask.succeed("Video saved");
@@ -8549,14 +8867,14 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
8549
8867
  }
8550
8868
 
8551
8869
  // src/utils/video-pipeline.ts
8552
- var import_node_fs16 = require("fs");
8870
+ var import_node_fs17 = require("fs");
8553
8871
  var import_promises15 = require("fs/promises");
8554
8872
  var import_node_path21 = require("path");
8555
8873
  var import_chalk9 = __toESM(require("chalk"));
8556
8874
  init_lib();
8557
8875
 
8558
8876
  // src/utils/music.ts
8559
- var import_node_fs15 = require("fs");
8877
+ var import_node_fs16 = require("fs");
8560
8878
  var import_node_path20 = require("path");
8561
8879
 
8562
8880
  // src/commands/utils/record-helpers.ts
@@ -8573,6 +8891,17 @@ var import_playwright6 = require("playwright");
8573
8891
  init_mcp_context();
8574
8892
  init_lib();
8575
8893
  init_json_repair();
8894
+ var WRITTEN_ADDRESS = /\b(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?/gi;
8895
+ var SPELLED_OUT_ACRONYM = /\b[A-Z](?:\s+[A-Z])+\b/g;
8896
+ function spokenCaption(caption) {
8897
+ const held = [];
8898
+ const masked = caption.replace(SPELLED_OUT_ACRONYM, (run2) => run2.replace(/\s+/g, "")).replace(WRITTEN_ADDRESS, (address) => {
8899
+ held.push(address);
8900
+ return `qpaddr${held.length - 1}qp`;
8901
+ });
8902
+ const shaped = toTitleCase((0, import_video_kit8.toSpokenForm)(masked));
8903
+ return shaped.replace(/qpaddr(\d+)qp/gi, (_match, index) => held[Number(index)] ?? "");
8904
+ }
8576
8905
  async function crawlExtension(url, extensionPath, workDir, task2) {
8577
8906
  const browserTask = task2("Launching browser with extension");
8578
8907
  const userDataDir = (0, import_node_path18.join)(workDir, ".browser-data");
@@ -8701,8 +9030,18 @@ async function crawlAndAnalyze(url, lang, description, maxSteps, task2, logError
8701
9030
  // The spoken form is what the voice will actually say, so it is what
8702
9031
  // the caption shows: burning the written form instead left the words on
8703
9032
  // screen out of step with the words in the ear wherever the two differ
8704
- // (a domain read with its dot, a number read as words).
8705
- caption: s.caption ? toTitleCase((0, import_video_kit8.toSpokenForm)(s.caption)) : s.caption
9033
+ // (a number read as words).
9034
+ //
9035
+ // Addresses are the exception. Expanding one here turned "quickpeek.co"
9036
+ // into three caption words, "Quickpeek Dot Co", which the line packer
9037
+ // is then free to break across lines: the call to action arrived on
9038
+ // screen as "DOT C O" with the name on the row above. An address is the
9039
+ // one thing in a demo a viewer has to be able to read and type, so it
9040
+ // is held back verbatim from both passes and rejoins as written. The
9041
+ // voice is unaffected: generate.ts derives its own spoken form from
9042
+ // this caption at synthesis time, and word timings fall back to an even
9043
+ // spread across the speech envelope when the counts no longer line up.
9044
+ caption: s.caption ? spokenCaption(s.caption) : s.caption
8706
9045
  }));
8707
9046
  aiTask.succeed(`Plan: ${plan.steps.length} steps`);
8708
9047
  return plan;
@@ -8874,12 +9213,12 @@ function getOutputDirFromUrl(url) {
8874
9213
  }
8875
9214
 
8876
9215
  // src/utils/music.ts
8877
- var DEFAULT_MOOD = "calm";
9216
+ var DEFAULT_MOOD = "corporate";
8878
9217
  function musicDir() {
8879
9218
  let dir = getScriptDir();
8880
9219
  for (let i = 0; i < 6; i++) {
8881
9220
  const candidate = (0, import_node_path20.join)(dir, "assets", "music");
8882
- if ((0, import_node_fs15.existsSync)((0, import_node_path20.join)(candidate, "manifest.json"))) return candidate;
9221
+ if ((0, import_node_fs16.existsSync)((0, import_node_path20.join)(candidate, "manifest.json"))) return candidate;
8883
9222
  const parent = (0, import_node_path20.dirname)(dir);
8884
9223
  if (parent === dir) break;
8885
9224
  dir = parent;
@@ -8890,9 +9229,9 @@ function listBundledMusic() {
8890
9229
  const dir = musicDir();
8891
9230
  if (!dir) return [];
8892
9231
  try {
8893
- const parsed = JSON.parse((0, import_node_fs15.readFileSync)((0, import_node_path20.join)(dir, "manifest.json"), "utf8"));
9232
+ const parsed = JSON.parse((0, import_node_fs16.readFileSync)((0, import_node_path20.join)(dir, "manifest.json"), "utf8"));
8894
9233
  const tracks = Array.isArray(parsed.tracks) ? parsed.tracks : [];
8895
- return tracks.filter((t) => typeof t?.file === "string").map((t) => ({ ...t, path: (0, import_node_path20.join)(dir, t.file) })).filter((t) => (0, import_node_fs15.existsSync)(t.path));
9234
+ return tracks.filter((t) => typeof t?.file === "string").map((t) => ({ ...t, path: (0, import_node_path20.join)(dir, t.file) })).filter((t) => (0, import_node_fs16.existsSync)(t.path));
8896
9235
  } catch {
8897
9236
  return [];
8898
9237
  }
@@ -8905,6 +9244,16 @@ function resolveBundledBed(mood = DEFAULT_MOOD) {
8905
9244
 
8906
9245
  // src/utils/video-pipeline.ts
8907
9246
  var INDENT3 = " ";
9247
+ function captionWordTimings(caption, spoken, duration) {
9248
+ const captionWords = caption.split(/\s+/).filter(Boolean);
9249
+ if (spoken.length === 0 || captionWords.length === 0) {
9250
+ return estimateWordTimings(caption, duration);
9251
+ }
9252
+ if (spoken.length === captionWords.length) {
9253
+ return spoken.map((w, i) => ({ ...w, word: captionWords[i] ?? w.word }));
9254
+ }
9255
+ return estimateWordTimings(caption, duration);
9256
+ }
8908
9257
  async function generateASS(plan, stepAudio, workDir, config, captions) {
8909
9258
  const cues = [];
8910
9259
  let currentTime = VOICE_LEAD_MS;
@@ -8915,7 +9264,7 @@ async function generateASS(plan, stepAudio, workDir, config, captions) {
8915
9264
  currentTime += config.timing.minStepDuration;
8916
9265
  continue;
8917
9266
  }
8918
- const words = audio.words.length > 0 ? audio.words : estimateWordTimings(step.caption || "", audio.duration);
9267
+ const words = captionWordTimings(step.caption || "", audio.words, audio.duration);
8919
9268
  if (words.length > 0 && !step.spokenOnly) {
8920
9269
  const offset = currentTime;
8921
9270
  cues.push({
@@ -8935,7 +9284,7 @@ async function generateASS(plan, stepAudio, workDir, config, captions) {
8935
9284
  return assPath;
8936
9285
  }
8937
9286
  var STILL_EXTENSIONS = ["png", "jpg", "jpeg", "webp"];
8938
- function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
9287
+ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0, totalSecs, blackSpans) {
8939
9288
  const overlays = [];
8940
9289
  let cumulativeTime = leadInSecs;
8941
9290
  for (let i = 0; i < steps.length; i++) {
@@ -8949,12 +9298,19 @@ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
8949
9298
  const still = isCard && STILL_EXTENSIONS.includes(ext);
8950
9299
  if (still || isClip && ["mp4", "webm", "mov"].includes(ext)) {
8951
9300
  const mediaPath = (0, import_node_path21.isAbsolute)(step.value) ? step.value : (0, import_node_path21.resolve)(baseDir, step.value);
8952
- if ((0, import_node_fs16.existsSync)(mediaPath) && durationSec > 0) {
9301
+ if ((0, import_node_fs17.existsSync)(mediaPath) && durationSec > 0) {
8953
9302
  const partial = parsePartial(step.partial);
9303
+ const runsToEnd = still && i === steps.length - 1 && totalSecs !== void 0;
9304
+ let duration = runsToEnd ? Math.max(durationSec, totalSecs - cumulativeTime) : durationSec;
9305
+ if (still && !runsToEnd && blackSpans) {
9306
+ const endsAt = cumulativeTime + duration;
9307
+ const following = blackSpans.find((sp) => sp.start <= endsAt + 0.25 && sp.end > endsAt);
9308
+ if (following) duration = following.end - cumulativeTime;
9309
+ }
8954
9310
  overlays.push({
8955
9311
  path: mediaPath,
8956
9312
  startTime: cumulativeTime,
8957
- duration: durationSec,
9313
+ duration,
8958
9314
  mode: step.target === "stretch" ? "stretch" : step.target === "cover" ? "cover" : "center",
8959
9315
  ...still ? { still: true } : {},
8960
9316
  ...partial.trimStart !== void 0 ? { trimStart: partial.trimStart } : {},
@@ -8967,7 +9323,7 @@ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
8967
9323
  }
8968
9324
  return overlays;
8969
9325
  }
8970
- async function composeAndFinish(videoPath, audioPath, assPath, config, userTier = "free", mainContentDuration, trimStart = 0.5, stepTransitions, hasClipAudio = false, endsOnCreditsCard = false, _stepSync) {
9326
+ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier = "free", mainContentDuration, trimStart = 0.5, stepTransitions, hasClipAudio = false, endsOnCreditsCard = false, _stepSync, musicStartSecs = 0) {
8971
9327
  header("Compose");
8972
9328
  const configPath = (0, import_node_path21.join)(runRoot(), CONFIG_FILE);
8973
9329
  console.log(`${INDENT3}${import_chalk9.default.dim(`Config: ${formatClickablePath(configPath)}`)}`);
@@ -8983,9 +9339,9 @@ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier
8983
9339
  const suffix = config.lang ? `-${config.lang}` : "";
8984
9340
  const finalPath = (0, import_node_path21.join)(config.output.dir, `demo${suffix}.${config.video.format}`);
8985
9341
  const videoDuration = await getMediaDuration(videoPath) / 1e3;
8986
- const hasVoiceover = audioPath && (0, import_node_fs16.existsSync)(audioPath);
9342
+ const hasVoiceover = audioPath && (0, import_node_fs17.existsSync)(audioPath);
8987
9343
  const musicPath = config.music?.path ? (0, import_node_path21.isAbsolute)(config.music.path) ? config.music.path : (0, import_node_path21.resolve)(config.output.dir, config.music.path) : "";
8988
- let hasMusic = musicPath && (0, import_node_fs16.existsSync)(musicPath);
9344
+ let hasMusic = musicPath && (0, import_node_fs17.existsSync)(musicPath);
8989
9345
  let bedPath = "";
8990
9346
  let bedCredit = "";
8991
9347
  if (!hasMusic && config.audio?.ambience !== false) {
@@ -9037,7 +9393,14 @@ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier
9037
9393
  hasClipAudio,
9038
9394
  mainContentDuration,
9039
9395
  stepTransitions,
9040
- hwaccel: config.video.hwaccel
9396
+ hwaccel: config.video.hwaccel,
9397
+ // The tempo pass runs after this and speeds the whole file up, music
9398
+ // included. Told about it, compose lays the bed down pre-slowed so the
9399
+ // song comes out at its own speed.
9400
+ tempo: config.video.tempo,
9401
+ musicStartSecs,
9402
+ // So the bed can be landed with its last note on the last frame.
9403
+ musicDurationSecs: hasMusic ? await getMediaDuration(backingPath).then((ms) => ms / 1e3).catch(() => 0) : 0
9041
9404
  });
9042
9405
  composeTask.succeed("Video composed");
9043
9406
  return finalPath;
@@ -9110,7 +9473,7 @@ function resolveOutro(config, userTier, userEmail) {
9110
9473
  const fallback = (0, import_node_path23.join)(scriptDir, "..", "dist", "assets", "qp.wm");
9111
9474
  return (0, import_node_fs18.existsSync)(fallback) ? fallback : void 0;
9112
9475
  }
9113
- var EMPTY_MIN_SECS = 0.5;
9476
+ var EMPTY_MIN_SECS = 0.4;
9114
9477
  var OUTRO_TAIL_MS = 900;
9115
9478
  var FIT_SECOND_PASS_CUSHION_SECS = 1;
9116
9479
  var FIT_SECOND_PASS_MAX_SPEEDUP = 1.45;
@@ -9140,6 +9503,7 @@ async function renderVideoPhase(params) {
9140
9503
  const firstStep = plan.steps[0];
9141
9504
  const firstStepIsVideo = firstStep && (firstStep.action === "video" || firstStep.action === "media");
9142
9505
  let trimStart = 0;
9506
+ let recordedStepLengths = null;
9143
9507
  const doRecord = async () => {
9144
9508
  header("Record");
9145
9509
  const result = await recordSession(plan, config, stepDurations, workDir, config.output.dir, task, logError, {
@@ -9151,6 +9515,7 @@ async function renderVideoPhase(params) {
9151
9515
  ]);
9152
9516
  const detectedEmpty = mergeSpans([...result.leadSpans ?? [], ...emptyBlack, ...emptyWhite]);
9153
9517
  let stepLengths = result.actualStepDurations;
9518
+ let zeroAnchored = false;
9154
9519
  if (detectedEmpty.length > 0) {
9155
9520
  const bounds = [];
9156
9521
  let at = 0;
@@ -9175,6 +9540,7 @@ async function renderVideoPhase(params) {
9175
9540
  const sum = result.actualStepDurations.reduce((a, b2) => a + b2, 0);
9176
9541
  const factor = sum > 0 && realSecs > 0 ? realSecs * 1e3 / sum : 1;
9177
9542
  stepLengths = result.actualStepDurations.map((ms) => ms * factor);
9543
+ zeroAnchored = true;
9178
9544
  info(`Sped up ${emptySecs.toFixed(1)}s of loading screens at ${EMPTY_SPAN_SPEED}x`);
9179
9545
  } else {
9180
9546
  await discardPartial(retimed);
@@ -9191,16 +9557,19 @@ async function renderVideoPhase(params) {
9191
9557
  const meta = buildRecordingMeta(plan, config, stepDurations);
9192
9558
  meta.actualStepDurations = stepLengths;
9193
9559
  meta.pageLoadTime = result.pageLoadTime;
9560
+ meta.zeroAnchored = zeroAnchored;
9194
9561
  await saveRecordingMeta(config.output.dir, meta);
9195
9562
  videoPath = recordingPath;
9196
- trimStart = firstStepIsVideo ? 0 : result.pageLoadTime;
9563
+ recordedStepLengths = stepLengths;
9564
+ trimStart = firstStepIsVideo || zeroAnchored ? 0 : result.pageLoadTime;
9197
9565
  };
9198
9566
  if (hasExistingRecording) {
9199
9567
  const reuseCheck = canReuseRecording(plan, config, stepDurations, existingRecordingMeta);
9200
9568
  if (reuseCheck.canReuse) {
9201
9569
  info("Reusing existing recording");
9202
9570
  videoPath = recordingPath;
9203
- trimStart = firstStepIsVideo ? 0 : existingRecordingMeta?.pageLoadTime ?? 0.5;
9571
+ recordedStepLengths = existingRecordingMeta.actualStepDurations ?? existingRecordingMeta.stepDurations;
9572
+ trimStart = firstStepIsVideo || existingRecordingMeta.zeroAnchored ? 0 : existingRecordingMeta?.pageLoadTime ?? 0.5;
9204
9573
  stepAudio = await alignVoiceoverToRecording(
9205
9574
  stepAudio,
9206
9575
  existingRecordingMeta.actualStepDurations ?? existingRecordingMeta.stepDurations,
@@ -9214,9 +9583,23 @@ async function renderVideoPhase(params) {
9214
9583
  } else {
9215
9584
  await doRecord();
9216
9585
  }
9217
- const alignedStepDurations = plan.steps.map((_, i) => stepAudio.get(i + 1)?.duration ?? 0);
9586
+ const alignedStepDurations = plan.steps.map(
9587
+ (step, i) => Math.max(
9588
+ stepAudio.get(i + 1)?.duration ?? recordedStepLengths?.[i] ?? stepDurations[i] ?? 0,
9589
+ step.duration ? step.duration * 1e3 : 0
9590
+ )
9591
+ );
9218
9592
  let splicedClipAudio = false;
9219
- const videoOverlays = buildVideoOverlays(plan.steps, alignedStepDurations, config.output.dir, trimStart);
9593
+ const recordingSecs = await getMediaDuration(videoPath).then((ms) => ms / 1e3).catch(() => void 0);
9594
+ const blackSpans = await detectBlackSpans(videoPath, 0.3).catch(() => []);
9595
+ const videoOverlays = buildVideoOverlays(
9596
+ plan.steps,
9597
+ alignedStepDurations,
9598
+ config.output.dir,
9599
+ trimStart,
9600
+ recordingSecs,
9601
+ blackSpans
9602
+ );
9220
9603
  if (videoOverlays.length > 0) {
9221
9604
  const spliceTask = task("Painting cards and clips into recording");
9222
9605
  try {
@@ -9270,11 +9653,13 @@ async function renderVideoPhase(params) {
9270
9653
  const cardStepSeconds = (index) => {
9271
9654
  const action = plan.steps[index]?.action;
9272
9655
  if (action !== "image" && action !== "media") return 0;
9273
- return (stepAudio.get(index + 1)?.duration ?? 0) / 1e3;
9656
+ const audioSecs = (stepAudio.get(index + 1)?.duration ?? 0) / 1e3;
9657
+ const tail = index === plan.steps.length - 1 && action === "image" ? OUTRO_TAIL_MS / 1e3 : 0;
9658
+ return Math.max(audioSecs, plan.steps[index]?.duration ?? 0) + tail;
9274
9659
  };
9275
9660
  const introSecs = cardStepSeconds(0);
9276
9661
  const outroStepSecs = cardStepSeconds(plan.steps.length - 1);
9277
- const outroSeconds = config.video.outroCard?.seconds ?? (outroStepSecs || 3.5);
9662
+ const outroSeconds = Math.max(config.video.outroCard?.seconds ?? 3.5, outroStepSecs);
9278
9663
  const card = config.video.outroCard;
9279
9664
  if (card?.subject?.name) {
9280
9665
  const cardTask = task("Rendering credits card");
@@ -9318,6 +9703,20 @@ async function renderVideoPhase(params) {
9318
9703
  mainContentDuration = void 0;
9319
9704
  }
9320
9705
  }
9706
+ const outroOnly = config.music?.outroOnly !== false;
9707
+ let musicStartSecs = 0;
9708
+ if (outroOnly) {
9709
+ let trailingCards = 0;
9710
+ for (let i = plan.steps.length - 1; i >= 0; i--) {
9711
+ const action = plan.steps[i]?.action;
9712
+ if (action !== "image" && action !== "media") break;
9713
+ trailingCards++;
9714
+ }
9715
+ if (trailingCards > 0 && trailingCards < plan.steps.length) {
9716
+ const upTo = plan.steps.length - trailingCards;
9717
+ musicStartSecs = alignedStepDurations.slice(0, upTo).reduce((a, b) => a + b, 0) / 1e3;
9718
+ }
9719
+ }
9321
9720
  let finalPath = await composeAndFinish(
9322
9721
  videoToCompose,
9323
9722
  voiceoverPath,
@@ -9329,7 +9728,9 @@ async function renderVideoPhase(params) {
9329
9728
  stepTransitions,
9330
9729
  // The splice writes a clip-audio track only when a spliced clip had one.
9331
9730
  splicedClipAudio,
9332
- endsOnCreditsCard
9731
+ endsOnCreditsCard,
9732
+ void 0,
9733
+ musicStartSecs
9333
9734
  );
9334
9735
  const maxSilence = config.video.maxSilenceSecs ?? 0.6;
9335
9736
  if (maxSilence > 0) {
@@ -9685,8 +10086,8 @@ async function captureCommand(url, lang) {
9685
10086
  }
9686
10087
 
9687
10088
  // src/commands/edit-cmd.ts
9688
- var import_node_fs21 = require("fs");
9689
- var import_node_path27 = require("path");
10089
+ var import_node_fs22 = require("fs");
10090
+ var import_node_path28 = require("path");
9690
10091
  var import_chalk12 = __toESM(require("chalk"));
9691
10092
 
9692
10093
  // src/lib/plan-validation.ts
@@ -9844,17 +10245,81 @@ function planEditorUrl(baseUrl, plan) {
9844
10245
  return url.length <= MAX_HANDOFF_URL_CHARS ? url : null;
9845
10246
  }
9846
10247
 
10248
+ // src/lib/plan-thumbs.ts
10249
+ var import_node_fs21 = require("fs");
10250
+ var import_node_path27 = require("path");
10251
+ init_spawn();
10252
+ var MEDIA_ACTIONS = ["image", "media", "video"];
10253
+ var THUMB_WIDTH = 160;
10254
+ var MAX_THUMB_CHARS = 1e4;
10255
+ var MAX_TOTAL_THUMB_CHARS = 16e3;
10256
+ var THUMB_TIMEOUT_MS = 3e3;
10257
+ function thumbnail(path) {
10258
+ return new Promise((done) => {
10259
+ const proc = spawnFFmpeg("ffmpeg", [
10260
+ "-v",
10261
+ "error",
10262
+ "-i",
10263
+ path,
10264
+ "-frames:v",
10265
+ "1",
10266
+ "-vf",
10267
+ `scale=${THUMB_WIDTH}:-2`,
10268
+ "-q:v",
10269
+ "14",
10270
+ "-f",
10271
+ "mjpeg",
10272
+ "-"
10273
+ ]);
10274
+ const chunks = [];
10275
+ const timer = setTimeout(() => {
10276
+ proc.kill();
10277
+ done(null);
10278
+ }, THUMB_TIMEOUT_MS);
10279
+ const finish = (result) => {
10280
+ clearTimeout(timer);
10281
+ done(result);
10282
+ };
10283
+ proc.stdout.on("data", (chunk) => chunks.push(chunk));
10284
+ proc.on("error", () => finish(null));
10285
+ proc.on("close", (code) => {
10286
+ const bytes = Buffer.concat(chunks);
10287
+ if (code !== 0 || bytes.length === 0) return finish(null);
10288
+ const base64 = bytes.toString("base64");
10289
+ finish(base64.length > MAX_THUMB_CHARS ? null : `data:image/jpeg;base64,${base64}`);
10290
+ });
10291
+ });
10292
+ }
10293
+ async function withEditorThumbs(plan, baseDir) {
10294
+ const steps = [...plan.steps];
10295
+ let budget = MAX_TOTAL_THUMB_CHARS;
10296
+ for (const [index, step] of steps.entries()) {
10297
+ if (!step.value || !MEDIA_ACTIONS.includes(step.action)) continue;
10298
+ const path = (0, import_node_path27.isAbsolute)(step.value) ? step.value : (0, import_node_path27.resolve)(baseDir, step.value);
10299
+ if (!(0, import_node_fs21.existsSync)(path)) continue;
10300
+ const thumb = await thumbnail(path);
10301
+ if (!thumb || thumb.length > budget) continue;
10302
+ budget -= thumb.length;
10303
+ steps[index] = { ...step, thumb };
10304
+ }
10305
+ return { ...plan, steps };
10306
+ }
10307
+ async function editorUrlWithThumbs(baseUrl, plan, baseDir) {
10308
+ const withThumbs = await withEditorThumbs(plan, baseDir);
10309
+ return planEditorUrl(baseUrl, withThumbs) ?? planEditorUrl(baseUrl, plan);
10310
+ }
10311
+
9847
10312
  // src/commands/edit-cmd.ts
9848
10313
  var PLAN_FILE = /^plan-([a-z]{2}(?:-[A-Z]{2})?)\.json$/;
9849
10314
  function findPlanFile(dir, lang) {
9850
10315
  if (lang) {
9851
- const exact = (0, import_node_path27.join)(dir, `plan-${lang}.json`);
9852
- return (0, import_node_fs21.existsSync)(exact) ? exact : null;
10316
+ const exact = (0, import_node_path28.join)(dir, `plan-${lang}.json`);
10317
+ return (0, import_node_fs22.existsSync)(exact) ? exact : null;
9853
10318
  }
9854
- if (!(0, import_node_fs21.existsSync)(dir)) return null;
9855
- const plans = (0, import_node_fs21.readdirSync)(dir).filter((f) => PLAN_FILE.test(f));
10319
+ if (!(0, import_node_fs22.existsSync)(dir)) return null;
10320
+ const plans = (0, import_node_fs22.readdirSync)(dir).filter((f) => PLAN_FILE.test(f));
9856
10321
  const preferred = plans.find((f) => f === "plan-en.json") ?? plans[0];
9857
- return preferred ? (0, import_node_path27.join)(dir, preferred) : null;
10322
+ return preferred ? (0, import_node_path28.join)(dir, preferred) : null;
9858
10323
  }
9859
10324
  async function editCommand(url, lang) {
9860
10325
  console.log();
@@ -9867,14 +10332,14 @@ async function editCommand(url, lang) {
9867
10332
  console.log();
9868
10333
  return;
9869
10334
  }
9870
- const parsed = parsePlanText((0, import_node_fs21.readFileSync)(planPath, "utf8"));
10335
+ const parsed = parsePlanText((0, import_node_fs22.readFileSync)(planPath, "utf8"));
9871
10336
  if (!parsed.ok) {
9872
10337
  console.log(import_chalk12.default.red(` ${planPath} cannot be opened: ${parsed.error}`));
9873
10338
  console.log();
9874
10339
  return;
9875
10340
  }
9876
10341
  const base = editorBaseUrl();
9877
- const editorUrl = planEditorUrl(base, parsed.plan);
10342
+ const editorUrl = await editorUrlWithThumbs(base, parsed.plan, dir);
9878
10343
  if (!editorUrl) {
9879
10344
  console.log(import_chalk12.default.yellow(" This plan is too long to carry in a link."));
9880
10345
  console.log(import_chalk12.default.dim(` Open ${base}/edit and load `) + import_chalk12.default.cyan(formatClickablePath(planPath)));
@@ -9890,14 +10355,14 @@ async function editCommand(url, lang) {
9890
10355
  }
9891
10356
 
9892
10357
  // src/commands/init-cmd.ts
9893
- var import_node_fs22 = require("fs");
10358
+ var import_node_fs23 = require("fs");
9894
10359
  var import_promises18 = require("fs/promises");
9895
- var import_node_path28 = require("path");
10360
+ var import_node_path29 = require("path");
9896
10361
  var import_chalk13 = __toESM(require("chalk"));
9897
10362
  init_lib();
9898
10363
  async function initCommand() {
9899
- const configPath = (0, import_node_path28.join)(process.cwd(), CONFIG_FILE);
9900
- if ((0, import_node_fs22.existsSync)(configPath)) {
10364
+ const configPath = (0, import_node_path29.join)(process.cwd(), CONFIG_FILE);
10365
+ if ((0, import_node_fs23.existsSync)(configPath)) {
9901
10366
  console.log(import_chalk13.default.yellow(` \u26A0 ${CONFIG_FILE} already exists`));
9902
10367
  return;
9903
10368
  }
@@ -9928,9 +10393,9 @@ async function initCommand() {
9928
10393
  console.log(import_chalk13.default.dim(` Edit it or use: qp config <key> <value>`));
9929
10394
  }
9930
10395
  async function configCommand(key, value) {
9931
- const configPath = (0, import_node_path28.join)(process.cwd(), CONFIG_FILE);
10396
+ const configPath = (0, import_node_path29.join)(process.cwd(), CONFIG_FILE);
9932
10397
  if (!key) {
9933
- if (!(0, import_node_fs22.existsSync)(configPath)) {
10398
+ if (!(0, import_node_fs23.existsSync)(configPath)) {
9934
10399
  console.log(import_chalk13.default.yellow(` No ${CONFIG_FILE} found. Run: qp init`));
9935
10400
  return;
9936
10401
  }
@@ -9945,7 +10410,7 @@ async function configCommand(key, value) {
9945
10410
  return;
9946
10411
  }
9947
10412
  let config = {};
9948
- if ((0, import_node_fs22.existsSync)(configPath)) {
10413
+ if ((0, import_node_fs23.existsSync)(configPath)) {
9949
10414
  try {
9950
10415
  config = JSON.parse(await (0, import_promises18.readFile)(configPath, "utf-8"));
9951
10416
  } catch {
@@ -10034,11 +10499,36 @@ async function logoutCommand() {
10034
10499
  }
10035
10500
 
10036
10501
  // src/commands/record-cmd.ts
10037
- var import_node_fs28 = require("fs");
10502
+ var import_node_fs29 = require("fs");
10038
10503
  var import_promises23 = require("fs/promises");
10039
- var import_node_path34 = require("path");
10504
+ var import_node_path35 = require("path");
10040
10505
  var import_chalk21 = __toESM(require("chalk"));
10041
10506
  init_mcp_context();
10507
+
10508
+ // src/lib/demo-registry.ts
10509
+ init_api_client();
10510
+ var TIMEOUT_MS2 = 4e3;
10511
+ async function registerDemoRun(email, run2) {
10512
+ if (!email || email === "anonymous") return false;
10513
+ const controller = new AbortController();
10514
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS2);
10515
+ try {
10516
+ const token = await getOrRefreshRelayToken(email);
10517
+ const response = await fetch(`${RELAY_BASE}/api/cli/demos`, {
10518
+ method: "POST",
10519
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
10520
+ body: JSON.stringify(run2),
10521
+ signal: controller.signal
10522
+ });
10523
+ return response.ok;
10524
+ } catch {
10525
+ return false;
10526
+ } finally {
10527
+ clearTimeout(timer);
10528
+ }
10529
+ }
10530
+
10531
+ // src/commands/record-cmd.ts
10042
10532
  init_lib();
10043
10533
 
10044
10534
  // src/commands/phases/auth-phase.ts
@@ -10055,7 +10545,7 @@ async function handleRegistrationGate() {
10055
10545
  console.log(import_chalk15.default.white(" Sign in to continue creating videos."));
10056
10546
  console.log();
10057
10547
  console.log(import_chalk15.default.dim(" Benefits of signing in:"));
10058
- console.log(import_chalk15.default.dim(" \u2022 20 more videos per month"));
10548
+ console.log(import_chalk15.default.dim(" \u2022 10 videos a day instead of 5 in total"));
10059
10549
  console.log(import_chalk15.default.dim(" \u2022 Keep your video history"));
10060
10550
  console.log(import_chalk15.default.dim(" \u2022 Upgrade to Pro anytime"));
10061
10551
  let email = await browserLogin();
@@ -10106,9 +10596,12 @@ async function runAuthPhase(input) {
10106
10596
  const { isVerified: isVerified2 } = input;
10107
10597
  const mcpTool = currentMcpTool();
10108
10598
  if (mcpTool && !(userEmail && isVerified2)) {
10109
- throw new Error(
10110
- `"${mcpTool}" needs a verified QuickPeek account and this machine has none saved${userEmail ? ` (${userEmail} is registered but unverified)` : ""}. Sign in once from a terminal with \`qp login\` \u2014 it is a browser flow and cannot run inside an MCP call \u2014 then retry. Use check_account to confirm.`
10111
- );
10599
+ if (localVideoCount >= FREE_VIDEO_LIMIT) {
10600
+ throw new Error(
10601
+ `"${mcpTool}" has used all ${FREE_VIDEO_LIMIT} free videos on this machine and needs a verified QuickPeek account to continue${userEmail ? ` (${userEmail} is registered but unverified)` : ""}. Sign in once from a terminal with \`qp login\`, a browser flow that cannot run inside an MCP call, then retry. Use check_account to confirm.`
10602
+ );
10603
+ }
10604
+ return { userEmail: "anonymous", userTier: "free", localVideoCount, isVerified: false };
10112
10605
  }
10113
10606
  if (userEmail && isVerified2) {
10114
10607
  const tierTask = task("Checking subscription");
@@ -10199,7 +10692,7 @@ async function runAuthPhase(input) {
10199
10692
  const remaining2 = FREE_VIDEO_LIMIT - localVideoCount;
10200
10693
  console.log();
10201
10694
  console.log(import_chalk16.default.cyan(` \u{1F3AC} Video ${localVideoCount + 1}/${FREE_VIDEO_LIMIT} free`));
10202
- console.log(import_chalk16.default.dim(` ${remaining2 - 1} more after this \u2022 Sign up for unlimited`));
10695
+ console.log(import_chalk16.default.dim(` ${remaining2 - 1} more after this \u2022 Sign up for 10 a day`));
10203
10696
  userEmail = await setupUserEmail();
10204
10697
  let registeredVerified = false;
10205
10698
  try {
@@ -10218,9 +10711,9 @@ async function runAuthPhase(input) {
10218
10711
  }
10219
10712
 
10220
10713
  // src/commands/phases/plan-phase.ts
10221
- var import_node_fs25 = require("fs");
10714
+ var import_node_fs26 = require("fs");
10222
10715
  var import_promises20 = require("fs/promises");
10223
- var import_node_path31 = require("path");
10716
+ var import_node_path32 = require("path");
10224
10717
  var import_chalk18 = __toESM(require("chalk"));
10225
10718
 
10226
10719
  // src/ai/fix.ts
@@ -10376,15 +10869,15 @@ Return ONLY JSON: {"description": "...", "captions": ["...", ...]}`;
10376
10869
  }
10377
10870
 
10378
10871
  // src/commands/phases/plan-cards.ts
10379
- var import_node_fs24 = require("fs");
10380
- var import_node_path30 = require("path");
10872
+ var import_node_fs25 = require("fs");
10873
+ var import_node_path31 = require("path");
10381
10874
  var import_chalk17 = __toESM(require("chalk"));
10382
10875
  init_render_outro();
10383
10876
 
10384
10877
  // src/lib/og-card.ts
10385
- var import_node_fs23 = require("fs");
10878
+ var import_node_fs24 = require("fs");
10386
10879
  var import_promises19 = require("fs/promises");
10387
- var import_node_path29 = require("path");
10880
+ var import_node_path30 = require("path");
10388
10881
  init_image_kind();
10389
10882
  var PORTRAIT_PATHS = ["/opengraph-image/portrait", "/opengraph-image-portrait", "/og/portrait"];
10390
10883
  async function fetchImage(url) {
@@ -10464,7 +10957,7 @@ async function fetchOgCard(siteUrl, dir, preferPortrait) {
10464
10957
  for (const candidate of candidates) {
10465
10958
  const buf = await fetchImage(candidate);
10466
10959
  if (buf) {
10467
- const path = (0, import_node_path29.join)(dir, "og-card.png");
10960
+ const path = (0, import_node_path30.join)(dir, "og-card.png");
10468
10961
  await (0, import_promises19.writeFile)(path, buf);
10469
10962
  return path;
10470
10963
  }
@@ -10537,7 +11030,7 @@ async function saveFirstUsableIcon(hrefs, siteUrl, dir) {
10537
11030
  if (!buf) continue;
10538
11031
  const kind = sniffImageKind(buf);
10539
11032
  if (!kind) continue;
10540
- const path = (0, import_node_path29.join)(dir, `app-icon.${kind === "jpeg" ? "jpg" : kind}`);
11033
+ const path = (0, import_node_path30.join)(dir, `app-icon.${kind === "jpeg" ? "jpg" : kind}`);
10541
11034
  await (0, import_promises19.writeFile)(path, buf);
10542
11035
  if (kind === "png") {
10543
11036
  const size = pngSize(path);
@@ -10551,10 +11044,10 @@ async function saveFirstUsableIcon(hrefs, siteUrl, dir) {
10551
11044
  }
10552
11045
  function existingIcon(dir) {
10553
11046
  for (const ext of ["png", "svg", "jpg", "webp", "gif"]) {
10554
- const path = (0, import_node_path29.join)(dir, `app-icon.${ext}`);
11047
+ const path = (0, import_node_path30.join)(dir, `app-icon.${ext}`);
10555
11048
  try {
10556
- if (!(0, import_node_fs23.existsSync)(path)) continue;
10557
- if (!sniffImageKind((0, import_node_fs23.readFileSync)(path))) continue;
11049
+ if (!(0, import_node_fs24.existsSync)(path)) continue;
11050
+ if (!sniffImageKind((0, import_node_fs24.readFileSync)(path))) continue;
10558
11051
  return path;
10559
11052
  } catch {
10560
11053
  }
@@ -10563,7 +11056,7 @@ function existingIcon(dir) {
10563
11056
  }
10564
11057
  function pngSize(path) {
10565
11058
  try {
10566
- const buf = (0, import_node_fs23.readFileSync)(path);
11059
+ const buf = (0, import_node_fs24.readFileSync)(path);
10567
11060
  const head = buf.subarray(0, 33);
10568
11061
  if (head.length >= 24 && head.readUInt32BE(12) === 1229472850) {
10569
11062
  return { width: head.readUInt32BE(16), height: head.readUInt32BE(20) };
@@ -10642,8 +11135,8 @@ async function useOgCardAsIntro(plan, url, config, fresh = false) {
10642
11135
  }
10643
11136
  const rewritable = first.action === "wait" && !first.target;
10644
11137
  const portrait = (config.video.height ?? 0) > (config.video.width ?? 0);
10645
- const existing = (0, import_node_path30.join)(config.output.dir, "og-card.png");
10646
- const fetched = !fresh && (0, import_node_fs24.existsSync)(existing) ? existing : await fetchOgCard(url, config.output.dir, portrait);
11138
+ const existing = (0, import_node_path31.join)(config.output.dir, "og-card.png");
11139
+ const fetched = !fresh && (0, import_node_fs25.existsSync)(existing) ? existing : await fetchOgCard(url, config.output.dir, portrait);
10647
11140
  const ogPath = fetched && coversFrame(fetched, config) ? fetched : null;
10648
11141
  const cardPath = ogPath ?? await renderGeneratedIntroCard(url, config);
10649
11142
  if (!cardPath) {
@@ -10733,30 +11226,20 @@ function fitFor(cardPath, config) {
10733
11226
  return Math.abs(size.width / size.height - frame) < 0.06 ? "stretch" : "cover";
10734
11227
  }
10735
11228
  function resolveCardPath(value, config) {
10736
- return (0, import_node_path30.isAbsolute)(value) ? value : (0, import_node_path30.join)(config.output.dir, value.replace(/^\.\//, ""));
11229
+ return (0, import_node_path31.isAbsolute)(value) ? value : (0, import_node_path31.join)(config.output.dir, value.replace(/^\.\//, ""));
10737
11230
  }
10738
11231
  var DEFAULT_INTRO_CARD_SECONDS = 2.5;
10739
11232
  function packageAsset(relative) {
10740
11233
  let dir = getScriptDir();
10741
11234
  for (let up = 0; up < 6; up++) {
10742
- const candidate = (0, import_node_path30.join)(dir, relative);
10743
- if ((0, import_node_fs24.existsSync)(candidate)) return candidate;
10744
- dir = (0, import_node_path30.join)(dir, "..");
10745
- }
10746
- return void 0;
10747
- }
10748
- function siblingAsset(relative) {
10749
- let dir = (0, import_node_path30.join)(getScriptDir(), "..");
10750
- for (let up = 0; up < 7; up++) {
10751
- const candidate = (0, import_node_path30.join)(dir, relative);
10752
- if ((0, import_node_fs24.existsSync)(candidate)) return candidate;
10753
- dir = (0, import_node_path30.join)(dir, "..");
11235
+ const candidate = (0, import_node_path31.join)(dir, relative);
11236
+ if ((0, import_node_fs25.existsSync)(candidate)) return candidate;
11237
+ dir = (0, import_node_path31.join)(dir, "..");
10754
11238
  }
10755
11239
  return void 0;
10756
11240
  }
10757
11241
  var CREDIT_TOOLS = [
10758
- { name: "QuickPeek", role: "Video generated by", icon: () => packageAsset("imgs/qp.png") },
10759
- { name: "Vidlet", role: "Video edited by", icon: () => siblingAsset("VidLet/res/icons/vidlet-512.png") }
11242
+ { name: "QuickPeek", role: "Video generated by", icon: () => packageAsset("imgs/qp.png") }
10760
11243
  ];
10761
11244
  function oneTagline(text) {
10762
11245
  if (!text) return void 0;
@@ -10848,7 +11331,7 @@ async function runPlanPhase(params) {
10848
11331
  const extensionPath = config.browser?.extension;
10849
11332
  let existingPlan = null;
10850
11333
  let existingPlanPath = null;
10851
- if ((0, import_node_fs25.existsSync)(planPath)) {
11334
+ if ((0, import_node_fs26.existsSync)(planPath)) {
10852
11335
  try {
10853
11336
  const parsed = JSON.parse(await (0, import_promises20.readFile)(planPath, "utf-8"));
10854
11337
  if (parsed.url && parsed.steps?.length > 0) {
@@ -10860,10 +11343,10 @@ async function runPlanPhase(params) {
10860
11343
  }
10861
11344
  if (!existingPlan) {
10862
11345
  try {
10863
- const files = (0, import_node_fs25.readdirSync)(config.output.dir);
10864
- const planFiles = files.filter((f) => /^plan-[a-z]{2}(-[A-Z]{2})?\.json$/.test(f) && f !== (0, import_node_path31.basename)(planPath));
11346
+ const files = (0, import_node_fs26.readdirSync)(config.output.dir);
11347
+ const planFiles = files.filter((f) => /^plan-[a-z]{2}(-[A-Z]{2})?\.json$/.test(f) && f !== (0, import_node_path32.basename)(planPath));
10865
11348
  for (const file of planFiles) {
10866
- const filePath = (0, import_node_path31.join)(config.output.dir, file);
11349
+ const filePath = (0, import_node_path32.join)(config.output.dir, file);
10867
11350
  try {
10868
11351
  const parsed = JSON.parse(await (0, import_promises20.readFile)(filePath, "utf-8"));
10869
11352
  if (parsed.url && parsed.steps?.length > 0) {
@@ -10878,7 +11361,22 @@ async function runPlanPhase(params) {
10878
11361
  }
10879
11362
  }
10880
11363
  const normalizeUrl4 = (u) => u.replace(/\/+$/, "").toLowerCase();
10881
- const urlMatches = existingPlan && normalizeUrl4(existingPlan.url) === normalizeUrl4(url);
11364
+ const isBareOrigin = (u) => {
11365
+ try {
11366
+ const parsed = new URL(u);
11367
+ return parsed.pathname === "/" && !parsed.search && !parsed.hash;
11368
+ } catch {
11369
+ return false;
11370
+ }
11371
+ };
11372
+ const sameOrigin = (a, b) => {
11373
+ try {
11374
+ return new URL(a).origin === new URL(b).origin;
11375
+ } catch {
11376
+ return false;
11377
+ }
11378
+ };
11379
+ const urlMatches = existingPlan && (normalizeUrl4(existingPlan.url) === normalizeUrl4(url) || isBareOrigin(existingPlan.url) && sameOrigin(existingPlan.url, url));
10882
11380
  if (existingPlan && !urlMatches && isAutoYes()) {
10883
11381
  console.log();
10884
11382
  console.log(import_chalk18.default.dim(` Existing plan is for ${existingPlan.url}, not ${url}, regenerating.`));
@@ -11022,7 +11520,7 @@ async function runPlanPhase(params) {
11022
11520
  const planLang = plan.lang || "en";
11023
11521
  if (planLang !== targetLang) {
11024
11522
  let useExistingTranslation = false;
11025
- if ((0, import_node_fs25.existsSync)(planPath) && existingPlanPath !== planPath) {
11523
+ if ((0, import_node_fs26.existsSync)(planPath) && existingPlanPath !== planPath) {
11026
11524
  try {
11027
11525
  const translatedPlan = JSON.parse(await (0, import_promises20.readFile)(planPath, "utf-8"));
11028
11526
  if (translatedPlan.steps?.length === plan.steps.length && translatedPlan.lang === targetLang) {
@@ -11242,31 +11740,31 @@ async function ensureCliSignIn(url, opts) {
11242
11740
  }
11243
11741
 
11244
11742
  // src/commands/utils/chrome-extension.ts
11245
- var import_node_fs26 = require("fs");
11743
+ var import_node_fs27 = require("fs");
11246
11744
  var import_node_os7 = require("os");
11247
- var import_node_path32 = require("path");
11745
+ var import_node_path33 = require("path");
11248
11746
  function resolveChromeExtension(extensionId) {
11249
11747
  const platform = process.platform;
11250
11748
  const possibleDirs = [];
11251
11749
  const getChromeExtDirs = (userDataPath) => {
11252
11750
  const dirs = [];
11253
11751
  try {
11254
- const profiles = (0, import_node_fs26.readdirSync)(userDataPath).filter((p) => p === "Default" || p.startsWith("Profile "));
11752
+ const profiles = (0, import_node_fs27.readdirSync)(userDataPath).filter((p) => p === "Default" || p.startsWith("Profile "));
11255
11753
  for (const profile of profiles) {
11256
- dirs.push((0, import_node_path32.join)(userDataPath, profile, "Extensions"));
11754
+ dirs.push((0, import_node_path33.join)(userDataPath, profile, "Extensions"));
11257
11755
  }
11258
11756
  } catch {
11259
- dirs.push((0, import_node_path32.join)(userDataPath, "Default", "Extensions"));
11757
+ dirs.push((0, import_node_path33.join)(userDataPath, "Default", "Extensions"));
11260
11758
  }
11261
11759
  return dirs;
11262
11760
  };
11263
11761
  if (platform === "win32") {
11264
- possibleDirs.push(...getChromeExtDirs((0, import_node_path32.join)((0, import_node_os7.homedir)(), "AppData", "Local", "Google", "Chrome", "User Data")));
11762
+ possibleDirs.push(...getChromeExtDirs((0, import_node_path33.join)((0, import_node_os7.homedir)(), "AppData", "Local", "Google", "Chrome", "User Data")));
11265
11763
  } else if (platform === "darwin") {
11266
- possibleDirs.push(...getChromeExtDirs((0, import_node_path32.join)((0, import_node_os7.homedir)(), "Library", "Application Support", "Google", "Chrome")));
11764
+ possibleDirs.push(...getChromeExtDirs((0, import_node_path33.join)((0, import_node_os7.homedir)(), "Library", "Application Support", "Google", "Chrome")));
11267
11765
  } else {
11268
11766
  try {
11269
- const windowsUsers = (0, import_node_fs26.readdirSync)("/mnt/c/Users").filter(
11767
+ const windowsUsers = (0, import_node_fs27.readdirSync)("/mnt/c/Users").filter(
11270
11768
  (u) => !["All Users", "Default", "Default User", "Public", "desktop.ini"].includes(u)
11271
11769
  );
11272
11770
  for (const user of windowsUsers) {
@@ -11274,22 +11772,22 @@ function resolveChromeExtension(extensionId) {
11274
11772
  }
11275
11773
  } catch {
11276
11774
  }
11277
- possibleDirs.push(...getChromeExtDirs((0, import_node_path32.join)((0, import_node_os7.homedir)(), ".config", "google-chrome")));
11775
+ possibleDirs.push(...getChromeExtDirs((0, import_node_path33.join)((0, import_node_os7.homedir)(), ".config", "google-chrome")));
11278
11776
  }
11279
11777
  for (const chromeExtDir of possibleDirs) {
11280
- const extDir = (0, import_node_path32.join)(chromeExtDir, extensionId);
11281
- if (!(0, import_node_fs26.existsSync)(extDir)) continue;
11778
+ const extDir = (0, import_node_path33.join)(chromeExtDir, extensionId);
11779
+ if (!(0, import_node_fs27.existsSync)(extDir)) continue;
11282
11780
  try {
11283
- const versions = (0, import_node_fs26.readdirSync)(extDir).filter((f) => !f.startsWith("."));
11781
+ const versions = (0, import_node_fs27.readdirSync)(extDir).filter((f) => !f.startsWith("."));
11284
11782
  if (versions.length === 0) continue;
11285
11783
  const latestVersion = versions.sort().pop();
11286
- const extPath = (0, import_node_path32.join)(extDir, latestVersion);
11784
+ const extPath = (0, import_node_path33.join)(extDir, latestVersion);
11287
11785
  let name = extensionId;
11288
11786
  let popupPath = "popup.html";
11289
- const manifestPath = (0, import_node_path32.join)(extPath, "manifest.json");
11290
- if ((0, import_node_fs26.existsSync)(manifestPath)) {
11787
+ const manifestPath = (0, import_node_path33.join)(extPath, "manifest.json");
11788
+ if ((0, import_node_fs27.existsSync)(manifestPath)) {
11291
11789
  try {
11292
- const manifest = JSON.parse((0, import_node_fs26.readFileSync)(manifestPath, "utf-8"));
11790
+ const manifest = JSON.parse((0, import_node_fs27.readFileSync)(manifestPath, "utf-8"));
11293
11791
  if (manifest.name && !manifest.name.startsWith("__MSG_")) {
11294
11792
  name = manifest.name;
11295
11793
  }
@@ -11310,9 +11808,9 @@ function resolveChromeExtension(extensionId) {
11310
11808
 
11311
11809
  // src/commands/utils/plan-review.ts
11312
11810
  var import_node_crypto2 = require("crypto");
11313
- var import_node_fs27 = require("fs");
11811
+ var import_node_fs28 = require("fs");
11314
11812
  var import_promises22 = require("fs/promises");
11315
- var import_node_path33 = require("path");
11813
+ var import_node_path34 = require("path");
11316
11814
  var REVIEW_FILE = "plan-review.json";
11317
11815
  async function hashPlanFile(planPath) {
11318
11816
  try {
@@ -11322,8 +11820,8 @@ async function hashPlanFile(planPath) {
11322
11820
  }
11323
11821
  }
11324
11822
  async function loadPlanReview(outputDir) {
11325
- const path = (0, import_node_path33.join)(outputDir, REVIEW_FILE);
11326
- if (!(0, import_node_fs27.existsSync)(path)) return {};
11823
+ const path = (0, import_node_path34.join)(outputDir, REVIEW_FILE);
11824
+ if (!(0, import_node_fs28.existsSync)(path)) return {};
11327
11825
  try {
11328
11826
  return JSON.parse(await (0, import_promises22.readFile)(path, "utf-8"));
11329
11827
  } catch {
@@ -11331,7 +11829,7 @@ async function loadPlanReview(outputDir) {
11331
11829
  }
11332
11830
  }
11333
11831
  async function savePlanReview(outputDir, review) {
11334
- await (0, import_promises22.writeFile)((0, import_node_path33.join)(outputDir, REVIEW_FILE), JSON.stringify(review, null, 2));
11832
+ await (0, import_promises22.writeFile)((0, import_node_path34.join)(outputDir, REVIEW_FILE), JSON.stringify(review, null, 2));
11335
11833
  }
11336
11834
 
11337
11835
  // src/commands/record-cmd.ts
@@ -11393,7 +11891,7 @@ async function recordCommand(url, options = {}) {
11393
11891
  url = startUrl;
11394
11892
  }
11395
11893
  }
11396
- config.output.dir = chromeExtension ? (0, import_node_path34.join)(runRoot(), `${chromeExtension.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-demo`) : getOutputDirFromUrl(url);
11894
+ config.output.dir = chromeExtension ? (0, import_node_path35.join)(runRoot(), `${chromeExtension.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-demo`) : getOutputDirFromUrl(url);
11397
11895
  const userData = await loadUserData();
11398
11896
  const authResult = await runAuthPhase({
11399
11897
  userEmail: userData.email,
@@ -11404,7 +11902,7 @@ async function recordCommand(url, options = {}) {
11404
11902
  if (!authResult) return;
11405
11903
  const { userEmail, userTier, localVideoCount } = authResult;
11406
11904
  if (configCreated) {
11407
- const configPath = (0, import_node_path34.join)(runRoot(), CONFIG_FILE);
11905
+ const configPath = (0, import_node_path35.join)(runRoot(), CONFIG_FILE);
11408
11906
  console.log();
11409
11907
  console.log(import_chalk21.default.green(` \u2714 Created ${CONFIG_FILE}`));
11410
11908
  console.log(import_chalk21.default.dim(` ${configPath}`));
@@ -11425,17 +11923,17 @@ async function recordCommand(url, options = {}) {
11425
11923
  }
11426
11924
  if (langOverride) config.lang = langOverride;
11427
11925
  if (config.music?.path) {
11428
- const musicPath = (0, import_node_path34.isAbsolute)(config.music.path) ? config.music.path : (0, import_node_path34.resolve)(config.output.dir, config.music.path);
11429
- if (!(0, import_node_fs28.existsSync)(musicPath)) {
11926
+ const musicPath = (0, import_node_path35.isAbsolute)(config.music.path) ? config.music.path : (0, import_node_path35.resolve)(config.output.dir, config.music.path);
11927
+ if (!(0, import_node_fs29.existsSync)(musicPath)) {
11430
11928
  console.log();
11431
11929
  console.log(import_chalk21.default.yellow(` \u26A0 Music file not found: ${config.music.path}`));
11432
- console.log(import_chalk21.default.dim(` Config: ${(0, import_node_path34.join)(runRoot(), CONFIG_FILE)}`));
11930
+ console.log(import_chalk21.default.dim(` Config: ${(0, import_node_path35.join)(runRoot(), CONFIG_FILE)}`));
11433
11931
  console.log();
11434
11932
  const proceed = await confirm(`${INDENT2}Continue without background music?`);
11435
11933
  if (!proceed) {
11436
11934
  console.log(import_chalk21.default.yellow(`
11437
11935
  Edit music.path in config and run again.`));
11438
- console.log(import_chalk21.default.dim(` ${(0, import_node_path34.join)(runRoot(), CONFIG_FILE)}
11936
+ console.log(import_chalk21.default.dim(` ${(0, import_node_path35.join)(runRoot(), CONFIG_FILE)}
11439
11937
  `));
11440
11938
  process.exit(0);
11441
11939
  }
@@ -11443,9 +11941,9 @@ async function recordCommand(url, options = {}) {
11443
11941
  }
11444
11942
  }
11445
11943
  const captions = resolveCaptions(await loadCaptions(), config.video);
11446
- const workDir = await prepareWorkDir((0, import_node_path34.basename)(config.output.dir));
11944
+ const workDir = await prepareWorkDir((0, import_node_path35.basename)(config.output.dir));
11447
11945
  const targetLang = config.lang || "en";
11448
- const planPath = (0, import_node_path34.join)(config.output.dir, `plan-${targetLang}.json`);
11946
+ const planPath = (0, import_node_path35.join)(config.output.dir, `plan-${targetLang}.json`);
11449
11947
  const isExtensionUrl = url.startsWith("chrome-extension://");
11450
11948
  await (0, import_promises23.mkdir)(config.output.dir, { recursive: true });
11451
11949
  await setLogFilePath(config.output.dir);
@@ -11536,6 +12034,13 @@ async function recordCommand(url, options = {}) {
11536
12034
  userTier,
11537
12035
  userEmail
11538
12036
  });
12037
+ await registerDemoRun(userEmail, {
12038
+ source: currentMcpTool2() ? "mcp" : "cli",
12039
+ targetUrl: validatedPlan.url || url,
12040
+ localPath: finalPath,
12041
+ title: validatedPlan.title,
12042
+ ...config.lang ? { language: config.lang } : {}
12043
+ });
11539
12044
  if (!userEmail || userEmail === "anonymous") {
11540
12045
  const newCount = localVideoCount + 1;
11541
12046
  await saveUserData({ localVideoCount: newCount });
@@ -11562,7 +12067,7 @@ async function recordCommand(url, options = {}) {
11562
12067
 
11563
12068
  // src/commands/script-cmd.ts
11564
12069
  var import_promises24 = require("fs/promises");
11565
- var import_node_path35 = require("path");
12070
+ var import_node_path36 = require("path");
11566
12071
  var import_chalk22 = __toESM(require("chalk"));
11567
12072
  init_lib();
11568
12073
  async function reusablePlan(planPath, lines) {
@@ -11694,7 +12199,7 @@ async function scriptCommand(url, scriptPath, options = {}) {
11694
12199
  );
11695
12200
  console.log(import_chalk22.default.dim(` Raise plan.maxSteps in quickpeek.config.json to silence this, or split the script.`));
11696
12201
  }
11697
- const planPath = (0, import_node_path35.join)(config.output.dir, `plan-${lang ?? "en"}.json`);
12202
+ const planPath = (0, import_node_path36.join)(config.output.dir, `plan-${lang ?? "en"}.json`);
11698
12203
  const appTitle = new URL(url).hostname.replace(/^www\./, "");
11699
12204
  let plan = await reusablePlan(planPath, lines);
11700
12205
  if (plan) {
@@ -11904,7 +12409,7 @@ function parseArgs(args) {
11904
12409
  i++;
11905
12410
  } else if (arg2 === "--skip-voice" || arg2 === "-sv") {
11906
12411
  skipVoice = true;
11907
- } else if (arg2 === "--draft" || arg2 === "-d") {
12412
+ } else if (arg2 === "--draft" || arg2 === "-dr") {
11908
12413
  draft = true;
11909
12414
  } else if (arg2 === "--yes" || arg2 === "-y") {
11910
12415
  yes = true;