@spark-apps/quickpeek 1.2.4 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import { Page } from 'playwright';
3
3
  /**
4
4
  * Configuration types and defaults for QuickPeek
5
5
  */
6
- declare const VERSION = "1.2.3";
6
+ declare const VERSION = "1.2.6";
7
7
  declare const CONFIG_FILE = "quickpeek.config.json";
8
8
  type UserTier = 'free' | 'pro' | 'elite';
9
9
  /** The one dimension knob users see: wide (16:9) or short (9:16). */
@@ -63,6 +63,12 @@ interface Config {
63
63
  contrast: number;
64
64
  tempo: number;
65
65
  stepDelay: number;
66
+ watermark?: {
67
+ name: string;
68
+ iconPath?: string;
69
+ position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
70
+ opacity?: number;
71
+ };
66
72
  outro?: string;
67
73
  outroCard?: {
68
74
  /** The app the video is about - the card's headline. */
@@ -71,6 +77,8 @@ interface Config {
71
77
  tagline?: string;
72
78
  domain?: string;
73
79
  iconPath?: string;
80
+ /** Play the mark rather than show a still of it. See OutroSubject. */
81
+ animatedMark?: 'vidlet';
74
82
  };
75
83
  /** Tools that made the video, shown small in the corner. */
76
84
  credits?: Array<{
@@ -123,10 +131,25 @@ interface Config {
123
131
  /**
124
132
  * Which bundled CC0 bed to score with when no `path` is given.
125
133
  *
126
- * 'calm' | 'gentle' | 'lofi' - see assets/music/manifest.json. An unknown
134
+ * 'corporate' | 'calm' | 'lofi' - see assets/music/manifest.json. An unknown
127
135
  * mood falls back to the default rather than dropping the music.
128
136
  */
129
137
  mood?: string;
138
+ /**
139
+ * Hold the music back until the closing card.
140
+ *
141
+ * A track written FOR the product is not a bed: ducked under a narrator
142
+ * for a minute it is inaudible, and it is the thing people are meant to
143
+ * hear. With this on, the demo plays dry and the song fades in exactly as
144
+ * the closing card arrives, with the rest of the video silent underneath
145
+ * the voice.
146
+ *
147
+ * ON by default. Music under narration was the single most disliked thing
148
+ * about the output: ducked it is mud, unducked it fights the voice, and
149
+ * either way it makes a 17 second demo sound like a stock video. Set
150
+ * false in quickpeek.config.json to score the whole video.
151
+ */
152
+ outroOnly?: boolean;
130
153
  };
131
154
  timing: {
132
155
  minStepDuration: number;
@@ -257,6 +280,8 @@ interface AIError {
257
280
  host?: string;
258
281
  /** Raw response body (truncated), for the error log. */
259
282
  detail?: string;
283
+ /** The call succeeded but its output was not a usable plan. */
284
+ isParseError?: boolean;
260
285
  }
261
286
  type AIResult = AIResponse | AIError;
262
287
  /** Called before each backoff so the CLI can explain a long pause. */
@@ -299,6 +324,11 @@ declare const SERVER_BACKOFF_MS: number[];
299
324
  * 16 Aug 2026). Only safe where a human is waiting on a spinner.
300
325
  */
301
326
  declare const CLI_BACKOFF_MS: number[];
327
+ declare const RELAY_BASE: string;
328
+ /**
329
+ * Relay mode: exchange the signed-in email for a short-lived relay token (cached)
330
+ */
331
+ declare function getOrRefreshRelayToken(email: string): Promise<string>;
302
332
  /**
303
333
  * Direct Spark AI call using the runtime SPARK_AI_KEY (dev + server-side use).
304
334
  */
@@ -310,7 +340,17 @@ declare function callAI(systemPrompt: string, userPrompt: string, tier?: UserTie
310
340
  */
311
341
  declare function callAIViaRelay(systemPrompt: string, userPrompt: string, email: string, tier?: UserTier, opts?: ChatOptions): Promise<AIResult>;
312
342
  /**
313
- * Parse AI-generated plan JSON from response text
343
+ * Parse AI-generated plan JSON from response text.
344
+ *
345
+ * Delegates to parseAIJson, the same lenient path the CLI's plan step uses.
346
+ * This used to do its own `\{[\s\S]*\}` extraction, which fails on exactly the
347
+ * two things a model does under a token ceiling: stopping mid-array (the
348
+ * greedy match then ends at an inner brace and JSON.parse throws), and
349
+ * spending the whole budget on prose so no braces are emitted at all — the
350
+ * "No valid JSON found in AI response" the web wizard kept showing, where
351
+ * hitting Retry worked only because the next sample happened to be terser.
352
+ * The repair path recovers every complete step from a truncated plan, so a
353
+ * clipped response yields a usable plan instead of an error and a re-crawl.
314
354
  */
315
355
  declare function parseAIPlanResponse(content: string): {
316
356
  success: true;
@@ -329,6 +369,36 @@ declare function parseAIPlanResponse(content: string): {
329
369
  success: false;
330
370
  error: string;
331
371
  };
372
+ /**
373
+ * Ask for a plan and parse it, resampling once if the answer is unusable.
374
+ *
375
+ * parseAIJson recovers a truncated plan, but it cannot recover the other
376
+ * failure mode: a model that spends its whole completion budget reasoning in
377
+ * prose and is cut off before writing a single brace. That is sampling luck,
378
+ * not a broken request — which is why the wizard's Retry button "fixed" it,
379
+ * and why the fix is to resample here rather than to hand the user a red box.
380
+ * Retrying in the route is strictly cheaper than the user retrying: their
381
+ * Retry re-runs the page crawl as well.
382
+ *
383
+ * Exactly one extra attempt. A model that produces prose twice is a prompt or
384
+ * backend problem, and a third round trip only spends the caller's serverless
385
+ * budget on the same answer.
386
+ */
387
+ declare function callAIForPlan(systemPrompt: string, userPrompt: string, tier?: UserTier, opts?: ChatOptions): Promise<{
388
+ success: true;
389
+ plan: {
390
+ title?: string;
391
+ description?: string;
392
+ steps: Array<{
393
+ id?: number;
394
+ action?: string;
395
+ caption?: string;
396
+ target?: string;
397
+ value?: string;
398
+ }>;
399
+ };
400
+ limits: RateLimitInfo;
401
+ } | AIError>;
332
402
  /**
333
403
  * Normalize a URL by adding https:// and stripping www prefix
334
404
  */
@@ -373,6 +443,12 @@ interface Step {
373
443
  highlight?: HighlightMode;
374
444
  mediaFile?: string;
375
445
  mediaType?: 'image' | 'video';
446
+ /**
447
+ * A small JPEG data URL of the file `value` names, attached only when a plan
448
+ * is handed to the browser editor, which cannot read a path on disk. Never
449
+ * written to a plan file; see plan-thumbs.ts.
450
+ */
451
+ thumb?: string;
376
452
  /**
377
453
  * Speak this step's caption but do not burn it.
378
454
  *
@@ -610,4 +686,4 @@ declare function hasVoice(lang: string): boolean;
610
686
  */
611
687
  declare function voiceFor(lang: string, gender?: VoiceGender): string | null;
612
688
 
613
- export { getTierByEmail as $, type AIError as A, type VoiceGender as B, type CaptionStyle as C, DEFAULT_CAPTIONS as D, EXCLUDED_LINK_PATTERNS as E, applyProfile as F, ariaLabelSelector as G, type HighlightMode as H, INTERACTIVE_SELECTORS as I, buildSystemPrompt as J, buildUserPrompt as K, callAI as L, callAIViaRelay as M, NO_CLIP_OUTRO as N, crawlPage as O, PLAN_MAX_TOKENS as P, deStock as Q, RATE_FACTOR as R, SERVER_BACKOFF_MS as S, type Tier as T, type UserTier as U, type VoiceRate as V, defaultCaptionsFor as W, getErrorMessage as X, getLanguageName as Y, getStatus as Z, getStatusCached as _, type CaptionPosition as a, gotoSettled as a0, hasTrialRemaining as a1, hasVoice as a2, idSelector as a3, internationalVoiceFor as a4, isCanceling as a5, isExcludedLink as a6, isPaid as a7, isVerified as a8, normalizeUrl as a9, parseAIPlanResponse as aa, parsePartial as ab, pricingUrl as ac, rgbaToHex as ad, runPooled as ae, shouldSkipLink as af, showPaywall as ag, stripLongDashes as ah, toTitleCase as ai, voiceFor as aj, waitForPlaceholdersGone as ak, getTier as al, type Config as b, type AIResponse as c, type AIResult as d, CLI_BACKOFF_MS as e, CONFIG_FILE as f, type CaptionPreset as g, type CaptionWordStyle as h, type ChatOptions as i, DEFAULT_CONFIG as j, type ElementInfo as k, type ElementType as l, INTERNATIONAL_VOICE as m, type Plan as n, type RateLimitInfo as o, type RetryNotice as p, SIZE_PRESETS as q, type SparkStatus as r, type SparkSubscription as s, type SparkTrial as t, type Step as u, type SuggestedAction as v, VERSION as w, VIDEO_PROFILES as x, type VideoProfileName as y, type VideoSize as z };
689
+ export { getOrRefreshRelayToken as $, type AIError as A, type VideoSize as B, type CaptionStyle as C, DEFAULT_CAPTIONS as D, EXCLUDED_LINK_PATTERNS as E, type VoiceGender as F, applyProfile as G, type HighlightMode as H, INTERACTIVE_SELECTORS as I, ariaLabelSelector as J, buildSystemPrompt as K, buildUserPrompt as L, callAI as M, NO_CLIP_OUTRO as N, callAIForPlan as O, PLAN_MAX_TOKENS as P, callAIViaRelay as Q, RATE_FACTOR as R, SERVER_BACKOFF_MS as S, type Tier as T, type UserTier as U, type VoiceRate as V, crawlPage as W, deStock as X, defaultCaptionsFor as Y, getErrorMessage as Z, getLanguageName as _, type CaptionPosition as a, getStatus as a0, getStatusCached as a1, getTierByEmail as a2, gotoSettled as a3, hasTrialRemaining as a4, hasVoice as a5, idSelector as a6, internationalVoiceFor as a7, isCanceling as a8, isExcludedLink as a9, isPaid as aa, isVerified as ab, normalizeUrl as ac, parseAIPlanResponse as ad, parsePartial as ae, pricingUrl as af, rgbaToHex as ag, runPooled as ah, shouldSkipLink as ai, showPaywall as aj, stripLongDashes as ak, toTitleCase as al, voiceFor as am, waitForPlaceholdersGone as an, getTier as ao, type Config as b, type AIResponse as c, type AIResult as d, CLI_BACKOFF_MS as e, CONFIG_FILE as f, type CaptionPreset as g, type CaptionWordStyle as h, type ChatOptions as i, DEFAULT_CONFIG as j, type ElementInfo as k, type ElementType as l, INTERNATIONAL_VOICE as m, type Plan as n, RELAY_BASE as o, type RateLimitInfo as p, type RetryNotice as q, SIZE_PRESETS as r, type SparkStatus as s, type SparkSubscription as t, type SparkTrial as u, type Step as v, type SuggestedAction as w, VERSION as x, VIDEO_PROFILES as y, type VideoProfileName as z };
package/dist/web.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { n as Plan } from './voices-CPpnWn39.mjs';
2
- export { A as AIError, c as AIResponse, d as AIResult, i as ChatOptions, E as EXCLUDED_LINK_PATTERNS, k as ElementInfo, l as ElementType, H as HighlightMode, I as INTERACTIVE_SELECTORS, m as INTERNATIONAL_VOICE, P as PLAN_MAX_TOKENS, o as RateLimitInfo, r as SparkStatus, s as SparkSubscription, t as SparkTrial, u as Step, v as SuggestedAction, T as Tier, J as buildSystemPrompt, K as buildUserPrompt, L as callAI, O as crawlPage, Q as deStock, X as getErrorMessage, Y as getLanguageName, Z as getStatus, _ as getStatusCached, al as getTier, $ as getTierByEmail, a1 as hasTrialRemaining, a2 as hasVoice, a4 as internationalVoiceFor, a5 as isCanceling, a6 as isExcludedLink, a7 as isPaid, a8 as isVerified, a9 as normalizeUrl, aa as parseAIPlanResponse, ab as parsePartial, ac as pricingUrl, ad as rgbaToHex, ae as runPooled, af as shouldSkipLink, ag as showPaywall, ah as stripLongDashes, ai as toTitleCase, aj as voiceFor } from './voices-CPpnWn39.mjs';
1
+ import { n as Plan } from './voices-5AFt1GDp.mjs';
2
+ export { A as AIError, c as AIResponse, d as AIResult, i as ChatOptions, E as EXCLUDED_LINK_PATTERNS, k as ElementInfo, l as ElementType, H as HighlightMode, I as INTERACTIVE_SELECTORS, m as INTERNATIONAL_VOICE, P as PLAN_MAX_TOKENS, p as RateLimitInfo, s as SparkStatus, t as SparkSubscription, u as SparkTrial, v as Step, w as SuggestedAction, T as Tier, K as buildSystemPrompt, L as buildUserPrompt, M as callAI, O as callAIForPlan, W as crawlPage, X as deStock, Z as getErrorMessage, _ as getLanguageName, a0 as getStatus, a1 as getStatusCached, ao as getTier, a2 as getTierByEmail, a4 as hasTrialRemaining, a5 as hasVoice, a7 as internationalVoiceFor, a8 as isCanceling, a9 as isExcludedLink, aa as isPaid, ab as isVerified, ac as normalizeUrl, ad as parseAIPlanResponse, ae as parsePartial, af as pricingUrl, ag as rgbaToHex, ah as runPooled, ai as shouldSkipLink, aj as showPaywall, ak as stripLongDashes, al as toTitleCase, am as voiceFor } from './voices-5AFt1GDp.mjs';
3
3
  export { MALE_VOICES, MULTILINGUAL_VOICES, WIDE_VOICES, hexToAss as hexToASS, windowsDrivePathToWsl as windowsPathToWSL } from '@spark-apps/video-kit';
4
4
  import 'playwright';
5
5
 
package/dist/web.mjs CHANGED
@@ -1,3 +1,105 @@
1
+ // src/lib/json-repair.ts
2
+ function repairUnicodeEscapes(content) {
3
+ return content.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (match, hex) => {
4
+ const code = Number.parseInt(hex, 16);
5
+ return code <= 1114111 ? String.fromCodePoint(code) : match;
6
+ }).replace(/\\u(?![0-9a-fA-F]{4})/g, "\\\\u");
7
+ }
8
+ var MAX_SALVAGE_ATTEMPTS = 500;
9
+ var CLOSERS = { "{": "}", "[": "]" };
10
+ function salvageTruncatedJson(content) {
11
+ const stack = [];
12
+ const cuts = [];
13
+ let inString = false;
14
+ let escaped = false;
15
+ const record = (index, closesContainer) => cuts.push({ index, open: stack.join(""), closesContainer });
16
+ for (let i = 0; i < content.length; i++) {
17
+ const ch = content[i];
18
+ if (inString) {
19
+ if (escaped) escaped = false;
20
+ else if (ch === "\\") escaped = true;
21
+ else if (ch === '"') {
22
+ inString = false;
23
+ record(i + 1, false);
24
+ }
25
+ continue;
26
+ }
27
+ if (ch === '"') {
28
+ inString = true;
29
+ } else if (ch === "{" || ch === "[") {
30
+ stack.push(ch);
31
+ } else if (ch === "}" || ch === "]") {
32
+ stack.pop();
33
+ record(i + 1, true);
34
+ } else if (ch === ",") {
35
+ record(i, content[i - 1] === "}" || content[i - 1] === "]");
36
+ }
37
+ }
38
+ const tryCut = (cut) => {
39
+ const head = content.slice(0, cut.index).replace(/[\s,]+$/, "");
40
+ if (head.endsWith(":")) return null;
41
+ let closed = head;
42
+ for (let d = cut.open.length - 1; d >= 0; d--) {
43
+ closed += CLOSERS[cut.open[d]];
44
+ }
45
+ try {
46
+ JSON.parse(closed);
47
+ return closed;
48
+ } catch {
49
+ return null;
50
+ }
51
+ };
52
+ const attempts = Math.min(cuts.length, MAX_SALVAGE_ATTEMPTS);
53
+ for (const containerOnly of [true, false]) {
54
+ for (let n = 0; n < attempts; n++) {
55
+ const cut = cuts[cuts.length - 1 - n];
56
+ if (containerOnly && !cut.closesContainer) continue;
57
+ const salvaged = tryCut(cut);
58
+ if (salvaged !== null) return salvaged;
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+ function extractJsonCandidates(content) {
64
+ let text = content.trim();
65
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)(?:```|$)/);
66
+ if (fence?.[1]?.trim()) text = fence[1].trim();
67
+ const start = text.search(/[{[]/);
68
+ if (start === -1) return [];
69
+ const closer = text[start] === "{" ? "}" : "]";
70
+ const end = text.lastIndexOf(closer);
71
+ const candidates = [text.slice(start)];
72
+ if (end > start && end < text.length - 1) candidates.unshift(text.slice(start, end + 1));
73
+ return candidates;
74
+ }
75
+ function parseAIJson(content) {
76
+ const candidates = extractJsonCandidates(content);
77
+ if (candidates.length === 0) return null;
78
+ for (const text of candidates) {
79
+ try {
80
+ return { value: JSON.parse(text), repaired: false };
81
+ } catch {
82
+ }
83
+ }
84
+ for (const text of candidates) {
85
+ try {
86
+ return { value: JSON.parse(repairUnicodeEscapes(text)), repaired: true };
87
+ } catch {
88
+ }
89
+ }
90
+ for (const text of candidates) {
91
+ for (const variant of [repairUnicodeEscapes(text), text]) {
92
+ const salvaged = salvageTruncatedJson(variant);
93
+ if (salvaged === null) continue;
94
+ try {
95
+ return { value: JSON.parse(salvaged), repaired: true };
96
+ } catch {
97
+ }
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+
1
103
  // src/lib/utils.ts
2
104
  import { hexToAss } from "@spark-apps/video-kit";
3
105
  import { windowsDrivePathToWsl } from "@spark-apps/video-kit";
@@ -276,24 +378,26 @@ async function callAI(systemPrompt, userPrompt, tier = "free", opts = {}) {
276
378
  }
277
379
  }
278
380
  function parseAIPlanResponse(content) {
279
- try {
280
- let processedContent = content;
281
- const codeBlockMatch = processedContent.match(/```(?:json)?\s*([\s\S]*?)```/);
282
- if (codeBlockMatch?.[1]) {
283
- processedContent = codeBlockMatch[1].trim();
284
- }
285
- const jsonMatch = processedContent.match(/\{[\s\S]*\}/);
286
- if (!jsonMatch) {
287
- return { success: false, error: "No valid JSON found in AI response" };
288
- }
289
- const parsed = JSON.parse(jsonMatch[0]);
290
- return { success: true, plan: parsed };
291
- } catch (err) {
292
- return {
293
- success: false,
294
- error: `Failed to parse AI response: ${err instanceof Error ? err.message : String(err)}`
295
- };
381
+ const parsed = parseAIJson(content);
382
+ if (!parsed) {
383
+ return { success: false, error: "No valid JSON found in AI response" };
384
+ }
385
+ if (!Array.isArray(parsed.value.steps) || parsed.value.steps.length === 0) {
386
+ return { success: false, error: "AI response has no steps" };
387
+ }
388
+ return { success: true, plan: { ...parsed.value, steps: parsed.value.steps } };
389
+ }
390
+ async function callAIForPlan(systemPrompt, userPrompt, tier = "free", opts = {}) {
391
+ let lastParseError = "No valid JSON found in AI response";
392
+ for (let attempt = 1; attempt <= 2; attempt++) {
393
+ const aiResult = await callAI(systemPrompt, userPrompt, tier, opts);
394
+ if (!aiResult.success) return aiResult;
395
+ const parsed = parseAIPlanResponse(aiResult.content);
396
+ if (parsed.success) return { success: true, plan: parsed.plan, limits: aiResult.limits };
397
+ lastParseError = parsed.error;
398
+ console.error(`Plan parse failed (attempt ${attempt}/2):`, parsed.error);
296
399
  }
400
+ return { success: false, error: lastParseError, isParseError: true };
297
401
  }
298
402
  function normalizeUrl(input) {
299
403
  let url = input.trim();
@@ -301,8 +405,24 @@ function normalizeUrl(input) {
301
405
  return `https://${url}`;
302
406
  }
303
407
 
304
- // src/lib/crawl.ts
408
+ // src/browser/headless.ts
305
409
  import { chromium } from "playwright";
410
+ var NO_BROWSER_MESSAGE = "QuickPeek needs a browser to record and could not find one.\n Run this once, then run QuickPeek again:\n npx playwright install chromium";
411
+ async function launchHeadlessChromium(options = {}) {
412
+ const opts = { ...options, headless: true };
413
+ try {
414
+ return await chromium.launch(opts);
415
+ } catch (bundledError) {
416
+ try {
417
+ return await chromium.launch({ ...opts, channel: "chrome" });
418
+ } catch {
419
+ const detail = bundledError instanceof Error ? bundledError.message : String(bundledError);
420
+ throw new Error(`${NO_BROWSER_MESSAGE}
421
+
422
+ Playwright reported: ${detail}`);
423
+ }
424
+ }
425
+ }
306
426
 
307
427
  // src/lib/scraping.ts
308
428
  var EXCLUDED_LINK_PATTERNS = [
@@ -423,6 +543,7 @@ async function waitForDomStable(page, cap = SETTLE_CAP_MS) {
423
543
  var PLACEHOLDER_POLL_MS = 250;
424
544
  var PLACEHOLDER_CAP_MS = 8e3;
425
545
  var PLACEHOLDER_STATIC_POLLS = 4;
546
+ var PLACEHOLDER_CLEAR_POLLS = 3;
426
547
  var PLACEHOLDER_SELECTOR = [
427
548
  '[aria-busy="true"]',
428
549
  '[role="progressbar"]',
@@ -435,6 +556,7 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
435
556
  const deadline = Date.now() + cap;
436
557
  let previous = -1;
437
558
  let unchanged = 0;
559
+ let clear = 0;
438
560
  while (Date.now() < deadline) {
439
561
  const count = await page.evaluate((selector) => {
440
562
  let visible = 0;
@@ -444,7 +566,12 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
444
566
  }
445
567
  return visible;
446
568
  }, PLACEHOLDER_SELECTOR).catch(() => 0);
447
- if (count === 0) return;
569
+ if (count === 0) {
570
+ if (++clear >= PLACEHOLDER_CLEAR_POLLS) return;
571
+ await page.waitForTimeout(PLACEHOLDER_POLL_MS);
572
+ continue;
573
+ }
574
+ clear = 0;
448
575
  if (count === previous) {
449
576
  if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
450
577
  } else {
@@ -530,7 +657,13 @@ async function crawlPage(url, task = noopTask, storageStatePath, viewport) {
530
657
  return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
531
658
  }
532
659
  const browserTask = task("Launching browser");
533
- const browser = await chromium.launch({ headless: true });
660
+ let browser;
661
+ try {
662
+ browser = await launchHeadlessChromium();
663
+ } catch (err) {
664
+ browserTask.succeed("Could not launch a browser");
665
+ throw err;
666
+ }
534
667
  try {
535
668
  const context = await browser.newContext({
536
669
  ...storageStatePath ? { storageState: storageStatePath } : {},
@@ -1307,6 +1440,7 @@ export {
1307
1440
  buildSystemPrompt,
1308
1441
  buildUserPrompt,
1309
1442
  callAI,
1443
+ callAIForPlan,
1310
1444
  collectPreservedValues,
1311
1445
  crawlPage,
1312
1446
  deStock,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spark-apps/quickpeek",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "description": "Auto-generate demo videos for your web app with AI",
5
5
  "mcpName": "co.quickpeek/quickpeek",
6
6
  "main": "dist/qp.js",
@@ -48,8 +48,15 @@
48
48
  "voiceover",
49
49
  "automation",
50
50
  "cli",
51
- "documentation"
51
+ "documentation",
52
+ "mcp",
53
+ "model-context-protocol"
52
54
  ],
55
+ "homepage": "https://quickpeek.co",
56
+ "bugs": {
57
+ "url": "https://quickpeek.co/docs",
58
+ "email": "support@quickpeek.co"
59
+ },
53
60
  "license": "MIT",
54
61
  "dependencies": {
55
62
  "@modelcontextprotocol/sdk": "^1.29.0",