@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/index.mjs CHANGED
@@ -434,8 +434,13 @@ async function composeVideo(opts) {
434
434
  musicPath,
435
435
  musicVolume = 0.3,
436
436
  musicTargetLufs = MUSIC_BED_LUFS,
437
- musicFadeIn = 2,
438
- musicFadeOut = 2,
437
+ // Sized for the outro window: the song has 5.5s, so a 2s fade at each end
438
+ // left 1.5s of music anyone could actually hear. Up fast, hold, out slow.
439
+ musicFadeIn = 1,
440
+ musicFadeOut = 1.5,
441
+ tempo = 1,
442
+ musicStartSecs = 0,
443
+ musicDurationSecs = 0,
439
444
  width,
440
445
  height,
441
446
  fps,
@@ -496,7 +501,19 @@ async function composeVideo(opts) {
496
501
  const clipAudioLabel = opts.hasClipAudio ? "[0:a]" : "";
497
502
  const hasVoiceover = !!voiceoverPath;
498
503
  const hasMusic = !!musicPath;
499
- const musicFadeOutStart = Math.max(0, effectiveDuration - musicFadeOut);
504
+ const bedStart = Math.max(0, Math.min(musicStartSecs, Math.max(0, effectiveDuration - 0.5)));
505
+ const bedLength = Math.max(0.5, effectiveDuration - bedStart);
506
+ const bedDelay = bedStart > 0 ? `adelay=${Math.round(bedStart * 1e3)}|${Math.round(bedStart * 1e3)},` : "";
507
+ const bedTempo = tempo > 1 ? 1 / tempo : 1;
508
+ const bedSlow = bedTempo === 1 ? "" : `atempo=${bedTempo.toFixed(6)},`;
509
+ const bedSource = bedLength * bedTempo;
510
+ const bedSeek = musicDurationSecs > bedSource + 0.05 ? musicDurationSecs - bedSource : 0;
511
+ const bedTrimIn = bedSeek > 0 ? `atrim=start=${bedSeek.toFixed(3)},asetpts=PTS-STARTPTS,` : "";
512
+ const bedFadeOut = bedSeek > 0 ? 0 : musicFadeOut * tempo;
513
+ const bedLoop = bedSeek > 0 ? "" : "aloop=loop=-1:size=2e+09,";
514
+ const bedFadeIn = musicFadeIn * tempo;
515
+ const musicFadeOutStart = Math.max(0, bedLength - bedFadeOut);
516
+ const bedFadeOutFilter = bedFadeOut > 0 ? `afade=t=out:st=${musicFadeOutStart}:d=${bedFadeOut},` : "";
500
517
  const vfStr = [...vf, ...encoder.filter ? [encoder.filter] : []].join(",");
501
518
  if (hasVoiceover && hasMusic) {
502
519
  const mixInputs = clipAudioLabel ? 3 : 2;
@@ -512,7 +529,7 @@ async function composeVideo(opts) {
512
529
  // card, which is the one place the bed is the only thing playing.
513
530
  `[voicekey0]apad=whole_dur=${effectiveDuration}[voicekey]`,
514
531
  // Music: normalised to a known level, trimmed, then faded.
515
- `[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]`,
532
+ `[2:a]${bedTrimIn}${bedLoop}${bedSlow}atrim=0:${bedLength},loudnorm=I=${musicTargetLufs}:TP=-9:LRA=11,volume=${musicVolume},afade=t=in:st=0:d=${bedFadeIn},${bedFadeOutFilter}${bedDelay}apad=whole_dur=${effectiveDuration}[bed]`,
516
533
  `[bed][voicekey]${DUCK}[music]`,
517
534
  // normalize=0, because amix's default divides every input by the number
518
535
  // of them: adding the bed used to quietly take 6 dB off the narration,
@@ -536,7 +553,7 @@ async function composeVideo(opts) {
536
553
  args.push(...encoder.output);
537
554
  args.push("-c:a", "aac", "-b:a", audioBitrate);
538
555
  } else if (hasMusic) {
539
- const musicFilter = `aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut},${MASTER_CHAIN}`;
556
+ const musicFilter = `${bedTrimIn}${bedLoop}${bedSlow}atrim=0:${bedLength},volume=${musicVolume},afade=t=in:st=0:d=${bedFadeIn},${bedFadeOutFilter}${bedDelay}apad=whole_dur=${effectiveDuration},${MASTER_CHAIN}`;
540
557
  args.push("-vf", vfStr);
541
558
  args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
542
559
  args.push(...encoder.output);
@@ -1846,6 +1863,108 @@ var init_timing = __esm({
1846
1863
  }
1847
1864
  });
1848
1865
 
1866
+ // src/lib/json-repair.ts
1867
+ function repairUnicodeEscapes(content) {
1868
+ return content.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (match, hex) => {
1869
+ const code = Number.parseInt(hex, 16);
1870
+ return code <= 1114111 ? String.fromCodePoint(code) : match;
1871
+ }).replace(/\\u(?![0-9a-fA-F]{4})/g, "\\\\u");
1872
+ }
1873
+ var MAX_SALVAGE_ATTEMPTS = 500;
1874
+ var CLOSERS = { "{": "}", "[": "]" };
1875
+ function salvageTruncatedJson(content) {
1876
+ const stack = [];
1877
+ const cuts = [];
1878
+ let inString = false;
1879
+ let escaped = false;
1880
+ const record = (index, closesContainer) => cuts.push({ index, open: stack.join(""), closesContainer });
1881
+ for (let i = 0; i < content.length; i++) {
1882
+ const ch = content[i];
1883
+ if (inString) {
1884
+ if (escaped) escaped = false;
1885
+ else if (ch === "\\") escaped = true;
1886
+ else if (ch === '"') {
1887
+ inString = false;
1888
+ record(i + 1, false);
1889
+ }
1890
+ continue;
1891
+ }
1892
+ if (ch === '"') {
1893
+ inString = true;
1894
+ } else if (ch === "{" || ch === "[") {
1895
+ stack.push(ch);
1896
+ } else if (ch === "}" || ch === "]") {
1897
+ stack.pop();
1898
+ record(i + 1, true);
1899
+ } else if (ch === ",") {
1900
+ record(i, content[i - 1] === "}" || content[i - 1] === "]");
1901
+ }
1902
+ }
1903
+ const tryCut = (cut) => {
1904
+ const head = content.slice(0, cut.index).replace(/[\s,]+$/, "");
1905
+ if (head.endsWith(":")) return null;
1906
+ let closed = head;
1907
+ for (let d = cut.open.length - 1; d >= 0; d--) {
1908
+ closed += CLOSERS[cut.open[d]];
1909
+ }
1910
+ try {
1911
+ JSON.parse(closed);
1912
+ return closed;
1913
+ } catch {
1914
+ return null;
1915
+ }
1916
+ };
1917
+ const attempts = Math.min(cuts.length, MAX_SALVAGE_ATTEMPTS);
1918
+ for (const containerOnly of [true, false]) {
1919
+ for (let n = 0; n < attempts; n++) {
1920
+ const cut = cuts[cuts.length - 1 - n];
1921
+ if (containerOnly && !cut.closesContainer) continue;
1922
+ const salvaged = tryCut(cut);
1923
+ if (salvaged !== null) return salvaged;
1924
+ }
1925
+ }
1926
+ return null;
1927
+ }
1928
+ function extractJsonCandidates(content) {
1929
+ let text = content.trim();
1930
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)(?:```|$)/);
1931
+ if (fence?.[1]?.trim()) text = fence[1].trim();
1932
+ const start = text.search(/[{[]/);
1933
+ if (start === -1) return [];
1934
+ const closer = text[start] === "{" ? "}" : "]";
1935
+ const end = text.lastIndexOf(closer);
1936
+ const candidates = [text.slice(start)];
1937
+ if (end > start && end < text.length - 1) candidates.unshift(text.slice(start, end + 1));
1938
+ return candidates;
1939
+ }
1940
+ function parseAIJson(content) {
1941
+ const candidates = extractJsonCandidates(content);
1942
+ if (candidates.length === 0) return null;
1943
+ for (const text of candidates) {
1944
+ try {
1945
+ return { value: JSON.parse(text), repaired: false };
1946
+ } catch {
1947
+ }
1948
+ }
1949
+ for (const text of candidates) {
1950
+ try {
1951
+ return { value: JSON.parse(repairUnicodeEscapes(text)), repaired: true };
1952
+ } catch {
1953
+ }
1954
+ }
1955
+ for (const text of candidates) {
1956
+ for (const variant of [repairUnicodeEscapes(text), text]) {
1957
+ const salvaged = salvageTruncatedJson(variant);
1958
+ if (salvaged === null) continue;
1959
+ try {
1960
+ return { value: JSON.parse(salvaged), repaired: true };
1961
+ } catch {
1962
+ }
1963
+ }
1964
+ }
1965
+ return null;
1966
+ }
1967
+
1849
1968
  // src/lib/api-client.ts
1850
1969
  init_utils();
1851
1970
  var PLAN_MAX_TOKENS = 1500;
@@ -2056,24 +2175,26 @@ async function callAIViaRelay(systemPrompt, userPrompt, email, tier = "free", op
2056
2175
  }
2057
2176
  }
2058
2177
  function parseAIPlanResponse(content) {
2059
- try {
2060
- let processedContent = content;
2061
- const codeBlockMatch = processedContent.match(/```(?:json)?\s*([\s\S]*?)```/);
2062
- if (codeBlockMatch?.[1]) {
2063
- processedContent = codeBlockMatch[1].trim();
2064
- }
2065
- const jsonMatch = processedContent.match(/\{[\s\S]*\}/);
2066
- if (!jsonMatch) {
2067
- return { success: false, error: "No valid JSON found in AI response" };
2068
- }
2069
- const parsed = JSON.parse(jsonMatch[0]);
2070
- return { success: true, plan: parsed };
2071
- } catch (err) {
2072
- return {
2073
- success: false,
2074
- error: `Failed to parse AI response: ${err instanceof Error ? err.message : String(err)}`
2075
- };
2178
+ const parsed = parseAIJson(content);
2179
+ if (!parsed) {
2180
+ return { success: false, error: "No valid JSON found in AI response" };
2076
2181
  }
2182
+ if (!Array.isArray(parsed.value.steps) || parsed.value.steps.length === 0) {
2183
+ return { success: false, error: "AI response has no steps" };
2184
+ }
2185
+ return { success: true, plan: { ...parsed.value, steps: parsed.value.steps } };
2186
+ }
2187
+ async function callAIForPlan(systemPrompt, userPrompt, tier = "free", opts = {}) {
2188
+ let lastParseError = "No valid JSON found in AI response";
2189
+ for (let attempt = 1; attempt <= 2; attempt++) {
2190
+ const aiResult = await callAI(systemPrompt, userPrompt, tier, opts);
2191
+ if (!aiResult.success) return aiResult;
2192
+ const parsed = parseAIPlanResponse(aiResult.content);
2193
+ if (parsed.success) return { success: true, plan: parsed.plan, limits: aiResult.limits };
2194
+ lastParseError = parsed.error;
2195
+ console.error(`Plan parse failed (attempt ${attempt}/2):`, parsed.error);
2196
+ }
2197
+ return { success: false, error: lastParseError, isParseError: true };
2077
2198
  }
2078
2199
  function normalizeUrl(input) {
2079
2200
  let url = input.trim();
@@ -2152,15 +2273,12 @@ function pricingUrl(opts) {
2152
2273
  }
2153
2274
 
2154
2275
  // src/lib/billing.ts
2155
- function shouldWatermark(tier, videoDurationSeconds) {
2156
- return tier === "free" && videoDurationSeconds > 20;
2157
- }
2158
2276
  function getUpgradeUrl(userEmail) {
2159
2277
  return pricingUrl(userEmail ? { email: userEmail } : void 0);
2160
2278
  }
2161
2279
 
2162
2280
  // src/lib/config.ts
2163
- var VERSION = "1.2.3";
2281
+ var VERSION = "1.2.6";
2164
2282
  var CONFIG_FILE = "quickpeek.config.json";
2165
2283
  var SIZE_PRESETS = {
2166
2284
  wide: { width: 1920, height: 1080, zoom: 1 },
@@ -2187,11 +2305,12 @@ var DEFAULT_CONFIG = {
2187
2305
  // ms
2188
2306
  transitions: "none",
2189
2307
  contrast: 0.25,
2190
- // A demo plays a touch faster than it was recorded, because a recording
2191
- // paced for the narration reads as slow next to anything else on the same
2192
- // feed. 1.2 is the most that still sounds like a person talking; set 1 in
2193
- // quickpeek.config.json for the raw pace.
2194
- tempo: 1.2,
2308
+ // The recording plays at the pace it was filmed at. A tempo above 1 is
2309
+ // multiplied by the narration speed in VIDEO_PROFILES and by the fit
2310
+ // pass's squeeze, and the three together read as frantic rather than
2311
+ // brisk. Speed belongs in one place, so it lives in audioSpeed; set a
2312
+ // tempo in quickpeek.config.json to push the picture on top of that.
2313
+ tempo: 1,
2195
2314
  stepDelay: 200
2196
2315
  // ms delay between steps
2197
2316
  },
@@ -2205,13 +2324,18 @@ var VIDEO_PROFILES = {
2205
2324
  // these bitrates - the encode is pure wall-clock tax on every run.
2206
2325
  wide: {
2207
2326
  video: { width: 1920, height: 1080, zoom: 1.5, fitLimitSecs: 0, maxSpeedup: 1, preset: "veryfast" },
2208
- audioSpeed: 1.6
2327
+ // A tutorial is watched to learn something, so the narrator is allowed to
2328
+ // be brisk but never hurried.
2329
+ audioSpeed: 1.15
2209
2330
  },
2210
2331
  short: {
2211
2332
  // fadeOut 0: the video ends on a card the viewer is meant to read,
2212
2333
  // and a fade takes it away mid-sentence.
2213
2334
  video: { width: 1080, height: 1920, zoom: 3, fitLimitSecs: 60, maxSpeedup: 1.7, preset: "veryfast", fadeOut: 0 },
2214
- audioSpeed: 1.8,
2335
+ // 1.8 was unwatchable: stacked on tempo 1.2 and the outro speedup it ran
2336
+ // the voice at better than twice its natural rate. A short wants energy,
2337
+ // which is the voice and the cutting, not the tape running fast.
2338
+ audioSpeed: 1,
2215
2339
  // A short lives or dies on energy. Ava is the warmest, most animated of
2216
2340
  // the free neural voices; Sonia reads a short like the shipping forecast.
2217
2341
  voice: "en-US-AvaMultilingualNeural"
@@ -2227,11 +2351,15 @@ function applyProfile(config, profile) {
2227
2351
  format: "mp3",
2228
2352
  bitrate: "192k",
2229
2353
  ...config.audio,
2230
- speed: config.audio?.speed ?? preset.audioSpeed,
2354
+ // outroOnly is on unless turned off, matching the video phase. The
2355
+ // speedup buys the song its window, so it applies to the profile's own
2356
+ // pace only: a speed somebody set by hand is the speed they wanted.
2357
+ speed: config.audio?.speed ?? preset.audioSpeed * (config.music?.outroOnly !== false ? OUTRO_SONG_SPEEDUP : 1),
2231
2358
  ...config.audio?.voice || !preset.voice ? {} : { voice: preset.voice }
2232
2359
  }
2233
2360
  };
2234
2361
  }
2362
+ var OUTRO_SONG_SPEEDUP = 1.13;
2235
2363
  var DEFAULT_CAPTIONS = {
2236
2364
  font: "Arial",
2237
2365
  outline: "#000000",
@@ -2287,8 +2415,24 @@ function defaultCaptionsFor(width, height) {
2287
2415
  };
2288
2416
  }
2289
2417
 
2290
- // src/lib/crawl.ts
2418
+ // src/browser/headless.ts
2291
2419
  import { chromium } from "playwright";
2420
+ var 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";
2421
+ async function launchHeadlessChromium(options = {}) {
2422
+ const opts = { ...options, headless: true };
2423
+ try {
2424
+ return await chromium.launch(opts);
2425
+ } catch (bundledError) {
2426
+ try {
2427
+ return await chromium.launch({ ...opts, channel: "chrome" });
2428
+ } catch {
2429
+ const detail = bundledError instanceof Error ? bundledError.message : String(bundledError);
2430
+ throw new Error(`${NO_BROWSER_MESSAGE}
2431
+
2432
+ Playwright reported: ${detail}`);
2433
+ }
2434
+ }
2435
+ }
2292
2436
 
2293
2437
  // src/lib/scraping.ts
2294
2438
  var EXCLUDED_LINK_PATTERNS = [
@@ -2409,6 +2553,7 @@ async function waitForDomStable(page, cap = SETTLE_CAP_MS) {
2409
2553
  var PLACEHOLDER_POLL_MS = 250;
2410
2554
  var PLACEHOLDER_CAP_MS = 8e3;
2411
2555
  var PLACEHOLDER_STATIC_POLLS = 4;
2556
+ var PLACEHOLDER_CLEAR_POLLS = 3;
2412
2557
  var PLACEHOLDER_SELECTOR = [
2413
2558
  '[aria-busy="true"]',
2414
2559
  '[role="progressbar"]',
@@ -2421,6 +2566,7 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
2421
2566
  const deadline = Date.now() + cap;
2422
2567
  let previous = -1;
2423
2568
  let unchanged = 0;
2569
+ let clear = 0;
2424
2570
  while (Date.now() < deadline) {
2425
2571
  const count = await page.evaluate((selector) => {
2426
2572
  let visible = 0;
@@ -2430,7 +2576,12 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
2430
2576
  }
2431
2577
  return visible;
2432
2578
  }, PLACEHOLDER_SELECTOR).catch(() => 0);
2433
- if (count === 0) return;
2579
+ if (count === 0) {
2580
+ if (++clear >= PLACEHOLDER_CLEAR_POLLS) return;
2581
+ await page.waitForTimeout(PLACEHOLDER_POLL_MS);
2582
+ continue;
2583
+ }
2584
+ clear = 0;
2434
2585
  if (count === previous) {
2435
2586
  if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
2436
2587
  } else {
@@ -2519,7 +2670,13 @@ async function crawlPage(url, task = noopTask, storageStatePath, viewport) {
2519
2670
  return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
2520
2671
  }
2521
2672
  const browserTask = task("Launching browser");
2522
- const browser = await chromium.launch({ headless: true });
2673
+ let browser;
2674
+ try {
2675
+ browser = await launchHeadlessChromium();
2676
+ } catch (err) {
2677
+ browserTask.succeed("Could not launch a browser");
2678
+ throw err;
2679
+ }
2523
2680
  try {
2524
2681
  const context = await browser.newContext({
2525
2682
  ...storageStatePath ? { storageState: storageStatePath } : {},
@@ -2702,108 +2859,6 @@ var CURSOR_PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAAEsBAMAAAB0
2702
2859
  // src/lib/index.ts
2703
2860
  init_ffmpeg2();
2704
2861
 
2705
- // src/lib/json-repair.ts
2706
- function repairUnicodeEscapes(content) {
2707
- return content.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (match, hex) => {
2708
- const code = Number.parseInt(hex, 16);
2709
- return code <= 1114111 ? String.fromCodePoint(code) : match;
2710
- }).replace(/\\u(?![0-9a-fA-F]{4})/g, "\\\\u");
2711
- }
2712
- var MAX_SALVAGE_ATTEMPTS = 500;
2713
- var CLOSERS = { "{": "}", "[": "]" };
2714
- function salvageTruncatedJson(content) {
2715
- const stack = [];
2716
- const cuts = [];
2717
- let inString = false;
2718
- let escaped = false;
2719
- const record = (index, closesContainer) => cuts.push({ index, open: stack.join(""), closesContainer });
2720
- for (let i = 0; i < content.length; i++) {
2721
- const ch = content[i];
2722
- if (inString) {
2723
- if (escaped) escaped = false;
2724
- else if (ch === "\\") escaped = true;
2725
- else if (ch === '"') {
2726
- inString = false;
2727
- record(i + 1, false);
2728
- }
2729
- continue;
2730
- }
2731
- if (ch === '"') {
2732
- inString = true;
2733
- } else if (ch === "{" || ch === "[") {
2734
- stack.push(ch);
2735
- } else if (ch === "}" || ch === "]") {
2736
- stack.pop();
2737
- record(i + 1, true);
2738
- } else if (ch === ",") {
2739
- record(i, content[i - 1] === "}" || content[i - 1] === "]");
2740
- }
2741
- }
2742
- const tryCut = (cut) => {
2743
- const head = content.slice(0, cut.index).replace(/[\s,]+$/, "");
2744
- if (head.endsWith(":")) return null;
2745
- let closed = head;
2746
- for (let d = cut.open.length - 1; d >= 0; d--) {
2747
- closed += CLOSERS[cut.open[d]];
2748
- }
2749
- try {
2750
- JSON.parse(closed);
2751
- return closed;
2752
- } catch {
2753
- return null;
2754
- }
2755
- };
2756
- const attempts = Math.min(cuts.length, MAX_SALVAGE_ATTEMPTS);
2757
- for (const containerOnly of [true, false]) {
2758
- for (let n = 0; n < attempts; n++) {
2759
- const cut = cuts[cuts.length - 1 - n];
2760
- if (containerOnly && !cut.closesContainer) continue;
2761
- const salvaged = tryCut(cut);
2762
- if (salvaged !== null) return salvaged;
2763
- }
2764
- }
2765
- return null;
2766
- }
2767
- function extractJsonCandidates(content) {
2768
- let text = content.trim();
2769
- const fence = text.match(/```(?:json)?\s*([\s\S]*?)(?:```|$)/);
2770
- if (fence?.[1]?.trim()) text = fence[1].trim();
2771
- const start = text.search(/[{[]/);
2772
- if (start === -1) return [];
2773
- const closer = text[start] === "{" ? "}" : "]";
2774
- const end = text.lastIndexOf(closer);
2775
- const candidates = [text.slice(start)];
2776
- if (end > start && end < text.length - 1) candidates.unshift(text.slice(start, end + 1));
2777
- return candidates;
2778
- }
2779
- function parseAIJson(content) {
2780
- const candidates = extractJsonCandidates(content);
2781
- if (candidates.length === 0) return null;
2782
- for (const text of candidates) {
2783
- try {
2784
- return { value: JSON.parse(text), repaired: false };
2785
- } catch {
2786
- }
2787
- }
2788
- for (const text of candidates) {
2789
- try {
2790
- return { value: JSON.parse(repairUnicodeEscapes(text)), repaired: true };
2791
- } catch {
2792
- }
2793
- }
2794
- for (const text of candidates) {
2795
- for (const variant of [repairUnicodeEscapes(text), text]) {
2796
- const salvaged = salvageTruncatedJson(variant);
2797
- if (salvaged === null) continue;
2798
- try {
2799
- return { value: JSON.parse(salvaged), repaired: true };
2800
- } catch {
2801
- }
2802
- }
2803
- }
2804
- return null;
2805
- }
2806
-
2807
2862
  // src/lib/prompts.ts
2808
2863
  init_utils();
2809
2864
  function buildSystemPrompt(maxSteps, profile) {
@@ -3162,6 +3217,9 @@ function renderHormoziAss(ctx) {
3162
3217
  return `${header}${lines.join("\n")}
3163
3218
  `;
3164
3219
  }
3220
+ function looksLikeAddress(token) {
3221
+ return /^(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?[.,!?]?$/i.test(token);
3222
+ }
3165
3223
  function renderShortsAss(ctx) {
3166
3224
  const { captions, width, height } = ctx;
3167
3225
  const font = resolveCaptionFont(captions.font === "Arial" ? "Arial Black" : captions.font);
@@ -3172,7 +3230,7 @@ function renderShortsAss(ctx) {
3172
3230
  const alignment = positionToAlignment(captions.position);
3173
3231
  const outline = Math.max(2, Math.round(fontSize / 22));
3174
3232
  const upper = true;
3175
- const caseOf = (t) => toCaptionCase(t, upper);
3233
+ const caseOf = (t) => looksLikeAddress(t) ? t : toCaptionCase(t, upper);
3176
3234
  const maxChars = fittingMaxChars(width, fontSize, SIDE_MARGIN, captions.maxChars, upper);
3177
3235
  const header = `[Script Info]
3178
3236
  Title: QuickPeek Demo
@@ -3472,6 +3530,8 @@ asyncio.run(main())
3472
3530
  await writeFile2(tempScriptPath, pythonScript);
3473
3531
  const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
3474
3532
  await execAsync(pythonCmd, { timeout: 3e4 });
3533
+ const written = await stat(outputPath).catch(() => null);
3534
+ if (!written?.size) throw new Error("edge-tts produced no audio");
3475
3535
  return await readWordsSidecar(outputPath);
3476
3536
  } finally {
3477
3537
  await unlink3(tempScriptPath).catch(() => {
@@ -3695,12 +3755,21 @@ function spellAcronyms(text) {
3695
3755
  (m) => m.split("").map((c) => LETTER_SOUNDS[c] ?? c).join(" ")
3696
3756
  );
3697
3757
  }
3758
+ var SPOKEN_TLD = {
3759
+ ai: "ay-eye"
3760
+ };
3698
3761
  function bindDomains(text) {
3699
- return text.replace(/\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi, "$1-dot-$2");
3762
+ 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(
3763
+ /\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi,
3764
+ (_m, host, tld) => `${host}-dot-${SPOKEN_TLD[tld.toLowerCase()] ?? tld}`
3765
+ );
3700
3766
  }
3701
3767
  function speakSymbols(text) {
3702
3768
  return text.replace(/\s*&\s*/g, " and ").replace(/[<>]/g, " ").replace(/\s{2,}/g, " ").trim();
3703
3769
  }
3770
+ function spokenForm(caption) {
3771
+ return speakNumbers(spellAcronyms(toSpokenForm(bindDomains(speakSymbols(caption)))));
3772
+ }
3704
3773
  async function generateVoiceover(steps, config, workDir, outputDir, logError, task, info) {
3705
3774
  resetVoiceEngine();
3706
3775
  const voice = resolveVoice2(config);
@@ -3747,7 +3816,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError, ta
3747
3816
  }
3748
3817
  const audioPath = join6(audioDir, `step-${stepNum}.mp3`);
3749
3818
  try {
3750
- const spokenText = speakNumbers(spellAcronyms(toSpokenForm(bindDomains(speakSymbols(step.caption)))));
3819
+ const spokenText = spokenForm(step.caption);
3751
3820
  const key = clipKey(spokenText, voice, ratePercent ?? "");
3752
3821
  const cacheDir = join6(outputDir, ".tts-clips");
3753
3822
  const cached = await reuseClip(cacheDir, key, audioPath);
@@ -3855,6 +3924,7 @@ export {
3855
3924
  NO_CLIP_OUTRO,
3856
3925
  PLAN_MAX_TOKENS,
3857
3926
  RATE_FACTOR,
3927
+ RELAY_BASE,
3858
3928
  SERVER_BACKOFF_MS,
3859
3929
  SIZE_PRESETS,
3860
3930
  TRIM_EDGE_SILENCE,
@@ -3879,6 +3949,7 @@ export {
3879
3949
  buildUserPrompt,
3880
3950
  buildWatermarkFilters,
3881
3951
  callAI,
3952
+ callAIForPlan,
3882
3953
  callAIViaRelay,
3883
3954
  canReuseVoiceover,
3884
3955
  captionCanvas,
@@ -3906,6 +3977,7 @@ export {
3906
3977
  getErrorMessage,
3907
3978
  getLanguageName,
3908
3979
  getMediaDuration,
3980
+ getOrRefreshRelayToken,
3909
3981
  getStatus,
3910
3982
  getStatusCached,
3911
3983
  getTierByEmail,
@@ -3949,12 +4021,12 @@ export {
3949
4021
  runPooled,
3950
4022
  salvageTruncatedJson,
3951
4023
  shouldSkipLink,
3952
- shouldWatermark,
3953
4024
  showPaywall,
3954
4025
  speakSymbols,
3955
4026
  speedUpMiddle,
3956
4027
  speedUpVideo,
3957
4028
  spliceVideos,
4029
+ spokenForm,
3958
4030
  spokenWordTimings,
3959
4031
  stripLongDashes,
3960
4032
  synthesizeSpeech,