@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.
@@ -230,7 +230,7 @@ function spawnFFmpeg(cmd2, args) {
230
230
  return trackChild(spawn(cmd2, args));
231
231
  }
232
232
  function runFFmpeg(args, errorLabel) {
233
- return new Promise((resolve12, reject) => {
233
+ return new Promise((resolve13, reject) => {
234
234
  const proc = spawnFFmpeg("ffmpeg", args);
235
235
  let stderr = "";
236
236
  proc.stderr.on("data", (d) => {
@@ -238,7 +238,7 @@ function runFFmpeg(args, errorLabel) {
238
238
  });
239
239
  proc.on("close", (code) => {
240
240
  if (code !== 0) reject(new Error(`${errorLabel}: ${stderr.slice(-500)}`));
241
- else resolve12();
241
+ else resolve13();
242
242
  });
243
243
  proc.on("error", reject);
244
244
  });
@@ -254,6 +254,48 @@ var init_spawn = __esm({
254
254
  }
255
255
  });
256
256
 
257
+ // src/lib/image-kind.ts
258
+ import { readFileSync as readFileSync3 } from "fs";
259
+ function mimeForKind(kind) {
260
+ return MIME[kind];
261
+ }
262
+ function sniffImageKind(buf) {
263
+ if (buf.length < 12) return null;
264
+ if (buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "png";
265
+ if (buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "jpeg";
266
+ if (buf.subarray(0, 3).toString("latin1") === "GIF") return "gif";
267
+ if (buf.subarray(0, 4).toString("latin1") === "RIFF" && buf.subarray(8, 12).toString("latin1") === "WEBP") {
268
+ return "webp";
269
+ }
270
+ const head = buf.subarray(0, 1024).toString("utf8").replace(/^/, "").trimStart();
271
+ if (/^<(\?xml|!--|!doctype svg|svg)[\s>]/i.test(head) && /<svg[\s>]/i.test(head)) return "svg";
272
+ return null;
273
+ }
274
+ function imageDataUri(path) {
275
+ try {
276
+ const buf = readFileSync3(path);
277
+ const kind = sniffImageKind(buf);
278
+ if (!kind) return null;
279
+ return `data:${mimeForKind(kind)};base64,${buf.toString("base64")}`;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+ var MIME, MIN_ICON_PX;
285
+ var init_image_kind = __esm({
286
+ "src/lib/image-kind.ts"() {
287
+ "use strict";
288
+ MIME = {
289
+ png: "image/png",
290
+ jpeg: "image/jpeg",
291
+ gif: "image/gif",
292
+ webp: "image/webp",
293
+ svg: "image/svg+xml"
294
+ };
295
+ MIN_ICON_PX = 48;
296
+ }
297
+ });
298
+
257
299
  // src/lib/api-client.ts
258
300
  function apiKey() {
259
301
  return process.env.SPARK_AI_KEY || "";
@@ -370,7 +412,7 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
370
412
  ...detail ? { detail } : {},
371
413
  ...retryAfter2 ? { retryAfterSeconds: parseInt(retryAfter2, 10) } : {}
372
414
  };
373
- await new Promise((resolve12) => setTimeout(resolve12, Math.min(delayMs, 3e4)));
415
+ await new Promise((resolve13) => setTimeout(resolve13, Math.min(delayMs, 3e4)));
374
416
  continue;
375
417
  }
376
418
  const retryAfter = response.headers.get("Retry-After");
@@ -410,7 +452,7 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
410
452
  return { success: false, error: `${host} unreachable: ${getErrorMessage(err)}`, host };
411
453
  }
412
454
  const delayMs = 1e3 * 2 ** (attempt - 1);
413
- await new Promise((resolve12) => setTimeout(resolve12, delayMs));
455
+ await new Promise((resolve13) => setTimeout(resolve13, delayMs));
414
456
  }
415
457
  }
416
458
  return lastRetriableError || { success: false, error: `${host}: request failed after retries`, host };
@@ -572,9 +614,6 @@ var init_SparkStripe_feature_gating = __esm({
572
614
  });
573
615
 
574
616
  // src/lib/billing.ts
575
- function shouldWatermark(tier, videoDurationSeconds) {
576
- return tier === "free" && videoDurationSeconds > 20;
577
- }
578
617
  function getUpgradeUrl(userEmail) {
579
618
  return pricingUrl(userEmail ? { email: userEmail } : void 0);
580
619
  }
@@ -609,7 +648,10 @@ function applyProfile(config, profile) {
609
648
  format: "mp3",
610
649
  bitrate: "192k",
611
650
  ...config.audio,
612
- speed: config.audio?.speed ?? preset.audioSpeed,
651
+ // outroOnly is on unless turned off, matching the video phase. The
652
+ // speedup buys the song its window, so it applies to the profile's own
653
+ // pace only: a speed somebody set by hand is the speed they wanted.
654
+ speed: config.audio?.speed ?? preset.audioSpeed * (config.music?.outroOnly !== false ? OUTRO_SONG_SPEEDUP : 1),
613
655
  ...config.audio?.voice || !preset.voice ? {} : { voice: preset.voice }
614
656
  }
615
657
  };
@@ -646,11 +688,11 @@ function defaultCaptionsFor(width, height) {
646
688
  maxChars: 20
647
689
  };
648
690
  }
649
- var VERSION, CONFIG_FILE, SIZE_PRESETS, RATE_FACTOR, NO_CLIP_OUTRO, DEFAULT_CONFIG, VIDEO_PROFILES, DEFAULT_CAPTIONS;
691
+ var VERSION, CONFIG_FILE, SIZE_PRESETS, RATE_FACTOR, NO_CLIP_OUTRO, DEFAULT_CONFIG, VIDEO_PROFILES, OUTRO_SONG_SPEEDUP, DEFAULT_CAPTIONS;
650
692
  var init_config = __esm({
651
693
  "src/lib/config.ts"() {
652
694
  "use strict";
653
- VERSION = "1.2.3";
695
+ VERSION = "1.2.5";
654
696
  CONFIG_FILE = "quickpeek.config.json";
655
697
  SIZE_PRESETS = {
656
698
  wide: { width: 1920, height: 1080, zoom: 1 },
@@ -677,11 +719,12 @@ var init_config = __esm({
677
719
  // ms
678
720
  transitions: "none",
679
721
  contrast: 0.25,
680
- // A demo plays a touch faster than it was recorded, because a recording
681
- // paced for the narration reads as slow next to anything else on the same
682
- // feed. 1.2 is the most that still sounds like a person talking; set 1 in
683
- // quickpeek.config.json for the raw pace.
684
- tempo: 1.2,
722
+ // The recording plays at the pace it was filmed at. A tempo above 1 is
723
+ // multiplied by the narration speed in VIDEO_PROFILES and by the fit
724
+ // pass's squeeze, and the three together read as frantic rather than
725
+ // brisk. Speed belongs in one place, so it lives in audioSpeed; set a
726
+ // tempo in quickpeek.config.json to push the picture on top of that.
727
+ tempo: 1,
685
728
  stepDelay: 200
686
729
  // ms delay between steps
687
730
  },
@@ -695,18 +738,24 @@ var init_config = __esm({
695
738
  // these bitrates - the encode is pure wall-clock tax on every run.
696
739
  wide: {
697
740
  video: { width: 1920, height: 1080, zoom: 1.5, fitLimitSecs: 0, maxSpeedup: 1, preset: "veryfast" },
698
- audioSpeed: 1.6
741
+ // A tutorial is watched to learn something, so the narrator is allowed to
742
+ // be brisk but never hurried.
743
+ audioSpeed: 1.15
699
744
  },
700
745
  short: {
701
746
  // fadeOut 0: the video ends on a card the viewer is meant to read,
702
747
  // and a fade takes it away mid-sentence.
703
748
  video: { width: 1080, height: 1920, zoom: 3, fitLimitSecs: 60, maxSpeedup: 1.7, preset: "veryfast", fadeOut: 0 },
704
- audioSpeed: 1.8,
749
+ // 1.8 was unwatchable: stacked on tempo 1.2 and the outro speedup it ran
750
+ // the voice at better than twice its natural rate. A short wants energy,
751
+ // which is the voice and the cutting, not the tape running fast.
752
+ audioSpeed: 1,
705
753
  // A short lives or dies on energy. Ava is the warmest, most animated of
706
754
  // the free neural voices; Sonia reads a short like the shipping forecast.
707
755
  voice: "en-US-AvaMultilingualNeural"
708
756
  }
709
757
  };
758
+ OUTRO_SONG_SPEEDUP = 1.13;
710
759
  DEFAULT_CAPTIONS = {
711
760
  font: "Arial",
712
761
  outline: "#000000",
@@ -856,6 +905,7 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
856
905
  const deadline = Date.now() + cap;
857
906
  let previous = -1;
858
907
  let unchanged = 0;
908
+ let clear = 0;
859
909
  while (Date.now() < deadline) {
860
910
  const count = await page.evaluate((selector) => {
861
911
  let visible = 0;
@@ -865,7 +915,12 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
865
915
  }
866
916
  return visible;
867
917
  }, PLACEHOLDER_SELECTOR).catch(() => 0);
868
- if (count === 0) return;
918
+ if (count === 0) {
919
+ if (++clear >= PLACEHOLDER_CLEAR_POLLS) return;
920
+ await page.waitForTimeout(PLACEHOLDER_POLL_MS);
921
+ continue;
922
+ }
923
+ clear = 0;
869
924
  if (count === previous) {
870
925
  if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
871
926
  } else {
@@ -1125,7 +1180,7 @@ async function crawlPage(url, task2 = noopTask, storageStatePath, viewport) {
1125
1180
  });
1126
1181
  }
1127
1182
  }
1128
- var 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;
1183
+ var 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;
1129
1184
  var init_crawl = __esm({
1130
1185
  "src/lib/crawl.ts"() {
1131
1186
  "use strict";
@@ -1136,6 +1191,7 @@ var init_crawl = __esm({
1136
1191
  PLACEHOLDER_POLL_MS = 250;
1137
1192
  PLACEHOLDER_CAP_MS = 8e3;
1138
1193
  PLACEHOLDER_STATIC_POLLS = 4;
1194
+ PLACEHOLDER_CLEAR_POLLS = 3;
1139
1195
  PLACEHOLDER_SELECTOR = [
1140
1196
  '[aria-busy="true"]',
1141
1197
  '[role="progressbar"]',
@@ -1287,13 +1343,13 @@ function canEncode(encoder) {
1287
1343
  ]);
1288
1344
  }
1289
1345
  function run(args) {
1290
- return new Promise((resolve12) => {
1346
+ return new Promise((resolve13) => {
1291
1347
  let settled = false;
1292
1348
  const done = (ok) => {
1293
1349
  if (settled) return;
1294
1350
  settled = true;
1295
1351
  clearTimeout(timer);
1296
- resolve12(ok);
1352
+ resolve13(ok);
1297
1353
  };
1298
1354
  const proc = trackChild(spawn3("ffmpeg", args, { stdio: "ignore" }));
1299
1355
  const timer = setTimeout(() => {
@@ -1350,8 +1406,13 @@ async function composeVideo(opts) {
1350
1406
  musicPath,
1351
1407
  musicVolume = 0.3,
1352
1408
  musicTargetLufs = MUSIC_BED_LUFS,
1353
- musicFadeIn = 2,
1354
- musicFadeOut = 2,
1409
+ // Sized for the outro window: the song has 5.5s, so a 2s fade at each end
1410
+ // left 1.5s of music anyone could actually hear. Up fast, hold, out slow.
1411
+ musicFadeIn = 1,
1412
+ musicFadeOut = 1.5,
1413
+ tempo = 1,
1414
+ musicStartSecs = 0,
1415
+ musicDurationSecs = 0,
1355
1416
  width,
1356
1417
  height,
1357
1418
  fps,
@@ -1412,7 +1473,19 @@ async function composeVideo(opts) {
1412
1473
  const clipAudioLabel = opts.hasClipAudio ? "[0:a]" : "";
1413
1474
  const hasVoiceover = !!voiceoverPath;
1414
1475
  const hasMusic = !!musicPath;
1415
- const musicFadeOutStart = Math.max(0, effectiveDuration - musicFadeOut);
1476
+ const bedStart = Math.max(0, Math.min(musicStartSecs, Math.max(0, effectiveDuration - 0.5)));
1477
+ const bedLength = Math.max(0.5, effectiveDuration - bedStart);
1478
+ const bedDelay = bedStart > 0 ? `adelay=${Math.round(bedStart * 1e3)}|${Math.round(bedStart * 1e3)},` : "";
1479
+ const bedTempo = tempo > 1 ? 1 / tempo : 1;
1480
+ const bedSlow = bedTempo === 1 ? "" : `atempo=${bedTempo.toFixed(6)},`;
1481
+ const bedSource = bedLength * bedTempo;
1482
+ const bedSeek = musicDurationSecs > bedSource + 0.05 ? musicDurationSecs - bedSource : 0;
1483
+ const bedTrimIn = bedSeek > 0 ? `atrim=start=${bedSeek.toFixed(3)},asetpts=PTS-STARTPTS,` : "";
1484
+ const bedFadeOut = bedSeek > 0 ? 0 : musicFadeOut * tempo;
1485
+ const bedLoop = bedSeek > 0 ? "" : "aloop=loop=-1:size=2e+09,";
1486
+ const bedFadeIn = musicFadeIn * tempo;
1487
+ const musicFadeOutStart = Math.max(0, bedLength - bedFadeOut);
1488
+ const bedFadeOutFilter = bedFadeOut > 0 ? `afade=t=out:st=${musicFadeOutStart}:d=${bedFadeOut},` : "";
1416
1489
  const vfStr = [...vf, ...encoder.filter ? [encoder.filter] : []].join(",");
1417
1490
  if (hasVoiceover && hasMusic) {
1418
1491
  const mixInputs = clipAudioLabel ? 3 : 2;
@@ -1428,7 +1501,7 @@ async function composeVideo(opts) {
1428
1501
  // card, which is the one place the bed is the only thing playing.
1429
1502
  `[voicekey0]apad=whole_dur=${effectiveDuration}[voicekey]`,
1430
1503
  // Music: normalised to a known level, trimmed, then faded.
1431
- `[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]`,
1504
+ `[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]`,
1432
1505
  `[bed][voicekey]${DUCK}[music]`,
1433
1506
  // normalize=0, because amix's default divides every input by the number
1434
1507
  // of them: adding the bed used to quietly take 6 dB off the narration,
@@ -1452,7 +1525,7 @@ async function composeVideo(opts) {
1452
1525
  args.push(...encoder.output);
1453
1526
  args.push("-c:a", "aac", "-b:a", audioBitrate);
1454
1527
  } else if (hasMusic) {
1455
- const musicFilter = `aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut},${MASTER_CHAIN}`;
1528
+ const musicFilter = `${bedTrimIn}${bedLoop}${bedSlow}atrim=0:${bedLength},volume=${musicVolume},afade=t=in:st=0:d=${bedFadeIn},${bedFadeOutFilter}${bedDelay}apad=whole_dur=${effectiveDuration},${MASTER_CHAIN}`;
1456
1529
  args.push("-vf", vfStr);
1457
1530
  args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
1458
1531
  args.push(...encoder.output);
@@ -1584,7 +1657,7 @@ function cacheKey(filePath) {
1584
1657
  }
1585
1658
  }
1586
1659
  function getVideoDimensions(filePath) {
1587
- return new Promise((resolve12, reject) => {
1660
+ return new Promise((resolve13, reject) => {
1588
1661
  const args = [
1589
1662
  "-v",
1590
1663
  "error",
@@ -1615,12 +1688,12 @@ function getVideoDimensions(filePath) {
1615
1688
  reject(new Error(`ffprobe returned no dimensions for ${filePath}`));
1616
1689
  return;
1617
1690
  }
1618
- resolve12({ width: w, height: h });
1691
+ resolve13({ width: w, height: h });
1619
1692
  });
1620
1693
  });
1621
1694
  }
1622
1695
  function hasAudioStream(filePath) {
1623
- return new Promise((resolve12) => {
1696
+ return new Promise((resolve13) => {
1624
1697
  const proc = spawnFFmpeg("ffprobe", [
1625
1698
  "-v",
1626
1699
  "error",
@@ -1636,15 +1709,15 @@ function hasAudioStream(filePath) {
1636
1709
  proc.stdout.on("data", (d) => {
1637
1710
  out += d;
1638
1711
  });
1639
- proc.on("close", () => resolve12(out.trim().length > 0));
1640
- proc.on("error", () => resolve12(false));
1712
+ proc.on("close", () => resolve13(out.trim().length > 0));
1713
+ proc.on("error", () => resolve13(false));
1641
1714
  });
1642
1715
  }
1643
1716
  function getMediaDuration(filePath) {
1644
1717
  const key = cacheKey(filePath);
1645
1718
  const cached = key === null ? void 0 : durationCache.get(key);
1646
1719
  if (cached !== void 0) return Promise.resolve(cached);
1647
- return new Promise((resolve12, reject) => {
1720
+ return new Promise((resolve13, reject) => {
1648
1721
  const args = [
1649
1722
  "-v",
1650
1723
  "error",
@@ -1671,13 +1744,13 @@ function getMediaDuration(filePath) {
1671
1744
  const ms = Math.ceil(parseFloat(stdout.trim()) * 1e3);
1672
1745
  const freshKey = cacheKey(filePath);
1673
1746
  if (freshKey !== null && Number.isFinite(ms)) durationCache.set(freshKey, ms);
1674
- resolve12(ms);
1747
+ resolve13(ms);
1675
1748
  });
1676
1749
  proc.on("error", reject);
1677
1750
  });
1678
1751
  }
1679
1752
  function getAudioCodec(filePath) {
1680
- return new Promise((resolve12) => {
1753
+ return new Promise((resolve13) => {
1681
1754
  const args = [
1682
1755
  "-v",
1683
1756
  "error",
@@ -1694,8 +1767,8 @@ function getAudioCodec(filePath) {
1694
1767
  proc.stdout.on("data", (data) => {
1695
1768
  stdout += data;
1696
1769
  });
1697
- proc.on("close", (code) => resolve12(code === 0 ? stdout.trim() : ""));
1698
- proc.on("error", () => resolve12(""));
1770
+ proc.on("close", (code) => resolve13(code === 0 ? stdout.trim() : ""));
1771
+ proc.on("error", () => resolve13(""));
1699
1772
  });
1700
1773
  }
1701
1774
  async function isHonestWav(filePath) {
@@ -1849,7 +1922,7 @@ var init_media = __esm({
1849
1922
 
1850
1923
  // src/lib/ffmpeg/silence.ts
1851
1924
  function getVideoStreamDuration(inputPath) {
1852
- return new Promise((resolve12) => {
1925
+ return new Promise((resolve13) => {
1853
1926
  const proc = spawnFFmpeg("ffprobe", [
1854
1927
  "-v",
1855
1928
  "error",
@@ -1866,12 +1939,12 @@ function getVideoStreamDuration(inputPath) {
1866
1939
  proc.stdout.on("data", (d) => {
1867
1940
  out += d;
1868
1941
  });
1869
- proc.on("error", () => resolve12(0));
1870
- proc.on("close", () => resolve12(Number.parseFloat(out.trim()) || 0));
1942
+ proc.on("error", () => resolve13(0));
1943
+ proc.on("close", () => resolve13(Number.parseFloat(out.trim()) || 0));
1871
1944
  });
1872
1945
  }
1873
1946
  function detectSilences(inputPath, minDurSecs) {
1874
- return new Promise((resolve12) => {
1947
+ return new Promise((resolve13) => {
1875
1948
  const proc = spawnFFmpeg("ffmpeg", [
1876
1949
  "-hide_banner",
1877
1950
  "-nostats",
@@ -1890,7 +1963,7 @@ function detectSilences(inputPath, minDurSecs) {
1890
1963
  proc.stderr.on("data", (d) => {
1891
1964
  err += d;
1892
1965
  });
1893
- proc.on("error", () => resolve12([]));
1966
+ proc.on("error", () => resolve13([]));
1894
1967
  proc.on("close", () => {
1895
1968
  const spans = [];
1896
1969
  let start = null;
@@ -1905,12 +1978,12 @@ function detectSilences(inputPath, minDurSecs) {
1905
1978
  start = null;
1906
1979
  }
1907
1980
  }
1908
- resolve12(spans);
1981
+ resolve13(spans);
1909
1982
  });
1910
1983
  });
1911
1984
  }
1912
1985
  function detectFrozenSpans(inputPath, minDurSecs) {
1913
- return new Promise((resolve12) => {
1986
+ return new Promise((resolve13) => {
1914
1987
  const proc = spawnFFmpeg("ffmpeg", [
1915
1988
  "-hide_banner",
1916
1989
  "-nostats",
@@ -1931,7 +2004,7 @@ function detectFrozenSpans(inputPath, minDurSecs) {
1931
2004
  proc.stderr.on("data", (d) => {
1932
2005
  err += d;
1933
2006
  });
1934
- proc.on("error", () => resolve12([]));
2007
+ proc.on("error", () => resolve13([]));
1935
2008
  proc.on("close", () => {
1936
2009
  const spans = [];
1937
2010
  let start = null;
@@ -1944,12 +2017,12 @@ function detectFrozenSpans(inputPath, minDurSecs) {
1944
2017
  start = null;
1945
2018
  }
1946
2019
  }
1947
- resolve12(spans);
2020
+ resolve13(spans);
1948
2021
  });
1949
2022
  });
1950
2023
  }
1951
2024
  function detectBlackSpans(inputPath, minDurSecs) {
1952
- return new Promise((resolve12) => {
2025
+ return new Promise((resolve13) => {
1953
2026
  const proc = spawnFFmpeg("ffmpeg", [
1954
2027
  "-hide_banner",
1955
2028
  "-nostats",
@@ -1972,19 +2045,19 @@ function detectBlackSpans(inputPath, minDurSecs) {
1972
2045
  proc.stderr.on("data", (d) => {
1973
2046
  err += d;
1974
2047
  });
1975
- proc.on("error", () => resolve12([]));
2048
+ proc.on("error", () => resolve13([]));
1976
2049
  proc.on("close", () => {
1977
2050
  const spans = [];
1978
2051
  for (const line of err.split("\n")) {
1979
2052
  const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
1980
2053
  if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
1981
2054
  }
1982
- resolve12(spans);
2055
+ resolve13(spans);
1983
2056
  });
1984
2057
  });
1985
2058
  }
1986
2059
  function detectWhiteSpans(inputPath, minDurSecs) {
1987
- return new Promise((resolve12) => {
2060
+ return new Promise((resolve13) => {
1988
2061
  const proc = spawnFFmpeg("ffmpeg", [
1989
2062
  "-hide_banner",
1990
2063
  "-nostats",
@@ -2002,14 +2075,14 @@ function detectWhiteSpans(inputPath, minDurSecs) {
2002
2075
  proc.stderr.on("data", (d) => {
2003
2076
  err += d;
2004
2077
  });
2005
- proc.on("error", () => resolve12([]));
2078
+ proc.on("error", () => resolve13([]));
2006
2079
  proc.on("close", () => {
2007
2080
  const spans = [];
2008
2081
  for (const line of err.split("\n")) {
2009
2082
  const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
2010
2083
  if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
2011
2084
  }
2012
- resolve12(spans);
2085
+ resolve13(spans);
2013
2086
  });
2014
2087
  });
2015
2088
  }
@@ -2099,7 +2172,7 @@ async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
2099
2172
  const drop = keep.map((s) => `between(t,${s.start.toFixed(3)},${s.end.toFixed(3)})`).join("+");
2100
2173
  const filter = `[0:v]select='not(${drop})',setpts=N/FRAME_RATE/TB[outv]`;
2101
2174
  const enc = await passEncoder(preset);
2102
- return new Promise((resolve12) => {
2175
+ return new Promise((resolve13) => {
2103
2176
  const proc = spawnFFmpeg("ffmpeg", [
2104
2177
  "-y",
2105
2178
  ...filterThreadArgs(),
@@ -2117,8 +2190,8 @@ async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
2117
2190
  outputPath
2118
2191
  ]);
2119
2192
  proc.stderr.resume();
2120
- proc.on("error", () => resolve12(false));
2121
- proc.on("close", (code) => resolve12(code === 0));
2193
+ proc.on("error", () => resolve13(false));
2194
+ proc.on("close", (code) => resolve13(code === 0));
2122
2195
  });
2123
2196
  }
2124
2197
  function buildRetimeFilter(spans, stepBounds, speed, fps) {
@@ -2200,7 +2273,7 @@ async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, spee
2200
2273
  const filter = buildRetimeFilter(spans, stepBounds, speed, fps);
2201
2274
  if (!filter) return false;
2202
2275
  const enc = await passEncoder(preset);
2203
- return new Promise((resolve12) => {
2276
+ return new Promise((resolve13) => {
2204
2277
  const proc = spawnFFmpeg("ffmpeg", [
2205
2278
  "-y",
2206
2279
  ...filterThreadArgs(),
@@ -2218,8 +2291,8 @@ async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, spee
2218
2291
  outputPath
2219
2292
  ]);
2220
2293
  proc.stderr.resume();
2221
- proc.on("error", () => resolve12(false));
2222
- proc.on("close", (code) => resolve12(code === 0));
2294
+ proc.on("error", () => resolve13(false));
2295
+ proc.on("close", (code) => resolve13(code === 0));
2223
2296
  });
2224
2297
  }
2225
2298
  async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, preset, protectAfterSecs, narrationPath, protectBeforeSecs, leadSpans, keepSpans) {
@@ -2245,7 +2318,7 @@ async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, pr
2245
2318
  const filter = buildCompressionFilter(spans, totalSecs, maxSecs, protectAfterSecs, protectBeforeSecs);
2246
2319
  if (!filter) return false;
2247
2320
  const enc = await passEncoder(preset);
2248
- return new Promise((resolve12) => {
2321
+ return new Promise((resolve13) => {
2249
2322
  const proc = spawnFFmpeg("ffmpeg", [
2250
2323
  "-y",
2251
2324
  ...filterThreadArgs(),
@@ -2271,8 +2344,8 @@ async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, pr
2271
2344
  outputPath
2272
2345
  ]);
2273
2346
  proc.stderr.resume();
2274
- proc.on("error", () => resolve12(false));
2275
- proc.on("close", (code) => resolve12(code === 0));
2347
+ proc.on("error", () => resolve13(false));
2348
+ proc.on("close", (code) => resolve13(code === 0));
2276
2349
  });
2277
2350
  }
2278
2351
  async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, introSecs, outroSecs, maxFactor, preset) {
@@ -2296,7 +2369,7 @@ async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, intro
2296
2369
  "[v0][a0][v1][a1][v2][a2]concat=n=3:v=1:a=1[outv][outa]"
2297
2370
  ].join(";");
2298
2371
  const enc = await passEncoder(preset);
2299
- const ok = await new Promise((resolve12) => {
2372
+ const ok = await new Promise((resolve13) => {
2300
2373
  const proc = spawnFFmpeg("ffmpeg", [
2301
2374
  "-y",
2302
2375
  ...filterThreadArgs(),
@@ -2317,8 +2390,8 @@ async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, intro
2317
2390
  outputPath
2318
2391
  ]);
2319
2392
  proc.stderr.resume();
2320
- proc.on("error", () => resolve12(false));
2321
- proc.on("close", (code) => resolve12(code === 0));
2393
+ proc.on("error", () => resolve13(false));
2394
+ proc.on("close", (code) => resolve13(code === 0));
2322
2395
  });
2323
2396
  return { ok, factor, reachedTarget: needed <= maxFactor };
2324
2397
  }
@@ -2354,7 +2427,7 @@ async function spliceVideos(opts) {
2354
2427
  const audioPresent = await Promise.all(
2355
2428
  overlays.map((o) => o.still ? Promise.resolve(false) : hasAudioStream(o.path))
2356
2429
  );
2357
- return new Promise((resolve12, reject) => {
2430
+ return new Promise((resolve13, reject) => {
2358
2431
  const args = ["-y", "-i", inputPath];
2359
2432
  for (const overlay of overlays) {
2360
2433
  if (overlay.still) {
@@ -2417,7 +2490,7 @@ async function spliceVideos(opts) {
2417
2490
  args.push("-c:v", "libx264", "-preset", preset, "-crf", "18", "-pix_fmt", "yuv420p");
2418
2491
  if (clipAudio.length > 0) args.push("-c:a", "aac");
2419
2492
  args.push(outputPath);
2420
- runFFmpeg(args, "ffmpeg splice failed").then(resolve12, reject);
2493
+ runFFmpeg(args, "ffmpeg splice failed").then(resolve13, reject);
2421
2494
  });
2422
2495
  }
2423
2496
  var CLIP_AUDIO_GAIN, STILL_FRAMERATE, COVER_WASH_W, COVER_WASH_H, COVER_BLUR_SIGMA;
@@ -2453,10 +2526,10 @@ function buildStepTransitions(steps, stepDurations, defaultTransition) {
2453
2526
  return transitions;
2454
2527
  }
2455
2528
  function applyTransitions(opts) {
2456
- return new Promise((resolve12, reject) => {
2529
+ return new Promise((resolve13, reject) => {
2457
2530
  const { inputVideo, outputVideo, transitions, videoDuration, preset = "fast" } = opts;
2458
2531
  if (transitions.length === 0) {
2459
- runFFmpeg(["-y", "-i", inputVideo, "-c", "copy", outputVideo], "FFmpeg copy failed").then(resolve12, reject);
2532
+ runFFmpeg(["-y", "-i", inputVideo, "-c", "copy", outputVideo], "FFmpeg copy failed").then(resolve13, reject);
2460
2533
  return;
2461
2534
  }
2462
2535
  const sorted = [...transitions].sort((a, b) => a.timestamp - b.timestamp);
@@ -2504,7 +2577,7 @@ function applyTransitions(opts) {
2504
2577
  "yuv420p",
2505
2578
  outputVideo
2506
2579
  ];
2507
- runFFmpeg(args, "FFmpeg transitions failed").then(resolve12, reject);
2580
+ runFFmpeg(args, "FFmpeg transitions failed").then(resolve13, reject);
2508
2581
  });
2509
2582
  }
2510
2583
  var init_transitions = __esm({
@@ -2977,6 +3050,9 @@ function renderHormoziAss(ctx) {
2977
3050
  return `${header2}${lines.join("\n")}
2978
3051
  `;
2979
3052
  }
3053
+ function looksLikeAddress(token) {
3054
+ return /^(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?[.,!?]?$/i.test(token);
3055
+ }
2980
3056
  function renderShortsAss(ctx) {
2981
3057
  const { captions, width, height } = ctx;
2982
3058
  const font = resolveCaptionFont(captions.font === "Arial" ? "Arial Black" : captions.font);
@@ -2987,7 +3063,7 @@ function renderShortsAss(ctx) {
2987
3063
  const alignment = positionToAlignment(captions.position);
2988
3064
  const outline = Math.max(2, Math.round(fontSize / 22));
2989
3065
  const upper = true;
2990
- const caseOf = (t) => toCaptionCase(t, upper);
3066
+ const caseOf = (t) => looksLikeAddress(t) ? t : toCaptionCase(t, upper);
2991
3067
  const maxChars = fittingMaxChars(width, fontSize, SIDE_MARGIN, captions.maxChars, upper);
2992
3068
  const header2 = `[Script Info]
2993
3069
  Title: QuickPeek Demo
@@ -3549,6 +3625,8 @@ asyncio.run(main())
3549
3625
  await writeFile3(tempScriptPath, pythonScript);
3550
3626
  const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
3551
3627
  await execAsync(pythonCmd, { timeout: 3e4 });
3628
+ const written = await stat(outputPath).catch(() => null);
3629
+ if (!written?.size) throw new Error("edge-tts produced no audio");
3552
3630
  return await readWordsSidecar(outputPath);
3553
3631
  } finally {
3554
3632
  await unlink4(tempScriptPath).catch(() => {
@@ -3736,11 +3814,17 @@ function spellAcronyms(text) {
3736
3814
  );
3737
3815
  }
3738
3816
  function bindDomains(text) {
3739
- return text.replace(/\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi, "$1-dot-$2");
3817
+ 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(
3818
+ /\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi,
3819
+ (_m, host, tld) => `${host}-dot-${SPOKEN_TLD[tld.toLowerCase()] ?? tld}`
3820
+ );
3740
3821
  }
3741
3822
  function speakSymbols(text) {
3742
3823
  return text.replace(/\s*&\s*/g, " and ").replace(/[<>]/g, " ").replace(/\s{2,}/g, " ").trim();
3743
3824
  }
3825
+ function spokenForm(caption) {
3826
+ return speakNumbers(spellAcronyms(toSpokenForm(bindDomains(speakSymbols(caption)))));
3827
+ }
3744
3828
  async function generateVoiceover(steps, config, workDir, outputDir, logError2, task2, info2) {
3745
3829
  resetVoiceEngine();
3746
3830
  const voice = resolveVoice2(config);
@@ -3787,7 +3871,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
3787
3871
  }
3788
3872
  const audioPath = join9(audioDir, `step-${stepNum}.mp3`);
3789
3873
  try {
3790
- const spokenText = speakNumbers(spellAcronyms(toSpokenForm(bindDomains(speakSymbols(step.caption)))));
3874
+ const spokenText = spokenForm(step.caption);
3791
3875
  const key = clipKey(spokenText, voice, ratePercent ?? "");
3792
3876
  const cacheDir = join9(outputDir, ".tts-clips");
3793
3877
  const cached = await reuseClip(cacheDir, key, audioPath);
@@ -3868,7 +3952,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
3868
3952
  await saveVoiceoverMeta(outputDir, meta);
3869
3953
  return { voiceoverPath, stepAudio };
3870
3954
  }
3871
- var TTS_POOL, ONES, TENS, SPELLED_OUT, LETTER_SOUNDS;
3955
+ var TTS_POOL, ONES, TENS, SPELLED_OUT, LETTER_SOUNDS, SPOKEN_TLD;
3872
3956
  var init_generate = __esm({
3873
3957
  "src/lib/tts/generate.ts"() {
3874
3958
  "use strict";
@@ -3933,6 +4017,9 @@ var init_generate = __esm({
3933
4017
  Y: "why",
3934
4018
  Z: "zed"
3935
4019
  };
4020
+ SPOKEN_TLD = {
4021
+ ai: "ay-eye"
4022
+ };
3936
4023
  }
3937
4024
  });
3938
4025
 
@@ -3992,6 +4079,7 @@ __export(lib_exports, {
3992
4079
  NO_CLIP_OUTRO: () => NO_CLIP_OUTRO,
3993
4080
  PLAN_MAX_TOKENS: () => PLAN_MAX_TOKENS,
3994
4081
  RATE_FACTOR: () => RATE_FACTOR,
4082
+ RELAY_BASE: () => RELAY_BASE,
3995
4083
  SERVER_BACKOFF_MS: () => SERVER_BACKOFF_MS,
3996
4084
  SIZE_PRESETS: () => SIZE_PRESETS,
3997
4085
  TRIM_EDGE_SILENCE: () => TRIM_EDGE_SILENCE,
@@ -4043,6 +4131,7 @@ __export(lib_exports, {
4043
4131
  getErrorMessage: () => getErrorMessage,
4044
4132
  getLanguageName: () => getLanguageName,
4045
4133
  getMediaDuration: () => getMediaDuration,
4134
+ getOrRefreshRelayToken: () => getOrRefreshRelayToken,
4046
4135
  getStatus: () => getStatus,
4047
4136
  getStatusCached: () => getStatusCached,
4048
4137
  getTierByEmail: () => getTierByEmail,
@@ -4086,12 +4175,12 @@ __export(lib_exports, {
4086
4175
  runPooled: () => runPooled,
4087
4176
  salvageTruncatedJson: () => salvageTruncatedJson,
4088
4177
  shouldSkipLink: () => shouldSkipLink,
4089
- shouldWatermark: () => shouldWatermark,
4090
4178
  showPaywall: () => showPaywall,
4091
4179
  speakSymbols: () => speakSymbols,
4092
4180
  speedUpMiddle: () => speedUpMiddle,
4093
4181
  speedUpVideo: () => speedUpVideo,
4094
4182
  spliceVideos: () => spliceVideos,
4183
+ spokenForm: () => spokenForm,
4095
4184
  spokenWordTimings: () => spokenWordTimings,
4096
4185
  stripLongDashes: () => stripLongDashes,
4097
4186
  synthesizeSpeech: () => synthesizeSpeech,
@@ -4401,7 +4490,10 @@ async function loadCaptions() {
4401
4490
  async function savePlan(plan, planPath) {
4402
4491
  const cleanPlan = {
4403
4492
  ...plan,
4404
- steps: plan.steps.map(({ duration, ...step }) => step)
4493
+ steps: plan.steps.map(({ duration, ...step }) => ({
4494
+ ...step,
4495
+ ...typeof duration === "number" && duration > 0 ? { duration: Math.min(duration, 30) } : {}
4496
+ }))
4405
4497
  };
4406
4498
  await mkdir4(dirname2(planPath), { recursive: true });
4407
4499
  await writeFile5(planPath, JSON.stringify(cleanPlan, null, 2));
@@ -4514,55 +4606,16 @@ var init_mcp_context = __esm({
4514
4606
  }
4515
4607
  });
4516
4608
 
4517
- // src/lib/image-kind.ts
4518
- function mimeForKind(kind) {
4519
- return MIME[kind];
4520
- }
4521
- function sniffImageKind(buf) {
4522
- if (buf.length < 12) return null;
4523
- if (buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "png";
4524
- if (buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "jpeg";
4525
- if (buf.subarray(0, 3).toString("latin1") === "GIF") return "gif";
4526
- if (buf.subarray(0, 4).toString("latin1") === "RIFF" && buf.subarray(8, 12).toString("latin1") === "WEBP") {
4527
- return "webp";
4528
- }
4529
- const head = buf.subarray(0, 1024).toString("utf8").replace(/^/, "").trimStart();
4530
- if (/^<(\?xml|!--|!doctype svg|svg)[\s>]/i.test(head) && /<svg[\s>]/i.test(head)) return "svg";
4531
- return null;
4532
- }
4533
- var MIME, MIN_ICON_PX;
4534
- var init_image_kind = __esm({
4535
- "src/lib/image-kind.ts"() {
4536
- "use strict";
4537
- MIME = {
4538
- png: "image/png",
4539
- jpeg: "image/jpeg",
4540
- gif: "image/gif",
4541
- webp: "image/webp",
4542
- svg: "image/svg+xml"
4543
- };
4544
- MIN_ICON_PX = 48;
4545
- }
4546
- });
4547
-
4548
4609
  // src/lib/outro-card.ts
4549
- import { readFileSync as readFileSync5 } from "fs";
4550
- function dataUri(path) {
4551
- try {
4552
- const buf = readFileSync5(path);
4553
- const kind = sniffImageKind(buf);
4554
- if (!kind) return null;
4555
- return `data:${mimeForKind(kind)};base64,${buf.toString("base64")}`;
4556
- } catch {
4557
- return null;
4558
- }
4559
- }
4560
4610
  function escapeHtml(text) {
4561
4611
  return text.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
4562
4612
  }
4563
4613
  function buildOutroCardHtml(options) {
4564
4614
  const { subject, credits = [], promo, width, height } = options;
4565
4615
  const unit = Math.min(width, height);
4616
+ const markFraction = width > height ? 0.28 : 0.46;
4617
+ const markSize = unit * markFraction;
4618
+ const padTop = width > height ? unit * 0.08 : unit * 0.23;
4566
4619
  const creditRows = credits.map((credit) => {
4567
4620
  const icon = credit.iconPath ? dataUri(credit.iconPath) : null;
4568
4621
  return `<div class="credit">
@@ -4571,7 +4624,8 @@ function buildOutroCardHtml(options) {
4571
4624
  ${icon ? `<img class="cicon" src="${icon}" alt="" />` : ""}
4572
4625
  </div>`;
4573
4626
  }).join("");
4574
- const subjectIcon = subject.iconPath ? dataUri(subject.iconPath) : null;
4627
+ const animated = subject.animatedMark;
4628
+ const subjectIcon = animated ? null : subject.iconPath ? dataUri(subject.iconPath) : null;
4575
4629
  return `<!doctype html>
4576
4630
  <html><head><meta charset="utf-8" /><style>
4577
4631
  * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -4589,7 +4643,7 @@ function buildOutroCardHtml(options) {
4589
4643
  position: relative;
4590
4644
  display: flex; flex-direction: column;
4591
4645
  align-items: center; justify-content: flex-start;
4592
- padding-top: ${unit * 0.23}px;
4646
+ padding-top: ${padTop}px;
4593
4647
  gap: ${unit * 0.028}px;
4594
4648
  }
4595
4649
  /* Toolchain credits: top right, deliberately small. */
@@ -4607,7 +4661,7 @@ function buildOutroCardHtml(options) {
4607
4661
  /* The app the video is about. */
4608
4662
  /* The app's mark leads the card, and it is meant to be seen: at 46% of
4609
4663
  the short edge it reads at a glance on a phone. */
4610
- .mark { width: ${unit * 0.46}px; height: ${unit * 0.46}px; object-fit: contain; border-radius: ${unit * 0.09}px; }
4664
+ .mark { width: ${markSize}px; height: ${markSize}px; object-fit: contain; border-radius: ${unit * 0.09}px; }
4611
4665
  /* A dark mark on a dark card is invisible, which looks exactly like a
4612
4666
  failed image. Sites that publish a black-on-transparent logo are common
4613
4667
  enough to be worth catching, and a light plate is what their own site
@@ -4629,8 +4683,12 @@ function buildOutroCardHtml(options) {
4629
4683
  }
4630
4684
 
4631
4685
  /* Today's offer, along the very bottom edge. */
4686
+ /* In the flow, not pinned to the bottom edge. Pinned, it was laid out
4687
+ without reference to the text above it, so on a short canvas it simply
4688
+ covered whatever had grown into its space. margin-top pushes it clear of
4689
+ the domain instead. */
4632
4690
  .promo {
4633
- position: absolute; bottom: ${unit * 0.2}px;
4691
+ margin-top: ${unit * 0.05}px;
4634
4692
  display: flex; align-items: baseline; gap: ${unit * 0.022}px;
4635
4693
  padding: ${unit * 0.026}px ${unit * 0.055}px;
4636
4694
  border: 2px solid rgba(255, 209, 64, .85); border-radius: ${unit * 0.024}px;
@@ -4638,9 +4696,60 @@ function buildOutroCardHtml(options) {
4638
4696
  }
4639
4697
  .ppct { font-size: ${unit * 0.056}px; font-weight: 700; color: #ffd140; }
4640
4698
  .pcode { font-size: ${unit * 0.048}px; font-weight: 700; letter-spacing: .05em; }
4699
+
4700
+ /* The animated mark, sized to the same 46% slot the still one occupies so
4701
+ the rest of the card's rhythm is unchanged. Ported from VidLet's
4702
+ src/gui/css/loader.css: the amber tip flies off and snaps back into the
4703
+ cut - a jump cut, on a loop. Pure CSS, because the renderer steps these
4704
+ animations frame by frame and a JS-driven one could not be stepped.
4705
+
4706
+ No prefers-reduced-motion fallback here, unlike the original: this runs
4707
+ in a headless Chromium rendering a video, where there is no viewer whose
4708
+ preference could be read, and honouring the default would silently flatten
4709
+ the animation to a fade in CI. */
4710
+ .amark { width: ${markSize}px; height: ${markSize}px; display: block; }
4711
+ .amark path {
4712
+ /* Without fill-box the origin is the whole SVG viewport, which swings the
4713
+ tip off its own centre on the scale steps. */
4714
+ transform-box: fill-box;
4715
+ transform-origin: center;
4716
+ }
4717
+ .amark .vl-body { fill: #fff6e5; stroke: #fff6e5; animation: vl-nudge 1.4s cubic-bezier(.4,0,.2,1) infinite; }
4718
+ .amark .vl-tip { fill: #ffc53d; stroke: #ffc53d; animation: vl-cut 1.4s cubic-bezier(.4,0,.2,1) infinite; }
4719
+ .amark .vl-ghost { opacity: .35; }
4720
+ .amark .vl-ghost .vl-tip { animation-delay: -.1s; }
4721
+
4722
+ /* Translations are in viewBox units, so the motion scales with the mark. */
4723
+ @keyframes vl-cut {
4724
+ 0%, 30% { transform: translateX(0) scale(1); opacity: 1; }
4725
+ 48% { transform: translateX(62px) scale(.94); opacity: 1; }
4726
+ 60% { transform: translateX(88px) scale(.86); opacity: 0; }
4727
+ 61%, 74% { transform: translateX(-58px) scale(.86); opacity: 0; }
4728
+ 86% { transform: translateX(6px) scale(1.06); opacity: 1; }
4729
+ 100% { transform: translateX(0) scale(1); opacity: 1; }
4730
+ }
4731
+ /* The body takes the recoil, so the cut lands rather than just sliding. */
4732
+ @keyframes vl-nudge {
4733
+ 0%, 30% { transform: scaleX(1); }
4734
+ 55% { transform: scaleX(1.04); }
4735
+ 86% { transform: scaleX(.97); }
4736
+ 100% { transform: scaleX(1); }
4737
+ }
4738
+ ${/* The card normally reads its palette off the mark's pixels, and an SVG
4739
+ drawn by this stylesheet has none to read - the measuring script finds
4740
+ no <img class="mark"> and leaves the defaults, which would put VidLet's
4741
+ amber on a teal card. Stated instead of measured, last so it wins. */
4742
+ animated === "vidlet" ? ":root { --bg-in: #1f1a10; --bg-out: #07060a; --accent: #ffc53d; }" : ""}
4641
4743
  </style></head>
4642
4744
  <body>
4643
4745
  ${credits.length ? `<div class="credits">${creditRows}</div>` : ""}
4746
+ ${animated === "vidlet" ? `<svg class="amark" viewBox="0 0 512 512" aria-hidden="true">
4747
+ <g stroke-linejoin="round" stroke-width="20">
4748
+ <g class="vl-ghost"><path class="vl-tip" d="M318 246 L410 302 L318 358 Z"/></g>
4749
+ <path class="vl-body" d="M176 124 L300 200 L300 312 L176 388 Z"/>
4750
+ <path class="vl-tip" d="M318 246 L410 302 L318 358 Z"/>
4751
+ </g>
4752
+ </svg>` : ""}
4644
4753
  ${subjectIcon ? `<img class="mark" src="${subjectIcon}" alt="" />` : ""}
4645
4754
  <div class="name">${escapeHtml(subject.name)}</div>
4646
4755
  ${subject.tagline ? `<div class="tagline">${escapeHtml(subject.tagline)}</div>` : ""}
@@ -4743,10 +4852,18 @@ function buildOutroCardHtml(options) {
4743
4852
  </script>
4744
4853
  </body></html>`;
4745
4854
  }
4855
+ var ANIMATED_MARK_LOOP_SECONDS, dataUri;
4746
4856
  var init_outro_card = __esm({
4747
4857
  "src/lib/outro-card.ts"() {
4748
4858
  "use strict";
4749
4859
  init_image_kind();
4860
+ ANIMATED_MARK_LOOP_SECONDS = {
4861
+ // Matches `vl-cut` / `vl-nudge` in VidLet's src/gui/css/loader.css. Changing
4862
+ // it there without changing it here makes the encoded loop stutter at the
4863
+ // seam, because the clip is cut to exactly this length and then repeated.
4864
+ vidlet: 1.4
4865
+ };
4866
+ dataUri = imageDataUri;
4750
4867
  }
4751
4868
  });
4752
4869
 
@@ -4756,8 +4873,8 @@ __export(render_outro_exports, {
4756
4873
  renderOutroClip: () => renderOutroClip,
4757
4874
  renderOutroPng: () => renderOutroPng
4758
4875
  });
4759
- import { writeFile as writeFile8 } from "fs/promises";
4760
- import { join as join15, resolve as resolve5 } from "path";
4876
+ import { mkdir as mkdir7, rm as rm2, writeFile as writeFile8 } from "fs/promises";
4877
+ import { join as join15, resolve as resolve6 } from "path";
4761
4878
  import { pathToFileURL as pathToFileURL2 } from "url";
4762
4879
  import { chromium as chromium6 } from "playwright";
4763
4880
  async function renderOutroPng(options) {
@@ -4768,7 +4885,7 @@ async function renderOutroPng(options) {
4768
4885
  const browser = await chromium6.launch({ headless: true });
4769
4886
  try {
4770
4887
  const page = await browser.newPage({ viewport: { width, height } });
4771
- await page.goto(pathToFileURL2(resolve5(htmlPath)).href, { waitUntil: "load" });
4888
+ await page.goto(pathToFileURL2(resolve6(htmlPath)).href, { waitUntil: "load" });
4772
4889
  await page.waitForSelector('body[data-cards-ready="1"]', { timeout: 3e3 }).catch(() => page.waitForTimeout(300));
4773
4890
  await page.screenshot({ path: pngPath });
4774
4891
  } finally {
@@ -4781,11 +4898,14 @@ async function renderOutroClip(options) {
4781
4898
  const htmlPath = join15(workDir, "outro-card.html");
4782
4899
  const pngPath = join15(workDir, "outro-card.png");
4783
4900
  const clipPath = join15(workDir, "outro-card.mp4");
4901
+ if (options.subject.animatedMark) {
4902
+ return renderAnimatedOutroClip(options, { htmlPath, clipPath, seconds, fps });
4903
+ }
4784
4904
  await writeFile8(htmlPath, buildOutroCardHtml(options));
4785
4905
  const browser = await chromium6.launch({ headless: true });
4786
4906
  try {
4787
4907
  const page = await browser.newPage({ viewport: { width, height } });
4788
- await page.goto(pathToFileURL2(resolve5(htmlPath)).href, { waitUntil: "load" });
4908
+ await page.goto(pathToFileURL2(resolve6(htmlPath)).href, { waitUntil: "load" });
4789
4909
  await page.waitForSelector('body[data-cards-ready="1"]', { timeout: 3e3 }).catch(() => {
4790
4910
  });
4791
4911
  await page.waitForTimeout(300);
@@ -4823,6 +4943,70 @@ async function renderOutroClip(options) {
4823
4943
  );
4824
4944
  return clipPath;
4825
4945
  }
4946
+ async function renderAnimatedOutroClip(options, paths) {
4947
+ const { htmlPath, clipPath, seconds, fps } = paths;
4948
+ const { workDir, width, height } = options;
4949
+ const loopSeconds = ANIMATED_MARK_LOOP_SECONDS[options.subject.animatedMark];
4950
+ const frameCount = Math.max(1, Math.round(loopSeconds * fps));
4951
+ const framesDir = join15(workDir, "outro-frames");
4952
+ await rm2(framesDir, { recursive: true, force: true });
4953
+ await mkdir7(framesDir, { recursive: true });
4954
+ await writeFile8(htmlPath, buildOutroCardHtml(options));
4955
+ const browser = await chromium6.launch({ headless: true });
4956
+ try {
4957
+ const page = await browser.newPage({ viewport: { width, height } });
4958
+ await page.goto(pathToFileURL2(resolve6(htmlPath)).href, { waitUntil: "load" });
4959
+ await page.waitForSelector('body[data-cards-ready="1"]', { timeout: 3e3 }).catch(() => {
4960
+ });
4961
+ await page.waitForTimeout(300);
4962
+ await page.evaluate(() => {
4963
+ for (const animation of document.getAnimations()) animation.pause();
4964
+ });
4965
+ for (let frame = 0; frame < frameCount; frame++) {
4966
+ const elapsedMs = frame / frameCount * loopSeconds * 1e3;
4967
+ await page.evaluate((ms) => {
4968
+ for (const animation of document.getAnimations()) animation.currentTime = ms;
4969
+ }, elapsedMs);
4970
+ await page.screenshot({ path: join15(framesDir, `f-${String(frame).padStart(4, "0")}.png`) });
4971
+ }
4972
+ } finally {
4973
+ await browser.close();
4974
+ }
4975
+ await runFFmpeg(
4976
+ [
4977
+ "-y",
4978
+ // -stream_loop applies to the input that FOLLOWS it, so it has to sit
4979
+ // before -framerate/-i, not after.
4980
+ "-stream_loop",
4981
+ "-1",
4982
+ "-framerate",
4983
+ String(fps),
4984
+ "-i",
4985
+ join15(framesDir, "f-%04d.png"),
4986
+ "-f",
4987
+ "lavfi",
4988
+ "-i",
4989
+ "anullsrc=channel_layout=stereo:sample_rate=44100",
4990
+ "-t",
4991
+ String(seconds),
4992
+ "-r",
4993
+ String(fps),
4994
+ "-pix_fmt",
4995
+ "yuv420p",
4996
+ "-c:v",
4997
+ "libx264",
4998
+ "-preset",
4999
+ "veryfast",
5000
+ "-c:a",
5001
+ "aac",
5002
+ "-shortest",
5003
+ clipPath
5004
+ ],
5005
+ "ffmpeg animated outro card render failed"
5006
+ );
5007
+ await rm2(framesDir, { recursive: true, force: true });
5008
+ return clipPath;
5009
+ }
4826
5010
  var init_render_outro = __esm({
4827
5011
  "src/browser/render-outro.ts"() {
4828
5012
  "use strict";
@@ -4890,11 +5074,11 @@ __export(external_clips_exports, {
4890
5074
  findStepClips: () => findStepClips,
4891
5075
  loadExternalNarration: () => loadExternalNarration
4892
5076
  });
4893
- import { existsSync as existsSync21, mkdirSync as mkdirSync2, readdirSync as readdirSync4, rmSync as rmSync3 } from "fs";
5077
+ import { existsSync as existsSync22, mkdirSync as mkdirSync2, readdirSync as readdirSync4, rmSync as rmSync3 } from "fs";
4894
5078
  import { join as join27 } from "path";
4895
5079
  function findStepClips(dir) {
4896
5080
  const clips = /* @__PURE__ */ new Map();
4897
- if (!existsSync21(dir)) return clips;
5081
+ if (!existsSync22(dir)) return clips;
4898
5082
  for (const name of readdirSync4(dir)) {
4899
5083
  const match = /^step-0*(\d+)\.(wav|mp3|m4a|flac|ogg)$/i.exec(name);
4900
5084
  if (match?.[1]) clips.set(Number(match[1]), join27(dir, name));
@@ -5147,8 +5331,8 @@ async function fileResult(output, data, poster) {
5147
5331
  }
5148
5332
 
5149
5333
  // src/mcp/tools.ts
5150
- import { existsSync as existsSync26, mkdirSync as mkdirSync3, readdirSync as readdirSync6, readFileSync as readFileSync10, statSync as statSync4 } from "fs";
5151
- import { basename as basename5, join as join33, resolve as resolve11 } from "path";
5334
+ import { existsSync as existsSync27, mkdirSync as mkdirSync3, readdirSync as readdirSync6, readFileSync as readFileSync10, statSync as statSync4 } from "fs";
5335
+ import { basename as basename5, dirname as dirname5, join as join33, resolve as resolve12 } from "path";
5152
5336
  import { MALE_VOICES as MALE_VOICES3, WIDE_VOICES as WIDE_VOICES3 } from "@spark-apps/video-kit";
5153
5337
 
5154
5338
  // src/browser/auth-state.ts
@@ -5263,9 +5447,100 @@ init_lifecycle();
5263
5447
  import { mkdir as mkdir2 } from "fs/promises";
5264
5448
  import { chromium as chromium2 } from "playwright";
5265
5449
 
5450
+ // src/browser/brand-watermark.ts
5451
+ init_image_kind();
5452
+ function watermarkInitScript(payload) {
5453
+ return `(() => {
5454
+ var ID = '__quickpeek_brand_watermark';
5455
+ var P = ${embedJson(payload)};
5456
+ var vertical = P.position.indexOf('top') === 0 ? 'top:22px' : 'bottom:22px';
5457
+ var horizontal = P.position.indexOf('right') > -1 ? 'right:24px' : 'left:24px';
5458
+ var SHELL = [
5459
+ 'position:fixed',
5460
+ vertical,
5461
+ horizontal,
5462
+ 'z-index:2147483646',
5463
+ 'display:flex',
5464
+ 'align-items:center',
5465
+ 'gap:10px',
5466
+ 'padding:8px 16px 8px 10px',
5467
+ 'border-radius:999px',
5468
+ // A hairline and a blur rather than a solid plate: the mark sits on top of
5469
+ // whatever the app is showing, and a hard box reads as part of the UI.
5470
+ // The alpha is high enough to stay dark over a LIGHT app: at 0.55 the blur
5471
+ // averaged a pale page up into grey and the white label sat on it at about
5472
+ // 2:1, which photographs as a smudge.
5473
+ 'background:rgba(12,12,18,0.78)',
5474
+ 'border:1px solid rgba(255,255,255,0.14)',
5475
+ 'backdrop-filter:blur(14px) saturate(1.3)',
5476
+ '-webkit-backdrop-filter:blur(14px) saturate(1.3)',
5477
+ 'box-shadow:0 6px 24px rgba(0,0,0,0.30)',
5478
+ 'opacity:' + P.opacity,
5479
+ 'pointer-events:none',
5480
+ 'user-select:none'
5481
+ ].join(';');
5482
+ var ICON = 'width:26px;height:26px;border-radius:7px;object-fit:contain;display:block';
5483
+ var NAME = [
5484
+ 'font:600 15px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
5485
+ 'letter-spacing:0.015em',
5486
+ 'color:#ffffff',
5487
+ 'white-space:nowrap'
5488
+ ].join(';');
5489
+
5490
+ function mount() {
5491
+ try {
5492
+ if (!document.body || document.getElementById(ID)) return;
5493
+ var el = document.createElement('div');
5494
+ el.id = ID;
5495
+ el.setAttribute('style', SHELL);
5496
+ if (P.icon) {
5497
+ var img = document.createElement('img');
5498
+ img.setAttribute('style', ICON);
5499
+ img.src = P.icon;
5500
+ img.alt = '';
5501
+ el.appendChild(img);
5502
+ }
5503
+ var label = document.createElement('span');
5504
+ label.setAttribute('style', NAME);
5505
+ label.textContent = P.name;
5506
+ el.appendChild(label);
5507
+ document.body.appendChild(el);
5508
+ } catch (e) {
5509
+ // A page that refuses the node still records; the mark is cosmetic.
5510
+ }
5511
+ }
5512
+
5513
+ if (document.body) mount();
5514
+ else document.addEventListener('DOMContentLoaded', mount, { once: true });
5515
+ setInterval(mount, 400);
5516
+ })();`;
5517
+ }
5518
+ async function applyBrandWatermark(context, watermark) {
5519
+ if (!watermark?.name) return false;
5520
+ const payload = {
5521
+ name: watermark.name,
5522
+ icon: watermark.iconPath ? imageDataUri(watermark.iconPath) : null,
5523
+ position: watermark.position ?? "top-right",
5524
+ opacity: clampOpacity(watermark.opacity)
5525
+ };
5526
+ try {
5527
+ await context.addInitScript(watermarkInitScript(payload));
5528
+ return true;
5529
+ } catch {
5530
+ return false;
5531
+ }
5532
+ }
5533
+ function embedJson(value) {
5534
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
5535
+ }
5536
+ function clampOpacity(value) {
5537
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0.92;
5538
+ return Math.min(1, Math.max(0.35, value));
5539
+ }
5540
+
5266
5541
  // src/browser/wsl.ts
5267
5542
  import { execSync, spawn as spawn2 } from "child_process";
5268
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
5543
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
5269
5544
  import { request } from "http";
5270
5545
  import { chromium } from "playwright";
5271
5546
  function isWSL() {
@@ -5310,7 +5585,7 @@ function getWindowsHostIP() {
5310
5585
  } catch {
5311
5586
  }
5312
5587
  try {
5313
- const m = readFileSync3("/etc/resolv.conf", "utf-8").match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
5588
+ const m = readFileSync4("/etc/resolv.conf", "utf-8").match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
5314
5589
  if (m?.[1]) return m[1];
5315
5590
  } catch {
5316
5591
  }
@@ -5318,7 +5593,7 @@ function getWindowsHostIP() {
5318
5593
  }
5319
5594
  function startRelay(nodePath, windowsHost, targetPort) {
5320
5595
  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')});`;
5321
- return new Promise((resolve12, reject) => {
5596
+ return new Promise((resolve13, reject) => {
5322
5597
  const proc = spawn2(nodePath, ["-e", script], { stdio: ["ignore", "pipe", "ignore"], detached: true });
5323
5598
  const timer = setTimeout(() => reject(new Error("relay startup timed out")), 6e3);
5324
5599
  let buf = "";
@@ -5327,7 +5602,7 @@ function startRelay(nodePath, windowsHost, targetPort) {
5327
5602
  const m = buf.match(/PORT:(\d+)/);
5328
5603
  if (m) {
5329
5604
  clearTimeout(timer);
5330
- resolve12({ proc, port: +m[1] });
5605
+ resolve13({ proc, port: +m[1] });
5331
5606
  }
5332
5607
  });
5333
5608
  proc.on("error", (e) => {
@@ -5337,7 +5612,7 @@ function startRelay(nodePath, windowsHost, targetPort) {
5337
5612
  });
5338
5613
  }
5339
5614
  function waitForChrome(host, port) {
5340
- return new Promise((resolve12, reject) => {
5615
+ return new Promise((resolve13, reject) => {
5341
5616
  const deadline = Date.now() + 2e4;
5342
5617
  const poll = () => {
5343
5618
  if (Date.now() > deadline) {
@@ -5346,7 +5621,7 @@ function waitForChrome(host, port) {
5346
5621
  }
5347
5622
  const req = request({ host, port, path: "/json/version", method: "GET" }, (res) => {
5348
5623
  res.resume();
5349
- resolve12();
5624
+ resolve13();
5350
5625
  });
5351
5626
  req.on("error", () => setTimeout(poll, 600));
5352
5627
  req.setTimeout(1e3, () => {
@@ -5489,6 +5764,7 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
5489
5764
  });
5490
5765
  }
5491
5766
  await hideDevOverlays(context);
5767
+ await applyBrandWatermark(context, config.video.watermark);
5492
5768
  const session = { context, browser, chromeSession };
5493
5769
  session.release = onCleanup(() => closeBrowserSession(session));
5494
5770
  return session;
@@ -5716,7 +5992,7 @@ async function importBrowserSession(url) {
5716
5992
  }
5717
5993
 
5718
5994
  // src/commands/edit-cmd.ts
5719
- import { existsSync as existsSync9, readdirSync, readFileSync as readFileSync4 } from "fs";
5995
+ import { existsSync as existsSync10, readdirSync, readFileSync as readFileSync5 } from "fs";
5720
5996
  import { join as join14 } from "path";
5721
5997
  import chalk7 from "chalk";
5722
5998
 
@@ -5791,8 +6067,8 @@ function editorBaseUrl() {
5791
6067
  function openInBrowser(url) {
5792
6068
  const isWSL2 = process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME);
5793
6069
  const command = isWSL2 ? `cmd.exe /c start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
5794
- return new Promise((resolve12) => {
5795
- exec2(command, () => resolve12());
6070
+ return new Promise((resolve13) => {
6071
+ exec2(command, () => resolve13());
5796
6072
  });
5797
6073
  }
5798
6074
 
@@ -5951,8 +6227,72 @@ function planEditorUrl(baseUrl, plan) {
5951
6227
  return url.length <= MAX_HANDOFF_URL_CHARS ? url : null;
5952
6228
  }
5953
6229
 
6230
+ // src/lib/plan-thumbs.ts
6231
+ init_spawn();
6232
+ import { existsSync as existsSync9 } from "fs";
6233
+ import { isAbsolute as isAbsolute2, resolve as resolve4 } from "path";
6234
+ var MEDIA_ACTIONS = ["image", "media", "video"];
6235
+ var THUMB_WIDTH = 160;
6236
+ var MAX_THUMB_CHARS = 1e4;
6237
+ var MAX_TOTAL_THUMB_CHARS = 16e3;
6238
+ var THUMB_TIMEOUT_MS = 3e3;
6239
+ function thumbnail(path) {
6240
+ return new Promise((done) => {
6241
+ const proc = spawnFFmpeg("ffmpeg", [
6242
+ "-v",
6243
+ "error",
6244
+ "-i",
6245
+ path,
6246
+ "-frames:v",
6247
+ "1",
6248
+ "-vf",
6249
+ `scale=${THUMB_WIDTH}:-2`,
6250
+ "-q:v",
6251
+ "14",
6252
+ "-f",
6253
+ "mjpeg",
6254
+ "-"
6255
+ ]);
6256
+ const chunks = [];
6257
+ const timer = setTimeout(() => {
6258
+ proc.kill();
6259
+ done(null);
6260
+ }, THUMB_TIMEOUT_MS);
6261
+ const finish = (result) => {
6262
+ clearTimeout(timer);
6263
+ done(result);
6264
+ };
6265
+ proc.stdout.on("data", (chunk) => chunks.push(chunk));
6266
+ proc.on("error", () => finish(null));
6267
+ proc.on("close", (code) => {
6268
+ const bytes = Buffer.concat(chunks);
6269
+ if (code !== 0 || bytes.length === 0) return finish(null);
6270
+ const base64 = bytes.toString("base64");
6271
+ finish(base64.length > MAX_THUMB_CHARS ? null : `data:image/jpeg;base64,${base64}`);
6272
+ });
6273
+ });
6274
+ }
6275
+ async function withEditorThumbs(plan, baseDir) {
6276
+ const steps = [...plan.steps];
6277
+ let budget = MAX_TOTAL_THUMB_CHARS;
6278
+ for (const [index, step] of steps.entries()) {
6279
+ if (!step.value || !MEDIA_ACTIONS.includes(step.action)) continue;
6280
+ const path = isAbsolute2(step.value) ? step.value : resolve4(baseDir, step.value);
6281
+ if (!existsSync9(path)) continue;
6282
+ const thumb = await thumbnail(path);
6283
+ if (!thumb || thumb.length > budget) continue;
6284
+ budget -= thumb.length;
6285
+ steps[index] = { ...step, thumb };
6286
+ }
6287
+ return { ...plan, steps };
6288
+ }
6289
+ async function editorUrlWithThumbs(baseUrl, plan, baseDir) {
6290
+ const withThumbs = await withEditorThumbs(plan, baseDir);
6291
+ return planEditorUrl(baseUrl, withThumbs) ?? planEditorUrl(baseUrl, plan);
6292
+ }
6293
+
5954
6294
  // src/utils/ui.ts
5955
- import { resolve as resolve4 } from "path";
6295
+ import { resolve as resolve5 } from "path";
5956
6296
  import { createInterface } from "readline";
5957
6297
  import chalk2 from "chalk";
5958
6298
  import ora from "ora";
@@ -6019,7 +6359,7 @@ function waitForEnter(prompt2 = `${INDENT}Press Enter when done...`) {
6019
6359
  )
6020
6360
  );
6021
6361
  }
6022
- return new Promise((resolve12, reject) => {
6362
+ return new Promise((resolve13, reject) => {
6023
6363
  const rl = createInterface({ input: process.stdin, output: process.stdout });
6024
6364
  let done = false;
6025
6365
  rl.on("close", () => {
@@ -6030,13 +6370,13 @@ function waitForEnter(prompt2 = `${INDENT}Press Enter when done...`) {
6030
6370
  rl.question(prompt2, () => {
6031
6371
  done = true;
6032
6372
  rl.close();
6033
- resolve12();
6373
+ resolve13();
6034
6374
  });
6035
6375
  });
6036
6376
  }
6037
6377
  var _isWSL = process.platform === "linux" && process.env.WSL_DISTRO_NAME;
6038
6378
  function formatClickablePath(filePath) {
6039
- const absolutePath = resolve4(filePath);
6379
+ const absolutePath = resolve5(filePath);
6040
6380
  if (_isWSL || process.platform !== "win32") {
6041
6381
  return absolutePath;
6042
6382
  }
@@ -6077,13 +6417,13 @@ import chalk3 from "chalk";
6077
6417
  init_SparkStripe_feature_gating();
6078
6418
  var TIMEOUT_MS = 12e4;
6079
6419
  async function browserLogin() {
6080
- return new Promise((resolve12) => {
6420
+ return new Promise((resolve13) => {
6081
6421
  let server;
6082
6422
  let timer;
6083
6423
  const cleanup = (email) => {
6084
6424
  clearTimeout(timer);
6085
6425
  server?.close();
6086
- resolve12(email);
6426
+ resolve13(email);
6087
6427
  };
6088
6428
  server = createServer((req, res) => {
6089
6429
  const url = new URL2(req.url || "/", `http://localhost`);
@@ -6234,10 +6574,10 @@ function getLastRateLimitInfo() {
6234
6574
  }
6235
6575
  function prompt(question) {
6236
6576
  const rl = createInterface2({ input: process.stdin, output: process.stdout });
6237
- return new Promise((resolve12) => {
6577
+ return new Promise((resolve13) => {
6238
6578
  rl.question(question, (answer) => {
6239
6579
  rl.close();
6240
- resolve12(answer.trim());
6580
+ resolve13(answer.trim());
6241
6581
  });
6242
6582
  });
6243
6583
  }
@@ -6246,8 +6586,10 @@ async function setupUserEmail() {
6246
6586
  console.log(chalk4.yellow(" \u2709 Account Setup"));
6247
6587
  console.log(chalk4.dim(" \u2500".repeat(20)));
6248
6588
  console.log();
6249
- console.log(chalk4.white(" Sign in with your Google account to track usage"));
6250
- console.log(chalk4.white(" and unlock Pro features."));
6589
+ console.log(chalk4.white(" The AI that plans your demo runs on our servers, so it"));
6590
+ console.log(chalk4.white(" needs an address to meter. No card, and no verification"));
6591
+ console.log(chalk4.white(" needed for your first 5 videos."));
6592
+ console.log(chalk4.dim(" After that, signing in gets you 10 videos a day."));
6251
6593
  const browserEmail = await browserLogin();
6252
6594
  if (browserEmail) {
6253
6595
  console.log();
@@ -6256,11 +6598,15 @@ async function setupUserEmail() {
6256
6598
  return browserEmail;
6257
6599
  }
6258
6600
  console.log();
6259
- console.log(chalk4.dim(" Or enter your email manually:"));
6601
+ console.log(chalk4.dim(" Or enter your email (Enter to skip):"));
6260
6602
  const email = await prompt(chalk4.white(" Email: "));
6261
6603
  if (!email) {
6262
6604
  console.log();
6263
- console.log(chalk4.red(" No email provided. Exiting."));
6605
+ console.log(chalk4.yellow(" No email given, so the AI planner cannot be reached."));
6606
+ console.log(chalk4.dim(" Nothing has been recorded. Run qp again when you are ready."));
6607
+ console.log(chalk4.dim(" To record with no account at all, use the MCP server:"));
6608
+ console.log(chalk4.dim(" https://quickpeek.co/mcp"));
6609
+ console.log();
6264
6610
  process.exit(1);
6265
6611
  }
6266
6612
  if (!email.includes("@") || !email.includes(".")) {
@@ -6268,6 +6614,9 @@ async function setupUserEmail() {
6268
6614
  console.log(chalk4.yellow(" \u26A0 That doesn't look like a valid email address."));
6269
6615
  const proceed = await prompt(chalk4.white(" Continue anyway? ") + chalk4.dim("[y/N] "));
6270
6616
  if (proceed.toLowerCase() !== "y") {
6617
+ console.log();
6618
+ console.log(chalk4.dim(" Nothing has been recorded. Run qp again when you are ready."));
6619
+ console.log();
6271
6620
  process.exit(1);
6272
6621
  }
6273
6622
  }
@@ -6394,6 +6743,17 @@ async function parseAIJsonWithRepair(content, opts) {
6394
6743
  init_mcp_context();
6395
6744
  init_lib();
6396
6745
  init_json_repair();
6746
+ var WRITTEN_ADDRESS = /\b(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?/gi;
6747
+ var SPELLED_OUT_ACRONYM = /\b[A-Z](?:\s+[A-Z])+\b/g;
6748
+ function spokenCaption(caption) {
6749
+ const held = [];
6750
+ const masked = caption.replace(SPELLED_OUT_ACRONYM, (run2) => run2.replace(/\s+/g, "")).replace(WRITTEN_ADDRESS, (address) => {
6751
+ held.push(address);
6752
+ return `qpaddr${held.length - 1}qp`;
6753
+ });
6754
+ const shaped = toTitleCase(toSpokenForm2(masked));
6755
+ return shaped.replace(/qpaddr(\d+)qp/gi, (_match, index) => held[Number(index)] ?? "");
6756
+ }
6397
6757
  async function crawlExtension(url, extensionPath, workDir, task2) {
6398
6758
  const browserTask = task2("Launching browser with extension");
6399
6759
  const userDataDir = join12(workDir, ".browser-data");
@@ -6522,8 +6882,18 @@ async function crawlAndAnalyze(url, lang, description, maxSteps, task2, logError
6522
6882
  // The spoken form is what the voice will actually say, so it is what
6523
6883
  // the caption shows: burning the written form instead left the words on
6524
6884
  // screen out of step with the words in the ear wherever the two differ
6525
- // (a domain read with its dot, a number read as words).
6526
- caption: s.caption ? toTitleCase(toSpokenForm2(s.caption)) : s.caption
6885
+ // (a number read as words).
6886
+ //
6887
+ // Addresses are the exception. Expanding one here turned "quickpeek.co"
6888
+ // into three caption words, "Quickpeek Dot Co", which the line packer
6889
+ // is then free to break across lines: the call to action arrived on
6890
+ // screen as "DOT C O" with the name on the row above. An address is the
6891
+ // one thing in a demo a viewer has to be able to read and type, so it
6892
+ // is held back verbatim from both passes and rejoins as written. The
6893
+ // voice is unaffected: generate.ts derives its own spoken form from
6894
+ // this caption at synthesis time, and word timings fall back to an even
6895
+ // spread across the speech envelope when the counts no longer line up.
6896
+ caption: s.caption ? spokenCaption(s.caption) : s.caption
6527
6897
  }));
6528
6898
  aiTask.succeed(`Plan: ${plan.steps.length} steps`);
6529
6899
  return plan;
@@ -6701,9 +7071,9 @@ var PLAN_FILE = /^plan-([a-z]{2}(?:-[A-Z]{2})?)\.json$/;
6701
7071
  function findPlanFile(dir, lang) {
6702
7072
  if (lang) {
6703
7073
  const exact = join14(dir, `plan-${lang}.json`);
6704
- return existsSync9(exact) ? exact : null;
7074
+ return existsSync10(exact) ? exact : null;
6705
7075
  }
6706
- if (!existsSync9(dir)) return null;
7076
+ if (!existsSync10(dir)) return null;
6707
7077
  const plans = readdirSync(dir).filter((f) => PLAN_FILE.test(f));
6708
7078
  const preferred = plans.find((f) => f === "plan-en.json") ?? plans[0];
6709
7079
  return preferred ? join14(dir, preferred) : null;
@@ -6711,10 +7081,35 @@ function findPlanFile(dir, lang) {
6711
7081
 
6712
7082
  // src/commands/record-cmd.ts
6713
7083
  init_mcp_context();
6714
- import { existsSync as existsSync25 } from "fs";
6715
- import { mkdir as mkdir9, readFile as readFile12 } from "fs/promises";
6716
- import { basename as basename4, isAbsolute as isAbsolute6, join as join31, resolve as resolve10 } from "path";
7084
+ import { existsSync as existsSync26 } from "fs";
7085
+ import { mkdir as mkdir10, readFile as readFile12 } from "fs/promises";
7086
+ import { basename as basename4, isAbsolute as isAbsolute7, join as join31, resolve as resolve11 } from "path";
6717
7087
  import chalk17 from "chalk";
7088
+
7089
+ // src/lib/demo-registry.ts
7090
+ init_api_client();
7091
+ var TIMEOUT_MS2 = 4e3;
7092
+ async function registerDemoRun(email, run2) {
7093
+ if (!email || email === "anonymous") return false;
7094
+ const controller = new AbortController();
7095
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS2);
7096
+ try {
7097
+ const token = await getOrRefreshRelayToken(email);
7098
+ const response = await fetch(`${RELAY_BASE}/api/cli/demos`, {
7099
+ method: "POST",
7100
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
7101
+ body: JSON.stringify(run2),
7102
+ signal: controller.signal
7103
+ });
7104
+ return response.ok;
7105
+ } catch {
7106
+ return false;
7107
+ } finally {
7108
+ clearTimeout(timer);
7109
+ }
7110
+ }
7111
+
7112
+ // src/commands/record-cmd.ts
6718
7113
  init_lib();
6719
7114
  init_config_loader();
6720
7115
  init_run_root();
@@ -6734,7 +7129,7 @@ async function handleRegistrationGate() {
6734
7129
  console.log(chalk8.white(" Sign in to continue creating videos."));
6735
7130
  console.log();
6736
7131
  console.log(chalk8.dim(" Benefits of signing in:"));
6737
- console.log(chalk8.dim(" \u2022 20 more videos per month"));
7132
+ console.log(chalk8.dim(" \u2022 10 videos a day instead of 5 in total"));
6738
7133
  console.log(chalk8.dim(" \u2022 Keep your video history"));
6739
7134
  console.log(chalk8.dim(" \u2022 Upgrade to Pro anytime"));
6740
7135
  let email = await browserLogin();
@@ -6785,9 +7180,12 @@ async function runAuthPhase(input) {
6785
7180
  const { isVerified: isVerified2 } = input;
6786
7181
  const mcpTool = currentMcpTool();
6787
7182
  if (mcpTool && !(userEmail && isVerified2)) {
6788
- throw new Error(
6789
- `"${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.`
6790
- );
7183
+ if (localVideoCount >= FREE_VIDEO_LIMIT) {
7184
+ throw new Error(
7185
+ `"${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.`
7186
+ );
7187
+ }
7188
+ return { userEmail: "anonymous", userTier: "free", localVideoCount, isVerified: false };
6791
7189
  }
6792
7190
  if (userEmail && isVerified2) {
6793
7191
  const tierTask = task("Checking subscription");
@@ -6878,7 +7276,7 @@ async function runAuthPhase(input) {
6878
7276
  const remaining2 = FREE_VIDEO_LIMIT - localVideoCount;
6879
7277
  console.log();
6880
7278
  console.log(chalk9.cyan(` \u{1F3AC} Video ${localVideoCount + 1}/${FREE_VIDEO_LIMIT} free`));
6881
- console.log(chalk9.dim(` ${remaining2 - 1} more after this \u2022 Sign up for unlimited`));
7279
+ console.log(chalk9.dim(` ${remaining2 - 1} more after this \u2022 Sign up for 10 a day`));
6882
7280
  userEmail = await setupUserEmail();
6883
7281
  let registeredVerified = false;
6884
7282
  try {
@@ -6897,7 +7295,7 @@ async function runAuthPhase(input) {
6897
7295
  }
6898
7296
 
6899
7297
  // src/commands/phases/plan-phase.ts
6900
- import { existsSync as existsSync20, readdirSync as readdirSync3 } from "fs";
7298
+ import { existsSync as existsSync21, readdirSync as readdirSync3 } from "fs";
6901
7299
  import { readFile as readFile9 } from "fs/promises";
6902
7300
  import { basename as basename3, join as join26 } from "path";
6903
7301
  import chalk13 from "chalk";
@@ -7059,13 +7457,13 @@ init_config_loader();
7059
7457
 
7060
7458
  // src/commands/phases/plan-cards.ts
7061
7459
  init_render_outro();
7062
- import { existsSync as existsSync19 } from "fs";
7063
- import { isAbsolute as isAbsolute5, join as join25 } from "path";
7460
+ import { existsSync as existsSync20 } from "fs";
7461
+ import { isAbsolute as isAbsolute6, join as join25 } from "path";
7064
7462
  import chalk12 from "chalk";
7065
7463
 
7066
7464
  // src/lib/og-card.ts
7067
7465
  init_image_kind();
7068
- import { existsSync as existsSync10, readFileSync as readFileSync6 } from "fs";
7466
+ import { existsSync as existsSync11, readFileSync as readFileSync6 } from "fs";
7069
7467
  import { writeFile as writeFile9 } from "fs/promises";
7070
7468
  import { join as join16 } from "path";
7071
7469
  var PORTRAIT_PATHS = ["/opengraph-image/portrait", "/opengraph-image-portrait", "/og/portrait"];
@@ -7235,7 +7633,7 @@ function existingIcon(dir) {
7235
7633
  for (const ext of ["png", "svg", "jpg", "webp", "gif"]) {
7236
7634
  const path = join16(dir, `app-icon.${ext}`);
7237
7635
  try {
7238
- if (!existsSync10(path)) continue;
7636
+ if (!existsSync11(path)) continue;
7239
7637
  if (!sniffImageKind(readFileSync6(path))) continue;
7240
7638
  return path;
7241
7639
  } catch {
@@ -7279,9 +7677,9 @@ function jpegSize(buf) {
7279
7677
  }
7280
7678
 
7281
7679
  // src/commands/phases/video-phase.ts
7282
- import { existsSync as existsSync18, statSync as statSync3 } from "fs";
7680
+ import { existsSync as existsSync19, statSync as statSync3 } from "fs";
7283
7681
  import { copyFile as copyFile3 } from "fs/promises";
7284
- import { join as join24, resolve as resolve9 } from "path";
7682
+ import { join as join24, resolve as resolve10 } from "path";
7285
7683
 
7286
7684
  // src/browser/signed-in.ts
7287
7685
  function pathDepth(raw) {
@@ -7387,7 +7785,7 @@ async function hasHeadroom() {
7387
7785
  async function withRenderSlot(render, onWait) {
7388
7786
  if (active >= MAX_CONCURRENT_RENDERS) {
7389
7787
  onWait?.(`waiting for a render slot (${active}/${MAX_CONCURRENT_RENDERS} busy)`);
7390
- await new Promise((resolve12) => waiting.push(resolve12));
7788
+ await new Promise((resolve13) => waiting.push(resolve13));
7391
7789
  }
7392
7790
  active++;
7393
7791
  try {
@@ -7416,20 +7814,20 @@ init_lib();
7416
7814
  init_helpers();
7417
7815
 
7418
7816
  // src/record.ts
7419
- import { existsSync as existsSync15 } from "fs";
7420
- import { mkdir as mkdir7 } from "fs/promises";
7817
+ import { existsSync as existsSync16 } from "fs";
7818
+ import { mkdir as mkdir8, rm as rm3 } from "fs/promises";
7421
7819
  import { join as join21 } from "path";
7422
7820
  import chalk10 from "chalk";
7423
7821
 
7424
7822
  // src/browser/actions.ts
7425
7823
  init_lib();
7426
- import { existsSync as existsSync12 } from "fs";
7824
+ import { existsSync as existsSync13 } from "fs";
7427
7825
  import { readFile as readFile7 } from "fs/promises";
7428
- import { isAbsolute as isAbsolute2, resolve as resolve6 } from "path";
7826
+ import { isAbsolute as isAbsolute3, resolve as resolve7 } from "path";
7429
7827
 
7430
7828
  // src/browser/effects.ts
7431
7829
  init_lib();
7432
- import { existsSync as existsSync11, readFileSync as readFileSync7 } from "fs";
7830
+ import { existsSync as existsSync12, readFileSync as readFileSync7 } from "fs";
7433
7831
  import { join as join17 } from "path";
7434
7832
 
7435
7833
  // src/browser/page-eval.ts
@@ -7449,7 +7847,7 @@ function loadHighlightCSS() {
7449
7847
  // src/browser/ → src/
7450
7848
  ];
7451
7849
  for (const p of candidates) {
7452
- if (existsSync11(p)) return readFileSync7(p, "utf-8");
7850
+ if (existsSync12(p)) return readFileSync7(p, "utf-8");
7453
7851
  }
7454
7852
  return "";
7455
7853
  }
@@ -7662,6 +8060,7 @@ function locatorFor(page, keyword) {
7662
8060
  var WAIT_MS = 450;
7663
8061
  var HANDLE_MS = 3e3;
7664
8062
  var RESOLVE_BUDGET_MS2 = 2e4;
8063
+ var DOM_POLL_MS = 150;
7665
8064
  function remaining(deadline) {
7666
8065
  return Math.max(1, deadline - Date.now());
7667
8066
  }
@@ -7772,7 +8171,7 @@ async function resolveElementSettled(page, target, deadlineMs = RESOLVE_DEADLINE
7772
8171
  }
7773
8172
  }
7774
8173
  async function resolvePlainText(page, text, deadline) {
7775
- const scanned = await domTextMatch(page, text);
8174
+ let scanned = await domTextMatch(page, text);
7776
8175
  const stages = plainTextStages(page, text);
7777
8176
  while (Date.now() < deadline) {
7778
8177
  for (const { locator, state } of stages) {
@@ -7783,6 +8182,12 @@ async function resolvePlainText(page, text, deadline) {
7783
8182
  }
7784
8183
  }
7785
8184
  if (scanned) return scanned;
8185
+ while (Date.now() < deadline) {
8186
+ await page.waitForTimeout(Math.min(DOM_POLL_MS, remaining(deadline)));
8187
+ scanned = await domTextMatch(page, text);
8188
+ if (scanned) break;
8189
+ }
8190
+ if (!scanned) return null;
7786
8191
  }
7787
8192
  return scanned ?? null;
7788
8193
  }
@@ -8207,8 +8612,8 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
8207
8612
  const isPlaceholder = !rawPath || rawPath.startsWith("[") || rawPath.includes("example");
8208
8613
  if (!isPlaceholder) {
8209
8614
  const normalizedPath = rawPath.startsWith("mnt/") ? `/${rawPath}` : rawPath;
8210
- const filePath = isAbsolute2(normalizedPath) ? normalizedPath : resolve6(baseDir, normalizedPath);
8211
- if (existsSync12(filePath)) {
8615
+ const filePath = isAbsolute3(normalizedPath) ? normalizedPath : resolve7(baseDir, normalizedPath);
8616
+ if (existsSync13(filePath)) {
8212
8617
  await fileInput.setInputFiles(filePath);
8213
8618
  await page.waitForTimeout(2e3);
8214
8619
  break;
@@ -8324,8 +8729,8 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
8324
8729
  case "wait":
8325
8730
  break;
8326
8731
  case "image": {
8327
- const imagePath = step.value ? isAbsolute2(step.value) ? step.value : resolve6(baseDir, step.value) : null;
8328
- if (imagePath && existsSync12(imagePath)) {
8732
+ const imagePath = step.value ? isAbsolute3(step.value) ? step.value : resolve7(baseDir, step.value) : null;
8733
+ if (imagePath && existsSync13(imagePath)) {
8329
8734
  const imgStyle = step.target === "stretch" ? "width: 100vw; height: 100vh; object-fit: cover;" : "max-width: 90%; max-height: 90%;";
8330
8735
  if (await showImagePage(page, imagePath, imgStyle, config, cursorInjected, false)) injected = true;
8331
8736
  }
@@ -8354,7 +8759,7 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
8354
8759
  await pendingDownload;
8355
8760
  pendingDownload = null;
8356
8761
  }
8357
- if (lastDownloadPath && existsSync12(lastDownloadPath)) {
8762
+ if (lastDownloadPath && existsSync13(lastDownloadPath)) {
8358
8763
  const imgStyle = step.target === "stretch" ? "width: 100%; height: 100%; object-fit: contain;" : "max-width: 90%; max-height: 90%; object-fit: contain;";
8359
8764
  if (await showImagePage(page, lastDownloadPath, imgStyle, config, cursorInjected)) injected = true;
8360
8765
  }
@@ -8462,13 +8867,13 @@ async function getPageElementSelectors(page, query, limit, keyword = "") {
8462
8867
  // src/browser/step-validator.ts
8463
8868
  init_crawl();
8464
8869
  init_utils();
8465
- import { existsSync as existsSync13, readdirSync as readdirSync2 } from "fs";
8466
- import { basename as basename2, isAbsolute as isAbsolute3, join as join18, resolve as resolve7 } from "path";
8870
+ import { existsSync as existsSync14, readdirSync as readdirSync2 } from "fs";
8871
+ import { basename as basename2, isAbsolute as isAbsolute4, join as join18, resolve as resolve8 } from "path";
8467
8872
  function findMediaFile(filename, baseDir) {
8468
8873
  const name = basename2(filename);
8469
8874
  const searchDirs = [baseDir, join18(baseDir, "res"), join18(baseDir, "assets"), join18(baseDir, "media")];
8470
8875
  for (const dir of searchDirs) {
8471
- if (!existsSync13(dir)) continue;
8876
+ if (!existsSync14(dir)) continue;
8472
8877
  try {
8473
8878
  const files = readdirSync2(dir);
8474
8879
  if (files.includes(name)) return join18(dir, name);
@@ -8521,8 +8926,8 @@ function resolveMediaStep(step, baseDir, label, startTime) {
8521
8926
  if (!step.value) {
8522
8927
  return { success: false, error: `Missing ${label} path`, executionTimeMs: Date.now() - startTime };
8523
8928
  }
8524
- const specifiedPath = isAbsolute3(step.value) ? step.value : resolve7(baseDir, step.value);
8525
- if (existsSync13(specifiedPath)) return { success: true, executionTimeMs: Date.now() - startTime };
8929
+ const specifiedPath = isAbsolute4(step.value) ? step.value : resolve8(baseDir, step.value);
8930
+ if (existsSync14(specifiedPath)) return { success: true, executionTimeMs: Date.now() - startTime };
8526
8931
  const foundPath = findMediaFile(step.value, baseDir);
8527
8932
  if (foundPath) {
8528
8933
  step.value = `./${foundPath.replace(`${baseDir}/`, "").replace(`${baseDir}\\`, "")}`;
@@ -8650,8 +9055,8 @@ async function validateAndExecuteStep(page, step, plan, baseDir) {
8650
9055
  const isPlaceholder = !rawPath || rawPath.startsWith("[") || rawPath.includes("example");
8651
9056
  if (!isPlaceholder) {
8652
9057
  const normalizedPath = rawPath.startsWith("mnt/") ? `/${rawPath}` : rawPath;
8653
- let filePath = isAbsolute3(normalizedPath) ? normalizedPath : resolve7(baseDir, normalizedPath);
8654
- if (!existsSync13(filePath)) {
9058
+ let filePath = isAbsolute4(normalizedPath) ? normalizedPath : resolve8(baseDir, normalizedPath);
9059
+ if (!existsSync14(filePath)) {
8655
9060
  const foundPath = findMediaFile(rawPath, baseDir);
8656
9061
  if (foundPath) {
8657
9062
  const relativePath = `./${foundPath.replace(`${baseDir}/`, "").replace(`${baseDir}\\`, "")}`;
@@ -8793,7 +9198,7 @@ async function validateStepsOnPage(plan, config, logError2, baseDir, onStepStart
8793
9198
  }
8794
9199
 
8795
9200
  // src/browser/meta.ts
8796
- import { existsSync as existsSync14 } from "fs";
9201
+ import { existsSync as existsSync15 } from "fs";
8797
9202
  import { readFile as readFile8, writeFile as writeFile10 } from "fs/promises";
8798
9203
  import { join as join20 } from "path";
8799
9204
  var RECORDING_FILE = "recording.mp4";
@@ -8803,7 +9208,7 @@ function getRecordingPath(outputDir) {
8803
9208
  }
8804
9209
  async function loadRecordingMeta(outputDir) {
8805
9210
  const metaPath = join20(outputDir, RECORDING_META_FILE);
8806
- if (!existsSync14(metaPath)) return null;
9211
+ if (!existsSync15(metaPath)) return null;
8807
9212
  try {
8808
9213
  const content = await readFile8(metaPath, "utf-8");
8809
9214
  return JSON.parse(content);
@@ -8910,7 +9315,7 @@ async function leavesApp(el, step, plannedUrl) {
8910
9315
  }
8911
9316
  async function recordSession(plan, config, stepDurations, workDir, baseDir, task2, logError2, opts = {}) {
8912
9317
  const videoDir = join21(workDir, "video");
8913
- await mkdir7(videoDir, { recursive: true });
9318
+ await mkdir8(videoDir, { recursive: true });
8914
9319
  const getStepDuration = (stepNum) => {
8915
9320
  return stepDurations[stepNum - 1] ?? config.timing.minStepDuration;
8916
9321
  };
@@ -8921,11 +9326,33 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
8921
9326
  const authState = findAuthState(plan.url);
8922
9327
  const { context, browser, chromeSession } = await launchBrowserContext(config, userDataDir, recordVideo, authState);
8923
9328
  try {
9329
+ const isExtensionPage = plan.url.startsWith("chrome-extension://");
9330
+ if (!isExtensionPage) {
9331
+ const warm = await context.newPage();
9332
+ const warmVideo = warm.video();
9333
+ const warmUrls = [plan.url];
9334
+ for (const step of plan.steps) {
9335
+ if (step.action !== "navigate" || !step.target) continue;
9336
+ if (!warmUrls.includes(step.target)) warmUrls.push(step.target);
9337
+ }
9338
+ try {
9339
+ for (const url of warmUrls) {
9340
+ await gotoSettled(warm, url).catch(() => {
9341
+ });
9342
+ }
9343
+ } catch {
9344
+ } finally {
9345
+ await warm.close().catch(() => {
9346
+ });
9347
+ const stray = await warmVideo?.path().catch(() => void 0);
9348
+ if (stray) await rm3(stray, { force: true }).catch(() => {
9349
+ });
9350
+ }
9351
+ }
8924
9352
  const page = context.pages()[0] || await context.newPage();
8925
9353
  setupDownloadTracking(page);
8926
9354
  context.on("page", (p) => setupDownloadTracking(p));
8927
9355
  const firstStepIsMedia = ["media", "image", "video"].includes(plan.steps[0]?.action ?? "");
8928
- const isExtensionPage = plan.url.startsWith("chrome-extension://");
8929
9356
  let signInChecked = false;
8930
9357
  const guardSignedIn = async () => {
8931
9358
  if (signInChecked || opts.allowLoggedOut || isExtensionPage) return;
@@ -9002,7 +9429,7 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
9002
9429
  if (!targetEl) {
9003
9430
  console.log(
9004
9431
  `
9005
- \u26A0 target "${step.target}" not found on ${page.url()} \u2014 step ${stepNum} recorded without the action`
9432
+ \u26A0 target "${step.target}" not found on ${page.url()}; step ${stepNum} recorded without the action`
9006
9433
  );
9007
9434
  }
9008
9435
  }
@@ -9141,7 +9568,7 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
9141
9568
  if (!pageClosedEarly) await page.close();
9142
9569
  const videoPath = await video.path();
9143
9570
  await cleanupBrowserSession({ context, browser, chromeSession });
9144
- if (!videoPath || !existsSync15(videoPath)) {
9571
+ if (!videoPath || !existsSync16(videoPath)) {
9145
9572
  throw new Error("Video file was not created");
9146
9573
  }
9147
9574
  saveTask.succeed("Video saved");
@@ -9156,20 +9583,20 @@ init_run_root();
9156
9583
 
9157
9584
  // src/utils/video-pipeline.ts
9158
9585
  init_lib();
9159
- import { existsSync as existsSync17 } from "fs";
9160
- import { mkdir as mkdir8, writeFile as writeFile11 } from "fs/promises";
9161
- import { isAbsolute as isAbsolute4, join as join23, resolve as resolve8 } from "path";
9586
+ import { existsSync as existsSync18 } from "fs";
9587
+ import { mkdir as mkdir9, writeFile as writeFile11 } from "fs/promises";
9588
+ import { isAbsolute as isAbsolute5, join as join23, resolve as resolve9 } from "path";
9162
9589
  import chalk11 from "chalk";
9163
9590
 
9164
9591
  // src/utils/music.ts
9165
- import { existsSync as existsSync16, readFileSync as readFileSync8 } from "fs";
9592
+ import { existsSync as existsSync17, readFileSync as readFileSync8 } from "fs";
9166
9593
  import { dirname as dirname4, join as join22 } from "path";
9167
- var DEFAULT_MOOD = "calm";
9594
+ var DEFAULT_MOOD = "corporate";
9168
9595
  function musicDir() {
9169
9596
  let dir = getScriptDir();
9170
9597
  for (let i = 0; i < 6; i++) {
9171
9598
  const candidate = join22(dir, "assets", "music");
9172
- if (existsSync16(join22(candidate, "manifest.json"))) return candidate;
9599
+ if (existsSync17(join22(candidate, "manifest.json"))) return candidate;
9173
9600
  const parent = dirname4(dir);
9174
9601
  if (parent === dir) break;
9175
9602
  dir = parent;
@@ -9182,7 +9609,7 @@ function listBundledMusic() {
9182
9609
  try {
9183
9610
  const parsed = JSON.parse(readFileSync8(join22(dir, "manifest.json"), "utf8"));
9184
9611
  const tracks = Array.isArray(parsed.tracks) ? parsed.tracks : [];
9185
- return tracks.filter((t) => typeof t?.file === "string").map((t) => ({ ...t, path: join22(dir, t.file) })).filter((t) => existsSync16(t.path));
9612
+ return tracks.filter((t) => typeof t?.file === "string").map((t) => ({ ...t, path: join22(dir, t.file) })).filter((t) => existsSync17(t.path));
9186
9613
  } catch {
9187
9614
  return [];
9188
9615
  }
@@ -9196,6 +9623,16 @@ function resolveBundledBed(mood = DEFAULT_MOOD) {
9196
9623
  // src/utils/video-pipeline.ts
9197
9624
  init_run_root();
9198
9625
  var INDENT3 = " ";
9626
+ function captionWordTimings(caption, spoken, duration) {
9627
+ const captionWords = caption.split(/\s+/).filter(Boolean);
9628
+ if (spoken.length === 0 || captionWords.length === 0) {
9629
+ return estimateWordTimings(caption, duration);
9630
+ }
9631
+ if (spoken.length === captionWords.length) {
9632
+ return spoken.map((w, i) => ({ ...w, word: captionWords[i] ?? w.word }));
9633
+ }
9634
+ return estimateWordTimings(caption, duration);
9635
+ }
9199
9636
  async function generateASS(plan, stepAudio, workDir, config, captions) {
9200
9637
  const cues = [];
9201
9638
  let currentTime = VOICE_LEAD_MS;
@@ -9206,7 +9643,7 @@ async function generateASS(plan, stepAudio, workDir, config, captions) {
9206
9643
  currentTime += config.timing.minStepDuration;
9207
9644
  continue;
9208
9645
  }
9209
- const words = audio.words.length > 0 ? audio.words : estimateWordTimings(step.caption || "", audio.duration);
9646
+ const words = captionWordTimings(step.caption || "", audio.words, audio.duration);
9210
9647
  if (words.length > 0 && !step.spokenOnly) {
9211
9648
  const offset = currentTime;
9212
9649
  cues.push({
@@ -9226,7 +9663,7 @@ async function generateASS(plan, stepAudio, workDir, config, captions) {
9226
9663
  return assPath;
9227
9664
  }
9228
9665
  var STILL_EXTENSIONS = ["png", "jpg", "jpeg", "webp"];
9229
- function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
9666
+ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0, totalSecs, blackSpans) {
9230
9667
  const overlays = [];
9231
9668
  let cumulativeTime = leadInSecs;
9232
9669
  for (let i = 0; i < steps.length; i++) {
@@ -9239,13 +9676,20 @@ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
9239
9676
  const ext = step.value.split(".").pop()?.toLowerCase() || "";
9240
9677
  const still = isCard && STILL_EXTENSIONS.includes(ext);
9241
9678
  if (still || isClip && ["mp4", "webm", "mov"].includes(ext)) {
9242
- const mediaPath = isAbsolute4(step.value) ? step.value : resolve8(baseDir, step.value);
9243
- if (existsSync17(mediaPath) && durationSec > 0) {
9679
+ const mediaPath = isAbsolute5(step.value) ? step.value : resolve9(baseDir, step.value);
9680
+ if (existsSync18(mediaPath) && durationSec > 0) {
9244
9681
  const partial = parsePartial(step.partial);
9682
+ const runsToEnd = still && i === steps.length - 1 && totalSecs !== void 0;
9683
+ let duration = runsToEnd ? Math.max(durationSec, totalSecs - cumulativeTime) : durationSec;
9684
+ if (still && !runsToEnd && blackSpans) {
9685
+ const endsAt = cumulativeTime + duration;
9686
+ const following = blackSpans.find((sp) => sp.start <= endsAt + 0.25 && sp.end > endsAt);
9687
+ if (following) duration = following.end - cumulativeTime;
9688
+ }
9245
9689
  overlays.push({
9246
9690
  path: mediaPath,
9247
9691
  startTime: cumulativeTime,
9248
- duration: durationSec,
9692
+ duration,
9249
9693
  mode: step.target === "stretch" ? "stretch" : step.target === "cover" ? "cover" : "center",
9250
9694
  ...still ? { still: true } : {},
9251
9695
  ...partial.trimStart !== void 0 ? { trimStart: partial.trimStart } : {},
@@ -9258,7 +9702,7 @@ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
9258
9702
  }
9259
9703
  return overlays;
9260
9704
  }
9261
- async function composeAndFinish(videoPath, audioPath, assPath, config, userTier = "free", mainContentDuration, trimStart = 0.5, stepTransitions, hasClipAudio = false, endsOnCreditsCard = false, _stepSync) {
9705
+ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier = "free", mainContentDuration, trimStart = 0.5, stepTransitions, hasClipAudio = false, endsOnCreditsCard = false, _stepSync, musicStartSecs = 0) {
9262
9706
  header("Compose");
9263
9707
  const configPath = join23(runRoot(), CONFIG_FILE);
9264
9708
  console.log(`${INDENT3}${chalk11.dim(`Config: ${formatClickablePath(configPath)}`)}`);
@@ -9270,13 +9714,13 @@ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier
9270
9714
  process.exit(0);
9271
9715
  }
9272
9716
  const composeTask = task("Building final video");
9273
- await mkdir8(config.output.dir, { recursive: true });
9717
+ await mkdir9(config.output.dir, { recursive: true });
9274
9718
  const suffix = config.lang ? `-${config.lang}` : "";
9275
9719
  const finalPath = join23(config.output.dir, `demo${suffix}.${config.video.format}`);
9276
9720
  const videoDuration = await getMediaDuration(videoPath) / 1e3;
9277
- const hasVoiceover = audioPath && existsSync17(audioPath);
9278
- const musicPath = config.music?.path ? isAbsolute4(config.music.path) ? config.music.path : resolve8(config.output.dir, config.music.path) : "";
9279
- let hasMusic = musicPath && existsSync17(musicPath);
9721
+ const hasVoiceover = audioPath && existsSync18(audioPath);
9722
+ const musicPath = config.music?.path ? isAbsolute5(config.music.path) ? config.music.path : resolve9(config.output.dir, config.music.path) : "";
9723
+ let hasMusic = musicPath && existsSync18(musicPath);
9280
9724
  let bedPath = "";
9281
9725
  let bedCredit = "";
9282
9726
  if (!hasMusic && config.audio?.ambience !== false) {
@@ -9328,7 +9772,14 @@ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier
9328
9772
  hasClipAudio,
9329
9773
  mainContentDuration,
9330
9774
  stepTransitions,
9331
- hwaccel: config.video.hwaccel
9775
+ hwaccel: config.video.hwaccel,
9776
+ // The tempo pass runs after this and speeds the whole file up, music
9777
+ // included. Told about it, compose lays the bed down pre-slowed so the
9778
+ // song comes out at its own speed.
9779
+ tempo: config.video.tempo,
9780
+ musicStartSecs,
9781
+ // So the bed can be landed with its last note on the last frame.
9782
+ musicDurationSecs: hasMusic ? await getMediaDuration(backingPath).then((ms) => ms / 1e3).catch(() => 0) : 0
9332
9783
  });
9333
9784
  composeTask.succeed("Video composed");
9334
9785
  return finalPath;
@@ -9390,18 +9841,18 @@ function resolveOutro(config, userTier, userEmail) {
9390
9841
  if (!usesQuickPeekOutro(userTier, userEmail)) {
9391
9842
  const custom = config.video.outro?.trim();
9392
9843
  if (!custom) return void 0;
9393
- const path = resolve9(custom);
9394
- if (existsSync18(path)) return path;
9844
+ const path = resolve10(custom);
9845
+ if (existsSync19(path)) return path;
9395
9846
  info(`${INDENT4}\u26A0 outro not found: ${path}`);
9396
9847
  return void 0;
9397
9848
  }
9398
9849
  const scriptDir = getScriptDir();
9399
9850
  const bundled = join24(scriptDir, "assets", "qp.wm");
9400
- if (existsSync18(bundled)) return bundled;
9851
+ if (existsSync19(bundled)) return bundled;
9401
9852
  const fallback = join24(scriptDir, "..", "dist", "assets", "qp.wm");
9402
- return existsSync18(fallback) ? fallback : void 0;
9853
+ return existsSync19(fallback) ? fallback : void 0;
9403
9854
  }
9404
- var EMPTY_MIN_SECS = 0.5;
9855
+ var EMPTY_MIN_SECS = 0.4;
9405
9856
  var OUTRO_TAIL_MS = 900;
9406
9857
  var FIT_SECOND_PASS_CUSHION_SECS = 1;
9407
9858
  var FIT_SECOND_PASS_MAX_SPEEDUP = 1.45;
@@ -9426,11 +9877,12 @@ async function renderVideoPhase(params) {
9426
9877
  })
9427
9878
  );
9428
9879
  const recordingPath = getRecordingPath(config.output.dir);
9429
- const hasExistingRecording = existingRecordingMeta && existsSync18(recordingPath);
9880
+ const hasExistingRecording = existingRecordingMeta && existsSync19(recordingPath);
9430
9881
  let videoPath = recordingPath;
9431
9882
  const firstStep = plan.steps[0];
9432
9883
  const firstStepIsVideo = firstStep && (firstStep.action === "video" || firstStep.action === "media");
9433
9884
  let trimStart = 0;
9885
+ let recordedStepLengths = null;
9434
9886
  const doRecord = async () => {
9435
9887
  header("Record");
9436
9888
  const result = await recordSession(plan, config, stepDurations, workDir, config.output.dir, task, logError, {
@@ -9442,6 +9894,7 @@ async function renderVideoPhase(params) {
9442
9894
  ]);
9443
9895
  const detectedEmpty = mergeSpans([...result.leadSpans ?? [], ...emptyBlack, ...emptyWhite]);
9444
9896
  let stepLengths = result.actualStepDurations;
9897
+ let zeroAnchored = false;
9445
9898
  if (detectedEmpty.length > 0) {
9446
9899
  const bounds = [];
9447
9900
  let at = 0;
@@ -9466,6 +9919,7 @@ async function renderVideoPhase(params) {
9466
9919
  const sum = result.actualStepDurations.reduce((a, b2) => a + b2, 0);
9467
9920
  const factor = sum > 0 && realSecs > 0 ? realSecs * 1e3 / sum : 1;
9468
9921
  stepLengths = result.actualStepDurations.map((ms) => ms * factor);
9922
+ zeroAnchored = true;
9469
9923
  info(`Sped up ${emptySecs.toFixed(1)}s of loading screens at ${EMPTY_SPAN_SPEED}x`);
9470
9924
  } else {
9471
9925
  await discardPartial(retimed);
@@ -9482,16 +9936,19 @@ async function renderVideoPhase(params) {
9482
9936
  const meta = buildRecordingMeta(plan, config, stepDurations);
9483
9937
  meta.actualStepDurations = stepLengths;
9484
9938
  meta.pageLoadTime = result.pageLoadTime;
9939
+ meta.zeroAnchored = zeroAnchored;
9485
9940
  await saveRecordingMeta(config.output.dir, meta);
9486
9941
  videoPath = recordingPath;
9487
- trimStart = firstStepIsVideo ? 0 : result.pageLoadTime;
9942
+ recordedStepLengths = stepLengths;
9943
+ trimStart = firstStepIsVideo || zeroAnchored ? 0 : result.pageLoadTime;
9488
9944
  };
9489
9945
  if (hasExistingRecording) {
9490
9946
  const reuseCheck = canReuseRecording(plan, config, stepDurations, existingRecordingMeta);
9491
9947
  if (reuseCheck.canReuse) {
9492
9948
  info("Reusing existing recording");
9493
9949
  videoPath = recordingPath;
9494
- trimStart = firstStepIsVideo ? 0 : existingRecordingMeta?.pageLoadTime ?? 0.5;
9950
+ recordedStepLengths = existingRecordingMeta.actualStepDurations ?? existingRecordingMeta.stepDurations;
9951
+ trimStart = firstStepIsVideo || existingRecordingMeta.zeroAnchored ? 0 : existingRecordingMeta?.pageLoadTime ?? 0.5;
9495
9952
  stepAudio = await alignVoiceoverToRecording(
9496
9953
  stepAudio,
9497
9954
  existingRecordingMeta.actualStepDurations ?? existingRecordingMeta.stepDurations,
@@ -9505,9 +9962,23 @@ async function renderVideoPhase(params) {
9505
9962
  } else {
9506
9963
  await doRecord();
9507
9964
  }
9508
- const alignedStepDurations = plan.steps.map((_, i) => stepAudio.get(i + 1)?.duration ?? 0);
9965
+ const alignedStepDurations = plan.steps.map(
9966
+ (step, i) => Math.max(
9967
+ stepAudio.get(i + 1)?.duration ?? recordedStepLengths?.[i] ?? stepDurations[i] ?? 0,
9968
+ step.duration ? step.duration * 1e3 : 0
9969
+ )
9970
+ );
9509
9971
  let splicedClipAudio = false;
9510
- const videoOverlays = buildVideoOverlays(plan.steps, alignedStepDurations, config.output.dir, trimStart);
9972
+ const recordingSecs = await getMediaDuration(videoPath).then((ms) => ms / 1e3).catch(() => void 0);
9973
+ const blackSpans = await detectBlackSpans(videoPath, 0.3).catch(() => []);
9974
+ const videoOverlays = buildVideoOverlays(
9975
+ plan.steps,
9976
+ alignedStepDurations,
9977
+ config.output.dir,
9978
+ trimStart,
9979
+ recordingSecs,
9980
+ blackSpans
9981
+ );
9511
9982
  if (videoOverlays.length > 0) {
9512
9983
  const spliceTask = task("Painting cards and clips into recording");
9513
9984
  try {
@@ -9561,11 +10032,13 @@ async function renderVideoPhase(params) {
9561
10032
  const cardStepSeconds = (index) => {
9562
10033
  const action = plan.steps[index]?.action;
9563
10034
  if (action !== "image" && action !== "media") return 0;
9564
- return (stepAudio.get(index + 1)?.duration ?? 0) / 1e3;
10035
+ const audioSecs = (stepAudio.get(index + 1)?.duration ?? 0) / 1e3;
10036
+ const tail = index === plan.steps.length - 1 && action === "image" ? OUTRO_TAIL_MS / 1e3 : 0;
10037
+ return Math.max(audioSecs, plan.steps[index]?.duration ?? 0) + tail;
9565
10038
  };
9566
10039
  const introSecs = cardStepSeconds(0);
9567
10040
  const outroStepSecs = cardStepSeconds(plan.steps.length - 1);
9568
- const outroSeconds = config.video.outroCard?.seconds ?? (outroStepSecs || 3.5);
10041
+ const outroSeconds = Math.max(config.video.outroCard?.seconds ?? 3.5, outroStepSecs);
9569
10042
  const card = config.video.outroCard;
9570
10043
  if (card?.subject?.name) {
9571
10044
  const cardTask = task("Rendering credits card");
@@ -9609,6 +10082,20 @@ async function renderVideoPhase(params) {
9609
10082
  mainContentDuration = void 0;
9610
10083
  }
9611
10084
  }
10085
+ const outroOnly = config.music?.outroOnly !== false;
10086
+ let musicStartSecs = 0;
10087
+ if (outroOnly) {
10088
+ let trailingCards = 0;
10089
+ for (let i = plan.steps.length - 1; i >= 0; i--) {
10090
+ const action = plan.steps[i]?.action;
10091
+ if (action !== "image" && action !== "media") break;
10092
+ trailingCards++;
10093
+ }
10094
+ if (trailingCards > 0 && trailingCards < plan.steps.length) {
10095
+ const upTo = plan.steps.length - trailingCards;
10096
+ musicStartSecs = alignedStepDurations.slice(0, upTo).reduce((a, b) => a + b, 0) / 1e3;
10097
+ }
10098
+ }
9612
10099
  let finalPath = await composeAndFinish(
9613
10100
  videoToCompose,
9614
10101
  voiceoverPath,
@@ -9620,7 +10107,9 @@ async function renderVideoPhase(params) {
9620
10107
  stepTransitions,
9621
10108
  // The splice writes a clip-audio track only when a spliced clip had one.
9622
10109
  splicedClipAudio,
9623
- endsOnCreditsCard
10110
+ endsOnCreditsCard,
10111
+ void 0,
10112
+ musicStartSecs
9624
10113
  );
9625
10114
  const maxSilence = config.video.maxSilenceSecs ?? 0.6;
9626
10115
  if (maxSilence > 0) {
@@ -9785,7 +10274,7 @@ async function discardPartial(path) {
9785
10274
  }
9786
10275
  var MIN_ARTIFACT_BYTES = 1024;
9787
10276
  function assertArtifact(finalPath) {
9788
- const size = existsSync18(finalPath) ? statSync3(finalPath).size : 0;
10277
+ const size = existsSync19(finalPath) ? statSync3(finalPath).size : 0;
9789
10278
  if (size >= MIN_ARTIFACT_BYTES) return;
9790
10279
  throw new Error(
9791
10280
  `The run finished without producing a video: ${finalPath} ${size === 0 ? "does not exist" : `is only ${size} bytes`}. Check the log above for the failed ffmpeg pass.`
@@ -9839,7 +10328,7 @@ async function useOgCardAsIntro(plan, url, config, fresh = false) {
9839
10328
  const rewritable = first.action === "wait" && !first.target;
9840
10329
  const portrait = (config.video.height ?? 0) > (config.video.width ?? 0);
9841
10330
  const existing = join25(config.output.dir, "og-card.png");
9842
- const fetched = !fresh && existsSync19(existing) ? existing : await fetchOgCard(url, config.output.dir, portrait);
10331
+ const fetched = !fresh && existsSync20(existing) ? existing : await fetchOgCard(url, config.output.dir, portrait);
9843
10332
  const ogPath = fetched && coversFrame(fetched, config) ? fetched : null;
9844
10333
  const cardPath = ogPath ?? await renderGeneratedIntroCard(url, config);
9845
10334
  if (!cardPath) {
@@ -9929,30 +10418,20 @@ function fitFor(cardPath, config) {
9929
10418
  return Math.abs(size.width / size.height - frame) < 0.06 ? "stretch" : "cover";
9930
10419
  }
9931
10420
  function resolveCardPath(value, config) {
9932
- return isAbsolute5(value) ? value : join25(config.output.dir, value.replace(/^\.\//, ""));
10421
+ return isAbsolute6(value) ? value : join25(config.output.dir, value.replace(/^\.\//, ""));
9933
10422
  }
9934
10423
  var DEFAULT_INTRO_CARD_SECONDS = 2.5;
9935
10424
  function packageAsset(relative) {
9936
10425
  let dir = getScriptDir();
9937
10426
  for (let up = 0; up < 6; up++) {
9938
10427
  const candidate = join25(dir, relative);
9939
- if (existsSync19(candidate)) return candidate;
9940
- dir = join25(dir, "..");
9941
- }
9942
- return void 0;
9943
- }
9944
- function siblingAsset(relative) {
9945
- let dir = join25(getScriptDir(), "..");
9946
- for (let up = 0; up < 7; up++) {
9947
- const candidate = join25(dir, relative);
9948
- if (existsSync19(candidate)) return candidate;
10428
+ if (existsSync20(candidate)) return candidate;
9949
10429
  dir = join25(dir, "..");
9950
10430
  }
9951
10431
  return void 0;
9952
10432
  }
9953
10433
  var CREDIT_TOOLS = [
9954
- { name: "QuickPeek", role: "Video generated by", icon: () => packageAsset("imgs/qp.png") },
9955
- { name: "Vidlet", role: "Video edited by", icon: () => siblingAsset("VidLet/res/icons/vidlet-512.png") }
10434
+ { name: "QuickPeek", role: "Video generated by", icon: () => packageAsset("imgs/qp.png") }
9956
10435
  ];
9957
10436
  function oneTagline(text) {
9958
10437
  if (!text) return void 0;
@@ -10044,7 +10523,7 @@ async function runPlanPhase(params) {
10044
10523
  const extensionPath = config.browser?.extension;
10045
10524
  let existingPlan = null;
10046
10525
  let existingPlanPath = null;
10047
- if (existsSync20(planPath)) {
10526
+ if (existsSync21(planPath)) {
10048
10527
  try {
10049
10528
  const parsed = JSON.parse(await readFile9(planPath, "utf-8"));
10050
10529
  if (parsed.url && parsed.steps?.length > 0) {
@@ -10074,7 +10553,22 @@ async function runPlanPhase(params) {
10074
10553
  }
10075
10554
  }
10076
10555
  const normalizeUrl2 = (u) => u.replace(/\/+$/, "").toLowerCase();
10077
- const urlMatches = existingPlan && normalizeUrl2(existingPlan.url) === normalizeUrl2(url);
10556
+ const isBareOrigin = (u) => {
10557
+ try {
10558
+ const parsed = new URL(u);
10559
+ return parsed.pathname === "/" && !parsed.search && !parsed.hash;
10560
+ } catch {
10561
+ return false;
10562
+ }
10563
+ };
10564
+ const sameOrigin = (a, b) => {
10565
+ try {
10566
+ return new URL(a).origin === new URL(b).origin;
10567
+ } catch {
10568
+ return false;
10569
+ }
10570
+ };
10571
+ const urlMatches = existingPlan && (normalizeUrl2(existingPlan.url) === normalizeUrl2(url) || isBareOrigin(existingPlan.url) && sameOrigin(existingPlan.url, url));
10078
10572
  if (existingPlan && !urlMatches && isAutoYes()) {
10079
10573
  console.log();
10080
10574
  console.log(chalk13.dim(` Existing plan is for ${existingPlan.url}, not ${url}, regenerating.`));
@@ -10218,7 +10712,7 @@ async function runPlanPhase(params) {
10218
10712
  const planLang = plan.lang || "en";
10219
10713
  if (planLang !== targetLang) {
10220
10714
  let useExistingTranslation = false;
10221
- if (existsSync20(planPath) && existingPlanPath !== planPath) {
10715
+ if (existsSync21(planPath) && existingPlanPath !== planPath) {
10222
10716
  try {
10223
10717
  const translatedPlan = JSON.parse(await readFile9(planPath, "utf-8"));
10224
10718
  if (translatedPlan.steps?.length === plan.steps.length && translatedPlan.lang === targetLang) {
@@ -10397,7 +10891,7 @@ async function runValidationPhase(params) {
10397
10891
 
10398
10892
  // src/commands/phases/voiceover-phase.ts
10399
10893
  init_lib();
10400
- import { existsSync as existsSync22 } from "fs";
10894
+ import { existsSync as existsSync23 } from "fs";
10401
10895
  import { join as join28 } from "path";
10402
10896
  import chalk15 from "chalk";
10403
10897
  var INDENT6 = " ";
@@ -10458,14 +10952,14 @@ async function clearCachedFiles(outputDir) {
10458
10952
  const { unlink: unlink5 } = await import("fs/promises");
10459
10953
  for (const file of CACHED_FILES) {
10460
10954
  const filePath = join28(outputDir, file);
10461
- if (existsSync22(filePath)) await unlink5(filePath).catch(() => {
10955
+ if (existsSync23(filePath)) await unlink5(filePath).catch(() => {
10462
10956
  });
10463
10957
  }
10464
10958
  info("Cleared cached files (--fresh)");
10465
10959
  }
10466
10960
  async function usableVoiceover(outputDir, meta) {
10467
10961
  const voiceoverFile = join28(outputDir, "voiceover.wav");
10468
- if (!meta || !existsSync22(voiceoverFile)) return null;
10962
+ if (!meta || !existsSync23(voiceoverFile)) return null;
10469
10963
  if (await isHonestWav(voiceoverFile)) return meta;
10470
10964
  info("Existing voiceover has a stale container, regenerating");
10471
10965
  return null;
@@ -10543,7 +11037,7 @@ async function ensureCliSignIn(url, opts) {
10543
11037
  }
10544
11038
 
10545
11039
  // src/commands/utils/chrome-extension.ts
10546
- import { existsSync as existsSync23, readdirSync as readdirSync5, readFileSync as readFileSync9 } from "fs";
11040
+ import { existsSync as existsSync24, readdirSync as readdirSync5, readFileSync as readFileSync9 } from "fs";
10547
11041
  import { homedir as homedir4 } from "os";
10548
11042
  import { join as join29 } from "path";
10549
11043
  function resolveChromeExtension(extensionId) {
@@ -10579,7 +11073,7 @@ function resolveChromeExtension(extensionId) {
10579
11073
  }
10580
11074
  for (const chromeExtDir of possibleDirs) {
10581
11075
  const extDir = join29(chromeExtDir, extensionId);
10582
- if (!existsSync23(extDir)) continue;
11076
+ if (!existsSync24(extDir)) continue;
10583
11077
  try {
10584
11078
  const versions = readdirSync5(extDir).filter((f) => !f.startsWith("."));
10585
11079
  if (versions.length === 0) continue;
@@ -10588,7 +11082,7 @@ function resolveChromeExtension(extensionId) {
10588
11082
  let name = extensionId;
10589
11083
  let popupPath = "popup.html";
10590
11084
  const manifestPath = join29(extPath, "manifest.json");
10591
- if (existsSync23(manifestPath)) {
11085
+ if (existsSync24(manifestPath)) {
10592
11086
  try {
10593
11087
  const manifest = JSON.parse(readFileSync9(manifestPath, "utf-8"));
10594
11088
  if (manifest.name && !manifest.name.startsWith("__MSG_")) {
@@ -10611,7 +11105,7 @@ function resolveChromeExtension(extensionId) {
10611
11105
 
10612
11106
  // src/commands/utils/plan-review.ts
10613
11107
  import { createHash as createHash2 } from "crypto";
10614
- import { existsSync as existsSync24 } from "fs";
11108
+ import { existsSync as existsSync25 } from "fs";
10615
11109
  import { readFile as readFile11, writeFile as writeFile12 } from "fs/promises";
10616
11110
  import { join as join30 } from "path";
10617
11111
  var REVIEW_FILE = "plan-review.json";
@@ -10624,7 +11118,7 @@ async function hashPlanFile(planPath) {
10624
11118
  }
10625
11119
  async function loadPlanReview(outputDir) {
10626
11120
  const path = join30(outputDir, REVIEW_FILE);
10627
- if (!existsSync24(path)) return {};
11121
+ if (!existsSync25(path)) return {};
10628
11122
  try {
10629
11123
  return JSON.parse(await readFile11(path, "utf-8"));
10630
11124
  } catch {
@@ -10746,8 +11240,8 @@ async function recordCommand(url, options = {}) {
10746
11240
  }
10747
11241
  if (langOverride) config.lang = langOverride;
10748
11242
  if (config.music?.path) {
10749
- const musicPath = isAbsolute6(config.music.path) ? config.music.path : resolve10(config.output.dir, config.music.path);
10750
- if (!existsSync25(musicPath)) {
11243
+ const musicPath = isAbsolute7(config.music.path) ? config.music.path : resolve11(config.output.dir, config.music.path);
11244
+ if (!existsSync26(musicPath)) {
10751
11245
  console.log();
10752
11246
  console.log(chalk17.yellow(` \u26A0 Music file not found: ${config.music.path}`));
10753
11247
  console.log(chalk17.dim(` Config: ${join31(runRoot(), CONFIG_FILE)}`));
@@ -10768,7 +11262,7 @@ async function recordCommand(url, options = {}) {
10768
11262
  const targetLang = config.lang || "en";
10769
11263
  const planPath = join31(config.output.dir, `plan-${targetLang}.json`);
10770
11264
  const isExtensionUrl = url.startsWith("chrome-extension://");
10771
- await mkdir9(config.output.dir, { recursive: true });
11265
+ await mkdir10(config.output.dir, { recursive: true });
10772
11266
  await setLogFilePath(config.output.dir);
10773
11267
  const preRunHash = await hashPlanFile(planPath);
10774
11268
  try {
@@ -10857,6 +11351,13 @@ async function recordCommand(url, options = {}) {
10857
11351
  userTier,
10858
11352
  userEmail
10859
11353
  });
11354
+ await registerDemoRun(userEmail, {
11355
+ source: currentMcpTool2() ? "mcp" : "cli",
11356
+ targetUrl: validatedPlan.url || url,
11357
+ localPath: finalPath,
11358
+ title: validatedPlan.title,
11359
+ ...config.lang ? { language: config.lang } : {}
11360
+ });
10860
11361
  if (!userEmail || userEmail === "anonymous") {
10861
11362
  const newCount = localVideoCount + 1;
10862
11363
  await saveUserData({ localVideoCount: newCount });
@@ -11287,7 +11788,7 @@ function withTierNote(result, tier) {
11287
11788
  ...result.content,
11288
11789
  {
11289
11790
  type: "text",
11290
- text: `Free tier: this demo carries a watermark and the free video limit applies. Upgrade at ${pricingUrl()}`
11791
+ text: `Free tier: a demo of 20 seconds or under comes out clean; over 20 seconds it carries a watermark. The free video limit applies. Upgrade at ${pricingUrl()}`
11291
11792
  }
11292
11793
  ]
11293
11794
  };
@@ -11383,7 +11884,7 @@ var FORMAT_PROPERTY = {
11383
11884
  var APPROVE_PLAN_PROPERTY = {
11384
11885
  approve_plan: {
11385
11886
  type: "boolean",
11386
- description: "The user has reviewed and approved the planned steps this tool returned as plan_ready \u2014 record them. Only honored while the plan file is unchanged since the call started, so it can never approve steps the user was not shown. Never pass it on a first call."
11887
+ description: "The user has reviewed and approved the planned steps this tool returned as plan_ready. Record them. Only honored while the plan file is unchanged since the call started, so it can never approve steps the user was not shown. Never pass it on a first call."
11387
11888
  }
11388
11889
  };
11389
11890
  var DRAFT_PROPERTY = {
@@ -11430,7 +11931,7 @@ var OUTRO_PROPERTY = {
11430
11931
  var QUICKPEEK_TOOLS = [
11431
11932
  {
11432
11933
  name: "generate_demo",
11433
- description: "Record a narrated demo video of a web app, end to end: an AI plans the walkthrough, Playwright drives a real browser, and the result is composed with voiceover and burned karaoke captions. This is the headline tool. It takes minutes, not seconds, because a real browser session is recorded. Returns the finished MP4 path and a file:// url. The flow is interactive: with no saved session the user is asked to sign in first (a visible browser opens; QuickPeek never sees the password) so the demo starts inside the app instead of the logged-out landing page; the user picks voice gender, pace and video size on the first run (remembered afterwards); and the planned steps pause for the user\u2019s review before recording. On clients that cannot prompt, the tool returns setup_required / plan_ready statuses instead \u2014 ask the USER the questions they carry (never answer them yourself) and re-call with the answers. Pass skip_auth for public sites. The result says whether this recording was signed in.",
11934
+ description: "Record a narrated demo video of a web app, end to end: an AI plans the walkthrough, Playwright drives a real browser, and the result is composed with voiceover and burned karaoke captions. This is the headline tool. It takes minutes, not seconds, because a real browser session is recorded. Returns the finished MP4 path and a file:// url. The flow is interactive: with no saved session the user is asked to sign in first (a visible browser opens; QuickPeek never sees the password) so the demo starts inside the app instead of the logged-out landing page; the user picks voice gender, pace and video size on the first run (remembered afterwards); and the planned steps pause for the user\u2019s review before recording. On clients that cannot prompt, the tool returns setup_required / plan_ready statuses instead. Ask the USER the questions they carry (never answer them yourself) and re-call with the answers. Pass skip_auth for public sites. The result says whether this recording was signed in.",
11434
11935
  inputSchema: {
11435
11936
  type: "object",
11436
11937
  properties: {
@@ -11506,7 +12007,7 @@ var QUICKPEEK_TOOLS = [
11506
12007
  },
11507
12008
  {
11508
12009
  name: "start_auth",
11509
- description: "Open a visible browser on a site so the user can sign in, once, by hand. Everything recorded afterwards starts from that session, and demos of the bare URL begin on the page the user leaves open when the session is saved \u2014 tell them to finish on their dashboard. QuickPeek never sees the password: only the resulting cookies are saved. On clients that support prompting, this saves the session as soon as the user confirms; otherwise call finish_auth when they say they are done.",
12010
+ description: "Open a visible browser on a site so the user can sign in, once, by hand. Everything recorded afterwards starts from that session, and demos of the bare URL begin on the page the user leaves open when the session is saved. Tell them to finish on their dashboard. QuickPeek never sees the password: only the resulting cookies are saved. On clients that support prompting, this saves the session as soon as the user confirms; otherwise call finish_auth when they say they are done.",
11510
12011
  inputSchema: {
11511
12012
  type: "object",
11512
12013
  properties: { ...URL_PROPERTY },
@@ -11543,7 +12044,7 @@ var QUICKPEEK_TOOLS = [
11543
12044
  },
11544
12045
  {
11545
12046
  name: "check_account",
11546
- description: "Report the signed-in account and its tier, so you know whether a demo will carry a watermark or hit the free video limit before spending minutes recording one.",
12047
+ description: "Report the signed-in account and its tier, so you know whether a demo will carry a watermark or hit the free video limit before spending minutes recording one. Over MCP a free demo of 20 seconds or under is clean; only a longer one is watermarked.",
11547
12048
  inputSchema: { type: "object", properties: {} }
11548
12049
  }
11549
12050
  ];
@@ -11628,7 +12129,7 @@ async function runDemoGates(url, args) {
11628
12129
  ...missing.length ? { options_needed: missing } : {},
11629
12130
  next_steps: [
11630
12131
  ...authNeeded ? ["Ask the user whether to sign in; start_auth then finish_auth, or skip_auth: true."] : [],
11631
- ...missing.length ? [`Ask the USER to choose ${missing.join(", ")} \u2014 do not pick for them \u2014 and pass the answers as params.`] : [],
12132
+ ...missing.length ? [`Ask the USER to choose ${missing.join(", ")}, do not pick for them, and pass the answers as params.`] : [],
11632
12133
  "Then call this tool again with the same arguments plus the answers."
11633
12134
  ]
11634
12135
  });
@@ -11664,7 +12165,7 @@ async function runDemoGates(url, args) {
11664
12165
  return jsonContent({
11665
12166
  status: "setup_required",
11666
12167
  host,
11667
- sign_in: `The client could not show the sign-in prompt. Call start_auth with this url, then finish_auth once the user confirms \u2014 or pass skip_auth: true for a logged-out demo.`
12168
+ sign_in: `The client could not show the sign-in prompt. Call start_auth with this url, then finish_auth once the user confirms, or pass skip_auth: true for a logged-out demo.`
11668
12169
  });
11669
12170
  }
11670
12171
  }
@@ -11714,7 +12215,7 @@ async function runDemoGates(url, args) {
11714
12215
  }
11715
12216
  function mcpPlanReview() {
11716
12217
  return async (plan, planPath) => {
11717
- const editorUrl = planEditorUrl(editorBaseUrl(), plan);
12218
+ const editorUrl = await editorUrlWithThumbs(editorBaseUrl(), plan, dirname5(planPath));
11718
12219
  if (!canElicit()) throw new PlanReviewPending(plan, planPath, editorUrl);
11719
12220
  if (editorUrl) await openInBrowser(editorUrl);
11720
12221
  const steps = plan.steps.map((s, i) => `${i + 1}. [${s.action}] ${s.caption ?? ""}`).join("\n");
@@ -11723,7 +12224,7 @@ function mcpPlanReview() {
11723
12224
 
11724
12225
  ${steps}
11725
12226
 
11726
- ` + (editorUrl ? `The plan is open in your browser editor \u2014 to change it, edit there and Save over the plan file first. ` : "") + `Accept to record, decline to cancel.`,
12227
+ ` + (editorUrl ? `The plan is open in your browser editor. To change it, edit there and Save over the plan file first. ` : "") + `Accept to record, decline to cancel.`,
11727
12228
  "approved",
11728
12229
  "Record this plan?"
11729
12230
  );
@@ -11741,14 +12242,14 @@ function planReadyResult(pending2) {
11741
12242
  next_steps: [
11742
12243
  "Show these steps to the USER and ask whether to record them (they can also fine-tune in editor_url).",
11743
12244
  "If approved, call this tool again with the same arguments plus approve_plan: true.",
11744
- "To change the story instead, re-call with fresh: true and a `description` saying what to cover \u2014 without fresh, the cached plan is reused as-is."
12245
+ "To change the story instead, re-call with fresh: true and a `description` saying what to cover. Without fresh, the cached plan is reused as-is."
11745
12246
  ]
11746
12247
  });
11747
12248
  }
11748
12249
  function planRejectedResult(e) {
11749
12250
  return jsonContent({
11750
12251
  status: e.outcome === "decline" ? "plan_rejected" : "cancelled",
11751
- note: e.outcome === "decline" ? "The user declined the planned steps; nothing was recorded. Ask what the demo should cover, then re-call with a `description` that says so (or let them edit the plan file) \u2014 fresh: true regenerates from scratch." : "The review prompt was dismissed; nothing was recorded."
12252
+ note: e.outcome === "decline" ? "The user declined the planned steps; nothing was recorded. Ask what the demo should cover, then re-call with a `description` that says so (or let them edit the plan file). Passing fresh: true regenerates from scratch." : "The review prompt was dismissed; nothing was recorded."
11752
12253
  });
11753
12254
  }
11754
12255
  async function handleGenerateDemo(args) {
@@ -11841,7 +12342,7 @@ async function handleOpenInEditor(args) {
11841
12342
  const parsed = parsePlanText(readFileSync10(planPath, "utf8"));
11842
12343
  if (!parsed.ok) return errorContent(`${planPath} cannot be opened: ${parsed.error}`);
11843
12344
  const base = editorBaseUrl();
11844
- const editorUrl = planEditorUrl(base, parsed.plan);
12345
+ const editorUrl = await editorUrlWithThumbs(base, parsed.plan, dir);
11845
12346
  await openInBrowser(editorUrl ?? `${base}/edit`);
11846
12347
  return jsonContent({
11847
12348
  opened: true,
@@ -11930,7 +12431,7 @@ async function handleCheckAccount() {
11930
12431
  return jsonContent({
11931
12432
  signedIn: false,
11932
12433
  tier: "free",
11933
- note: "Not signed in. Demos carry a watermark and the free video limit applies."
12434
+ note: "Not signed in. Over MCP a demo of 20 seconds or under comes out clean; a longer one carries a watermark. The free video limit applies."
11934
12435
  });
11935
12436
  }
11936
12437
  const tier = await getTierByEmail(userData.email);
@@ -11938,15 +12439,15 @@ async function handleCheckAccount() {
11938
12439
  signedIn: true,
11939
12440
  email: userData.email,
11940
12441
  tier,
11941
- note: tier === "free" ? "Free tier: demos carry a watermark and the free video limit applies." : `${tier} tier: no watermark.`
12442
+ note: tier === "free" ? "Free tier: a demo of 20 seconds or under comes out clean; a longer one carries a watermark. The free video limit applies." : `${tier} tier: no watermark.`
11942
12443
  });
11943
12444
  });
11944
12445
  }
11945
12446
  var PROMO_DIR = "promo";
11946
12447
  function resolveRunRoot(projectDir) {
11947
12448
  if (typeof projectDir !== "string" || !projectDir.trim()) return process.cwd();
11948
- const abs = resolve11(projectDir.trim());
11949
- if (!existsSync26(abs) || !statSync4(abs).isDirectory()) {
12449
+ const abs = resolve12(projectDir.trim());
12450
+ if (!existsSync27(abs) || !statSync4(abs).isDirectory()) {
11950
12451
  throw new Error(`project_dir is not an existing directory: ${abs}`);
11951
12452
  }
11952
12453
  const root = basename5(abs) === PROMO_DIR ? abs : join33(abs, PROMO_DIR);