@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.
package/dist/qp.js CHANGED
@@ -105,6 +105,181 @@ var init_lifecycle = __esm({
105
105
  }
106
106
  });
107
107
 
108
+ // src/lib/image-kind.ts
109
+ function mimeForKind(kind) {
110
+ return MIME[kind];
111
+ }
112
+ function sniffImageKind(buf) {
113
+ if (buf.length < 12) return null;
114
+ if (buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "png";
115
+ if (buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "jpeg";
116
+ if (buf.subarray(0, 3).toString("latin1") === "GIF") return "gif";
117
+ if (buf.subarray(0, 4).toString("latin1") === "RIFF" && buf.subarray(8, 12).toString("latin1") === "WEBP") {
118
+ return "webp";
119
+ }
120
+ const head = buf.subarray(0, 1024).toString("utf8").replace(/^/, "").trimStart();
121
+ if (/^<(\?xml|!--|!doctype svg|svg)[\s>]/i.test(head) && /<svg[\s>]/i.test(head)) return "svg";
122
+ return null;
123
+ }
124
+ function imageDataUri(path) {
125
+ try {
126
+ const buf = (0, import_node_fs2.readFileSync)(path);
127
+ const kind = sniffImageKind(buf);
128
+ if (!kind) return null;
129
+ return `data:${mimeForKind(kind)};base64,${buf.toString("base64")}`;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ var import_node_fs2, MIME, MIN_ICON_PX;
135
+ var init_image_kind = __esm({
136
+ "src/lib/image-kind.ts"() {
137
+ "use strict";
138
+ import_node_fs2 = require("fs");
139
+ MIME = {
140
+ png: "image/png",
141
+ jpeg: "image/jpeg",
142
+ gif: "image/gif",
143
+ webp: "image/webp",
144
+ svg: "image/svg+xml"
145
+ };
146
+ MIN_ICON_PX = 48;
147
+ }
148
+ });
149
+
150
+ // src/browser/headless.ts
151
+ async function launchHeadlessChromium(options = {}) {
152
+ const opts = { ...options, headless: true };
153
+ try {
154
+ return await import_playwright.chromium.launch(opts);
155
+ } catch (bundledError) {
156
+ try {
157
+ return await import_playwright.chromium.launch({ ...opts, channel: "chrome" });
158
+ } catch {
159
+ const detail = bundledError instanceof Error ? bundledError.message : String(bundledError);
160
+ throw new Error(`${NO_BROWSER_MESSAGE}
161
+
162
+ Playwright reported: ${detail}`);
163
+ }
164
+ }
165
+ }
166
+ var import_playwright, NO_BROWSER_MESSAGE;
167
+ var init_headless = __esm({
168
+ "src/browser/headless.ts"() {
169
+ "use strict";
170
+ import_playwright = require("playwright");
171
+ 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";
172
+ }
173
+ });
174
+
175
+ // src/lib/json-repair.ts
176
+ function repairUnicodeEscapes(content) {
177
+ return content.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (match, hex) => {
178
+ const code = Number.parseInt(hex, 16);
179
+ return code <= 1114111 ? String.fromCodePoint(code) : match;
180
+ }).replace(/\\u(?![0-9a-fA-F]{4})/g, "\\\\u");
181
+ }
182
+ function salvageTruncatedJson(content) {
183
+ const stack = [];
184
+ const cuts = [];
185
+ let inString = false;
186
+ let escaped = false;
187
+ const record = (index, closesContainer) => cuts.push({ index, open: stack.join(""), closesContainer });
188
+ for (let i = 0; i < content.length; i++) {
189
+ const ch = content[i];
190
+ if (inString) {
191
+ if (escaped) escaped = false;
192
+ else if (ch === "\\") escaped = true;
193
+ else if (ch === '"') {
194
+ inString = false;
195
+ record(i + 1, false);
196
+ }
197
+ continue;
198
+ }
199
+ if (ch === '"') {
200
+ inString = true;
201
+ } else if (ch === "{" || ch === "[") {
202
+ stack.push(ch);
203
+ } else if (ch === "}" || ch === "]") {
204
+ stack.pop();
205
+ record(i + 1, true);
206
+ } else if (ch === ",") {
207
+ record(i, content[i - 1] === "}" || content[i - 1] === "]");
208
+ }
209
+ }
210
+ const tryCut = (cut) => {
211
+ const head = content.slice(0, cut.index).replace(/[\s,]+$/, "");
212
+ if (head.endsWith(":")) return null;
213
+ let closed = head;
214
+ for (let d = cut.open.length - 1; d >= 0; d--) {
215
+ closed += CLOSERS[cut.open[d]];
216
+ }
217
+ try {
218
+ JSON.parse(closed);
219
+ return closed;
220
+ } catch {
221
+ return null;
222
+ }
223
+ };
224
+ const attempts = Math.min(cuts.length, MAX_SALVAGE_ATTEMPTS);
225
+ for (const containerOnly of [true, false]) {
226
+ for (let n = 0; n < attempts; n++) {
227
+ const cut = cuts[cuts.length - 1 - n];
228
+ if (containerOnly && !cut.closesContainer) continue;
229
+ const salvaged = tryCut(cut);
230
+ if (salvaged !== null) return salvaged;
231
+ }
232
+ }
233
+ return null;
234
+ }
235
+ function extractJsonCandidates(content) {
236
+ let text = content.trim();
237
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)(?:```|$)/);
238
+ if (fence?.[1]?.trim()) text = fence[1].trim();
239
+ const start = text.search(/[{[]/);
240
+ if (start === -1) return [];
241
+ const closer = text[start] === "{" ? "}" : "]";
242
+ const end = text.lastIndexOf(closer);
243
+ const candidates = [text.slice(start)];
244
+ if (end > start && end < text.length - 1) candidates.unshift(text.slice(start, end + 1));
245
+ return candidates;
246
+ }
247
+ function parseAIJson(content) {
248
+ const candidates = extractJsonCandidates(content);
249
+ if (candidates.length === 0) return null;
250
+ for (const text of candidates) {
251
+ try {
252
+ return { value: JSON.parse(text), repaired: false };
253
+ } catch {
254
+ }
255
+ }
256
+ for (const text of candidates) {
257
+ try {
258
+ return { value: JSON.parse(repairUnicodeEscapes(text)), repaired: true };
259
+ } catch {
260
+ }
261
+ }
262
+ for (const text of candidates) {
263
+ for (const variant of [repairUnicodeEscapes(text), text]) {
264
+ const salvaged = salvageTruncatedJson(variant);
265
+ if (salvaged === null) continue;
266
+ try {
267
+ return { value: JSON.parse(salvaged), repaired: true };
268
+ } catch {
269
+ }
270
+ }
271
+ }
272
+ return null;
273
+ }
274
+ var MAX_SALVAGE_ATTEMPTS, CLOSERS;
275
+ var init_json_repair = __esm({
276
+ "src/lib/json-repair.ts"() {
277
+ "use strict";
278
+ MAX_SALVAGE_ATTEMPTS = 500;
279
+ CLOSERS = { "{": "}", "[": "]" };
280
+ }
281
+ });
282
+
108
283
  // src/lib/utils.ts
109
284
  function getLanguageName(lang) {
110
285
  if (!lang || lang === "en") return null;
@@ -344,7 +519,7 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
344
519
  ...detail ? { detail } : {},
345
520
  ...retryAfter2 ? { retryAfterSeconds: parseInt(retryAfter2, 10) } : {}
346
521
  };
347
- await new Promise((resolve10) => setTimeout(resolve10, Math.min(delayMs, 3e4)));
522
+ await new Promise((resolve11) => setTimeout(resolve11, Math.min(delayMs, 3e4)));
348
523
  continue;
349
524
  }
350
525
  const retryAfter = response.headers.get("Retry-After");
@@ -384,7 +559,7 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
384
559
  return { success: false, error: `${host} unreachable: ${getErrorMessage(err)}`, host };
385
560
  }
386
561
  const delayMs = 1e3 * 2 ** (attempt - 1);
387
- await new Promise((resolve10) => setTimeout(resolve10, delayMs));
562
+ await new Promise((resolve11) => setTimeout(resolve11, delayMs));
388
563
  }
389
564
  }
390
565
  return lastRetriableError || { success: false, error: `${host}: request failed after retries`, host };
@@ -426,24 +601,26 @@ async function callAIViaRelay(systemPrompt, userPrompt, email, tier = "free", op
426
601
  }
427
602
  }
428
603
  function parseAIPlanResponse(content) {
429
- try {
430
- let processedContent = content;
431
- const codeBlockMatch = processedContent.match(/```(?:json)?\s*([\s\S]*?)```/);
432
- if (codeBlockMatch?.[1]) {
433
- processedContent = codeBlockMatch[1].trim();
434
- }
435
- const jsonMatch = processedContent.match(/\{[\s\S]*\}/);
436
- if (!jsonMatch) {
437
- return { success: false, error: "No valid JSON found in AI response" };
438
- }
439
- const parsed = JSON.parse(jsonMatch[0]);
440
- return { success: true, plan: parsed };
441
- } catch (err) {
442
- return {
443
- success: false,
444
- error: `Failed to parse AI response: ${err instanceof Error ? err.message : String(err)}`
445
- };
604
+ const parsed = parseAIJson(content);
605
+ if (!parsed) {
606
+ return { success: false, error: "No valid JSON found in AI response" };
446
607
  }
608
+ if (!Array.isArray(parsed.value.steps) || parsed.value.steps.length === 0) {
609
+ return { success: false, error: "AI response has no steps" };
610
+ }
611
+ return { success: true, plan: { ...parsed.value, steps: parsed.value.steps } };
612
+ }
613
+ async function callAIForPlan(systemPrompt, userPrompt, tier = "free", opts = {}) {
614
+ let lastParseError = "No valid JSON found in AI response";
615
+ for (let attempt = 1; attempt <= 2; attempt++) {
616
+ const aiResult = await callAI(systemPrompt, userPrompt, tier, opts);
617
+ if (!aiResult.success) return aiResult;
618
+ const parsed = parseAIPlanResponse(aiResult.content);
619
+ if (parsed.success) return { success: true, plan: parsed.plan, limits: aiResult.limits };
620
+ lastParseError = parsed.error;
621
+ console.error(`Plan parse failed (attempt ${attempt}/2):`, parsed.error);
622
+ }
623
+ return { success: false, error: lastParseError, isParseError: true };
447
624
  }
448
625
  function normalizeUrl(input) {
449
626
  let url = input.trim();
@@ -454,6 +631,7 @@ var PLAN_MAX_TOKENS, SERVER_BACKOFF_MS, CLI_BACKOFF_MS, SPARK_AI_BASE, SPARK_AI_
454
631
  var init_api_client = __esm({
455
632
  "src/lib/api-client.ts"() {
456
633
  "use strict";
634
+ init_json_repair();
457
635
  init_utils();
458
636
  PLAN_MAX_TOKENS = 1500;
459
637
  SERVER_BACKOFF_MS = [2e3, 4e3];
@@ -546,9 +724,6 @@ var init_SparkStripe_feature_gating = __esm({
546
724
  });
547
725
 
548
726
  // src/lib/billing.ts
549
- function shouldWatermark(tier, videoDurationSeconds) {
550
- return tier === "free" && videoDurationSeconds > 20;
551
- }
552
727
  function getUpgradeUrl(userEmail) {
553
728
  return pricingUrl(userEmail ? { email: userEmail } : void 0);
554
729
  }
@@ -583,7 +758,10 @@ function applyProfile(config, profile) {
583
758
  format: "mp3",
584
759
  bitrate: "192k",
585
760
  ...config.audio,
586
- speed: config.audio?.speed ?? preset.audioSpeed,
761
+ // outroOnly is on unless turned off, matching the video phase. The
762
+ // speedup buys the song its window, so it applies to the profile's own
763
+ // pace only: a speed somebody set by hand is the speed they wanted.
764
+ speed: config.audio?.speed ?? preset.audioSpeed * (config.music?.outroOnly !== false ? OUTRO_SONG_SPEEDUP : 1),
587
765
  ...config.audio?.voice || !preset.voice ? {} : { voice: preset.voice }
588
766
  }
589
767
  };
@@ -620,11 +798,11 @@ function defaultCaptionsFor(width, height) {
620
798
  maxChars: 20
621
799
  };
622
800
  }
623
- var VERSION, CONFIG_FILE, SIZE_PRESETS, RATE_FACTOR, NO_CLIP_OUTRO, DEFAULT_CONFIG, VIDEO_PROFILES, DEFAULT_CAPTIONS;
801
+ var VERSION, CONFIG_FILE, SIZE_PRESETS, RATE_FACTOR, NO_CLIP_OUTRO, DEFAULT_CONFIG, VIDEO_PROFILES, OUTRO_SONG_SPEEDUP, DEFAULT_CAPTIONS;
624
802
  var init_config = __esm({
625
803
  "src/lib/config.ts"() {
626
804
  "use strict";
627
- VERSION = "1.2.3";
805
+ VERSION = "1.2.6";
628
806
  CONFIG_FILE = "quickpeek.config.json";
629
807
  SIZE_PRESETS = {
630
808
  wide: { width: 1920, height: 1080, zoom: 1 },
@@ -651,11 +829,12 @@ var init_config = __esm({
651
829
  // ms
652
830
  transitions: "none",
653
831
  contrast: 0.25,
654
- // A demo plays a touch faster than it was recorded, because a recording
655
- // paced for the narration reads as slow next to anything else on the same
656
- // feed. 1.2 is the most that still sounds like a person talking; set 1 in
657
- // quickpeek.config.json for the raw pace.
658
- tempo: 1.2,
832
+ // The recording plays at the pace it was filmed at. A tempo above 1 is
833
+ // multiplied by the narration speed in VIDEO_PROFILES and by the fit
834
+ // pass's squeeze, and the three together read as frantic rather than
835
+ // brisk. Speed belongs in one place, so it lives in audioSpeed; set a
836
+ // tempo in quickpeek.config.json to push the picture on top of that.
837
+ tempo: 1,
659
838
  stepDelay: 200
660
839
  // ms delay between steps
661
840
  },
@@ -669,18 +848,24 @@ var init_config = __esm({
669
848
  // these bitrates - the encode is pure wall-clock tax on every run.
670
849
  wide: {
671
850
  video: { width: 1920, height: 1080, zoom: 1.5, fitLimitSecs: 0, maxSpeedup: 1, preset: "veryfast" },
672
- audioSpeed: 1.6
851
+ // A tutorial is watched to learn something, so the narrator is allowed to
852
+ // be brisk but never hurried.
853
+ audioSpeed: 1.15
673
854
  },
674
855
  short: {
675
856
  // fadeOut 0: the video ends on a card the viewer is meant to read,
676
857
  // and a fade takes it away mid-sentence.
677
858
  video: { width: 1080, height: 1920, zoom: 3, fitLimitSecs: 60, maxSpeedup: 1.7, preset: "veryfast", fadeOut: 0 },
678
- audioSpeed: 1.8,
859
+ // 1.8 was unwatchable: stacked on tempo 1.2 and the outro speedup it ran
860
+ // the voice at better than twice its natural rate. A short wants energy,
861
+ // which is the voice and the cutting, not the tape running fast.
862
+ audioSpeed: 1,
679
863
  // A short lives or dies on energy. Ava is the warmest, most animated of
680
864
  // the free neural voices; Sonia reads a short like the shipping forecast.
681
865
  voice: "en-US-AvaMultilingualNeural"
682
866
  }
683
867
  };
868
+ OUTRO_SONG_SPEEDUP = 1.13;
684
869
  DEFAULT_CAPTIONS = {
685
870
  font: "Arial",
686
871
  outline: "#000000",
@@ -829,6 +1014,7 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
829
1014
  const deadline = Date.now() + cap;
830
1015
  let previous = -1;
831
1016
  let unchanged = 0;
1017
+ let clear = 0;
832
1018
  while (Date.now() < deadline) {
833
1019
  const count = await page.evaluate((selector) => {
834
1020
  let visible = 0;
@@ -838,7 +1024,12 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
838
1024
  }
839
1025
  return visible;
840
1026
  }, PLACEHOLDER_SELECTOR).catch(() => 0);
841
- if (count === 0) return;
1027
+ if (count === 0) {
1028
+ if (++clear >= PLACEHOLDER_CLEAR_POLLS) return;
1029
+ await page.waitForTimeout(PLACEHOLDER_POLL_MS);
1030
+ continue;
1031
+ }
1032
+ clear = 0;
842
1033
  if (count === previous) {
843
1034
  if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
844
1035
  } else {
@@ -922,7 +1113,13 @@ async function crawlPage(url, task2 = noopTask, storageStatePath, viewport) {
922
1113
  return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
923
1114
  }
924
1115
  const browserTask = task2("Launching browser");
925
- const browser = await import_playwright4.chromium.launch({ headless: true });
1116
+ let browser;
1117
+ try {
1118
+ browser = await launchHeadlessChromium();
1119
+ } catch (err) {
1120
+ browserTask.succeed("Could not launch a browser");
1121
+ throw err;
1122
+ }
926
1123
  try {
927
1124
  const context = await browser.newContext({
928
1125
  ...storageStatePath ? { storageState: storageStatePath } : {},
@@ -1098,11 +1295,11 @@ async function crawlPage(url, task2 = noopTask, storageStatePath, viewport) {
1098
1295
  });
1099
1296
  }
1100
1297
  }
1101
- var import_playwright4, SETTLE_POLL_MS, SETTLE_STABLE_POLLS, SETTLE_CAP_MS, PLACEHOLDER_POLL_MS, PLACEHOLDER_CAP_MS, PLACEHOLDER_STATIC_POLLS, PLACEHOLDER_SELECTOR, noopTask, CRAWL_CACHE_TTL_MS, CRAWL_CACHE_MAX_ENTRIES, crawlCache;
1298
+ 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;
1102
1299
  var init_crawl = __esm({
1103
1300
  "src/lib/crawl.ts"() {
1104
1301
  "use strict";
1105
- import_playwright4 = require("playwright");
1302
+ init_headless();
1106
1303
  init_scraping();
1107
1304
  SETTLE_POLL_MS = 400;
1108
1305
  SETTLE_STABLE_POLLS = 2;
@@ -1110,6 +1307,7 @@ var init_crawl = __esm({
1110
1307
  PLACEHOLDER_POLL_MS = 250;
1111
1308
  PLACEHOLDER_CAP_MS = 8e3;
1112
1309
  PLACEHOLDER_STATIC_POLLS = 4;
1310
+ PLACEHOLDER_CLEAR_POLLS = 3;
1113
1311
  PLACEHOLDER_SELECTOR = [
1114
1312
  '[aria-busy="true"]',
1115
1313
  '[role="progressbar"]',
@@ -1259,13 +1457,13 @@ function canEncode(encoder) {
1259
1457
  ]);
1260
1458
  }
1261
1459
  function run(args) {
1262
- return new Promise((resolve10) => {
1460
+ return new Promise((resolve11) => {
1263
1461
  let settled = false;
1264
1462
  const done = (ok) => {
1265
1463
  if (settled) return;
1266
1464
  settled = true;
1267
1465
  clearTimeout(timer);
1268
- resolve10(ok);
1466
+ resolve11(ok);
1269
1467
  };
1270
1468
  const proc = trackChild((0, import_node_child_process2.spawn)("ffmpeg", args, { stdio: "ignore" }));
1271
1469
  const timer = setTimeout(() => {
@@ -1332,7 +1530,7 @@ function spawnFFmpeg(cmd2, args) {
1332
1530
  return trackChild((0, import_node_child_process3.spawn)(cmd2, args));
1333
1531
  }
1334
1532
  function runFFmpeg(args, errorLabel) {
1335
- return new Promise((resolve10, reject) => {
1533
+ return new Promise((resolve11, reject) => {
1336
1534
  const proc = spawnFFmpeg("ffmpeg", args);
1337
1535
  let stderr = "";
1338
1536
  proc.stderr.on("data", (d) => {
@@ -1340,7 +1538,7 @@ function runFFmpeg(args, errorLabel) {
1340
1538
  });
1341
1539
  proc.on("close", (code) => {
1342
1540
  if (code !== 0) reject(new Error(`${errorLabel}: ${stderr.slice(-500)}`));
1343
- else resolve10();
1541
+ else resolve11();
1344
1542
  });
1345
1543
  proc.on("error", reject);
1346
1544
  });
@@ -1367,8 +1565,13 @@ async function composeVideo(opts) {
1367
1565
  musicPath,
1368
1566
  musicVolume = 0.3,
1369
1567
  musicTargetLufs = MUSIC_BED_LUFS,
1370
- musicFadeIn = 2,
1371
- musicFadeOut = 2,
1568
+ // Sized for the outro window: the song has 5.5s, so a 2s fade at each end
1569
+ // left 1.5s of music anyone could actually hear. Up fast, hold, out slow.
1570
+ musicFadeIn = 1,
1571
+ musicFadeOut = 1.5,
1572
+ tempo = 1,
1573
+ musicStartSecs = 0,
1574
+ musicDurationSecs = 0,
1372
1575
  width,
1373
1576
  height,
1374
1577
  fps,
@@ -1429,7 +1632,19 @@ async function composeVideo(opts) {
1429
1632
  const clipAudioLabel = opts.hasClipAudio ? "[0:a]" : "";
1430
1633
  const hasVoiceover = !!voiceoverPath;
1431
1634
  const hasMusic = !!musicPath;
1432
- const musicFadeOutStart = Math.max(0, effectiveDuration - musicFadeOut);
1635
+ const bedStart = Math.max(0, Math.min(musicStartSecs, Math.max(0, effectiveDuration - 0.5)));
1636
+ const bedLength = Math.max(0.5, effectiveDuration - bedStart);
1637
+ const bedDelay = bedStart > 0 ? `adelay=${Math.round(bedStart * 1e3)}|${Math.round(bedStart * 1e3)},` : "";
1638
+ const bedTempo = tempo > 1 ? 1 / tempo : 1;
1639
+ const bedSlow = bedTempo === 1 ? "" : `atempo=${bedTempo.toFixed(6)},`;
1640
+ const bedSource = bedLength * bedTempo;
1641
+ const bedSeek = musicDurationSecs > bedSource + 0.05 ? musicDurationSecs - bedSource : 0;
1642
+ const bedTrimIn = bedSeek > 0 ? `atrim=start=${bedSeek.toFixed(3)},asetpts=PTS-STARTPTS,` : "";
1643
+ const bedFadeOut = bedSeek > 0 ? 0 : musicFadeOut * tempo;
1644
+ const bedLoop = bedSeek > 0 ? "" : "aloop=loop=-1:size=2e+09,";
1645
+ const bedFadeIn = musicFadeIn * tempo;
1646
+ const musicFadeOutStart = Math.max(0, bedLength - bedFadeOut);
1647
+ const bedFadeOutFilter = bedFadeOut > 0 ? `afade=t=out:st=${musicFadeOutStart}:d=${bedFadeOut},` : "";
1433
1648
  const vfStr = [...vf, ...encoder.filter ? [encoder.filter] : []].join(",");
1434
1649
  if (hasVoiceover && hasMusic) {
1435
1650
  const mixInputs = clipAudioLabel ? 3 : 2;
@@ -1445,7 +1660,7 @@ async function composeVideo(opts) {
1445
1660
  // card, which is the one place the bed is the only thing playing.
1446
1661
  `[voicekey0]apad=whole_dur=${effectiveDuration}[voicekey]`,
1447
1662
  // Music: normalised to a known level, trimmed, then faded.
1448
- `[2:a]aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},loudnorm=I=${musicTargetLufs}:TP=-9:LRA=11,volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut}[bed]`,
1663
+ `[2:a]${bedTrimIn}${bedLoop}${bedSlow}atrim=0:${bedLength},loudnorm=I=${musicTargetLufs}:TP=-9:LRA=11,volume=${musicVolume},afade=t=in:st=0:d=${bedFadeIn},${bedFadeOutFilter}${bedDelay}apad=whole_dur=${effectiveDuration}[bed]`,
1449
1664
  `[bed][voicekey]${DUCK}[music]`,
1450
1665
  // normalize=0, because amix's default divides every input by the number
1451
1666
  // of them: adding the bed used to quietly take 6 dB off the narration,
@@ -1469,7 +1684,7 @@ async function composeVideo(opts) {
1469
1684
  args.push(...encoder.output);
1470
1685
  args.push("-c:a", "aac", "-b:a", audioBitrate);
1471
1686
  } else if (hasMusic) {
1472
- const musicFilter = `aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut},${import_video_kit3.MASTER_CHAIN}`;
1687
+ const musicFilter = `${bedTrimIn}${bedLoop}${bedSlow}atrim=0:${bedLength},volume=${musicVolume},afade=t=in:st=0:d=${bedFadeIn},${bedFadeOutFilter}${bedDelay}apad=whole_dur=${effectiveDuration},${import_video_kit3.MASTER_CHAIN}`;
1473
1688
  args.push("-vf", vfStr);
1474
1689
  args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
1475
1690
  args.push(...encoder.output);
@@ -1594,14 +1809,14 @@ var init_gif = __esm({
1594
1809
  // src/lib/ffmpeg/media.ts
1595
1810
  function cacheKey(filePath) {
1596
1811
  try {
1597
- const { size, mtimeMs } = (0, import_node_fs4.statSync)(filePath);
1812
+ const { size, mtimeMs } = (0, import_node_fs5.statSync)(filePath);
1598
1813
  return `${filePath}:${size}:${mtimeMs}`;
1599
1814
  } catch {
1600
1815
  return null;
1601
1816
  }
1602
1817
  }
1603
1818
  function getVideoDimensions(filePath) {
1604
- return new Promise((resolve10, reject) => {
1819
+ return new Promise((resolve11, reject) => {
1605
1820
  const args = [
1606
1821
  "-v",
1607
1822
  "error",
@@ -1632,12 +1847,12 @@ function getVideoDimensions(filePath) {
1632
1847
  reject(new Error(`ffprobe returned no dimensions for ${filePath}`));
1633
1848
  return;
1634
1849
  }
1635
- resolve10({ width: w, height: h });
1850
+ resolve11({ width: w, height: h });
1636
1851
  });
1637
1852
  });
1638
1853
  }
1639
1854
  function hasAudioStream(filePath) {
1640
- return new Promise((resolve10) => {
1855
+ return new Promise((resolve11) => {
1641
1856
  const proc = spawnFFmpeg("ffprobe", [
1642
1857
  "-v",
1643
1858
  "error",
@@ -1653,15 +1868,15 @@ function hasAudioStream(filePath) {
1653
1868
  proc.stdout.on("data", (d) => {
1654
1869
  out += d;
1655
1870
  });
1656
- proc.on("close", () => resolve10(out.trim().length > 0));
1657
- proc.on("error", () => resolve10(false));
1871
+ proc.on("close", () => resolve11(out.trim().length > 0));
1872
+ proc.on("error", () => resolve11(false));
1658
1873
  });
1659
1874
  }
1660
1875
  function getMediaDuration(filePath) {
1661
1876
  const key = cacheKey(filePath);
1662
1877
  const cached = key === null ? void 0 : durationCache.get(key);
1663
1878
  if (cached !== void 0) return Promise.resolve(cached);
1664
- return new Promise((resolve10, reject) => {
1879
+ return new Promise((resolve11, reject) => {
1665
1880
  const args = [
1666
1881
  "-v",
1667
1882
  "error",
@@ -1688,13 +1903,13 @@ function getMediaDuration(filePath) {
1688
1903
  const ms = Math.ceil(parseFloat(stdout.trim()) * 1e3);
1689
1904
  const freshKey = cacheKey(filePath);
1690
1905
  if (freshKey !== null && Number.isFinite(ms)) durationCache.set(freshKey, ms);
1691
- resolve10(ms);
1906
+ resolve11(ms);
1692
1907
  });
1693
1908
  proc.on("error", reject);
1694
1909
  });
1695
1910
  }
1696
1911
  function getAudioCodec(filePath) {
1697
- return new Promise((resolve10) => {
1912
+ return new Promise((resolve11) => {
1698
1913
  const args = [
1699
1914
  "-v",
1700
1915
  "error",
@@ -1711,8 +1926,8 @@ function getAudioCodec(filePath) {
1711
1926
  proc.stdout.on("data", (data) => {
1712
1927
  stdout += data;
1713
1928
  });
1714
- proc.on("close", (code) => resolve10(code === 0 ? stdout.trim() : ""));
1715
- proc.on("error", () => resolve10(""));
1929
+ proc.on("close", (code) => resolve11(code === 0 ? stdout.trim() : ""));
1930
+ proc.on("error", () => resolve11(""));
1716
1931
  });
1717
1932
  }
1718
1933
  async function isHonestWav(filePath) {
@@ -1854,11 +2069,11 @@ function concatVideoWithFullFrameOutro(opts) {
1854
2069
  "ffmpeg full-frame outro concat failed"
1855
2070
  );
1856
2071
  }
1857
- var import_node_fs4, durationCache, TRIM_EDGE_SILENCE;
2072
+ var import_node_fs5, durationCache, TRIM_EDGE_SILENCE;
1858
2073
  var init_media = __esm({
1859
2074
  "src/lib/ffmpeg/media.ts"() {
1860
2075
  "use strict";
1861
- import_node_fs4 = require("fs");
2076
+ import_node_fs5 = require("fs");
1862
2077
  init_spawn();
1863
2078
  durationCache = /* @__PURE__ */ new Map();
1864
2079
  TRIM_EDGE_SILENCE = "silenceremove=start_periods=1:start_silence=0:start_threshold=-45dB:detection=peak,areverse,silenceremove=start_periods=1:start_silence=0:start_threshold=-45dB:detection=peak,areverse";
@@ -1867,7 +2082,7 @@ var init_media = __esm({
1867
2082
 
1868
2083
  // src/lib/ffmpeg/silence.ts
1869
2084
  function getVideoStreamDuration(inputPath) {
1870
- return new Promise((resolve10) => {
2085
+ return new Promise((resolve11) => {
1871
2086
  const proc = spawnFFmpeg("ffprobe", [
1872
2087
  "-v",
1873
2088
  "error",
@@ -1884,12 +2099,12 @@ function getVideoStreamDuration(inputPath) {
1884
2099
  proc.stdout.on("data", (d) => {
1885
2100
  out += d;
1886
2101
  });
1887
- proc.on("error", () => resolve10(0));
1888
- proc.on("close", () => resolve10(Number.parseFloat(out.trim()) || 0));
2102
+ proc.on("error", () => resolve11(0));
2103
+ proc.on("close", () => resolve11(Number.parseFloat(out.trim()) || 0));
1889
2104
  });
1890
2105
  }
1891
2106
  function detectSilences(inputPath, minDurSecs) {
1892
- return new Promise((resolve10) => {
2107
+ return new Promise((resolve11) => {
1893
2108
  const proc = spawnFFmpeg("ffmpeg", [
1894
2109
  "-hide_banner",
1895
2110
  "-nostats",
@@ -1908,7 +2123,7 @@ function detectSilences(inputPath, minDurSecs) {
1908
2123
  proc.stderr.on("data", (d) => {
1909
2124
  err += d;
1910
2125
  });
1911
- proc.on("error", () => resolve10([]));
2126
+ proc.on("error", () => resolve11([]));
1912
2127
  proc.on("close", () => {
1913
2128
  const spans = [];
1914
2129
  let start = null;
@@ -1923,12 +2138,12 @@ function detectSilences(inputPath, minDurSecs) {
1923
2138
  start = null;
1924
2139
  }
1925
2140
  }
1926
- resolve10(spans);
2141
+ resolve11(spans);
1927
2142
  });
1928
2143
  });
1929
2144
  }
1930
2145
  function detectFrozenSpans(inputPath, minDurSecs) {
1931
- return new Promise((resolve10) => {
2146
+ return new Promise((resolve11) => {
1932
2147
  const proc = spawnFFmpeg("ffmpeg", [
1933
2148
  "-hide_banner",
1934
2149
  "-nostats",
@@ -1949,7 +2164,7 @@ function detectFrozenSpans(inputPath, minDurSecs) {
1949
2164
  proc.stderr.on("data", (d) => {
1950
2165
  err += d;
1951
2166
  });
1952
- proc.on("error", () => resolve10([]));
2167
+ proc.on("error", () => resolve11([]));
1953
2168
  proc.on("close", () => {
1954
2169
  const spans = [];
1955
2170
  let start = null;
@@ -1962,12 +2177,12 @@ function detectFrozenSpans(inputPath, minDurSecs) {
1962
2177
  start = null;
1963
2178
  }
1964
2179
  }
1965
- resolve10(spans);
2180
+ resolve11(spans);
1966
2181
  });
1967
2182
  });
1968
2183
  }
1969
2184
  function detectBlackSpans(inputPath, minDurSecs) {
1970
- return new Promise((resolve10) => {
2185
+ return new Promise((resolve11) => {
1971
2186
  const proc = spawnFFmpeg("ffmpeg", [
1972
2187
  "-hide_banner",
1973
2188
  "-nostats",
@@ -1990,19 +2205,19 @@ function detectBlackSpans(inputPath, minDurSecs) {
1990
2205
  proc.stderr.on("data", (d) => {
1991
2206
  err += d;
1992
2207
  });
1993
- proc.on("error", () => resolve10([]));
2208
+ proc.on("error", () => resolve11([]));
1994
2209
  proc.on("close", () => {
1995
2210
  const spans = [];
1996
2211
  for (const line of err.split("\n")) {
1997
2212
  const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
1998
2213
  if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
1999
2214
  }
2000
- resolve10(spans);
2215
+ resolve11(spans);
2001
2216
  });
2002
2217
  });
2003
2218
  }
2004
2219
  function detectWhiteSpans(inputPath, minDurSecs) {
2005
- return new Promise((resolve10) => {
2220
+ return new Promise((resolve11) => {
2006
2221
  const proc = spawnFFmpeg("ffmpeg", [
2007
2222
  "-hide_banner",
2008
2223
  "-nostats",
@@ -2020,14 +2235,14 @@ function detectWhiteSpans(inputPath, minDurSecs) {
2020
2235
  proc.stderr.on("data", (d) => {
2021
2236
  err += d;
2022
2237
  });
2023
- proc.on("error", () => resolve10([]));
2238
+ proc.on("error", () => resolve11([]));
2024
2239
  proc.on("close", () => {
2025
2240
  const spans = [];
2026
2241
  for (const line of err.split("\n")) {
2027
2242
  const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
2028
2243
  if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
2029
2244
  }
2030
- resolve10(spans);
2245
+ resolve11(spans);
2031
2246
  });
2032
2247
  });
2033
2248
  }
@@ -2117,7 +2332,7 @@ async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
2117
2332
  const drop = keep.map((s) => `between(t,${s.start.toFixed(3)},${s.end.toFixed(3)})`).join("+");
2118
2333
  const filter = `[0:v]select='not(${drop})',setpts=N/FRAME_RATE/TB[outv]`;
2119
2334
  const enc = await passEncoder(preset);
2120
- return new Promise((resolve10) => {
2335
+ return new Promise((resolve11) => {
2121
2336
  const proc = spawnFFmpeg("ffmpeg", [
2122
2337
  "-y",
2123
2338
  ...filterThreadArgs(),
@@ -2135,8 +2350,8 @@ async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
2135
2350
  outputPath
2136
2351
  ]);
2137
2352
  proc.stderr.resume();
2138
- proc.on("error", () => resolve10(false));
2139
- proc.on("close", (code) => resolve10(code === 0));
2353
+ proc.on("error", () => resolve11(false));
2354
+ proc.on("close", (code) => resolve11(code === 0));
2140
2355
  });
2141
2356
  }
2142
2357
  function buildRetimeFilter(spans, stepBounds, speed, fps) {
@@ -2218,7 +2433,7 @@ async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, spee
2218
2433
  const filter = buildRetimeFilter(spans, stepBounds, speed, fps);
2219
2434
  if (!filter) return false;
2220
2435
  const enc = await passEncoder(preset);
2221
- return new Promise((resolve10) => {
2436
+ return new Promise((resolve11) => {
2222
2437
  const proc = spawnFFmpeg("ffmpeg", [
2223
2438
  "-y",
2224
2439
  ...filterThreadArgs(),
@@ -2236,8 +2451,8 @@ async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, spee
2236
2451
  outputPath
2237
2452
  ]);
2238
2453
  proc.stderr.resume();
2239
- proc.on("error", () => resolve10(false));
2240
- proc.on("close", (code) => resolve10(code === 0));
2454
+ proc.on("error", () => resolve11(false));
2455
+ proc.on("close", (code) => resolve11(code === 0));
2241
2456
  });
2242
2457
  }
2243
2458
  async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, preset, protectAfterSecs, narrationPath, protectBeforeSecs, leadSpans, keepSpans) {
@@ -2263,7 +2478,7 @@ async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, pr
2263
2478
  const filter = buildCompressionFilter(spans, totalSecs, maxSecs, protectAfterSecs, protectBeforeSecs);
2264
2479
  if (!filter) return false;
2265
2480
  const enc = await passEncoder(preset);
2266
- return new Promise((resolve10) => {
2481
+ return new Promise((resolve11) => {
2267
2482
  const proc = spawnFFmpeg("ffmpeg", [
2268
2483
  "-y",
2269
2484
  ...filterThreadArgs(),
@@ -2289,8 +2504,8 @@ async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, pr
2289
2504
  outputPath
2290
2505
  ]);
2291
2506
  proc.stderr.resume();
2292
- proc.on("error", () => resolve10(false));
2293
- proc.on("close", (code) => resolve10(code === 0));
2507
+ proc.on("error", () => resolve11(false));
2508
+ proc.on("close", (code) => resolve11(code === 0));
2294
2509
  });
2295
2510
  }
2296
2511
  async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, introSecs, outroSecs, maxFactor, preset) {
@@ -2314,7 +2529,7 @@ async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, intro
2314
2529
  "[v0][a0][v1][a1][v2][a2]concat=n=3:v=1:a=1[outv][outa]"
2315
2530
  ].join(";");
2316
2531
  const enc = await passEncoder(preset);
2317
- const ok = await new Promise((resolve10) => {
2532
+ const ok = await new Promise((resolve11) => {
2318
2533
  const proc = spawnFFmpeg("ffmpeg", [
2319
2534
  "-y",
2320
2535
  ...filterThreadArgs(),
@@ -2335,8 +2550,8 @@ async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, intro
2335
2550
  outputPath
2336
2551
  ]);
2337
2552
  proc.stderr.resume();
2338
- proc.on("error", () => resolve10(false));
2339
- proc.on("close", (code) => resolve10(code === 0));
2553
+ proc.on("error", () => resolve11(false));
2554
+ proc.on("close", (code) => resolve11(code === 0));
2340
2555
  });
2341
2556
  return { ok, factor, reachedTarget: needed <= maxFactor };
2342
2557
  }
@@ -2372,7 +2587,7 @@ async function spliceVideos(opts) {
2372
2587
  const audioPresent = await Promise.all(
2373
2588
  overlays.map((o) => o.still ? Promise.resolve(false) : hasAudioStream(o.path))
2374
2589
  );
2375
- return new Promise((resolve10, reject) => {
2590
+ return new Promise((resolve11, reject) => {
2376
2591
  const args = ["-y", "-i", inputPath];
2377
2592
  for (const overlay of overlays) {
2378
2593
  if (overlay.still) {
@@ -2435,7 +2650,7 @@ async function spliceVideos(opts) {
2435
2650
  args.push("-c:v", "libx264", "-preset", preset, "-crf", "18", "-pix_fmt", "yuv420p");
2436
2651
  if (clipAudio.length > 0) args.push("-c:a", "aac");
2437
2652
  args.push(outputPath);
2438
- runFFmpeg(args, "ffmpeg splice failed").then(resolve10, reject);
2653
+ runFFmpeg(args, "ffmpeg splice failed").then(resolve11, reject);
2439
2654
  });
2440
2655
  }
2441
2656
  var CLIP_AUDIO_GAIN, STILL_FRAMERATE, COVER_WASH_W, COVER_WASH_H, COVER_BLUR_SIGMA;
@@ -2471,10 +2686,10 @@ function buildStepTransitions(steps, stepDurations, defaultTransition) {
2471
2686
  return transitions;
2472
2687
  }
2473
2688
  function applyTransitions(opts) {
2474
- return new Promise((resolve10, reject) => {
2689
+ return new Promise((resolve11, reject) => {
2475
2690
  const { inputVideo, outputVideo, transitions, videoDuration, preset = "fast" } = opts;
2476
2691
  if (transitions.length === 0) {
2477
- runFFmpeg(["-y", "-i", inputVideo, "-c", "copy", outputVideo], "FFmpeg copy failed").then(resolve10, reject);
2692
+ runFFmpeg(["-y", "-i", inputVideo, "-c", "copy", outputVideo], "FFmpeg copy failed").then(resolve11, reject);
2478
2693
  return;
2479
2694
  }
2480
2695
  const sorted = [...transitions].sort((a, b) => a.timestamp - b.timestamp);
@@ -2522,7 +2737,7 @@ function applyTransitions(opts) {
2522
2737
  "yuv420p",
2523
2738
  outputVideo
2524
2739
  ];
2525
- runFFmpeg(args, "FFmpeg transitions failed").then(resolve10, reject);
2740
+ runFFmpeg(args, "FFmpeg transitions failed").then(resolve11, reject);
2526
2741
  });
2527
2742
  }
2528
2743
  var init_transitions = __esm({
@@ -2562,114 +2777,6 @@ var init_ffmpeg2 = __esm({
2562
2777
  }
2563
2778
  });
2564
2779
 
2565
- // src/lib/json-repair.ts
2566
- function repairUnicodeEscapes(content) {
2567
- return content.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (match, hex) => {
2568
- const code = Number.parseInt(hex, 16);
2569
- return code <= 1114111 ? String.fromCodePoint(code) : match;
2570
- }).replace(/\\u(?![0-9a-fA-F]{4})/g, "\\\\u");
2571
- }
2572
- function salvageTruncatedJson(content) {
2573
- const stack = [];
2574
- const cuts = [];
2575
- let inString = false;
2576
- let escaped = false;
2577
- const record = (index, closesContainer) => cuts.push({ index, open: stack.join(""), closesContainer });
2578
- for (let i = 0; i < content.length; i++) {
2579
- const ch = content[i];
2580
- if (inString) {
2581
- if (escaped) escaped = false;
2582
- else if (ch === "\\") escaped = true;
2583
- else if (ch === '"') {
2584
- inString = false;
2585
- record(i + 1, false);
2586
- }
2587
- continue;
2588
- }
2589
- if (ch === '"') {
2590
- inString = true;
2591
- } else if (ch === "{" || ch === "[") {
2592
- stack.push(ch);
2593
- } else if (ch === "}" || ch === "]") {
2594
- stack.pop();
2595
- record(i + 1, true);
2596
- } else if (ch === ",") {
2597
- record(i, content[i - 1] === "}" || content[i - 1] === "]");
2598
- }
2599
- }
2600
- const tryCut = (cut) => {
2601
- const head = content.slice(0, cut.index).replace(/[\s,]+$/, "");
2602
- if (head.endsWith(":")) return null;
2603
- let closed = head;
2604
- for (let d = cut.open.length - 1; d >= 0; d--) {
2605
- closed += CLOSERS[cut.open[d]];
2606
- }
2607
- try {
2608
- JSON.parse(closed);
2609
- return closed;
2610
- } catch {
2611
- return null;
2612
- }
2613
- };
2614
- const attempts = Math.min(cuts.length, MAX_SALVAGE_ATTEMPTS);
2615
- for (const containerOnly of [true, false]) {
2616
- for (let n = 0; n < attempts; n++) {
2617
- const cut = cuts[cuts.length - 1 - n];
2618
- if (containerOnly && !cut.closesContainer) continue;
2619
- const salvaged = tryCut(cut);
2620
- if (salvaged !== null) return salvaged;
2621
- }
2622
- }
2623
- return null;
2624
- }
2625
- function extractJsonCandidates(content) {
2626
- let text = content.trim();
2627
- const fence = text.match(/```(?:json)?\s*([\s\S]*?)(?:```|$)/);
2628
- if (fence?.[1]?.trim()) text = fence[1].trim();
2629
- const start = text.search(/[{[]/);
2630
- if (start === -1) return [];
2631
- const closer = text[start] === "{" ? "}" : "]";
2632
- const end = text.lastIndexOf(closer);
2633
- const candidates = [text.slice(start)];
2634
- if (end > start && end < text.length - 1) candidates.unshift(text.slice(start, end + 1));
2635
- return candidates;
2636
- }
2637
- function parseAIJson(content) {
2638
- const candidates = extractJsonCandidates(content);
2639
- if (candidates.length === 0) return null;
2640
- for (const text of candidates) {
2641
- try {
2642
- return { value: JSON.parse(text), repaired: false };
2643
- } catch {
2644
- }
2645
- }
2646
- for (const text of candidates) {
2647
- try {
2648
- return { value: JSON.parse(repairUnicodeEscapes(text)), repaired: true };
2649
- } catch {
2650
- }
2651
- }
2652
- for (const text of candidates) {
2653
- for (const variant of [repairUnicodeEscapes(text), text]) {
2654
- const salvaged = salvageTruncatedJson(variant);
2655
- if (salvaged === null) continue;
2656
- try {
2657
- return { value: JSON.parse(salvaged), repaired: true };
2658
- } catch {
2659
- }
2660
- }
2661
- }
2662
- return null;
2663
- }
2664
- var MAX_SALVAGE_ATTEMPTS, CLOSERS;
2665
- var init_json_repair = __esm({
2666
- "src/lib/json-repair.ts"() {
2667
- "use strict";
2668
- MAX_SALVAGE_ATTEMPTS = 500;
2669
- CLOSERS = { "{": "}", "[": "]" };
2670
- }
2671
- });
2672
-
2673
2780
  // src/lib/prompts.ts
2674
2781
  function buildSystemPrompt(maxSteps, profile) {
2675
2782
  let scope;
@@ -2985,6 +3092,9 @@ function renderHormoziAss(ctx) {
2985
3092
  return `${header2}${lines.join("\n")}
2986
3093
  `;
2987
3094
  }
3095
+ function looksLikeAddress(token) {
3096
+ return /^(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?[.,!?]?$/i.test(token);
3097
+ }
2988
3098
  function renderShortsAss(ctx) {
2989
3099
  const { captions, width, height } = ctx;
2990
3100
  const font = resolveCaptionFont(captions.font === "Arial" ? "Arial Black" : captions.font);
@@ -2995,7 +3105,7 @@ function renderShortsAss(ctx) {
2995
3105
  const alignment = positionToAlignment(captions.position);
2996
3106
  const outline = Math.max(2, Math.round(fontSize / 22));
2997
3107
  const upper = true;
2998
- const caseOf = (t) => (0, import_video_kit4.toCaptionCase)(t, upper);
3108
+ const caseOf = (t) => looksLikeAddress(t) ? t : (0, import_video_kit4.toCaptionCase)(t, upper);
2999
3109
  const maxChars = (0, import_video_kit4.fittingMaxChars)(width, fontSize, SIDE_MARGIN, captions.maxChars, upper);
3000
3110
  const header2 = `[Script Info]
3001
3111
  Title: QuickPeek Demo
@@ -3226,7 +3336,7 @@ async function getMediaStepDuration(step, baseDir) {
3226
3336
  return 0;
3227
3337
  }
3228
3338
  const mediaPath = (0, import_node_path4.isAbsolute)(step.value) ? step.value : (0, import_node_path4.resolve)(baseDir, step.value);
3229
- if (!(0, import_node_fs5.existsSync)(mediaPath)) {
3339
+ if (!(0, import_node_fs6.existsSync)(mediaPath)) {
3230
3340
  return 0;
3231
3341
  }
3232
3342
  try {
@@ -3248,7 +3358,7 @@ async function getMediaStepDuration(step, baseDir) {
3248
3358
  async function loadVoiceoverMeta(outputDir) {
3249
3359
  const metaPath = (0, import_node_path4.join)(outputDir, "voiceover.json");
3250
3360
  try {
3251
- if (!(0, import_node_fs5.existsSync)(metaPath)) return null;
3361
+ if (!(0, import_node_fs6.existsSync)(metaPath)) return null;
3252
3362
  const content = await (0, import_promises4.readFile)(metaPath, "utf-8");
3253
3363
  return JSON.parse(content);
3254
3364
  } catch {
@@ -3271,11 +3381,11 @@ async function concatenateAudio(inputs, output, workDir) {
3271
3381
  await (0, import_promises4.unlink)(listPath).catch(() => {
3272
3382
  });
3273
3383
  }
3274
- var import_node_fs5, import_promises4, import_node_path4, isWindows, NARRATION_LEAD_IN_MS, LEAD_IN_CLIP;
3384
+ var import_node_fs6, import_promises4, import_node_path4, isWindows, NARRATION_LEAD_IN_MS, LEAD_IN_CLIP;
3275
3385
  var init_helpers = __esm({
3276
3386
  "src/lib/tts/helpers.ts"() {
3277
3387
  "use strict";
3278
- import_node_fs5 = require("fs");
3388
+ import_node_fs6 = require("fs");
3279
3389
  import_promises4 = require("fs/promises");
3280
3390
  import_node_path4 = require("path");
3281
3391
  init_ffmpeg2();
@@ -3351,7 +3461,7 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
3351
3461
  const segments = [];
3352
3462
  const alignedStepAudio = /* @__PURE__ */ new Map();
3353
3463
  const leadInPath = (0, import_node_path5.join)(audioDir, LEAD_IN_CLIP);
3354
- const leadIn = (0, import_node_fs6.existsSync)(leadInPath) ? NARRATION_LEAD_IN_MS : 0;
3464
+ const leadIn = (0, import_node_fs7.existsSync)(leadInPath) ? NARRATION_LEAD_IN_MS : 0;
3355
3465
  if (leadIn) segments.push(leadInPath);
3356
3466
  for (let i = 0; i < actualRecordingDurations.length; i++) {
3357
3467
  const stepNum = i + 1;
@@ -3362,9 +3472,9 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
3362
3472
  const audioPath = (0, import_node_path5.join)(audioDir, `step-${stepNum}.mp3`);
3363
3473
  const originalPaddingPath = (0, import_node_path5.join)(audioDir, `step-${stepNum}-padding.mp3`);
3364
3474
  const silencePath = (0, import_node_path5.join)(audioDir, `step-${stepNum}-silence.mp3`);
3365
- if ((0, import_node_fs6.existsSync)(audioPath)) {
3475
+ if ((0, import_node_fs7.existsSync)(audioPath)) {
3366
3476
  segments.push(audioPath);
3367
- if ((0, import_node_fs6.existsSync)(originalPaddingPath)) {
3477
+ if ((0, import_node_fs7.existsSync)(originalPaddingPath)) {
3368
3478
  segments.push(originalPaddingPath);
3369
3479
  }
3370
3480
  if (recordingDuration > voiceDuration) {
@@ -3372,7 +3482,7 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
3372
3482
  await generateSilence(alignPaddingPath, recordingDuration - voiceDuration);
3373
3483
  segments.push(alignPaddingPath);
3374
3484
  }
3375
- } else if ((0, import_node_fs6.existsSync)(silencePath)) {
3485
+ } else if ((0, import_node_fs7.existsSync)(silencePath)) {
3376
3486
  const own = targetDuration - (stepNum === 1 ? leadIn : 0);
3377
3487
  if (targetDuration !== voiceDuration && own > 0) {
3378
3488
  await generateSilence(silencePath, own);
@@ -3401,11 +3511,11 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
3401
3511
  }
3402
3512
  return alignedStepAudio;
3403
3513
  }
3404
- var import_node_fs6, import_node_path5;
3514
+ var import_node_fs7, import_node_path5;
3405
3515
  var init_timing = __esm({
3406
3516
  "src/lib/tts/timing.ts"() {
3407
3517
  "use strict";
3408
- import_node_fs6 = require("fs");
3518
+ import_node_fs7 = require("fs");
3409
3519
  import_node_path5 = require("path");
3410
3520
  init_ffmpeg2();
3411
3521
  init_helpers();
@@ -3554,6 +3664,8 @@ asyncio.run(main())
3554
3664
  await (0, import_promises5.writeFile)(tempScriptPath, pythonScript);
3555
3665
  const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
3556
3666
  await execAsync(pythonCmd, { timeout: 3e4 });
3667
+ const written = await (0, import_promises5.stat)(outputPath).catch(() => null);
3668
+ if (!written?.size) throw new Error("edge-tts produced no audio");
3557
3669
  return await readWordsSidecar(outputPath);
3558
3670
  } finally {
3559
3671
  await (0, import_promises5.unlink)(tempScriptPath).catch(() => {
@@ -3581,7 +3693,7 @@ async function synthesizeWithNode(text, outputPath, voice, pythonMsg, rate, maxR
3581
3693
  const { audioStream, metadataStream } = tts.toStream(text, rate ? { rate } : void 0);
3582
3694
  const metadata = [];
3583
3695
  metadataStream?.on("data", (chunk) => metadata.push(chunk.toString()));
3584
- await (0, import_promises6.pipeline)(audioStream, (0, import_node_fs7.createWriteStream)(outputPath));
3696
+ await (0, import_promises6.pipeline)(audioStream, (0, import_node_fs8.createWriteStream)(outputPath));
3585
3697
  await new Promise((r) => setTimeout(r, 250));
3586
3698
  const { size } = await (0, import_promises5.stat)(outputPath);
3587
3699
  if (size === 0) throw new Error("No audio data received");
@@ -3653,12 +3765,12 @@ function splitJsonObjects(raw) {
3653
3765
  }
3654
3766
  return out;
3655
3767
  }
3656
- var import_node_child_process4, import_node_fs7, import_promises5, import_node_path7, import_promises6, import_node_util, execAsync, PYTHON_ATTEMPTS, TICKS_PER_MS, wordsSidecar, pythonEdgeTts, lockedEngine;
3768
+ var import_node_child_process4, import_node_fs8, import_promises5, import_node_path7, import_promises6, import_node_util, execAsync, PYTHON_ATTEMPTS, TICKS_PER_MS, wordsSidecar, pythonEdgeTts, lockedEngine;
3657
3769
  var init_synthesize = __esm({
3658
3770
  "src/lib/tts/synthesize.ts"() {
3659
3771
  "use strict";
3660
3772
  import_node_child_process4 = require("child_process");
3661
- import_node_fs7 = require("fs");
3773
+ import_node_fs8 = require("fs");
3662
3774
  import_promises5 = require("fs/promises");
3663
3775
  import_node_path7 = require("path");
3664
3776
  import_promises6 = require("stream/promises");
@@ -3680,7 +3792,7 @@ function clipKey(spokenText, voice, rate) {
3680
3792
  async function reuseClip(cacheDir, key, audioPath) {
3681
3793
  const mp3 = (0, import_node_path8.join)(cacheDir, `${key}.mp3`);
3682
3794
  const meta = (0, import_node_path8.join)(cacheDir, `${key}.json`);
3683
- if (!(0, import_node_fs8.existsSync)(mp3) || !(0, import_node_fs8.existsSync)(meta)) return null;
3795
+ if (!(0, import_node_fs9.existsSync)(mp3) || !(0, import_node_fs9.existsSync)(meta)) return null;
3684
3796
  try {
3685
3797
  const boundaries = JSON.parse(await (0, import_promises7.readFile)(meta, "utf-8"));
3686
3798
  await (0, import_promises7.copyFile)(mp3, audioPath);
@@ -3742,11 +3854,17 @@ function spellAcronyms(text) {
3742
3854
  );
3743
3855
  }
3744
3856
  function bindDomains(text) {
3745
- return text.replace(/\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi, "$1-dot-$2");
3857
+ 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(
3858
+ /\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi,
3859
+ (_m, host, tld) => `${host}-dot-${SPOKEN_TLD[tld.toLowerCase()] ?? tld}`
3860
+ );
3746
3861
  }
3747
3862
  function speakSymbols(text) {
3748
3863
  return text.replace(/\s*&\s*/g, " and ").replace(/[<>]/g, " ").replace(/\s{2,}/g, " ").trim();
3749
3864
  }
3865
+ function spokenForm(caption) {
3866
+ return speakNumbers(spellAcronyms((0, import_video_kit7.toSpokenForm)(bindDomains(speakSymbols(caption)))));
3867
+ }
3750
3868
  async function generateVoiceover(steps, config, workDir, outputDir, logError2, task2, info2) {
3751
3869
  resetVoiceEngine();
3752
3870
  const voice = resolveVoice2(config);
@@ -3793,7 +3911,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
3793
3911
  }
3794
3912
  const audioPath = (0, import_node_path8.join)(audioDir, `step-${stepNum}.mp3`);
3795
3913
  try {
3796
- const spokenText = speakNumbers(spellAcronyms((0, import_video_kit7.toSpokenForm)(bindDomains(speakSymbols(step.caption)))));
3914
+ const spokenText = spokenForm(step.caption);
3797
3915
  const key = clipKey(spokenText, voice, ratePercent ?? "");
3798
3916
  const cacheDir = (0, import_node_path8.join)(outputDir, ".tts-clips");
3799
3917
  const cached = await reuseClip(cacheDir, key, audioPath);
@@ -3874,12 +3992,12 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
3874
3992
  await saveVoiceoverMeta(outputDir, meta);
3875
3993
  return { voiceoverPath, stepAudio };
3876
3994
  }
3877
- var import_node_crypto, import_node_fs8, import_promises7, import_node_path8, import_video_kit7, TTS_POOL, ONES, TENS, SPELLED_OUT, LETTER_SOUNDS;
3995
+ var import_node_crypto, import_node_fs9, import_promises7, import_node_path8, import_video_kit7, TTS_POOL, ONES, TENS, SPELLED_OUT, LETTER_SOUNDS, SPOKEN_TLD;
3878
3996
  var init_generate = __esm({
3879
3997
  "src/lib/tts/generate.ts"() {
3880
3998
  "use strict";
3881
3999
  import_node_crypto = require("crypto");
3882
- import_node_fs8 = require("fs");
4000
+ import_node_fs9 = require("fs");
3883
4001
  import_promises7 = require("fs/promises");
3884
4002
  import_node_path8 = require("path");
3885
4003
  import_video_kit7 = require("@spark-apps/video-kit");
@@ -3944,6 +4062,9 @@ var init_generate = __esm({
3944
4062
  Y: "why",
3945
4063
  Z: "zed"
3946
4064
  };
4065
+ SPOKEN_TLD = {
4066
+ ai: "ay-eye"
4067
+ };
3947
4068
  }
3948
4069
  });
3949
4070
 
@@ -4003,6 +4124,7 @@ __export(lib_exports, {
4003
4124
  NO_CLIP_OUTRO: () => NO_CLIP_OUTRO,
4004
4125
  PLAN_MAX_TOKENS: () => PLAN_MAX_TOKENS,
4005
4126
  RATE_FACTOR: () => RATE_FACTOR,
4127
+ RELAY_BASE: () => RELAY_BASE,
4006
4128
  SERVER_BACKOFF_MS: () => SERVER_BACKOFF_MS,
4007
4129
  SIZE_PRESETS: () => SIZE_PRESETS,
4008
4130
  TRIM_EDGE_SILENCE: () => TRIM_EDGE_SILENCE,
@@ -4027,6 +4149,7 @@ __export(lib_exports, {
4027
4149
  buildUserPrompt: () => buildUserPrompt,
4028
4150
  buildWatermarkFilters: () => buildWatermarkFilters,
4029
4151
  callAI: () => callAI,
4152
+ callAIForPlan: () => callAIForPlan,
4030
4153
  callAIViaRelay: () => callAIViaRelay,
4031
4154
  canReuseVoiceover: () => canReuseVoiceover,
4032
4155
  captionCanvas: () => captionCanvas,
@@ -4054,6 +4177,7 @@ __export(lib_exports, {
4054
4177
  getErrorMessage: () => getErrorMessage,
4055
4178
  getLanguageName: () => getLanguageName,
4056
4179
  getMediaDuration: () => getMediaDuration,
4180
+ getOrRefreshRelayToken: () => getOrRefreshRelayToken,
4057
4181
  getStatus: () => getStatus,
4058
4182
  getStatusCached: () => getStatusCached,
4059
4183
  getTierByEmail: () => getTierByEmail,
@@ -4097,12 +4221,12 @@ __export(lib_exports, {
4097
4221
  runPooled: () => runPooled,
4098
4222
  salvageTruncatedJson: () => salvageTruncatedJson,
4099
4223
  shouldSkipLink: () => shouldSkipLink,
4100
- shouldWatermark: () => shouldWatermark,
4101
4224
  showPaywall: () => showPaywall,
4102
4225
  speakSymbols: () => speakSymbols,
4103
4226
  speedUpMiddle: () => speedUpMiddle,
4104
4227
  speedUpVideo: () => speedUpVideo,
4105
4228
  spliceVideos: () => spliceVideos,
4229
+ spokenForm: () => spokenForm,
4106
4230
  spokenWordTimings: () => spokenWordTimings,
4107
4231
  stripLongDashes: () => stripLongDashes,
4108
4232
  synthesizeSpeech: () => synthesizeSpeech,
@@ -4241,54 +4365,16 @@ var init_ambience = __esm({
4241
4365
  }
4242
4366
  });
4243
4367
 
4244
- // src/lib/image-kind.ts
4245
- function mimeForKind(kind) {
4246
- return MIME[kind];
4247
- }
4248
- function sniffImageKind(buf) {
4249
- if (buf.length < 12) return null;
4250
- if (buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "png";
4251
- if (buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "jpeg";
4252
- if (buf.subarray(0, 3).toString("latin1") === "GIF") return "gif";
4253
- if (buf.subarray(0, 4).toString("latin1") === "RIFF" && buf.subarray(8, 12).toString("latin1") === "WEBP") {
4254
- return "webp";
4255
- }
4256
- const head = buf.subarray(0, 1024).toString("utf8").replace(/^/, "").trimStart();
4257
- if (/^<(\?xml|!--|!doctype svg|svg)[\s>]/i.test(head) && /<svg[\s>]/i.test(head)) return "svg";
4258
- return null;
4259
- }
4260
- var MIME, MIN_ICON_PX;
4261
- var init_image_kind = __esm({
4262
- "src/lib/image-kind.ts"() {
4263
- "use strict";
4264
- MIME = {
4265
- png: "image/png",
4266
- jpeg: "image/jpeg",
4267
- gif: "image/gif",
4268
- webp: "image/webp",
4269
- svg: "image/svg+xml"
4270
- };
4271
- MIN_ICON_PX = 48;
4272
- }
4273
- });
4274
-
4275
4368
  // src/lib/outro-card.ts
4276
- function dataUri(path) {
4277
- try {
4278
- const buf = (0, import_node_fs17.readFileSync)(path);
4279
- const kind = sniffImageKind(buf);
4280
- if (!kind) return null;
4281
- return `data:${mimeForKind(kind)};base64,${buf.toString("base64")}`;
4282
- } catch {
4283
- return null;
4284
- }
4285
- }
4286
4369
  function escapeHtml(text) {
4287
4370
  return text.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
4288
4371
  }
4289
4372
  function buildOutroCardHtml(options) {
4290
4373
  const { subject, credits = [], promo, width, height } = options;
4291
4374
  const unit = Math.min(width, height);
4375
+ const markFraction = width > height ? 0.28 : 0.46;
4376
+ const markSize = unit * markFraction;
4377
+ const padTop = width > height ? unit * 0.08 : unit * 0.23;
4292
4378
  const creditRows = credits.map((credit) => {
4293
4379
  const icon = credit.iconPath ? dataUri(credit.iconPath) : null;
4294
4380
  return `<div class="credit">
@@ -4297,7 +4383,8 @@ function buildOutroCardHtml(options) {
4297
4383
  ${icon ? `<img class="cicon" src="${icon}" alt="" />` : ""}
4298
4384
  </div>`;
4299
4385
  }).join("");
4300
- const subjectIcon = subject.iconPath ? dataUri(subject.iconPath) : null;
4386
+ const animated = subject.animatedMark;
4387
+ const subjectIcon = animated ? null : subject.iconPath ? dataUri(subject.iconPath) : null;
4301
4388
  return `<!doctype html>
4302
4389
  <html><head><meta charset="utf-8" /><style>
4303
4390
  * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -4315,7 +4402,7 @@ function buildOutroCardHtml(options) {
4315
4402
  position: relative;
4316
4403
  display: flex; flex-direction: column;
4317
4404
  align-items: center; justify-content: flex-start;
4318
- padding-top: ${unit * 0.23}px;
4405
+ padding-top: ${padTop}px;
4319
4406
  gap: ${unit * 0.028}px;
4320
4407
  }
4321
4408
  /* Toolchain credits: top right, deliberately small. */
@@ -4333,7 +4420,7 @@ function buildOutroCardHtml(options) {
4333
4420
  /* The app the video is about. */
4334
4421
  /* The app's mark leads the card, and it is meant to be seen: at 46% of
4335
4422
  the short edge it reads at a glance on a phone. */
4336
- .mark { width: ${unit * 0.46}px; height: ${unit * 0.46}px; object-fit: contain; border-radius: ${unit * 0.09}px; }
4423
+ .mark { width: ${markSize}px; height: ${markSize}px; object-fit: contain; border-radius: ${unit * 0.09}px; }
4337
4424
  /* A dark mark on a dark card is invisible, which looks exactly like a
4338
4425
  failed image. Sites that publish a black-on-transparent logo are common
4339
4426
  enough to be worth catching, and a light plate is what their own site
@@ -4355,8 +4442,12 @@ function buildOutroCardHtml(options) {
4355
4442
  }
4356
4443
 
4357
4444
  /* Today's offer, along the very bottom edge. */
4445
+ /* In the flow, not pinned to the bottom edge. Pinned, it was laid out
4446
+ without reference to the text above it, so on a short canvas it simply
4447
+ covered whatever had grown into its space. margin-top pushes it clear of
4448
+ the domain instead. */
4358
4449
  .promo {
4359
- position: absolute; bottom: ${unit * 0.2}px;
4450
+ margin-top: ${unit * 0.05}px;
4360
4451
  display: flex; align-items: baseline; gap: ${unit * 0.022}px;
4361
4452
  padding: ${unit * 0.026}px ${unit * 0.055}px;
4362
4453
  border: 2px solid rgba(255, 209, 64, .85); border-radius: ${unit * 0.024}px;
@@ -4364,9 +4455,60 @@ function buildOutroCardHtml(options) {
4364
4455
  }
4365
4456
  .ppct { font-size: ${unit * 0.056}px; font-weight: 700; color: #ffd140; }
4366
4457
  .pcode { font-size: ${unit * 0.048}px; font-weight: 700; letter-spacing: .05em; }
4458
+
4459
+ /* The animated mark, sized to the same 46% slot the still one occupies so
4460
+ the rest of the card's rhythm is unchanged. Ported from VidLet's
4461
+ src/gui/css/loader.css: the amber tip flies off and snaps back into the
4462
+ cut - a jump cut, on a loop. Pure CSS, because the renderer steps these
4463
+ animations frame by frame and a JS-driven one could not be stepped.
4464
+
4465
+ No prefers-reduced-motion fallback here, unlike the original: this runs
4466
+ in a headless Chromium rendering a video, where there is no viewer whose
4467
+ preference could be read, and honouring the default would silently flatten
4468
+ the animation to a fade in CI. */
4469
+ .amark { width: ${markSize}px; height: ${markSize}px; display: block; }
4470
+ .amark path {
4471
+ /* Without fill-box the origin is the whole SVG viewport, which swings the
4472
+ tip off its own centre on the scale steps. */
4473
+ transform-box: fill-box;
4474
+ transform-origin: center;
4475
+ }
4476
+ .amark .vl-body { fill: #fff6e5; stroke: #fff6e5; animation: vl-nudge 1.4s cubic-bezier(.4,0,.2,1) infinite; }
4477
+ .amark .vl-tip { fill: #ffc53d; stroke: #ffc53d; animation: vl-cut 1.4s cubic-bezier(.4,0,.2,1) infinite; }
4478
+ .amark .vl-ghost { opacity: .35; }
4479
+ .amark .vl-ghost .vl-tip { animation-delay: -.1s; }
4480
+
4481
+ /* Translations are in viewBox units, so the motion scales with the mark. */
4482
+ @keyframes vl-cut {
4483
+ 0%, 30% { transform: translateX(0) scale(1); opacity: 1; }
4484
+ 48% { transform: translateX(62px) scale(.94); opacity: 1; }
4485
+ 60% { transform: translateX(88px) scale(.86); opacity: 0; }
4486
+ 61%, 74% { transform: translateX(-58px) scale(.86); opacity: 0; }
4487
+ 86% { transform: translateX(6px) scale(1.06); opacity: 1; }
4488
+ 100% { transform: translateX(0) scale(1); opacity: 1; }
4489
+ }
4490
+ /* The body takes the recoil, so the cut lands rather than just sliding. */
4491
+ @keyframes vl-nudge {
4492
+ 0%, 30% { transform: scaleX(1); }
4493
+ 55% { transform: scaleX(1.04); }
4494
+ 86% { transform: scaleX(.97); }
4495
+ 100% { transform: scaleX(1); }
4496
+ }
4497
+ ${/* The card normally reads its palette off the mark's pixels, and an SVG
4498
+ drawn by this stylesheet has none to read - the measuring script finds
4499
+ no <img class="mark"> and leaves the defaults, which would put VidLet's
4500
+ amber on a teal card. Stated instead of measured, last so it wins. */
4501
+ animated === "vidlet" ? ":root { --bg-in: #1f1a10; --bg-out: #07060a; --accent: #ffc53d; }" : ""}
4367
4502
  </style></head>
4368
4503
  <body>
4369
4504
  ${credits.length ? `<div class="credits">${creditRows}</div>` : ""}
4505
+ ${animated === "vidlet" ? `<svg class="amark" viewBox="0 0 512 512" aria-hidden="true">
4506
+ <g stroke-linejoin="round" stroke-width="20">
4507
+ <g class="vl-ghost"><path class="vl-tip" d="M318 246 L410 302 L318 358 Z"/></g>
4508
+ <path class="vl-body" d="M176 124 L300 200 L300 312 L176 388 Z"/>
4509
+ <path class="vl-tip" d="M318 246 L410 302 L318 358 Z"/>
4510
+ </g>
4511
+ </svg>` : ""}
4370
4512
  ${subjectIcon ? `<img class="mark" src="${subjectIcon}" alt="" />` : ""}
4371
4513
  <div class="name">${escapeHtml(subject.name)}</div>
4372
4514
  ${subject.tagline ? `<div class="tagline">${escapeHtml(subject.tagline)}</div>` : ""}
@@ -4469,12 +4611,18 @@ function buildOutroCardHtml(options) {
4469
4611
  </script>
4470
4612
  </body></html>`;
4471
4613
  }
4472
- var import_node_fs17;
4614
+ var ANIMATED_MARK_LOOP_SECONDS, dataUri;
4473
4615
  var init_outro_card = __esm({
4474
4616
  "src/lib/outro-card.ts"() {
4475
4617
  "use strict";
4476
- import_node_fs17 = require("fs");
4477
4618
  init_image_kind();
4619
+ ANIMATED_MARK_LOOP_SECONDS = {
4620
+ // Matches `vl-cut` / `vl-nudge` in VidLet's src/gui/css/loader.css. Changing
4621
+ // it there without changing it here makes the encoded loop stutter at the
4622
+ // seam, because the clip is cut to exactly this length and then repeated.
4623
+ vidlet: 1.4
4624
+ };
4625
+ dataUri = imageDataUri;
4478
4626
  }
4479
4627
  });
4480
4628
 
@@ -4505,6 +4653,9 @@ async function renderOutroClip(options) {
4505
4653
  const htmlPath = (0, import_node_path22.join)(workDir, "outro-card.html");
4506
4654
  const pngPath = (0, import_node_path22.join)(workDir, "outro-card.png");
4507
4655
  const clipPath = (0, import_node_path22.join)(workDir, "outro-card.mp4");
4656
+ if (options.subject.animatedMark) {
4657
+ return renderAnimatedOutroClip(options, { htmlPath, clipPath, seconds, fps });
4658
+ }
4508
4659
  await (0, import_promises16.writeFile)(htmlPath, buildOutroCardHtml(options));
4509
4660
  const browser = await import_playwright7.chromium.launch({ headless: true });
4510
4661
  try {
@@ -4547,6 +4698,70 @@ async function renderOutroClip(options) {
4547
4698
  );
4548
4699
  return clipPath;
4549
4700
  }
4701
+ async function renderAnimatedOutroClip(options, paths) {
4702
+ const { htmlPath, clipPath, seconds, fps } = paths;
4703
+ const { workDir, width, height } = options;
4704
+ const loopSeconds = ANIMATED_MARK_LOOP_SECONDS[options.subject.animatedMark];
4705
+ const frameCount = Math.max(1, Math.round(loopSeconds * fps));
4706
+ const framesDir = (0, import_node_path22.join)(workDir, "outro-frames");
4707
+ await (0, import_promises16.rm)(framesDir, { recursive: true, force: true });
4708
+ await (0, import_promises16.mkdir)(framesDir, { recursive: true });
4709
+ await (0, import_promises16.writeFile)(htmlPath, buildOutroCardHtml(options));
4710
+ const browser = await import_playwright7.chromium.launch({ headless: true });
4711
+ try {
4712
+ const page = await browser.newPage({ viewport: { width, height } });
4713
+ await page.goto((0, import_node_url2.pathToFileURL)((0, import_node_path22.resolve)(htmlPath)).href, { waitUntil: "load" });
4714
+ await page.waitForSelector('body[data-cards-ready="1"]', { timeout: 3e3 }).catch(() => {
4715
+ });
4716
+ await page.waitForTimeout(300);
4717
+ await page.evaluate(() => {
4718
+ for (const animation of document.getAnimations()) animation.pause();
4719
+ });
4720
+ for (let frame = 0; frame < frameCount; frame++) {
4721
+ const elapsedMs = frame / frameCount * loopSeconds * 1e3;
4722
+ await page.evaluate((ms) => {
4723
+ for (const animation of document.getAnimations()) animation.currentTime = ms;
4724
+ }, elapsedMs);
4725
+ await page.screenshot({ path: (0, import_node_path22.join)(framesDir, `f-${String(frame).padStart(4, "0")}.png`) });
4726
+ }
4727
+ } finally {
4728
+ await browser.close();
4729
+ }
4730
+ await runFFmpeg(
4731
+ [
4732
+ "-y",
4733
+ // -stream_loop applies to the input that FOLLOWS it, so it has to sit
4734
+ // before -framerate/-i, not after.
4735
+ "-stream_loop",
4736
+ "-1",
4737
+ "-framerate",
4738
+ String(fps),
4739
+ "-i",
4740
+ (0, import_node_path22.join)(framesDir, "f-%04d.png"),
4741
+ "-f",
4742
+ "lavfi",
4743
+ "-i",
4744
+ "anullsrc=channel_layout=stereo:sample_rate=44100",
4745
+ "-t",
4746
+ String(seconds),
4747
+ "-r",
4748
+ String(fps),
4749
+ "-pix_fmt",
4750
+ "yuv420p",
4751
+ "-c:v",
4752
+ "libx264",
4753
+ "-preset",
4754
+ "veryfast",
4755
+ "-c:a",
4756
+ "aac",
4757
+ "-shortest",
4758
+ clipPath
4759
+ ],
4760
+ "ffmpeg animated outro card render failed"
4761
+ );
4762
+ await (0, import_promises16.rm)(framesDir, { recursive: true, force: true });
4763
+ return clipPath;
4764
+ }
4550
4765
  var import_promises16, import_node_path22, import_node_url2, import_playwright7;
4551
4766
  var init_render_outro = __esm({
4552
4767
  "src/browser/render-outro.ts"() {
@@ -4857,14 +5072,108 @@ async function listAuthStates() {
4857
5072
 
4858
5073
  // src/browser/launch.ts
4859
5074
  var import_promises2 = require("fs/promises");
4860
- var import_playwright2 = require("playwright");
5075
+ var import_playwright3 = require("playwright");
4861
5076
  init_lifecycle();
4862
5077
 
5078
+ // src/browser/brand-watermark.ts
5079
+ init_image_kind();
5080
+ function watermarkInitScript(payload) {
5081
+ return `(() => {
5082
+ var ID = '__quickpeek_brand_watermark';
5083
+ var P = ${embedJson(payload)};
5084
+ var vertical = P.position.indexOf('top') === 0 ? 'top:22px' : 'bottom:22px';
5085
+ var horizontal = P.position.indexOf('right') > -1 ? 'right:24px' : 'left:24px';
5086
+ var SHELL = [
5087
+ 'position:fixed',
5088
+ vertical,
5089
+ horizontal,
5090
+ 'z-index:2147483646',
5091
+ 'display:flex',
5092
+ 'align-items:center',
5093
+ 'gap:10px',
5094
+ 'padding:8px 16px 8px 10px',
5095
+ 'border-radius:999px',
5096
+ // A hairline and a blur rather than a solid plate: the mark sits on top of
5097
+ // whatever the app is showing, and a hard box reads as part of the UI.
5098
+ // The alpha is high enough to stay dark over a LIGHT app: at 0.55 the blur
5099
+ // averaged a pale page up into grey and the white label sat on it at about
5100
+ // 2:1, which photographs as a smudge.
5101
+ 'background:rgba(12,12,18,0.78)',
5102
+ 'border:1px solid rgba(255,255,255,0.14)',
5103
+ 'backdrop-filter:blur(14px) saturate(1.3)',
5104
+ '-webkit-backdrop-filter:blur(14px) saturate(1.3)',
5105
+ 'box-shadow:0 6px 24px rgba(0,0,0,0.30)',
5106
+ 'opacity:' + P.opacity,
5107
+ 'pointer-events:none',
5108
+ 'user-select:none'
5109
+ ].join(';');
5110
+ var ICON = 'width:26px;height:26px;border-radius:7px;object-fit:contain;display:block';
5111
+ var NAME = [
5112
+ 'font:600 15px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
5113
+ 'letter-spacing:0.015em',
5114
+ 'color:#ffffff',
5115
+ 'white-space:nowrap'
5116
+ ].join(';');
5117
+
5118
+ function mount() {
5119
+ try {
5120
+ if (!document.body || document.getElementById(ID)) return;
5121
+ var el = document.createElement('div');
5122
+ el.id = ID;
5123
+ el.setAttribute('style', SHELL);
5124
+ if (P.icon) {
5125
+ var img = document.createElement('img');
5126
+ img.setAttribute('style', ICON);
5127
+ img.src = P.icon;
5128
+ img.alt = '';
5129
+ el.appendChild(img);
5130
+ }
5131
+ var label = document.createElement('span');
5132
+ label.setAttribute('style', NAME);
5133
+ label.textContent = P.name;
5134
+ el.appendChild(label);
5135
+ document.body.appendChild(el);
5136
+ } catch (e) {
5137
+ // A page that refuses the node still records; the mark is cosmetic.
5138
+ }
5139
+ }
5140
+
5141
+ if (document.body) mount();
5142
+ else document.addEventListener('DOMContentLoaded', mount, { once: true });
5143
+ setInterval(mount, 400);
5144
+ })();`;
5145
+ }
5146
+ async function applyBrandWatermark(context, watermark) {
5147
+ if (!watermark?.name) return false;
5148
+ const payload = {
5149
+ name: watermark.name,
5150
+ icon: watermark.iconPath ? imageDataUri(watermark.iconPath) : null,
5151
+ position: watermark.position ?? "top-right",
5152
+ opacity: clampOpacity(watermark.opacity)
5153
+ };
5154
+ try {
5155
+ await context.addInitScript(watermarkInitScript(payload));
5156
+ return true;
5157
+ } catch {
5158
+ return false;
5159
+ }
5160
+ }
5161
+ function embedJson(value) {
5162
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
5163
+ }
5164
+ function clampOpacity(value) {
5165
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0.92;
5166
+ return Math.min(1, Math.max(0.35, value));
5167
+ }
5168
+
5169
+ // src/browser/launch.ts
5170
+ init_headless();
5171
+
4863
5172
  // src/browser/wsl.ts
4864
5173
  var import_node_child_process = require("child_process");
4865
- var import_node_fs2 = require("fs");
5174
+ var import_node_fs3 = require("fs");
4866
5175
  var import_node_http = require("http");
4867
- var import_playwright = require("playwright");
5176
+ var import_playwright2 = require("playwright");
4868
5177
  function isWSL() {
4869
5178
  if (process.platform !== "linux") return false;
4870
5179
  try {
@@ -4875,7 +5184,7 @@ function isWSL() {
4875
5184
  }
4876
5185
  }
4877
5186
  function tryEnableWSLInterop() {
4878
- return (0, import_node_fs2.existsSync)("/proc/sys/fs/binfmt_misc/WSLInterop");
5187
+ return (0, import_node_fs3.existsSync)("/proc/sys/fs/binfmt_misc/WSLInterop");
4879
5188
  }
4880
5189
  function wslToWindowsPath(wslPath) {
4881
5190
  if (!wslPath) return wslPath;
@@ -4884,7 +5193,7 @@ function wslToWindowsPath(wslPath) {
4884
5193
  return `${m[1].toUpperCase()}:\\${m[2].replace(/\//g, "\\")}`;
4885
5194
  }
4886
5195
  function findExe(whereExeQuery, ...hardcoded) {
4887
- for (const p of hardcoded) if ((0, import_node_fs2.existsSync)(p)) return p;
5196
+ for (const p of hardcoded) if ((0, import_node_fs3.existsSync)(p)) return p;
4888
5197
  try {
4889
5198
  const line = (0, import_node_child_process.execSync)(`where.exe ${whereExeQuery}`, { encoding: "utf-8", timeout: 3e3 }).split("\n")[0]?.trim();
4890
5199
  if (line) return line.replace(/^([A-Za-z]):\\/, (_, d) => `/mnt/${d.toLowerCase()}/`).replace(/\\/g, "/");
@@ -4907,7 +5216,7 @@ function getWindowsHostIP() {
4907
5216
  } catch {
4908
5217
  }
4909
5218
  try {
4910
- const m = (0, import_node_fs2.readFileSync)("/etc/resolv.conf", "utf-8").match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
5219
+ const m = (0, import_node_fs3.readFileSync)("/etc/resolv.conf", "utf-8").match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
4911
5220
  if (m?.[1]) return m[1];
4912
5221
  } catch {
4913
5222
  }
@@ -4915,7 +5224,7 @@ function getWindowsHostIP() {
4915
5224
  }
4916
5225
  function startRelay(nodePath, windowsHost, targetPort) {
4917
5226
  const script = `var n=require('net'),s=n.createServer(function(c){var r=n.createConnection(${targetPort},'127.0.0.1');c.pipe(r);r.pipe(c);r.on('error',function(){c.destroy()});c.on('error',function(){r.destroy()});});s.listen(0,'${windowsHost}',function(){process.stdout.write('PORT:'+s.address().port+'\\n')});`;
4918
- return new Promise((resolve10, reject) => {
5227
+ return new Promise((resolve11, reject) => {
4919
5228
  const proc = (0, import_node_child_process.spawn)(nodePath, ["-e", script], { stdio: ["ignore", "pipe", "ignore"], detached: true });
4920
5229
  const timer = setTimeout(() => reject(new Error("relay startup timed out")), 6e3);
4921
5230
  let buf = "";
@@ -4924,7 +5233,7 @@ function startRelay(nodePath, windowsHost, targetPort) {
4924
5233
  const m = buf.match(/PORT:(\d+)/);
4925
5234
  if (m) {
4926
5235
  clearTimeout(timer);
4927
- resolve10({ proc, port: +m[1] });
5236
+ resolve11({ proc, port: +m[1] });
4928
5237
  }
4929
5238
  });
4930
5239
  proc.on("error", (e) => {
@@ -4934,7 +5243,7 @@ function startRelay(nodePath, windowsHost, targetPort) {
4934
5243
  });
4935
5244
  }
4936
5245
  function waitForChrome(host, port) {
4937
- return new Promise((resolve10, reject) => {
5246
+ return new Promise((resolve11, reject) => {
4938
5247
  const deadline = Date.now() + 2e4;
4939
5248
  const poll = () => {
4940
5249
  if (Date.now() > deadline) {
@@ -4943,7 +5252,7 @@ function waitForChrome(host, port) {
4943
5252
  }
4944
5253
  const req = (0, import_node_http.request)({ host, port, path: "/json/version", method: "GET" }, (res) => {
4945
5254
  res.resume();
4946
- resolve10();
5255
+ resolve11();
4947
5256
  });
4948
5257
  req.on("error", () => setTimeout(poll, 600));
4949
5258
  req.setTimeout(1e3, () => {
@@ -4983,7 +5292,7 @@ async function launchWindowsChrome(extensionPath, userDataDir, options = {}) {
4983
5292
  const { proc: relay, port: proxyPort } = await startRelay(nodePath, windowsHost, debugPort);
4984
5293
  try {
4985
5294
  await waitForChrome(windowsHost, proxyPort);
4986
- const browser = await import_playwright.chromium.connectOverCDP(`http://${windowsHost}:${proxyPort}`, { timeout: 5e3 });
5295
+ const browser = await import_playwright2.chromium.connectOverCDP(`http://${windowsHost}:${proxyPort}`, { timeout: 5e3 });
4987
5296
  return {
4988
5297
  browser,
4989
5298
  cleanup: async () => {
@@ -5040,7 +5349,7 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
5040
5349
  acceptDownloads: true
5041
5350
  });
5042
5351
  } catch {
5043
- context = await import_playwright2.chromium.launchPersistentContext(userDataDir, {
5352
+ context = await import_playwright3.chromium.launchPersistentContext(userDataDir, {
5044
5353
  headless: false,
5045
5354
  args: persistentArgs,
5046
5355
  viewport,
@@ -5051,7 +5360,7 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
5051
5360
  });
5052
5361
  }
5053
5362
  } else {
5054
- context = await import_playwright2.chromium.launchPersistentContext(userDataDir, {
5363
+ context = await import_playwright3.chromium.launchPersistentContext(userDataDir, {
5055
5364
  headless: false,
5056
5365
  args: persistentArgs,
5057
5366
  viewport,
@@ -5069,12 +5378,12 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
5069
5378
  ignoreDefaultArgs: ["--enable-automation"]
5070
5379
  };
5071
5380
  try {
5072
- browser = await import_playwright2.chromium.launch({ ...headedOpts, channel: "chrome" });
5381
+ browser = await import_playwright3.chromium.launch({ ...headedOpts, channel: "chrome" });
5073
5382
  } catch {
5074
- browser = await import_playwright2.chromium.launch(headedOpts);
5383
+ browser = await import_playwright3.chromium.launch(headedOpts);
5075
5384
  }
5076
5385
  } else {
5077
- browser = await import_playwright2.chromium.launch({ headless: true });
5386
+ browser = await launchHeadlessChromium();
5078
5387
  }
5079
5388
  context = await browser.newContext({
5080
5389
  viewport,
@@ -5086,6 +5395,7 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
5086
5395
  });
5087
5396
  }
5088
5397
  await hideDevOverlays(context);
5398
+ await applyBrandWatermark(context, config.video.watermark);
5089
5399
  const session = { context, browser, chromeSession };
5090
5400
  session.release = onCleanup(() => closeBrowserSession(session));
5091
5401
  return session;
@@ -5110,9 +5420,9 @@ async function launchSignInBrowser() {
5110
5420
  ignoreDefaultArgs: ["--enable-automation"]
5111
5421
  };
5112
5422
  try {
5113
- return await import_playwright2.chromium.launch({ ...opts, channel: "chrome" });
5423
+ return await import_playwright3.chromium.launch({ ...opts, channel: "chrome" });
5114
5424
  } catch {
5115
- return await import_playwright2.chromium.launch(opts);
5425
+ return await import_playwright3.chromium.launch(opts);
5116
5426
  }
5117
5427
  }
5118
5428
  async function cleanupBrowserSession(session) {
@@ -5178,10 +5488,10 @@ async function cancelSignIn() {
5178
5488
  }
5179
5489
 
5180
5490
  // src/browser/chrome-import.ts
5181
- var import_node_fs3 = require("fs");
5491
+ var import_node_fs4 = require("fs");
5182
5492
  var import_node_os2 = require("os");
5183
5493
  var import_node_path2 = require("path");
5184
- var import_playwright3 = require("playwright");
5494
+ var import_playwright4 = require("playwright");
5185
5495
  var BROWSERS = [
5186
5496
  {
5187
5497
  key: "chrome",
@@ -5215,7 +5525,7 @@ var COOKIE_SIBLINGS = ["", "-wal", "-shm", "-journal"];
5215
5525
  function findCookieDb(userDataDir, profile) {
5216
5526
  for (const rel of [(0, import_node_path2.join)(profile, "Network", "Cookies"), (0, import_node_path2.join)(profile, "Cookies")]) {
5217
5527
  const abs = (0, import_node_path2.join)(userDataDir, rel);
5218
- if ((0, import_node_fs3.existsSync)(abs)) return { abs, rel };
5528
+ if ((0, import_node_fs4.existsSync)(abs)) return { abs, rel };
5219
5529
  }
5220
5530
  return null;
5221
5531
  }
@@ -5223,7 +5533,7 @@ function detectImportSources() {
5223
5533
  const found = [];
5224
5534
  for (const browser of BROWSERS) {
5225
5535
  for (const userDataDir of userDataRoots(browser)) {
5226
- if (!(0, import_node_fs3.existsSync)(userDataDir)) continue;
5536
+ if (!(0, import_node_fs4.existsSync)(userDataDir)) continue;
5227
5537
  const db = findCookieDb(userDataDir, "Default");
5228
5538
  if (db) found.push({ browser, userDataDir, profile: "Default", cookiesPath: db.abs, cookiesRel: db.rel });
5229
5539
  }
@@ -5252,16 +5562,16 @@ function passwordStores() {
5252
5562
  async function importFromSource(url, source) {
5253
5563
  const host = hostOf2(url);
5254
5564
  if (!host) throw new Error(`Not a valid URL: ${url}`);
5255
- const scratch = (0, import_node_fs3.mkdtempSync)((0, import_node_path2.join)((0, import_node_os2.tmpdir)(), "qp-import-"));
5565
+ const scratch = (0, import_node_fs4.mkdtempSync)((0, import_node_path2.join)((0, import_node_os2.tmpdir)(), "qp-import-"));
5256
5566
  try {
5257
5567
  const localState = (0, import_node_path2.join)(source.userDataDir, "Local State");
5258
- if ((0, import_node_fs3.existsSync)(localState)) (0, import_node_fs3.copyFileSync)(localState, (0, import_node_path2.join)(scratch, "Local State"));
5568
+ if ((0, import_node_fs4.existsSync)(localState)) (0, import_node_fs4.copyFileSync)(localState, (0, import_node_path2.join)(scratch, "Local State"));
5259
5569
  for (const suffix of COOKIE_SIBLINGS) {
5260
5570
  const from = source.cookiesPath + suffix;
5261
- if (!(0, import_node_fs3.existsSync)(from)) continue;
5571
+ if (!(0, import_node_fs4.existsSync)(from)) continue;
5262
5572
  const to = (0, import_node_path2.join)(scratch, source.cookiesRel + suffix);
5263
- (0, import_node_fs3.mkdirSync)((0, import_node_path2.dirname)(to), { recursive: true });
5264
- (0, import_node_fs3.copyFileSync)(from, to);
5573
+ (0, import_node_fs4.mkdirSync)((0, import_node_path2.dirname)(to), { recursive: true });
5574
+ (0, import_node_fs4.copyFileSync)(from, to);
5265
5575
  }
5266
5576
  for (const store2 of passwordStores()) {
5267
5577
  const state = await readState(scratch, source.browser.channel, store2);
@@ -5277,7 +5587,7 @@ async function importFromSource(url, source) {
5277
5587
  }
5278
5588
  return null;
5279
5589
  } finally {
5280
- (0, import_node_fs3.rmSync)(scratch, { recursive: true, force: true });
5590
+ (0, import_node_fs4.rmSync)(scratch, { recursive: true, force: true });
5281
5591
  }
5282
5592
  }
5283
5593
  async function readState(profileDir, channel, store2) {
@@ -5291,7 +5601,7 @@ async function readState(profileDir, channel, store2) {
5291
5601
  ];
5292
5602
  if (store2) args.push(`--password-store=${store2}`);
5293
5603
  try {
5294
- const context = await import_playwright3.chromium.launchPersistentContext(profileDir, { headless: true, channel, args });
5604
+ const context = await import_playwright4.chromium.launchPersistentContext(profileDir, { headless: true, channel, args });
5295
5605
  try {
5296
5606
  return await context.storageState();
5297
5607
  } finally {
@@ -5334,7 +5644,7 @@ function renderLogo(config = {}) {
5334
5644
  }
5335
5645
 
5336
5646
  // src/utils/config-loader.ts
5337
- var import_node_fs9 = require("fs");
5647
+ var import_node_fs10 = require("fs");
5338
5648
  var import_promises8 = require("fs/promises");
5339
5649
  var import_node_os5 = require("os");
5340
5650
  var import_node_path9 = require("path");
@@ -5351,7 +5661,7 @@ function runRoot() {
5351
5661
  async function resolveRunFile(name) {
5352
5662
  const target = (0, import_node_path9.join)(runRoot(), name);
5353
5663
  const source = (0, import_node_path9.join)(process.cwd(), name);
5354
- if (target !== source && !(0, import_node_fs9.existsSync)(target) && (0, import_node_fs9.existsSync)(source)) {
5664
+ if (target !== source && !(0, import_node_fs10.existsSync)(target) && (0, import_node_fs10.existsSync)(source)) {
5355
5665
  await (0, import_promises8.mkdir)((0, import_node_path9.dirname)(target), { recursive: true });
5356
5666
  await (0, import_promises8.copyFile)(source, target);
5357
5667
  }
@@ -5359,7 +5669,7 @@ async function resolveRunFile(name) {
5359
5669
  }
5360
5670
  async function loadConfig(_url, langOverride) {
5361
5671
  const configPath = await resolveRunFile(CONFIG_FILE);
5362
- if ((0, import_node_fs9.existsSync)(configPath)) {
5672
+ if ((0, import_node_fs10.existsSync)(configPath)) {
5363
5673
  try {
5364
5674
  const content = await (0, import_promises8.readFile)(configPath, "utf-8");
5365
5675
  const userConfig = JSON.parse(content);
@@ -5420,7 +5730,7 @@ async function prepareWorkDir(scope = "run") {
5420
5730
  const safe = scope.replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "run";
5421
5731
  const root = (0, import_node_path9.join)(runRoot(), WORK_DIR);
5422
5732
  const workDir = (0, import_node_path9.join)(root, `${safe}-${process.pid}`);
5423
- if ((0, import_node_fs9.existsSync)(workDir)) {
5733
+ if ((0, import_node_fs10.existsSync)(workDir)) {
5424
5734
  await (0, import_promises8.rm)(workDir, { recursive: true });
5425
5735
  }
5426
5736
  await (0, import_promises8.mkdir)(workDir, { recursive: true });
@@ -5447,7 +5757,7 @@ var USER_DATA_DIR = (0, import_node_path9.join)((0, import_node_os5.homedir)(),
5447
5757
  var USER_DATA_FILE = (0, import_node_path9.join)(USER_DATA_DIR, "user.json");
5448
5758
  async function loadUserData() {
5449
5759
  try {
5450
- if ((0, import_node_fs9.existsSync)(USER_DATA_FILE)) {
5760
+ if ((0, import_node_fs10.existsSync)(USER_DATA_FILE)) {
5451
5761
  return JSON.parse(await (0, import_promises8.readFile)(USER_DATA_FILE, "utf-8"));
5452
5762
  }
5453
5763
  } catch {
@@ -5455,7 +5765,7 @@ async function loadUserData() {
5455
5765
  return {};
5456
5766
  }
5457
5767
  async function saveUserData(userData) {
5458
- if (!(0, import_node_fs9.existsSync)(USER_DATA_DIR)) {
5768
+ if (!(0, import_node_fs10.existsSync)(USER_DATA_DIR)) {
5459
5769
  await (0, import_promises8.mkdir)(USER_DATA_DIR, { recursive: true });
5460
5770
  }
5461
5771
  const existing = await loadUserData();
@@ -5524,7 +5834,7 @@ function parseCaptionCSS(css) {
5524
5834
  }
5525
5835
  async function loadCaptions() {
5526
5836
  const captionsPath = await resolveRunFile("captions.css");
5527
- if (!(0, import_node_fs9.existsSync)(captionsPath)) {
5837
+ if (!(0, import_node_fs10.existsSync)(captionsPath)) {
5528
5838
  await (0, import_promises8.writeFile)(captionsPath, DEFAULT_CAPTIONS_CSS);
5529
5839
  return DEFAULT_CAPTIONS;
5530
5840
  }
@@ -5538,7 +5848,10 @@ async function loadCaptions() {
5538
5848
  async function savePlan(plan, planPath) {
5539
5849
  const cleanPlan = {
5540
5850
  ...plan,
5541
- steps: plan.steps.map(({ duration, ...step }) => step)
5851
+ steps: plan.steps.map(({ duration, ...step }) => ({
5852
+ ...step,
5853
+ ...typeof duration === "number" && duration > 0 ? { duration: Math.min(duration, 30) } : {}
5854
+ }))
5542
5855
  };
5543
5856
  await (0, import_promises8.mkdir)((0, import_node_path9.dirname)(planPath), { recursive: true });
5544
5857
  await (0, import_promises8.writeFile)(planPath, JSON.stringify(cleanPlan, null, 2));
@@ -5647,6 +5960,11 @@ async function showHelp() {
5647
5960
  `-s, --script <file>`,
5648
5961
  "Narration script (one caption per line)"
5649
5962
  ],
5963
+ [
5964
+ `${opt("--draft")}, ${opt("-dr")}`,
5965
+ `--draft, -dr`,
5966
+ "Rough cut first: small, silent, quick. Re-run without it for the real one"
5967
+ ],
5650
5968
  [`${opt("-y")}, ${opt("--yes")}`, `-y, --yes`, "Skip confirmation prompts"],
5651
5969
  [`${opt("--skip-voice")}, ${opt("-sv")}`, `--skip-voice, -sv`, "Reuse existing voiceover"],
5652
5970
  [`${opt("-f")}, ${opt("--fresh")}`, `-f, --fresh`, "Clear cached voiceover and recording"],
@@ -5744,7 +6062,7 @@ function waitForEnter(prompt2 = `${INDENT}Press Enter when done...`) {
5744
6062
  )
5745
6063
  );
5746
6064
  }
5747
- return new Promise((resolve10, reject) => {
6065
+ return new Promise((resolve11, reject) => {
5748
6066
  const rl = (0, import_node_readline.createInterface)({ input: process.stdin, output: process.stdout });
5749
6067
  let done = false;
5750
6068
  rl.on("close", () => {
@@ -5755,7 +6073,7 @@ function waitForEnter(prompt2 = `${INDENT}Press Enter when done...`) {
5755
6073
  rl.question(prompt2, () => {
5756
6074
  done = true;
5757
6075
  rl.close();
5758
- resolve10();
6076
+ resolve11();
5759
6077
  });
5760
6078
  });
5761
6079
  }
@@ -5897,8 +6215,8 @@ function editorBaseUrl() {
5897
6215
  function openInBrowser(url) {
5898
6216
  const isWSL2 = process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME);
5899
6217
  const command = isWSL2 ? `cmd.exe /c start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
5900
- return new Promise((resolve10) => {
5901
- (0, import_node_child_process5.exec)(command, () => resolve10());
6218
+ return new Promise((resolve11) => {
6219
+ (0, import_node_child_process5.exec)(command, () => resolve11());
5902
6220
  });
5903
6221
  }
5904
6222
 
@@ -5906,13 +6224,13 @@ function openInBrowser(url) {
5906
6224
  init_SparkStripe_feature_gating();
5907
6225
  var TIMEOUT_MS = 12e4;
5908
6226
  async function browserLogin() {
5909
- return new Promise((resolve10) => {
6227
+ return new Promise((resolve11) => {
5910
6228
  let server;
5911
6229
  let timer;
5912
6230
  const cleanup = (email) => {
5913
6231
  clearTimeout(timer);
5914
6232
  server?.close();
5915
- resolve10(email);
6233
+ resolve11(email);
5916
6234
  };
5917
6235
  server = (0, import_node_http2.createServer)((req, res) => {
5918
6236
  const url = new import_node_url.URL(req.url || "/", `http://localhost`);
@@ -6063,10 +6381,10 @@ function getLastRateLimitInfo() {
6063
6381
  }
6064
6382
  function prompt(question) {
6065
6383
  const rl = (0, import_node_readline2.createInterface)({ input: process.stdin, output: process.stdout });
6066
- return new Promise((resolve10) => {
6384
+ return new Promise((resolve11) => {
6067
6385
  rl.question(question, (answer) => {
6068
6386
  rl.close();
6069
- resolve10(answer.trim());
6387
+ resolve11(answer.trim());
6070
6388
  });
6071
6389
  });
6072
6390
  }
@@ -6075,30 +6393,27 @@ async function setupUserEmail() {
6075
6393
  console.log(import_chalk5.default.yellow(" \u2709 Account Setup"));
6076
6394
  console.log(import_chalk5.default.dim(" \u2500".repeat(20)));
6077
6395
  console.log();
6078
- console.log(import_chalk5.default.white(" Sign in with your Google account to track usage"));
6079
- console.log(import_chalk5.default.white(" and unlock Pro features."));
6080
- const browserEmail = await browserLogin();
6081
- if (browserEmail) {
6396
+ console.log(import_chalk5.default.white(" The AI that plans your demo runs on our servers, so it"));
6397
+ console.log(import_chalk5.default.white(" needs an address to meter. No card, and no verification"));
6398
+ console.log(import_chalk5.default.white(" needed for your first 5 videos."));
6399
+ console.log(import_chalk5.default.dim(" After that, signing in gets you 10 videos a day."));
6400
+ console.log();
6401
+ console.log(import_chalk5.default.dim(" Type your email, or 'b' to sign in through a browser."));
6402
+ const email = await prompt(import_chalk5.default.white(" Email: "));
6403
+ if (!email) return null;
6404
+ if (email.toLowerCase() === "b" || email.toLowerCase() === "browser") {
6405
+ const browserEmail = await browserLogin();
6406
+ if (!browserEmail) return null;
6082
6407
  console.log();
6083
6408
  console.log(import_chalk5.default.green(` \u2713 Signed in as ${browserEmail}`));
6084
6409
  console.log();
6085
6410
  return browserEmail;
6086
6411
  }
6087
- console.log();
6088
- console.log(import_chalk5.default.dim(" Or enter your email manually:"));
6089
- const email = await prompt(import_chalk5.default.white(" Email: "));
6090
- if (!email) {
6091
- console.log();
6092
- console.log(import_chalk5.default.red(" No email provided. Exiting."));
6093
- process.exit(1);
6094
- }
6095
6412
  if (!email.includes("@") || !email.includes(".")) {
6096
6413
  console.log();
6097
6414
  console.log(import_chalk5.default.yellow(" \u26A0 That doesn't look like a valid email address."));
6098
6415
  const proceed = await prompt(import_chalk5.default.white(" Continue anyway? ") + import_chalk5.default.dim("[y/N] "));
6099
- if (proceed.toLowerCase() !== "y") {
6100
- process.exit(1);
6101
- }
6416
+ if (proceed.toLowerCase() !== "y") return null;
6102
6417
  }
6103
6418
  console.log();
6104
6419
  console.log(import_chalk5.default.green(" \u2713 Email registered"));
@@ -6570,15 +6885,15 @@ async function launchCaptureChrome() {
6570
6885
  if (!nodePath) throw new Error("Windows Node.js not found. Install Node.js for Windows.");
6571
6886
  const windowsHost = getWindowsHostIP();
6572
6887
  const probe = await startRelay(nodePath, windowsHost, debugPort);
6573
- const alreadyUp = await new Promise((resolve10) => {
6888
+ const alreadyUp = await new Promise((resolve11) => {
6574
6889
  const req = (0, import_node_http3.request)({ host: windowsHost, port: probe.port, path: "/json/version", method: "GET" }, (res) => {
6575
6890
  res.resume();
6576
- resolve10(true);
6891
+ resolve11(true);
6577
6892
  });
6578
- req.on("error", () => resolve10(false));
6893
+ req.on("error", () => resolve11(false));
6579
6894
  req.setTimeout(2e3, () => {
6580
6895
  req.destroy();
6581
- resolve10(false);
6896
+ resolve11(false);
6582
6897
  });
6583
6898
  req.end();
6584
6899
  });
@@ -6784,7 +7099,7 @@ async function hasHeadroom() {
6784
7099
  async function withRenderSlot(render, onWait) {
6785
7100
  if (active >= MAX_CONCURRENT_RENDERS) {
6786
7101
  onWait?.(`waiting for a render slot (${active}/${MAX_CONCURRENT_RENDERS} busy)`);
6787
- await new Promise((resolve10) => waiting.push(resolve10));
7102
+ await new Promise((resolve11) => waiting.push(resolve11));
6788
7103
  }
6789
7104
  active++;
6790
7105
  try {
@@ -6813,19 +7128,19 @@ init_lib();
6813
7128
  init_helpers();
6814
7129
 
6815
7130
  // src/record.ts
6816
- var import_node_fs14 = require("fs");
7131
+ var import_node_fs15 = require("fs");
6817
7132
  var import_promises13 = require("fs/promises");
6818
7133
  var import_node_path17 = require("path");
6819
7134
  var import_chalk6 = __toESM(require("chalk"));
6820
7135
 
6821
7136
  // src/browser/actions.ts
6822
- var import_node_fs11 = require("fs");
7137
+ var import_node_fs12 = require("fs");
6823
7138
  var import_promises11 = require("fs/promises");
6824
7139
  var import_node_path13 = require("path");
6825
7140
  init_lib();
6826
7141
 
6827
7142
  // src/browser/effects.ts
6828
- var import_node_fs10 = require("fs");
7143
+ var import_node_fs11 = require("fs");
6829
7144
  var import_node_path12 = require("path");
6830
7145
  init_lib();
6831
7146
 
@@ -6846,7 +7161,7 @@ function loadHighlightCSS() {
6846
7161
  // src/browser/ → src/
6847
7162
  ];
6848
7163
  for (const p of candidates) {
6849
- if ((0, import_node_fs10.existsSync)(p)) return (0, import_node_fs10.readFileSync)(p, "utf-8");
7164
+ if ((0, import_node_fs11.existsSync)(p)) return (0, import_node_fs11.readFileSync)(p, "utf-8");
6850
7165
  }
6851
7166
  return "";
6852
7167
  }
@@ -7059,6 +7374,7 @@ function locatorFor(page, keyword) {
7059
7374
  var WAIT_MS = 450;
7060
7375
  var HANDLE_MS = 3e3;
7061
7376
  var RESOLVE_BUDGET_MS2 = 2e4;
7377
+ var DOM_POLL_MS = 150;
7062
7378
  function remaining(deadline) {
7063
7379
  return Math.max(1, deadline - Date.now());
7064
7380
  }
@@ -7169,7 +7485,7 @@ async function resolveElementSettled(page, target, deadlineMs = RESOLVE_DEADLINE
7169
7485
  }
7170
7486
  }
7171
7487
  async function resolvePlainText(page, text, deadline) {
7172
- const scanned = await domTextMatch(page, text);
7488
+ let scanned = await domTextMatch(page, text);
7173
7489
  const stages = plainTextStages(page, text);
7174
7490
  while (Date.now() < deadline) {
7175
7491
  for (const { locator, state } of stages) {
@@ -7180,6 +7496,12 @@ async function resolvePlainText(page, text, deadline) {
7180
7496
  }
7181
7497
  }
7182
7498
  if (scanned) return scanned;
7499
+ while (Date.now() < deadline) {
7500
+ await page.waitForTimeout(Math.min(DOM_POLL_MS, remaining(deadline)));
7501
+ scanned = await domTextMatch(page, text);
7502
+ if (scanned) break;
7503
+ }
7504
+ if (!scanned) return null;
7183
7505
  }
7184
7506
  return scanned ?? null;
7185
7507
  }
@@ -7605,7 +7927,7 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
7605
7927
  if (!isPlaceholder) {
7606
7928
  const normalizedPath = rawPath.startsWith("mnt/") ? `/${rawPath}` : rawPath;
7607
7929
  const filePath = (0, import_node_path13.isAbsolute)(normalizedPath) ? normalizedPath : (0, import_node_path13.resolve)(baseDir, normalizedPath);
7608
- if ((0, import_node_fs11.existsSync)(filePath)) {
7930
+ if ((0, import_node_fs12.existsSync)(filePath)) {
7609
7931
  await fileInput.setInputFiles(filePath);
7610
7932
  await page.waitForTimeout(2e3);
7611
7933
  break;
@@ -7722,7 +8044,7 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
7722
8044
  break;
7723
8045
  case "image": {
7724
8046
  const imagePath = step.value ? (0, import_node_path13.isAbsolute)(step.value) ? step.value : (0, import_node_path13.resolve)(baseDir, step.value) : null;
7725
- if (imagePath && (0, import_node_fs11.existsSync)(imagePath)) {
8047
+ if (imagePath && (0, import_node_fs12.existsSync)(imagePath)) {
7726
8048
  const imgStyle = step.target === "stretch" ? "width: 100vw; height: 100vh; object-fit: cover;" : "max-width: 90%; max-height: 90%;";
7727
8049
  if (await showImagePage(page, imagePath, imgStyle, config, cursorInjected, false)) injected = true;
7728
8050
  }
@@ -7751,7 +8073,7 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
7751
8073
  await pendingDownload;
7752
8074
  pendingDownload = null;
7753
8075
  }
7754
- if (lastDownloadPath && (0, import_node_fs11.existsSync)(lastDownloadPath)) {
8076
+ if (lastDownloadPath && (0, import_node_fs12.existsSync)(lastDownloadPath)) {
7755
8077
  const imgStyle = step.target === "stretch" ? "width: 100%; height: 100%; object-fit: contain;" : "max-width: 90%; max-height: 90%; object-fit: contain;";
7756
8078
  if (await showImagePage(page, lastDownloadPath, imgStyle, config, cursorInjected)) injected = true;
7757
8079
  }
@@ -7857,7 +8179,7 @@ async function getPageElementSelectors(page, query, limit, keyword = "") {
7857
8179
  }
7858
8180
 
7859
8181
  // src/browser/step-validator.ts
7860
- var import_node_fs12 = require("fs");
8182
+ var import_node_fs13 = require("fs");
7861
8183
  var import_node_path14 = require("path");
7862
8184
  init_crawl();
7863
8185
  init_utils();
@@ -7865,9 +8187,9 @@ function findMediaFile(filename, baseDir) {
7865
8187
  const name = (0, import_node_path14.basename)(filename);
7866
8188
  const searchDirs = [baseDir, (0, import_node_path14.join)(baseDir, "res"), (0, import_node_path14.join)(baseDir, "assets"), (0, import_node_path14.join)(baseDir, "media")];
7867
8189
  for (const dir of searchDirs) {
7868
- if (!(0, import_node_fs12.existsSync)(dir)) continue;
8190
+ if (!(0, import_node_fs13.existsSync)(dir)) continue;
7869
8191
  try {
7870
- const files = (0, import_node_fs12.readdirSync)(dir);
8192
+ const files = (0, import_node_fs13.readdirSync)(dir);
7871
8193
  if (files.includes(name)) return (0, import_node_path14.join)(dir, name);
7872
8194
  const match = files.find((f) => f.toLowerCase() === name.toLowerCase());
7873
8195
  if (match) return (0, import_node_path14.join)(dir, match);
@@ -7919,7 +8241,7 @@ function resolveMediaStep(step, baseDir, label, startTime) {
7919
8241
  return { success: false, error: `Missing ${label} path`, executionTimeMs: Date.now() - startTime };
7920
8242
  }
7921
8243
  const specifiedPath = (0, import_node_path14.isAbsolute)(step.value) ? step.value : (0, import_node_path14.resolve)(baseDir, step.value);
7922
- if ((0, import_node_fs12.existsSync)(specifiedPath)) return { success: true, executionTimeMs: Date.now() - startTime };
8244
+ if ((0, import_node_fs13.existsSync)(specifiedPath)) return { success: true, executionTimeMs: Date.now() - startTime };
7923
8245
  const foundPath = findMediaFile(step.value, baseDir);
7924
8246
  if (foundPath) {
7925
8247
  step.value = `./${foundPath.replace(`${baseDir}/`, "").replace(`${baseDir}\\`, "")}`;
@@ -8048,7 +8370,7 @@ async function validateAndExecuteStep(page, step, plan, baseDir) {
8048
8370
  if (!isPlaceholder) {
8049
8371
  const normalizedPath = rawPath.startsWith("mnt/") ? `/${rawPath}` : rawPath;
8050
8372
  let filePath = (0, import_node_path14.isAbsolute)(normalizedPath) ? normalizedPath : (0, import_node_path14.resolve)(baseDir, normalizedPath);
8051
- if (!(0, import_node_fs12.existsSync)(filePath)) {
8373
+ if (!(0, import_node_fs13.existsSync)(filePath)) {
8052
8374
  const foundPath = findMediaFile(rawPath, baseDir);
8053
8375
  if (foundPath) {
8054
8376
  const relativePath = `./${foundPath.replace(`${baseDir}/`, "").replace(`${baseDir}\\`, "")}`;
@@ -8190,7 +8512,7 @@ async function validateStepsOnPage(plan, config, logError2, baseDir, onStepStart
8190
8512
  }
8191
8513
 
8192
8514
  // src/browser/meta.ts
8193
- var import_node_fs13 = require("fs");
8515
+ var import_node_fs14 = require("fs");
8194
8516
  var import_promises12 = require("fs/promises");
8195
8517
  var import_node_path16 = require("path");
8196
8518
  var RECORDING_FILE = "recording.mp4";
@@ -8200,7 +8522,7 @@ function getRecordingPath(outputDir) {
8200
8522
  }
8201
8523
  async function loadRecordingMeta(outputDir) {
8202
8524
  const metaPath = (0, import_node_path16.join)(outputDir, RECORDING_META_FILE);
8203
- if (!(0, import_node_fs13.existsSync)(metaPath)) return null;
8525
+ if (!(0, import_node_fs14.existsSync)(metaPath)) return null;
8204
8526
  try {
8205
8527
  const content = await (0, import_promises12.readFile)(metaPath, "utf-8");
8206
8528
  return JSON.parse(content);
@@ -8318,11 +8640,33 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
8318
8640
  const authState = findAuthState(plan.url);
8319
8641
  const { context, browser, chromeSession } = await launchBrowserContext(config, userDataDir, recordVideo, authState);
8320
8642
  try {
8643
+ const isExtensionPage = plan.url.startsWith("chrome-extension://");
8644
+ if (!isExtensionPage) {
8645
+ const warm = await context.newPage();
8646
+ const warmVideo = warm.video();
8647
+ const warmUrls = [plan.url];
8648
+ for (const step of plan.steps) {
8649
+ if (step.action !== "navigate" || !step.target) continue;
8650
+ if (!warmUrls.includes(step.target)) warmUrls.push(step.target);
8651
+ }
8652
+ try {
8653
+ for (const url of warmUrls) {
8654
+ await gotoSettled(warm, url).catch(() => {
8655
+ });
8656
+ }
8657
+ } catch {
8658
+ } finally {
8659
+ await warm.close().catch(() => {
8660
+ });
8661
+ const stray = await warmVideo?.path().catch(() => void 0);
8662
+ if (stray) await (0, import_promises13.rm)(stray, { force: true }).catch(() => {
8663
+ });
8664
+ }
8665
+ }
8321
8666
  const page = context.pages()[0] || await context.newPage();
8322
8667
  setupDownloadTracking(page);
8323
8668
  context.on("page", (p) => setupDownloadTracking(p));
8324
8669
  const firstStepIsMedia = ["media", "image", "video"].includes(plan.steps[0]?.action ?? "");
8325
- const isExtensionPage = plan.url.startsWith("chrome-extension://");
8326
8670
  let signInChecked = false;
8327
8671
  const guardSignedIn = async () => {
8328
8672
  if (signInChecked || opts.allowLoggedOut || isExtensionPage) return;
@@ -8399,7 +8743,7 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
8399
8743
  if (!targetEl) {
8400
8744
  console.log(
8401
8745
  `
8402
- \u26A0 target "${step.target}" not found on ${page.url()} \u2014 step ${stepNum} recorded without the action`
8746
+ \u26A0 target "${step.target}" not found on ${page.url()}; step ${stepNum} recorded without the action`
8403
8747
  );
8404
8748
  }
8405
8749
  }
@@ -8538,7 +8882,7 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
8538
8882
  if (!pageClosedEarly) await page.close();
8539
8883
  const videoPath = await video.path();
8540
8884
  await cleanupBrowserSession({ context, browser, chromeSession });
8541
- if (!videoPath || !(0, import_node_fs14.existsSync)(videoPath)) {
8885
+ if (!videoPath || !(0, import_node_fs15.existsSync)(videoPath)) {
8542
8886
  throw new Error("Video file was not created");
8543
8887
  }
8544
8888
  saveTask.succeed("Video saved");
@@ -8549,14 +8893,14 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
8549
8893
  }
8550
8894
 
8551
8895
  // src/utils/video-pipeline.ts
8552
- var import_node_fs16 = require("fs");
8896
+ var import_node_fs17 = require("fs");
8553
8897
  var import_promises15 = require("fs/promises");
8554
8898
  var import_node_path21 = require("path");
8555
8899
  var import_chalk9 = __toESM(require("chalk"));
8556
8900
  init_lib();
8557
8901
 
8558
8902
  // src/utils/music.ts
8559
- var import_node_fs15 = require("fs");
8903
+ var import_node_fs16 = require("fs");
8560
8904
  var import_node_path20 = require("path");
8561
8905
 
8562
8906
  // src/commands/utils/record-helpers.ts
@@ -8573,6 +8917,17 @@ var import_playwright6 = require("playwright");
8573
8917
  init_mcp_context();
8574
8918
  init_lib();
8575
8919
  init_json_repair();
8920
+ var WRITTEN_ADDRESS = /\b(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?/gi;
8921
+ var SPELLED_OUT_ACRONYM = /\b[A-Z](?:\s+[A-Z])+\b/g;
8922
+ function spokenCaption(caption) {
8923
+ const held = [];
8924
+ const masked = caption.replace(SPELLED_OUT_ACRONYM, (run2) => run2.replace(/\s+/g, "")).replace(WRITTEN_ADDRESS, (address) => {
8925
+ held.push(address);
8926
+ return `qpaddr${held.length - 1}qp`;
8927
+ });
8928
+ const shaped = toTitleCase((0, import_video_kit8.toSpokenForm)(masked));
8929
+ return shaped.replace(/qpaddr(\d+)qp/gi, (_match, index) => held[Number(index)] ?? "");
8930
+ }
8576
8931
  async function crawlExtension(url, extensionPath, workDir, task2) {
8577
8932
  const browserTask = task2("Launching browser with extension");
8578
8933
  const userDataDir = (0, import_node_path18.join)(workDir, ".browser-data");
@@ -8701,8 +9056,18 @@ async function crawlAndAnalyze(url, lang, description, maxSteps, task2, logError
8701
9056
  // The spoken form is what the voice will actually say, so it is what
8702
9057
  // the caption shows: burning the written form instead left the words on
8703
9058
  // screen out of step with the words in the ear wherever the two differ
8704
- // (a domain read with its dot, a number read as words).
8705
- caption: s.caption ? toTitleCase((0, import_video_kit8.toSpokenForm)(s.caption)) : s.caption
9059
+ // (a number read as words).
9060
+ //
9061
+ // Addresses are the exception. Expanding one here turned "quickpeek.co"
9062
+ // into three caption words, "Quickpeek Dot Co", which the line packer
9063
+ // is then free to break across lines: the call to action arrived on
9064
+ // screen as "DOT C O" with the name on the row above. An address is the
9065
+ // one thing in a demo a viewer has to be able to read and type, so it
9066
+ // is held back verbatim from both passes and rejoins as written. The
9067
+ // voice is unaffected: generate.ts derives its own spoken form from
9068
+ // this caption at synthesis time, and word timings fall back to an even
9069
+ // spread across the speech envelope when the counts no longer line up.
9070
+ caption: s.caption ? spokenCaption(s.caption) : s.caption
8706
9071
  }));
8707
9072
  aiTask.succeed(`Plan: ${plan.steps.length} steps`);
8708
9073
  return plan;
@@ -8874,12 +9239,12 @@ function getOutputDirFromUrl(url) {
8874
9239
  }
8875
9240
 
8876
9241
  // src/utils/music.ts
8877
- var DEFAULT_MOOD = "calm";
9242
+ var DEFAULT_MOOD = "corporate";
8878
9243
  function musicDir() {
8879
9244
  let dir = getScriptDir();
8880
9245
  for (let i = 0; i < 6; i++) {
8881
9246
  const candidate = (0, import_node_path20.join)(dir, "assets", "music");
8882
- if ((0, import_node_fs15.existsSync)((0, import_node_path20.join)(candidate, "manifest.json"))) return candidate;
9247
+ if ((0, import_node_fs16.existsSync)((0, import_node_path20.join)(candidate, "manifest.json"))) return candidate;
8883
9248
  const parent = (0, import_node_path20.dirname)(dir);
8884
9249
  if (parent === dir) break;
8885
9250
  dir = parent;
@@ -8890,9 +9255,9 @@ function listBundledMusic() {
8890
9255
  const dir = musicDir();
8891
9256
  if (!dir) return [];
8892
9257
  try {
8893
- const parsed = JSON.parse((0, import_node_fs15.readFileSync)((0, import_node_path20.join)(dir, "manifest.json"), "utf8"));
9258
+ const parsed = JSON.parse((0, import_node_fs16.readFileSync)((0, import_node_path20.join)(dir, "manifest.json"), "utf8"));
8894
9259
  const tracks = Array.isArray(parsed.tracks) ? parsed.tracks : [];
8895
- return tracks.filter((t) => typeof t?.file === "string").map((t) => ({ ...t, path: (0, import_node_path20.join)(dir, t.file) })).filter((t) => (0, import_node_fs15.existsSync)(t.path));
9260
+ return tracks.filter((t) => typeof t?.file === "string").map((t) => ({ ...t, path: (0, import_node_path20.join)(dir, t.file) })).filter((t) => (0, import_node_fs16.existsSync)(t.path));
8896
9261
  } catch {
8897
9262
  return [];
8898
9263
  }
@@ -8905,6 +9270,16 @@ function resolveBundledBed(mood = DEFAULT_MOOD) {
8905
9270
 
8906
9271
  // src/utils/video-pipeline.ts
8907
9272
  var INDENT3 = " ";
9273
+ function captionWordTimings(caption, spoken, duration) {
9274
+ const captionWords = caption.split(/\s+/).filter(Boolean);
9275
+ if (spoken.length === 0 || captionWords.length === 0) {
9276
+ return estimateWordTimings(caption, duration);
9277
+ }
9278
+ if (spoken.length === captionWords.length) {
9279
+ return spoken.map((w, i) => ({ ...w, word: captionWords[i] ?? w.word }));
9280
+ }
9281
+ return estimateWordTimings(caption, duration);
9282
+ }
8908
9283
  async function generateASS(plan, stepAudio, workDir, config, captions) {
8909
9284
  const cues = [];
8910
9285
  let currentTime = VOICE_LEAD_MS;
@@ -8915,7 +9290,7 @@ async function generateASS(plan, stepAudio, workDir, config, captions) {
8915
9290
  currentTime += config.timing.minStepDuration;
8916
9291
  continue;
8917
9292
  }
8918
- const words = audio.words.length > 0 ? audio.words : estimateWordTimings(step.caption || "", audio.duration);
9293
+ const words = captionWordTimings(step.caption || "", audio.words, audio.duration);
8919
9294
  if (words.length > 0 && !step.spokenOnly) {
8920
9295
  const offset = currentTime;
8921
9296
  cues.push({
@@ -8935,7 +9310,7 @@ async function generateASS(plan, stepAudio, workDir, config, captions) {
8935
9310
  return assPath;
8936
9311
  }
8937
9312
  var STILL_EXTENSIONS = ["png", "jpg", "jpeg", "webp"];
8938
- function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
9313
+ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0, totalSecs, blackSpans) {
8939
9314
  const overlays = [];
8940
9315
  let cumulativeTime = leadInSecs;
8941
9316
  for (let i = 0; i < steps.length; i++) {
@@ -8949,12 +9324,19 @@ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
8949
9324
  const still = isCard && STILL_EXTENSIONS.includes(ext);
8950
9325
  if (still || isClip && ["mp4", "webm", "mov"].includes(ext)) {
8951
9326
  const mediaPath = (0, import_node_path21.isAbsolute)(step.value) ? step.value : (0, import_node_path21.resolve)(baseDir, step.value);
8952
- if ((0, import_node_fs16.existsSync)(mediaPath) && durationSec > 0) {
9327
+ if ((0, import_node_fs17.existsSync)(mediaPath) && durationSec > 0) {
8953
9328
  const partial = parsePartial(step.partial);
9329
+ const runsToEnd = still && i === steps.length - 1 && totalSecs !== void 0;
9330
+ let duration = runsToEnd ? Math.max(durationSec, totalSecs - cumulativeTime) : durationSec;
9331
+ if (still && !runsToEnd && blackSpans) {
9332
+ const endsAt = cumulativeTime + duration;
9333
+ const following = blackSpans.find((sp) => sp.start <= endsAt + 0.25 && sp.end > endsAt);
9334
+ if (following) duration = following.end - cumulativeTime;
9335
+ }
8954
9336
  overlays.push({
8955
9337
  path: mediaPath,
8956
9338
  startTime: cumulativeTime,
8957
- duration: durationSec,
9339
+ duration,
8958
9340
  mode: step.target === "stretch" ? "stretch" : step.target === "cover" ? "cover" : "center",
8959
9341
  ...still ? { still: true } : {},
8960
9342
  ...partial.trimStart !== void 0 ? { trimStart: partial.trimStart } : {},
@@ -8967,7 +9349,7 @@ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
8967
9349
  }
8968
9350
  return overlays;
8969
9351
  }
8970
- async function composeAndFinish(videoPath, audioPath, assPath, config, userTier = "free", mainContentDuration, trimStart = 0.5, stepTransitions, hasClipAudio = false, endsOnCreditsCard = false, _stepSync) {
9352
+ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier = "free", mainContentDuration, trimStart = 0.5, stepTransitions, hasClipAudio = false, endsOnCreditsCard = false, _stepSync, musicStartSecs = 0) {
8971
9353
  header("Compose");
8972
9354
  const configPath = (0, import_node_path21.join)(runRoot(), CONFIG_FILE);
8973
9355
  console.log(`${INDENT3}${import_chalk9.default.dim(`Config: ${formatClickablePath(configPath)}`)}`);
@@ -8983,9 +9365,9 @@ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier
8983
9365
  const suffix = config.lang ? `-${config.lang}` : "";
8984
9366
  const finalPath = (0, import_node_path21.join)(config.output.dir, `demo${suffix}.${config.video.format}`);
8985
9367
  const videoDuration = await getMediaDuration(videoPath) / 1e3;
8986
- const hasVoiceover = audioPath && (0, import_node_fs16.existsSync)(audioPath);
9368
+ const hasVoiceover = audioPath && (0, import_node_fs17.existsSync)(audioPath);
8987
9369
  const musicPath = config.music?.path ? (0, import_node_path21.isAbsolute)(config.music.path) ? config.music.path : (0, import_node_path21.resolve)(config.output.dir, config.music.path) : "";
8988
- let hasMusic = musicPath && (0, import_node_fs16.existsSync)(musicPath);
9370
+ let hasMusic = musicPath && (0, import_node_fs17.existsSync)(musicPath);
8989
9371
  let bedPath = "";
8990
9372
  let bedCredit = "";
8991
9373
  if (!hasMusic && config.audio?.ambience !== false) {
@@ -9037,7 +9419,14 @@ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier
9037
9419
  hasClipAudio,
9038
9420
  mainContentDuration,
9039
9421
  stepTransitions,
9040
- hwaccel: config.video.hwaccel
9422
+ hwaccel: config.video.hwaccel,
9423
+ // The tempo pass runs after this and speeds the whole file up, music
9424
+ // included. Told about it, compose lays the bed down pre-slowed so the
9425
+ // song comes out at its own speed.
9426
+ tempo: config.video.tempo,
9427
+ musicStartSecs,
9428
+ // So the bed can be landed with its last note on the last frame.
9429
+ musicDurationSecs: hasMusic ? await getMediaDuration(backingPath).then((ms) => ms / 1e3).catch(() => 0) : 0
9041
9430
  });
9042
9431
  composeTask.succeed("Video composed");
9043
9432
  return finalPath;
@@ -9110,7 +9499,7 @@ function resolveOutro(config, userTier, userEmail) {
9110
9499
  const fallback = (0, import_node_path23.join)(scriptDir, "..", "dist", "assets", "qp.wm");
9111
9500
  return (0, import_node_fs18.existsSync)(fallback) ? fallback : void 0;
9112
9501
  }
9113
- var EMPTY_MIN_SECS = 0.5;
9502
+ var EMPTY_MIN_SECS = 0.4;
9114
9503
  var OUTRO_TAIL_MS = 900;
9115
9504
  var FIT_SECOND_PASS_CUSHION_SECS = 1;
9116
9505
  var FIT_SECOND_PASS_MAX_SPEEDUP = 1.45;
@@ -9140,6 +9529,7 @@ async function renderVideoPhase(params) {
9140
9529
  const firstStep = plan.steps[0];
9141
9530
  const firstStepIsVideo = firstStep && (firstStep.action === "video" || firstStep.action === "media");
9142
9531
  let trimStart = 0;
9532
+ let recordedStepLengths = null;
9143
9533
  const doRecord = async () => {
9144
9534
  header("Record");
9145
9535
  const result = await recordSession(plan, config, stepDurations, workDir, config.output.dir, task, logError, {
@@ -9151,6 +9541,7 @@ async function renderVideoPhase(params) {
9151
9541
  ]);
9152
9542
  const detectedEmpty = mergeSpans([...result.leadSpans ?? [], ...emptyBlack, ...emptyWhite]);
9153
9543
  let stepLengths = result.actualStepDurations;
9544
+ let zeroAnchored = false;
9154
9545
  if (detectedEmpty.length > 0) {
9155
9546
  const bounds = [];
9156
9547
  let at = 0;
@@ -9175,6 +9566,7 @@ async function renderVideoPhase(params) {
9175
9566
  const sum = result.actualStepDurations.reduce((a, b2) => a + b2, 0);
9176
9567
  const factor = sum > 0 && realSecs > 0 ? realSecs * 1e3 / sum : 1;
9177
9568
  stepLengths = result.actualStepDurations.map((ms) => ms * factor);
9569
+ zeroAnchored = true;
9178
9570
  info(`Sped up ${emptySecs.toFixed(1)}s of loading screens at ${EMPTY_SPAN_SPEED}x`);
9179
9571
  } else {
9180
9572
  await discardPartial(retimed);
@@ -9191,16 +9583,19 @@ async function renderVideoPhase(params) {
9191
9583
  const meta = buildRecordingMeta(plan, config, stepDurations);
9192
9584
  meta.actualStepDurations = stepLengths;
9193
9585
  meta.pageLoadTime = result.pageLoadTime;
9586
+ meta.zeroAnchored = zeroAnchored;
9194
9587
  await saveRecordingMeta(config.output.dir, meta);
9195
9588
  videoPath = recordingPath;
9196
- trimStart = firstStepIsVideo ? 0 : result.pageLoadTime;
9589
+ recordedStepLengths = stepLengths;
9590
+ trimStart = firstStepIsVideo || zeroAnchored ? 0 : result.pageLoadTime;
9197
9591
  };
9198
9592
  if (hasExistingRecording) {
9199
9593
  const reuseCheck = canReuseRecording(plan, config, stepDurations, existingRecordingMeta);
9200
9594
  if (reuseCheck.canReuse) {
9201
9595
  info("Reusing existing recording");
9202
9596
  videoPath = recordingPath;
9203
- trimStart = firstStepIsVideo ? 0 : existingRecordingMeta?.pageLoadTime ?? 0.5;
9597
+ recordedStepLengths = existingRecordingMeta.actualStepDurations ?? existingRecordingMeta.stepDurations;
9598
+ trimStart = firstStepIsVideo || existingRecordingMeta.zeroAnchored ? 0 : existingRecordingMeta?.pageLoadTime ?? 0.5;
9204
9599
  stepAudio = await alignVoiceoverToRecording(
9205
9600
  stepAudio,
9206
9601
  existingRecordingMeta.actualStepDurations ?? existingRecordingMeta.stepDurations,
@@ -9214,9 +9609,23 @@ async function renderVideoPhase(params) {
9214
9609
  } else {
9215
9610
  await doRecord();
9216
9611
  }
9217
- const alignedStepDurations = plan.steps.map((_, i) => stepAudio.get(i + 1)?.duration ?? 0);
9612
+ const alignedStepDurations = plan.steps.map(
9613
+ (step, i) => Math.max(
9614
+ stepAudio.get(i + 1)?.duration ?? recordedStepLengths?.[i] ?? stepDurations[i] ?? 0,
9615
+ step.duration ? step.duration * 1e3 : 0
9616
+ )
9617
+ );
9218
9618
  let splicedClipAudio = false;
9219
- const videoOverlays = buildVideoOverlays(plan.steps, alignedStepDurations, config.output.dir, trimStart);
9619
+ const recordingSecs = await getMediaDuration(videoPath).then((ms) => ms / 1e3).catch(() => void 0);
9620
+ const blackSpans = await detectBlackSpans(videoPath, 0.3).catch(() => []);
9621
+ const videoOverlays = buildVideoOverlays(
9622
+ plan.steps,
9623
+ alignedStepDurations,
9624
+ config.output.dir,
9625
+ trimStart,
9626
+ recordingSecs,
9627
+ blackSpans
9628
+ );
9220
9629
  if (videoOverlays.length > 0) {
9221
9630
  const spliceTask = task("Painting cards and clips into recording");
9222
9631
  try {
@@ -9270,11 +9679,13 @@ async function renderVideoPhase(params) {
9270
9679
  const cardStepSeconds = (index) => {
9271
9680
  const action = plan.steps[index]?.action;
9272
9681
  if (action !== "image" && action !== "media") return 0;
9273
- return (stepAudio.get(index + 1)?.duration ?? 0) / 1e3;
9682
+ const audioSecs = (stepAudio.get(index + 1)?.duration ?? 0) / 1e3;
9683
+ const tail = index === plan.steps.length - 1 && action === "image" ? OUTRO_TAIL_MS / 1e3 : 0;
9684
+ return Math.max(audioSecs, plan.steps[index]?.duration ?? 0) + tail;
9274
9685
  };
9275
9686
  const introSecs = cardStepSeconds(0);
9276
9687
  const outroStepSecs = cardStepSeconds(plan.steps.length - 1);
9277
- const outroSeconds = config.video.outroCard?.seconds ?? (outroStepSecs || 3.5);
9688
+ const outroSeconds = Math.max(config.video.outroCard?.seconds ?? 3.5, outroStepSecs);
9278
9689
  const card = config.video.outroCard;
9279
9690
  if (card?.subject?.name) {
9280
9691
  const cardTask = task("Rendering credits card");
@@ -9318,6 +9729,20 @@ async function renderVideoPhase(params) {
9318
9729
  mainContentDuration = void 0;
9319
9730
  }
9320
9731
  }
9732
+ const outroOnly = config.music?.outroOnly !== false;
9733
+ let musicStartSecs = 0;
9734
+ if (outroOnly) {
9735
+ let trailingCards = 0;
9736
+ for (let i = plan.steps.length - 1; i >= 0; i--) {
9737
+ const action = plan.steps[i]?.action;
9738
+ if (action !== "image" && action !== "media") break;
9739
+ trailingCards++;
9740
+ }
9741
+ if (trailingCards > 0 && trailingCards < plan.steps.length) {
9742
+ const upTo = plan.steps.length - trailingCards;
9743
+ musicStartSecs = alignedStepDurations.slice(0, upTo).reduce((a, b) => a + b, 0) / 1e3;
9744
+ }
9745
+ }
9321
9746
  let finalPath = await composeAndFinish(
9322
9747
  videoToCompose,
9323
9748
  voiceoverPath,
@@ -9329,7 +9754,9 @@ async function renderVideoPhase(params) {
9329
9754
  stepTransitions,
9330
9755
  // The splice writes a clip-audio track only when a spliced clip had one.
9331
9756
  splicedClipAudio,
9332
- endsOnCreditsCard
9757
+ endsOnCreditsCard,
9758
+ void 0,
9759
+ musicStartSecs
9333
9760
  );
9334
9761
  const maxSilence = config.video.maxSilenceSecs ?? 0.6;
9335
9762
  if (maxSilence > 0) {
@@ -9685,8 +10112,8 @@ async function captureCommand(url, lang) {
9685
10112
  }
9686
10113
 
9687
10114
  // src/commands/edit-cmd.ts
9688
- var import_node_fs21 = require("fs");
9689
- var import_node_path27 = require("path");
10115
+ var import_node_fs22 = require("fs");
10116
+ var import_node_path28 = require("path");
9690
10117
  var import_chalk12 = __toESM(require("chalk"));
9691
10118
 
9692
10119
  // src/lib/plan-validation.ts
@@ -9844,17 +10271,81 @@ function planEditorUrl(baseUrl, plan) {
9844
10271
  return url.length <= MAX_HANDOFF_URL_CHARS ? url : null;
9845
10272
  }
9846
10273
 
10274
+ // src/lib/plan-thumbs.ts
10275
+ var import_node_fs21 = require("fs");
10276
+ var import_node_path27 = require("path");
10277
+ init_spawn();
10278
+ var MEDIA_ACTIONS = ["image", "media", "video"];
10279
+ var THUMB_WIDTH = 160;
10280
+ var MAX_THUMB_CHARS = 1e4;
10281
+ var MAX_TOTAL_THUMB_CHARS = 16e3;
10282
+ var THUMB_TIMEOUT_MS = 3e3;
10283
+ function thumbnail(path) {
10284
+ return new Promise((done) => {
10285
+ const proc = spawnFFmpeg("ffmpeg", [
10286
+ "-v",
10287
+ "error",
10288
+ "-i",
10289
+ path,
10290
+ "-frames:v",
10291
+ "1",
10292
+ "-vf",
10293
+ `scale=${THUMB_WIDTH}:-2`,
10294
+ "-q:v",
10295
+ "14",
10296
+ "-f",
10297
+ "mjpeg",
10298
+ "-"
10299
+ ]);
10300
+ const chunks = [];
10301
+ const timer = setTimeout(() => {
10302
+ proc.kill();
10303
+ done(null);
10304
+ }, THUMB_TIMEOUT_MS);
10305
+ const finish = (result) => {
10306
+ clearTimeout(timer);
10307
+ done(result);
10308
+ };
10309
+ proc.stdout.on("data", (chunk) => chunks.push(chunk));
10310
+ proc.on("error", () => finish(null));
10311
+ proc.on("close", (code) => {
10312
+ const bytes = Buffer.concat(chunks);
10313
+ if (code !== 0 || bytes.length === 0) return finish(null);
10314
+ const base64 = bytes.toString("base64");
10315
+ finish(base64.length > MAX_THUMB_CHARS ? null : `data:image/jpeg;base64,${base64}`);
10316
+ });
10317
+ });
10318
+ }
10319
+ async function withEditorThumbs(plan, baseDir) {
10320
+ const steps = [...plan.steps];
10321
+ let budget = MAX_TOTAL_THUMB_CHARS;
10322
+ for (const [index, step] of steps.entries()) {
10323
+ if (!step.value || !MEDIA_ACTIONS.includes(step.action)) continue;
10324
+ const path = (0, import_node_path27.isAbsolute)(step.value) ? step.value : (0, import_node_path27.resolve)(baseDir, step.value);
10325
+ if (!(0, import_node_fs21.existsSync)(path)) continue;
10326
+ const thumb = await thumbnail(path);
10327
+ if (!thumb || thumb.length > budget) continue;
10328
+ budget -= thumb.length;
10329
+ steps[index] = { ...step, thumb };
10330
+ }
10331
+ return { ...plan, steps };
10332
+ }
10333
+ async function editorUrlWithThumbs(baseUrl, plan, baseDir) {
10334
+ const withThumbs = await withEditorThumbs(plan, baseDir);
10335
+ return planEditorUrl(baseUrl, withThumbs) ?? planEditorUrl(baseUrl, plan);
10336
+ }
10337
+
9847
10338
  // src/commands/edit-cmd.ts
9848
10339
  var PLAN_FILE = /^plan-([a-z]{2}(?:-[A-Z]{2})?)\.json$/;
9849
10340
  function findPlanFile(dir, lang) {
9850
10341
  if (lang) {
9851
- const exact = (0, import_node_path27.join)(dir, `plan-${lang}.json`);
9852
- return (0, import_node_fs21.existsSync)(exact) ? exact : null;
10342
+ const exact = (0, import_node_path28.join)(dir, `plan-${lang}.json`);
10343
+ return (0, import_node_fs22.existsSync)(exact) ? exact : null;
9853
10344
  }
9854
- if (!(0, import_node_fs21.existsSync)(dir)) return null;
9855
- const plans = (0, import_node_fs21.readdirSync)(dir).filter((f) => PLAN_FILE.test(f));
10345
+ if (!(0, import_node_fs22.existsSync)(dir)) return null;
10346
+ const plans = (0, import_node_fs22.readdirSync)(dir).filter((f) => PLAN_FILE.test(f));
9856
10347
  const preferred = plans.find((f) => f === "plan-en.json") ?? plans[0];
9857
- return preferred ? (0, import_node_path27.join)(dir, preferred) : null;
10348
+ return preferred ? (0, import_node_path28.join)(dir, preferred) : null;
9858
10349
  }
9859
10350
  async function editCommand(url, lang) {
9860
10351
  console.log();
@@ -9867,14 +10358,14 @@ async function editCommand(url, lang) {
9867
10358
  console.log();
9868
10359
  return;
9869
10360
  }
9870
- const parsed = parsePlanText((0, import_node_fs21.readFileSync)(planPath, "utf8"));
10361
+ const parsed = parsePlanText((0, import_node_fs22.readFileSync)(planPath, "utf8"));
9871
10362
  if (!parsed.ok) {
9872
10363
  console.log(import_chalk12.default.red(` ${planPath} cannot be opened: ${parsed.error}`));
9873
10364
  console.log();
9874
10365
  return;
9875
10366
  }
9876
10367
  const base = editorBaseUrl();
9877
- const editorUrl = planEditorUrl(base, parsed.plan);
10368
+ const editorUrl = await editorUrlWithThumbs(base, parsed.plan, dir);
9878
10369
  if (!editorUrl) {
9879
10370
  console.log(import_chalk12.default.yellow(" This plan is too long to carry in a link."));
9880
10371
  console.log(import_chalk12.default.dim(` Open ${base}/edit and load `) + import_chalk12.default.cyan(formatClickablePath(planPath)));
@@ -9890,14 +10381,14 @@ async function editCommand(url, lang) {
9890
10381
  }
9891
10382
 
9892
10383
  // src/commands/init-cmd.ts
9893
- var import_node_fs22 = require("fs");
10384
+ var import_node_fs23 = require("fs");
9894
10385
  var import_promises18 = require("fs/promises");
9895
- var import_node_path28 = require("path");
10386
+ var import_node_path29 = require("path");
9896
10387
  var import_chalk13 = __toESM(require("chalk"));
9897
10388
  init_lib();
9898
10389
  async function initCommand() {
9899
- const configPath = (0, import_node_path28.join)(process.cwd(), CONFIG_FILE);
9900
- if ((0, import_node_fs22.existsSync)(configPath)) {
10390
+ const configPath = (0, import_node_path29.join)(process.cwd(), CONFIG_FILE);
10391
+ if ((0, import_node_fs23.existsSync)(configPath)) {
9901
10392
  console.log(import_chalk13.default.yellow(` \u26A0 ${CONFIG_FILE} already exists`));
9902
10393
  return;
9903
10394
  }
@@ -9928,9 +10419,9 @@ async function initCommand() {
9928
10419
  console.log(import_chalk13.default.dim(` Edit it or use: qp config <key> <value>`));
9929
10420
  }
9930
10421
  async function configCommand(key, value) {
9931
- const configPath = (0, import_node_path28.join)(process.cwd(), CONFIG_FILE);
10422
+ const configPath = (0, import_node_path29.join)(process.cwd(), CONFIG_FILE);
9932
10423
  if (!key) {
9933
- if (!(0, import_node_fs22.existsSync)(configPath)) {
10424
+ if (!(0, import_node_fs23.existsSync)(configPath)) {
9934
10425
  console.log(import_chalk13.default.yellow(` No ${CONFIG_FILE} found. Run: qp init`));
9935
10426
  return;
9936
10427
  }
@@ -9945,7 +10436,7 @@ async function configCommand(key, value) {
9945
10436
  return;
9946
10437
  }
9947
10438
  let config = {};
9948
- if ((0, import_node_fs22.existsSync)(configPath)) {
10439
+ if ((0, import_node_fs23.existsSync)(configPath)) {
9949
10440
  try {
9950
10441
  config = JSON.parse(await (0, import_promises18.readFile)(configPath, "utf-8"));
9951
10442
  } catch {
@@ -10034,11 +10525,36 @@ async function logoutCommand() {
10034
10525
  }
10035
10526
 
10036
10527
  // src/commands/record-cmd.ts
10037
- var import_node_fs28 = require("fs");
10528
+ var import_node_fs29 = require("fs");
10038
10529
  var import_promises23 = require("fs/promises");
10039
- var import_node_path34 = require("path");
10530
+ var import_node_path35 = require("path");
10040
10531
  var import_chalk21 = __toESM(require("chalk"));
10041
10532
  init_mcp_context();
10533
+
10534
+ // src/lib/demo-registry.ts
10535
+ init_api_client();
10536
+ var TIMEOUT_MS2 = 4e3;
10537
+ async function registerDemoRun(email, run2) {
10538
+ if (!email || email === "anonymous") return false;
10539
+ const controller = new AbortController();
10540
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS2);
10541
+ try {
10542
+ const token = await getOrRefreshRelayToken(email);
10543
+ const response = await fetch(`${RELAY_BASE}/api/cli/demos`, {
10544
+ method: "POST",
10545
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
10546
+ body: JSON.stringify(run2),
10547
+ signal: controller.signal
10548
+ });
10549
+ return response.ok;
10550
+ } catch {
10551
+ return false;
10552
+ } finally {
10553
+ clearTimeout(timer);
10554
+ }
10555
+ }
10556
+
10557
+ // src/commands/record-cmd.ts
10042
10558
  init_lib();
10043
10559
 
10044
10560
  // src/commands/phases/auth-phase.ts
@@ -10055,13 +10571,14 @@ async function handleRegistrationGate() {
10055
10571
  console.log(import_chalk15.default.white(" Sign in to continue creating videos."));
10056
10572
  console.log();
10057
10573
  console.log(import_chalk15.default.dim(" Benefits of signing in:"));
10058
- console.log(import_chalk15.default.dim(" \u2022 20 more videos per month"));
10574
+ console.log(import_chalk15.default.dim(" \u2022 10 videos a day instead of 5 in total"));
10059
10575
  console.log(import_chalk15.default.dim(" \u2022 Keep your video history"));
10060
10576
  console.log(import_chalk15.default.dim(" \u2022 Upgrade to Pro anytime"));
10061
- let email = await browserLogin();
10062
- if (!email) {
10063
- console.log();
10064
- email = await promptInput("Email: ");
10577
+ console.log();
10578
+ console.log(import_chalk15.default.dim(" Type your email, or 'b' to sign in through a browser."));
10579
+ let email = await promptInput("Email: ");
10580
+ if (email.toLowerCase() === "b" || email.toLowerCase() === "browser") {
10581
+ email = await browserLogin() ?? "";
10065
10582
  }
10066
10583
  if (!email || !email.includes("@")) {
10067
10584
  console.log(import_chalk15.default.red(" Invalid email. Run qp again to try again."));
@@ -10106,9 +10623,12 @@ async function runAuthPhase(input) {
10106
10623
  const { isVerified: isVerified2 } = input;
10107
10624
  const mcpTool = currentMcpTool();
10108
10625
  if (mcpTool && !(userEmail && isVerified2)) {
10109
- throw new Error(
10110
- `"${mcpTool}" needs a verified QuickPeek account and this machine has none saved${userEmail ? ` (${userEmail} is registered but unverified)` : ""}. Sign in once from a terminal with \`qp login\` \u2014 it is a browser flow and cannot run inside an MCP call \u2014 then retry. Use check_account to confirm.`
10111
- );
10626
+ if (localVideoCount >= FREE_VIDEO_LIMIT) {
10627
+ throw new Error(
10628
+ `"${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.`
10629
+ );
10630
+ }
10631
+ return { userEmail: "anonymous", userTier: "free", localVideoCount, isVerified: false };
10112
10632
  }
10113
10633
  if (userEmail && isVerified2) {
10114
10634
  const tierTask = task("Checking subscription");
@@ -10133,7 +10653,7 @@ async function runAuthPhase(input) {
10133
10653
  console.log();
10134
10654
  const input2 = await promptInput(`New email or Enter to retry (${userEmail}): `);
10135
10655
  if (input2.toLowerCase() === "exit") {
10136
- gracefulExit();
10656
+ gracefulExit(1);
10137
10657
  return null;
10138
10658
  }
10139
10659
  if (input2?.includes("@")) {
@@ -10142,7 +10662,7 @@ async function runAuthPhase(input) {
10142
10662
  const result = await registerFreeUser(newEmail);
10143
10663
  if (!result.success) {
10144
10664
  registerTask.fail(result.error || "Registration failed");
10145
- gracefulExit();
10665
+ gracefulExit(1);
10146
10666
  return null;
10147
10667
  }
10148
10668
  if (result.alreadyVerified) {
@@ -10157,7 +10677,7 @@ async function runAuthPhase(input) {
10157
10677
  console.log(import_chalk16.default.cyan(` \u{1F4E7} Verification sent to: ${newEmail}`));
10158
10678
  console.log(import_chalk16.default.dim(" Click the link in the email, then run qp again."));
10159
10679
  console.log();
10160
- gracefulExit();
10680
+ gracefulExit(1);
10161
10681
  return null;
10162
10682
  }
10163
10683
  } else {
@@ -10173,7 +10693,7 @@ async function runAuthPhase(input) {
10173
10693
  console.log();
10174
10694
  console.log(import_chalk16.default.cyan(` Register/upgrade at: ${getUpgradeUrl(userEmail)}`));
10175
10695
  console.log();
10176
- gracefulExit();
10696
+ gracefulExit(1);
10177
10697
  return null;
10178
10698
  }
10179
10699
  }
@@ -10181,7 +10701,7 @@ async function runAuthPhase(input) {
10181
10701
  } else if (localVideoCount >= FREE_VIDEO_LIMIT) {
10182
10702
  const registration = await handleRegistrationGate();
10183
10703
  if (!registration) {
10184
- gracefulExit();
10704
+ gracefulExit(1);
10185
10705
  return null;
10186
10706
  }
10187
10707
  userEmail = registration.email;
@@ -10191,7 +10711,7 @@ async function runAuthPhase(input) {
10191
10711
  localVideoCount
10192
10712
  });
10193
10713
  if (!registration.verified) {
10194
- gracefulExit();
10714
+ gracefulExit(1);
10195
10715
  return null;
10196
10716
  }
10197
10717
  await displayUsageStatus(userEmail, userTier, localVideoCount);
@@ -10199,8 +10719,17 @@ async function runAuthPhase(input) {
10199
10719
  const remaining2 = FREE_VIDEO_LIMIT - localVideoCount;
10200
10720
  console.log();
10201
10721
  console.log(import_chalk16.default.cyan(` \u{1F3AC} Video ${localVideoCount + 1}/${FREE_VIDEO_LIMIT} free`));
10202
- console.log(import_chalk16.default.dim(` ${remaining2 - 1} more after this \u2022 Sign up for unlimited`));
10203
- userEmail = await setupUserEmail();
10722
+ console.log(import_chalk16.default.dim(` ${remaining2 - 1} more after this \u2022 Sign up for 10 a day`));
10723
+ const enteredEmail = await setupUserEmail();
10724
+ if (!enteredEmail) {
10725
+ console.log();
10726
+ console.log(import_chalk16.default.yellow(" No email, so no video was recorded."));
10727
+ console.log(import_chalk16.default.white(" Next: run `qp login` to sign in, then run qp again."));
10728
+ console.log();
10729
+ gracefulExit(1);
10730
+ return null;
10731
+ }
10732
+ userEmail = enteredEmail;
10204
10733
  let registeredVerified = false;
10205
10734
  try {
10206
10735
  const reg = await registerFreeUser(userEmail);
@@ -10218,9 +10747,9 @@ async function runAuthPhase(input) {
10218
10747
  }
10219
10748
 
10220
10749
  // src/commands/phases/plan-phase.ts
10221
- var import_node_fs25 = require("fs");
10750
+ var import_node_fs26 = require("fs");
10222
10751
  var import_promises20 = require("fs/promises");
10223
- var import_node_path31 = require("path");
10752
+ var import_node_path32 = require("path");
10224
10753
  var import_chalk18 = __toESM(require("chalk"));
10225
10754
 
10226
10755
  // src/ai/fix.ts
@@ -10376,15 +10905,15 @@ Return ONLY JSON: {"description": "...", "captions": ["...", ...]}`;
10376
10905
  }
10377
10906
 
10378
10907
  // src/commands/phases/plan-cards.ts
10379
- var import_node_fs24 = require("fs");
10380
- var import_node_path30 = require("path");
10908
+ var import_node_fs25 = require("fs");
10909
+ var import_node_path31 = require("path");
10381
10910
  var import_chalk17 = __toESM(require("chalk"));
10382
10911
  init_render_outro();
10383
10912
 
10384
10913
  // src/lib/og-card.ts
10385
- var import_node_fs23 = require("fs");
10914
+ var import_node_fs24 = require("fs");
10386
10915
  var import_promises19 = require("fs/promises");
10387
- var import_node_path29 = require("path");
10916
+ var import_node_path30 = require("path");
10388
10917
  init_image_kind();
10389
10918
  var PORTRAIT_PATHS = ["/opengraph-image/portrait", "/opengraph-image-portrait", "/og/portrait"];
10390
10919
  async function fetchImage(url) {
@@ -10464,7 +10993,7 @@ async function fetchOgCard(siteUrl, dir, preferPortrait) {
10464
10993
  for (const candidate of candidates) {
10465
10994
  const buf = await fetchImage(candidate);
10466
10995
  if (buf) {
10467
- const path = (0, import_node_path29.join)(dir, "og-card.png");
10996
+ const path = (0, import_node_path30.join)(dir, "og-card.png");
10468
10997
  await (0, import_promises19.writeFile)(path, buf);
10469
10998
  return path;
10470
10999
  }
@@ -10537,7 +11066,7 @@ async function saveFirstUsableIcon(hrefs, siteUrl, dir) {
10537
11066
  if (!buf) continue;
10538
11067
  const kind = sniffImageKind(buf);
10539
11068
  if (!kind) continue;
10540
- const path = (0, import_node_path29.join)(dir, `app-icon.${kind === "jpeg" ? "jpg" : kind}`);
11069
+ const path = (0, import_node_path30.join)(dir, `app-icon.${kind === "jpeg" ? "jpg" : kind}`);
10541
11070
  await (0, import_promises19.writeFile)(path, buf);
10542
11071
  if (kind === "png") {
10543
11072
  const size = pngSize(path);
@@ -10551,10 +11080,10 @@ async function saveFirstUsableIcon(hrefs, siteUrl, dir) {
10551
11080
  }
10552
11081
  function existingIcon(dir) {
10553
11082
  for (const ext of ["png", "svg", "jpg", "webp", "gif"]) {
10554
- const path = (0, import_node_path29.join)(dir, `app-icon.${ext}`);
11083
+ const path = (0, import_node_path30.join)(dir, `app-icon.${ext}`);
10555
11084
  try {
10556
- if (!(0, import_node_fs23.existsSync)(path)) continue;
10557
- if (!sniffImageKind((0, import_node_fs23.readFileSync)(path))) continue;
11085
+ if (!(0, import_node_fs24.existsSync)(path)) continue;
11086
+ if (!sniffImageKind((0, import_node_fs24.readFileSync)(path))) continue;
10558
11087
  return path;
10559
11088
  } catch {
10560
11089
  }
@@ -10563,7 +11092,7 @@ function existingIcon(dir) {
10563
11092
  }
10564
11093
  function pngSize(path) {
10565
11094
  try {
10566
- const buf = (0, import_node_fs23.readFileSync)(path);
11095
+ const buf = (0, import_node_fs24.readFileSync)(path);
10567
11096
  const head = buf.subarray(0, 33);
10568
11097
  if (head.length >= 24 && head.readUInt32BE(12) === 1229472850) {
10569
11098
  return { width: head.readUInt32BE(16), height: head.readUInt32BE(20) };
@@ -10642,8 +11171,8 @@ async function useOgCardAsIntro(plan, url, config, fresh = false) {
10642
11171
  }
10643
11172
  const rewritable = first.action === "wait" && !first.target;
10644
11173
  const portrait = (config.video.height ?? 0) > (config.video.width ?? 0);
10645
- const existing = (0, import_node_path30.join)(config.output.dir, "og-card.png");
10646
- const fetched = !fresh && (0, import_node_fs24.existsSync)(existing) ? existing : await fetchOgCard(url, config.output.dir, portrait);
11174
+ const existing = (0, import_node_path31.join)(config.output.dir, "og-card.png");
11175
+ const fetched = !fresh && (0, import_node_fs25.existsSync)(existing) ? existing : await fetchOgCard(url, config.output.dir, portrait);
10647
11176
  const ogPath = fetched && coversFrame(fetched, config) ? fetched : null;
10648
11177
  const cardPath = ogPath ?? await renderGeneratedIntroCard(url, config);
10649
11178
  if (!cardPath) {
@@ -10733,30 +11262,20 @@ function fitFor(cardPath, config) {
10733
11262
  return Math.abs(size.width / size.height - frame) < 0.06 ? "stretch" : "cover";
10734
11263
  }
10735
11264
  function resolveCardPath(value, config) {
10736
- return (0, import_node_path30.isAbsolute)(value) ? value : (0, import_node_path30.join)(config.output.dir, value.replace(/^\.\//, ""));
11265
+ return (0, import_node_path31.isAbsolute)(value) ? value : (0, import_node_path31.join)(config.output.dir, value.replace(/^\.\//, ""));
10737
11266
  }
10738
11267
  var DEFAULT_INTRO_CARD_SECONDS = 2.5;
10739
11268
  function packageAsset(relative) {
10740
11269
  let dir = getScriptDir();
10741
11270
  for (let up = 0; up < 6; up++) {
10742
- const candidate = (0, import_node_path30.join)(dir, relative);
10743
- if ((0, import_node_fs24.existsSync)(candidate)) return candidate;
10744
- dir = (0, import_node_path30.join)(dir, "..");
10745
- }
10746
- return void 0;
10747
- }
10748
- function siblingAsset(relative) {
10749
- let dir = (0, import_node_path30.join)(getScriptDir(), "..");
10750
- for (let up = 0; up < 7; up++) {
10751
- const candidate = (0, import_node_path30.join)(dir, relative);
10752
- if ((0, import_node_fs24.existsSync)(candidate)) return candidate;
10753
- dir = (0, import_node_path30.join)(dir, "..");
11271
+ const candidate = (0, import_node_path31.join)(dir, relative);
11272
+ if ((0, import_node_fs25.existsSync)(candidate)) return candidate;
11273
+ dir = (0, import_node_path31.join)(dir, "..");
10754
11274
  }
10755
11275
  return void 0;
10756
11276
  }
10757
11277
  var CREDIT_TOOLS = [
10758
- { name: "QuickPeek", role: "Video generated by", icon: () => packageAsset("imgs/qp.png") },
10759
- { name: "Vidlet", role: "Video edited by", icon: () => siblingAsset("VidLet/res/icons/vidlet-512.png") }
11278
+ { name: "QuickPeek", role: "Video generated by", icon: () => packageAsset("imgs/qp.png") }
10760
11279
  ];
10761
11280
  function oneTagline(text) {
10762
11281
  if (!text) return void 0;
@@ -10848,7 +11367,7 @@ async function runPlanPhase(params) {
10848
11367
  const extensionPath = config.browser?.extension;
10849
11368
  let existingPlan = null;
10850
11369
  let existingPlanPath = null;
10851
- if ((0, import_node_fs25.existsSync)(planPath)) {
11370
+ if ((0, import_node_fs26.existsSync)(planPath)) {
10852
11371
  try {
10853
11372
  const parsed = JSON.parse(await (0, import_promises20.readFile)(planPath, "utf-8"));
10854
11373
  if (parsed.url && parsed.steps?.length > 0) {
@@ -10860,10 +11379,10 @@ async function runPlanPhase(params) {
10860
11379
  }
10861
11380
  if (!existingPlan) {
10862
11381
  try {
10863
- const files = (0, import_node_fs25.readdirSync)(config.output.dir);
10864
- const planFiles = files.filter((f) => /^plan-[a-z]{2}(-[A-Z]{2})?\.json$/.test(f) && f !== (0, import_node_path31.basename)(planPath));
11382
+ const files = (0, import_node_fs26.readdirSync)(config.output.dir);
11383
+ const planFiles = files.filter((f) => /^plan-[a-z]{2}(-[A-Z]{2})?\.json$/.test(f) && f !== (0, import_node_path32.basename)(planPath));
10865
11384
  for (const file of planFiles) {
10866
- const filePath = (0, import_node_path31.join)(config.output.dir, file);
11385
+ const filePath = (0, import_node_path32.join)(config.output.dir, file);
10867
11386
  try {
10868
11387
  const parsed = JSON.parse(await (0, import_promises20.readFile)(filePath, "utf-8"));
10869
11388
  if (parsed.url && parsed.steps?.length > 0) {
@@ -10878,7 +11397,22 @@ async function runPlanPhase(params) {
10878
11397
  }
10879
11398
  }
10880
11399
  const normalizeUrl4 = (u) => u.replace(/\/+$/, "").toLowerCase();
10881
- const urlMatches = existingPlan && normalizeUrl4(existingPlan.url) === normalizeUrl4(url);
11400
+ const isBareOrigin = (u) => {
11401
+ try {
11402
+ const parsed = new URL(u);
11403
+ return parsed.pathname === "/" && !parsed.search && !parsed.hash;
11404
+ } catch {
11405
+ return false;
11406
+ }
11407
+ };
11408
+ const sameOrigin = (a, b) => {
11409
+ try {
11410
+ return new URL(a).origin === new URL(b).origin;
11411
+ } catch {
11412
+ return false;
11413
+ }
11414
+ };
11415
+ const urlMatches = existingPlan && (normalizeUrl4(existingPlan.url) === normalizeUrl4(url) || isBareOrigin(existingPlan.url) && sameOrigin(existingPlan.url, url));
10882
11416
  if (existingPlan && !urlMatches && isAutoYes()) {
10883
11417
  console.log();
10884
11418
  console.log(import_chalk18.default.dim(` Existing plan is for ${existingPlan.url}, not ${url}, regenerating.`));
@@ -11022,7 +11556,7 @@ async function runPlanPhase(params) {
11022
11556
  const planLang = plan.lang || "en";
11023
11557
  if (planLang !== targetLang) {
11024
11558
  let useExistingTranslation = false;
11025
- if ((0, import_node_fs25.existsSync)(planPath) && existingPlanPath !== planPath) {
11559
+ if ((0, import_node_fs26.existsSync)(planPath) && existingPlanPath !== planPath) {
11026
11560
  try {
11027
11561
  const translatedPlan = JSON.parse(await (0, import_promises20.readFile)(planPath, "utf-8"));
11028
11562
  if (translatedPlan.steps?.length === plan.steps.length && translatedPlan.lang === targetLang) {
@@ -11242,31 +11776,31 @@ async function ensureCliSignIn(url, opts) {
11242
11776
  }
11243
11777
 
11244
11778
  // src/commands/utils/chrome-extension.ts
11245
- var import_node_fs26 = require("fs");
11779
+ var import_node_fs27 = require("fs");
11246
11780
  var import_node_os7 = require("os");
11247
- var import_node_path32 = require("path");
11781
+ var import_node_path33 = require("path");
11248
11782
  function resolveChromeExtension(extensionId) {
11249
11783
  const platform = process.platform;
11250
11784
  const possibleDirs = [];
11251
11785
  const getChromeExtDirs = (userDataPath) => {
11252
11786
  const dirs = [];
11253
11787
  try {
11254
- const profiles = (0, import_node_fs26.readdirSync)(userDataPath).filter((p) => p === "Default" || p.startsWith("Profile "));
11788
+ const profiles = (0, import_node_fs27.readdirSync)(userDataPath).filter((p) => p === "Default" || p.startsWith("Profile "));
11255
11789
  for (const profile of profiles) {
11256
- dirs.push((0, import_node_path32.join)(userDataPath, profile, "Extensions"));
11790
+ dirs.push((0, import_node_path33.join)(userDataPath, profile, "Extensions"));
11257
11791
  }
11258
11792
  } catch {
11259
- dirs.push((0, import_node_path32.join)(userDataPath, "Default", "Extensions"));
11793
+ dirs.push((0, import_node_path33.join)(userDataPath, "Default", "Extensions"));
11260
11794
  }
11261
11795
  return dirs;
11262
11796
  };
11263
11797
  if (platform === "win32") {
11264
- possibleDirs.push(...getChromeExtDirs((0, import_node_path32.join)((0, import_node_os7.homedir)(), "AppData", "Local", "Google", "Chrome", "User Data")));
11798
+ possibleDirs.push(...getChromeExtDirs((0, import_node_path33.join)((0, import_node_os7.homedir)(), "AppData", "Local", "Google", "Chrome", "User Data")));
11265
11799
  } else if (platform === "darwin") {
11266
- possibleDirs.push(...getChromeExtDirs((0, import_node_path32.join)((0, import_node_os7.homedir)(), "Library", "Application Support", "Google", "Chrome")));
11800
+ possibleDirs.push(...getChromeExtDirs((0, import_node_path33.join)((0, import_node_os7.homedir)(), "Library", "Application Support", "Google", "Chrome")));
11267
11801
  } else {
11268
11802
  try {
11269
- const windowsUsers = (0, import_node_fs26.readdirSync)("/mnt/c/Users").filter(
11803
+ const windowsUsers = (0, import_node_fs27.readdirSync)("/mnt/c/Users").filter(
11270
11804
  (u) => !["All Users", "Default", "Default User", "Public", "desktop.ini"].includes(u)
11271
11805
  );
11272
11806
  for (const user of windowsUsers) {
@@ -11274,22 +11808,22 @@ function resolveChromeExtension(extensionId) {
11274
11808
  }
11275
11809
  } catch {
11276
11810
  }
11277
- possibleDirs.push(...getChromeExtDirs((0, import_node_path32.join)((0, import_node_os7.homedir)(), ".config", "google-chrome")));
11811
+ possibleDirs.push(...getChromeExtDirs((0, import_node_path33.join)((0, import_node_os7.homedir)(), ".config", "google-chrome")));
11278
11812
  }
11279
11813
  for (const chromeExtDir of possibleDirs) {
11280
- const extDir = (0, import_node_path32.join)(chromeExtDir, extensionId);
11281
- if (!(0, import_node_fs26.existsSync)(extDir)) continue;
11814
+ const extDir = (0, import_node_path33.join)(chromeExtDir, extensionId);
11815
+ if (!(0, import_node_fs27.existsSync)(extDir)) continue;
11282
11816
  try {
11283
- const versions = (0, import_node_fs26.readdirSync)(extDir).filter((f) => !f.startsWith("."));
11817
+ const versions = (0, import_node_fs27.readdirSync)(extDir).filter((f) => !f.startsWith("."));
11284
11818
  if (versions.length === 0) continue;
11285
11819
  const latestVersion = versions.sort().pop();
11286
- const extPath = (0, import_node_path32.join)(extDir, latestVersion);
11820
+ const extPath = (0, import_node_path33.join)(extDir, latestVersion);
11287
11821
  let name = extensionId;
11288
11822
  let popupPath = "popup.html";
11289
- const manifestPath = (0, import_node_path32.join)(extPath, "manifest.json");
11290
- if ((0, import_node_fs26.existsSync)(manifestPath)) {
11823
+ const manifestPath = (0, import_node_path33.join)(extPath, "manifest.json");
11824
+ if ((0, import_node_fs27.existsSync)(manifestPath)) {
11291
11825
  try {
11292
- const manifest = JSON.parse((0, import_node_fs26.readFileSync)(manifestPath, "utf-8"));
11826
+ const manifest = JSON.parse((0, import_node_fs27.readFileSync)(manifestPath, "utf-8"));
11293
11827
  if (manifest.name && !manifest.name.startsWith("__MSG_")) {
11294
11828
  name = manifest.name;
11295
11829
  }
@@ -11310,9 +11844,9 @@ function resolveChromeExtension(extensionId) {
11310
11844
 
11311
11845
  // src/commands/utils/plan-review.ts
11312
11846
  var import_node_crypto2 = require("crypto");
11313
- var import_node_fs27 = require("fs");
11847
+ var import_node_fs28 = require("fs");
11314
11848
  var import_promises22 = require("fs/promises");
11315
- var import_node_path33 = require("path");
11849
+ var import_node_path34 = require("path");
11316
11850
  var REVIEW_FILE = "plan-review.json";
11317
11851
  async function hashPlanFile(planPath) {
11318
11852
  try {
@@ -11322,8 +11856,8 @@ async function hashPlanFile(planPath) {
11322
11856
  }
11323
11857
  }
11324
11858
  async function loadPlanReview(outputDir) {
11325
- const path = (0, import_node_path33.join)(outputDir, REVIEW_FILE);
11326
- if (!(0, import_node_fs27.existsSync)(path)) return {};
11859
+ const path = (0, import_node_path34.join)(outputDir, REVIEW_FILE);
11860
+ if (!(0, import_node_fs28.existsSync)(path)) return {};
11327
11861
  try {
11328
11862
  return JSON.parse(await (0, import_promises22.readFile)(path, "utf-8"));
11329
11863
  } catch {
@@ -11331,7 +11865,7 @@ async function loadPlanReview(outputDir) {
11331
11865
  }
11332
11866
  }
11333
11867
  async function savePlanReview(outputDir, review) {
11334
- await (0, import_promises22.writeFile)((0, import_node_path33.join)(outputDir, REVIEW_FILE), JSON.stringify(review, null, 2));
11868
+ await (0, import_promises22.writeFile)((0, import_node_path34.join)(outputDir, REVIEW_FILE), JSON.stringify(review, null, 2));
11335
11869
  }
11336
11870
 
11337
11871
  // src/commands/record-cmd.ts
@@ -11393,7 +11927,7 @@ async function recordCommand(url, options = {}) {
11393
11927
  url = startUrl;
11394
11928
  }
11395
11929
  }
11396
- config.output.dir = chromeExtension ? (0, import_node_path34.join)(runRoot(), `${chromeExtension.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-demo`) : getOutputDirFromUrl(url);
11930
+ config.output.dir = chromeExtension ? (0, import_node_path35.join)(runRoot(), `${chromeExtension.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-demo`) : getOutputDirFromUrl(url);
11397
11931
  const userData = await loadUserData();
11398
11932
  const authResult = await runAuthPhase({
11399
11933
  userEmail: userData.email,
@@ -11404,7 +11938,7 @@ async function recordCommand(url, options = {}) {
11404
11938
  if (!authResult) return;
11405
11939
  const { userEmail, userTier, localVideoCount } = authResult;
11406
11940
  if (configCreated) {
11407
- const configPath = (0, import_node_path34.join)(runRoot(), CONFIG_FILE);
11941
+ const configPath = (0, import_node_path35.join)(runRoot(), CONFIG_FILE);
11408
11942
  console.log();
11409
11943
  console.log(import_chalk21.default.green(` \u2714 Created ${CONFIG_FILE}`));
11410
11944
  console.log(import_chalk21.default.dim(` ${configPath}`));
@@ -11425,17 +11959,17 @@ async function recordCommand(url, options = {}) {
11425
11959
  }
11426
11960
  if (langOverride) config.lang = langOverride;
11427
11961
  if (config.music?.path) {
11428
- const musicPath = (0, import_node_path34.isAbsolute)(config.music.path) ? config.music.path : (0, import_node_path34.resolve)(config.output.dir, config.music.path);
11429
- if (!(0, import_node_fs28.existsSync)(musicPath)) {
11962
+ const musicPath = (0, import_node_path35.isAbsolute)(config.music.path) ? config.music.path : (0, import_node_path35.resolve)(config.output.dir, config.music.path);
11963
+ if (!(0, import_node_fs29.existsSync)(musicPath)) {
11430
11964
  console.log();
11431
11965
  console.log(import_chalk21.default.yellow(` \u26A0 Music file not found: ${config.music.path}`));
11432
- console.log(import_chalk21.default.dim(` Config: ${(0, import_node_path34.join)(runRoot(), CONFIG_FILE)}`));
11966
+ console.log(import_chalk21.default.dim(` Config: ${(0, import_node_path35.join)(runRoot(), CONFIG_FILE)}`));
11433
11967
  console.log();
11434
11968
  const proceed = await confirm(`${INDENT2}Continue without background music?`);
11435
11969
  if (!proceed) {
11436
11970
  console.log(import_chalk21.default.yellow(`
11437
11971
  Edit music.path in config and run again.`));
11438
- console.log(import_chalk21.default.dim(` ${(0, import_node_path34.join)(runRoot(), CONFIG_FILE)}
11972
+ console.log(import_chalk21.default.dim(` ${(0, import_node_path35.join)(runRoot(), CONFIG_FILE)}
11439
11973
  `));
11440
11974
  process.exit(0);
11441
11975
  }
@@ -11443,9 +11977,9 @@ async function recordCommand(url, options = {}) {
11443
11977
  }
11444
11978
  }
11445
11979
  const captions = resolveCaptions(await loadCaptions(), config.video);
11446
- const workDir = await prepareWorkDir((0, import_node_path34.basename)(config.output.dir));
11980
+ const workDir = await prepareWorkDir((0, import_node_path35.basename)(config.output.dir));
11447
11981
  const targetLang = config.lang || "en";
11448
- const planPath = (0, import_node_path34.join)(config.output.dir, `plan-${targetLang}.json`);
11982
+ const planPath = (0, import_node_path35.join)(config.output.dir, `plan-${targetLang}.json`);
11449
11983
  const isExtensionUrl = url.startsWith("chrome-extension://");
11450
11984
  await (0, import_promises23.mkdir)(config.output.dir, { recursive: true });
11451
11985
  await setLogFilePath(config.output.dir);
@@ -11536,6 +12070,13 @@ async function recordCommand(url, options = {}) {
11536
12070
  userTier,
11537
12071
  userEmail
11538
12072
  });
12073
+ await registerDemoRun(userEmail, {
12074
+ source: currentMcpTool2() ? "mcp" : "cli",
12075
+ targetUrl: validatedPlan.url || url,
12076
+ localPath: finalPath,
12077
+ title: validatedPlan.title,
12078
+ ...config.lang ? { language: config.lang } : {}
12079
+ });
11539
12080
  if (!userEmail || userEmail === "anonymous") {
11540
12081
  const newCount = localVideoCount + 1;
11541
12082
  await saveUserData({ localVideoCount: newCount });
@@ -11562,7 +12103,7 @@ async function recordCommand(url, options = {}) {
11562
12103
 
11563
12104
  // src/commands/script-cmd.ts
11564
12105
  var import_promises24 = require("fs/promises");
11565
- var import_node_path35 = require("path");
12106
+ var import_node_path36 = require("path");
11566
12107
  var import_chalk22 = __toESM(require("chalk"));
11567
12108
  init_lib();
11568
12109
  async function reusablePlan(planPath, lines) {
@@ -11694,7 +12235,7 @@ async function scriptCommand(url, scriptPath, options = {}) {
11694
12235
  );
11695
12236
  console.log(import_chalk22.default.dim(` Raise plan.maxSteps in quickpeek.config.json to silence this, or split the script.`));
11696
12237
  }
11697
- const planPath = (0, import_node_path35.join)(config.output.dir, `plan-${lang ?? "en"}.json`);
12238
+ const planPath = (0, import_node_path36.join)(config.output.dir, `plan-${lang ?? "en"}.json`);
11698
12239
  const appTitle = new URL(url).hostname.replace(/^www\./, "");
11699
12240
  let plan = await reusablePlan(planPath, lines);
11700
12241
  if (plan) {
@@ -11904,7 +12445,7 @@ function parseArgs(args) {
11904
12445
  i++;
11905
12446
  } else if (arg2 === "--skip-voice" || arg2 === "-sv") {
11906
12447
  skipVoice = true;
11907
- } else if (arg2 === "--draft" || arg2 === "-d") {
12448
+ } else if (arg2 === "--draft" || arg2 === "-dr") {
11908
12449
  draft = true;
11909
12450
  } else if (arg2 === "--yes" || arg2 === "-y") {
11910
12451
  yes = true;