@spark-apps/quickpeek 1.1.2 → 1.2.0

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/web.mjs ADDED
@@ -0,0 +1,978 @@
1
+ // src/lib/utils.ts
2
+ import { hexToAss } from "@spark-apps/video-kit";
3
+ import { windowsDrivePathToWsl } from "@spark-apps/video-kit";
4
+ var LANGUAGE_NAMES = {
5
+ ar: "Arabic",
6
+ es: "Spanish",
7
+ fr: "French",
8
+ de: "German",
9
+ zh: "Chinese",
10
+ ja: "Japanese",
11
+ ko: "Korean",
12
+ pt: "Portuguese",
13
+ ru: "Russian",
14
+ hi: "Hindi",
15
+ tr: "Turkish",
16
+ it: "Italian"
17
+ };
18
+ function getLanguageName(lang) {
19
+ if (!lang || lang === "en") return null;
20
+ return LANGUAGE_NAMES[lang] || lang.toUpperCase();
21
+ }
22
+ function rgbaToHex(color) {
23
+ if (color.startsWith("#")) return color;
24
+ const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
25
+ if (!match) return "#000000";
26
+ const r = parseInt(match[1], 10).toString(16).padStart(2, "0");
27
+ const g = parseInt(match[2], 10).toString(16).padStart(2, "0");
28
+ const b = parseInt(match[3], 10).toString(16).padStart(2, "0");
29
+ return `#${r}${g}${b}`.toUpperCase();
30
+ }
31
+ var LOWERCASE_WORDS = /* @__PURE__ */ new Set([
32
+ "a",
33
+ "an",
34
+ "the",
35
+ "and",
36
+ "but",
37
+ "or",
38
+ "nor",
39
+ "for",
40
+ "yet",
41
+ "so",
42
+ "at",
43
+ "by",
44
+ "in",
45
+ "of",
46
+ "on",
47
+ "to",
48
+ "up",
49
+ "as",
50
+ "if",
51
+ "off",
52
+ "per",
53
+ "via",
54
+ "from",
55
+ "into",
56
+ "onto",
57
+ "upon",
58
+ "with",
59
+ "over",
60
+ "under",
61
+ "than"
62
+ ]);
63
+ function parsePartial(partial) {
64
+ if (!partial) return {};
65
+ const parts = partial.split("-");
66
+ if (parts.length !== 2) return {};
67
+ const ts = parts[0] ? parseFloat(parts[0]) : NaN;
68
+ const te = parts[1] ? parseFloat(parts[1]) : NaN;
69
+ const result = {};
70
+ if (!Number.isNaN(ts)) result.trimStart = ts;
71
+ if (!Number.isNaN(te)) result.trimEnd = te;
72
+ return result;
73
+ }
74
+ function getErrorMessage(err) {
75
+ return err instanceof Error ? err.message : String(err);
76
+ }
77
+ function toTitleCase(text) {
78
+ return text.replace(/\w+/g, (word, index) => {
79
+ if (index === 0) {
80
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
81
+ }
82
+ if (LOWERCASE_WORDS.has(word.toLowerCase())) {
83
+ return word.toLowerCase();
84
+ }
85
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
86
+ });
87
+ }
88
+
89
+ // src/lib/api-client.ts
90
+ var SPARK_AI_BASE = "https://sparkbrain.app";
91
+ var SPARK_AI_CHAT = `${SPARK_AI_BASE}/api/chat`;
92
+ var SPARK_AI_TOKEN = `${SPARK_AI_BASE}/api/models/token`;
93
+ var RELAY_BASE = process.env.QUICKPEEK_API_BASE || "https://quickpeek.co";
94
+ var RELAY_TOKEN_URL = `${RELAY_BASE}/api/cli/token`;
95
+ var RELAY_CHAT_URL = `${RELAY_BASE}/api/cli/ai`;
96
+ var API_KEY = process.env.SPARK_AI_KEY || "";
97
+ var cachedDirectToken = null;
98
+ async function getOrRefreshDirectToken() {
99
+ const now = Date.now();
100
+ if (cachedDirectToken && cachedDirectToken.expiresAt > now + 3e5) {
101
+ return cachedDirectToken.token;
102
+ }
103
+ try {
104
+ const response = await fetch(SPARK_AI_TOKEN, {
105
+ method: "POST",
106
+ headers: { "X-Api-Key": API_KEY }
107
+ });
108
+ if (!response.ok) {
109
+ throw new Error(`Token exchange failed: ${response.status} ${response.statusText}`);
110
+ }
111
+ const data = await response.json();
112
+ if (!data.success || !data.token) {
113
+ throw new Error(data.error || `Token exchange failed: unexpected response`);
114
+ }
115
+ cachedDirectToken = {
116
+ token: data.token,
117
+ expiresAt: data.expiresAt || now + 36e5
118
+ };
119
+ return data.token;
120
+ } catch (err) {
121
+ throw new Error(`Failed to get Spark AI token: ${getErrorMessage(err)}`);
122
+ }
123
+ }
124
+ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier) {
125
+ let lastRateLimitError = null;
126
+ for (let attempt = 1; attempt <= 3; attempt++) {
127
+ try {
128
+ const response = await fetch(chatUrl, {
129
+ method: "POST",
130
+ headers: {
131
+ "Content-Type": "application/json",
132
+ Authorization: `Bearer ${bearerToken}`
133
+ },
134
+ body: JSON.stringify({
135
+ messages: [
136
+ { role: "system", content: systemPrompt },
137
+ { role: "user", content: userPrompt }
138
+ ]
139
+ })
140
+ });
141
+ let data = {};
142
+ const contentType = response.headers.get("content-type") ?? "";
143
+ if (contentType.includes("application/json")) {
144
+ data = await response.json();
145
+ }
146
+ if (!response.ok) {
147
+ const error = data.error || `Spark AI error: ${response.status} ${response.statusText}`;
148
+ if (response.status === 429 && attempt < 3) {
149
+ const retryAfter = response.headers.get("Retry-After");
150
+ const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : 1e3 * 2 ** (attempt - 1);
151
+ lastRateLimitError = {
152
+ success: false,
153
+ error,
154
+ isRateLimit: true,
155
+ ...retryAfter ? { retryAfterSeconds: parseInt(retryAfter, 10) } : {}
156
+ };
157
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, 3e4)));
158
+ continue;
159
+ }
160
+ return {
161
+ success: false,
162
+ error,
163
+ isRateLimit: response.status === 429,
164
+ isAuthError: response.status === 401 || response.status === 403
165
+ };
166
+ }
167
+ if (!data.success || !data.response) {
168
+ return {
169
+ success: false,
170
+ error: data.error || "No response from Spark AI"
171
+ };
172
+ }
173
+ return {
174
+ success: true,
175
+ content: data.response,
176
+ limits: {
177
+ used: data.limits?.used ?? 0,
178
+ limit: data.limits?.limit ?? 100,
179
+ tier,
180
+ ...data.limits?.resetAt ? { resetAt: data.limits.resetAt } : {}
181
+ }
182
+ };
183
+ } catch (err) {
184
+ if (attempt === 3) throw err;
185
+ const delayMs = 1e3 * 2 ** (attempt - 1);
186
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
187
+ }
188
+ }
189
+ return lastRateLimitError || { success: false, error: "Request failed after retries" };
190
+ }
191
+ async function callAI(systemPrompt, userPrompt, tier = "free") {
192
+ if (!API_KEY) {
193
+ return {
194
+ success: false,
195
+ error: "AI service is not configured. Please report this issue.",
196
+ isAuthError: true
197
+ };
198
+ }
199
+ try {
200
+ const token = await getOrRefreshDirectToken();
201
+ return await executeChat(SPARK_AI_CHAT, token, systemPrompt, userPrompt, tier);
202
+ } catch (err) {
203
+ return { success: false, error: `Spark AI error: ${getErrorMessage(err)}` };
204
+ }
205
+ }
206
+ function parseAIPlanResponse(content) {
207
+ try {
208
+ let processedContent = content;
209
+ const codeBlockMatch = processedContent.match(/```(?:json)?\s*([\s\S]*?)```/);
210
+ if (codeBlockMatch?.[1]) {
211
+ processedContent = codeBlockMatch[1].trim();
212
+ }
213
+ const jsonMatch = processedContent.match(/\{[\s\S]*\}/);
214
+ if (!jsonMatch) {
215
+ return { success: false, error: "No valid JSON found in AI response" };
216
+ }
217
+ const parsed = JSON.parse(jsonMatch[0]);
218
+ return { success: true, plan: parsed };
219
+ } catch (err) {
220
+ return {
221
+ success: false,
222
+ error: `Failed to parse AI response: ${err instanceof Error ? err.message : String(err)}`
223
+ };
224
+ }
225
+ }
226
+ function normalizeUrl(input) {
227
+ let url = input.trim();
228
+ url = url.replace(/^(https?:\/\/)?(www\.)?/i, "");
229
+ return `https://${url}`;
230
+ }
231
+
232
+ // src/lib/crawl.ts
233
+ import { chromium } from "playwright";
234
+
235
+ // src/lib/scraping.ts
236
+ var EXCLUDED_LINK_PATTERNS = [
237
+ // Legal & Policy
238
+ "privacy",
239
+ "terms",
240
+ "cookie",
241
+ "legal",
242
+ "policy",
243
+ "disclaimer",
244
+ "copyright",
245
+ "gdpr",
246
+ // Auth & Account
247
+ "login",
248
+ "signin",
249
+ "sign in",
250
+ "signup",
251
+ "sign up",
252
+ "register",
253
+ "logout",
254
+ "sign out",
255
+ "my account",
256
+ "profile",
257
+ "settings",
258
+ // Support
259
+ "contact",
260
+ "about",
261
+ "help",
262
+ "support",
263
+ "faq",
264
+ // Social Media
265
+ "facebook",
266
+ "twitter",
267
+ "linkedin",
268
+ "instagram",
269
+ "youtube",
270
+ "tiktok",
271
+ "github",
272
+ // Company
273
+ "blog",
274
+ "news",
275
+ "press",
276
+ "careers",
277
+ "jobs",
278
+ "team",
279
+ // Developer
280
+ "docs",
281
+ "documentation",
282
+ "api",
283
+ "developers",
284
+ // Commerce
285
+ "pricing",
286
+ "plans",
287
+ "subscribe",
288
+ "newsletter",
289
+ "upgrade",
290
+ "buy",
291
+ "purchase",
292
+ // Misc
293
+ "accessibility",
294
+ "sitemap",
295
+ "rss",
296
+ "feed",
297
+ "home"
298
+ ];
299
+ var INTERACTIVE_SELECTORS = [
300
+ "button",
301
+ "input",
302
+ "select",
303
+ "textarea",
304
+ "a[href]",
305
+ '[role="button"]',
306
+ '[role="checkbox"]',
307
+ '[role="radio"]',
308
+ '[role="switch"]',
309
+ '[role="slider"]',
310
+ '[role="combobox"]',
311
+ '[role="menuitem"]',
312
+ '[type="submit"]',
313
+ "[onclick]"
314
+ ];
315
+ function isExcludedLink(text, href = "") {
316
+ const lowerText = text.toLowerCase();
317
+ const lowerHref = href.toLowerCase();
318
+ return EXCLUDED_LINK_PATTERNS.some((p) => lowerText.includes(p) || lowerHref.includes(p));
319
+ }
320
+ function shouldSkipLink(text, href, currentPath) {
321
+ if (!href || href === "#") return true;
322
+ try {
323
+ const linkPath = new URL(href).pathname;
324
+ if (linkPath === "/" || linkPath === currentPath) return true;
325
+ } catch {
326
+ return true;
327
+ }
328
+ return isExcludedLink(text, href);
329
+ }
330
+
331
+ // src/lib/crawl.ts
332
+ function getActionForType(type) {
333
+ switch (type) {
334
+ case "button":
335
+ case "link":
336
+ return "click";
337
+ case "file":
338
+ return "upload";
339
+ case "range":
340
+ return "drag";
341
+ case "select":
342
+ return "select";
343
+ case "checkbox":
344
+ return "check";
345
+ default:
346
+ return "type";
347
+ }
348
+ }
349
+ var noopTask = () => ({ succeed: () => {
350
+ } });
351
+ var CRAWL_CACHE_TTL_MS = 5 * 60 * 1e3;
352
+ var CRAWL_CACHE_MAX_ENTRIES = 50;
353
+ var crawlCache = /* @__PURE__ */ new Map();
354
+ async function crawlPage(url, task = noopTask, storageStatePath) {
355
+ const cached = crawlCache.get(url);
356
+ if (cached && Date.now() - cached.timestamp < CRAWL_CACHE_TTL_MS) {
357
+ task("Using cached crawl").succeed(`Found ${cached.elements.length} interactive elements (cached)`);
358
+ return { title: cached.title, elements: [...cached.elements] };
359
+ }
360
+ const browserTask = task("Launching browser");
361
+ const browser = await chromium.launch({ headless: true });
362
+ const context = await browser.newContext(storageStatePath ? { storageState: storageStatePath } : {});
363
+ const page = await context.newPage();
364
+ browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
365
+ const crawlTask = task("Crawling page");
366
+ await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
367
+ const title = await page.title();
368
+ const rawElements = await page.evaluate(`
369
+ (() => {
370
+ const items = [];
371
+
372
+ // Helper to get parent context
373
+ const getCtx = (el) => {
374
+ let p = el.parentElement;
375
+ while (p && p !== document.body) {
376
+ if (p.id) return '#' + p.id;
377
+ const cls = Array.from(p.classList).find(c =>
378
+ /toolbar|header|footer|sidebar|panel|modal|dialog|nav|menu|controls|actions/i.test(c)
379
+ );
380
+ if (cls) return '.' + cls;
381
+ if (['header', 'footer', 'nav', 'main', 'aside', 'form'].includes(p.tagName.toLowerCase())) {
382
+ return p.tagName.toLowerCase();
383
+ }
384
+ p = p.parentElement;
385
+ }
386
+ return '';
387
+ };
388
+
389
+ // Buttons (use textContent to catch hidden elements too)
390
+ document.querySelectorAll('button, [role="button"]').forEach(el => {
391
+ const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
392
+ const testId = el.getAttribute('data-testid');
393
+ const ariaLabel = el.getAttribute('aria-label');
394
+ if (!text && !testId && !ariaLabel) return;
395
+ items.push({ type: 'button', text: text || ariaLabel || testId, testId, ctx: getCtx(el) });
396
+ });
397
+
398
+ // Inputs (including file inputs, range sliders, checkboxes, selects)
399
+ document.querySelectorAll('input, textarea, select').forEach(el => {
400
+ if (el.type === 'hidden') return;
401
+ const testId = el.getAttribute('data-testid');
402
+ const inputType = el.type || 'text';
403
+
404
+ // Get label text for any input type
405
+ let labelText = '';
406
+ if (el.labels?.[0]) {
407
+ labelText = el.labels[0].textContent?.trim() || '';
408
+ } else if (el.id) {
409
+ const label = document.querySelector('label[for="' + el.id + '"]');
410
+ if (label) labelText = label.textContent?.trim() || '';
411
+ }
412
+
413
+ const text = labelText || el.placeholder || el.name || el.id || '';
414
+
415
+ // Include file, range, and checkbox inputs even without text
416
+ const alwaysInclude = ['file', 'range', 'checkbox'].includes(inputType);
417
+ if (!text && !testId && !alwaysInclude) return;
418
+
419
+ items.push({
420
+ type: inputType,
421
+ text: text || (inputType === 'file' ? 'file upload' : inputType === 'range' ? 'slider' : inputType === 'checkbox' ? 'checkbox' : testId),
422
+ testId,
423
+ id: el.id,
424
+ name: el.name,
425
+ placeholder: el.placeholder,
426
+ isFile: inputType === 'file'
427
+ });
428
+ });
429
+
430
+ // Links (use textContent to catch hidden elements too)
431
+ document.querySelectorAll('a[href]').forEach(el => {
432
+ const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
433
+ if (text && text.length >= 2) items.push({ type: 'link', text });
434
+ });
435
+
436
+ return items.slice(0, 40);
437
+ })()
438
+ `);
439
+ const filteredElements = rawElements.filter((el) => {
440
+ if (!el.text) return true;
441
+ const text = el.text.toLowerCase().trim();
442
+ return !EXCLUDED_LINK_PATTERNS.some((pattern) => text.includes(pattern));
443
+ });
444
+ const btnTexts = {};
445
+ for (const el of filteredElements) {
446
+ if (el.type === "button" && el.text) {
447
+ btnTexts[el.text] = (btnTexts[el.text] || 0) + 1;
448
+ }
449
+ }
450
+ const elements = filteredElements.map((el) => {
451
+ let selector;
452
+ if (el.testId) {
453
+ selector = `[data-testid="${el.testId}"]`;
454
+ } else if (el.isFile || el.type === "file") {
455
+ selector = 'input[type="file"]';
456
+ } else if (el.type === "range") {
457
+ selector = el.id ? `#${el.id}` : 'input[type="range"]';
458
+ } else if (el.type === "checkbox") {
459
+ selector = el.id ? `#${el.id}` : 'input[type="checkbox"]';
460
+ } else if (el.type === "button") {
461
+ if (btnTexts[el.text] === 1) {
462
+ selector = `text="${el.text}"`;
463
+ } else {
464
+ selector = el.ctx ? `${el.ctx} >> text="${el.text}"` : `button:has-text("${el.text}")`;
465
+ }
466
+ } else if (el.type === "link") {
467
+ selector = `text="${el.text}"`;
468
+ } else if (el.id) {
469
+ selector = `#${el.id}`;
470
+ } else if (el.name) {
471
+ selector = `[name="${el.name}"]`;
472
+ } else if (el.placeholder) {
473
+ selector = `[placeholder="${el.placeholder}"]`;
474
+ } else {
475
+ selector = `text="${el.text}"`;
476
+ }
477
+ let normalizedType;
478
+ if (el.isFile || el.type === "file") {
479
+ normalizedType = "file";
480
+ } else if (el.type === "range") {
481
+ normalizedType = "range";
482
+ } else if (el.type === "checkbox") {
483
+ normalizedType = "checkbox";
484
+ } else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
485
+ normalizedType = "select";
486
+ } else if (el.type === "button" || el.type === "link") {
487
+ normalizedType = el.type;
488
+ } else {
489
+ normalizedType = "input";
490
+ }
491
+ const action = getActionForType(normalizedType);
492
+ const target = normalizedType === "file" ? selector : (el.text || "").toLowerCase();
493
+ return { type: normalizedType, text: el.text, selector, action, target };
494
+ });
495
+ await browser.close();
496
+ crawlTask.succeed(`Found ${elements.length} interactive elements`);
497
+ crawlCache.set(url, { timestamp: Date.now(), title, elements: [...elements] });
498
+ if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
499
+ const oldest = crawlCache.keys().next().value;
500
+ if (oldest !== void 0) crawlCache.delete(oldest);
501
+ }
502
+ return { title, elements };
503
+ }
504
+
505
+ // src/lib/plan-validation.ts
506
+ var INVALID_TARGETS = [
507
+ /\.png$/i,
508
+ /\.jpg$/i,
509
+ /\.gif$/i,
510
+ /\.webp$/i,
511
+ /\.svg$/i,
512
+ /sprite_sheet/i,
513
+ /downloaded/i
514
+ ];
515
+ var ACTIONS_REQUIRING_TARGET = ["click", "type", "select", "check", "drag"];
516
+ function isFilePath(value) {
517
+ if (!value) return false;
518
+ return /\.\w{2,5}$/.test(value) && (/[/\\]/.test(value) || /^[a-zA-Z]:/.test(value));
519
+ }
520
+ function isInvalidTarget(target) {
521
+ return INVALID_TARGETS.some((pattern) => pattern.test(target));
522
+ }
523
+ function validateStep(step) {
524
+ if (!step?.action) {
525
+ return "Missing action";
526
+ }
527
+ if (step.action === "navigate") {
528
+ if (!step.target) return "Navigate requires a URL target";
529
+ if (!step.target.startsWith("http://") && !step.target.startsWith("https://")) {
530
+ return "Navigate target must be a valid URL";
531
+ }
532
+ }
533
+ if (step.action === "media") {
534
+ if (!step.mediaFile && !isFilePath(step.value)) {
535
+ return "Media step requires a file (image or video)";
536
+ }
537
+ }
538
+ if (step.action === "upload") {
539
+ if (!step.mediaFile && !isFilePath(step.value)) {
540
+ return "Upload step requires a file";
541
+ }
542
+ }
543
+ if (ACTIONS_REQUIRING_TARGET.includes(step.action)) {
544
+ if (!step.target) return `${step.action} requires a target`;
545
+ if (isInvalidTarget(step.target)) return `Invalid target pattern: ${step.target}`;
546
+ }
547
+ return null;
548
+ }
549
+ function isValidStep(step) {
550
+ return validateStep(step) === null;
551
+ }
552
+ function filterValidSteps(steps) {
553
+ return steps.filter(isValidStep);
554
+ }
555
+ var REFINE_PLAN_SYSTEM_PROMPT = `You refine demo video plans based on user feedback.
556
+
557
+ AVAILABLE ACTIONS (use these exact formats):
558
+
559
+ 1. WAIT - Pause for narration (intro/outro/explanations)
560
+ Format: { "action": "wait", "caption": "Your narration text..." }
561
+ Use when: Starting intro, ending outro, explaining something without interaction
562
+ NO target needed
563
+
564
+ 2. CLICK - Click buttons, links, or interactive elements
565
+ Format: { "action": "click", "target": "button text", "caption": "Click description..." }
566
+ Target: Just the button/link text (case insensitive, partial match OK)
567
+ Examples: "extract frame", "download", "submit", "next"
568
+ Use when: User says "click", "press", "tap", "select button", "open", "close"
569
+
570
+ 3. TYPE - Enter text into input fields
571
+ Format: { "action": "type", "target": "input[name=\\"field\\"]", "value": "text to type", "caption": "..." }
572
+ Selectors: input[type="text"], input[name="..."], textarea, #id
573
+ Use when: User says "type", "enter", "input", "fill in", "write"
574
+ CRITICAL: PRESERVE existing value - never change what's being typed
575
+
576
+ 4. UPLOAD - Upload a file to a web form's file input element
577
+ Format: { "action": "upload", "target": "input[type=\\"file\\"]", "value": "path/to/file", "caption": "..." }
578
+ Target: REQUIRED - CSS selector for file input, usually input[type="file"]
579
+ Value: Path to file being uploaded
580
+ Use when: Uploading a file TO a website's form (the page has a file picker)
581
+ CRITICAL: PRESERVE existing value - never change the file path
582
+
583
+ 5. SCROLL - Scroll page or zoom camera
584
+ Format: { "action": "scroll", "value": "direction", "caption": "..." }
585
+ Values: "up", "down", "bottom", "80%" (page scroll) OR "-10", "10" (zoom: negative=out, positive=in)
586
+ Use when: User says "scroll", "zoom in", "zoom out", "show more", "go to bottom"
587
+ NO target needed
588
+
589
+ 6. DRAG - Drag sliders or rotate 3D objects
590
+ Format: { "action": "drag", "target": "input[type=\\"range\\"]", "value": "50", "caption": "..." }
591
+ Values: "0"-"100" for sliders, "rotate" for 3D rotation (stays at new angle), "around" for full 360\xB0 (returns to start)
592
+ Selectors: input[type="range"], canvas, .slider
593
+ Use when: User says "drag", "slide", "adjust slider", "rotate", "spin", "move"
594
+ NOTE: Use "rotate" (not "around") for showcasing 3D models - it rotates ~270\xB0 and stays at the new position
595
+
596
+ 7. SELECT - Choose option from dropdown
597
+ Format: { "action": "select", "target": "select[name=\\"..\\"]", "value": "option text", "caption": "..." }
598
+ Selector: select, select[name="..."], #id
599
+ Use when: User says "select", "choose", "pick from dropdown", "change option"
600
+
601
+ 8. CHECK - Toggle checkbox
602
+ Format: { "action": "check", "target": "input[type=\\"checkbox\\"]", "caption": "..." }
603
+ Selector: input[type="checkbox"], [role="checkbox"]
604
+ Use when: User says "check", "uncheck", "toggle", "enable", "disable"
605
+
606
+ 9. MEDIA - Display a media file fullscreen (NOT for uploading to a form!)
607
+ Format: { "action": "media", "value": "path/to/file.png", "caption": "..." }
608
+ Target: OPTIONAL - "scale" (fill viewport) or "center" (original size). Defaults to center.
609
+ Value: REQUIRED - File path to display
610
+ Supported: .png, .jpg, .jpeg, .gif, .webp, .mp4, .webm, .mov, .glb
611
+ Use when: Showing a file directly in the demo (screenshot, 3D model, video clip)
612
+ NOT for uploading - use UPLOAD action if page has a file input
613
+ CRITICAL: PRESERVE existing value - never change the file path
614
+
615
+ 10. SHOW_DOWNLOAD - Display a download animation/result
616
+ Format: { "action": "showDownload", "caption": "..." }
617
+ Use when: Showing the final result after a download action
618
+ NO target needed
619
+
620
+ IMPORTANT - UPLOAD vs MEDIA:
621
+ \u2022 UPLOAD: File goes INTO a web form (page has <input type="file">). Requires target selector.
622
+ \u2022 MEDIA: File is DISPLAYED fullscreen in demo (no web interaction). No target needed.
623
+ Example: "Show the 3D model" \u2192 use MEDIA action
624
+ Example: "Upload a file to the converter" \u2192 use UPLOAD action
625
+
626
+ TARGET SYNTAX (simplified):
627
+ \u2022 Buttons/Links: Just use the text! "extract frame", "download", "submit" (case insensitive)
628
+ \u2022 Inputs: input[type="text"], input[name="field"], input[type="file"], input[type="range"]
629
+ \u2022 By ID: #elementId (only if text won't work)
630
+ \u2022 By class: .className (only if text won't work)
631
+
632
+ PREFER SIMPLE TEXT for buttons: "download" instead of button:has-text("Download")
633
+
634
+ DETECTING USER INTENT:
635
+ \u2022 "add intro/outro" \u2192 add wait step at start/end
636
+ \u2022 "remove step X" \u2192 delete that step
637
+ \u2022 "change caption" \u2192 update the caption text only
638
+ \u2022 "click X instead" \u2192 change target to X
639
+ \u2022 "add a click on X" \u2192 insert new click step
640
+ \u2022 "show the slider" \u2192 add drag step for input[type="range"]
641
+ \u2022 "zoom in/out" \u2192 add scroll with numeric value
642
+ \u2022 "reorder" \u2192 rearrange step sequence
643
+
644
+ RULES:
645
+ \u2022 Return ONLY valid JSON
646
+ \u2022 Keep step ids sequential (1, 2, 3...)
647
+ \u2022 PRESERVE upload/type values EXACTLY - copy from input unchanged
648
+ \u2022 Only modify steps mentioned in feedback
649
+ \u2022 Use available elements list when provided to find correct selectors
650
+
651
+ Return ONLY the modified JSON plan:
652
+ {
653
+ "steps": [
654
+ { "id": 1, "action": "wait", "caption": "intro text" },
655
+ { "id": 2, "action": "click", "target": "start", "caption": "Click to begin" },
656
+ { "id": 3, "action": "upload", "target": "input[type=\\"file\\"]", "value": "path/to/file.png", "caption": "Upload the file" },
657
+ { "id": 4, "action": "click", "target": "extract", "caption": "Process the file" },
658
+ { "id": 5, "action": "media", "value": "path/to/result.png", "caption": "Here's the result" },
659
+ ...
660
+ ]
661
+ }`;
662
+ var FIX_PLAN_SYSTEM_PROMPT = `You are a web automation expert. Fix demo plan steps that failed validation.
663
+
664
+ RULES:
665
+ \u2022 Return ONLY valid JSON with the complete plan structure
666
+ \u2022 Fix ONLY the failed steps - keep working steps unchanged
667
+ \u2022 Use the available page elements to find correct selectors
668
+ \u2022 For click targets: use simplified button text like "extract frame", "download" (case insensitive)
669
+ \u2022 For elements not found, check if a similar element exists in the page elements list
670
+ \u2022 If an element truly doesn't exist, remove the step or change the action
671
+ \u2022 Preserve file paths in "value" fields
672
+ \u2022 Keep step ids sequential starting from 1
673
+
674
+ TARGET PATTERNS:
675
+ \u2022 Buttons/Links: Just the text! "extract frame", "download", "submit" (case insensitive)
676
+ \u2022 File inputs: input[type="file"]
677
+ \u2022 Sliders: input[type="range"]
678
+ \u2022 Text inputs: input[name="field"], #id, [placeholder="..."]
679
+
680
+ ACTION FORMATS:
681
+ \u2022 click: { "target": "download" } - use simplified button text
682
+ \u2022 scroll: { "value": "up|down" } for page, { "value": "10|-10" } for zoom (positive=in, negative=out)
683
+ \u2022 drag: { "target": "input[type=\\"range\\"]", "value": "10" } for sliders, { "value": "rotate" } for 3D rotation (auto-targets canvas)
684
+ \u2022 media: { "value": "path/to/file.png" } - display file fullscreen (NOT upload!)
685
+ \u2022 upload: { "target": "input[type=\\"file\\"]", "value": "path/to/file" } - upload to form
686
+
687
+ IMPORTANT: Only use elements that actually exist on the page based on the provided list.`;
688
+ function collectPreservedValues(steps) {
689
+ const uploadSteps = [];
690
+ const mediaSteps = [];
691
+ const otherValues = /* @__PURE__ */ new Map();
692
+ for (const step of steps) {
693
+ if (step.action === "upload") {
694
+ uploadSteps.push({
695
+ value: step.value,
696
+ target: step.target,
697
+ mediaFile: step.mediaFile,
698
+ mediaType: step.mediaType
699
+ });
700
+ } else if (step.action === "media") {
701
+ mediaSteps.push({
702
+ mediaFile: step.mediaFile,
703
+ mediaType: step.mediaType,
704
+ target: step.target
705
+ });
706
+ } else if (step.value && step.target) {
707
+ otherValues.set(`${step.action}:${step.target}`, step.value);
708
+ }
709
+ }
710
+ return { uploadSteps, mediaSteps, otherValues };
711
+ }
712
+ function restorePreservedValues(step, preserved, indices) {
713
+ let { uploadIdx, mediaIdx } = indices;
714
+ if (step.action === "upload" && uploadIdx < preserved.uploadSteps.length) {
715
+ const original = preserved.uploadSteps[uploadIdx];
716
+ if (original.value !== void 0) step.value = original.value;
717
+ if (original.target !== void 0) step.target = original.target;
718
+ if (original.mediaFile !== void 0) step.mediaFile = original.mediaFile;
719
+ if (original.mediaType !== void 0) step.mediaType = original.mediaType;
720
+ uploadIdx++;
721
+ }
722
+ if (step.action === "media" && mediaIdx < preserved.mediaSteps.length) {
723
+ const original = preserved.mediaSteps[mediaIdx];
724
+ if (original.mediaFile !== void 0) step.mediaFile = original.mediaFile;
725
+ if (original.mediaType !== void 0) step.mediaType = original.mediaType;
726
+ if (!step.target && original.target) step.target = original.target;
727
+ mediaIdx++;
728
+ }
729
+ if (step.action !== "upload" && step.action !== "media" && step.target) {
730
+ const original = preserved.otherValues.get(`${step.action}:${step.target}`);
731
+ if (original) step.value = original;
732
+ }
733
+ return { uploadIdx, mediaIdx };
734
+ }
735
+
736
+ // src/lib/prompts.ts
737
+ function buildSystemPrompt(maxSteps) {
738
+ let scope;
739
+ if (maxSteps <= 5) {
740
+ scope = `SCOPE: Minimal demo (${maxSteps} steps max) - ONE complete user journey only`;
741
+ } else if (maxSteps <= 8) {
742
+ scope = `SCOPE: Standard demo (${maxSteps} steps max) - One journey with 1-2 options`;
743
+ } else {
744
+ scope = `SCOPE: Extended demo (${maxSteps} steps max) - Journey plus key settings`;
745
+ }
746
+ return `You create demo video scripts for web apps.
747
+
748
+ ${scope}
749
+
750
+ CORE FLOW (always follow this structure):
751
+ 1. wait - Intro (what does this app do?)
752
+ 2. Actions - Complete ONE user journey (upload\u2192process\u2192result)
753
+ 3. wait - Outro (what was accomplished?)
754
+
755
+ TARGET SYNTAX (simplified):
756
+ \u2022 Buttons/Links: Use plain text like "extract frame", "download" (case insensitive, partial match OK)
757
+ \u2022 File inputs: input[type="file"] - ALWAYS use this exact selector
758
+ \u2022 Sliders/Range: input[type="range"] - ALWAYS use this for sliders
759
+ \u2022 Select: select or select[name="..."]
760
+ \u2022 Text inputs: input[type="text"], input[name="..."], or #id
761
+
762
+ SKIP THESE ELEMENTS (never include in demo):
763
+ \u2022 Navigation: Home, About, Contact, Help, Support, FAQ
764
+ \u2022 Auth: Login, Sign In, Sign Up, Register, My Account
765
+ \u2022 Business: Pricing, Plans, Subscribe, Buy, Upgrade
766
+ \u2022 Footer: Privacy, Terms, Blog, Careers, Social media
767
+
768
+ FORBIDDEN:
769
+ \u2022 Repeating same action multiple times (e.g., multiple clicks on same button)
770
+ \u2022 Clicking downloaded files or result images
771
+ \u2022 Vague targets like "slider", "animation", "options" - use CSS selectors instead
772
+ \u2022 More steps than maxSteps allows
773
+
774
+ ACTION FORMATS (STRICT - follow exactly):
775
+ \u2022 click: { "action": "click", "target": "extract frame" } - use simplified button text
776
+ \u2022 type: { "action": "type", "target": "input[name=\\"field\\"]", "value": "text to type" }
777
+ \u2022 upload: { "action": "upload", "target": "input[type=\\"file\\"]", "value": "[file]" }
778
+ \u2022 wait: { "action": "wait" } - for intro/outro captions
779
+ \u2022 scroll: { "action": "scroll", "value": "-80" } - negative=zoom out, positive=zoom in
780
+ \u2022 drag: { "action": "drag", "target": "input[type=\\"range\\"]", "value": "50" }
781
+ \u2022 select: { "action": "select", "target": "select[name=\\"..\\"]", "value": "option" }
782
+ \u2022 check: { "action": "check", "target": "input[type=\\"checkbox\\"]" }
783
+
784
+ EXAMPLES:
785
+ \u2022 Click button: "target": "extract frame" or "target": "download"
786
+ \u2022 File upload: "target": "input[type=\\"file\\"]"
787
+ \u2022 Slider: "target": "input[type=\\"range\\"]"`;
788
+ }
789
+ function buildUserPrompt(url, title, elements, langName, description, maxSteps) {
790
+ const lang = langName ? `
791
+ Language: Write ALL captions in ${langName}.` : "";
792
+ const purpose = description ? `
793
+ Purpose: ${description}` : "";
794
+ const minSteps = Math.max(3, Math.floor(maxSteps * 0.6));
795
+ const clickables = elements.filter((e) => e.action === "click");
796
+ const uploadables = elements.filter((e) => e.action === "upload");
797
+ const typables = elements.filter((e) => e.action === "type");
798
+ const draggables = elements.filter((e) => e.action === "drag");
799
+ const selectables = elements.filter((e) => e.action === "select");
800
+ const checkables = elements.filter((e) => e.action === "check");
801
+ const elementDict = [];
802
+ if (clickables.length > 0) {
803
+ elementDict.push(`CLICK targets (use simplified text):
804
+ ${clickables.map((e) => `\u2022 "${e.target || e.text.toLowerCase()}"`).join("\n")}`);
805
+ }
806
+ if (uploadables.length > 0) {
807
+ elementDict.push(`UPLOAD targets (use selector):
808
+ ${uploadables.map((e) => `\u2022 ${e.selector}`).join("\n")}`);
809
+ }
810
+ if (typables.length > 0) {
811
+ elementDict.push(`TYPE targets (use selector):
812
+ ${typables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`);
813
+ }
814
+ if (draggables.length > 0) {
815
+ elementDict.push(`DRAG targets (use selector):
816
+ ${draggables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`);
817
+ }
818
+ if (selectables.length > 0) {
819
+ elementDict.push(`SELECT targets (use selector):
820
+ ${selectables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`);
821
+ }
822
+ if (checkables.length > 0) {
823
+ elementDict.push(`CHECK targets (use selector):
824
+ ${checkables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`);
825
+ }
826
+ const elementSummary = elementDict.length > 0 ? elementDict.join("\n\n") : "(no interactive elements found)";
827
+ return `URL: ${url}
828
+ Title: ${title}${purpose}
829
+
830
+ AVAILABLE ELEMENTS (grouped by action):
831
+ ${elementSummary}
832
+
833
+ REQUIREMENTS:
834
+ \u2022 ${minSteps}-${maxSteps} steps total (start with wait intro, end with wait outro)
835
+ \u2022 Complete ONE user journey: input \u2192 process \u2192 output
836
+ \u2022 For CLICK: use simplified text target like "extract frame" or "download"
837
+ \u2022 For UPLOAD/DRAG/TYPE: use the CSS selector shown above
838
+ \u2022 For buttons that appear after actions: use partial text like "extract" or "download"${lang}
839
+
840
+ Return JSON:
841
+ {
842
+ "url": "${url}",
843
+ "title": "Short demo title",
844
+ "description": "What this app does",
845
+ "steps": [
846
+ { "id": 1, "action": "wait", "caption": "Welcome! This app..." },
847
+ { "id": 2, "action": "upload", "target": "input[type=\\"file\\"]", "caption": "Upload your file...", "value": "[file]" },
848
+ { "id": 3, "action": "drag", "target": "input[type=\\"range\\"]", "caption": "Adjust the slider...", "value": "50" },
849
+ { "id": 4, "action": "click", "target": "process", "caption": "Click to process..." },
850
+ { "id": 5, "action": "click", "target": "download", "caption": "Download your result..." },
851
+ { "id": 6, "action": "wait", "caption": "Done! Your file is ready." }
852
+ ]
853
+ }
854
+
855
+ TARGET SYNTAX:
856
+ \u2022 Buttons/Links: Plain text like "extract frame", "download" (case insensitive)
857
+ \u2022 File inputs: input[type="file"]
858
+ \u2022 Sliders: input[type="range"]
859
+ \u2022 Text inputs: Use selector from list above`;
860
+ }
861
+
862
+ // src/lib/SparkStripe-feature-gating.ts
863
+ var APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
864
+ var BASE = "https://sparkstripe.com";
865
+ var TIERS = {
866
+ price_1TADnPEf9opkuWid9NtqOYON: "pro",
867
+ price_1TADnPEf9opkuWidgbvvX9ma: "pro",
868
+ price_1TADm7Ef9opkuWid3I9tquIB: "pro",
869
+ price_1TADm8Ef9opkuWid1RSA2wCa: "pro",
870
+ price_1TCREmEf9opkuWidvhuqzqDM: "pro",
871
+ price_1TCREmEf9opkuWidacf1tiC7: "pro"
872
+ };
873
+ var RANK = {
874
+ free: 0,
875
+ pro: 1
876
+ };
877
+ var FEATURES = {
878
+ free: [],
879
+ pro: [
880
+ "Everything in Free",
881
+ "No watermark",
882
+ "Full video composition (voiceover + captions + music + transitions)",
883
+ "Unlimited videos",
884
+ "Priority support"
885
+ ]
886
+ };
887
+ async function _fetch(email, init) {
888
+ const r = await fetch(
889
+ `${BASE}/api/public/subscription/status-public?email=${encodeURIComponent(email)}&app_id=${APP_ID}`,
890
+ init
891
+ );
892
+ if (!r.ok) throw new Error(`spark-stripe: ${r.status}`);
893
+ return r.json();
894
+ }
895
+ var getStatus = (email) => _fetch(email);
896
+ var _cache = /* @__PURE__ */ new Map();
897
+ async function getStatusCached(email) {
898
+ const k = email.toLowerCase(), hit = _cache.get(k);
899
+ if (hit && Date.now() < hit.exp) return hit.data;
900
+ const data = await getStatus(email);
901
+ _cache.set(k, { data, exp: Date.now() + 6e4 });
902
+ return data;
903
+ }
904
+ function getTier(d) {
905
+ if (!d.subscription || !d.registered) return "free";
906
+ if (d.subscription.status === "canceled" || d.subscription.status === "past_due") return "free";
907
+ return TIERS[d.subscription.price_id] ?? "free";
908
+ }
909
+ var isPaid = (d) => getTier(d) !== "free";
910
+ var hasAccess = (d, min) => (RANK[getTier(d)] ?? 0) >= (RANK[min] ?? 0);
911
+ var hasTrialRemaining = (d) => isPaid(d) || !!d.trial && !d.trial.is_expired && d.trial.uses_remaining > 0;
912
+ var showPaywall = (d) => !isPaid(d) && !hasTrialRemaining(d);
913
+ var isCanceling = (d) => !!d.subscription && d.subscription.cancel_at_period_end && d.subscription.status !== "canceled";
914
+ async function getTierByEmail(email) {
915
+ try {
916
+ return getTier(await getStatusCached(email));
917
+ } catch {
918
+ return "free";
919
+ }
920
+ }
921
+ async function isVerified(email) {
922
+ try {
923
+ return (await getStatusCached(email)).verified;
924
+ } catch {
925
+ return false;
926
+ }
927
+ }
928
+ function pricingUrl(opts) {
929
+ const u = new URL(`${BASE}/pricing/${APP_ID}`);
930
+ if (opts?.email) u.searchParams.set("email", opts.email);
931
+ if (opts?.returnUrl) u.searchParams.set("return_url", opts.returnUrl);
932
+ return u.toString();
933
+ }
934
+
935
+ // src/lib/voices.ts
936
+ import { WIDE_VOICES } from "@spark-apps/video-kit";
937
+ export {
938
+ ACTIONS_REQUIRING_TARGET,
939
+ EXCLUDED_LINK_PATTERNS,
940
+ FEATURES,
941
+ FIX_PLAN_SYSTEM_PROMPT,
942
+ INTERACTIVE_SELECTORS,
943
+ INVALID_TARGETS,
944
+ WIDE_VOICES as LANGUAGE_VOICES,
945
+ REFINE_PLAN_SYSTEM_PROMPT,
946
+ buildSystemPrompt,
947
+ buildUserPrompt,
948
+ callAI,
949
+ collectPreservedValues,
950
+ crawlPage,
951
+ filterValidSteps,
952
+ getErrorMessage,
953
+ getLanguageName,
954
+ getStatus,
955
+ getStatusCached,
956
+ getTier,
957
+ getTierByEmail,
958
+ hasAccess,
959
+ hasTrialRemaining,
960
+ hexToAss as hexToASS,
961
+ isCanceling,
962
+ isExcludedLink,
963
+ isInvalidTarget,
964
+ isPaid,
965
+ isValidStep,
966
+ isVerified,
967
+ normalizeUrl,
968
+ parseAIPlanResponse,
969
+ parsePartial,
970
+ pricingUrl,
971
+ restorePreservedValues,
972
+ rgbaToHex,
973
+ shouldSkipLink,
974
+ showPaywall,
975
+ toTitleCase,
976
+ validateStep,
977
+ windowsDrivePathToWsl as windowsPathToWSL
978
+ };