@spark-apps/quickpeek 1.2.4 → 1.2.6

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,181 @@ 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
+
299
+ // src/browser/headless.ts
300
+ import { chromium } from "playwright";
301
+ async function launchHeadlessChromium(options = {}) {
302
+ const opts = { ...options, headless: true };
303
+ try {
304
+ return await chromium.launch(opts);
305
+ } catch (bundledError) {
306
+ try {
307
+ return await chromium.launch({ ...opts, channel: "chrome" });
308
+ } catch {
309
+ const detail = bundledError instanceof Error ? bundledError.message : String(bundledError);
310
+ throw new Error(`${NO_BROWSER_MESSAGE}
311
+
312
+ Playwright reported: ${detail}`);
313
+ }
314
+ }
315
+ }
316
+ var NO_BROWSER_MESSAGE;
317
+ var init_headless = __esm({
318
+ "src/browser/headless.ts"() {
319
+ "use strict";
320
+ NO_BROWSER_MESSAGE = "QuickPeek needs a browser to record and could not find one.\n Run this once, then run QuickPeek again:\n npx playwright install chromium";
321
+ }
322
+ });
323
+
324
+ // src/lib/json-repair.ts
325
+ function repairUnicodeEscapes(content) {
326
+ return content.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (match, hex) => {
327
+ const code = Number.parseInt(hex, 16);
328
+ return code <= 1114111 ? String.fromCodePoint(code) : match;
329
+ }).replace(/\\u(?![0-9a-fA-F]{4})/g, "\\\\u");
330
+ }
331
+ function salvageTruncatedJson(content) {
332
+ const stack = [];
333
+ const cuts = [];
334
+ let inString = false;
335
+ let escaped = false;
336
+ const record = (index, closesContainer) => cuts.push({ index, open: stack.join(""), closesContainer });
337
+ for (let i = 0; i < content.length; i++) {
338
+ const ch = content[i];
339
+ if (inString) {
340
+ if (escaped) escaped = false;
341
+ else if (ch === "\\") escaped = true;
342
+ else if (ch === '"') {
343
+ inString = false;
344
+ record(i + 1, false);
345
+ }
346
+ continue;
347
+ }
348
+ if (ch === '"') {
349
+ inString = true;
350
+ } else if (ch === "{" || ch === "[") {
351
+ stack.push(ch);
352
+ } else if (ch === "}" || ch === "]") {
353
+ stack.pop();
354
+ record(i + 1, true);
355
+ } else if (ch === ",") {
356
+ record(i, content[i - 1] === "}" || content[i - 1] === "]");
357
+ }
358
+ }
359
+ const tryCut = (cut) => {
360
+ const head = content.slice(0, cut.index).replace(/[\s,]+$/, "");
361
+ if (head.endsWith(":")) return null;
362
+ let closed = head;
363
+ for (let d = cut.open.length - 1; d >= 0; d--) {
364
+ closed += CLOSERS[cut.open[d]];
365
+ }
366
+ try {
367
+ JSON.parse(closed);
368
+ return closed;
369
+ } catch {
370
+ return null;
371
+ }
372
+ };
373
+ const attempts = Math.min(cuts.length, MAX_SALVAGE_ATTEMPTS);
374
+ for (const containerOnly of [true, false]) {
375
+ for (let n = 0; n < attempts; n++) {
376
+ const cut = cuts[cuts.length - 1 - n];
377
+ if (containerOnly && !cut.closesContainer) continue;
378
+ const salvaged = tryCut(cut);
379
+ if (salvaged !== null) return salvaged;
380
+ }
381
+ }
382
+ return null;
383
+ }
384
+ function extractJsonCandidates(content) {
385
+ let text = content.trim();
386
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)(?:```|$)/);
387
+ if (fence?.[1]?.trim()) text = fence[1].trim();
388
+ const start = text.search(/[{[]/);
389
+ if (start === -1) return [];
390
+ const closer = text[start] === "{" ? "}" : "]";
391
+ const end = text.lastIndexOf(closer);
392
+ const candidates = [text.slice(start)];
393
+ if (end > start && end < text.length - 1) candidates.unshift(text.slice(start, end + 1));
394
+ return candidates;
395
+ }
396
+ function parseAIJson(content) {
397
+ const candidates = extractJsonCandidates(content);
398
+ if (candidates.length === 0) return null;
399
+ for (const text of candidates) {
400
+ try {
401
+ return { value: JSON.parse(text), repaired: false };
402
+ } catch {
403
+ }
404
+ }
405
+ for (const text of candidates) {
406
+ try {
407
+ return { value: JSON.parse(repairUnicodeEscapes(text)), repaired: true };
408
+ } catch {
409
+ }
410
+ }
411
+ for (const text of candidates) {
412
+ for (const variant of [repairUnicodeEscapes(text), text]) {
413
+ const salvaged = salvageTruncatedJson(variant);
414
+ if (salvaged === null) continue;
415
+ try {
416
+ return { value: JSON.parse(salvaged), repaired: true };
417
+ } catch {
418
+ }
419
+ }
420
+ }
421
+ return null;
422
+ }
423
+ var MAX_SALVAGE_ATTEMPTS, CLOSERS;
424
+ var init_json_repair = __esm({
425
+ "src/lib/json-repair.ts"() {
426
+ "use strict";
427
+ MAX_SALVAGE_ATTEMPTS = 500;
428
+ CLOSERS = { "{": "}", "[": "]" };
429
+ }
430
+ });
431
+
257
432
  // src/lib/api-client.ts
258
433
  function apiKey() {
259
434
  return process.env.SPARK_AI_KEY || "";
@@ -370,7 +545,7 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
370
545
  ...detail ? { detail } : {},
371
546
  ...retryAfter2 ? { retryAfterSeconds: parseInt(retryAfter2, 10) } : {}
372
547
  };
373
- await new Promise((resolve12) => setTimeout(resolve12, Math.min(delayMs, 3e4)));
548
+ await new Promise((resolve13) => setTimeout(resolve13, Math.min(delayMs, 3e4)));
374
549
  continue;
375
550
  }
376
551
  const retryAfter = response.headers.get("Retry-After");
@@ -410,7 +585,7 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
410
585
  return { success: false, error: `${host} unreachable: ${getErrorMessage(err)}`, host };
411
586
  }
412
587
  const delayMs = 1e3 * 2 ** (attempt - 1);
413
- await new Promise((resolve12) => setTimeout(resolve12, delayMs));
588
+ await new Promise((resolve13) => setTimeout(resolve13, delayMs));
414
589
  }
415
590
  }
416
591
  return lastRetriableError || { success: false, error: `${host}: request failed after retries`, host };
@@ -452,24 +627,26 @@ async function callAIViaRelay(systemPrompt, userPrompt, email, tier = "free", op
452
627
  }
453
628
  }
454
629
  function parseAIPlanResponse(content) {
455
- try {
456
- let processedContent = content;
457
- const codeBlockMatch = processedContent.match(/```(?:json)?\s*([\s\S]*?)```/);
458
- if (codeBlockMatch?.[1]) {
459
- processedContent = codeBlockMatch[1].trim();
460
- }
461
- const jsonMatch = processedContent.match(/\{[\s\S]*\}/);
462
- if (!jsonMatch) {
463
- return { success: false, error: "No valid JSON found in AI response" };
464
- }
465
- const parsed = JSON.parse(jsonMatch[0]);
466
- return { success: true, plan: parsed };
467
- } catch (err) {
468
- return {
469
- success: false,
470
- error: `Failed to parse AI response: ${err instanceof Error ? err.message : String(err)}`
471
- };
630
+ const parsed = parseAIJson(content);
631
+ if (!parsed) {
632
+ return { success: false, error: "No valid JSON found in AI response" };
472
633
  }
634
+ if (!Array.isArray(parsed.value.steps) || parsed.value.steps.length === 0) {
635
+ return { success: false, error: "AI response has no steps" };
636
+ }
637
+ return { success: true, plan: { ...parsed.value, steps: parsed.value.steps } };
638
+ }
639
+ async function callAIForPlan(systemPrompt, userPrompt, tier = "free", opts = {}) {
640
+ let lastParseError = "No valid JSON found in AI response";
641
+ for (let attempt = 1; attempt <= 2; attempt++) {
642
+ const aiResult = await callAI(systemPrompt, userPrompt, tier, opts);
643
+ if (!aiResult.success) return aiResult;
644
+ const parsed = parseAIPlanResponse(aiResult.content);
645
+ if (parsed.success) return { success: true, plan: parsed.plan, limits: aiResult.limits };
646
+ lastParseError = parsed.error;
647
+ console.error(`Plan parse failed (attempt ${attempt}/2):`, parsed.error);
648
+ }
649
+ return { success: false, error: lastParseError, isParseError: true };
473
650
  }
474
651
  function normalizeUrl(input) {
475
652
  let url = input.trim();
@@ -480,6 +657,7 @@ var PLAN_MAX_TOKENS, SERVER_BACKOFF_MS, CLI_BACKOFF_MS, SPARK_AI_BASE, SPARK_AI_
480
657
  var init_api_client = __esm({
481
658
  "src/lib/api-client.ts"() {
482
659
  "use strict";
660
+ init_json_repair();
483
661
  init_utils();
484
662
  PLAN_MAX_TOKENS = 1500;
485
663
  SERVER_BACKOFF_MS = [2e3, 4e3];
@@ -572,9 +750,6 @@ var init_SparkStripe_feature_gating = __esm({
572
750
  });
573
751
 
574
752
  // src/lib/billing.ts
575
- function shouldWatermark(tier, videoDurationSeconds) {
576
- return tier === "free" && videoDurationSeconds > 20;
577
- }
578
753
  function getUpgradeUrl(userEmail) {
579
754
  return pricingUrl(userEmail ? { email: userEmail } : void 0);
580
755
  }
@@ -609,7 +784,10 @@ function applyProfile(config, profile) {
609
784
  format: "mp3",
610
785
  bitrate: "192k",
611
786
  ...config.audio,
612
- speed: config.audio?.speed ?? preset.audioSpeed,
787
+ // outroOnly is on unless turned off, matching the video phase. The
788
+ // speedup buys the song its window, so it applies to the profile's own
789
+ // pace only: a speed somebody set by hand is the speed they wanted.
790
+ speed: config.audio?.speed ?? preset.audioSpeed * (config.music?.outroOnly !== false ? OUTRO_SONG_SPEEDUP : 1),
613
791
  ...config.audio?.voice || !preset.voice ? {} : { voice: preset.voice }
614
792
  }
615
793
  };
@@ -646,11 +824,11 @@ function defaultCaptionsFor(width, height) {
646
824
  maxChars: 20
647
825
  };
648
826
  }
649
- var VERSION, CONFIG_FILE, SIZE_PRESETS, RATE_FACTOR, NO_CLIP_OUTRO, DEFAULT_CONFIG, VIDEO_PROFILES, DEFAULT_CAPTIONS;
827
+ var VERSION, CONFIG_FILE, SIZE_PRESETS, RATE_FACTOR, NO_CLIP_OUTRO, DEFAULT_CONFIG, VIDEO_PROFILES, OUTRO_SONG_SPEEDUP, DEFAULT_CAPTIONS;
650
828
  var init_config = __esm({
651
829
  "src/lib/config.ts"() {
652
830
  "use strict";
653
- VERSION = "1.2.3";
831
+ VERSION = "1.2.6";
654
832
  CONFIG_FILE = "quickpeek.config.json";
655
833
  SIZE_PRESETS = {
656
834
  wide: { width: 1920, height: 1080, zoom: 1 },
@@ -677,11 +855,12 @@ var init_config = __esm({
677
855
  // ms
678
856
  transitions: "none",
679
857
  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,
858
+ // The recording plays at the pace it was filmed at. A tempo above 1 is
859
+ // multiplied by the narration speed in VIDEO_PROFILES and by the fit
860
+ // pass's squeeze, and the three together read as frantic rather than
861
+ // brisk. Speed belongs in one place, so it lives in audioSpeed; set a
862
+ // tempo in quickpeek.config.json to push the picture on top of that.
863
+ tempo: 1,
685
864
  stepDelay: 200
686
865
  // ms delay between steps
687
866
  },
@@ -695,18 +874,24 @@ var init_config = __esm({
695
874
  // these bitrates - the encode is pure wall-clock tax on every run.
696
875
  wide: {
697
876
  video: { width: 1920, height: 1080, zoom: 1.5, fitLimitSecs: 0, maxSpeedup: 1, preset: "veryfast" },
698
- audioSpeed: 1.6
877
+ // A tutorial is watched to learn something, so the narrator is allowed to
878
+ // be brisk but never hurried.
879
+ audioSpeed: 1.15
699
880
  },
700
881
  short: {
701
882
  // fadeOut 0: the video ends on a card the viewer is meant to read,
702
883
  // and a fade takes it away mid-sentence.
703
884
  video: { width: 1080, height: 1920, zoom: 3, fitLimitSecs: 60, maxSpeedup: 1.7, preset: "veryfast", fadeOut: 0 },
704
- audioSpeed: 1.8,
885
+ // 1.8 was unwatchable: stacked on tempo 1.2 and the outro speedup it ran
886
+ // the voice at better than twice its natural rate. A short wants energy,
887
+ // which is the voice and the cutting, not the tape running fast.
888
+ audioSpeed: 1,
705
889
  // A short lives or dies on energy. Ava is the warmest, most animated of
706
890
  // the free neural voices; Sonia reads a short like the shipping forecast.
707
891
  voice: "en-US-AvaMultilingualNeural"
708
892
  }
709
893
  };
894
+ OUTRO_SONG_SPEEDUP = 1.13;
710
895
  DEFAULT_CAPTIONS = {
711
896
  font: "Arial",
712
897
  outline: "#000000",
@@ -835,7 +1020,6 @@ var init_scraping = __esm({
835
1020
  });
836
1021
 
837
1022
  // src/lib/crawl.ts
838
- import { chromium as chromium4 } from "playwright";
839
1023
  async function waitForDomStable(page, cap = SETTLE_CAP_MS) {
840
1024
  const deadline = Date.now() + cap;
841
1025
  let previous = -1;
@@ -856,6 +1040,7 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
856
1040
  const deadline = Date.now() + cap;
857
1041
  let previous = -1;
858
1042
  let unchanged = 0;
1043
+ let clear = 0;
859
1044
  while (Date.now() < deadline) {
860
1045
  const count = await page.evaluate((selector) => {
861
1046
  let visible = 0;
@@ -865,7 +1050,12 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
865
1050
  }
866
1051
  return visible;
867
1052
  }, PLACEHOLDER_SELECTOR).catch(() => 0);
868
- if (count === 0) return;
1053
+ if (count === 0) {
1054
+ if (++clear >= PLACEHOLDER_CLEAR_POLLS) return;
1055
+ await page.waitForTimeout(PLACEHOLDER_POLL_MS);
1056
+ continue;
1057
+ }
1058
+ clear = 0;
869
1059
  if (count === previous) {
870
1060
  if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
871
1061
  } else {
@@ -949,7 +1139,13 @@ async function crawlPage(url, task2 = noopTask, storageStatePath, viewport) {
949
1139
  return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
950
1140
  }
951
1141
  const browserTask = task2("Launching browser");
952
- const browser = await chromium4.launch({ headless: true });
1142
+ let browser;
1143
+ try {
1144
+ browser = await launchHeadlessChromium();
1145
+ } catch (err) {
1146
+ browserTask.succeed("Could not launch a browser");
1147
+ throw err;
1148
+ }
953
1149
  try {
954
1150
  const context = await browser.newContext({
955
1151
  ...storageStatePath ? { storageState: storageStatePath } : {},
@@ -1125,10 +1321,11 @@ async function crawlPage(url, task2 = noopTask, storageStatePath, viewport) {
1125
1321
  });
1126
1322
  }
1127
1323
  }
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;
1324
+ 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
1325
  var init_crawl = __esm({
1130
1326
  "src/lib/crawl.ts"() {
1131
1327
  "use strict";
1328
+ init_headless();
1132
1329
  init_scraping();
1133
1330
  SETTLE_POLL_MS = 400;
1134
1331
  SETTLE_STABLE_POLLS = 2;
@@ -1136,6 +1333,7 @@ var init_crawl = __esm({
1136
1333
  PLACEHOLDER_POLL_MS = 250;
1137
1334
  PLACEHOLDER_CAP_MS = 8e3;
1138
1335
  PLACEHOLDER_STATIC_POLLS = 4;
1336
+ PLACEHOLDER_CLEAR_POLLS = 3;
1139
1337
  PLACEHOLDER_SELECTOR = [
1140
1338
  '[aria-busy="true"]',
1141
1339
  '[role="progressbar"]',
@@ -1287,13 +1485,13 @@ function canEncode(encoder) {
1287
1485
  ]);
1288
1486
  }
1289
1487
  function run(args) {
1290
- return new Promise((resolve12) => {
1488
+ return new Promise((resolve13) => {
1291
1489
  let settled = false;
1292
1490
  const done = (ok) => {
1293
1491
  if (settled) return;
1294
1492
  settled = true;
1295
1493
  clearTimeout(timer);
1296
- resolve12(ok);
1494
+ resolve13(ok);
1297
1495
  };
1298
1496
  const proc = trackChild(spawn3("ffmpeg", args, { stdio: "ignore" }));
1299
1497
  const timer = setTimeout(() => {
@@ -1350,8 +1548,13 @@ async function composeVideo(opts) {
1350
1548
  musicPath,
1351
1549
  musicVolume = 0.3,
1352
1550
  musicTargetLufs = MUSIC_BED_LUFS,
1353
- musicFadeIn = 2,
1354
- musicFadeOut = 2,
1551
+ // Sized for the outro window: the song has 5.5s, so a 2s fade at each end
1552
+ // left 1.5s of music anyone could actually hear. Up fast, hold, out slow.
1553
+ musicFadeIn = 1,
1554
+ musicFadeOut = 1.5,
1555
+ tempo = 1,
1556
+ musicStartSecs = 0,
1557
+ musicDurationSecs = 0,
1355
1558
  width,
1356
1559
  height,
1357
1560
  fps,
@@ -1412,7 +1615,19 @@ async function composeVideo(opts) {
1412
1615
  const clipAudioLabel = opts.hasClipAudio ? "[0:a]" : "";
1413
1616
  const hasVoiceover = !!voiceoverPath;
1414
1617
  const hasMusic = !!musicPath;
1415
- const musicFadeOutStart = Math.max(0, effectiveDuration - musicFadeOut);
1618
+ const bedStart = Math.max(0, Math.min(musicStartSecs, Math.max(0, effectiveDuration - 0.5)));
1619
+ const bedLength = Math.max(0.5, effectiveDuration - bedStart);
1620
+ const bedDelay = bedStart > 0 ? `adelay=${Math.round(bedStart * 1e3)}|${Math.round(bedStart * 1e3)},` : "";
1621
+ const bedTempo = tempo > 1 ? 1 / tempo : 1;
1622
+ const bedSlow = bedTempo === 1 ? "" : `atempo=${bedTempo.toFixed(6)},`;
1623
+ const bedSource = bedLength * bedTempo;
1624
+ const bedSeek = musicDurationSecs > bedSource + 0.05 ? musicDurationSecs - bedSource : 0;
1625
+ const bedTrimIn = bedSeek > 0 ? `atrim=start=${bedSeek.toFixed(3)},asetpts=PTS-STARTPTS,` : "";
1626
+ const bedFadeOut = bedSeek > 0 ? 0 : musicFadeOut * tempo;
1627
+ const bedLoop = bedSeek > 0 ? "" : "aloop=loop=-1:size=2e+09,";
1628
+ const bedFadeIn = musicFadeIn * tempo;
1629
+ const musicFadeOutStart = Math.max(0, bedLength - bedFadeOut);
1630
+ const bedFadeOutFilter = bedFadeOut > 0 ? `afade=t=out:st=${musicFadeOutStart}:d=${bedFadeOut},` : "";
1416
1631
  const vfStr = [...vf, ...encoder.filter ? [encoder.filter] : []].join(",");
1417
1632
  if (hasVoiceover && hasMusic) {
1418
1633
  const mixInputs = clipAudioLabel ? 3 : 2;
@@ -1428,7 +1643,7 @@ async function composeVideo(opts) {
1428
1643
  // card, which is the one place the bed is the only thing playing.
1429
1644
  `[voicekey0]apad=whole_dur=${effectiveDuration}[voicekey]`,
1430
1645
  // 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]`,
1646
+ `[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
1647
  `[bed][voicekey]${DUCK}[music]`,
1433
1648
  // normalize=0, because amix's default divides every input by the number
1434
1649
  // of them: adding the bed used to quietly take 6 dB off the narration,
@@ -1452,7 +1667,7 @@ async function composeVideo(opts) {
1452
1667
  args.push(...encoder.output);
1453
1668
  args.push("-c:a", "aac", "-b:a", audioBitrate);
1454
1669
  } 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}`;
1670
+ 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
1671
  args.push("-vf", vfStr);
1457
1672
  args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
1458
1673
  args.push(...encoder.output);
@@ -1584,7 +1799,7 @@ function cacheKey(filePath) {
1584
1799
  }
1585
1800
  }
