@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 +62 -13
- package/dist/assets/music/manifest.json +1 -1
- package/dist/index.d.mts +49 -10
- package/dist/index.mjs +69 -22
- package/dist/mcp-tools.mjs +771 -270
- package/dist/qp.js +817 -312
- package/dist/{voices-CPpnWn39.d.mts → voices-ufga0oOn.d.mts} +37 -3
- package/dist/web.d.mts +2 -2
- package/dist/web.mjs +8 -1
- package/package.json +12 -2
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
|
-
[
|
|
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
|
[](https://www.npmjs.com/package/@spark-apps/quickpeek)
|
|
16
15
|
[][license-link]
|
|
17
16
|
[][discord-link]
|
|
18
|
-
[][stars-link]
|
|
19
17
|
|
|
20
18
|
[](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)
|
|
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** |
|
|
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
|
|
67
|
+
### Basic: AI generates the plan
|
|
51
68
|
|
|
52
69
|
```bash
|
|
53
70
|
qp localhost:3000
|
|
54
71
|
```
|
|
55
72
|
|
|
56
|
-
### Script mode
|
|
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
|
|
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
|
|
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
|
-
|
|
198
|
-
|
|
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
|
|
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-
|
|
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
|
|
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 —
|
|
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,
|
|
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
|
-
|
|
438
|
-
|
|
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
|
|
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]
|
|
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 =
|
|
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.
|
|
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
|
-
//
|
|
2191
|
-
//
|
|
2192
|
-
//
|
|
2193
|
-
//
|
|
2194
|
-
tempo
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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 =
|
|
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,
|