@spark-apps/quickpeek 1.1.3 → 1.2.1

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
@@ -9,18 +9,12 @@ var __export = (target, all) => {
9
9
  };
10
10
 
11
11
  // src/lib/utils.ts
12
+ import { hexToAss } from "@spark-apps/video-kit";
13
+ import { windowsDrivePathToWsl } from "@spark-apps/video-kit";
12
14
  function getLanguageName(lang) {
13
15
  if (!lang || lang === "en") return null;
14
16
  return LANGUAGE_NAMES[lang] || lang.toUpperCase();
15
17
  }
16
- function hexToASS(hex, alpha = 0) {
17
- const clean = hex.replace("#", "");
18
- const r = clean.slice(0, 2);
19
- const g = clean.slice(2, 4);
20
- const b = clean.slice(4, 6);
21
- const a = alpha.toString(16).padStart(2, "0").toUpperCase();
22
- return `&H${a}${b}${g}${r}`.toUpperCase();
23
- }
24
18
  function rgbaToHex(color) {
25
19
  if (color.startsWith("#")) return color;
26
20
  const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
@@ -30,12 +24,6 @@ function rgbaToHex(color) {
30
24
  const b = parseInt(match[3], 10).toString(16).padStart(2, "0");
31
25
  return `#${r}${g}${b}`.toUpperCase();
32
26
  }
33
- function windowsPathToWSL(path) {
34
- const driveMatch = /^([A-Z]):[/\\]/i.exec(path);
35
- if (!driveMatch) return null;
36
- const drive = driveMatch[1].toLowerCase();
37
- return `/mnt/${drive}/${path.slice(3).replace(/\\/g, "/")}`;
38
- }
39
27
  function parsePartial(partial) {
40
28
  if (!partial) return {};
41
29
  const parts = partial.split("-");
@@ -51,7 +39,7 @@ function getErrorMessage(err) {
51
39
  return err instanceof Error ? err.message : String(err);
52
40
  }
53
41
  function toTitleCase(text) {
54
- return text.replace(/\w+/g, (word, index) => {
42
+ return text.replace(/\w+(?:'\w+)*/g, (word, index) => {
55
43
  if (index === 0) {
56
44
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
57
45
  }
@@ -147,10 +135,10 @@ var init_watermark = __esm({
147
135
  import { spawn } from "child_process";
148
136
  function toWSLPath(path) {
149
137
  if (!isWindowsRuntime) return path;
150
- return windowsPathToWSL(path) ?? path.replace(/\\/g, "/");
138
+ return windowsDrivePathToWsl(path) ?? path.replace(/\\/g, "/");
151
139
  }
152
140
  function convertArgToWSL(arg) {
153
- const wslPath = windowsPathToWSL(arg);
141
+ const wslPath = windowsDrivePathToWsl(arg);
154
142
  if (wslPath) return wslPath;
155
143
  if (arg.includes("\\")) return arg.replace(/\\/g, "/");
156
144
  return arg;
@@ -186,6 +174,7 @@ var init_spawn = __esm({
186
174
  });
187
175
 
188
176
  // src/lib/ffmpeg/compose.ts
177
+ import { MASTER_CHAIN } from "@spark-apps/video-kit";
189
178
  function composeVideo(opts) {
190
179
  return new Promise((resolve3, reject) => {
191
180
  const {
@@ -246,20 +235,24 @@ function composeVideo(opts) {
246
235
  `[1:a]apad=whole_dur=${voiceoverDuration},afade=t=in:st=0:d=${fadeIn}[voice]`,
247
236
  // Music: fade in and fade out
248
237
  `[2:a]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}[music]`,
249
- `[voice][music]amix=inputs=2:duration=longest[aout]`
238
+ // Normalise the MIX and hard-limit its true peak - ported from
239
+ // VidLet, where single-pass loudnorm alone measured +0.9 dBTP on
240
+ // sharp TTS consonants. -14 LUFS is the YouTube/IG/TikTok playback
241
+ // target, so the platform leaves the audio untouched.
242
+ `[voice][music]amix=inputs=2:duration=longest,${MASTER_CHAIN}[aout]`
250
243
  ].join(";");
251
244
  args.push("-vf", vfStr);
252
245
  args.push("-filter_complex", complexFilter, "-map", "0:v", "-map", "[aout]");
253
246
  args.push("-c:v", "libx264", "-preset", preset, "-crf", String(crf), "-pix_fmt", "yuv420p");
254
247
  args.push("-c:a", "aac", "-b:a", audioBitrate);
255
248
  } else if (hasVoiceover) {
256
- const af = `apad=whole_dur=${voiceoverDuration},afade=t=in:st=0:d=${fadeIn}`;
249
+ const af = `apad=whole_dur=${voiceoverDuration},afade=t=in:st=0:d=${fadeIn},${MASTER_CHAIN}`;
257
250
  args.push("-vf", vfStr);
258
251
  args.push("-map", "0:v", "-map", "1:a", "-af", af);
259
252
  args.push("-c:v", "libx264", "-preset", preset, "-crf", String(crf), "-pix_fmt", "yuv420p");
260
253
  args.push("-c:a", "aac", "-b:a", audioBitrate);
261
254
  } else if (hasMusic) {
262
- 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}`;
255
+ 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}`;
263
256
  args.push("-vf", vfStr);
264
257
  args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
265
258
  args.push("-c:v", "libx264", "-preset", preset, "-crf", String(crf), "-pix_fmt", "yuv420p");
@@ -531,14 +524,13 @@ function applyTransitions(opts) {
531
524
  filterParts.push(`[0:v]trim=start=${start.toFixed(3)}:end=${end.toFixed(3)},setpts=PTS-STARTPTS[v${i}]`);
532
525
  }
533
526
  let currentV = "v0";
527
+ let chainDuration = boundaries[1] - boundaries[0];
534
528
  for (let i = 0; i < sorted.length; i++) {
535
529
  const trans = sorted[i];
536
530
  const duration = trans.duration ?? 0.3;
537
531
  const nextIdx = i + 1;
538
- const segStart = boundaries[i];
539
- const segEnd = boundaries[i + 1];
540
- const segDuration = segEnd - segStart;
541
- const offset = Math.max(0, segDuration - duration);
532
+ const offset = Math.max(0, chainDuration - duration);
533
+ chainDuration += boundaries[nextIdx + 1] - boundaries[nextIdx] - duration;
542
534
  const xfadeType = trans.type === "fade" ? "fadeblack" : "fade";
543
535
  const outV = i === sorted.length - 1 ? "outv" : `xv${i}`;
544
536
  filterParts.push(
@@ -610,7 +602,7 @@ import { existsSync } from "fs";
610
602
  import { readFile, unlink, writeFile } from "fs/promises";
611
603
  import { isAbsolute, join, resolve } from "path";
612
604
  function toWSLPathForTTS(path) {
613
- return windowsPathToWSL(path) ?? path;
605
+ return windowsDrivePathToWsl(path) ?? path;
614
606
  }
615
607
  function estimateActionDuration(step) {
616
608
  if (step.action === "drag" && step.value === "around") {
@@ -677,7 +669,7 @@ async function saveVoiceoverMeta(outputDir, meta) {
677
669
  async function concatenateAudio(inputs, output, workDir) {
678
670
  const formatPath = (f) => {
679
671
  const resolved = resolve(f);
680
- return isWindows ? windowsPathToWSL(resolved) ?? resolved : resolved;
672
+ return isWindows ? windowsDrivePathToWsl(resolved) ?? resolved : resolved;
681
673
  };
682
674
  const listContent = inputs.map((f) => `file '${formatPath(f)}'`).join("\n");
683
675
  const listPath = join(workDir, "concat.txt");
@@ -786,23 +778,32 @@ init_utils();
786
778
  var SPARK_AI_BASE = "https://sparkbrain.app";
787
779
  var SPARK_AI_CHAT = `${SPARK_AI_BASE}/api/chat`;
788
780
  var SPARK_AI_TOKEN = `${SPARK_AI_BASE}/api/models/token`;
789
- var API_KEY = process.env.SPARK_AI_KEY || "";
790
- var cachedToken = null;
791
- async function getOrRefreshToken() {
781
+ var RELAY_BASE = process.env.QUICKPEEK_API_BASE || "https://quickpeek.co";
782
+ var RELAY_TOKEN_URL = `${RELAY_BASE}/api/cli/token`;
783
+ var RELAY_CHAT_URL = `${RELAY_BASE}/api/cli/ai`;
784
+ function apiKey() {
785
+ return process.env.SPARK_AI_KEY || "";
786
+ }
787
+ var cachedDirectToken = null;
788
+ var cachedRelayToken = null;
789
+ async function getOrRefreshDirectToken() {
792
790
  const now = Date.now();
793
- if (cachedToken && cachedToken.expiresAt > now + 3e5) {
794
- return cachedToken.token;
791
+ if (cachedDirectToken && cachedDirectToken.expiresAt > now + 3e5) {
792
+ return cachedDirectToken.token;
795
793
  }
796
794
  try {
797
795
  const response = await fetch(SPARK_AI_TOKEN, {
798
796
  method: "POST",
799
- headers: { "X-Api-Key": API_KEY }
797
+ headers: { "X-Api-Key": apiKey() }
800
798
  });
799
+ if (!response.ok) {
800
+ throw new Error(`Token exchange failed: ${response.status} ${response.statusText}`);
801
+ }
801
802
  const data = await response.json();
802
- if (!response.ok || !data.success || !data.token) {
803
- throw new Error(data.error || `Token exchange failed: ${response.status}`);
803
+ if (!data.success || !data.token) {
804
+ throw new Error(data.error || `Token exchange failed: unexpected response`);
804
805
  }
805
- cachedToken = {
806
+ cachedDirectToken = {
806
807
  token: data.token,
807
808
  expiresAt: data.expiresAt || now + 36e5
808
809
  };
@@ -811,77 +812,130 @@ async function getOrRefreshToken() {
811
812
  throw new Error(`Failed to get Spark AI token: ${getErrorMessage(err)}`);
812
813
  }
813
814
  }
814
- async function callAI(systemPrompt, userPrompt, tier = "free") {
815
- if (!API_KEY) {
816
- return {
817
- success: false,
818
- error: "AI service is not configured. Please report this issue.",
819
- isAuthError: true
820
- };
815
+ async function getOrRefreshRelayToken(email) {
816
+ const now = Date.now();
817
+ if (cachedRelayToken && cachedRelayToken.email === email && cachedRelayToken.expiresAt > now + 3e5) {
818
+ return cachedRelayToken.token;
821
819
  }
822
820
  try {
823
- const token = await getOrRefreshToken();
824
- let lastRateLimitError = null;
825
- for (let attempt = 1; attempt <= 3; attempt++) {
826
- try {
827
- const response = await fetch(SPARK_AI_CHAT, {
828
- method: "POST",
829
- headers: {
830
- "Content-Type": "application/json",
831
- Authorization: `Bearer ${token}`
832
- },
833
- body: JSON.stringify({
834
- messages: [
835
- { role: "system", content: systemPrompt },
836
- { role: "user", content: userPrompt }
837
- ]
838
- })
839
- });
840
- const data = await response.json();
841
- if (!response.ok) {
842
- const error = data.error || `Spark AI error: ${response.status}`;
843
- if (response.status === 429 && attempt < 3) {
844
- const retryAfter = response.headers.get("Retry-After");
845
- const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : 1e3 * Math.pow(2, attempt - 1);
846
- lastRateLimitError = {
847
- success: false,
848
- error,
849
- isRateLimit: true,
850
- ...retryAfter ? { retryAfterSeconds: parseInt(retryAfter, 10) } : {}
851
- };
852
- await new Promise((resolve3) => setTimeout(resolve3, Math.min(delayMs, 3e4)));
853
- continue;
854
- }
855
- return {
821
+ const response = await fetch(RELAY_TOKEN_URL, {
822
+ method: "POST",
823
+ headers: { "Content-Type": "application/json" },
824
+ body: JSON.stringify({ email })
825
+ });
826
+ const data = await response.json().catch(() => ({}));
827
+ if (!response.ok || !data.success || !data.token) {
828
+ throw new Error(data.error || `Token request failed: ${response.status} ${response.statusText}`);
829
+ }
830
+ cachedRelayToken = {
831
+ token: data.token,
832
+ expiresAt: data.expiresAt || now + 36e5,
833
+ email
834
+ };
835
+ return data.token;
836
+ } catch (err) {
837
+ throw new Error(`Failed to get QuickPeek token: ${getErrorMessage(err)}`);
838
+ }
839
+ }
840
+ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier) {
841
+ let lastRateLimitError = null;
842
+ for (let attempt = 1; attempt <= 3; attempt++) {
843
+ try {
844
+ const response = await fetch(chatUrl, {
845
+ method: "POST",
846
+ headers: {
847
+ "Content-Type": "application/json",
848
+ Authorization: `Bearer ${bearerToken}`
849
+ },
850
+ body: JSON.stringify({
851
+ messages: [
852
+ { role: "system", content: systemPrompt },
853
+ { role: "user", content: userPrompt }
854
+ ]
855
+ })
856
+ });
857
+ let data = {};
858
+ const contentType = response.headers.get("content-type") ?? "";
859
+ if (contentType.includes("application/json")) {
860
+ data = await response.json();
861
+ }
862
+ if (!response.ok) {
863
+ const error = data.error || `Spark AI error: ${response.status} ${response.statusText}`;
864
+ if (response.status === 429 && attempt < 3) {
865
+ const retryAfter = response.headers.get("Retry-After");
866
+ const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : 1e3 * 2 ** (attempt - 1);
867
+ lastRateLimitError = {
856
868
  success: false,
857
869
  error,
858
- isRateLimit: response.status === 429,
859
- isAuthError: response.status === 401 || response.status === 403
860
- };
861
- }
862
- if (!data.success || !data.response) {
863
- return {
864
- success: false,
865
- error: data.error || "No response from Spark AI"
870
+ isRateLimit: true,
871
+ ...retryAfter ? { retryAfterSeconds: parseInt(retryAfter, 10) } : {}
866
872
  };
873
+ await new Promise((resolve3) => setTimeout(resolve3, Math.min(delayMs, 3e4)));
874
+ continue;
867
875
  }
868
876
  return {
869
- success: true,
870
- content: data.response,
871
- limits: {
872
- used: data.limits?.used ?? 0,
873
- limit: data.limits?.limit ?? 100,
874
- tier,
875
- ...data.limits?.resetAt ? { resetAt: data.limits.resetAt } : {}
876
- }
877
+ success: false,
878
+ error,
879
+ isRateLimit: response.status === 429,
880
+ isAuthError: response.status === 401 || response.status === 403
881
+ };
882
+ }
883
+ if (!data.success || !data.response) {
884
+ return {
885
+ success: false,
886
+ error: data.error || "No response from Spark AI"
877
887
  };
878
- } catch (err) {
879
- if (attempt === 3) throw err;
880
- const delayMs = 1e3 * Math.pow(2, attempt - 1);
881
- await new Promise((resolve3) => setTimeout(resolve3, delayMs));
882
888
  }
889
+ return {
890
+ success: true,
891
+ content: data.response,
892
+ limits: {
893
+ used: data.limits?.used ?? 0,
894
+ limit: data.limits?.limit ?? 100,
895
+ tier,
896
+ ...data.limits?.resetAt ? { resetAt: data.limits.resetAt } : {}
897
+ }
898
+ };
899
+ } catch (err) {
900
+ if (attempt === 3) throw err;
901
+ const delayMs = 1e3 * 2 ** (attempt - 1);
902
+ await new Promise((resolve3) => setTimeout(resolve3, delayMs));
883
903
  }
884
- return lastRateLimitError || { success: false, error: "Request failed after retries" };
904
+ }
905
+ return lastRateLimitError || { success: false, error: "Request failed after retries" };
906
+ }
907
+ async function callAI(systemPrompt, userPrompt, tier = "free") {
908
+ if (!apiKey()) {
909
+ return {
910
+ success: false,
911
+ error: "AI service is not configured. Please report this issue.",
912
+ isAuthError: true
913
+ };
914
+ }
915
+ try {
916
+ const token = await getOrRefreshDirectToken();
917
+ return await executeChat(SPARK_AI_CHAT, token, systemPrompt, userPrompt, tier);
918
+ } catch (err) {
919
+ return { success: false, error: `Spark AI error: ${getErrorMessage(err)}` };
920
+ }
921
+ }
922
+ async function callAIViaRelay(systemPrompt, userPrompt, email, tier = "free") {
923
+ if (!email) {
924
+ return {
925
+ success: false,
926
+ error: "Sign in required. Run qp login to connect your account.",
927
+ isAuthError: true
928
+ };
929
+ }
930
+ try {
931
+ let token = await getOrRefreshRelayToken(email);
932
+ let result = await executeChat(RELAY_CHAT_URL, token, systemPrompt, userPrompt, tier);
933
+ if (!result.success && result.isAuthError) {
934
+ cachedRelayToken = null;
935
+ token = await getOrRefreshRelayToken(email);
936
+ result = await executeChat(RELAY_CHAT_URL, token, systemPrompt, userPrompt, tier);
937
+ }
938
+ return result;
885
939
  } catch (err) {
886
940
  return { success: false, error: `Spark AI error: ${getErrorMessage(err)}` };
887
941
  }
@@ -916,26 +970,32 @@ function normalizeUrl(input) {
916
970
  var APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
917
971
  var BASE = "https://sparkstripe.com";
918
972
  var TIERS = {
919
- "price_1TADnPEf9opkuWid9NtqOYON": "pro",
920
- "price_1TADnPEf9opkuWidgbvvX9ma": "pro",
921
- "price_1TADm7Ef9opkuWid3I9tquIB": "pro",
922
- "price_1TADm8Ef9opkuWid1RSA2wCa": "pro",
923
- "price_1TCREmEf9opkuWidvhuqzqDM": "pro",
924
- "price_1TCREmEf9opkuWidacf1tiC7": "pro"
973
+ price_1TADnPEf9opkuWid9NtqOYON: "pro",
974
+ price_1TADnPEf9opkuWidgbvvX9ma: "pro",
975
+ price_1TADm7Ef9opkuWid3I9tquIB: "pro",
976
+ price_1TADm8Ef9opkuWid1RSA2wCa: "pro",
977
+ price_1TCREmEf9opkuWidvhuqzqDM: "pro",
978
+ price_1TCREmEf9opkuWidacf1tiC7: "pro"
925
979
  };
926
980
  var RANK = {
927
- "free": 0,
928
- "pro": 1
981
+ free: 0,
982
+ pro: 1
983
+ };
984
+ var FEATURES = {
985
+ free: [],
986
+ pro: [
987
+ "Everything in Free",
988
+ "No watermark",
989
+ "Full video composition (voiceover + captions + music + transitions)",
990
+ "Unlimited videos",
991
+ "Priority support"
992
+ ]
929
993
  };
930
- var FEATURES = { "free": [], "pro": [
931
- "Everything in Free",
932
- "No watermark",
933
- "Full video composition (voiceover + captions + music + transitions)",
934
- "Unlimited videos",
935
- "Priority support"
936
- ] };
937
994
  async function _fetch(email, init) {
938
- const r = await fetch(`${BASE}/api/public/subscription/status-public?email=${encodeURIComponent(email)}&app_id=${APP_ID}`, init);
995
+ const r = await fetch(
996
+ `${BASE}/api/public/subscription/status-public?email=${encodeURIComponent(email)}&app_id=${APP_ID}`,
997
+ init
998
+ );
939
999
  if (!r.ok) throw new Error(`spark-stripe: ${r.status}`);
940
1000
  return r.json();
941
1001
  }
@@ -988,7 +1048,7 @@ function getUpgradeUrl(userEmail) {
988
1048
  }
989
1049
 
990
1050
  // src/lib/config.ts
991
- var VERSION = "1.1.1";
1051
+ var VERSION = "1.2.1";
992
1052
  var CONFIG_FILE = "quickpeek.config.json";
993
1053
  var DEFAULT_CONFIG = {
994
1054
  plan: { maxSteps: 8 },
@@ -1152,14 +1212,32 @@ function getActionForType(type) {
1152
1212
  }
1153
1213
  var noopTask = () => ({ succeed: () => {
1154
1214
  } });
1155
- async function crawlPage(url, task = noopTask) {
1215
+ var CRAWL_CACHE_TTL_MS = 5 * 60 * 1e3;
1216
+ var CRAWL_CACHE_MAX_ENTRIES = 50;
1217
+ var crawlCache = /* @__PURE__ */ new Map();
1218
+ async function crawlPage(url, task = noopTask, storageStatePath, viewport) {
1219
+ const cacheKey = viewport ? `${url}@${viewport.width}x${viewport.height}` : url;
1220
+ const cached = crawlCache.get(cacheKey);
1221
+ if (cached && Date.now() - cached.timestamp < CRAWL_CACHE_TTL_MS) {
1222
+ task("Using cached crawl").succeed(`Found ${cached.elements.length} interactive elements (cached)`);
1223
+ return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
1224
+ }
1156
1225
  const browserTask = task("Launching browser");
1157
1226
  const browser = await chromium.launch({ headless: true });
1158
- const page = await browser.newPage();
1159
- browserTask.succeed("Browser launched");
1227
+ const context = await browser.newContext({
1228
+ ...storageStatePath ? { storageState: storageStatePath } : {},
1229
+ // Crawl at the size we will record at, or the measurements below describe
1230
+ // a page nobody will ever see.
1231
+ ...viewport ? { viewport } : {}
1232
+ });
1233
+ const page = await context.newPage();
1234
+ browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
1160
1235
  const crawlTask = task("Crawling page");
1161
1236
  await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
1162
1237
  const title = await page.title();
1238
+ const scrollable = await page.evaluate(
1239
+ "Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
1240
+ );
1163
1241
  const rawElements = await page.evaluate(`
1164
1242
  (() => {
1165
1243
  const items = [];
@@ -1289,7 +1367,12 @@ async function crawlPage(url, task = noopTask) {
1289
1367
  });
1290
1368
  await browser.close();
1291
1369
  crawlTask.succeed(`Found ${elements.length} interactive elements`);
1292
- return { title, elements };
1370
+ crawlCache.set(cacheKey, { timestamp: Date.now(), title, elements: [...elements], scrollable });
1371
+ if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
1372
+ const oldest = crawlCache.keys().next().value;
1373
+ if (oldest !== void 0) crawlCache.delete(oldest);
1374
+ }
1375
+ return { title, elements, scrollable };
1293
1376
  }
1294
1377
 
1295
1378
  // src/lib/cursor.ts
@@ -1352,7 +1435,8 @@ EXAMPLES:
1352
1435
  \u2022 File upload: "target": "input[type=\\"file\\"]"
1353
1436
  \u2022 Slider: "target": "input[type=\\"range\\"]"`;
1354
1437
  }
1355
- function buildUserPrompt(url, title, elements, langName, description, maxSteps) {
1438
+ var MIN_USEFUL_SCROLL_PX = 200;
1439
+ function buildUserPrompt(url, title, elements, langName, description, maxSteps, scrollable) {
1356
1440
  const lang = langName ? `
1357
1441
  Language: Write ALL captions in ${langName}.` : "";
1358
1442
  const purpose = description ? `
@@ -1390,8 +1474,13 @@ ${selectables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`
1390
1474
  ${checkables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`);
1391
1475
  }
1392
1476
  const elementSummary = elementDict.length > 0 ? elementDict.join("\n\n") : "(no interactive elements found)";
1477
+ const cannotScroll = scrollable !== void 0 && scrollable < MIN_USEFUL_SCROLL_PX;
1478
+ const scrolling = cannotScroll ? `
1479
+
1480
+ SCROLLING: this page has only ${scrollable}px of scroll travel at the recording size, so it does not scroll.` : "";
1481
+ 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" : "";
1393
1482
  return `URL: ${url}
1394
- Title: ${title}${purpose}
1483
+ Title: ${title}${purpose}${scrolling}
1395
1484
 
1396
1485
  AVAILABLE ELEMENTS (grouped by action):
1397
1486
  ${elementSummary}
@@ -1401,7 +1490,7 @@ REQUIREMENTS:
1401
1490
  \u2022 Complete ONE user journey: input \u2192 process \u2192 output
1402
1491
  \u2022 For CLICK: use simplified text target like "extract frame" or "download"
1403
1492
  \u2022 For UPLOAD/DRAG/TYPE: use the CSS selector shown above
1404
- \u2022 For buttons that appear after actions: use partial text like "extract" or "download"${lang}
1493
+ \u2022 For buttons that appear after actions: use partial text like "extract" or "download"${scrollRule}${lang}
1405
1494
 
1406
1495
  Return JSON:
1407
1496
  {
@@ -1426,6 +1515,7 @@ TARGET SYNTAX:
1426
1515
  }
1427
1516
 
1428
1517
  // src/lib/styles.ts
1518
+ import { buildAssHeader } from "@spark-apps/video-kit";
1429
1519
  var CURSOR_MOVE_DURATION = 800;
1430
1520
  var CURSOR_CSS = `
1431
1521
  #qp-cursor {
@@ -1465,19 +1555,14 @@ var CURSOR_SCRIPT = `
1465
1555
  })();
1466
1556
  `;
1467
1557
  function generateASSHeader(width, height, font, size, primaryColor, secondaryColor, outlineColor, backColor, bold, outlineWidth, shadow, marginBottom) {
1468
- return `[Script Info]
1469
- Title: QuickPeek Demo
1470
- ScriptType: v4.00+
1471
- PlayResX: ${width}
1472
- PlayResY: ${height}
1473
-
1474
- [V4+ Styles]
1475
- Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
1476
- Style: Karaoke,${font},${size},${primaryColor},${secondaryColor},${outlineColor},${backColor},${bold ? 1 : 0},0,0,0,100,100,0,0,1,${outlineWidth},${shadow},2,40,40,${marginBottom},1
1477
-
1478
- [Events]
1479
- Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
1480
- `;
1558
+ return buildAssHeader({
1559
+ title: "QuickPeek Demo",
1560
+ width,
1561
+ height,
1562
+ styles: [
1563
+ `Style: Karaoke,${font},${size},${primaryColor},${secondaryColor},${outlineColor},${backColor},${bold ? 1 : 0},0,0,0,100,100,0,0,1,${outlineWidth},${shadow},2,40,40,${marginBottom},1`
1564
+ ]
1565
+ });
1481
1566
  }
1482
1567
 
1483
1568
  // src/lib/subscription-client.ts
@@ -1560,75 +1645,7 @@ import { mkdir } from "fs/promises";
1560
1645
  import { join as join5 } from "path";
1561
1646
 
1562
1647
  // src/lib/voices.ts
1563
- var LANGUAGE_VOICES = {
1564
- en: "en-GB-SoniaNeural",
1565
- ar: "ar-LB-LaylaNeural",
1566
- es: "es-ES-ElviraNeural",
1567
- fr: "fr-FR-DeniseNeural",
1568
- de: "de-DE-KatjaNeural",
1569
- it: "it-IT-ElsaNeural",
1570
- pt: "pt-BR-FranciscaNeural",
1571
- ru: "ru-RU-SvetlanaNeural",
1572
- zh: "zh-CN-XiaoxiaoNeural",
1573
- ja: "ja-JP-NanamiNeural",
1574
- ko: "ko-KR-SunHiNeural",
1575
- hi: "hi-IN-SwaraNeural",
1576
- tr: "tr-TR-EmelNeural",
1577
- nl: "nl-NL-ColetteNeural",
1578
- pl: "pl-PL-AgnieszkaNeural",
1579
- sv: "sv-SE-SofieNeural",
1580
- he: "he-IL-HilaNeural",
1581
- th: "th-TH-PremwadeeNeural",
1582
- vi: "vi-VN-HoaiMyNeural",
1583
- id: "id-ID-GadisNeural",
1584
- ms: "ms-MY-YasminNeural",
1585
- fil: "fil-PH-BlessicaNeural",
1586
- uk: "uk-UA-PolinaNeural",
1587
- cs: "cs-CZ-VlastaNeural",
1588
- el: "el-GR-AthinaNeural",
1589
- ro: "ro-RO-AlinaNeural",
1590
- hu: "hu-HU-NoemiNeural",
1591
- da: "da-DK-ChristelNeural",
1592
- fi: "fi-FI-NooraNeural",
1593
- no: "nb-NO-PernilleNeural",
1594
- sk: "sk-SK-ViktoriaNeural",
1595
- bg: "bg-BG-KalinaNeural",
1596
- hr: "hr-HR-GabrijelaNeural",
1597
- sl: "sl-SI-PetraNeural",
1598
- et: "et-EE-AnuNeural",
1599
- lv: "lv-LV-EveritaNeural",
1600
- lt: "lt-LT-OnaNeural",
1601
- bn: "bn-IN-TanishaaNeural",
1602
- ta: "ta-IN-PallaviNeural",
1603
- te: "te-IN-ShrutiNeural",
1604
- mr: "mr-IN-AarohiNeural",
1605
- gu: "gu-IN-DhwaniNeural",
1606
- kn: "kn-IN-SapnaNeural",
1607
- ml: "ml-IN-SobhanaNeural",
1608
- ur: "ur-PK-UzmaNeural",
1609
- fa: "fa-IR-DilaraNeural",
1610
- sw: "sw-KE-ZuriNeural",
1611
- af: "af-ZA-AdriNeural",
1612
- ca: "ca-ES-JoanaNeural",
1613
- cy: "cy-GB-NiaNeural",
1614
- ga: "ga-IE-OrlaNeural",
1615
- mt: "mt-MT-GraceNeural",
1616
- is: "is-IS-GudrunNeural",
1617
- mk: "mk-MK-MarijaNeural",
1618
- sr: "sr-RS-SophieNeural",
1619
- bs: "bs-BA-VesnaNeural",
1620
- sq: "sq-AL-AnilaNeural",
1621
- am: "am-ET-MekdesNeural",
1622
- az: "az-AZ-BabekNeural",
1623
- ka: "ka-GE-EkaNeural",
1624
- kk: "kk-KZ-AigulNeural",
1625
- mn: "mn-MN-YesUINeural",
1626
- ne: "ne-NP-HemkalaNeural",
1627
- ps: "ps-AF-LatifaNeural",
1628
- si: "si-LK-ThiliniNeural",
1629
- so: "so-SO-UbaxNeural",
1630
- zu: "zu-ZA-ThandoNeural"
1631
- };
1648
+ import { WIDE_VOICES } from "@spark-apps/video-kit";
1632
1649
 
1633
1650
  // src/lib/tts/generate.ts
1634
1651
  init_helpers();
@@ -1695,7 +1712,7 @@ function estimateDurationFromText(text, wordsPerMinute = 150) {
1695
1712
  }
1696
1713
  function resolveVoice(config) {
1697
1714
  if (config.audio?.voice) return config.audio.voice;
1698
- if (config.lang) return LANGUAGE_VOICES[config.lang] || null;
1715
+ if (config.lang) return WIDE_VOICES[config.lang] || null;
1699
1716
  return null;
1700
1717
  }
1701
1718
  async function generateVoiceover(steps, config, workDir, outputDir, logError, task, info) {
@@ -1803,7 +1820,7 @@ export {
1803
1820
  EXCLUDED_LINK_PATTERNS,
1804
1821
  FEATURES,
1805
1822
  INTERACTIVE_SELECTORS,
1806
- LANGUAGE_VOICES,
1823
+ WIDE_VOICES as LANGUAGE_VOICES,
1807
1824
  VERSION,
1808
1825
  alignVoiceoverToRecording,
1809
1826
  applyTransitions,
@@ -1813,6 +1830,7 @@ export {
1813
1830
  buildUserPrompt,
1814
1831
  buildWatermarkFilters,
1815
1832
  callAI,
1833
+ callAIViaRelay,
1816
1834
  canReuseVoiceover,
1817
1835
  composeVideo,
1818
1836
  concatMedia,
@@ -1831,7 +1849,7 @@ export {
1831
1849
  getUpgradeUrl,
1832
1850
  hasAccess,
1833
1851
  hasTrialRemaining,
1834
- hexToASS,
1852
+ hexToAss as hexToASS,
1835
1853
  isCanceling,
1836
1854
  isExcludedLink,
1837
1855
  isPaid,
@@ -1853,5 +1871,5 @@ export {
1853
1871
  spliceVideos,
1854
1872
  synthesizeSpeech,
1855
1873
  toTitleCase,
1856
- windowsPathToWSL
1874
+ windowsDrivePathToWsl as windowsPathToWSL
1857
1875
  };