1586
1801
  function getVideoDimensions(filePath) {
1587
- return new Promise((resolve12, reject) => {
1802
+ return new Promise((resolve13, reject) => {
1588
1803
  const args = [
1589
1804
  "-v",
1590
1805
  "error",
@@ -1615,12 +1830,12 @@ function getVideoDimensions(filePath) {
1615
1830
  reject(new Error(`ffprobe returned no dimensions for ${filePath}`));
1616
1831
  return;
1617
1832
  }
1618
- resolve12({ width: w, height: h });
1833
+ resolve13({ width: w, height: h });
1619
1834
  });
1620
1835
  });
1621
1836
  }
1622
1837
  function hasAudioStream(filePath) {
1623
- return new Promise((resolve12) => {
1838
+ return new Promise((resolve13) => {
1624
1839
  const proc = spawnFFmpeg("ffprobe", [
1625
1840
  "-v",
1626
1841
  "error",
@@ -1636,15 +1851,15 @@ function hasAudioStream(filePath) {
1636
1851
  proc.stdout.on("data", (d) => {
1637
1852
  out += d;
1638
1853
  });
1639
- proc.on("close", () => resolve12(out.trim().length > 0));
1640
- proc.on("error", () => resolve12(false));
1854
+ proc.on("close", () => resolve13(out.trim().length > 0));
1855
+ proc.on("error", () => resolve13(false));
1641
1856
  });
1642
1857
  }
1643
1858
  function getMediaDuration(filePath) {
1644
1859
  const key = cacheKey(filePath);
1645
1860
  const cached = key === null ? void 0 : durationCache.get(key);
1646
1861
  if (cached !== void 0) return Promise.resolve(cached);
1647
- return new Promise((resolve12, reject) => {
1862
+ return new Promise((resolve13, reject) => {
1648
1863
  const args = [
1649
1864
  "-v",
1650
1865
  "error",
@@ -1671,13 +1886,13 @@ function getMediaDuration(filePath) {
1671
1886
  const ms = Math.ceil(parseFloat(stdout.trim()) * 1e3);
1672
1887
  const freshKey = cacheKey(filePath);
1673
1888
  if (freshKey !== null && Number.isFinite(ms)) durationCache.set(freshKey, ms);
1674
- resolve12(ms);
1889
+ resolve13(ms);
1675
1890
  });
1676
1891
  proc.on("error", reject);
1677
1892
  });
1678
1893
  }
1679
1894
  function getAudioCodec(filePath) {
1680
- return new Promise((resolve12) => {
1895
+ return new Promise((resolve13) => {
1681
1896
  const args = [
1682
1897
  "-v",
1683
1898
  "error",
@@ -1694,8 +1909,8 @@ function getAudioCodec(filePath) {
1694
1909
  proc.stdout.on("data", (data) => {
1695
1910
  stdout += data;
1696
1911
  });
1697
- proc.on("close", (code) => resolve12(code === 0 ? stdout.trim() : ""));
1698
- proc.on("error", () => resolve12(""));
1912
+ proc.on("close", (code) => resolve13(code === 0 ? stdout.trim() : ""));
1913
+ proc.on("error", () => resolve13(""));
1699
1914
  });
1700
1915
  }
1701
1916
  async function isHonestWav(filePath) {
@@ -1849,7 +2064,7 @@ var init_media = __esm({
1849
2064
 
1850
2065
  // src/lib/ffmpeg/silence.ts
1851
2066
  function getVideoStreamDuration(inputPath) {
1852
- return new Promise((resolve12) => {
2067
+ return new Promise((resolve13) => {
1853
2068
  const proc = spawnFFmpeg("ffprobe", [
1854
2069
  "-v",
1855
2070
  "error",
@@ -1866,12 +2081,12 @@ function getVideoStreamDuration(inputPath) {
1866
2081
  proc.stdout.on("data", (d) => {
1867
2082
  out += d;
1868
2083
  });
1869
- proc.on("error", () => resolve12(0));
1870
- proc.on("close", () => resolve12(Number.parseFloat(out.trim()) || 0));
2084
+ proc.on("error", () => resolve13(0));
2085
+ proc.on("close", () => resolve13(Number.parseFloat(out.trim()) || 0));
1871
2086
  });
1872
2087
  }
1873
2088
  function detectSilences(inputPath, minDurSecs) {
1874
- return new Promise((resolve12) => {
2089
+ return new Promise((resolve13) => {
1875
2090
  const proc = spawnFFmpeg("ffmpeg", [
1876
2091
  "-hide_banner",
1877
2092
  "-nostats",
@@ -1890,7 +2105,7 @@ function detectSilences(inputPath, minDurSecs) {
1890
2105
  proc.stderr.on("data", (d) => {
1891
2106
  err += d;
1892
2107
  });
1893
- proc.on("error", () => resolve12([]));
2108
+ proc.on("error", () => resolve13([]));
1894
2109
  proc.on("close", () => {
1895
2110
  const spans = [];
1896
2111
  let start = null;
@@ -1905,12 +2120,12 @@ function detectSilences(inputPath, minDurSecs) {
1905
2120
  start = null;
1906
2121
  }
1907
2122
  }
1908
- resolve12(spans);
2123
+ resolve13(spans);
1909
2124
  });
1910
2125
  });
1911
2126
  }
1912
2127
  function detectFrozenSpans(inputPath, minDurSecs) {
1913
- return new Promise((resolve12) => {
2128
+ return new Promise((resolve13) => {
1914
2129
  const proc = spawnFFmpeg("ffmpeg", [
1915
2130
  "-hide_banner",
1916
2131
  "-nostats",
@@ -1931,7 +2146,7 @@ function detectFrozenSpans(inputPath, minDurSecs) {
1931
2146
  proc.stderr.on("data", (d) => {
1932
2147
  err += d;
1933
2148
  });
1934
- proc.on("error", () => resolve12([]));
2149
+ proc.on("error", () => resolve13([]));
1935
2150
  proc.on("close", () => {
1936
2151
  const spans = [];
1937
2152
  let start = null;
@@ -1944,12 +2159,12 @@ function detectFrozenSpans(inputPath, minDurSecs) {
1944
2159
  start = null;
1945
2160
  }
1946
2161
  }
1947
- resolve12(spans);
2162
+ resolve13(spans);
1948
2163
  });
1949
2164
  });
1950
2165
  }
1951
2166
  function detectBlackSpans(inputPath, minDurSecs) {
1952
- return new Promise((resolve12) => {
2167
+ return new Promise((resolve13) => {
1953
2168
  const proc = spawnFFmpeg("ffmpeg", [
1954
2169
  "-hide_banner",
1955
2170
  "-nostats",
@@ -1972,19 +2187,19 @@ function detectBlackSpans(inputPath, minDurSecs) {
1972
2187
  proc.stderr.on("data", (d) => {
1973
2188
  err += d;
1974
2189
  });
1975
- proc.on("error", () => resolve12([]));
2190
+ proc.on("error", () => resolve13([]));
1976
2191
  proc.on("close", () => {
1977
2192
  const spans = [];
1978
2193
  for (const line of err.split("\n")) {
1979
2194
  const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
1980
2195
  if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
1981
2196
  }
1982
- resolve12(spans);
2197
+ resolve13(spans);
1983
2198
  });
1984
2199
  });
1985
2200
  }
1986
2201
  function detectWhiteSpans(inputPath, minDurSecs) {
1987
- return new Promise((resolve12) => {
2202
+ return new Promise((resolve13) => {
1988
2203
  const proc = spawnFFmpeg("ffmpeg", [
1989
2204
  "-hide_banner",
1990
2205
  "-nostats",
@@ -2002,14 +2217,14 @@ function detectWhiteSpans(inputPath, minDurSecs) {
2002
2217
  proc.stderr.on("data", (d) => {
2003
2218
  err += d;
2004
2219
  });
2005
- proc.on("error", () => resolve12([]));
2220
+ proc.on("error", () => resolve13([]));
2006
2221
  proc.on("close", () => {
2007
2222
  const spans = [];
2008
2223
  for (const line of err.split("\n")) {
2009
2224
  const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
2010
2225
  if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
2011
2226
  }
2012
- resolve12(spans);
2227
+ resolve13(spans);
2013
2228
  });
2014
2229
  });
2015
2230
  }
@@ -2099,7 +2314,7 @@ async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
2099
2314
  const drop = keep.map((s) => `between(t,${s.start.toFixed(3)},${s.end.toFixed(3)})`).join("+");
2100
2315
  const filter = `[0:v]select='not(${drop})',setpts=N/FRAME_RATE/TB[outv]`;
2101
2316
  const enc = await passEncoder(preset);
2102
- return new Promise((resolve12) => {
2317
+ return new Promise((resolve13) => {
2103
2318
  const proc = spawnFFmpeg("ffmpeg", [
2104
2319
  "-y",
2105
2320
  ...filterThreadArgs(),
@@ -2117,8 +2332,8 @@ async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
2117
2332
  outputPath
2118
2333
  ]);
2119
2334
  proc.stderr.resume();
2120
- proc.on("error", () => resolve12(false));
2121
- proc.on("close", (code) => resolve12(code === 0));
2335
+ proc.on("error", () => resolve13(false));
2336
+ proc.on("close", (code) => resolve13(code === 0));
2122
2337
  });
2123
2338
  }
2124
2339
  function buildRetimeFilter(spans, stepBounds, speed, fps) {
@@ -2200,7 +2415,7 @@ async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, spee
2200
2415
  const filter = buildRetimeFilter(spans, stepBounds, speed, fps);
2201
2416
  if (!filter) return false;
2202
2417
  const enc = await passEncoder(preset);
2203
- return new Promise((resolve12) => {
2418
+ return new Promise((resolve13) => {
2204
2419
  const proc = spawnFFmpeg("ffmpeg", [
2205
2420
  "-y",
2206
2421
  ...filterThreadArgs(),
@@ -2218,8 +2433,8 @@ async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, spee
2218
2433
  outputPath
2219
2434
  ]);
2220
2435
  proc.stderr.resume();
2221
- proc.on("error", () => resolve12(false));
2222
- proc.on("close", (code) => resolve12(code === 0));
2436
+ proc.on("error", () => resolve13(false));
2437
+ proc.on("close", (code) => resolve13(code === 0));
2223
2438
  });
2224
2439
  }
2225
2440
  async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, preset, protectAfterSecs, narrationPath, protectBeforeSecs, leadSpans, keepSpans) {
@@ -2245,7 +2460,7 @@ async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, pr
2245
2460
  const filter = buildCompressionFilter(spans, totalSecs, maxSecs, protectAfterSecs, protectBeforeSecs);
2246
2461
  if (!filter) return false;
2247
2462
  const enc = await passEncoder(preset);
2248
- return new Promise((resolve12) => {
2463
+ return new Promise((resolve13) => {
2249
2464
  const proc = spawnFFmpeg("ffmpeg", [
2250
2465
  "-y",
2251
2466
  ...filterThreadArgs(),
@@ -2271,8 +2486,8 @@ async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, pr
2271
2486
  outputPath
2272
2487
  ]);
2273
2488
  proc.stderr.resume();
2274
- proc.on("error", () => resolve12(false));
2275
- proc.on("close", (code) => resolve12(code === 0));
2489
+ proc.on("error", () => resolve13(false));
2490
+ proc.on("close", (code) => resolve13(code === 0));
2276
2491
  });
2277
2492
  }
2278
2493
  async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, introSecs, outroSecs, maxFactor, preset) {
@@ -2296,7 +2511,7 @@ async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, intro
2296
2511
  "[v0][a0][v1][a1][v2][a2]concat=n=3:v=1:a=1[outv][outa]"
2297
2512
  ].join(";");
2298
2513
  const enc = await passEncoder(preset);
2299
- const ok = await new Promise((resolve12) => {
2514
+ const ok = await new Promise((resolve13) => {
2300
2515
  const proc = spawnFFmpeg("ffmpeg", [
2301
2516
  "-y",
2302
2517
  ...filterThreadArgs(),
@@ -2317,8 +2532,8 @@ async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, intro
2317
2532
  outputPath
2318
2533
  ]);
2319
2534
  proc.stderr.resume();
2320
- proc.on("error", () => resolve12(false));
2321
- proc.on("close", (code) => resolve12(code === 0));
2535
+ proc.on("error", () => resolve13(false));
2536
+ proc.on("close", (code) => resolve13(code === 0));
2322
2537
  });
2323
2538
  return { ok, factor, reachedTarget: needed <= maxFactor };
2324
2539
  }
@@ -2354,7 +2569,7 @@ async function spliceVideos(opts) {
2354
2569
  const audioPresent = await Promise.all(
2355
2570
  overlays.map((o) => o.still ? Promise.resolve(false) : hasAudioStream(o.path))
2356
2571
  );
2357
- return new Promise((resolve12, reject) => {
2572
+ return new Promise((resolve13, reject) => {
2358
2573
  const args = ["-y", "-i", inputPath];
2359
2574
  for (const overlay of overlays) {
2360
2575
  if (overlay.still) {
@@ -2417,7 +2632,7 @@ async function spliceVideos(opts) {
2417
2632
  args.push("-c:v", "libx264", "-preset", preset, "-crf", "18", "-pix_fmt", "yuv420p");
2418
2633
  if (clipAudio.length > 0) args.push("-c:a", "aac");
2419
2634
  args.push(outputPath);
2420
- runFFmpeg(args, "ffmpeg splice failed").then(resolve12, reject);
2635
+ runFFmpeg(args, "ffmpeg splice failed").then(resolve13, reject);
2421
2636
  });
2422
2637
  }
2423
2638
  var CLIP_AUDIO_GAIN, STILL_FRAMERATE, COVER_WASH_W, COVER_WASH_H, COVER_BLUR_SIGMA;
@@ -2453,10 +2668,10 @@ function buildStepTransitions(steps, stepDurations, defaultTransition) {
2453
2668
  return transitions;
2454
2669
  }
2455
2670
  function applyTransitions(opts) {
2456
- return new Promise((resolve12, reject) => {
2671
+ return new Promise((resolve13, reject) => {
2457
2672
  const { inputVideo, outputVideo, transitions, videoDuration, preset = "fast" } = opts;
2458
2673
  if (transitions.length === 0) {
2459
- runFFmpeg(["-y", "-i", inputVideo, "-c", "copy", outputVideo], "FFmpeg copy failed").then(resolve12, reject);
2674
+ runFFmpeg(["-y", "-i", inputVideo, "-c", "copy", outputVideo], "FFmpeg copy failed").then(resolve13, reject);
2460
2675
  return;
2461
2676
  }
2462
2677
  const sorted = [...transitions].sort((a, b) => a.timestamp - b.timestamp);
@@ -2504,7 +2719,7 @@ function applyTransitions(opts) {
2504
2719
  "yuv420p",
2505
2720
  outputVideo
2506
2721
  ];
2507
- runFFmpeg(args, "FFmpeg transitions failed").then(resolve12, reject);
2722
+ runFFmpeg(args, "FFmpeg transitions failed").then(resolve13, reject);
2508
2723
  });
2509
2724
  }
2510
2725
  var init_transitions = __esm({
@@ -2544,114 +2759,6 @@ var init_ffmpeg2 = __esm({
2544
2759
  }
2545
2760
  });
2546
2761
 
2547
- // src/lib/json-repair.ts
2548
- function repairUnicodeEscapes(content) {
2549
- return content.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (match, hex) => {
2550
- const code = Number.parseInt(hex, 16);
2551
- return code <= 1114111 ? String.fromCodePoint(code) : match;
2552
- }).replace(/\\u(?![0-9a-fA-F]{4})/g, "\\\\u");
2553
- }
2554
- function salvageTruncatedJson(content) {
2555
- const stack = [];
2556
- const cuts = [];
2557
- let inString = false;
2558
- let escaped = false;
2559
- const record = (index, closesContainer) => cuts.push({ index, open: stack.join(""), closesContainer });
2560
- for (let i = 0; i < content.length; i++) {
2561
- const ch = content[i];
2562
- if (inString) {
2563
- if (escaped) escaped = false;
2564
- else if (ch === "\\") escaped = true;
2565
- else if (ch === '"') {
2566
- inString = false;
2567
- record(i + 1, false);
2568
- }
2569
- continue;
2570
- }
2571
- if (ch === '"') {
2572
- inString = true;
2573
- } else if (ch === "{" || ch === "[") {
2574
- stack.push(ch);
2575
- } else if (ch === "}" || ch === "]") {
2576
- stack.pop();
2577
- record(i + 1, true);
2578
- } else if (ch === ",") {
2579
- record(i, content[i - 1] === "}" || content[i - 1] === "]");
2580
- }
2581
- }
2582
- const tryCut = (cut) => {
2583
- const head = content.slice(0, cut.index).replace(/[\s,]+$/, "");
2584
- if (head.endsWith(":")) return null;
2585
- let closed = head;
2586
- for (let d = cut.open.length - 1; d >= 0; d--) {
2587
- closed += CLOSERS[cut.open[d]];
2588
- }
2589
- try {
2590
- JSON.parse(closed);
2591
- return closed;
2592
- } catch {
2593
- return null;
2594
- }
2595
- };
2596
- const attempts = Math.min(cuts.length, MAX_SALVAGE_ATTEMPTS);
2597
- for (const containerOnly of [true, false]) {
2598
- for (let n = 0; n < attempts; n++) {
2599
- const cut = cuts[cuts.length - 1 - n];
2600
- if (containerOnly && !cut.closesContainer) continue;
2601
- const salvaged = tryCut(cut);
2602
- if (salvaged !== null) return salvaged;
2603
- }
2604
- }
2605
- return null;
2606
- }
2607
- function extractJsonCandidates(content) {
2608
- let text = content.trim();
2609
- const fence = text.match(/```(?:json)?\s*([\s\S]*?)(?:```|$)/);
2610
- if (fence?.[1]?.trim()) text = fence[1].trim();
2611
- const start = text.search(/[{[]/);
2612
- if (start === -1) return [];
2613
- const closer = text[start] === "{" ? "}" : "]";
2614
- const end = text.lastIndexOf(closer);
2615
- const candidates = [text.slice(start)];
2616
- if (end > start && end < text.length - 1) candidates.unshift(text.slice(start, end + 1));
2617
- return candidates;
2618
- }
2619
- function parseAIJson(content) {
2620
- const candidates = extractJsonCandidates(content);
2621
- if (candidates.length === 0) return null;
2622
- for (const text of candidates) {
2623
- try {
2624
- return { value: JSON.parse(text), repaired: false };
2625
- } catch {
2626
- }
2627
- }
2628
- for (const text of candidates) {
2629
- try {
2630
- return { value: JSON.parse(repairUnicodeEscapes(text)), repaired: true };
2631
- } catch {
2632
- }
2633
- }
2634
- for (const text of candidates) {
2635
- for (const variant of [repairUnicodeEscapes(text), text]) {
2636
- const salvaged = salvageTruncatedJson(variant);
2637
- if (salvaged === null) continue;
2638
- try {
2639
- return { value: JSON.parse(salvaged), repaired: true };
2640
- } catch {
2641
- }
2642
- }
2643
- }
2644
- return null;
2645
- }
2646
- var MAX_SALVAGE_ATTEMPTS, CLOSERS;
2647
- var init_json_repair = __esm({
2648
- "src/lib/json-repair.ts"() {
2649
- "use strict";
2650
- MAX_SALVAGE_ATTEMPTS = 500;
2651
- CLOSERS = { "{": "}", "[": "]" };
2652
- }
2653
- });
2654
-
2655
2762
  // src/lib/prompts.ts
2656
2763
  function buildSystemPrompt(maxSteps, profile) {
2657
2764
  let scope;
@@ -2977,6 +3084,9 @@ function renderHormoziAss(ctx) {
2977
3084
  return `${header2}${lines.join("\n")}
2978
3085
  `;
2979
3086
  }
3087
+ function looksLikeAddress(token) {
3088
+ return /^(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?[.,!?]?$/i.test(token);
3089
+ }
2980
3090
  function renderShortsAss(ctx) {
2981
3091
  const { captions, width, height } = ctx;
2982
3092
  const font = resolveCaptionFont(captions.font === "Arial" ? "Arial Black" : captions.font);
@@ -2987,7 +3097,7 @@ function renderShortsAss(ctx) {
2987
3097
  const alignment = positionToAlignment(captions.position);
2988
3098
  const outline = Math.max(2, Math.round(fontSize / 22));
2989
3099
  const upper = true;
2990
- const caseOf = (t) => toCaptionCase(t, upper);
3100
+ const caseOf = (t) => looksLikeAddress(t) ? t : toCaptionCase(t, upper);
2991
3101
  const maxChars = fittingMaxChars(width, fontSize, SIDE_MARGIN, captions.maxChars, upper);
2992
3102
  const header2 = `[Script Info]
2993
3103
  Title: QuickPeek Demo
@@ -3549,6 +3659,8 @@ asyncio.run(main())
3549
3659
  await writeFile3(tempScriptPath, pythonScript);
3550
3660
  const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
3551
3661
  await execAsync(pythonCmd, { timeout: 3e4 });
3662
+ const written = await stat(outputPath).catch(() => null);
3663
+ if (!written?.size) throw new Error("edge-tts produced no audio");
3552
3664
  return await readWordsSidecar(outputPath);
3553
3665
  } finally {
3554
3666
  await unlink4(tempScriptPath).catch(() => {
@@ -3736,11 +3848,17 @@ function spellAcronyms(text) {
3736
3848
  );
3737
3849
  }
3738
3850
  function bindDomains(text) {
3739
- return text.replace(/\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi, "$1-dot-$2");
3851
+ 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(
3852
+ /\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi,
3853
+ (_m, host, tld) => `${host}-dot-${SPOKEN_TLD[tld.toLowerCase()] ?? tld}`
3854
+ );
3740
3855
  }
3741
3856
  function speakSymbols(text) {
3742
3857
  return text.replace(/\s*&\s*/g, " and ").replace(/[<>]/g, " ").replace(/\s{2,}/g, " ").trim();
3743
3858
  }
3859
+ function spokenForm(caption) {
3860
+ return speakNumbers(spellAcronyms(toSpokenForm(bindDomains(speakSymbols(caption)))));
3861
+ }
3744
3862
  async function generateVoiceover(steps, config, workDir, outputDir, logError2, task2, info2) {
3745
3863
  resetVoiceEngine();
3746
3864
  const voice = resolveVoice2(config);
@@ -3787,7 +3905,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
3787
3905
  }
3788
3906
  const audioPath = join9(audioDir, `step-${stepNum}.mp3`);
3789
3907
  try {
3790
- const spokenText = speakNumbers(spellAcronyms(toSpokenForm(bindDomains(speakSymbols(step.caption)))));
3908
+ const spokenText = spokenForm(step.caption);
3791
3909
  const key = clipKey(spokenText, voice, ratePercent ?? "");
3792
3910
  const cacheDir = join9(outputDir, ".tts-clips");
3793
3911
  const cached = await reuseClip(cacheDir, key, audioPath);
@@ -3868,7 +3986,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
3868
3986
  await saveVoiceoverMeta(outputDir, meta);
3869
3987
  return { voiceoverPath, stepAudio };
3870
3988
  }
3871
- var TTS_POOL, ONES, TENS, SPELLED_OUT, LETTER_SOUNDS;
3989
+ var TTS_POOL, ONES, TENS, SPELLED_OUT, LETTER_SOUNDS, SPOKEN_TLD;
3872
3990
  var init_generate = __esm({
3873
3991
  "src/lib/tts/generate.ts"() {
3874
3992
  "use strict";
@@ -3933,6 +4051,9 @@ var init_generate = __esm({
3933
4051
  Y: "why",
3934
4052
  Z: "zed"
3935
4053
  };
4054
+ SPOKEN_TLD = {
4055
+ ai: "ay-eye"
4056
+ };
3936
4057
  }
3937
4058
  });
3938
4059
 
@@ -3992,6 +4113,7 @@ __export(lib_exports, {
3992
4113
  NO_CLIP_OUTRO: () => NO_CLIP_OUTRO,
3993
4114
  PLAN_MAX_TOKENS: () => PLAN_MAX_TOKENS,
3994
4115
  RATE_FACTOR: () => RATE_FACTOR,
4116
+ RELAY_BASE: () => RELAY_BASE,
3995
4117
  SERVER_BACKOFF_MS: () => SERVER_BACKOFF_MS,
3996
4118
  SIZE_PRESETS: () => SIZE_PRESETS,
3997
4119
  TRIM_EDGE_SILENCE: () => TRIM_EDGE_SILENCE,
@@ -4016,6 +4138,7 @@ __export(lib_exports, {
4016
4138
  buildUserPrompt: () => buildUserPrompt,
4017
4139
  buildWatermarkFilters: () => buildWatermarkFilters,
4018
4140
  callAI: () => callAI,
4141
+ callAIForPlan: () => callAIForPlan,
4019
4142
  callAIViaRelay: () => callAIViaRelay,
4020
4143
  canReuseVoiceover: () => canReuseVoiceover,
4021
4144
  captionCanvas: () => captionCanvas,
@@ -4043,6 +4166,7 @@ __export(lib_exports, {
4043
4166
  getErrorMessage: () => getErrorMessage,
4044
4167
  getLanguageName: () => getLanguageName,
4045
4168
  getMediaDuration: () => getMediaDuration,
4169
+ getOrRefreshRelayToken: () => getOrRefreshRelayToken,
4046
4170
  getStatus: () => getStatus,
4047
4171
  getStatusCached: () => getStatusCached,
4048
4172
  getTierByEmail: () => getTierByEmail,
@@ -4086,12 +4210,12 @@ __export(lib_exports, {
4086
4210
  runPooled: () => runPooled,
4087
4211
  salvageTruncatedJson: () => salvageTruncatedJson,
4088
4212
  shouldSkipLink: () => shouldSkipLink,
4089
- shouldWatermark: () => shouldWatermark,
4090
4213
  showPaywall: () => showPaywall,
4091
4214
  speakSymbols: () => speakSymbols,
4092
4215
  speedUpMiddle: () => speedUpMiddle,
4093
4216
  speedUpVideo: () => speedUpVideo,
4094
4217
  spliceVideos: () => spliceVideos,
4218
+ spokenForm: () => spokenForm,
4095
4219
  spokenWordTimings: () => spokenWordTimings,
4096
4220
  stripLongDashes: () => stripLongDashes,
4097
4221
  synthesizeSpeech: () => synthesizeSpeech,
@@ -4401,7 +4525,10 @@ async function loadCaptions() {
4401
4525
  async function savePlan(plan, planPath) {
4402
4526
  const cleanPlan = {
4403
4527
  ...plan,
4404
- steps: plan.steps.map(({ duration, ...step }) => step)
4528
+ steps: plan.steps.map(({ duration, ...step }) => ({
4529
+ ...step,
4530
+ ...typeof duration === "number" && duration > 0 ? { duration: Math.min(duration, 30) } : {}
4531
+ }))
4405
4532
  };
4406
4533
  await mkdir4(dirname2(planPath), { recursive: true });
4407
4534
  await writeFile5(planPath, JSON.stringify(cleanPlan, null, 2));
@@ -4514,55 +4641,16 @@ var init_mcp_context = __esm({
4514
4641
  }
4515
4642
  });
4516
4643
 
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
4644
  // 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
4645
  function escapeHtml(text) {
4561
4646
  return text.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
4562
4647
  }
4563
4648
  function buildOutroCardHtml(options) {
4564
4649
  const { subject, credits = [], promo, width, height } = options;
4565
4650
  const unit = Math.min(width, height);
4651
+ const markFraction = width > height ? 0.28 : 0.46;
4652
+ const markSize = unit * markFraction;
4653
+ const padTop = width > height ? unit * 0.08 : unit * 0.23;
4566
4654
  const creditRows = credits.map((credit) => {
4567
4655
  const icon = credit.iconPath ? dataUri(credit.iconPath) : null;
4568
4656
  return `<div class="credit">
@@ -4571,7 +4659,8 @@ function buildOutroCardHtml(options) {
4571
4659
  ${icon ? `<img class="cicon" src="${icon}" alt="" />` : ""}
4572
4660
  </div>`;
4573
4661
  }).join("");
4574
- const subjectIcon = subject.iconPath ? dataUri(subject.iconPath) : null;
4662
+ const animated = subject.animatedMark;
4663
+ const subjectIcon = animated ? null : subject.iconPath ? dataUri(subject.iconPath) : null;
4575
4664
  return `<!doctype html>
4576
4665
  <html><head><meta charset="utf-8" /><style>
4577
4666
  * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -4589,7 +4678,7 @@ function buildOutroCardHtml(options) {
4589
4678
  position: relative;
4590
4679
  display: flex; flex-direction: column;
4591
4680
  align-items: center; justify-content: flex-start;
4592
- padding-top: ${unit * 0.23}px;
4681
+ padding-top: ${padTop}px;
4593
4682
  gap: ${unit * 0.028}px;
4594
4683
  }
4595
4684
  /* Toolchain credits: top right, deliberately small. */
@@ -4607,7 +4696,7 @@ function buildOutroCardHtml(options) {
4607
4696
  /* The app the video is about. */
4608
4697
  /* The app's mark leads the card, and it is meant to be seen: at 46% of
4609
4698
  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; }
4699
+ .mark { width: ${markSize}px; height: ${markSize}px; object-fit: contain; border-radius: ${unit * 0.09}px; }
4611
4700
  /* A dark mark on a dark card is invisible, which looks exactly like a
4612
4701
  failed image. Sites that publish a black-on-transparent logo are common
4613
4702
  enough to be worth catching, and a light plate is what their own site
@@ -4629,8 +4718,12 @@ function buildOutroCardHtml(options) {
4629
4718
  }
4630
4719
 
4631
4720
  /* Today's offer, along the very bottom edge. */
4721
+ /* In the flow, not pinned to the bottom edge. Pinned, it was laid out
4722
+ without reference to the text above it, so on a short canvas it simply
4723
+ covered whatever had grown into its space. margin-top pushes it clear of
4724
+ the domain instead. */
4632
4725
  .promo {
4633
- position: absolute; bottom: ${unit * 0.2}px;
4726
+ margin-top: ${unit * 0.05}px;
4634
4727
  display: flex; align-items: baseline; gap: ${unit * 0.022}px;
4635
4728
  padding: ${unit * 0.026}px ${unit * 0.055}px;
4636
4729
  border: 2px solid rgba(255, 209, 64, .85); border-radius: ${unit * 0.024}px;
@@ -4638,9 +4731,60 @@ function buildOutroCardHtml(options) {
4638
4731
  }
4639
4732
  .ppct { font-size: ${unit * 0.056}px; font-weight: 700; color: #ffd140; }
4640
4733
  .pcode { font-size: ${unit * 0.048}px; font-weight: 700; letter-spacing: .05em; }
4734
+
4735
+ /* The animated mark, sized to the same 46% slot the still one occupies so
4736
+ the rest of the card's rhythm is unchanged. Ported from VidLet's
4737
+ src/gui/css/loader.css: the amber tip flies off and snaps back into the
4738
+ cut - a jump cut, on a loop. Pure CSS, because the renderer steps these
4739
+ animations frame by frame and a JS-driven one could not be stepped.
4740
+
4741
+ No prefers-reduced-motion fallback here, unlike the original: this runs
4742
+ in a headless Chromium rendering a video, where there is no viewer whose
4743
+ preference could be read, and honouring the default would silently flatten
4744
+ the animation to a fade in CI. */
4745
+ .amark { width: ${markSize}px; height: ${markSize}px; display: block; }
4746
+ .amark path {
4747
+ /* Without fill-box the origin is the whole SVG viewport, which swings the
4748
+ tip off its own centre on the scale steps. */
4749
+ transform-box: fill-box;
4750
+ transform-origin: center;
4751
+ }
4752
+ .amark .vl-body { fill: #fff6e5; stroke: #fff6e5; animation: vl-nudge 1.4s cubic-bezier(.4,0,.2,1) infinite; }
4753
+ .amark .vl-tip { fill: #ffc53d; stroke: #ffc53d; animation: vl-cut 1.4s cubic-bezier(.4,0,.2,1) infinite; }
4754
+ .amark .vl-ghost { opacity: .35; }
4755
+ .amark .vl-ghost .vl-tip { animation-delay: -.1s; }
4756
+
4757
+ /* Translations are in viewBox units, so the motion scales with the mark. */
4758
+ @keyframes vl-cut {
4759
+ 0%, 30% { transform: translateX(0) scale(1); opacity: 1; }
4760
+ 48% { transform: translateX(62px) scale(.94); opacity: 1; }
4761
+ 60% { transform: translateX(88px) scale(.86); opacity: 0; }
4762
+ 61%, 74% { transform: translateX(-58px) scale(.86); opacity: 0; }
4763
+ 86% { transform: translateX(6px) scale(1.06); opacity: 1; }
4764
+ 100% { transform: translateX(0) scale(1); opacity: 1; }
4765
+ }
4766
+ /* The body takes the recoil, so the cut lands rather than just sliding. */
4767
+ @keyframes vl-nudge {
4768
+ 0%, 30% { transform: scaleX(1); }
4769
+ 55% { transform: scaleX(1.04); }
4770
+ 86% { transform: scaleX(.97); }
4771
+ 100% { transform: scaleX(1); }
4772
+ }
4773
+ ${/* The card normally reads its palette off the mark's pixels, and an SVG
4774
+ drawn by this stylesheet has none to read - the measuring script finds
4775
+ no <img class="mark"> and leaves the defaults, which would put VidLet's
4776
+ amber on a teal card. Stated instead of measured, last so it wins. */
4777
+ animated === "vidlet" ? ":root { --bg-in: #1f1a10; --bg-out: #07060a; --accent: #ffc53d; }" : ""}
4641
4778
  </style></head>
4642
4779
  <body>
4643
4780
  ${credits.length ? `<div class="credits">${creditRows}</div>` : ""}
4781
+ ${animated === "vidlet" ? `<svg class="amark" viewBox="0 0 512 512" aria-hidden="true">
4782
+ <g stroke-linejoin="round" stroke-width="20">
4783
+ <g class="vl-ghost"><path class="vl-tip" d="M318 246 L410 302 L318 358 Z"/></g>
4784
+ <path class="vl-body" d="M176 124 L300 200 L300 312 L176 388 Z"/>
4785
+ <path class="vl-tip" d="M318 246 L410 302 L318 358 Z"/>
4786
+ </g>
4787
+ </svg>` : ""}
4644
4788
  ${subjectIcon ? `<img class="mark" src="${subjectIcon}" alt="" />` : ""}
4645
4789
  <div class="name">${escapeHtml(subject.name)}</div>
4646
4790
  ${subject.tagline ? `<div class="tagline">${escapeHtml(subject.tagline)}</div>` : ""}
@@ -4743,10 +4887,18 @@ function buildOutroCardHtml(options) {
4743
4887
  </script>
4744
4888
  </body></html>`;
4745
4889
  }
4890
+ var ANIMATED_MARK_LOOP_SECONDS, dataUri;
4746
4891
  var init_outro_card = __esm({
4747
4892
  "src/lib/outro-card.ts"() {
4748
4893
  "use strict";
4749
4894
  init_image_kind();
4895
+ ANIMATED_MARK_LOOP_SECONDS = {
4896
+ // Matches `vl-cut` / `vl-nudge` in VidLet's src/gui/css/loader.css. Changing
4897
+ // it there without changing it here makes the encoded loop stutter at the
4898
+ // seam, because the clip is cut to exactly this length and then repeated.
4899
+ vidlet: 1.4
4900
+ };
4901
+ dataUri = imageDataUri;
4750
4902
  }
4751
4903
  });
4752
4904
 
@@ -4756,8 +4908,8 @@ __export(render_outro_exports, {
4756
4908
  renderOutroClip: () => renderOutroClip,
4757
4909
  renderOutroPng: () => renderOutroPng
4758
4910
  });
4759
- import { writeFile as writeFile8 } from "fs/promises";
4760
- import { join as join15, resolve as resolve5 } from "path";
4911
+ import { mkdir as mkdir7, rm as rm2, writeFile as writeFile8 } from "fs/promises";
4912
+ import { join as join15, resolve as resolve6 } from "path";
4761
4913
  import { pathToFileURL as pathToFileURL2 } from "url";
4762
4914
  import { chromium as chromium6 } from "playwright";
4763
4915
  async function renderOutroPng(options) {
@@ -4768,7 +4920,7 @@ async function renderOutroPng(options) {
4768
4920
  const browser = await chromium6.launch({ headless: true });
4769
4921
  try {
4770
4922
  const page = await browser.newPage({ viewport: { width, height } });
4771
- await page.goto(pathToFileURL2(resolve5(htmlPath)).href, { waitUntil: "load" });
4923
+ await page.goto(pathToFileURL2(resolve6(htmlPath)).href, { waitUntil: "load" });
4772
4924
  await page.waitForSelector('body[data-cards-ready="1"]', { timeout: 3e3 }).catch(() => page.waitForTimeout(300));
4773
4925
  await page.screenshot({ path: pngPath });
4774
4926
  } finally {
@@ -4781,11 +4933,14 @@ async function renderOutroClip(options) {
4781
4933
  const htmlPath = join15(workDir, "outro-card.html");
4782
4934
  const pngPath = join15(workDir, "outro-card.png");
4783
4935
  const clipPath = join15(workDir, "outro-card.mp4");
4936
+ if (options.subject.animatedMark) {
4937
+ return renderAnimatedOutroClip(options, { htmlPath, clipPath, seconds, fps });
4938
+ }
4784
4939
  await writeFile8(htmlPath, buildOutroCardHtml(options));
4785
4940
  const browser = await chromium6.launch({ headless: true });
4786
4941
  try {
4787
4942
  const page = await browser.newPage({ viewport: { width, height } });
4788
- await page.goto(pathToFileURL2(resolve5(htmlPath)).href, { waitUntil: "load" });
4943
+ await page.goto(pathToFileURL2(resolve6(htmlPath)).href, { waitUntil: "load" });
4789
4944
  await page.waitForSelector('body[data-cards-ready="1"]', { timeout: 3e3 }).catch(() => {
4790
4945
  });
4791
4946
  await page.waitForTimeout(300);
@@ -4823,6 +4978,70 @@ async function renderOutroClip(options) {
4823
4978
  );
4824
4979
  return clipPath;
4825
4980
  }
4981
+ async function renderAnimatedOutroClip(options, paths) {
4982
+ const { htmlPath, clipPath, seconds, fps } = paths;
4983
+ const { workDir, width, height } = options;
4984
+ const loopSeconds = ANIMATED_MARK_LOOP_SECONDS[options.subject.animatedMark];
4985
+ const frameCount = Math.max(1, Math.round(loopSeconds * fps));
4986
+ const framesDir = join15(workDir, "outro-frames");
4987
+ await rm2(framesDir, { recursive: true, force: true });
4988
+ await mkdir7(framesDir, { recursive: true });
4989
+ await writeFile8(htmlPath, buildOutroCardHtml(options));
4990
+ const browser = await chromium6.launch({ headless: true });
4991
+ try {
4992
+ const page = await browser.newPage({ viewport: { width, height } });
4993
+ await page.goto(pathToFileURL2(resolve6(htmlPath)).href, { waitUntil: "load" });
4994
+ await page.waitForSelector('body[data-cards-ready="1"]', { timeout: 3e3 }).catch(() => {
4995
+ });
4996
+ await page.waitForTimeout(300);
4997
+ await page.evaluate(() => {
4998
+ for (const animation of document.getAnimations()) animation.pause();
4999
+ });
5000
+ for (let frame = 0; frame < frameCount; frame++) {
5001
+ const elapsedMs = frame / frameCount * loopSeconds * 1e3;
5002
+ await page.evaluate((ms) => {
5003
+ for (const animation of document.getAnimations()) animation.currentTime = ms;
5004
+ }, elapsedMs);
5005
+ await page.screenshot({ path: join15(framesDir, `f-${String(frame).padStart(4, "0")}.png`) });
5006
+ }
5007
+ } finally {
5008
+ await browser.close();
5009
+ }
5010
+ await runFFmpeg(
5011
+ [
5012
+ "-y",
5013
+ // -stream_loop applies to the input that FOLLOWS it, so it has to sit
5014
+ // before -framerate/-i, not after.
5015
+ "-stream_loop",
5016
+ "-1",
5017
+ "-framerate",
5018
+ String(fps),
5019
+ "-i",
5020
+ join15(framesDir, "f-%04d.png"),
5021
+ "-f",
5022
+ "lavfi",
5023
+ "-i",
5024
+ "anullsrc=channel_layout=stereo:sample_rate=44100",
5025
+ "-t",
5026
+ String(seconds),
5027
+ "-r",
5028
+ String(fps),
5029
+ "-pix_fmt",
5030
+ "yuv420p",
5031
+ "-c:v",
5032
+ "libx264",
5033
+ "-preset",
5034
+ "veryfast",
5035
+ "-c:a",
5036
+ "aac",
5037
+ "-shortest",
5038
+ clipPath
5039
+ ],
5040
+ "ffmpeg animated outro card render failed"
5041
+ );
5042
+ await rm2(framesDir, { recursive: true, force: true });
5043
+ return clipPath;
5044
+ }
4826
5045
  var init_render_outro = __esm({
4827
5046
  "src/browser/render-outro.ts"() {
4828
5047
  "use strict";
@@ -4890,11 +5109,11 @@ __export(external_clips_exports, {
4890
5109
  findStepClips: () => findStepClips,
4891
5110
  loadExternalNarration: () => loadExternalNarration
4892
5111
  });
4893
- import { existsSync as existsSync21, mkdirSync as mkdirSync2, readdirSync as readdirSync4, rmSync as rmSync3 } from "fs";
5112
+ import { existsSync as existsSync22, mkdirSync as mkdirSync2, readdirSync as readdirSync4, rmSync as rmSync3 } from "fs";
4894
5113
  import { join as join27 } from "path";
4895
5114
  function findStepClips(dir) {
4896
5115
  const clips = /* @__PURE__ */ new Map();
4897
- if (!existsSync21(dir)) return clips;
5116
+ if (!existsSync22(dir)) return clips;
4898
5117
  for (const name of readdirSync4(dir)) {
4899
5118
  const match = /^step-0*(\d+)\.(wav|mp3|m4a|flac|ogg)$/i.exec(name);
4900
5119
  if (match?.[1]) clips.set(Number(match[1]), join27(dir, name));
@@ -5147,8 +5366,8 @@ async function fileResult(output, data, poster) {
5147
5366
  }
5148
5367
 
5149
5368
  // 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";
5369
+ import { existsSync as existsSync27, mkdirSync as mkdirSync3, readdirSync as readdirSync6, readFileSync as readFileSync10, statSync as statSync4 } from "fs";
5370
+ import { basename as basename5, dirname as dirname5, join as join33, resolve as resolve12 } from "path";
5152
5371
  import { MALE_VOICES as MALE_VOICES3, WIDE_VOICES as WIDE_VOICES3 } from "@spark-apps/video-kit";
5153
5372
 
5154
5373
  // src/browser/auth-state.ts
@@ -5261,13 +5480,107 @@ function resolveStartUrl(url) {
5261
5480
  // src/browser/launch.ts
5262
5481
  init_lifecycle();
5263
5482
  import { mkdir as mkdir2 } from "fs/promises";
5264
- import { chromium as chromium2 } from "playwright";
5483
+ import { chromium as chromium3 } from "playwright";
5484
+
5485
+ // src/browser/brand-watermark.ts
5486
+ init_image_kind();
5487
+ function watermarkInitScript(payload) {
5488
+ return `(() => {
5489
+ var ID = '__quickpeek_brand_watermark';
5490
+ var P = ${embedJson(payload)};
5491
+ var vertical = P.position.indexOf('top') === 0 ? 'top:22px' : 'bottom:22px';
5492
+ var horizontal = P.position.indexOf('right') > -1 ? 'right:24px' : 'left:24px';
5493
+ var SHELL = [
5494
+ 'position:fixed',
5495
+ vertical,
5496
+ horizontal,
5497
+ 'z-index:2147483646',
5498
+ 'display:flex',
5499
+ 'align-items:center',
5500
+ 'gap:10px',
5501
+ 'padding:8px 16px 8px 10px',
5502
+ 'border-radius:999px',
5503
+ // A hairline and a blur rather than a solid plate: the mark sits on top of
5504
+ // whatever the app is showing, and a hard box reads as part of the UI.
5505
+ // The alpha is high enough to stay dark over a LIGHT app: at 0.55 the blur
5506
+ // averaged a pale page up into grey and the white label sat on it at about
5507
+ // 2:1, which photographs as a smudge.
5508
+ 'background:rgba(12,12,18,0.78)',
5509
+ 'border:1px solid rgba(255,255,255,0.14)',
5510
+ 'backdrop-filter:blur(14px) saturate(1.3)',
5511
+ '-webkit-backdrop-filter:blur(14px) saturate(1.3)',
5512
+ 'box-shadow:0 6px 24px rgba(0,0,0,0.30)',
5513
+ 'opacity:' + P.opacity,
5514
+ 'pointer-events:none',
5515
+ 'user-select:none'
5516
+ ].join(';');
5517
+ var ICON = 'width:26px;height:26px;border-radius:7px;object-fit:contain;display:block';
5518
+ var NAME = [
5519
+ 'font:600 15px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
5520
+ 'letter-spacing:0.015em',
5521
+ 'color:#ffffff',
5522
+ 'white-space:nowrap'
5523
+ ].join(';');
5524
+
5525
+ function mount() {
5526
+ try {
5527
+ if (!document.body || document.getElementById(ID)) return;
5528
+ var el = document.createElement('div');
5529
+ el.id = ID;
5530
+ el.setAttribute('style', SHELL);
5531
+ if (P.icon) {
5532
+ var img = document.createElement('img');
5533
+ img.setAttribute('style', ICON);
5534
+ img.src = P.icon;
5535
+ img.alt = '';
5536
+ el.appendChild(img);
5537
+ }
5538
+ var label = document.createElement('span');
5539
+ label.setAttribute('style', NAME);
5540
+ label.textContent = P.name;
5541
+ el.appendChild(label);
5542
+ document.body.appendChild(el);
5543
+ } catch (e) {
5544
+ // A page that refuses the node still records; the mark is cosmetic.
5545
+ }
5546
+ }
5547
+
5548
+ if (document.body) mount();
5549
+ else document.addEventListener('DOMContentLoaded', mount, { once: true });
5550
+ setInterval(mount, 400);
5551
+ })();`;
5552
+ }
5553
+ async function applyBrandWatermark(context, watermark) {
5554
+ if (!watermark?.name) return false;
5555
+ const payload = {
5556
+ name: watermark.name,
5557
+ icon: watermark.iconPath ? imageDataUri(watermark.iconPath) : null,
5558
+ position: watermark.position ?? "top-right",
5559
+ opacity: clampOpacity(watermark.opacity)
5560
+ };
5561
+ try {
5562
+ await context.addInitScript(watermarkInitScript(payload));
5563
+ return true;
5564
+ } catch {
5565
+ return false;
5566
+ }
5567
+ }
5568
+ function embedJson(value) {
5569
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
5570
+ }
5571
+ function clampOpacity(value) {
5572
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0.92;
5573
+ return Math.min(1, Math.max(0.35, value));
5574
+ }
5575
+
5576
+ // src/browser/launch.ts
5577
+ init_headless();
5265
5578
 
5266
5579
  // src/browser/wsl.ts
5267
5580
  import { execSync, spawn as spawn2 } from "child_process";
5268
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
5581
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
5269
5582
  import { request } from "http";
5270
- import { chromium } from "playwright";
5583
+ import { chromium as chromium2 } from "playwright";
5271
5584
  function isWSL() {
5272
5585
  if (process.platform !== "linux") return false;
5273
5586
  try {
@@ -5310,7 +5623,7 @@ function getWindowsHostIP() {
5310
5623
  } catch {
5311
5624
  }
5312
5625
  try {
5313
- const m = readFileSync3("/etc/resolv.conf", "utf-8").match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
5626
+ const m = readFileSync4("/etc/resolv.conf", "utf-8").match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
5314
5627
  if (m?.[1]) return m[1];
5315
5628
  } catch {
5316
5629
  }
@@ -5318,7 +5631,7 @@ function getWindowsHostIP() {
5318
5631
  }
5319
5632
  function startRelay(nodePath, windowsHost, targetPort) {
5320
5633
  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) => {
5634
+ return new Promise((resolve13, reject) => {
5322
5635
  const proc = spawn2(nodePath, ["-e", script], { stdio: ["ignore", "pipe", "ignore"], detached: true });
5323
5636
  const timer = setTimeout(() => reject(new Error("relay startup timed out")), 6e3);
5324
5637
  let buf = "";
@@ -5327,7 +5640,7 @@ function startRelay(nodePath, windowsHost, targetPort) {
5327
5640
  const m = buf.match(/PORT:(\d+)/);
5328
5641
  if (m) {
5329
5642
  clearTimeout(timer);
5330
- resolve12({ proc, port: +m[1] });
5643
+ resolve13({ proc, port: +m[1] });
5331
5644
  }
5332
5645
  });
5333
5646
  proc.on("error", (e) => {
@@ -5337,7 +5650,7 @@ function startRelay(nodePath, windowsHost, targetPort) {
5337
5650
  });
5338
5651
  }
5339
5652
  function waitForChrome(host, port) {
5340
- return new Promise((resolve12, reject) => {
5653
+ return new Promise((resolve13, reject) => {
5341
5654
  const deadline = Date.now() + 2e4;
5342
5655
  const poll = () => {
5343
5656
  if (Date.now() > deadline) {
@@ -5346,7 +5659,7 @@ function waitForChrome(host, port) {
5346
5659
  }
5347
5660
  const req = request({ host, port, path: "/json/version", method: "GET" }, (res) => {
5348
5661
  res.resume();
5349
- resolve12();
5662
+ resolve13();
5350
5663
  });
5351
5664
  req.on("error", () => setTimeout(poll, 600));
5352
5665
  req.setTimeout(1e3, () => {
@@ -5386,7 +5699,7 @@ async function launchWindowsChrome(extensionPath, userDataDir, options = {}) {
5386
5699
  const { proc: relay, port: proxyPort } = await startRelay(nodePath, windowsHost, debugPort);
5387
5700
  try {
5388
5701
  await waitForChrome(windowsHost, proxyPort);
5389
- const browser = await chromium.connectOverCDP(`http://${windowsHost}:${proxyPort}`, { timeout: 5e3 });
5702
+ const browser = await chromium2.connectOverCDP(`http://${windowsHost}:${proxyPort}`, { timeout: 5e3 });
5390
5703
  return {
5391
5704
  browser,
5392
5705
  cleanup: async () => {
@@ -5443,7 +5756,7 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
5443
5756
  acceptDownloads: true
5444
5757
  });
5445
5758
  } catch {
5446
- context = await chromium2.launchPersistentContext(userDataDir, {
5759
+ context = await chromium3.launchPersistentContext(userDataDir, {
5447
5760
  headless: false,
5448
5761
  args: persistentArgs,
5449
5762
  viewport,
@@ -5454,7 +5767,7 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
5454
5767
  });
5455
5768
  }
5456
5769
  } else {
5457
- context = await chromium2.launchPersistentContext(userDataDir, {
5770
+ context = await chromium3.launchPersistentContext(userDataDir, {
5458
5771
  headless: false,
5459
5772
  args: persistentArgs,
5460
5773
  viewport,
@@ -5472,12 +5785,12 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
5472
5785
  ignoreDefaultArgs: ["--enable-automation"]
5473
5786
  };
5474
5787
  try {
5475
- browser = await chromium2.launch({ ...headedOpts, channel: "chrome" });
5788
+ browser = await chromium3.launch({ ...headedOpts, channel: "chrome" });
5476
5789
  } catch {
5477
- browser = await chromium2.launch(headedOpts);
5790
+ browser = await chromium3.launch(headedOpts);
5478
5791
  }
5479
5792
  } else {
5480
- browser = await chromium2.launch({ headless: true });
5793
+ browser = await launchHeadlessChromium();
5481
5794
  }
5482
5795
  context = await browser.newContext({
5483
5796
  viewport,
@@ -5489,6 +5802,7 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
5489
5802
  });
5490
5803
  }
5491
5804
  await hideDevOverlays(context);
5805
+ await applyBrandWatermark(context, config.video.watermark);
5492
5806
  const session = { context, browser, chromeSession };
5493
5807
  session.release = onCleanup(() => closeBrowserSession(session));
5494
5808
  return session;
@@ -5513,9 +5827,9 @@ async function launchSignInBrowser() {
5513
5827
  ignoreDefaultArgs: ["--enable-automation"]
5514
5828
  };
5515
5829
  try {
5516
- return await chromium2.launch({ ...opts, channel: "chrome" });
5830
+ return await chromium3.launch({ ...opts, channel: "chrome" });
5517
5831
  } catch {
5518
- return await chromium2.launch(opts);
5832
+ return await chromium3.launch(opts);
5519
5833
  }
5520
5834
  }
5521
5835
  async function cleanupBrowserSession(session) {
@@ -5587,7 +5901,7 @@ async function cancelSignIn() {
5587
5901
  import { copyFileSync, existsSync as existsSync4, mkdirSync, mkdtempSync, rmSync as rmSync2 } from "fs";
5588
5902
  import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
5589
5903
  import { dirname, join as join3 } from "path";
5590
- import { chromium as chromium3 } from "playwright";
5904
+ import { chromium as chromium4 } from "playwright";
5591
5905
  var BROWSERS = [
5592
5906
  {
5593
5907
  key: "chrome",
@@ -5697,7 +6011,7 @@ async function readState(profileDir, channel, store2) {
5697
6011
  ];
5698
6012
  if (store2) args.push(`--password-store=${store2}`);
5699
6013
  try {
5700
- const context = await chromium3.launchPersistentContext(profileDir, { headless: true, channel, args });
6014
+ const context = await chromium4.launchPersistentContext(profileDir, { headless: true, channel, args });
5701
6015
  try {
5702
6016
  return await context.storageState();
5703
6017
  } finally {
@@ -5716,7 +6030,7 @@ async function importBrowserSession(url) {
5716
6030
  }
5717
6031
 
5718
6032
  // src/commands/edit-cmd.ts
5719
- import { existsSync as existsSync9, readdirSync, readFileSync as readFileSync4 } from "fs";
6033
+ import { existsSync as existsSync10, readdirSync, readFileSync as readFileSync5 } from "fs";
5720
6034
  import { join as join14 } from "path";
5721
6035
  import chalk7 from "chalk";
5722
6036
 
@@ -5791,8 +6105,8 @@ function editorBaseUrl() {
5791
6105
  function openInBrowser(url) {
5792
6106
  const isWSL2 = process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME);
5793
6107
  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());
6108
+ return new Promise((resolve13) => {
6109
+ exec2(command, () => resolve13());
5796
6110
  });
5797
6111
  }
5798
6112
 
@@ -5951,8 +6265,72 @@ function planEditorUrl(baseUrl, plan) {
5951
6265
  return url.length <= MAX_HANDOFF_URL_CHARS ? url : null;
5952
6266
  }
5953
6267
 
6268
+ // src/lib/plan-thumbs.ts
6269
+ init_spawn();
6270
+ import { existsSync as existsSync9 } from "fs";
6271
+ import { isAbsolute as isAbsolute2, resolve as resolve4 } from "path";
6272
+ var MEDIA_ACTIONS = ["image", "media", "video"];
6273
+ var THUMB_WIDTH = 160;
6274
+ var MAX_THUMB_CHARS = 1e4;
6275
+ var MAX_TOTAL_THUMB_CHARS = 16e3;
6276
+ var THUMB_TIMEOUT_MS = 3e3;
6277
+ function thumbnail(path) {
6278
+ return new Promise((done) => {
6279
+ const proc = spawnFFmpeg("ffmpeg", [
6280
+ "-v",
6281
+ "error",
6282
+ "-i",
6283
+ path,
6284
+ "-frames:v",
6285
+ "1",
6286
+ "-vf",
6287
+ `scale=${THUMB_WIDTH}:-2`,
6288
+ "-q:v",
6289
+ "14",
6290
+ "-f",
6291
+ "mjpeg",
6292
+ "-"
6293
+ ]);
6294
+ const chunks = [];
6295
+ const timer = setTimeout(() => {
6296
+ proc.kill();
6297
+ done(null);
6298
+ }, THUMB_TIMEOUT_MS);
6299
+ const finish = (result) => {
6300
+ clearTimeout(timer);
6301
+ done(result);
6302
+ };
6303
+ proc.stdout.on("data", (chunk) => chunks.push(chunk));
6304
+ proc.on("error", () => finish(null));
6305
+ proc.on("close", (code) => {
6306
+ const bytes = Buffer.concat(chunks);
6307
+ if (code !== 0 || bytes.length === 0) return finish(null);
6308
+ const base64 = bytes.toString("base64");
6309
+ finish(base64.length > MAX_THUMB_CHARS ? null : `data:image/jpeg;base64,${base64}`);
6310
+ });
6311
+ });
6312
+ }
6313
+ async function withEditorThumbs(plan, baseDir) {
6314
+ const steps = [...plan.steps];
6315
+ let budget = MAX_TOTAL_THUMB_CHARS;
6316
+ for (const [index, step] of steps.entries()) {
6317
+ if (!step.value || !MEDIA_ACTIONS.includes(step.action)) continue;
6318
+ const path = isAbsolute2(step.value) ? step.value : resolve4(baseDir, step.value);
6319
+ if (!existsSync9(path)) continue;
6320
+ const thumb = await thumbnail(path);
6321
+ if (!thumb || thumb.length > budget) continue;
6322
+ budget -= thumb.length;
6323
+ steps[index] = { ...step, thumb };
6324
+ }
6325
+ return { ...plan, steps };
6326
+ }
6327
+ async function editorUrlWithThumbs(baseUrl, plan, baseDir) {
6328
+ const withThumbs = await withEditorThumbs(plan, baseDir);
6329
+ return planEditorUrl(baseUrl, withThumbs) ?? planEditorUrl(baseUrl, plan);
6330
+ }
6331
+
5954
6332
  // src/utils/ui.ts
5955
- import { resolve as resolve4 } from "path";
6333
+ import { resolve as resolve5 } from "path";
5956
6334
  import { createInterface } from "readline";
5957
6335
  import chalk2 from "chalk";
5958
6336
  import ora from "ora";
@@ -6019,7 +6397,7 @@ function waitForEnter(prompt2 = `${INDENT}Press Enter when done...`) {
6019
6397
  )
6020
6398
  );
6021
6399
  }
6022
- return new Promise((resolve12, reject) => {
6400
+ return new Promise((resolve13, reject) => {
6023
6401
  const rl = createInterface({ input: process.stdin, output: process.stdout });
6024
6402
  let done = false;
6025
6403
  rl.on("close", () => {
@@ -6030,13 +6408,13 @@ function waitForEnter(prompt2 = `${INDENT}Press Enter when done...`) {
6030
6408
  rl.question(prompt2, () => {
6031
6409
  done = true;
6032
6410
  rl.close();
6033
- resolve12();
6411
+ resolve13();
6034
6412
  });
6035
6413
  });
6036
6414
  }
6037
6415
  var _isWSL = process.platform === "linux" && process.env.WSL_DISTRO_NAME;
6038
6416
  function formatClickablePath(filePath) {
6039
- const absolutePath = resolve4(filePath);
6417
+ const absolutePath = resolve5(filePath);
6040
6418
  if (_isWSL || process.platform !== "win32") {
6041
6419
  return absolutePath;
6042
6420
  }
@@ -6077,13 +6455,13 @@ import chalk3 from "chalk";
6077
6455
  init_SparkStripe_feature_gating();
6078
6456
  var TIMEOUT_MS = 12e4;
6079
6457
  async function browserLogin() {
6080
- return new Promise((resolve12) => {
6458
+ return new Promise((resolve13) => {
6081
6459
  let server;
6082
6460
  let timer;
6083
6461
  const cleanup = (email) => {
6084
6462
  clearTimeout(timer);
6085
6463
  server?.close();
6086
- resolve12(email);
6464
+ resolve13(email);
6087
6465
  };
6088
6466
  server = createServer((req, res) => {
6089
6467
  const url = new URL2(req.url || "/", `http://localhost`);
@@ -6234,10 +6612,10 @@ function getLastRateLimitInfo() {
6234
6612
  }
6235
6613
  function prompt(question) {
6236
6614
  const rl = createInterface2({ input: process.stdin, output: process.stdout });
6237
- return new Promise((resolve12) => {
6615
+ return new Promise((resolve13) => {
6238
6616
  rl.question(question, (answer) => {
6239
6617
  rl.close();
6240
- resolve12(answer.trim());
6618
+ resolve13(answer.trim());
6241
6619
  });
6242
6620
  });
6243
6621
  }
@@ -6246,30 +6624,27 @@ async function setupUserEmail() {
6246
6624
  console.log(chalk4.yellow(" \u2709 Account Setup"));
6247
6625
  console.log(chalk4.dim(" \u2500".repeat(20)));
6248
6626
  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."));
6251
- const browserEmail = await browserLogin();
6252
- if (browserEmail) {
6627
+ console.log(chalk4.white(" The AI that plans your demo runs on our servers, so it"));
6628
+ console.log(chalk4.white(" needs an address to meter. No card, and no verification"));
6629
+ console.log(chalk4.white(" needed for your first 5 videos."));
6630
+ console.log(chalk4.dim(" After that, signing in gets you 10 videos a day."));
6631
+ console.log();
6632
+ console.log(chalk4.dim(" Type your email, or 'b' to sign in through a browser."));
6633
+ const email = await prompt(chalk4.white(" Email: "));
6634
+ if (!email) return null;
6635
+ if (email.toLowerCase() === "b" || email.toLowerCase() === "browser") {
6636
+ const browserEmail = await browserLogin();
6637
+ if (!browserEmail) return null;
6253
6638
  console.log();
6254
6639
  console.log(chalk4.green(` \u2713 Signed in as ${browserEmail}`));
6255
6640
  console.log();
6256
6641
  return browserEmail;
6257
6642
  }
6258
- console.log();
6259
- console.log(chalk4.dim(" Or enter your email manually:"));
6260
- const email = await prompt(chalk4.white(" Email: "));
6261
- if (!email) {
6262
- console.log();
6263
- console.log(chalk4.red(" No email provided. Exiting."));
6264
- process.exit(1);
6265
- }
6266
6643
  if (!email.includes("@") || !email.includes(".")) {
6267
6644
  console.log();
6268
6645
  console.log(chalk4.yellow(" \u26A0 That doesn't look like a valid email address."));
6269
6646
  const proceed = await prompt(chalk4.white(" Continue anyway? ") + chalk4.dim("[y/N] "));
6270
- if (proceed.toLowerCase() !== "y") {
6271
- process.exit(1);
6272
- }
6647
+ if (proceed.toLowerCase() !== "y") return null;
6273
6648
  }
6274
6649
  console.log();
6275
6650
  console.log(chalk4.green(" \u2713 Email registered"));
@@ -6394,6 +6769,17 @@ async function parseAIJsonWithRepair(content, opts) {
6394
6769
  init_mcp_context();
6395
6770
  init_lib();
6396
6771
  init_json_repair();
6772
+ var WRITTEN_ADDRESS = /\b(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?/gi;
6773
+ var SPELLED_OUT_ACRONYM = /\b[A-Z](?:\s+[A-Z])+\b/g;
6774
+ function spokenCaption(caption) {
6775
+ const held = [];
6776
+ const masked = caption.replace(SPELLED_OUT_ACRONYM, (run2) => run2.replace(/\s+/g, "")).replace(WRITTEN_ADDRESS, (address) => {
6777
+ held.push(address);
6778
+ return `qpaddr${held.length - 1}qp`;
6779
+ });
6780
+ const shaped = toTitleCase(toSpokenForm2(masked));
6781
+ return shaped.replace(/qpaddr(\d+)qp/gi, (_match, index) => held[Number(index)] ?? "");
6782
+ }
6397
6783
  async function crawlExtension(url, extensionPath, workDir, task2) {
6398
6784
  const browserTask = task2("Launching browser with extension");
6399
6785
  const userDataDir = join12(workDir, ".browser-data");
@@ -6522,8 +6908,18 @@ async function crawlAndAnalyze(url, lang, description, maxSteps, task2, logError
6522
6908
  // The spoken form is what the voice will actually say, so it is what
6523
6909
  // the caption shows: burning the written form instead left the words on
6524
6910
  // 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
6911
+ // (a number read as words).
6912
+ //
6913
+ // Addresses are the exception. Expanding one here turned "quickpeek.co"
6914
+ // into three caption words, "Quickpeek Dot Co", which the line packer
6915
+ // is then free to break across lines: the call to action arrived on
6916
+ // screen as "DOT C O" with the name on the row above. An address is the
6917
+ // one thing in a demo a viewer has to be able to read and type, so it
6918
+ // is held back verbatim from both passes and rejoins as written. The
6919
+ // voice is unaffected: generate.ts derives its own spoken form from
6920
+ // this caption at synthesis time, and word timings fall back to an even
6921
+ // spread across the speech envelope when the counts no longer line up.
6922
+ caption: s.caption ? spokenCaption(s.caption) : s.caption
6527
6923
  }));
6528
6924
  aiTask.succeed(`Plan: ${plan.steps.length} steps`);
6529
6925
  return plan;
@@ -6701,9 +7097,9 @@ var PLAN_FILE = /^plan-([a-z]{2}(?:-[A-Z]{2})?)\.json$/;
6701
7097
  function findPlanFile(dir, lang) {
6702
7098
  if (lang) {
6703
7099
  const exact = join14(dir, `plan-${lang}.json`);
6704
- return existsSync9(exact) ? exact : null;
7100
+ return existsSync10(exact) ? exact : null;
6705
7101
  }
6706
- if (!existsSync9(dir)) return null;
7102
+ if (!existsSync10(dir)) return null;
6707
7103
  const plans = readdirSync(dir).filter((f) => PLAN_FILE.test(f));
6708
7104
  const preferred = plans.find((f) => f === "plan-en.json") ?? plans[0];
6709
7105
  return preferred ? join14(dir, preferred) : null;
@@ -6711,10 +7107,35 @@ function findPlanFile(dir, lang) {
6711
7107
 
6712
7108
  // src/commands/record-cmd.ts
6713
7109
  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";
7110
+ import { existsSync as existsSync26 } from "fs";
7111
+ import { mkdir as mkdir10, readFile as readFile12 } from "fs/promises";
7112
+ import { basename as basename4, isAbsolute as isAbsolute7, join as join31, resolve as resolve11 } from "path";
6717
7113
  import chalk17 from "chalk";
7114
+
7115
+ // src/lib/demo-registry.ts
7116
+ init_api_client();
7117
+ var TIMEOUT_MS2 = 4e3;
7118
+ async function registerDemoRun(email, run2) {
7119
+ if (!email || email === "anonymous") return false;
7120
+ const controller = new AbortController();
7121
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS2);
7122
+ try {
7123
+ const token = await getOrRefreshRelayToken(email);
7124
+ const response = await fetch(`${RELAY_BASE}/api/cli/demos`, {
7125
+ method: "POST",
7126
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
7127
+ body: JSON.stringify(run2),
7128
+ signal: controller.signal
7129
+ });
7130
+ return response.ok;
7131
+ } catch {
7132
+ return false;
7133
+ } finally {
7134
+ clearTimeout(timer);
7135
+ }
7136
+ }
7137
+
7138
+ // src/commands/record-cmd.ts
6718
7139
  init_lib();
6719
7140
  init_config_loader();
6720
7141
  init_run_root();
@@ -6734,13 +7155,14 @@ async function handleRegistrationGate() {
6734
7155
  console.log(chalk8.white(" Sign in to continue creating videos."));
6735
7156
  console.log();
6736
7157
  console.log(chalk8.dim(" Benefits of signing in:"));
6737
- console.log(chalk8.dim(" \u2022 20 more videos per month"));
7158
+ console.log(chalk8.dim(" \u2022 10 videos a day instead of 5 in total"));
6738
7159
  console.log(chalk8.dim(" \u2022 Keep your video history"));
6739
7160
  console.log(chalk8.dim(" \u2022 Upgrade to Pro anytime"));
6740
- let email = await browserLogin();
6741
- if (!email) {
6742
- console.log();
6743
- email = await promptInput("Email: ");
7161
+ console.log();
7162
+ console.log(chalk8.dim(" Type your email, or 'b' to sign in through a browser."));
7163
+ let email = await promptInput("Email: ");
7164
+ if (email.toLowerCase() === "b" || email.toLowerCase() === "browser") {
7165
+ email = await browserLogin() ?? "";
6744
7166
  }
6745
7167
  if (!email || !email.includes("@")) {
6746
7168
  console.log(chalk8.red(" Invalid email. Run qp again to try again."));
@@ -6785,9 +7207,12 @@ async function runAuthPhase(input) {
6785
7207
  const { isVerified: isVerified2 } = input;
6786
7208
  const mcpTool = currentMcpTool();
6787
7209
  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
- );
7210
+ if (localVideoCount >= FREE_VIDEO_LIMIT) {
7211
+ throw new Error(
7212
+ `"${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.`
7213
+ );
7214
+ }
7215
+ return { userEmail: "anonymous", userTier: "free", localVideoCount, isVerified: false };
6791
7216
  }
6792
7217
  if (userEmail && isVerified2) {
6793
7218
  const tierTask = task("Checking subscription");
@@ -6812,7 +7237,7 @@ async function runAuthPhase(input) {
6812
7237
  console.log();
6813
7238
  const input2 = await promptInput(`New email or Enter to retry (${userEmail}): `);
6814
7239
  if (input2.toLowerCase() === "exit") {
6815
- gracefulExit();
7240
+ gracefulExit(1);
6816
7241
  return null;
6817
7242
  }
6818
7243
  if (input2?.includes("@")) {
@@ -6821,7 +7246,7 @@ async function runAuthPhase(input) {
6821
7246
  const result = await registerFreeUser(newEmail);
6822
7247
  if (!result.success) {
6823
7248
  registerTask.fail(result.error || "Registration failed");
6824
- gracefulExit();
7249
+ gracefulExit(1);
6825
7250
  return null;
6826
7251
  }
6827
7252
  if (result.alreadyVerified) {
@@ -6836,7 +7261,7 @@ async function runAuthPhase(input) {
6836
7261
  console.log(chalk9.cyan(` \u{1F4E7} Verification sent to: ${newEmail}`));
6837
7262
  console.log(chalk9.dim(" Click the link in the email, then run qp again."));
6838
7263
  console.log();
6839
- gracefulExit();
7264
+ gracefulExit(1);
6840
7265
  return null;
6841
7266
  }
6842
7267
  } else {
@@ -6852,7 +7277,7 @@ async function runAuthPhase(input) {
6852
7277
  console.log();
6853
7278
  console.log(chalk9.cyan(` Register/upgrade at: ${getUpgradeUrl(userEmail)}`));
6854
7279
  console.log();
6855
- gracefulExit();
7280
+ gracefulExit(1);
6856
7281
  return null;
6857
7282
  }
6858
7283
  }
@@ -6860,7 +7285,7 @@ async function runAuthPhase(input) {
6860
7285
  } else if (localVideoCount >= FREE_VIDEO_LIMIT) {
6861
7286
  const registration = await handleRegistrationGate();
6862
7287
  if (!registration) {
6863
- gracefulExit();
7288
+ gracefulExit(1);
6864
7289
  return null;
6865
7290
  }
6866
7291
  userEmail = registration.email;
@@ -6870,7 +7295,7 @@ async function runAuthPhase(input) {
6870
7295
  localVideoCount
6871
7296
  });
6872
7297
  if (!registration.verified) {
6873
- gracefulExit();
7298
+ gracefulExit(1);
6874
7299
  return null;
6875
7300
  }
6876
7301
  await displayUsageStatus(userEmail, userTier, localVideoCount);
@@ -6878,8 +7303,17 @@ async function runAuthPhase(input) {
6878
7303
  const remaining2 = FREE_VIDEO_LIMIT - localVideoCount;
6879
7304
  console.log();
6880
7305
  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`));
6882
- userEmail = await setupUserEmail();
7306
+ console.log(chalk9.dim(` ${remaining2 - 1} more after this \u2022 Sign up for 10 a day`));
7307
+ const enteredEmail = await setupUserEmail();
7308
+ if (!enteredEmail) {
7309
+ console.log();
7310
+ console.log(chalk9.yellow(" No email, so no video was recorded."));
7311
+ console.log(chalk9.white(" Next: run `qp login` to sign in, then run qp again."));
7312
+ console.log();
7313
+ gracefulExit(1);
7314
+ return null;
7315
+ }
7316
+ userEmail = enteredEmail;
6883
7317
  let registeredVerified = false;
6884
7318
  try {
6885
7319
  const reg = await registerFreeUser(userEmail);
@@ -6897,7 +7331,7 @@ async function runAuthPhase(input) {
6897
7331
  }
6898
7332
 
6899
7333
  // src/commands/phases/plan-phase.ts
6900
- import { existsSync as existsSync20, readdirSync as readdirSync3 } from "fs";
7334
+ import { existsSync as existsSync21, readdirSync as readdirSync3 } from "fs";
6901
7335
  import { readFile as readFile9 } from "fs/promises";
6902
7336
  import { basename as basename3, join as join26 } from "path";
6903
7337
  import chalk13 from "chalk";
@@ -7059,13 +7493,13 @@ init_config_loader();
7059
7493
 
7060
7494
  // src/commands/phases/plan-cards.ts
7061
7495
  init_render_outro();
7062
- import { existsSync as existsSync19 } from "fs";
7063
- import { isAbsolute as isAbsolute5, join as join25 } from "path";
7496
+ import { existsSync as existsSync20 } from "fs";
7497
+ import { isAbsolute as isAbsolute6, join as join25 } from "path";
7064
7498
  import chalk12 from "chalk";
7065
7499
 
7066
7500
  // src/lib/og-card.ts
7067
7501
  init_image_kind();
7068
- import { existsSync as existsSync10, readFileSync as readFileSync6 } from "fs";
7502
+ import { existsSync as existsSync11, readFileSync as readFileSync6 } from "fs";
7069
7503
  import { writeFile as writeFile9 } from "fs/promises";
7070
7504
  import { join as join16 } from "path";
7071
7505
  var PORTRAIT_PATHS = ["/opengraph-image/portrait", "/opengraph-image-portrait", "/og/portrait"];
@@ -7235,7 +7669,7 @@ function existingIcon(dir) {
7235
7669
  for (const ext of ["png", "svg", "jpg", "webp", "gif"]) {
7236
7670
  const path = join16(dir, `app-icon.${ext}`);
7237
7671
  try {
7238
- if (!existsSync10(path)) continue;
7672
+ if (!existsSync11(path)) continue;
7239
7673
  if (!sniffImageKind(readFileSync6(path))) continue;
7240
7674
  return path;
7241
7675
  } catch {
@@ -7279,9 +7713,9 @@ function jpegSize(buf) {
7279
7713
  }
7280
7714
 
7281
7715
  // src/commands/phases/video-phase.ts
7282
- import { existsSync as existsSync18, statSync as statSync3 } from "fs";
7716
+ import { existsSync as existsSync19, statSync as statSync3 } from "fs";
7283
7717
  import { copyFile as copyFile3 } from "fs/promises";
7284
- import { join as join24, resolve as resolve9 } from "path";
7718
+ import { join as join24, resolve as resolve10 } from "path";
7285
7719
 
7286
7720
  // src/browser/signed-in.ts
7287
7721
  function pathDepth(raw) {
@@ -7387,7 +7821,7 @@ async function hasHeadroom() {
7387
7821
  async function withRenderSlot(render, onWait) {
7388
7822
  if (active >= MAX_CONCURRENT_RENDERS) {
7389
7823
  onWait?.(`waiting for a render slot (${active}/${MAX_CONCURRENT_RENDERS} busy)`);
7390
- await new Promise((resolve12) => waiting.push(resolve12));
7824
+ await new Promise((resolve13) => waiting.push(resolve13));
7391
7825
  }
7392
7826
  active++;
7393
7827
  try {
@@ -7416,20 +7850,20 @@ init_lib();
7416
7850
  init_helpers();
7417
7851
 
7418
7852
  // src/record.ts
7419
- import { existsSync as existsSync15 } from "fs";
7420
- import { mkdir as mkdir7 } from "fs/promises";
7853
+ import { existsSync as existsSync16 } from "fs";
7854
+ import { mkdir as mkdir8, rm as rm3 } from "fs/promises";
7421
7855
  import { join as join21 } from "path";
7422
7856
  import chalk10 from "chalk";
7423
7857
 
7424
7858
  // src/browser/actions.ts
7425
7859
  init_lib();
7426
- import { existsSync as existsSync12 } from "fs";
7860
+ import { existsSync as existsSync13 } from "fs";
7427
7861
  import { readFile as readFile7 } from "fs/promises";
7428
- import { isAbsolute as isAbsolute2, resolve as resolve6 } from "path";
7862
+ import { isAbsolute as isAbsolute3, resolve as resolve7 } from "path";
7429
7863
 
7430
7864
  // src/browser/effects.ts
7431
7865
  init_lib();
7432
- import { existsSync as existsSync11, readFileSync as readFileSync7 } from "fs";
7866
+ import { existsSync as existsSync12, readFileSync as readFileSync7 } from "fs";
7433
7867
  import { join as join17 } from "path";
7434
7868
 
7435
7869
  // src/browser/page-eval.ts
@@ -7449,7 +7883,7 @@ function loadHighlightCSS() {
7449
7883
  // src/browser/ → src/
7450
7884
  ];
7451
7885
  for (const p of candidates) {
7452
- if (existsSync11(p)) return readFileSync7(p, "utf-8");
7886
+ if (existsSync12(p)) return readFileSync7(p, "utf-8");
7453
7887
  }
7454
7888
  return "";
7455
7889
  }
@@ -7662,6 +8096,7 @@ function locatorFor(page, keyword) {
7662
8096
  var WAIT_MS = 450;
7663
8097
  var HANDLE_MS = 3e3;
7664
8098
  var RESOLVE_BUDGET_MS2 = 2e4;
8099
+ var DOM_POLL_MS = 150;
7665
8100
  function remaining(deadline) {
7666
8101
  return Math.max(1, deadline - Date.now());
7667
8102
  }
@@ -7772,7 +8207,7 @@ async function resolveElementSettled(page, target, deadlineMs = RESOLVE_DEADLINE
7772
8207
  }
7773
8208
  }
7774
8209
  async function resolvePlainText(page, text, deadline) {
7775
- const scanned = await domTextMatch(page, text);
8210
+ let scanned = await domTextMatch(page, text);
7776
8211
  const stages = plainTextStages(page, text);
7777
8212
  while (Date.now() < deadline) {
7778
8213
  for (const { locator, state } of stages) {
@@ -7783,6 +8218,12 @@ async function resolvePlainText(page, text, deadline) {
7783
8218
  }
7784
8219
  }
7785
8220
  if (scanned) return scanned;
8221
+ while (Date.now() < deadline) {
8222
+ await page.waitForTimeout(Math.min(DOM_POLL_MS, remaining(deadline)));
8223
+ scanned = await domTextMatch(page, text);
8224
+ if (scanned) break;
8225
+ }
8226
+ if (!scanned) return null;
7786
8227
  }
7787
8228
  return scanned ?? null;
7788
8229
  }
@@ -8207,8 +8648,8 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
8207
8648
  const isPlaceholder = !rawPath || rawPath.startsWith("[") || rawPath.includes("example");
8208
8649
  if (!isPlaceholder) {
8209
8650
  const normalizedPath = rawPath.startsWith("mnt/") ? `/${rawPath}` : rawPath;
8210
- const filePath = isAbsolute2(normalizedPath) ? normalizedPath : resolve6(baseDir, normalizedPath);
8211
- if (existsSync12(filePath)) {
8651
+ const filePath = isAbsolute3(normalizedPath) ? normalizedPath : resolve7(baseDir, normalizedPath);
8652
+ if (existsSync13(filePath)) {
8212
8653
  await fileInput.setInputFiles(filePath);
8213
8654
  await page.waitForTimeout(2e3);
8214
8655
  break;
@@ -8324,8 +8765,8 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
8324
8765
  case "wait":
8325
8766
  break;
8326
8767
  case "image": {
8327
- const imagePath = step.value ? isAbsolute2(step.value) ? step.value : resolve6(baseDir, step.value) : null;
8328
- if (imagePath && existsSync12(imagePath)) {
8768
+ const imagePath = step.value ? isAbsolute3(step.value) ? step.value : resolve7(baseDir, step.value) : null;
8769
+ if (imagePath && existsSync13(imagePath)) {
8329
8770
  const imgStyle = step.target === "stretch" ? "width: 100vw; height: 100vh; object-fit: cover;" : "max-width: 90%; max-height: 90%;";
8330
8771
  if (await showImagePage(page, imagePath, imgStyle, config, cursorInjected, false)) injected = true;
8331
8772
  }
@@ -8354,7 +8795,7 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
8354
8795
  await pendingDownload;
8355
8796
  pendingDownload = null;
8356
8797
  }
8357
- if (lastDownloadPath && existsSync12(lastDownloadPath)) {
8798
+ if (lastDownloadPath && existsSync13(lastDownloadPath)) {
8358
8799
  const imgStyle = step.target === "stretch" ? "width: 100%; height: 100%; object-fit: contain;" : "max-width: 90%; max-height: 90%; object-fit: contain;";
8359
8800
  if (await showImagePage(page, lastDownloadPath, imgStyle, config, cursorInjected)) injected = true;
8360
8801
  }
@@ -8462,13 +8903,13 @@ async function getPageElementSelectors(page, query, limit, keyword = "") {
8462
8903
  // src/browser/step-validator.ts
8463
8904
  init_crawl();
8464
8905
  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";
8906
+ import { existsSync as existsSync14, readdirSync as readdirSync2 } from "fs";
8907
+ import { basename as basename2, isAbsolute as isAbsolute4, join as join18, resolve as resolve8 } from "path";
8467
8908
  function findMediaFile(filename, baseDir) {
8468
8909
  const name = basename2(filename);
8469
8910
  const searchDirs = [baseDir, join18(baseDir, "res"), join18(baseDir, "assets"), join18(baseDir, "media")];
8470
8911
  for (const dir of searchDirs) {
8471
- if (!existsSync13(dir)) continue;
8912
+ if (!existsSync14(dir)) continue;
8472
8913
  try {
8473
8914
  const files = readdirSync2(dir);
8474
8915
  if (files.includes(name)) return join18(dir, name);
@@ -8521,8 +8962,8 @@ function resolveMediaStep(step, baseDir, label, startTime) {
8521
8962
  if (!step.value) {
8522
8963
  return { success: false, error: `Missing ${label} path`, executionTimeMs: Date.now() - startTime };
8523
8964
  }
8524
- const specifiedPath = isAbsolute3(step.value) ? step.value : resolve7(baseDir, step.value);
8525
- if (existsSync13(specifiedPath)) return { success: true, executionTimeMs: Date.now() - startTime };
8965
+ const specifiedPath = isAbsolute4(step.value) ? step.value : resolve8(baseDir, step.value);
8966
+ if (existsSync14(specifiedPath)) return { success: true, executionTimeMs: Date.now() - startTime };
8526
8967
  const foundPath = findMediaFile(step.value, baseDir);
8527
8968
  if (foundPath) {
8528
8969
  step.value = `./${foundPath.replace(`${baseDir}/`, "").replace(`${baseDir}\\`, "")}`;
@@ -8650,8 +9091,8 @@ async function validateAndExecuteStep(page, step, plan, baseDir) {
8650
9091
  const isPlaceholder = !rawPath || rawPath.startsWith("[") || rawPath.includes("example");
8651
9092
  if (!isPlaceholder) {
8652
9093
  const normalizedPath = rawPath.startsWith("mnt/") ? `/${rawPath}` : rawPath;
8653
- let filePath = isAbsolute3(normalizedPath) ? normalizedPath : resolve7(baseDir, normalizedPath);
8654
- if (!existsSync13(filePath)) {
9094
+ let filePath = isAbsolute4(normalizedPath) ? normalizedPath : resolve8(baseDir, normalizedPath);
9095
+ if (!existsSync14(filePath)) {
8655
9096
  const foundPath = findMediaFile(rawPath, baseDir);
8656
9097
  if (foundPath) {
8657
9098
  const relativePath = `./${foundPath.replace(`${baseDir}/`, "").replace(`${baseDir}\\`, "")}`;
@@ -8793,7 +9234,7 @@ async function validateStepsOnPage(plan, config, logError2, baseDir, onStepStart
8793
9234
  }
8794
9235
 
8795
9236
  // src/browser/meta.ts
8796
- import { existsSync as existsSync14 } from "fs";
9237
+ import { existsSync as existsSync15 } from "fs";
8797
9238
  import { readFile as readFile8, writeFile as writeFile10 } from "fs/promises";
8798
9239
  import { join as join20 } from "path";
8799
9240
  var RECORDING_FILE = "recording.mp4";
@@ -8803,7 +9244,7 @@ function getRecordingPath(outputDir) {
8803
9244
  }
8804
9245
  async function loadRecordingMeta(outputDir) {
8805
9246
  const metaPath = join20(outputDir, RECORDING_META_FILE);
8806
- if (!existsSync14(metaPath)) return null;
9247
+ if (!existsSync15(metaPath)) return null;
8807
9248
  try {
8808
9249
  const content = await readFile8(metaPath, "utf-8");
8809
9250
  return JSON.parse(content);
@@ -8910,7 +9351,7 @@ async function leavesApp(el, step, plannedUrl) {
8910
9351
  }
8911
9352
  async function recordSession(plan, config, stepDurations, workDir, baseDir, task2, logError2, opts = {}) {
8912
9353
  const videoDir = join21(workDir, "video");
8913
- await mkdir7(videoDir, { recursive: true });
9354
+ await mkdir8(videoDir, { recursive: true });
8914
9355
  const getStepDuration = (stepNum) => {
8915
9356
  return stepDurations[stepNum - 1] ?? config.timing.minStepDuration;
8916
9357
  };
@@ -8921,11 +9362,33 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
8921
9362
  const authState = findAuthState(plan.url);
8922
9363
  const { context, browser, chromeSession } = await launchBrowserContext(config, userDataDir, recordVideo, authState);
8923
9364
  try {
9365
+ const isExtensionPage = plan.url.startsWith("chrome-extension://");
9366
+ if (!isExtensionPage) {
9367
+ const warm = await context.newPage();
9368
+ const warmVideo = warm.video();
9369
+ const warmUrls = [plan.url];
9370
+ for (const step of plan.steps) {
9371
+ if (step.action !== "navigate" || !step.target) continue;
9372
+ if (!warmUrls.includes(step.target)) warmUrls.push(step.target);
9373
+ }
9374
+ try {
9375
+ for (const url of warmUrls) {
9376
+ await gotoSettled(warm, url).catch(() => {
9377
+ });
9378
+ }
9379
+ } catch {
9380
+ } finally {
9381
+ await warm.close().catch(() => {
9382
+ });
9383
+ const stray = await warmVideo?.path().catch(() => void 0);
9384
+ if (stray) await rm3(stray, { force: true }).catch(() => {
9385
+ });
9386
+ }
9387
+ }
8924
9388
  const page = context.pages()[0] || await context.newPage();
8925
9389
  setupDownloadTracking(page);
8926
9390
  context.on("page", (p) => setupDownloadTracking(p));
8927
9391
  const firstStepIsMedia = ["media", "image", "video"].includes(plan.steps[0]?.action ?? "");
8928
- const isExtensionPage = plan.url.startsWith("chrome-extension://");
8929
9392
  let signInChecked = false;
8930
9393
  const guardSignedIn = async () => {
8931
9394
  if (signInChecked || opts.allowLoggedOut || isExtensionPage) return;
@@ -9002,7 +9465,7 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
9002
9465
  if (!targetEl) {
9003
9466
  console.log(
9004
9467
  `
9005
- \u26A0 target "${step.target}" not found on ${page.url()} \u2014 step ${stepNum} recorded without the action`
9468
+ \u26A0 target "${step.target}" not found on ${page.url()}; step ${stepNum} recorded without the action`
9006
9469
  );
9007
9470
  }
9008
9471
  }
@@ -9141,7 +9604,7 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
9141
9604
  if (!pageClosedEarly) await page.close();
9142
9605
  const videoPath = await video.path();
9143
9606
  await cleanupBrowserSession({ context, browser, chromeSession });
9144
- if (!videoPath || !existsSync15(videoPath)) {
9607
+ if (!videoPath || !existsSync16(videoPath)) {
9145
9608
  throw new Error("Video file was not created");
9146
9609
  }
9147
9610
  saveTask.succeed("Video saved");
@@ -9156,20 +9619,20 @@ init_run_root();
9156
9619
 
9157
9620
  // src/utils/video-pipeline.ts
9158
9621
  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";
9622
+ import { existsSync as existsSync18 } from "fs";
9623
+ import { mkdir as mkdir9, writeFile as writeFile11 } from "fs/promises";
9624
+ import { isAbsolute as isAbsolute5, join as join23, resolve as resolve9 } from "path";
9162
9625
  import chalk11 from "chalk";
9163
9626
 
9164
9627
  // src/utils/music.ts
9165
- import { existsSync as existsSync16, readFileSync as readFileSync8 } from "fs";
9628
+ import { existsSync as existsSync17, readFileSync as readFileSync8 } from "fs";
9166
9629
  import { dirname as dirname4, join as join22 } from "path";
9167
- var DEFAULT_MOOD = "calm";
9630
+ var DEFAULT_MOOD = "corporate";
9168
9631
  function musicDir() {
9169
9632
  let dir = getScriptDir();
9170
9633
  for (let i = 0; i < 6; i++) {
9171
9634
  const candidate = join22(dir, "assets", "music");
9172
- if (existsSync16(join22(candidate, "manifest.json"))) return candidate;
9635
+ if (existsSync17(join22(candidate, "manifest.json"))) return candidate;
9173
9636
  const parent = dirname4(dir);
9174
9637
  if (parent === dir) break;
9175
9638
  dir = parent;
@@ -9182,7 +9645,7 @@ function listBundledMusic() {
9182
9645
  try {
9183
9646
  const parsed = JSON.parse(readFileSync8(join22(dir, "manifest.json"), "utf8"));
9184
9647
  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));
9648
+ return tracks.filter((t) => typeof t?.file === "string").map((t) => ({ ...t, path: join22(dir, t.file) })).filter((t) => existsSync17(t.path));
9186
9649
  } catch {
9187
9650
  return [];
9188
9651
  }
@@ -9196,6 +9659,16 @@ function resolveBundledBed(mood = DEFAULT_MOOD) {
9196
9659
  // src/utils/video-pipeline.ts
9197
9660
  init_run_root();
9198
9661
  var INDENT3 = " ";
9662
+ function captionWordTimings(caption, spoken, duration) {
9663
+ const captionWords = caption.split(/\s+/).filter(Boolean);
9664
+ if (spoken.length === 0 || captionWords.length === 0) {
9665
+ return estimateWordTimings(caption, duration);
9666
+ }
9667
+ if (spoken.length === captionWords.length) {
9668
+ return spoken.map((w, i) => ({ ...w, word: captionWords[i] ?? w.word }));
9669
+ }
9670
+ return estimateWordTimings(caption, duration);
9671
+ }
9199
9672
  async function generateASS(plan, stepAudio, workDir, config, captions) {
9200
9673
  const cues = [];
9201
9674
  let currentTime = VOICE_LEAD_MS;
@@ -9206,7 +9679,7 @@ async function generateASS(plan, stepAudio, workDir, config, captions) {
9206
9679
  currentTime += config.timing.minStepDuration;
9207
9680
  continue;
9208
9681
  }
9209
- const words = audio.words.length > 0 ? audio.words : estimateWordTimings(step.caption || "", audio.duration);
9682
+ const words = captionWordTimings(step.caption || "", audio.words, audio.duration);
9210
9683
  if (words.length > 0 && !step.spokenOnly) {
9211
9684
  const offset = currentTime;
9212
9685
  cues.push({
@@ -9226,7 +9699,7 @@ async function generateASS(plan, stepAudio, workDir, config, captions) {
9226
9699
  return assPath;
9227
9700
  }
9228
9701
  var STILL_EXTENSIONS = ["png", "jpg", "jpeg", "webp"];
9229
- function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
9702
+ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0, totalSecs, blackSpans) {
9230
9703
  const overlays = [];
9231
9704
  let cumulativeTime = leadInSecs;
9232
9705
  for (let i = 0; i < steps.length; i++) {
@@ -9239,13 +9712,20 @@ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
9239
9712
  const ext = step.value.split(".").pop()?.toLowerCase() || "";
9240
9713
  const still = isCard && STILL_EXTENSIONS.includes(ext);
9241
9714
  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) {
9715
+ const mediaPath = isAbsolute5(step.value) ? step.value : resolve9(baseDir, step.value);
9716
+ if (existsSync18(mediaPath) && durationSec > 0) {
9244
9717
  const partial = parsePartial(step.partial);
9718
+ const runsToEnd = still && i === steps.length - 1 && totalSecs !== void 0;
9719
+ let duration = runsToEnd ? Math.max(durationSec, totalSecs - cumulativeTime) : durationSec;
9720
+ if (still && !runsToEnd && blackSpans) {
9721
+ const endsAt = cumulativeTime + duration;
9722
+ const following = blackSpans.find((sp) => sp.start <= endsAt + 0.25 && sp.end > endsAt);
9723
+ if (following) duration = following.end - cumulativeTime;
9724
+ }
9245
9725
  overlays.push({
9246
9726
  path: mediaPath,
9247
9727
  startTime: cumulativeTime,
9248
- duration: durationSec,
9728
+ duration,
9249
9729
  mode: step.target === "stretch" ? "stretch" : step.target === "cover" ? "cover" : "center",
9250
9730
  ...still ? { still: true } : {},
9251
9731
  ...partial.trimStart !== void 0 ? { trimStart: partial.trimStart } : {},
@@ -9258,7 +9738,7 @@ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
9258
9738
  }
9259
9739
  return overlays;
9260
9740
  }
9261
- async function composeAndFinish(videoPath, audioPath, assPath, config, userTier = "free", mainContentDuration, trimStart = 0.5, stepTransitions, hasClipAudio = false, endsOnCreditsCard = false, _stepSync) {
9741
+ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier = "free", mainContentDuration, trimStart = 0.5, stepTransitions, hasClipAudio = false, endsOnCreditsCard = false, _stepSync, musicStartSecs = 0) {
9262
9742
  header("Compose");
9263
9743
  const configPath = join23(runRoot(), CONFIG_FILE);
9264
9744
  console.log(`${INDENT3}${chalk11.dim(`Config: ${formatClickablePath(configPath)}`)}`);
@@ -9270,13 +9750,13 @@ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier
9270
9750
  process.exit(0);
9271
9751
  }
9272
9752
  const composeTask = task("Building final video");
9273
- await mkdir8(config.output.dir, { recursive: true });
9753
+ await mkdir9(config.output.dir, { recursive: true });
9274
9754
  const suffix = config.lang ? `-${config.lang}` : "";
9275
9755
  const finalPath = join23(config.output.dir, `demo${suffix}.${config.video.format}`);
9276
9756
  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);
9757
+ const hasVoiceover = audioPath && existsSync18(audioPath);
9758
+ const musicPath = config.music?.path ? isAbsolute5(config.music.path) ? config.music.path : resolve9(config.output.dir, config.music.path) : "";
9759
+ let hasMusic = musicPath && existsSync18(musicPath);
9280
9760
  let bedPath = "";
9281
9761
  let bedCredit = "";
9282
9762
  if (!hasMusic && config.audio?.ambience !== false) {
@@ -9328,7 +9808,14 @@ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier
9328
9808
  hasClipAudio,
9329
9809
  mainContentDuration,
9330
9810
  stepTransitions,
9331
- hwaccel: config.video.hwaccel
9811
+ hwaccel: config.video.hwaccel,
9812
+ // The tempo pass runs after this and speeds the whole file up, music
9813
+ // included. Told about it, compose lays the bed down pre-slowed so the
9814
+ // song comes out at its own speed.
9815
+ tempo: config.video.tempo,
9816
+ musicStartSecs,
9817
+ // So the bed can be landed with its last note on the last frame.
9818
+ musicDurationSecs: hasMusic ? await getMediaDuration(backingPath).then((ms) => ms / 1e3).catch(() => 0) : 0
9332
9819
  });
9333
9820
  composeTask.succeed("Video composed");
9334
9821
  return finalPath;
@@ -9390,18 +9877,18 @@ function resolveOutro(config, userTier, userEmail) {
9390
9877
  if (!usesQuickPeekOutro(userTier, userEmail)) {
9391
9878
  const custom = config.video.outro?.trim();
9392
9879
  if (!custom) return void 0;
9393
- const path = resolve9(custom);
9394
- if (existsSync18(path)) return path;
9880
+ const path = resolve10(custom);
9881
+ if (existsSync19(path)) return path;
9395
9882
  info(`${INDENT4}\u26A0 outro not found: ${path}`);
9396
9883
  return void 0;
9397
9884
  }
9398
9885
  const scriptDir = getScriptDir();
9399
9886
  const bundled = join24(scriptDir, "assets", "qp.wm");
9400
- if (existsSync18(bundled)) return bundled;
9887
+ if (existsSync19(bundled)) return bundled;
9401
9888
  const fallback = join24(scriptDir, "..", "dist", "assets", "qp.wm");
9402
- return existsSync18(fallback) ? fallback : void 0;
9889
+ return existsSync19(fallback) ? fallback : void 0;
9403
9890
  }
9404
- var EMPTY_MIN_SECS = 0.5;
9891
+ var EMPTY_MIN_SECS = 0.4;
9405
9892
  var OUTRO_TAIL_MS = 900;
9406
9893
  var FIT_SECOND_PASS_CUSHION_SECS = 1;
9407
9894
  var FIT_SECOND_PASS_MAX_SPEEDUP = 1.45;
@@ -9426,11 +9913,12 @@ async function renderVideoPhase(params) {
9426
9913
  })
9427
9914
  );
9428
9915
  const recordingPath = getRecordingPath(config.output.dir);
9429
- const hasExistingRecording = existingRecordingMeta && existsSync18(recordingPath);
9916
+ const hasExistingRecording = existingRecordingMeta && existsSync19(recordingPath);
9430
9917
  let videoPath = recordingPath;
9431
9918
  const firstStep = plan.steps[0];
9432
9919
  const firstStepIsVideo = firstStep && (firstStep.action === "video" || firstStep.action === "media");
9433
9920
  let trimStart = 0;
9921
+ let recordedStepLengths = null;
9434
9922
  const doRecord = async () => {
9435
9923
  header("Record");
9436
9924
  const result = await recordSession(plan, config, stepDurations, workDir, config.output.dir, task, logError, {
@@ -9442,6 +9930,7 @@ async function renderVideoPhase(params) {
9442
9930
  ]);
9443
9931
  const detectedEmpty = mergeSpans([...result.leadSpans ?? [], ...emptyBlack, ...emptyWhite]);
9444
9932
  let stepLengths = result.actualStepDurations;
9933
+ let zeroAnchored = false;
9445
9934
  if (detectedEmpty.length > 0) {
9446
9935
  const bounds = [];
9447
9936
  let at = 0;
@@ -9466,6 +9955,7 @@ async function renderVideoPhase(params) {
9466
9955
  const sum = result.actualStepDurations.reduce((a, b2) => a + b2, 0);
9467
9956
  const factor = sum > 0 && realSecs > 0 ? realSecs * 1e3 / sum : 1;
9468
9957
  stepLengths = result.actualStepDurations.map((ms) => ms * factor);
9958
+ zeroAnchored = true;
9469
9959
  info(`Sped up ${emptySecs.toFixed(1)}s of loading screens at ${EMPTY_SPAN_SPEED}x`);
9470
9960
  } else {
9471
9961
  await discardPartial(retimed);
@@ -9482,16 +9972,19 @@ async function renderVideoPhase(params) {
9482
9972
  const meta = buildRecordingMeta(plan, config, stepDurations);
9483
9973
  meta.actualStepDurations = stepLengths;
9484
9974
  meta.pageLoadTime = result.pageLoadTime;
9975
+ meta.zeroAnchored = zeroAnchored;
9485
9976
  await saveRecordingMeta(config.output.dir, meta);
9486
9977
  videoPath = recordingPath;
9487
- trimStart = firstStepIsVideo ? 0 : result.pageLoadTime;
9978
+ recordedStepLengths = stepLengths;
9979
+ trimStart = firstStepIsVideo || zeroAnchored ? 0 : result.pageLoadTime;
9488
9980
  };
9489
9981
  if (hasExistingRecording) {
9490
9982
  const reuseCheck = canReuseRecording(plan, config, stepDurations, existingRecordingMeta);
9491
9983
  if (reuseCheck.canReuse) {
9492
9984
  info("Reusing existing recording");
9493
9985
  videoPath = recordingPath;
9494
- trimStart = firstStepIsVideo ? 0 : existingRecordingMeta?.pageLoadTime ?? 0.5;
9986
+ recordedStepLengths = existingRecordingMeta.actualStepDurations ?? existingRecordingMeta.stepDurations;
9987
+ trimStart = firstStepIsVideo || existingRecordingMeta.zeroAnchored ? 0 : existingRecordingMeta?.pageLoadTime ?? 0.5;
9495
9988
  stepAudio = await alignVoiceoverToRecording(
9496
9989
  stepAudio,
9497
9990
  existingRecordingMeta.actualStepDurations ?? existingRecordingMeta.stepDurations,
@@ -9505,9 +9998,23 @@ async function renderVideoPhase(params) {
9505
9998
  } else {
9506
9999
  await doRecord();
9507
10000
  }
9508
- const alignedStepDurations = plan.steps.map((_, i) => stepAudio.get(i + 1)?.duration ?? 0);
10001
+ const alignedStepDurations = plan.steps.map(
10002
+ (step, i) => Math.max(
10003
+ stepAudio.get(i + 1)?.duration ?? recordedStepLengths?.[i] ?? stepDurations[i] ?? 0,
10004
+ step.duration ? step.duration * 1e3 : 0
10005
+ )
10006
+ );
9509
10007
  let splicedClipAudio = false;
9510
- const videoOverlays = buildVideoOverlays(plan.steps, alignedStepDurations, config.output.dir, trimStart);
10008
+ const recordingSecs = await getMediaDuration(videoPath).then((ms) => ms / 1e3).catch(() => void 0);
10009
+ const blackSpans = await detectBlackSpans(videoPath, 0.3).catch(() => []);
10010
+ const videoOverlays = buildVideoOverlays(
10011
+ plan.steps,
10012
+ alignedStepDurations,
10013
+ config.output.dir,
10014
+ trimStart,
10015
+ recordingSecs,
10016
+ blackSpans
10017
+ );
9511
10018
  if (videoOverlays.length > 0) {
9512
10019
  const spliceTask = task("Painting cards and clips into recording");
9513
10020
  try {
@@ -9561,11 +10068,13 @@ async function renderVideoPhase(params) {
9561
10068
  const cardStepSeconds = (index) => {
9562
10069
  const action = plan.steps[index]?.action;
9563
10070
  if (action !== "image" && action !== "media") return 0;
9564
- return (stepAudio.get(index + 1)?.duration ?? 0) / 1e3;
10071
+ const audioSecs = (stepAudio.get(index + 1)?.duration ?? 0) / 1e3;
10072
+ const tail = index === plan.steps.length - 1 && action === "image" ? OUTRO_TAIL_MS / 1e3 : 0;
10073
+ return Math.max(audioSecs, plan.steps[index]?.duration ?? 0) + tail;
9565
10074
  };
9566
10075
  const introSecs = cardStepSeconds(0);
9567
10076
  const outroStepSecs = cardStepSeconds(plan.steps.length - 1);
9568
- const outroSeconds = config.video.outroCard?.seconds ?? (outroStepSecs || 3.5);
10077
+ const outroSeconds = Math.max(config.video.outroCard?.seconds ?? 3.5, outroStepSecs);
9569
10078
  const card = config.video.outroCard;
9570
10079
  if (card?.subject?.name) {
9571
10080
  const cardTask = task("Rendering credits card");
@@ -9609,6 +10118,20 @@ async function renderVideoPhase(params) {
9609
10118
  mainContentDuration = void 0;
9610
10119
  }
9611
10120
  }
10121
+ const outroOnly = config.music?.outroOnly !== false;
10122
+ let musicStartSecs = 0;
10123
+ if (outroOnly) {
10124
+ let trailingCards = 0;
10125
+ for (let i = plan.steps.length - 1; i >= 0; i--) {
10126
+ const action = plan.steps[i]?.action;
10127
+ if (action !== "image" && action !== "media") break;
10128
+ trailingCards++;
10129
+ }
10130
+ if (trailingCards > 0 && trailingCards < plan.steps.length) {
10131
+ const upTo = plan.steps.length - trailingCards;
10132
+ musicStartSecs = alignedStepDurations.slice(0, upTo).reduce((a, b) => a + b, 0) / 1e3;
10133
+ }
10134
+ }
9612
10135
  let finalPath = await composeAndFinish(
9613
10136
  videoToCompose,
9614
10137
  voiceoverPath,
@@ -9620,7 +10143,9 @@ async function renderVideoPhase(params) {
9620
10143
  stepTransitions,
9621
10144
  // The splice writes a clip-audio track only when a spliced clip had one.
9622
10145
  splicedClipAudio,
9623
- endsOnCreditsCard
10146
+ endsOnCreditsCard,
10147
+ void 0,
10148
+ musicStartSecs
9624
10149
  );
9625
10150
  const maxSilence = config.video.maxSilenceSecs ?? 0.6;
9626
10151
  if (maxSilence > 0) {
@@ -9785,7 +10310,7 @@ async function discardPartial(path) {
9785
10310
  }
9786
10311
  var MIN_ARTIFACT_BYTES = 1024;
9787
10312
  function assertArtifact(finalPath) {
9788
- const size = existsSync18(finalPath) ? statSync3(finalPath).size : 0;
10313
+ const size = existsSync19(finalPath) ? statSync3(finalPath).size : 0;
9789
10314
  if (size >= MIN_ARTIFACT_BYTES) return;
9790
10315
  throw new Error(
9791
10316
  `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 +10364,7 @@ async function useOgCardAsIntro(plan, url, config, fresh = false) {
9839
10364
  const rewritable = first.action === "wait" && !first.target;
9840
10365
  const portrait = (config.video.height ?? 0) > (config.video.width ?? 0);
9841
10366
  const existing = join25(config.output.dir, "og-card.png");
9842
- const fetched = !fresh && existsSync19(existing) ? existing : await fetchOgCard(url, config.output.dir, portrait);
10367
+ const fetched = !fresh && existsSync20(existing) ? existing : await fetchOgCard(url, config.output.dir, portrait);
9843
10368
  const ogPath = fetched && coversFrame(fetched, config) ? fetched : null;
9844
10369
  const cardPath = ogPath ?? await renderGeneratedIntroCard(url, config);
9845
10370
  if (!cardPath) {
@@ -9929,30 +10454,20 @@ function fitFor(cardPath, config) {
9929
10454
  return Math.abs(size.width / size.height - frame) < 0.06 ? "stretch" : "cover";
9930
10455
  }
9931
10456
  function resolveCardPath(value, config) {
9932
- return isAbsolute5(value) ? value : join25(config.output.dir, value.replace(/^\.\//, ""));
10457
+ return isAbsolute6(value) ? value : join25(config.output.dir, value.replace(/^\.\//, ""));
9933
10458
  }
9934
10459
  var DEFAULT_INTRO_CARD_SECONDS = 2.5;
9935
10460
  function packageAsset(relative) {
9936
10461
  let dir = getScriptDir();
9937
10462
  for (let up = 0; up < 6; up++) {
9938
10463
  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;
10464
+ if (existsSync20(candidate)) return candidate;
9949
10465
  dir = join25(dir, "..");
9950
10466
  }
9951
10467
  return void 0;
9952
10468
  }
9953
10469
  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") }
10470
+ { name: "QuickPeek", role: "Video generated by", icon: () => packageAsset("imgs/qp.png") }
9956
10471
  ];
9957
10472
  function oneTagline(text) {
9958
10473
  if (!text) return void 0;
@@ -10044,7 +10559,7 @@ async function runPlanPhase(params) {
10044
10559
  const extensionPath = config.browser?.extension;
10045
10560
  let existingPlan = null;
10046
10561
  let existingPlanPath = null;
10047
- if (existsSync20(planPath)) {
10562
+ if (existsSync21(planPath)) {
10048
10563
  try {
10049
10564
  const parsed = JSON.parse(await readFile9(planPath, "utf-8"));
10050
10565
  if (parsed.url && parsed.steps?.length > 0) {
@@ -10074,7 +10589,22 @@ async function runPlanPhase(params) {
10074
10589
  }
10075
10590
  }
10076
10591
  const normalizeUrl2 = (u) => u.replace(/\/+$/, "").toLowerCase();
10077
- const urlMatches = existingPlan && normalizeUrl2(existingPlan.url) === normalizeUrl2(url);
10592
+ const isBareOrigin = (u) => {
10593
+ try {
10594
+ const parsed = new URL(u);
10595
+ return parsed.pathname === "/" && !parsed.search && !parsed.hash;
10596
+ } catch {
10597
+ return false;
10598
+ }
10599
+ };
10600
+ const sameOrigin = (a, b) => {
10601
+ try {
10602
+ return new URL(a).origin === new URL(b).origin;
10603
+ } catch {
10604
+ return false;
10605
+ }
10606
+ };
10607
+ const urlMatches = existingPlan && (normalizeUrl2(existingPlan.url) === normalizeUrl2(url) || isBareOrigin(existingPlan.url) && sameOrigin(existingPlan.url, url));
10078
10608
  if (existingPlan && !urlMatches && isAutoYes()) {
10079
10609
  console.log();
10080
10610
  console.log(chalk13.dim(` Existing plan is for ${existingPlan.url}, not ${url}, regenerating.`));
@@ -10218,7 +10748,7 @@ async function runPlanPhase(params) {
10218
10748
  const planLang = plan.lang || "en";
10219
10749
  if (planLang !== targetLang) {
10220
10750
  let useExistingTranslation = false;
10221
- if (existsSync20(planPath) && existingPlanPath !== planPath) {
10751
+ if (existsSync21(planPath) && existingPlanPath !== planPath) {
10222
10752
  try {
10223
10753
  const translatedPlan = JSON.parse(await readFile9(planPath, "utf-8"));
10224
10754
  if (translatedPlan.steps?.length === plan.steps.length && translatedPlan.lang === targetLang) {
@@ -10397,7 +10927,7 @@ async function runValidationPhase(params) {
10397
10927
 
10398
10928
  // src/commands/phases/voiceover-phase.ts
10399
10929
  init_lib();
10400
- import { existsSync as existsSync22 } from "fs";
10930
+ import { existsSync as existsSync23 } from "fs";
10401
10931
  import { join as join28 } from "path";
10402
10932
  import chalk15 from "chalk";
10403
10933
  var INDENT6 = " ";
@@ -10458,14 +10988,14 @@ async function clearCachedFiles(outputDir) {
10458
10988
  const { unlink: unlink5 } = await import("fs/promises");
10459
10989
  for (const file of CACHED_FILES) {
10460
10990
  const filePath = join28(outputDir, file);
10461
- if (existsSync22(filePath)) await unlink5(filePath).catch(() => {
10991
+ if (existsSync23(filePath)) await unlink5(filePath).catch(() => {
10462
10992
  });
10463
10993
  }
10464
10994
  info("Cleared cached files (--fresh)");
10465
10995
  }
10466
10996
  async function usableVoiceover(outputDir, meta) {
10467
10997
  const voiceoverFile = join28(outputDir, "voiceover.wav");
10468
- if (!meta || !existsSync22(voiceoverFile)) return null;
10998
+ if (!meta || !existsSync23(voiceoverFile)) return null;
10469
10999
  if (await isHonestWav(voiceoverFile)) return meta;
10470
11000
  info("Existing voiceover has a stale container, regenerating");
10471
11001
  return null;
@@ -10543,7 +11073,7 @@ async function ensureCliSignIn(url, opts) {
10543
11073
  }
10544
11074
 
10545
11075
  // src/commands/utils/chrome-extension.ts
10546
- import { existsSync as existsSync23, readdirSync as readdirSync5, readFileSync as readFileSync9 } from "fs";
11076
+ import { existsSync as existsSync24, readdirSync as readdirSync5, readFileSync as readFileSync9 } from "fs";
10547
11077
  import { homedir as homedir4 } from "os";
10548
11078
  import { join as join29 } from "path";
10549
11079
  function resolveChromeExtension(extensionId) {
@@ -10579,7 +11109,7 @@ function resolveChromeExtension(extensionId) {
10579
11109
  }
10580
11110
  for (const chromeExtDir of possibleDirs) {
10581
11111
  const extDir = join29(chromeExtDir, extensionId);
10582
- if (!existsSync23(extDir)) continue;
11112
+ if (!existsSync24(extDir)) continue;
10583
11113
  try {
10584
11114
  const versions = readdirSync5(extDir).filter((f) => !f.startsWith("."));
10585
11115
  if (versions.length === 0) continue;
@@ -10588,7 +11118,7 @@ function resolveChromeExtension(extensionId) {
10588
11118
  let name = extensionId;
10589
11119
  let popupPath = "popup.html";
10590
11120
  const manifestPath = join29(extPath, "manifest.json");
10591
- if (existsSync23(manifestPath)) {
11121
+ if (existsSync24(manifestPath)) {
10592
11122
  try {
10593
11123
  const manifest = JSON.parse(readFileSync9(manifestPath, "utf-8"));
10594
11124
  if (manifest.name && !manifest.name.startsWith("__MSG_")) {
@@ -10611,7 +11141,7 @@ function resolveChromeExtension(extensionId) {
10611
11141
 
10612
11142
  // src/commands/utils/plan-review.ts
10613
11143
  import { createHash as createHash2 } from "crypto";
10614
- import { existsSync as existsSync24 } from "fs";
11144
+ import { existsSync as existsSync25 } from "fs";
10615
11145
  import { readFile as readFile11, writeFile as writeFile12 } from "fs/promises";
10616
11146
  import { join as join30 } from "path";
10617
11147
  var REVIEW_FILE = "plan-review.json";
@@ -10624,7 +11154,7 @@ async function hashPlanFile(planPath) {
10624
11154
  }
10625
11155
  async function loadPlanReview(outputDir) {
10626
11156
  const path = join30(outputDir, REVIEW_FILE);
10627
- if (!existsSync24(path)) return {};
11157
+ if (!existsSync25(path)) return {};
10628
11158
  try {
10629
11159
  return JSON.parse(await readFile11(path, "utf-8"));
10630
11160
  } catch {
@@ -10746,8 +11276,8 @@ async function recordCommand(url, options = {}) {
10746
11276
  }
10747
11277
  if (langOverride) config.lang = langOverride;
10748
11278
  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)) {
11279
+ const musicPath = isAbsolute7(config.music.path) ? config.music.path : resolve11(config.output.dir, config.music.path);
11280
+ if (!existsSync26(musicPath)) {
10751
11281
  console.log();
10752
11282
  console.log(chalk17.yellow(` \u26A0 Music file not found: ${config.music.path}`));
10753
11283
  console.log(chalk17.dim(` Config: ${join31(runRoot(), CONFIG_FILE)}`));
@@ -10768,7 +11298,7 @@ async function recordCommand(url, options = {}) {
10768
11298
  const targetLang = config.lang || "en";
10769
11299
  const planPath = join31(config.output.dir, `plan-${targetLang}.json`);
10770
11300
  const isExtensionUrl = url.startsWith("chrome-extension://");
10771
- await mkdir9(config.output.dir, { recursive: true });
11301
+ await mkdir10(config.output.dir, { recursive: true });
10772
11302
  await setLogFilePath(config.output.dir);
10773
11303
  const preRunHash = await hashPlanFile(planPath);
10774
11304
  try {
@@ -10857,6 +11387,13 @@ async function recordCommand(url, options = {}) {
10857
11387
  userTier,
10858
11388
  userEmail
10859
11389
  });
11390
+ await registerDemoRun(userEmail, {
11391
+ source: currentMcpTool2() ? "mcp" : "cli",
11392
+ targetUrl: validatedPlan.url || url,
11393
+ localPath: finalPath,
11394
+ title: validatedPlan.title,
11395
+ ...config.lang ? { language: config.lang } : {}
11396
+ });
10860
11397
  if (!userEmail || userEmail === "anonymous") {
10861
11398
  const newCount = localVideoCount + 1;
10862
11399
  await saveUserData({ localVideoCount: newCount });
@@ -11287,7 +11824,7 @@ function withTierNote(result, tier) {
11287
11824
  ...result.content,
11288
11825
  {
11289
11826
  type: "text",
11290
- text: `Free tier: this demo carries a watermark and the free video limit applies. Upgrade at ${pricingUrl()}`
11827
+ 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
11828
  }
11292
11829
  ]
11293
11830
  };
@@ -11383,7 +11920,7 @@ var FORMAT_PROPERTY = {
11383
11920
  var APPROVE_PLAN_PROPERTY = {
11384
11921
  approve_plan: {
11385
11922
  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."
11923
+ 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
11924
  }
11388
11925
  };
11389
11926
  var DRAFT_PROPERTY = {
@@ -11430,7 +11967,7 @@ var OUTRO_PROPERTY = {
11430
11967
  var QUICKPEEK_TOOLS = [
11431
11968
  {
11432
11969
  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.",
11970
+ 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
11971
  inputSchema: {
11435
11972
  type: "object",
11436
11973
  properties: {
@@ -11506,7 +12043,7 @@ var QUICKPEEK_TOOLS = [
11506
12043
  },
11507
12044
  {
11508
12045
  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.",
12046
+ 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
12047
  inputSchema: {
11511
12048
  type: "object",
11512
12049
  properties: { ...URL_PROPERTY },
@@ -11543,7 +12080,7 @@ var QUICKPEEK_TOOLS = [
11543
12080
  },
11544
12081
  {
11545
12082
  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.",
12083
+ 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
12084
  inputSchema: { type: "object", properties: {} }
11548
12085
  }
11549
12086
  ];
@@ -11628,7 +12165,7 @@ async function runDemoGates(url, args) {
11628
12165
  ...missing.length ? { options_needed: missing } : {},
11629
12166
  next_steps: [
11630
12167
  ...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.`] : [],
12168
+ ...missing.length ? [`Ask the USER to choose ${missing.join(", ")}, do not pick for them, and pass the answers as params.`] : [],
11632
12169
  "Then call this tool again with the same arguments plus the answers."
11633
12170
  ]
11634
12171
  });
@@ -11664,7 +12201,7 @@ async function runDemoGates(url, args) {
11664
12201
  return jsonContent({
11665
12202
  status: "setup_required",
11666
12203
  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.`
12204
+ 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
12205
  });
11669
12206
  }
11670
12207
  }
@@ -11714,7 +12251,7 @@ async function runDemoGates(url, args) {
11714
12251
  }
11715
12252
  function mcpPlanReview() {
11716
12253
  return async (plan, planPath) => {
11717
- const editorUrl = planEditorUrl(editorBaseUrl(), plan);
12254
+ const editorUrl = await editorUrlWithThumbs(editorBaseUrl(), plan, dirname5(planPath));
11718
12255
  if (!canElicit()) throw new PlanReviewPending(plan, planPath, editorUrl);
11719
12256
  if (editorUrl) await openInBrowser(editorUrl);
11720
12257
  const steps = plan.steps.map((s, i) => `${i + 1}. [${s.action}] ${s.caption ?? ""}`).join("\n");
@@ -11723,7 +12260,7 @@ function mcpPlanReview() {
11723
12260
 
11724
12261
  ${steps}
11725
12262
 
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.`,
12263
+ ` + (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
12264
  "approved",
11728
12265
  "Record this plan?"
11729
12266
  );
@@ -11741,14 +12278,14 @@ function planReadyResult(pending2) {
11741
12278
  next_steps: [
11742
12279
  "Show these steps to the USER and ask whether to record them (they can also fine-tune in editor_url).",
11743
12280
  "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."
12281
+ "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
12282
  ]
11746
12283
  });
11747
12284
  }
11748
12285
  function planRejectedResult(e) {
11749
12286
  return jsonContent({
11750
12287
  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."
12288
+ 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
12289
  });
11753
12290
  }
11754
12291
  async function handleGenerateDemo(args) {
@@ -11841,7 +12378,7 @@ async function handleOpenInEditor(args) {
11841
12378
  const parsed = parsePlanText(readFileSync10(planPath, "utf8"));
11842
12379
  if (!parsed.ok) return errorContent(`${planPath} cannot be opened: ${parsed.error}`);
11843
12380
  const base = editorBaseUrl();
11844
- const editorUrl = planEditorUrl(base, parsed.plan);
12381
+ const editorUrl = await editorUrlWithThumbs(base, parsed.plan, dir);
11845
12382
  await openInBrowser(editorUrl ?? `${base}/edit`);
11846
12383
  return jsonContent({
11847
12384
  opened: true,
@@ -11930,7 +12467,7 @@ async function handleCheckAccount() {
11930
12467
  return jsonContent({
11931
12468
  signedIn: false,
11932
12469
  tier: "free",
11933
- note: "Not signed in. Demos carry a watermark and the free video limit applies."
12470
+ 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
12471
  });
11935
12472
  }
11936
12473
  const tier = await getTierByEmail(userData.email);
@@ -11938,15 +12475,15 @@ async function handleCheckAccount() {
11938
12475
  signedIn: true,
11939
12476
  email: userData.email,
11940
12477
  tier,
11941
- note: tier === "free" ? "Free tier: demos carry a watermark and the free video limit applies." : `${tier} tier: no watermark.`
12478
+ 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
12479
  });
11943
12480
  });
11944
12481
  }
11945
12482
  var PROMO_DIR = "promo";
11946
12483
  function resolveRunRoot(projectDir) {
11947
12484
  if (typeof projectDir !== "string" || !projectDir.trim()) return process.cwd();
11948
- const abs = resolve11(projectDir.trim());
11949
- if (!existsSync26(abs) || !statSync4(abs).isDirectory()) {
12485
+ const abs = resolve12(projectDir.trim());
12486
+ if (!existsSync27(abs) || !statSync4(abs).isDirectory()) {
11950
12487
  throw new Error(`project_dir is not an existing directory: ${abs}`);
11951
12488
  }
11952
12489
  const root = basename5(abs) === PROMO_DIR ? abs : join33(abs, PROMO_DIR);