@spark-apps/quickpeek 1.2.4 → 1.2.5

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/README.md CHANGED
@@ -1,8 +1,7 @@
1
1
  [license-link]: ./LICENSE
2
2
  [privacy-link]: ./PRIVACY.md
3
3
  [discord-link]: https://discord.gg/mAjHWE3mSp
4
- [stars-link]: ../../stargazers
5
- [issues-link]: ../../issues
4
+ [coffee-link]: https://buymeacoffee.com/spark88
6
5
 
7
6
  <div align="center">
8
7
 
@@ -15,7 +14,6 @@
15
14
  [![npm](https://img.shields.io/npm/v/@spark-apps/quickpeek?style=flat-square&logo=npm&logoColor=white&color=crimson)](https://www.npmjs.com/package/@spark-apps/quickpeek)
16
15
  [![MIT](https://img.shields.io/badge/License-MIT-blueviolet?style=flat-square)][license-link]
17
16
  [![Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord&logoColor=white)][discord-link]
18
- [![GitHub Stars](https://img.shields.io/github/stars/muammar-yacoob/quickpeek?style=flat-square)][stars-link]
19
17
 
20
18
  [![Listed on Sellular](https://sellular.online/badge/quickpeek.svg)](https://sellular.online/badge/quickpeek/go)
21
19
 
@@ -25,18 +23,37 @@
25
23
 
26
24
  ## Quick Start
27
25
 
26
+ Nothing to install:
27
+
28
+ ```bash
29
+ npx @spark-apps/quickpeek https://your-app.com
30
+ ```
31
+
32
+ Or install it for repeat use:
33
+
28
34
  ```bash
29
35
  npm install -g @spark-apps/quickpeek
30
36
  qp localhost:3000
31
37
  ```
32
38
 
33
- **Requirements:** [ffmpeg](https://ffmpeg.org/download.html), [Groq API key](https://console.groq.com/keys) (free)
39
+ **Requirements:** [ffmpeg](https://ffmpeg.org/download.html). Nothing else. The AI
40
+ runs through QuickPeek's hosted relay, so there is no API key to get.
41
+
42
+ **Free tier:** 5 videos on an unconfirmed email, then 10 a day once you confirm
43
+ it. No card either way. The CLI asks for an address on the first run because the
44
+ AI planner runs on our servers and is metered per account; over
45
+ [MCP](#mcp-server) no account is needed at all, because your own model does the
46
+ writing. A free video of 20 seconds or under comes out clean; a longer free video
47
+ carries a QuickPeek watermark. The hosted web recorder at quickpeek.co is
48
+ deliberately different: there, free videos are capped at 20 seconds and are
49
+ always watermarked. [docs/plans-and-pricing.md](docs/plans-and-pricing.md) sets
50
+ the two side by side.
34
51
 
35
52
  ## Features
36
53
 
37
54
  | Feature | Description |
38
55
  |---------|-------------|
39
- | **AI Planning** | Groq AI analyzes your app and generates demo steps |
56
+ | **AI Planning** | AI analyzes your app and generates demo steps |
40
57
  | **Script Mode** | Provide your own narration script, AI maps it to UI actions |
41
58
  | **Capture Mode** | Record your own interactions → replayable plan.json |
42
59
  | **Voice Over** | Free Edge TTS narration in 60+ languages |
@@ -47,13 +64,13 @@ qp localhost:3000
47
64
 
48
65
  ## Examples
49
66
 
50
- ### Basic — AI generates the plan
67
+ ### Basic: AI generates the plan
51
68
 
52
69
  ```bash
53
70
  qp localhost:3000
54
71
  ```
55
72
 
56
- ### Script mode — you write the narration
73
+ ### Script mode: you write the narration
57
74
 
58
75
  ```bash
59
76
  qp localhost:3000 --script narration.txt
@@ -63,12 +80,12 @@ Script file format (one caption per line, `#` for comments):
63
80
 
64
81
  ```
65
82
  # My app demo
66
- We start on the dashboard — your main workspace.
83
+ We start on the dashboard, your main workspace.
67
84
  Let's search for a customer by typing their name.
68
85
  Click the result to open their profile.
69
86
  ```
70
87
 
71
- ### Capture mode — record your own interactions
88
+ ### Capture mode: record your own interactions
72
89
 
73
90
  ```bash
74
91
  qp capture localhost:3000
@@ -177,6 +194,40 @@ Edit `demo/plan.json` to customize your demo. Each step uses one of these action
177
194
 
178
195
  **Target shortcuts:** For buttons and links, use simplified text like `"download"` or `"extract frame"` instead of CSS selectors.
179
196
 
197
+ ## MCP Server
198
+
199
+ QuickPeek ships an MCP server (`quickpeek-mcp`) so an AI agent can record a demo
200
+ directly, with no shelling out to the CLI.
201
+
202
+ ```bash
203
+ claude mcp add quickpeek -- npx -y --package=@spark-apps/quickpeek quickpeek-mcp
204
+ ```
205
+
206
+ Or add it by hand, for any MCP client:
207
+
208
+ ```json
209
+ {
210
+ "mcpServers": {
211
+ "quickpeek": {
212
+ "command": "npx",
213
+ "args": ["-y", "--package=@spark-apps/quickpeek", "quickpeek-mcp"]
214
+ }
215
+ }
216
+ }
217
+ ```
218
+
219
+ Published on the [official MCP registry](https://registry.modelcontextprotocol.io)
220
+ as `co.quickpeek/quickpeek`.
221
+
222
+ **9 tools:** `generate_demo`, `generate_demo_from_script`, `list_demos`,
223
+ `get_demo_config`, `list_voices`, `open_in_editor`, `check_account`, and the
224
+ `start_auth` / `finish_auth` pair for recording an app you have to log into.
225
+
226
+ The server never calls an AI itself. It hands the planning and narration prompts
227
+ back to you, the calling model, and takes your answer as a parameter, so a demo
228
+ costs you no QuickPeek quota and no second-rate model. That is also why this path
229
+ needs no account at all for the first 5 videos, where the CLI asks for an email.
230
+
180
231
  ## Output
181
232
 
182
233
  | File | Description |
@@ -190,12 +241,10 @@ Edit `demo/plan.json` to customize your demo. Each step uses one of these action
190
241
 
191
242
  ## 🌱 Support & Contributions
192
243
 
193
- ⭐ **Star the repo** & I power up like Mario 🍄
194
244
  ☕ **Devs run on coffee** - [Buy me one?][coffee-link]
195
- 💖 **Sponsor** [Your support][stars-link] helps maintain and improve the tool<br>
196
245
  💰 **Crypto tips welcome** - [Tip in crypto](https://tip.md/muammar-yacoob)
197
- 🤝 **Contributions are welcome** - [🍴 Fork][fork-link], improve, PR!
198
- 🎥 **Need help?** <img src="https://img.icons8.com/color/20/youtube-play.png" alt="YouTube" width="20" height="20" style="vertical-align: middle;"> [Setup Tutorial][vid-link] • <img src="https://img.icons8.com/color/20/discord--v2.png" alt="Discord" width="20" height="20" style="vertical-align: middle;"> [Join Discord][discord-link]
246
+ 🎬 **See it work** - [Watch a demo](https://www.youtube.com/shorts/HaibrNvJknY)
247
+ 🐛 **Found a bug or want a feature?** <img src="https://img.icons8.com/color/20/discord--v2.png" alt="Discord" width="20" height="20" style="vertical-align: middle;"> [Join Discord][discord-link] - the repo is private, so Discord is the way in.
199
248
 
200
249
  <div align="center">
201
250
 
@@ -16,7 +16,7 @@
16
16
  "artist": "HoliznaCC0",
17
17
  "license": "CC0 1.0 Universal",
18
18
  "source": "https://freemusicarchive.org/music/holiznacc0/background-music",
19
- "mood": "gentle",
19
+ "mood": "corporate",
20
20
  "duration": 91.777
21
21
  },
22
22
  {
package/dist/index.d.mts CHANGED
@@ -1,17 +1,11 @@
1
- import { U as UserTier, C as CaptionStyle, a as CaptionPosition, b as Config, V as VoiceRate } from './voices-CPpnWn39.mjs';
2
- export { A as AIError, c as AIResponse, d as AIResult, e as CLI_BACKOFF_MS, f as CONFIG_FILE, g as CaptionPreset, h as CaptionWordStyle, i as ChatOptions, D as DEFAULT_CAPTIONS, j as DEFAULT_CONFIG, E as EXCLUDED_LINK_PATTERNS, k as ElementInfo, l as ElementType, H as HighlightMode, I as INTERACTIVE_SELECTORS, m as INTERNATIONAL_VOICE, N as NO_CLIP_OUTRO, P as PLAN_MAX_TOKENS, n as Plan, R as RATE_FACTOR, o as RateLimitInfo, p as RetryNotice, S as SERVER_BACKOFF_MS, q as SIZE_PRESETS, r as SparkStatus, s as SparkSubscription, t as SparkTrial, u as Step, v as SuggestedAction, T as Tier, w as VERSION, x as VIDEO_PROFILES, y as VideoProfileName, z as VideoSize, B as VoiceGender, F as applyProfile, G as ariaLabelSelector, J as buildSystemPrompt, K as buildUserPrompt, L as callAI, M as callAIViaRelay, O as crawlPage, Q as deStock, W as defaultCaptionsFor, X as getErrorMessage, Y as getLanguageName, Z as getStatus, _ as getStatusCached, $ as getTierByEmail, a0 as gotoSettled, a1 as hasTrialRemaining, a2 as hasVoice, a3 as idSelector, 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, ak as waitForPlaceholdersGone } from './voices-CPpnWn39.mjs';
1
+ import { U as UserTier, C as CaptionStyle, a as CaptionPosition, b as Config, V as VoiceRate } from './voices-ufga0oOn.mjs';
2
+ export { A as AIError, c as AIResponse, d as AIResult, e as CLI_BACKOFF_MS, f as CONFIG_FILE, g as CaptionPreset, h as CaptionWordStyle, i as ChatOptions, D as DEFAULT_CAPTIONS, j as DEFAULT_CONFIG, E as EXCLUDED_LINK_PATTERNS, k as ElementInfo, l as ElementType, H as HighlightMode, I as INTERACTIVE_SELECTORS, m as INTERNATIONAL_VOICE, N as NO_CLIP_OUTRO, P as PLAN_MAX_TOKENS, n as Plan, R as RATE_FACTOR, o as RELAY_BASE, p as RateLimitInfo, q as RetryNotice, S as SERVER_BACKOFF_MS, r as SIZE_PRESETS, s as SparkStatus, t as SparkSubscription, u as SparkTrial, v as Step, w as SuggestedAction, T as Tier, x as VERSION, y as VIDEO_PROFILES, z as VideoProfileName, B as VideoSize, F as VoiceGender, G as applyProfile, J as ariaLabelSelector, K as buildSystemPrompt, L as buildUserPrompt, M as callAI, O as callAIViaRelay, Q as crawlPage, W as deStock, X as defaultCaptionsFor, Y as getErrorMessage, Z as getLanguageName, _ as getOrRefreshRelayToken, $ as getStatus, a0 as getStatusCached, a1 as getTierByEmail, a2 as gotoSettled, a3 as hasTrialRemaining, a4 as hasVoice, a5 as idSelector, a6 as internationalVoiceFor, a7 as isCanceling, a8 as isExcludedLink, a9 as isPaid, aa as isVerified, ab as normalizeUrl, ac as parseAIPlanResponse, ad as parsePartial, ae as pricingUrl, af as rgbaToHex, ag as runPooled, ah as shouldSkipLink, ai as showPaywall, aj as stripLongDashes, ak as toTitleCase, al as voiceFor, am as waitForPlaceholdersGone } from './voices-ufga0oOn.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
 
6
6
  /**
7
- * Billing utilities — watermark decisions and upgrade URL generation
7
+ * Billing utilities — upgrade URL generation
8
8
  */
9
-
10
- /**
11
- * Check if watermark should be applied
12
- * Watermark applies to free tier videos longer than 20 seconds
13
- */
14
- declare function shouldWatermark(tier: UserTier, videoDurationSeconds: number): boolean;
15
9
  /**
16
10
  * Get upgrade URL for pricing page
17
11
  */
@@ -73,6 +67,38 @@ interface ComposeOptions {
73
67
  stepTransitions?: StepTransition[] | undefined;
74
68
  /** 'off' pins the deliverable encode to libx264; anything else probes for a GPU. */
75
69
  hwaccel?: 'auto' | 'off' | undefined;
70
+ /**
71
+ * The tempo pass that will run AFTER this compose, if any.
72
+ *
73
+ * That pass speeds the finished file up, and it cannot tell a narrator from
74
+ * a song: a demo scored with a track came out with the music playing 1.2x,
75
+ * which is audible on anything with a beat and is simply wrong - the voice
76
+ * is what the pacing is for. The bed is laid down pre-slowed by the same
77
+ * factor here, so the later speed-up returns it to its natural tempo.
78
+ * atempo preserves pitch in both directions, so the round trip is a tempo
79
+ * change and not a transposition. 1 (the default) changes nothing.
80
+ */
81
+ tempo?: number | undefined;
82
+ /**
83
+ * Where the music bed starts, in seconds on this compose's timeline.
84
+ *
85
+ * 0 (the default) scores the whole video. Set to the closing card's start
86
+ * and the demo plays dry, with the song arriving as the card does - which
87
+ * is what you want when the track is the product's own and is meant to be
88
+ * heard rather than ducked under a narrator for a minute first.
89
+ */
90
+ musicStartSecs?: number | undefined;
91
+ /**
92
+ * Length of the music file, in seconds, so its END can be landed on the
93
+ * video's end.
94
+ *
95
+ * A song has an ending, and a song cut off two thirds through does not: it
96
+ * stops. Given the track's length, the bed is seeked so that its last note
97
+ * falls on the last frame, which is what makes a closing card feel closed
98
+ * rather than interrupted. Omitted, the bed plays from its beginning as
99
+ * before.
100
+ */
101
+ musicDurationSecs?: number | undefined;
76
102
  }
77
103
  interface ConcatVideoOptions {
78
104
  inputPath: string;
@@ -821,6 +847,19 @@ declare function resolveRate(config: Config): VoiceRate;
821
847
  * engine to unescape an entity it may not have escaped itself.
822
848
  */
823
849
  declare function speakSymbols(text: string): string;
850
+ /**
851
+ * A caption as the voice should receive it.
852
+ *
853
+ * Order is load-bearing and each pass documents why above it: symbols before
854
+ * anything (a bare "&" invalidates the SSML), domains before toSpokenForm
855
+ * (matching "vidlet dot app" cannot tell an address from prose), acronyms
856
+ * after (so a TLD spelled here is not re-spelled), numbers last.
857
+ *
858
+ * Exported so the shaping can be asserted directly. It used to be one inline
859
+ * expression, which meant the only way to test a pronunciation was to
860
+ * re-create the chain in the test and hope it stayed in step.
861
+ */
862
+ declare function spokenForm(caption: string): string;
824
863
  declare function generateVoiceover(steps: TTSStep[], config: Config, workDir: string, outputDir: string, logError: (context: string, error: unknown) => Promise<void>, task: (text: string) => {
825
864
  succeed: (text?: string) => void;
826
865
  }, info: (text: string) => void): Promise<AudioResult>;
@@ -904,4 +943,4 @@ declare function buildWatermarkFilters(opts: {
904
943
  endsOnCreditsCard?: boolean;
905
944
  }): string;
906
945
 
907
- export { type ApplyTransitionsOptions, type AudioResult, type BlendTransition, CURSOR_CSS, CURSOR_PNG, CURSOR_SCRIPT, type CaptionCue, CaptionPosition, type CaptionRenderContext, CaptionStyle, type ComposeOptions, type ConcatVideoOptions, Config, EMPTY_SPAN_SPEED, GIF_DEFAULTS, type GifOptions, type LenientParse, MUSIC_BED_LUFS, NARRATION_LEAD_IN_MS, type SilenceSpan, type SpeedUpOptions, type SpliceVideosOptions, type SpokenWord, type StepAudio, type StepSync, type StepTransition, TRIM_EDGE_SILENCE, type TTSStep, type TimeSegment, UserTier, VOICE_LEAD_MS, type VideoOverlay, VoiceRate, type VoiceoverMeta, type WordTiming, alignVoiceoverToRecording, alignWordsToCaption, applyTransitions, atempoChain, buildCompressionFilter, buildConcatArgs, buildGifArgs, buildKeepSegments, buildPaletteArgs, buildRetimeFilter, buildStepTransitions, buildWatermarkFilters, canReuseVoiceover, captionCanvas, composeVideo, compressSilentSpans, concatMedia, concatVideoWithFullFrameOutro, concatVideoWithOutro, convertToGif, coverFilterChain, cutSpansFromVideo, detectBlackSpans, detectFrozenSpans, detectSilences, detectWhiteSpans, estimateWordTimings, extractJsonCandidates, generateASSHeader, generateSilence, generateVoiceover, getAudioCodec, getMediaDuration, getUpgradeUrl, getVideoDimensions, getVideoStreamDuration, hasAudioStream, intersectSpans, isHonestWav, loadExistingVoiceover, loadVoiceoverMeta, mapTimestampsAfterCompression, mergeSpans, parseAIJson, parseWordBoundaries, positionToAlignment, registerFreeUser, renderCaptionsAss, repairUnicodeEscapes, resetVoiceEngine, resolveCaptionFont, resolveCaptions, resolveRate, resolveVoice, retimeSpansInVideo, salvageTruncatedJson, shouldWatermark, speakSymbols, speedUpMiddle, speedUpVideo, spliceVideos, spokenWordTimings, synthesizeSpeech, trimEdgeSilence };
946
+ export { type ApplyTransitionsOptions, type AudioResult, type BlendTransition, CURSOR_CSS, CURSOR_PNG, CURSOR_SCRIPT, type CaptionCue, CaptionPosition, type CaptionRenderContext, CaptionStyle, type ComposeOptions, type ConcatVideoOptions, Config, EMPTY_SPAN_SPEED, GIF_DEFAULTS, type GifOptions, type LenientParse, MUSIC_BED_LUFS, NARRATION_LEAD_IN_MS, type SilenceSpan, type SpeedUpOptions, type SpliceVideosOptions, type SpokenWord, type StepAudio, type StepSync, type StepTransition, TRIM_EDGE_SILENCE, type TTSStep, type TimeSegment, UserTier, VOICE_LEAD_MS, type VideoOverlay, VoiceRate, type VoiceoverMeta, type WordTiming, alignVoiceoverToRecording, alignWordsToCaption, applyTransitions, atempoChain, buildCompressionFilter, buildConcatArgs, buildGifArgs, buildKeepSegments, buildPaletteArgs, buildRetimeFilter, buildStepTransitions, buildWatermarkFilters, canReuseVoiceover, captionCanvas, composeVideo, compressSilentSpans, concatMedia, concatVideoWithFullFrameOutro, concatVideoWithOutro, convertToGif, coverFilterChain, cutSpansFromVideo, detectBlackSpans, detectFrozenSpans, detectSilences, detectWhiteSpans, estimateWordTimings, extractJsonCandidates, generateASSHeader, generateSilence, generateVoiceover, getAudioCodec, getMediaDuration, getUpgradeUrl, getVideoDimensions, getVideoStreamDuration, hasAudioStream, intersectSpans, isHonestWav, loadExistingVoiceover, loadVoiceoverMeta, mapTimestampsAfterCompression, mergeSpans, parseAIJson, parseWordBoundaries, positionToAlignment, registerFreeUser, renderCaptionsAss, repairUnicodeEscapes, resetVoiceEngine, resolveCaptionFont, resolveCaptions, resolveRate, resolveVoice, retimeSpansInVideo, salvageTruncatedJson, speakSymbols, speedUpMiddle, speedUpVideo, spliceVideos, spokenForm, spokenWordTimings, synthesizeSpeech, trimEdgeSilence };
package/dist/index.mjs CHANGED
@@ -434,8 +434,13 @@ async function composeVideo(opts) {
434
434
  musicPath,
435
435
  musicVolume = 0.3,
436
436
  musicTargetLufs = MUSIC_BED_LUFS,
437
- musicFadeIn = 2,
438
- musicFadeOut = 2,
437
+ // Sized for the outro window: the song has 5.5s, so a 2s fade at each end
438
+ // left 1.5s of music anyone could actually hear. Up fast, hold, out slow.
439
+ musicFadeIn = 1,
440
+ musicFadeOut = 1.5,
441
+ tempo = 1,
442
+ musicStartSecs = 0,
443
+ musicDurationSecs = 0,
439
444
  width,
440
445
  height,
441
446
  fps,
@@ -496,7 +501,19 @@ async function composeVideo(opts) {
496
501
  const clipAudioLabel = opts.hasClipAudio ? "[0:a]" : "";
497
502
  const hasVoiceover = !!voiceoverPath;
498
503
  const hasMusic = !!musicPath;
499
- const musicFadeOutStart = Math.max(0, effectiveDuration - musicFadeOut);
504
+ const bedStart = Math.max(0, Math.min(musicStartSecs, Math.max(0, effectiveDuration - 0.5)));
505
+ const bedLength = Math.max(0.5, effectiveDuration - bedStart);
506
+ const bedDelay = bedStart > 0 ? `adelay=${Math.round(bedStart * 1e3)}|${Math.round(bedStart * 1e3)},` : "";
507
+ const bedTempo = tempo > 1 ? 1 / tempo : 1;
508
+ const bedSlow = bedTempo === 1 ? "" : `atempo=${bedTempo.toFixed(6)},`;
509
+ const bedSource = bedLength * bedTempo;
510
+ const bedSeek = musicDurationSecs > bedSource + 0.05 ? musicDurationSecs - bedSource : 0;
511
+ const bedTrimIn = bedSeek > 0 ? `atrim=start=${bedSeek.toFixed(3)},asetpts=PTS-STARTPTS,` : "";
512
+ const bedFadeOut = bedSeek > 0 ? 0 : musicFadeOut * tempo;
513
+ const bedLoop = bedSeek > 0 ? "" : "aloop=loop=-1:size=2e+09,";
514
+ const bedFadeIn = musicFadeIn * tempo;
515
+ const musicFadeOutStart = Math.max(0, bedLength - bedFadeOut);
516
+ const bedFadeOutFilter = bedFadeOut > 0 ? `afade=t=out:st=${musicFadeOutStart}:d=${bedFadeOut},` : "";
500
517
  const vfStr = [...vf, ...encoder.filter ? [encoder.filter] : []].join(",");
501
518
  if (hasVoiceover && hasMusic) {
502
519
  const mixInputs = clipAudioLabel ? 3 : 2;
@@ -512,7 +529,7 @@ async function composeVideo(opts) {
512
529
  // card, which is the one place the bed is the only thing playing.
513
530
  `[voicekey0]apad=whole_dur=${effectiveDuration}[voicekey]`,
514
531
  // Music: normalised to a known level, trimmed, then faded.
515
- `[2:a]aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},loudnorm=I=${musicTargetLufs}:TP=-9:LRA=11,volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut}[bed]`,
532
+ `[2:a]${bedTrimIn}${bedLoop}${bedSlow}atrim=0:${bedLength},loudnorm=I=${musicTargetLufs}:TP=-9:LRA=11,volume=${musicVolume},afade=t=in:st=0:d=${bedFadeIn},${bedFadeOutFilter}${bedDelay}apad=whole_dur=${effectiveDuration}[bed]`,
516
533
  `[bed][voicekey]${DUCK}[music]`,
517
534
  // normalize=0, because amix's default divides every input by the number
518
535
  // of them: adding the bed used to quietly take 6 dB off the narration,
@@ -536,7 +553,7 @@ async function composeVideo(opts) {
536
553
  args.push(...encoder.output);
537
554
  args.push("-c:a", "aac", "-b:a", audioBitrate);
538
555
  } else if (hasMusic) {
539
- const musicFilter = `aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut},${MASTER_CHAIN}`;
556
+ const musicFilter = `${bedTrimIn}${bedLoop}${bedSlow}atrim=0:${bedLength},volume=${musicVolume},afade=t=in:st=0:d=${bedFadeIn},${bedFadeOutFilter}${bedDelay}apad=whole_dur=${effectiveDuration},${MASTER_CHAIN}`;
540
557
  args.push("-vf", vfStr);
541
558
  args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
542
559
  args.push(...encoder.output);
@@ -2152,15 +2169,12 @@ function pricingUrl(opts) {
2152
2169
  }
2153
2170
 
2154
2171
  // src/lib/billing.ts
2155
- function shouldWatermark(tier, videoDurationSeconds) {
2156
- return tier === "free" && videoDurationSeconds > 20;
2157
- }
2158
2172
  function getUpgradeUrl(userEmail) {
2159
2173
  return pricingUrl(userEmail ? { email: userEmail } : void 0);
2160
2174
  }
2161
2175
 
2162
2176
  // src/lib/config.ts
2163
- var VERSION = "1.2.3";
2177
+ var VERSION = "1.2.5";
2164
2178
  var CONFIG_FILE = "quickpeek.config.json";
2165
2179
  var SIZE_PRESETS = {
2166
2180
  wide: { width: 1920, height: 1080, zoom: 1 },
@@ -2187,11 +2201,12 @@ var DEFAULT_CONFIG = {
2187
2201
  // ms
2188
2202
  transitions: "none",
2189
2203
  contrast: 0.25,
2190
- // A demo plays a touch faster than it was recorded, because a recording
2191
- // paced for the narration reads as slow next to anything else on the same
2192
- // feed. 1.2 is the most that still sounds like a person talking; set 1 in
2193
- // quickpeek.config.json for the raw pace.
2194
- tempo: 1.2,
2204
+ // The recording plays at the pace it was filmed at. A tempo above 1 is
2205
+ // multiplied by the narration speed in VIDEO_PROFILES and by the fit
2206
+ // pass's squeeze, and the three together read as frantic rather than
2207
+ // brisk. Speed belongs in one place, so it lives in audioSpeed; set a
2208
+ // tempo in quickpeek.config.json to push the picture on top of that.
2209
+ tempo: 1,
2195
2210
  stepDelay: 200
2196
2211
  // ms delay between steps
2197
2212
  },
@@ -2205,13 +2220,18 @@ var VIDEO_PROFILES = {
2205
2220
  // these bitrates - the encode is pure wall-clock tax on every run.
2206
2221
  wide: {
2207
2222
  video: { width: 1920, height: 1080, zoom: 1.5, fitLimitSecs: 0, maxSpeedup: 1, preset: "veryfast" },
2208
- audioSpeed: 1.6
2223
+ // A tutorial is watched to learn something, so the narrator is allowed to
2224
+ // be brisk but never hurried.
2225
+ audioSpeed: 1.15
2209
2226
  },
2210
2227
  short: {
2211
2228
  // fadeOut 0: the video ends on a card the viewer is meant to read,
2212
2229
  // and a fade takes it away mid-sentence.
2213
2230
  video: { width: 1080, height: 1920, zoom: 3, fitLimitSecs: 60, maxSpeedup: 1.7, preset: "veryfast", fadeOut: 0 },
2214
- audioSpeed: 1.8,
2231
+ // 1.8 was unwatchable: stacked on tempo 1.2 and the outro speedup it ran
2232
+ // the voice at better than twice its natural rate. A short wants energy,
2233
+ // which is the voice and the cutting, not the tape running fast.
2234
+ audioSpeed: 1,
2215
2235
  // A short lives or dies on energy. Ava is the warmest, most animated of
2216
2236
  // the free neural voices; Sonia reads a short like the shipping forecast.
2217
2237
  voice: "en-US-AvaMultilingualNeural"
@@ -2227,11 +2247,15 @@ function applyProfile(config, profile) {
2227
2247
  format: "mp3",
2228
2248
  bitrate: "192k",
2229
2249
  ...config.audio,
2230
- speed: config.audio?.speed ?? preset.audioSpeed,
2250
+ // outroOnly is on unless turned off, matching the video phase. The
2251
+ // speedup buys the song its window, so it applies to the profile's own
2252
+ // pace only: a speed somebody set by hand is the speed they wanted.
2253
+ speed: config.audio?.speed ?? preset.audioSpeed * (config.music?.outroOnly !== false ? OUTRO_SONG_SPEEDUP : 1),
2231
2254
  ...config.audio?.voice || !preset.voice ? {} : { voice: preset.voice }
2232
2255
  }
2233
2256
  };
2234
2257
  }
2258
+ var OUTRO_SONG_SPEEDUP = 1.13;
2235
2259
  var DEFAULT_CAPTIONS = {
2236
2260
  font: "Arial",
2237
2261
  outline: "#000000",
@@ -2409,6 +2433,7 @@ async function waitForDomStable(page, cap = SETTLE_CAP_MS) {
2409
2433
  var PLACEHOLDER_POLL_MS = 250;
2410
2434
  var PLACEHOLDER_CAP_MS = 8e3;
2411
2435
  var PLACEHOLDER_STATIC_POLLS = 4;
2436
+ var PLACEHOLDER_CLEAR_POLLS = 3;
2412
2437
  var PLACEHOLDER_SELECTOR = [
2413
2438
  '[aria-busy="true"]',
2414
2439
  '[role="progressbar"]',
@@ -2421,6 +2446,7 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
2421
2446
  const deadline = Date.now() + cap;
2422
2447
  let previous = -1;
2423
2448
  let unchanged = 0;
2449
+ let clear = 0;
2424
2450
  while (Date.now() < deadline) {
2425
2451
  const count = await page.evaluate((selector) => {
2426
2452
  let visible = 0;
@@ -2430,7 +2456,12 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
2430
2456
  }
2431
2457
  return visible;
2432
2458
  }, PLACEHOLDER_SELECTOR).catch(() => 0);
2433
- if (count === 0) return;
2459
+ if (count === 0) {
2460
+ if (++clear >= PLACEHOLDER_CLEAR_POLLS) return;
2461
+ await page.waitForTimeout(PLACEHOLDER_POLL_MS);
2462
+ continue;
2463
+ }
2464
+ clear = 0;
2434
2465
  if (count === previous) {
2435
2466
  if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
2436
2467
  } else {
@@ -3162,6 +3193,9 @@ function renderHormoziAss(ctx) {
3162
3193
  return `${header}${lines.join("\n")}
3163
3194
  `;
3164
3195
  }
3196
+ function looksLikeAddress(token) {
3197
+ return /^(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?[.,!?]?$/i.test(token);
3198
+ }
3165
3199
  function renderShortsAss(ctx) {
3166
3200
  const { captions, width, height } = ctx;
3167
3201
  const font = resolveCaptionFont(captions.font === "Arial" ? "Arial Black" : captions.font);
@@ -3172,7 +3206,7 @@ function renderShortsAss(ctx) {
3172
3206
  const alignment = positionToAlignment(captions.position);
3173
3207
  const outline = Math.max(2, Math.round(fontSize / 22));
3174
3208
  const upper = true;
3175
- const caseOf = (t) => toCaptionCase(t, upper);
3209
+ const caseOf = (t) => looksLikeAddress(t) ? t : toCaptionCase(t, upper);
3176
3210
  const maxChars = fittingMaxChars(width, fontSize, SIDE_MARGIN, captions.maxChars, upper);
3177
3211
  const header = `[Script Info]
3178
3212
  Title: QuickPeek Demo
@@ -3472,6 +3506,8 @@ asyncio.run(main())
3472
3506
  await writeFile2(tempScriptPath, pythonScript);
3473
3507
  const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
3474
3508
  await execAsync(pythonCmd, { timeout: 3e4 });
3509
+ const written = await stat(outputPath).catch(() => null);
3510
+ if (!written?.size) throw new Error("edge-tts produced no audio");
3475
3511
  return await readWordsSidecar(outputPath);
3476
3512
  } finally {
3477
3513
  await unlink3(tempScriptPath).catch(() => {
@@ -3695,12 +3731,21 @@ function spellAcronyms(text) {
3695
3731
  (m) => m.split("").map((c) => LETTER_SOUNDS[c] ?? c).join(" ")
3696
3732
  );
3697
3733
  }
3734
+ var SPOKEN_TLD = {
3735
+ ai: "ay-eye"
3736
+ };
3698
3737
  function bindDomains(text) {
3699
- return text.replace(/\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi, "$1-dot-$2");
3738
+ return text.replace(/\b([a-z0-9][a-z0-9._+-]*)@(?=[a-z0-9][a-z0-9-]*\.[a-z]{2,24}\b)/gi, "$1-at-").replace(
3739
+ /\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi,
3740
+ (_m, host, tld) => `${host}-dot-${SPOKEN_TLD[tld.toLowerCase()] ?? tld}`
3741
+ );
3700
3742
  }
3701
3743
  function speakSymbols(text) {
3702
3744
  return text.replace(/\s*&\s*/g, " and ").replace(/[<>]/g, " ").replace(/\s{2,}/g, " ").trim();
3703
3745
  }
3746
+ function spokenForm(caption) {
3747
+ return speakNumbers(spellAcronyms(toSpokenForm(bindDomains(speakSymbols(caption)))));
3748
+ }
3704
3749
  async function generateVoiceover(steps, config, workDir, outputDir, logError, task, info) {
3705
3750
  resetVoiceEngine();
3706
3751
  const voice = resolveVoice2(config);
@@ -3747,7 +3792,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError, ta
3747
3792
  }
3748
3793
  const audioPath = join6(audioDir, `step-${stepNum}.mp3`);
3749
3794
  try {
3750
- const spokenText = speakNumbers(spellAcronyms(toSpokenForm(bindDomains(speakSymbols(step.caption)))));
3795
+ const spokenText = spokenForm(step.caption);
3751
3796
  const key = clipKey(spokenText, voice, ratePercent ?? "");
3752
3797
  const cacheDir = join6(outputDir, ".tts-clips");
3753
3798
  const cached = await reuseClip(cacheDir, key, audioPath);
@@ -3855,6 +3900,7 @@ export {
3855
3900
  NO_CLIP_OUTRO,
3856
3901
  PLAN_MAX_TOKENS,
3857
3902
  RATE_FACTOR,
3903
+ RELAY_BASE,
3858
3904
  SERVER_BACKOFF_MS,
3859
3905
  SIZE_PRESETS,
3860
3906
  TRIM_EDGE_SILENCE,
@@ -3906,6 +3952,7 @@ export {
3906
3952
  getErrorMessage,
3907
3953
  getLanguageName,
3908
3954
  getMediaDuration,
3955
+ getOrRefreshRelayToken,
3909
3956
  getStatus,
3910
3957
  getStatusCached,
3911
3958
  getTierByEmail,
@@ -3949,12 +3996,12 @@ export {
3949
3996
  runPooled,
3950
3997
  salvageTruncatedJson,
3951
3998
  shouldSkipLink,
3952
- shouldWatermark,
3953
3999
  showPaywall,
3954
4000
  speakSymbols,
3955
4001
  speedUpMiddle,
3956
4002
  speedUpVideo,
3957
4003
  spliceVideos,
4004
+ spokenForm,
3958
4005
  spokenWordTimings,
3959
4006
  stripLongDashes,
3960
4007
  synthesizeSpeech,