@spark-apps/quickpeek 1.2.0 → 1.2.2

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
@@ -39,7 +39,7 @@ function getErrorMessage(err) {
39
39
  return err instanceof Error ? err.message : String(err);
40
40
  }
41
41
  function toTitleCase(text) {
42
- return text.replace(/\w+/g, (word, index) => {
42
+ return text.replace(/\w+(?:'\w+)*/g, (word, index) => {
43
43
  if (index === 0) {
44
44
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
45
45
  }
@@ -347,8 +347,39 @@ function getMediaDuration(filePath) {
347
347
  proc.on("error", reject);
348
348
  });
349
349
  }
350
+ function getAudioCodec(filePath) {
351
+ return new Promise((resolve3) => {
352
+ const args = [
353
+ "-v",
354
+ "error",
355
+ "-select_streams",
356
+ "a:0",
357
+ "-show_entries",
358
+ "stream=codec_name",
359
+ "-of",
360
+ "default=noprint_wrappers=1:nokey=1",
361
+ filePath
362
+ ];
363
+ const proc = spawnFFmpeg("ffprobe", args);
364
+ let stdout = "";
365
+ proc.stdout.on("data", (data) => {
366
+ stdout += data;
367
+ });
368
+ proc.on("close", (code) => resolve3(code === 0 ? stdout.trim() : ""));
369
+ proc.on("error", () => resolve3(""));
370
+ });
371
+ }
372
+ async function isHonestWav(filePath) {
373
+ const codec = await getAudioCodec(filePath);
374
+ return codec.startsWith("pcm_");
375
+ }
376
+ function buildConcatArgs(listPath, output) {
377
+ const isWav = output.toLowerCase().endsWith(".wav");
378
+ const codec = isWav ? ["-c:a", "pcm_s16le"] : ["-c", "copy"];
379
+ return ["-y", "-f", "concat", "-safe", "0", "-i", listPath, ...codec, output];
380
+ }
350
381
  function concatMedia(listPath, output) {
351
- return runFFmpeg(["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", output], "ffmpeg concat failed");
382
+ return runFFmpeg(buildConcatArgs(listPath, output), "ffmpeg concat failed");
352
383
  }
353
384
  function generateSilence(outputPath, durationMs) {
354
385
  return runFFmpeg(
@@ -775,13 +806,31 @@ var init_timing = __esm({
775
806
 
776
807
  // src/lib/api-client.ts
777
808
  init_utils();
809
+ var SERVER_BACKOFF_MS = [2e3, 4e3];
810
+ var CLI_BACKOFF_MS = [1e4, 3e4];
778
811
  var SPARK_AI_BASE = "https://sparkbrain.app";
779
812
  var SPARK_AI_CHAT = `${SPARK_AI_BASE}/api/chat`;
780
813
  var SPARK_AI_TOKEN = `${SPARK_AI_BASE}/api/models/token`;
781
814
  var RELAY_BASE = process.env.QUICKPEEK_API_BASE || "https://quickpeek.co";
782
815
  var RELAY_TOKEN_URL = `${RELAY_BASE}/api/cli/token`;
783
816
  var RELAY_CHAT_URL = `${RELAY_BASE}/api/cli/ai`;
784
- var API_KEY = process.env.SPARK_AI_KEY || "";
817
+ function apiKey() {
818
+ return process.env.SPARK_AI_KEY || "";
819
+ }
820
+ function hostOf(url) {
821
+ try {
822
+ return new URL(url).host;
823
+ } catch {
824
+ return "AI service";
825
+ }
826
+ }
827
+ function firstLine(text) {
828
+ return truncate((text ?? "").trim().split("\n")[0] ?? "", 200);
829
+ }
830
+ function truncate(text, max) {
831
+ const trimmed = (text ?? "").trim();
832
+ return trimmed.length > max ? `${trimmed.slice(0, max)}\u2026` : trimmed;
833
+ }
785
834
  var cachedDirectToken = null;
786
835
  var cachedRelayToken = null;
787
836
  async function getOrRefreshDirectToken() {
@@ -792,7 +841,7 @@ async function getOrRefreshDirectToken() {
792
841
  try {
793
842
  const response = await fetch(SPARK_AI_TOKEN, {
794
843
  method: "POST",
795
- headers: { "X-Api-Key": API_KEY }
844
+ headers: { "X-Api-Key": apiKey() }
796
845
  });
797
846
  if (!response.ok) {
798
847
  throw new Error(`Token exchange failed: ${response.status} ${response.statusText}`);
@@ -835,8 +884,10 @@ async function getOrRefreshRelayToken(email) {
835
884
  throw new Error(`Failed to get QuickPeek token: ${getErrorMessage(err)}`);
836
885
  }
837
886
  }
838
- async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier) {
839
- let lastRateLimitError = null;
887
+ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier, opts = {}) {
888
+ const { onRetry, backoffMs = SERVER_BACKOFF_MS } = opts;
889
+ let lastRetriableError = null;
890
+ const host = hostOf(chatUrl);
840
891
  for (let attempt = 1; attempt <= 3; attempt++) {
841
892
  try {
842
893
  const response = await fetch(chatUrl, {
@@ -852,36 +903,53 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier)
852
903
  ]
853
904
  })
854
905
  });
906
+ const bodyText = await response.text().catch(() => "");
855
907
  let data = {};
856
- const contentType = response.headers.get("content-type") ?? "";
857
- if (contentType.includes("application/json")) {
858
- data = await response.json();
908
+ try {
909
+ data = bodyText ? JSON.parse(bodyText) : {};
910
+ } catch {
859
911
  }
860
912
  if (!response.ok) {
861
- const error = data.error || `Spark AI error: ${response.status} ${response.statusText}`;
862
- if (response.status === 429 && attempt < 3) {
863
- const retryAfter = response.headers.get("Retry-After");
864
- const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : 1e3 * 2 ** (attempt - 1);
865
- lastRateLimitError = {
913
+ const upstream = data.error || firstLine(bodyText) || response.statusText || "no error body";
914
+ const error = `${host} ${response.status}: ${upstream}`;
915
+ const detail = truncate(bodyText, 800);
916
+ const isRetriable = response.status === 429 || response.status >= 500;
917
+ if (isRetriable && attempt < 3) {
918
+ const retryAfter2 = response.headers.get("Retry-After");
919
+ const delayMs = retryAfter2 ? parseInt(retryAfter2, 10) * 1e3 : response.status >= 500 ? backoffMs[attempt - 1] ?? backoffMs[backoffMs.length - 1] ?? 2e3 : 1e3 * 2 ** (attempt - 1);
920
+ onRetry?.({ attempt, status: response.status, delayMs, host });
921
+ lastRetriableError = {
866
922
  success: false,
867
923
  error,
868
- isRateLimit: true,
869
- ...retryAfter ? { retryAfterSeconds: parseInt(retryAfter, 10) } : {}
924
+ isRateLimit: response.status === 429,
925
+ status: response.status,
926
+ host,
927
+ ...detail ? { detail } : {},
928
+ ...retryAfter2 ? { retryAfterSeconds: parseInt(retryAfter2, 10) } : {}
870
929
  };
871
930
  await new Promise((resolve3) => setTimeout(resolve3, Math.min(delayMs, 3e4)));
872
931
  continue;
873
932
  }
933
+ const retryAfter = response.headers.get("Retry-After");
934
+ const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : Number.NaN;
874
935
  return {
875
936
  success: false,
876
937
  error,
877
938
  isRateLimit: response.status === 429,
878
- isAuthError: response.status === 401 || response.status === 403
939
+ isAuthError: response.status === 401 || response.status === 403,
940
+ status: response.status,
941
+ host,
942
+ ...detail ? { detail } : {},
943
+ ...Number.isFinite(retryAfterSeconds) ? { retryAfterSeconds } : {}
879
944
  };
880
945
  }
881
946
  if (!data.success || !data.response) {
882
947
  return {
883
948
  success: false,
884
- error: data.error || "No response from Spark AI"
949
+ error: `${host} 200 but no content: ${data.error || firstLine(bodyText) || "empty response"}`,
950
+ status: response.status,
951
+ host,
952
+ ...bodyText ? { detail: truncate(bodyText, 800) } : {}
885
953
  };
886
954
  }
887
955
  return {
@@ -895,15 +963,17 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier)
895
963
  }
896
964
  };
897
965
  } catch (err) {
898
- if (attempt === 3) throw err;
966
+ if (attempt === 3) {
967
+ return { success: false, error: `${host} unreachable: ${getErrorMessage(err)}`, host };
968
+ }
899
969
  const delayMs = 1e3 * 2 ** (attempt - 1);
900
970
  await new Promise((resolve3) => setTimeout(resolve3, delayMs));
901
971
  }
902
972
  }
903
- return lastRateLimitError || { success: false, error: "Request failed after retries" };
973
+ return lastRetriableError || { success: false, error: `${host}: request failed after retries`, host };
904
974
  }
905
- async function callAI(systemPrompt, userPrompt, tier = "free") {
906
- if (!API_KEY) {
975
+ async function callAI(systemPrompt, userPrompt, tier = "free", opts = {}) {
976
+ if (!apiKey()) {
907
977
  return {
908
978
  success: false,
909
979
  error: "AI service is not configured. Please report this issue.",
@@ -912,12 +982,12 @@ async function callAI(systemPrompt, userPrompt, tier = "free") {
912
982
  }
913
983
  try {
914
984
  const token = await getOrRefreshDirectToken();
915
- return await executeChat(SPARK_AI_CHAT, token, systemPrompt, userPrompt, tier);
985
+ return await executeChat(SPARK_AI_CHAT, token, systemPrompt, userPrompt, tier, opts);
916
986
  } catch (err) {
917
987
  return { success: false, error: `Spark AI error: ${getErrorMessage(err)}` };
918
988
  }
919
989
  }
920
- async function callAIViaRelay(systemPrompt, userPrompt, email, tier = "free") {
990
+ async function callAIViaRelay(systemPrompt, userPrompt, email, tier = "free", opts = {}) {
921
991
  if (!email) {
922
992
  return {
923
993
  success: false,
@@ -927,11 +997,11 @@ async function callAIViaRelay(systemPrompt, userPrompt, email, tier = "free") {
927
997
  }
928
998
  try {
929
999
  let token = await getOrRefreshRelayToken(email);
930
- let result = await executeChat(RELAY_CHAT_URL, token, systemPrompt, userPrompt, tier);
1000
+ let result = await executeChat(RELAY_CHAT_URL, token, systemPrompt, userPrompt, tier, opts);
931
1001
  if (!result.success && result.isAuthError) {
932
1002
  cachedRelayToken = null;
933
1003
  token = await getOrRefreshRelayToken(email);
934
- result = await executeChat(RELAY_CHAT_URL, token, systemPrompt, userPrompt, tier);
1004
+ result = await executeChat(RELAY_CHAT_URL, token, systemPrompt, userPrompt, tier, opts);
935
1005
  }
936
1006
  return result;
937
1007
  } catch (err) {
@@ -966,7 +1036,8 @@ function normalizeUrl(input) {
966
1036
 
967
1037
  // src/lib/SparkStripe-feature-gating.ts
968
1038
  var APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
969
- var BASE = "https://sparkstripe.com";
1039
+ var OWNER_SLUG = process.env.SPARK_OWNER_SLUG ?? "spark";
1040
+ var BASE = "https://sparkpay.dev";
970
1041
  var TIERS = {
971
1042
  price_1TADnPEf9opkuWid9NtqOYON: "pro",
972
1043
  price_1TADnPEf9opkuWidgbvvX9ma: "pro",
@@ -1009,7 +1080,7 @@ async function getStatusCached(email) {
1009
1080
  function getTier(d) {
1010
1081
  if (!d.subscription || !d.registered) return "free";
1011
1082
  if (d.subscription.status === "canceled" || d.subscription.status === "past_due") return "free";
1012
- return TIERS[d.subscription.price_id] ?? "free";
1083
+ return TIERS[d.subscription.price_id] ?? "pro";
1013
1084
  }
1014
1085
  var isPaid = (d) => getTier(d) !== "free";
1015
1086
  var hasAccess = (d, min) => (RANK[getTier(d)] ?? 0) >= (RANK[min] ?? 0);
@@ -1031,7 +1102,7 @@ async function isVerified(email) {
1031
1102
  }
1032
1103
  }
1033
1104
  function pricingUrl(opts) {
1034
- const u = new URL(`${BASE}/pricing/${APP_ID}`);
1105
+ const u = new URL(`${BASE}/${OWNER_SLUG}/${APP_ID}`);
1035
1106
  if (opts?.email) u.searchParams.set("email", opts.email);
1036
1107
  if (opts?.returnUrl) u.searchParams.set("return_url", opts.returnUrl);
1037
1108
  return u.toString();
@@ -1046,7 +1117,7 @@ function getUpgradeUrl(userEmail) {
1046
1117
  }
1047
1118
 
1048
1119
  // src/lib/config.ts
1049
- var VERSION = "1.1.1";
1120
+ var VERSION = "1.2.1";
1050
1121
  var CONFIG_FILE = "quickpeek.config.json";
1051
1122
  var DEFAULT_CONFIG = {
1052
1123
  plan: { maxSteps: 8 },
@@ -1191,6 +1262,9 @@ function shouldSkipLink(text, href, currentPath) {
1191
1262
  }
1192
1263
 
1193
1264
  // src/lib/crawl.ts
1265
+ function idSelector(id) {
1266
+ return /^[A-Za-z_-][\w-]*$/.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
1267
+ }
1194
1268
  function getActionForType(type) {
1195
1269
  switch (type) {
1196
1270
  case "button":
@@ -1213,20 +1287,29 @@ var noopTask = () => ({ succeed: () => {
1213
1287
  var CRAWL_CACHE_TTL_MS = 5 * 60 * 1e3;
1214
1288
  var CRAWL_CACHE_MAX_ENTRIES = 50;
1215
1289
  var crawlCache = /* @__PURE__ */ new Map();
1216
- async function crawlPage(url, task = noopTask, storageStatePath) {
1217
- const cached = crawlCache.get(url);
1290
+ async function crawlPage(url, task = noopTask, storageStatePath, viewport) {
1291
+ const cacheKey = viewport ? `${url}@${viewport.width}x${viewport.height}` : url;
1292
+ const cached = crawlCache.get(cacheKey);
1218
1293
  if (cached && Date.now() - cached.timestamp < CRAWL_CACHE_TTL_MS) {
1219
1294
  task("Using cached crawl").succeed(`Found ${cached.elements.length} interactive elements (cached)`);
1220
- return { title: cached.title, elements: [...cached.elements] };
1295
+ return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
1221
1296
  }
1222
1297
  const browserTask = task("Launching browser");
1223
1298
  const browser = await chromium.launch({ headless: true });
1224
- const context = await browser.newContext(storageStatePath ? { storageState: storageStatePath } : {});
1299
+ const context = await browser.newContext({
1300
+ ...storageStatePath ? { storageState: storageStatePath } : {},
1301
+ // Crawl at the size we will record at, or the measurements below describe
1302
+ // a page nobody will ever see.
1303
+ ...viewport ? { viewport } : {}
1304
+ });
1225
1305
  const page = await context.newPage();
1226
1306
  browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
1227
1307
  const crawlTask = task("Crawling page");
1228
1308
  await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
1229
1309
  const title = await page.title();
1310
+ const scrollable = await page.evaluate(
1311
+ "Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
1312
+ );
1230
1313
  const rawElements = await page.evaluate(`
1231
1314
  (() => {
1232
1315
  const items = [];
@@ -1316,9 +1399,9 @@ async function crawlPage(url, task = noopTask, storageStatePath) {
1316
1399
  } else if (el.isFile || el.type === "file") {
1317
1400
  selector = 'input[type="file"]';
1318
1401
  } else if (el.type === "range") {
1319
- selector = el.id ? `#${el.id}` : 'input[type="range"]';
1402
+ selector = el.id ? idSelector(el.id) : 'input[type="range"]';
1320
1403
  } else if (el.type === "checkbox") {
1321
- selector = el.id ? `#${el.id}` : 'input[type="checkbox"]';
1404
+ selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
1322
1405
  } else if (el.type === "button") {
1323
1406
  if (btnTexts[el.text] === 1) {
1324
1407
  selector = `text="${el.text}"`;
@@ -1328,7 +1411,7 @@ async function crawlPage(url, task = noopTask, storageStatePath) {
1328
1411
  } else if (el.type === "link") {
1329
1412
  selector = `text="${el.text}"`;
1330
1413
  } else if (el.id) {
1331
- selector = `#${el.id}`;
1414
+ selector = idSelector(el.id);
1332
1415
  } else if (el.name) {
1333
1416
  selector = `[name="${el.name}"]`;
1334
1417
  } else if (el.placeholder) {
@@ -1356,12 +1439,12 @@ async function crawlPage(url, task = noopTask, storageStatePath) {
1356
1439
  });
1357
1440
  await browser.close();
1358
1441
  crawlTask.succeed(`Found ${elements.length} interactive elements`);
1359
- crawlCache.set(url, { timestamp: Date.now(), title, elements: [...elements] });
1442
+ crawlCache.set(cacheKey, { timestamp: Date.now(), title, elements: [...elements], scrollable });
1360
1443
  if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
1361
1444
  const oldest = crawlCache.keys().next().value;
1362
1445
  if (oldest !== void 0) crawlCache.delete(oldest);
1363
1446
  }
1364
- return { title, elements };
1447
+ return { title, elements, scrollable };
1365
1448
  }
1366
1449
 
1367
1450
  // src/lib/cursor.ts
@@ -1370,6 +1453,108 @@ var CURSOR_PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAAEsBAMAAAB0
1370
1453
  // src/lib/index.ts
1371
1454
  init_ffmpeg2();
1372
1455
 
1456
+ // src/lib/json-repair.ts
1457
+ function repairUnicodeEscapes(content) {
1458
+ return content.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (match, hex) => {
1459
+ const code = Number.parseInt(hex, 16);
1460
+ return code <= 1114111 ? String.fromCodePoint(code) : match;
1461
+ }).replace(/\\u(?![0-9a-fA-F]{4})/g, "\\\\u");
1462
+ }
1463
+ var MAX_SALVAGE_ATTEMPTS = 500;
1464
+ var CLOSERS = { "{": "}", "[": "]" };
1465
+ function salvageTruncatedJson(content) {
1466
+ const stack = [];
1467
+ const cuts = [];
1468
+ let inString = false;
1469
+ let escaped = false;
1470
+ const record = (index, closesContainer) => cuts.push({ index, open: stack.join(""), closesContainer });
1471
+ for (let i = 0; i < content.length; i++) {
1472
+ const ch = content[i];
1473
+ if (inString) {
1474
+ if (escaped) escaped = false;
1475
+ else if (ch === "\\") escaped = true;
1476
+ else if (ch === '"') {
1477
+ inString = false;
1478
+ record(i + 1, false);
1479
+ }
1480
+ continue;
1481
+ }
1482
+ if (ch === '"') {
1483
+ inString = true;
1484
+ } else if (ch === "{" || ch === "[") {
1485
+ stack.push(ch);
1486
+ } else if (ch === "}" || ch === "]") {
1487
+ stack.pop();
1488
+ record(i + 1, true);
1489
+ } else if (ch === ",") {
1490
+ record(i, content[i - 1] === "}" || content[i - 1] === "]");
1491
+ }
1492
+ }
1493
+ const tryCut = (cut) => {
1494
+ const head = content.slice(0, cut.index).replace(/[\s,]+$/, "");
1495
+ if (head.endsWith(":")) return null;
1496
+ let closed = head;
1497
+ for (let d = cut.open.length - 1; d >= 0; d--) {
1498
+ closed += CLOSERS[cut.open[d]];
1499
+ }
1500
+ try {
1501
+ JSON.parse(closed);
1502
+ return closed;
1503
+ } catch {
1504
+ return null;
1505
+ }
1506
+ };
1507
+ const attempts = Math.min(cuts.length, MAX_SALVAGE_ATTEMPTS);
1508
+ for (const containerOnly of [true, false]) {
1509
+ for (let n = 0; n < attempts; n++) {
1510
+ const cut = cuts[cuts.length - 1 - n];
1511
+ if (containerOnly && !cut.closesContainer) continue;
1512
+ const salvaged = tryCut(cut);
1513
+ if (salvaged !== null) return salvaged;
1514
+ }
1515
+ }
1516
+ return null;
1517
+ }
1518
+ function extractJsonCandidates(content) {
1519
+ let text = content.trim();
1520
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)(?:```|$)/);
1521
+ if (fence?.[1]?.trim()) text = fence[1].trim();
1522
+ const start = text.search(/[{[]/);
1523
+ if (start === -1) return [];
1524
+ const closer = text[start] === "{" ? "}" : "]";
1525
+ const end = text.lastIndexOf(closer);
1526
+ const candidates = [text.slice(start)];
1527
+ if (end > start && end < text.length - 1) candidates.unshift(text.slice(start, end + 1));
1528
+ return candidates;
1529
+ }
1530
+ function parseAIJson(content) {
1531
+ const candidates = extractJsonCandidates(content);
1532
+ if (candidates.length === 0) return null;
1533
+ for (const text of candidates) {
1534
+ try {
1535
+ return { value: JSON.parse(text), repaired: false };
1536
+ } catch {
1537
+ }
1538
+ }
1539
+ for (const text of candidates) {
1540
+ try {
1541
+ return { value: JSON.parse(repairUnicodeEscapes(text)), repaired: true };
1542
+ } catch {
1543
+ }
1544
+ }
1545
+ for (const text of candidates) {
1546
+ for (const variant of [repairUnicodeEscapes(text), text]) {
1547
+ const salvaged = salvageTruncatedJson(variant);
1548
+ if (salvaged === null) continue;
1549
+ try {
1550
+ return { value: JSON.parse(salvaged), repaired: true };
1551
+ } catch {
1552
+ }
1553
+ }
1554
+ }
1555
+ return null;
1556
+ }
1557
+
1373
1558
  // src/lib/prompts.ts
1374
1559
  init_utils();
1375
1560
  function buildSystemPrompt(maxSteps) {
@@ -1424,7 +1609,8 @@ EXAMPLES:
1424
1609
  \u2022 File upload: "target": "input[type=\\"file\\"]"
1425
1610
  \u2022 Slider: "target": "input[type=\\"range\\"]"`;
1426
1611
  }
1427
- function buildUserPrompt(url, title, elements, langName, description, maxSteps) {
1612
+ var MIN_USEFUL_SCROLL_PX = 200;
1613
+ function buildUserPrompt(url, title, elements, langName, description, maxSteps, scrollable) {
1428
1614
  const lang = langName ? `
1429
1615
  Language: Write ALL captions in ${langName}.` : "";
1430
1616
  const purpose = description ? `
@@ -1462,8 +1648,13 @@ ${selectables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`
1462
1648
  ${checkables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`);
1463
1649
  }
1464
1650
  const elementSummary = elementDict.length > 0 ? elementDict.join("\n\n") : "(no interactive elements found)";
1651
+ const cannotScroll = scrollable !== void 0 && scrollable < MIN_USEFUL_SCROLL_PX;
1652
+ const scrolling = cannotScroll ? `
1653
+
1654
+ SCROLLING: this page has only ${scrollable}px of scroll travel at the recording size, so it does not scroll.` : "";
1655
+ const scrollRule = cannotScroll ? "\n\u2022 NEVER use the scroll action: this page does not scroll, so the video would freeze. Use wait and click steps only" : "";
1465
1656
  return `URL: ${url}
1466
- Title: ${title}${purpose}
1657
+ Title: ${title}${purpose}${scrolling}
1467
1658
 
1468
1659
  AVAILABLE ELEMENTS (grouped by action):
1469
1660
  ${elementSummary}
@@ -1473,7 +1664,7 @@ REQUIREMENTS:
1473
1664
  \u2022 Complete ONE user journey: input \u2192 process \u2192 output
1474
1665
  \u2022 For CLICK: use simplified text target like "extract frame" or "download"
1475
1666
  \u2022 For UPLOAD/DRAG/TYPE: use the CSS selector shown above
1476
- \u2022 For buttons that appear after actions: use partial text like "extract" or "download"${lang}
1667
+ \u2022 For buttons that appear after actions: use partial text like "extract" or "download"${scrollRule}${lang}
1477
1668
 
1478
1669
  Return JSON:
1479
1670
  {
@@ -1552,7 +1743,7 @@ function generateASSHeader(width, height, font, size, primaryColor, secondaryCol
1552
1743
  async function registerFreeUser(email) {
1553
1744
  const APP_ID2 = process.env.SPARK_APP_ID ?? "quickpeek";
1554
1745
  try {
1555
- const response = await fetch(`https://sparkstripe.com/api/register-free`, {
1746
+ const response = await fetch(`https://sparkpay.dev/api/register-free`, {
1556
1747
  method: "POST",
1557
1748
  headers: { "Content-Type": "application/json" },
1558
1749
  body: JSON.stringify({ email, app_id: APP_ID2, app_name: "QuickPeek" })
@@ -1636,11 +1827,27 @@ init_helpers();
1636
1827
  // src/lib/tts/synthesize.ts
1637
1828
  init_helpers();
1638
1829
  import { exec } from "child_process";
1639
- import { writeFile as writeFile2 } from "fs/promises";
1830
+ import { createWriteStream } from "fs";
1831
+ import { stat, unlink as unlink2, writeFile as writeFile2 } from "fs/promises";
1640
1832
  import { join as join4, resolve as resolve2 } from "path";
1833
+ import { pipeline } from "stream/promises";
1641
1834
  import { promisify } from "util";
1642
1835
  var execAsync = promisify(exec);
1836
+ var PYTHON_ATTEMPTS = 2;
1643
1837
  async function synthesizeSpeech(text, outputPath, voice) {
1838
+ let pythonMsg = "";
1839
+ for (let attempt = 0; attempt < PYTHON_ATTEMPTS; attempt++) {
1840
+ try {
1841
+ await synthesizeWithPython(text, outputPath, voice);
1842
+ return;
1843
+ } catch (err) {
1844
+ pythonMsg = err instanceof Error ? err.message : String(err);
1845
+ if (attempt < PYTHON_ATTEMPTS - 1) await new Promise((r) => setTimeout(r, 500));
1846
+ }
1847
+ }
1848
+ await synthesizeWithNode(text, outputPath, voice, pythonMsg);
1849
+ }
1850
+ async function synthesizeWithPython(text, outputPath, voice) {
1644
1851
  const tempScriptPath = join4(resolve2(outputPath, ".."), `tts-${Date.now()}.py`);
1645
1852
  const wslScriptPath = isWindows ? toWSLPathForTTS(resolve2(tempScriptPath)) : tempScriptPath;
1646
1853
  const wslOutputPath = isWindows ? toWSLPathForTTS(resolve2(outputPath)) : outputPath;
@@ -1661,28 +1868,30 @@ asyncio.run(main())
1661
1868
  await writeFile2(tempScriptPath, pythonScript);
1662
1869
  const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
1663
1870
  await execAsync(pythonCmd, { timeout: 3e4 });
1664
- await import("fs/promises").then((fs) => fs.unlink(tempScriptPath).catch(() => {
1665
- }));
1666
- return;
1667
- } catch (pythonErr) {
1668
- await import("fs/promises").then((fs) => fs.unlink(tempScriptPath).catch(() => {
1669
- }));
1670
- const pythonMsg = pythonErr instanceof Error ? pythonErr.message : String(pythonErr);
1671
- const maxRetries = 2;
1672
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
1673
- try {
1674
- const { MsEdgeTTS, OUTPUT_FORMAT } = await import("msedge-tts");
1675
- const tts = new MsEdgeTTS();
1676
- await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3);
1677
- await tts.toFile(outputPath, text);
1678
- return;
1679
- } catch (nodeErr) {
1680
- if (attempt === maxRetries) {
1681
- const nodeMsg = nodeErr instanceof Error ? nodeErr.message : String(nodeErr);
1682
- throw new Error(`TTS failed (Python: ${pythonMsg.slice(0, 100)}, Node: ${nodeMsg.slice(0, 100)})`);
1683
- }
1684
- await new Promise((r) => setTimeout(r, 500));
1871
+ } finally {
1872
+ await unlink2(tempScriptPath).catch(() => {
1873
+ });
1874
+ }
1875
+ }
1876
+ async function synthesizeWithNode(text, outputPath, voice, pythonMsg, maxRetries = 2) {
1877
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1878
+ try {
1879
+ const { MsEdgeTTS, OUTPUT_FORMAT } = await import("msedge-tts");
1880
+ const tts = new MsEdgeTTS();
1881
+ await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3);
1882
+ const { audioStream } = tts.toStream(text);
1883
+ await pipeline(audioStream, createWriteStream(outputPath));
1884
+ const { size } = await stat(outputPath);
1885
+ if (size === 0) throw new Error("No audio data received");
1886
+ return;
1887
+ } catch (nodeErr) {
1888
+ await unlink2(outputPath).catch(() => {
1889
+ });
1890
+ if (attempt === maxRetries) {
1891
+ const nodeMsg = nodeErr instanceof Error ? nodeErr.message : String(nodeErr);
1892
+ throw new Error(`TTS failed (Python: ${pythonMsg.slice(0, 100)}, Node: ${nodeMsg.slice(0, 100)})`);
1685
1893
  }
1894
+ await new Promise((r) => setTimeout(r, 500));
1686
1895
  }
1687
1896
  }
1688
1897
  }
@@ -1794,6 +2003,7 @@ init_timing();
1794
2003
  init_utils();
1795
2004
  init_watermark();
1796
2005
  export {
2006
+ CLI_BACKOFF_MS,
1797
2007
  CONFIG_FILE,
1798
2008
  CURSOR_CSS,
1799
2009
  CURSOR_PNG,
@@ -1804,9 +2014,11 @@ export {
1804
2014
  FEATURES,
1805
2015
  INTERACTIVE_SELECTORS,
1806
2016
  WIDE_VOICES as LANGUAGE_VOICES,
2017
+ SERVER_BACKOFF_MS,
1807
2018
  VERSION,
1808
2019
  alignVoiceoverToRecording,
1809
2020
  applyTransitions,
2021
+ buildConcatArgs,
1810
2022
  buildKeepSegments,
1811
2023
  buildStepTransitions,
1812
2024
  buildSystemPrompt,
@@ -1820,9 +2032,11 @@ export {
1820
2032
  concatVideoWithOutro,
1821
2033
  crawlPage,
1822
2034
  estimateWordTimings,
2035
+ extractJsonCandidates,
1823
2036
  generateASSHeader,
1824
2037
  generateSilence,
1825
2038
  generateVoiceover,
2039
+ getAudioCodec,
1826
2040
  getErrorMessage,
1827
2041
  getLanguageName,
1828
2042
  getMediaDuration,
@@ -1833,20 +2047,25 @@ export {
1833
2047
  hasAccess,
1834
2048
  hasTrialRemaining,
1835
2049
  hexToAss as hexToASS,
2050
+ idSelector,
1836
2051
  isCanceling,
1837
2052
  isExcludedLink,
2053
+ isHonestWav,
1838
2054
  isPaid,
1839
2055
  isVerified,
1840
2056
  loadExistingVoiceover,
1841
2057
  loadVoiceoverMeta,
1842
2058
  mapTimestampsAfterCompression,
1843
2059
  normalizeUrl,
2060
+ parseAIJson,
1844
2061
  parseAIPlanResponse,
1845
2062
  parsePartial,
1846
2063
  pricingUrl,
1847
2064
  registerFreeUser,
2065
+ repairUnicodeEscapes,
1848
2066
  resolveVoice,
1849
2067
  rgbaToHex,
2068
+ salvageTruncatedJson,
1850
2069
  shouldSkipLink,
1851
2070
  shouldWatermark,
1852
2071
  showPaywall,