@spark-apps/quickpeek 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,1857 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+
11
+ // src/lib/utils.ts
12
+ function getLanguageName(lang) {
13
+ if (!lang || lang === "en") return null;
14
+ return LANGUAGE_NAMES[lang] || lang.toUpperCase();
15
+ }
16
+ function hexToASS(hex, alpha = 0) {
17
+ const clean = hex.replace("#", "");
18
+ const r = clean.slice(0, 2);
19
+ const g = clean.slice(2, 4);
20
+ const b = clean.slice(4, 6);
21
+ const a = alpha.toString(16).padStart(2, "0").toUpperCase();
22
+ return `&H${a}${b}${g}${r}`.toUpperCase();
23
+ }
24
+ function rgbaToHex(color) {
25
+ if (color.startsWith("#")) return color;
26
+ const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
27
+ if (!match) return "#000000";
28
+ const r = parseInt(match[1], 10).toString(16).padStart(2, "0");
29
+ const g = parseInt(match[2], 10).toString(16).padStart(2, "0");
30
+ const b = parseInt(match[3], 10).toString(16).padStart(2, "0");
31
+ return `#${r}${g}${b}`.toUpperCase();
32
+ }
33
+ function windowsPathToWSL(path) {
34
+ const driveMatch = /^([A-Z]):[/\\]/i.exec(path);
35
+ if (!driveMatch) return null;
36
+ const drive = driveMatch[1].toLowerCase();
37
+ return `/mnt/${drive}/${path.slice(3).replace(/\\/g, "/")}`;
38
+ }
39
+ function parsePartial(partial) {
40
+ if (!partial) return {};
41
+ const parts = partial.split("-");
42
+ if (parts.length !== 2) return {};
43
+ const ts = parts[0] ? parseFloat(parts[0]) : NaN;
44
+ const te = parts[1] ? parseFloat(parts[1]) : NaN;
45
+ const result = {};
46
+ if (!Number.isNaN(ts)) result.trimStart = ts;
47
+ if (!Number.isNaN(te)) result.trimEnd = te;
48
+ return result;
49
+ }
50
+ function getErrorMessage(err) {
51
+ return err instanceof Error ? err.message : String(err);
52
+ }
53
+ function toTitleCase(text) {
54
+ return text.replace(/\w+/g, (word, index) => {
55
+ if (index === 0) {
56
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
57
+ }
58
+ if (LOWERCASE_WORDS.has(word.toLowerCase())) {
59
+ return word.toLowerCase();
60
+ }
61
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
62
+ });
63
+ }
64
+ var LANGUAGE_NAMES, LOWERCASE_WORDS;
65
+ var init_utils = __esm({
66
+ "src/lib/utils.ts"() {
67
+ "use strict";
68
+ LANGUAGE_NAMES = {
69
+ ar: "Arabic",
70
+ es: "Spanish",
71
+ fr: "French",
72
+ de: "German",
73
+ zh: "Chinese",
74
+ ja: "Japanese",
75
+ ko: "Korean",
76
+ pt: "Portuguese",
77
+ ru: "Russian",
78
+ hi: "Hindi",
79
+ tr: "Turkish",
80
+ it: "Italian"
81
+ };
82
+ LOWERCASE_WORDS = /* @__PURE__ */ new Set([
83
+ "a",
84
+ "an",
85
+ "the",
86
+ "and",
87
+ "but",
88
+ "or",
89
+ "nor",
90
+ "for",
91
+ "yet",
92
+ "so",
93
+ "at",
94
+ "by",
95
+ "in",
96
+ "of",
97
+ "on",
98
+ "to",
99
+ "up",
100
+ "as",
101
+ "if",
102
+ "off",
103
+ "per",
104
+ "via",
105
+ "from",
106
+ "into",
107
+ "onto",
108
+ "upon",
109
+ "with",
110
+ "over",
111
+ "under",
112
+ "than"
113
+ ]);
114
+ }
115
+ });
116
+
117
+ // src/lib/watermark.ts
118
+ function buildWatermarkFilters(opts) {
119
+ const { tier, videoDuration } = opts;
120
+ if (tier === "pro") {
121
+ return "";
122
+ }
123
+ if (videoDuration <= WATERMARK_THRESHOLD_SECONDS) {
124
+ return "";
125
+ }
126
+ const filters = [];
127
+ filters.push(`drawtext=text='qp':fontsize=24:fontcolor=white@0.3:x=w-tw-20:y=h-th-20`);
128
+ const endTextStart = Math.max(0, videoDuration - END_TEXT_DURATION);
129
+ const fadeEndTime = endTextStart + END_TEXT_FADE_IN;
130
+ const alphaExpr = `if(lt(t,${endTextStart}),0,if(lt(t,${fadeEndTime}),(t-${endTextStart})*0.6,0.6))`;
131
+ filters.push(
132
+ `drawtext=text='Video made with QuickPeek':fontsize=32:fontcolor=white:x=(w-tw)/2:y=h-80:alpha='${alphaExpr}'`
133
+ );
134
+ return filters.join(",");
135
+ }
136
+ var WATERMARK_THRESHOLD_SECONDS, END_TEXT_DURATION, END_TEXT_FADE_IN;
137
+ var init_watermark = __esm({
138
+ "src/lib/watermark.ts"() {
139
+ "use strict";
140
+ WATERMARK_THRESHOLD_SECONDS = 20;
141
+ END_TEXT_DURATION = 3;
142
+ END_TEXT_FADE_IN = 1;
143
+ }
144
+ });
145
+
146
+ // src/lib/ffmpeg/spawn.ts
147
+ import { spawn } from "child_process";
148
+ function toWSLPath(path) {
149
+ if (!isWindowsRuntime) return path;
150
+ return windowsPathToWSL(path) ?? path.replace(/\\/g, "/");
151
+ }
152
+ function convertArgToWSL(arg) {
153
+ const wslPath = windowsPathToWSL(arg);
154
+ if (wslPath) return wslPath;
155
+ if (arg.includes("\\")) return arg.replace(/\\/g, "/");
156
+ return arg;
157
+ }
158
+ function spawnFFmpeg(cmd, args) {
159
+ if (isWindowsRuntime) {
160
+ const wslArgs = args.map(convertArgToWSL);
161
+ return spawn("wsl", ["-e", cmd, ...wslArgs]);
162
+ }
163
+ return spawn(cmd, args);
164
+ }
165
+ function runFFmpeg(args, errorLabel) {
166
+ return new Promise((resolve3, reject) => {
167
+ const proc = spawnFFmpeg("ffmpeg", args);
168
+ let stderr = "";
169
+ proc.stderr.on("data", (d) => {
170
+ stderr += d.toString();
171
+ });
172
+ proc.on("close", (code) => {
173
+ if (code !== 0) reject(new Error(`${errorLabel}: ${stderr.slice(-500)}`));
174
+ else resolve3();
175
+ });
176
+ proc.on("error", reject);
177
+ });
178
+ }
179
+ var isWindowsRuntime;
180
+ var init_spawn = __esm({
181
+ "src/lib/ffmpeg/spawn.ts"() {
182
+ "use strict";
183
+ init_utils();
184
+ isWindowsRuntime = process.platform === "win32";
185
+ }
186
+ });
187
+
188
+ // src/lib/ffmpeg/compose.ts
189
+ function composeVideo(opts) {
190
+ return new Promise((resolve3, reject) => {
191
+ const {
192
+ videoPath,
193
+ assPath,
194
+ outputPath,
195
+ voiceoverPath,
196
+ musicPath,
197
+ musicVolume = 0.3,
198
+ musicFadeIn = 2,
199
+ musicFadeOut = 2,
200
+ width,
201
+ height,
202
+ fps,
203
+ preset,
204
+ quality,
205
+ fadeIn,
206
+ fadeOut,
207
+ videoDuration,
208
+ audioBitrate = "192k",
209
+ contrast = 0,
210
+ trimStart = 0,
211
+ userTier = "free"
212
+ } = opts;
213
+ const args = ["-y"];
214
+ if (trimStart > 0) {
215
+ args.push("-ss", String(trimStart));
216
+ }
217
+ args.push("-i", videoPath);
218
+ const effectiveDuration = videoDuration - trimStart;
219
+ const voiceoverDuration = opts.mainContentDuration ?? effectiveDuration;
220
+ if (voiceoverPath) args.push("-i", voiceoverPath);
221
+ if (musicPath) args.push("-i", musicPath);
222
+ const vf = [`scale=${width}:${height}`, `fps=${fps}`];
223
+ if (contrast > 0) {
224
+ const ffmpegContrast = 1 + contrast;
225
+ vf.push(`eq=contrast=${ffmpegContrast.toFixed(2)}`);
226
+ }
227
+ const watermarkFilters = buildWatermarkFilters({ tier: userTier, videoDuration: effectiveDuration });
228
+ if (watermarkFilters) {
229
+ vf.push(watermarkFilters);
230
+ }
231
+ const assPathForFilter = toWSLPath(assPath).replace(/:/g, "\\:");
232
+ vf.push(`ass=${assPathForFilter}`);
233
+ if (fadeIn > 0) vf.push(`fade=t=in:st=0:d=${fadeIn}:c=black`);
234
+ if (fadeOut > 0) {
235
+ const videoFadeOutStart = Math.max(0, effectiveDuration - fadeOut);
236
+ vf.push(`fade=t=out:st=${videoFadeOutStart}:d=${fadeOut}:c=black`);
237
+ }
238
+ const crf = Math.round(28 - quality * 10);
239
+ const hasVoiceover = !!voiceoverPath;
240
+ const hasMusic = !!musicPath;
241
+ const musicFadeOutStart = Math.max(0, effectiveDuration - musicFadeOut);
242
+ const vfStr = vf.join(",");
243
+ if (hasVoiceover && hasMusic) {
244
+ const complexFilter = [
245
+ // Voiceover: fade in only (no fade out - stays clear until end)
246
+ `[1:a]apad=whole_dur=${voiceoverDuration},afade=t=in:st=0:d=${fadeIn}[voice]`,
247
+ // Music: fade in and fade out
248
+ `[2:a]aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut}[music]`,
249
+ `[voice][music]amix=inputs=2:duration=longest[aout]`
250
+ ].join(";");
251
+ args.push("-vf", vfStr);
252
+ args.push("-filter_complex", complexFilter, "-map", "0:v", "-map", "[aout]");
253
+ args.push("-c:v", "libx264", "-preset", preset, "-crf", String(crf), "-pix_fmt", "yuv420p");
254
+ args.push("-c:a", "aac", "-b:a", audioBitrate);
255
+ } else if (hasVoiceover) {
256
+ const af = `apad=whole_dur=${voiceoverDuration},afade=t=in:st=0:d=${fadeIn}`;
257
+ args.push("-vf", vfStr);
258
+ args.push("-map", "0:v", "-map", "1:a", "-af", af);
259
+ args.push("-c:v", "libx264", "-preset", preset, "-crf", String(crf), "-pix_fmt", "yuv420p");
260
+ args.push("-c:a", "aac", "-b:a", audioBitrate);
261
+ } else if (hasMusic) {
262
+ const musicFilter = `aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut}`;
263
+ args.push("-vf", vfStr);
264
+ args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
265
+ args.push("-c:v", "libx264", "-preset", preset, "-crf", String(crf), "-pix_fmt", "yuv420p");
266
+ args.push("-c:a", "aac", "-b:a", "192k");
267
+ } else {
268
+ args.push("-vf", vfStr);
269
+ args.push("-c:v", "libx264", "-preset", preset, "-crf", String(crf), "-pix_fmt", "yuv420p");
270
+ }
271
+ args.push(outputPath);
272
+ runFFmpeg(args, "ffmpeg compose failed").then(resolve3, reject);
273
+ });
274
+ }
275
+ var init_compose = __esm({
276
+ "src/lib/ffmpeg/compose.ts"() {
277
+ "use strict";
278
+ init_watermark();
279
+ init_spawn();
280
+ }
281
+ });
282
+
283
+ // src/lib/ffmpeg/compress.ts
284
+ function buildKeepSegments(eventTimestampsSeconds, totalDurationSeconds, preWindow = 0.5, postWindow = 1.2) {
285
+ if (!eventTimestampsSeconds.length) {
286
+ return [{ start: 0, end: totalDurationSeconds }];
287
+ }
288
+ const windows = eventTimestampsSeconds.map((t) => ({
289
+ start: Math.max(0, t - preWindow),
290
+ end: Math.min(totalDurationSeconds, t + postWindow)
291
+ }));
292
+ windows.sort((a, b) => a.start - b.start);
293
+ const merged = [windows[0]];
294
+ for (let i = 1; i < windows.length; i++) {
295
+ const last = merged[merged.length - 1];
296
+ const curr = windows[i];
297
+ if (curr.start <= last.end) {
298
+ last.end = Math.max(last.end, curr.end);
299
+ } else {
300
+ merged.push(curr);
301
+ }
302
+ }
303
+ return merged;
304
+ }
305
+ function mapTimestampsAfterCompression(originalTimestampsSeconds, segments) {
306
+ return originalTimestampsSeconds.map((t) => {
307
+ let offset = 0;
308
+ for (const seg of segments) {
309
+ if (t >= seg.start && t <= seg.end) {
310
+ return offset + (t - seg.start);
311
+ }
312
+ if (t > seg.end) {
313
+ offset += seg.end - seg.start;
314
+ }
315
+ }
316
+ return offset;
317
+ });
318
+ }
319
+ var init_compress = __esm({
320
+ "src/lib/ffmpeg/compress.ts"() {
321
+ "use strict";
322
+ }
323
+ });
324
+
325
+ // src/lib/ffmpeg/media.ts
326
+ function getMediaDuration(filePath) {
327
+ return new Promise((resolve3, reject) => {
328
+ const args = [
329
+ "-v",
330
+ "error",
331
+ "-show_entries",
332
+ "format=duration",
333
+ "-of",
334
+ "default=noprint_wrappers=1:nokey=1",
335
+ filePath
336
+ ];
337
+ const proc = spawnFFmpeg("ffprobe", args);
338
+ let stdout = "";
339
+ let stderr = "";
340
+ proc.stdout.on("data", (data) => {
341
+ stdout += data;
342
+ });
343
+ proc.stderr.on("data", (data) => {
344
+ stderr += data;
345
+ });
346
+ proc.on("close", (code) => {
347
+ if (code !== 0) {
348
+ reject(new Error(`ffprobe failed: ${stderr}`));
349
+ return;
350
+ }
351
+ const duration = parseFloat(stdout.trim());
352
+ resolve3(Math.ceil(duration * 1e3));
353
+ });
354
+ proc.on("error", reject);
355
+ });
356
+ }
357
+ function concatMedia(listPath, output) {
358
+ return runFFmpeg(["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", output], "ffmpeg concat failed");
359
+ }
360
+ function generateSilence(outputPath, durationMs) {
361
+ return runFFmpeg(
362
+ [
363
+ "-y",
364
+ "-f",
365
+ "lavfi",
366
+ "-i",
367
+ "anullsrc=r=24000:cl=mono",
368
+ "-t",
369
+ String(durationMs / 1e3),
370
+ "-c:a",
371
+ "libmp3lame",
372
+ "-b:a",
373
+ "96k",
374
+ outputPath
375
+ ],
376
+ "ffmpeg silence generation failed"
377
+ );
378
+ }
379
+ function speedUpVideo(opts) {
380
+ const { inputPath, outputPath, factor, preset } = opts;
381
+ const videoFilter = `setpts=${(1 / factor).toFixed(3)}*PTS`;
382
+ const audioFilters = [];
383
+ let remaining = factor;
384
+ while (remaining > 1) {
385
+ const tempo = Math.min(remaining, 2);
386
+ audioFilters.push(`atempo=${tempo.toFixed(2)}`);
387
+ remaining /= tempo;
388
+ }
389
+ return runFFmpeg(
390
+ ["-y", "-i", inputPath, "-vf", videoFilter, "-af", audioFilters.join(","), "-preset", preset, outputPath],
391
+ "ffmpeg speedup failed"
392
+ );
393
+ }
394
+ function buildOutroFilterComplex(width, height) {
395
+ const outroWidth = Math.round(width * 0.6);
396
+ const yPos = Math.round(height * 0.2);
397
+ return [
398
+ "[0:v]copy[v0]",
399
+ `[1:v]scale=${outroWidth}:-1[scaled]`,
400
+ `color=black:${width}x${height}:d=5[bg]`,
401
+ `[bg][scaled]overlay=(${width}-${outroWidth})/2:${yPos}[v1]`,
402
+ "[v0][v1]concat=n=2:v=1:a=0[outv]"
403
+ ].join(";");
404
+ }
405
+ function concatVideoWithOutro(opts) {
406
+ const { inputPath, outroPath, outputPath, width, height, preset } = opts;
407
+ const filterComplex = buildOutroFilterComplex(width, height);
408
+ return runFFmpeg(
409
+ [
410
+ "-y",
411
+ "-i",
412
+ inputPath,
413
+ "-i",
414
+ outroPath,
415
+ "-filter_complex",
416
+ filterComplex,
417
+ "-map",
418
+ "[outv]",
419
+ "-c:v",
420
+ "libx264",
421
+ "-preset",
422
+ preset,
423
+ "-crf",
424
+ "18",
425
+ "-pix_fmt",
426
+ "yuv420p",
427
+ "-an",
428
+ outputPath
429
+ ],
430
+ "ffmpeg concat video failed"
431
+ );
432
+ }
433
+ var init_media = __esm({
434
+ "src/lib/ffmpeg/media.ts"() {
435
+ "use strict";
436
+ init_spawn();
437
+ }
438
+ });
439
+
440
+ // src/lib/ffmpeg/splice.ts
441
+ function spliceVideos(opts) {
442
+ return new Promise((resolve3, reject) => {
443
+ const { inputPath, outputPath, overlays, width, height, preset } = opts;
444
+ if (overlays.length === 0) {
445
+ runFFmpeg(["-y", "-i", inputPath, "-c", "copy", outputPath], "ffmpeg copy failed").then(resolve3, reject);
446
+ return;
447
+ }
448
+ const args = ["-y", "-i", inputPath];
449
+ for (const overlay of overlays) {
450
+ args.push("-i", overlay.path);
451
+ }
452
+ const filterParts = [];
453
+ let currentStream = "0:v";
454
+ for (let i = 0; i < overlays.length; i++) {
455
+ const overlay = overlays[i];
456
+ const inputIdx = i + 1;
457
+ const endTime = overlay.startTime + overlay.duration;
458
+ const filters = [];
459
+ if (overlay.trimStart !== void 0 || overlay.trimEnd !== void 0) {
460
+ const trimStart = overlay.trimStart ?? 0;
461
+ const trimEnd = overlay.trimEnd;
462
+ if (trimEnd !== void 0) {
463
+ filters.push(`trim=start=${trimStart}:end=${trimEnd}`);
464
+ } else {
465
+ filters.push(`trim=start=${trimStart}`);
466
+ }
467
+ }
468
+ if (overlay.mode === "stretch") {
469
+ filters.push(`scale=${width}:${height}:force_original_aspect_ratio=increase`);
470
+ filters.push(`crop=${width}:${height}`);
471
+ } else {
472
+ filters.push(`scale=${width}:${height}:force_original_aspect_ratio=decrease`);
473
+ filters.push(`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=#1a1a2e`);
474
+ }
475
+ filters.push("setpts=PTS-STARTPTS");
476
+ filterParts.push(`[${inputIdx}:v]${filters.join(",")}[v${inputIdx}]`);
477
+ const nextStream = i === overlays.length - 1 ? "outv" : `tmp${i}`;
478
+ filterParts.push(
479
+ `[${currentStream}][v${inputIdx}]overlay=0:0:enable='between(t,${overlay.startTime.toFixed(3)},${endTime.toFixed(3)})'[${nextStream}]`
480
+ );
481
+ currentStream = nextStream;
482
+ }
483
+ args.push("-filter_complex", filterParts.join(";"));
484
+ args.push("-map", "[outv]");
485
+ args.push("-map", "0:a?");
486
+ args.push("-c:v", "libx264", "-preset", preset, "-crf", "18", "-pix_fmt", "yuv420p");
487
+ args.push("-c:a", "copy");
488
+ args.push(outputPath);
489
+ runFFmpeg(args, "ffmpeg splice failed").then(resolve3, reject);
490
+ });
491
+ }
492
+ var init_splice = __esm({
493
+ "src/lib/ffmpeg/splice.ts"() {
494
+ "use strict";
495
+ init_spawn();
496
+ }
497
+ });
498
+
499
+ // src/lib/ffmpeg/transitions.ts
500
+ function buildStepTransitions(steps, stepDurations, defaultTransition) {
501
+ const transitions = [];
502
+ let cumulativeTime = 0;
503
+ for (let i = 0; i < steps.length; i++) {
504
+ const step = steps[i];
505
+ const durationMs = stepDurations[i] ?? 0;
506
+ const transitionType = step.transition ?? (defaultTransition !== "none" ? defaultTransition : void 0);
507
+ if (i > 0 && transitionType) {
508
+ transitions.push({
509
+ timestamp: cumulativeTime / 1e3,
510
+ type: transitionType
511
+ });
512
+ }
513
+ cumulativeTime += durationMs;
514
+ }
515
+ return transitions;
516
+ }
517
+ function applyTransitions(opts) {
518
+ return new Promise((resolve3, reject) => {
519
+ const { inputVideo, outputVideo, transitions, videoDuration, preset = "fast" } = opts;
520
+ if (transitions.length === 0) {
521
+ runFFmpeg(["-y", "-i", inputVideo, "-c", "copy", outputVideo], "FFmpeg copy failed").then(resolve3, reject);
522
+ return;
523
+ }
524
+ const sorted = [...transitions].sort((a, b) => a.timestamp - b.timestamp);
525
+ const boundaries = [0, ...sorted.map((t) => t.timestamp), videoDuration];
526
+ const filterParts = [];
527
+ const numSegments = boundaries.length - 1;
528
+ for (let i = 0; i < numSegments; i++) {
529
+ const start = boundaries[i];
530
+ const end = boundaries[i + 1];
531
+ filterParts.push(`[0:v]trim=start=${start.toFixed(3)}:end=${end.toFixed(3)},setpts=PTS-STARTPTS[v${i}]`);
532
+ }
533
+ let currentV = "v0";
534
+ for (let i = 0; i < sorted.length; i++) {
535
+ const trans = sorted[i];
536
+ const duration = trans.duration ?? 0.3;
537
+ const nextIdx = i + 1;
538
+ const segStart = boundaries[i];
539
+ const segEnd = boundaries[i + 1];
540
+ const segDuration = segEnd - segStart;
541
+ const offset = Math.max(0, segDuration - duration);
542
+ const xfadeType = trans.type === "fade" ? "fadeblack" : "fade";
543
+ const outV = i === sorted.length - 1 ? "outv" : `xv${i}`;
544
+ filterParts.push(
545
+ `[${currentV}][v${nextIdx}]xfade=transition=${xfadeType}:duration=${duration.toFixed(3)}:offset=${offset.toFixed(3)}[${outV}]`
546
+ );
547
+ currentV = outV;
548
+ }
549
+ const filterComplex = filterParts.join(";");
550
+ const args = [
551
+ "-y",
552
+ "-i",
553
+ inputVideo,
554
+ "-filter_complex",
555
+ filterComplex,
556
+ "-map",
557
+ "[outv]",
558
+ "-an",
559
+ // No audio - voiceover is added in compose step
560
+ "-c:v",
561
+ "libx264",
562
+ "-preset",
563
+ preset,
564
+ "-crf",
565
+ "23",
566
+ "-pix_fmt",
567
+ "yuv420p",
568
+ outputVideo
569
+ ];
570
+ runFFmpeg(args, "FFmpeg transitions failed").then(resolve3, reject);
571
+ });
572
+ }
573
+ var init_transitions = __esm({
574
+ "src/lib/ffmpeg/transitions.ts"() {
575
+ "use strict";
576
+ init_spawn();
577
+ }
578
+ });
579
+
580
+ // src/lib/ffmpeg/types.ts
581
+ var init_types = __esm({
582
+ "src/lib/ffmpeg/types.ts"() {
583
+ "use strict";
584
+ }
585
+ });
586
+
587
+ // src/lib/ffmpeg/index.ts
588
+ var init_ffmpeg = __esm({
589
+ "src/lib/ffmpeg/index.ts"() {
590
+ "use strict";
591
+ init_compose();
592
+ init_compress();
593
+ init_media();
594
+ init_splice();
595
+ init_transitions();
596
+ init_types();
597
+ }
598
+ });
599
+
600
+ // src/lib/ffmpeg.ts
601
+ var init_ffmpeg2 = __esm({
602
+ "src/lib/ffmpeg.ts"() {
603
+ "use strict";
604
+ init_ffmpeg();
605
+ }
606
+ });
607
+
608
+ // src/lib/tts/helpers.ts
609
+ import { existsSync } from "fs";
610
+ import { readFile, unlink, writeFile } from "fs/promises";
611
+ import { isAbsolute, join, resolve } from "path";
612
+ function toWSLPathForTTS(path) {
613
+ return windowsPathToWSL(path) ?? path;
614
+ }
615
+ function estimateActionDuration(step) {
616
+ if (step.action === "drag" && step.value === "around") {
617
+ return 3e3;
618
+ }
619
+ if (step.action === "drag") {
620
+ return 2e3;
621
+ }
622
+ if (step.action === "scroll") {
623
+ return 1500;
624
+ }
625
+ return 1e3;
626
+ }
627
+ function getCaptionsHash(steps) {
628
+ const captions = steps.map((s) => s.caption || "").join("|");
629
+ let hash = 0;
630
+ for (let i = 0; i < captions.length; i++) {
631
+ hash = (hash << 5) - hash + captions.charCodeAt(i) | 0;
632
+ }
633
+ return hash.toString(16);
634
+ }
635
+ async function getMediaStepDuration(step, baseDir) {
636
+ if (step.action !== "video" && step.action !== "media" || !step.value) {
637
+ return 0;
638
+ }
639
+ const ext = step.value.split(".").pop()?.toLowerCase() || "";
640
+ if (!["mp4", "webm", "mov"].includes(ext)) {
641
+ return 0;
642
+ }
643
+ const mediaPath = isAbsolute(step.value) ? step.value : resolve(baseDir, step.value);
644
+ if (!existsSync(mediaPath)) {
645
+ return 0;
646
+ }
647
+ try {
648
+ if (step.partial) {
649
+ const { trimStart, trimEnd } = parsePartial(step.partial);
650
+ if (trimStart !== void 0 && trimEnd !== void 0) {
651
+ return (trimEnd - trimStart) * 1e3;
652
+ }
653
+ const fullDuration = await getMediaDuration(mediaPath);
654
+ const start = (trimStart ?? 0) * 1e3;
655
+ const end = trimEnd !== void 0 ? trimEnd * 1e3 : fullDuration;
656
+ return end - start;
657
+ }
658
+ return await getMediaDuration(mediaPath);
659
+ } catch {
660
+ return 0;
661
+ }
662
+ }
663
+ async function loadVoiceoverMeta(outputDir) {
664
+ const metaPath = join(outputDir, "voiceover.json");
665
+ try {
666
+ if (!existsSync(metaPath)) return null;
667
+ const content = await readFile(metaPath, "utf-8");
668
+ return JSON.parse(content);
669
+ } catch {
670
+ return null;
671
+ }
672
+ }
673
+ async function saveVoiceoverMeta(outputDir, meta) {
674
+ const metaPath = join(outputDir, "voiceover.json");
675
+ await writeFile(metaPath, JSON.stringify(meta, null, 2));
676
+ }
677
+ async function concatenateAudio(inputs, output, workDir) {
678
+ const formatPath = (f) => {
679
+ const resolved = resolve(f);
680
+ return isWindows ? windowsPathToWSL(resolved) ?? resolved : resolved;
681
+ };
682
+ const listContent = inputs.map((f) => `file '${formatPath(f)}'`).join("\n");
683
+ const listPath = join(workDir, "concat.txt");
684
+ await writeFile(listPath, listContent);
685
+ await concatMedia(listPath, output);
686
+ await unlink(listPath).catch(() => {
687
+ });
688
+ }
689
+ var isWindows;
690
+ var init_helpers = __esm({
691
+ "src/lib/tts/helpers.ts"() {
692
+ "use strict";
693
+ init_ffmpeg2();
694
+ init_utils();
695
+ isWindows = process.platform === "win32";
696
+ }
697
+ });
698
+
699
+ // src/lib/tts/timing.ts
700
+ var timing_exports = {};
701
+ __export(timing_exports, {
702
+ alignVoiceoverToRecording: () => alignVoiceoverToRecording,
703
+ estimateWordTimings: () => estimateWordTimings
704
+ });
705
+ import { existsSync as existsSync2 } from "fs";
706
+ import { join as join2 } from "path";
707
+ function estimateWordTimings(caption, duration) {
708
+ const words = caption.split(/\s+/).filter((w) => w.length > 0);
709
+ const wordDuration = duration / words.length;
710
+ return words.map((word, i) => ({
711
+ word,
712
+ start: Math.round(i * wordDuration),
713
+ end: Math.round((i + 1) * wordDuration)
714
+ }));
715
+ }
716
+ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, workDir, outputDir) {
717
+ let needsRealignment = false;
718
+ for (let i = 0; i < actualRecordingDurations.length; i++) {
719
+ const stepNum = i + 1;
720
+ const voiceDuration = stepAudio.get(stepNum)?.duration ?? 0;
721
+ const recordingDuration = actualRecordingDurations[i] ?? 0;
722
+ if (recordingDuration > voiceDuration) {
723
+ needsRealignment = true;
724
+ break;
725
+ }
726
+ }
727
+ if (!needsRealignment) {
728
+ return stepAudio;
729
+ }
730
+ const audioDir = join2(workDir, "audio");
731
+ const segments = [];
732
+ const alignedStepAudio = /* @__PURE__ */ new Map();
733
+ for (let i = 0; i < actualRecordingDurations.length; i++) {
734
+ const stepNum = i + 1;
735
+ const voiceData = stepAudio.get(stepNum);
736
+ const voiceDuration = voiceData?.duration ?? 0;
737
+ const recordingDuration = actualRecordingDurations[i] ?? 0;
738
+ const targetDuration = Math.max(voiceDuration, recordingDuration);
739
+ const audioPath = join2(audioDir, `step-${stepNum}.mp3`);
740
+ const originalPaddingPath = join2(audioDir, `step-${stepNum}-padding.mp3`);
741
+ const silencePath = join2(audioDir, `step-${stepNum}-silence.mp3`);
742
+ if (existsSync2(audioPath)) {
743
+ segments.push(audioPath);
744
+ if (existsSync2(originalPaddingPath)) {
745
+ segments.push(originalPaddingPath);
746
+ }
747
+ if (recordingDuration > voiceDuration) {
748
+ const alignPaddingPath = join2(audioDir, `step-${stepNum}-align-padding.mp3`);
749
+ await generateSilence(alignPaddingPath, recordingDuration - voiceDuration);
750
+ segments.push(alignPaddingPath);
751
+ }
752
+ } else if (existsSync2(silencePath)) {
753
+ if (targetDuration !== voiceDuration) {
754
+ await generateSilence(silencePath, targetDuration);
755
+ }
756
+ segments.push(silencePath);
757
+ }
758
+ alignedStepAudio.set(stepNum, {
759
+ duration: targetDuration,
760
+ words: voiceData?.words ?? []
761
+ });
762
+ }
763
+ if (segments.length > 0) {
764
+ const voiceoverPath = join2(outputDir, "voiceover.wav");
765
+ await concatenateAudio(segments, voiceoverPath, workDir);
766
+ }
767
+ const existingMeta = await loadVoiceoverMeta(outputDir);
768
+ if (existingMeta) {
769
+ existingMeta.stepDurations = Object.fromEntries(
770
+ Array.from(alignedStepAudio.entries()).map(([k, v]) => [k, v.duration])
771
+ );
772
+ await saveVoiceoverMeta(outputDir, existingMeta);
773
+ }
774
+ return alignedStepAudio;
775
+ }
776
+ var init_timing = __esm({
777
+ "src/lib/tts/timing.ts"() {
778
+ "use strict";
779
+ init_ffmpeg2();
780
+ init_helpers();
781
+ }
782
+ });
783
+
784
+ // src/lib/api-client.ts
785
+ init_utils();
786
+ var SPARK_AI_BASE = "https://sparkbrain.app";
787
+ var SPARK_AI_CHAT = `${SPARK_AI_BASE}/api/chat`;
788
+ var SPARK_AI_TOKEN = `${SPARK_AI_BASE}/api/models/token`;
789
+ var API_KEY = process.env.SPARK_AI_KEY || "";
790
+ var cachedToken = null;
791
+ async function getOrRefreshToken() {
792
+ const now = Date.now();
793
+ if (cachedToken && cachedToken.expiresAt > now + 3e5) {
794
+ return cachedToken.token;
795
+ }
796
+ try {
797
+ const response = await fetch(SPARK_AI_TOKEN, {
798
+ method: "POST",
799
+ headers: { "X-Api-Key": API_KEY }
800
+ });
801
+ const data = await response.json();
802
+ if (!response.ok || !data.success || !data.token) {
803
+ throw new Error(data.error || `Token exchange failed: ${response.status}`);
804
+ }
805
+ cachedToken = {
806
+ token: data.token,
807
+ expiresAt: data.expiresAt || now + 36e5
808
+ };
809
+ return data.token;
810
+ } catch (err) {
811
+ throw new Error(`Failed to get Spark AI token: ${getErrorMessage(err)}`);
812
+ }
813
+ }
814
+ async function callAI(systemPrompt, userPrompt, tier = "free") {
815
+ if (!API_KEY) {
816
+ return {
817
+ success: false,
818
+ error: "AI service is not configured. Please report this issue.",
819
+ isAuthError: true
820
+ };
821
+ }
822
+ try {
823
+ const token = await getOrRefreshToken();
824
+ let lastRateLimitError = null;
825
+ for (let attempt = 1; attempt <= 3; attempt++) {
826
+ try {
827
+ const response = await fetch(SPARK_AI_CHAT, {
828
+ method: "POST",
829
+ headers: {
830
+ "Content-Type": "application/json",
831
+ Authorization: `Bearer ${token}`
832
+ },
833
+ body: JSON.stringify({
834
+ messages: [
835
+ { role: "system", content: systemPrompt },
836
+ { role: "user", content: userPrompt }
837
+ ]
838
+ })
839
+ });
840
+ const data = await response.json();
841
+ if (!response.ok) {
842
+ const error = data.error || `Spark AI error: ${response.status}`;
843
+ if (response.status === 429 && attempt < 3) {
844
+ const retryAfter = response.headers.get("Retry-After");
845
+ const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : 1e3 * Math.pow(2, attempt - 1);
846
+ lastRateLimitError = {
847
+ success: false,
848
+ error,
849
+ isRateLimit: true,
850
+ ...retryAfter ? { retryAfterSeconds: parseInt(retryAfter, 10) } : {}
851
+ };
852
+ await new Promise((resolve3) => setTimeout(resolve3, Math.min(delayMs, 3e4)));
853
+ continue;
854
+ }
855
+ return {
856
+ success: false,
857
+ error,
858
+ isRateLimit: response.status === 429,
859
+ isAuthError: response.status === 401 || response.status === 403
860
+ };
861
+ }
862
+ if (!data.success || !data.response) {
863
+ return {
864
+ success: false,
865
+ error: data.error || "No response from Spark AI"
866
+ };
867
+ }
868
+ return {
869
+ success: true,
870
+ content: data.response,
871
+ limits: {
872
+ used: data.limits?.used ?? 0,
873
+ limit: data.limits?.limit ?? 100,
874
+ tier,
875
+ ...data.limits?.resetAt ? { resetAt: data.limits.resetAt } : {}
876
+ }
877
+ };
878
+ } catch (err) {
879
+ if (attempt === 3) throw err;
880
+ const delayMs = 1e3 * Math.pow(2, attempt - 1);
881
+ await new Promise((resolve3) => setTimeout(resolve3, delayMs));
882
+ }
883
+ }
884
+ return lastRateLimitError || { success: false, error: "Request failed after retries" };
885
+ } catch (err) {
886
+ return { success: false, error: `Spark AI error: ${getErrorMessage(err)}` };
887
+ }
888
+ }
889
+ function parseAIPlanResponse(content) {
890
+ try {
891
+ let processedContent = content;
892
+ const codeBlockMatch = processedContent.match(/```(?:json)?\s*([\s\S]*?)```/);
893
+ if (codeBlockMatch?.[1]) {
894
+ processedContent = codeBlockMatch[1].trim();
895
+ }
896
+ const jsonMatch = processedContent.match(/\{[\s\S]*\}/);
897
+ if (!jsonMatch) {
898
+ return { success: false, error: "No valid JSON found in AI response" };
899
+ }
900
+ const parsed = JSON.parse(jsonMatch[0]);
901
+ return { success: true, plan: parsed };
902
+ } catch (err) {
903
+ return {
904
+ success: false,
905
+ error: `Failed to parse AI response: ${err instanceof Error ? err.message : String(err)}`
906
+ };
907
+ }
908
+ }
909
+ function normalizeUrl(input) {
910
+ let url = input.trim();
911
+ url = url.replace(/^(https?:\/\/)?(www\.)?/i, "");
912
+ return `https://${url}`;
913
+ }
914
+
915
+ // src/lib/SparkStripe-feature-gating.ts
916
+ var APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
917
+ var BASE = "https://sparkstripe.com";
918
+ var TIERS = {
919
+ "price_1TADnPEf9opkuWid9NtqOYON": "pro",
920
+ "price_1TADnPEf9opkuWidgbvvX9ma": "pro",
921
+ "price_1TADm7Ef9opkuWid3I9tquIB": "pro",
922
+ "price_1TADm8Ef9opkuWid1RSA2wCa": "pro",
923
+ "price_1TCREmEf9opkuWidvhuqzqDM": "pro",
924
+ "price_1TCREmEf9opkuWidacf1tiC7": "pro"
925
+ };
926
+ var RANK = {
927
+ "free": 0,
928
+ "pro": 1
929
+ };
930
+ var FEATURES = { "free": [], "pro": [
931
+ "Everything in Free",
932
+ "No watermark",
933
+ "Full video composition (voiceover + captions + music + transitions)",
934
+ "Unlimited videos",
935
+ "Priority support"
936
+ ] };
937
+ async function _fetch(email, init) {
938
+ const r = await fetch(`${BASE}/api/public/subscription/status-public?email=${encodeURIComponent(email)}&app_id=${APP_ID}`, init);
939
+ if (!r.ok) throw new Error(`spark-stripe: ${r.status}`);
940
+ return r.json();
941
+ }
942
+ var getStatus = (email) => _fetch(email);
943
+ var _cache = /* @__PURE__ */ new Map();
944
+ async function getStatusCached(email) {
945
+ const k = email.toLowerCase(), hit = _cache.get(k);
946
+ if (hit && Date.now() < hit.exp) return hit.data;
947
+ const data = await getStatus(email);
948
+ _cache.set(k, { data, exp: Date.now() + 6e4 });
949
+ return data;
950
+ }
951
+ function getTier(d) {
952
+ if (!d.subscription || !d.registered) return "free";
953
+ if (d.subscription.status === "canceled" || d.subscription.status === "past_due") return "free";
954
+ return TIERS[d.subscription.price_id] ?? "free";
955
+ }
956
+ var isPaid = (d) => getTier(d) !== "free";
957
+ var hasAccess = (d, min) => (RANK[getTier(d)] ?? 0) >= (RANK[min] ?? 0);
958
+ var hasTrialRemaining = (d) => isPaid(d) || !!d.trial && !d.trial.is_expired && d.trial.uses_remaining > 0;
959
+ var showPaywall = (d) => !isPaid(d) && !hasTrialRemaining(d);
960
+ var isCanceling = (d) => !!d.subscription && d.subscription.cancel_at_period_end && d.subscription.status !== "canceled";
961
+ async function getTierByEmail(email) {
962
+ try {
963
+ return getTier(await getStatusCached(email));
964
+ } catch {
965
+ return "free";
966
+ }
967
+ }
968
+ async function isVerified(email) {
969
+ try {
970
+ return (await getStatusCached(email)).verified;
971
+ } catch {
972
+ return false;
973
+ }
974
+ }
975
+ function pricingUrl(opts) {
976
+ const u = new URL(`${BASE}/pricing/${APP_ID}`);
977
+ if (opts?.email) u.searchParams.set("email", opts.email);
978
+ if (opts?.returnUrl) u.searchParams.set("return_url", opts.returnUrl);
979
+ return u.toString();
980
+ }
981
+
982
+ // src/lib/billing.ts
983
+ function shouldWatermark(tier, videoDurationSeconds) {
984
+ return tier === "free" && videoDurationSeconds > 20;
985
+ }
986
+ function getUpgradeUrl(userEmail) {
987
+ return pricingUrl(userEmail ? { email: userEmail } : void 0);
988
+ }
989
+
990
+ // src/lib/config.ts
991
+ var VERSION = "1.1.1";
992
+ var CONFIG_FILE = "quickpeek.config.json";
993
+ var DEFAULT_CONFIG = {
994
+ plan: { maxSteps: 8 },
995
+ video: {
996
+ width: 1080,
997
+ height: 1920,
998
+ format: "mp4",
999
+ preset: "fast",
1000
+ quality: 0.5,
1001
+ fps: 30,
1002
+ cursor: true,
1003
+ highlight: "outline",
1004
+ fadeIn: 1500,
1005
+ // ms
1006
+ fadeOut: 1500,
1007
+ // ms
1008
+ transitions: "none",
1009
+ contrast: 0.25,
1010
+ tempo: 1,
1011
+ stepDelay: 200
1012
+ // ms delay between steps
1013
+ },
1014
+ audio: { format: "mp3", bitrate: "192k" },
1015
+ timing: { minStepDuration: 1500 },
1016
+ output: { dir: "./demo" }
1017
+ };
1018
+ var DEFAULT_CAPTIONS = {
1019
+ font: "Arial",
1020
+ outline: "#000000",
1021
+ outlineWidth: 3,
1022
+ shadow: 2,
1023
+ marginBottom: 80,
1024
+ regular: {
1025
+ color: "#FFFFFF",
1026
+ size: 56,
1027
+ bold: true
1028
+ },
1029
+ highlight: {
1030
+ color: "#FFFF00",
1031
+ size: 64,
1032
+ bold: true
1033
+ }
1034
+ };
1035
+
1036
+ // src/lib/crawl.ts
1037
+ import { chromium } from "playwright";
1038
+
1039
+ // src/lib/scraping.ts
1040
+ var EXCLUDED_LINK_PATTERNS = [
1041
+ // Legal & Policy
1042
+ "privacy",
1043
+ "terms",
1044
+ "cookie",
1045
+ "legal",
1046
+ "policy",
1047
+ "disclaimer",
1048
+ "copyright",
1049
+ "gdpr",
1050
+ // Auth & Account
1051
+ "login",
1052
+ "signin",
1053
+ "sign in",
1054
+ "signup",
1055
+ "sign up",
1056
+ "register",
1057
+ "logout",
1058
+ "sign out",
1059
+ "my account",
1060
+ "profile",
1061
+ "settings",
1062
+ // Support
1063
+ "contact",
1064
+ "about",
1065
+ "help",
1066
+ "support",
1067
+ "faq",
1068
+ // Social Media
1069
+ "facebook",
1070
+ "twitter",
1071
+ "linkedin",
1072
+ "instagram",
1073
+ "youtube",
1074
+ "tiktok",
1075
+ "github",
1076
+ // Company
1077
+ "blog",
1078
+ "news",
1079
+ "press",
1080
+ "careers",
1081
+ "jobs",
1082
+ "team",
1083
+ // Developer
1084
+ "docs",
1085
+ "documentation",
1086
+ "api",
1087
+ "developers",
1088
+ // Commerce
1089
+ "pricing",
1090
+ "plans",
1091
+ "subscribe",
1092
+ "newsletter",
1093
+ "upgrade",
1094
+ "buy",
1095
+ "purchase",
1096
+ // Misc
1097
+ "accessibility",
1098
+ "sitemap",
1099
+ "rss",
1100
+ "feed",
1101
+ "home"
1102
+ ];
1103
+ var INTERACTIVE_SELECTORS = [
1104
+ "button",
1105
+ "input",
1106
+ "select",
1107
+ "textarea",
1108
+ "a[href]",
1109
+ '[role="button"]',
1110
+ '[role="checkbox"]',
1111
+ '[role="radio"]',
1112
+ '[role="switch"]',
1113
+ '[role="slider"]',
1114
+ '[role="combobox"]',
1115
+ '[role="menuitem"]',
1116
+ '[type="submit"]',
1117
+ "[onclick]"
1118
+ ];
1119
+ function isExcludedLink(text, href = "") {
1120
+ const lowerText = text.toLowerCase();
1121
+ const lowerHref = href.toLowerCase();
1122
+ return EXCLUDED_LINK_PATTERNS.some((p) => lowerText.includes(p) || lowerHref.includes(p));
1123
+ }
1124
+ function shouldSkipLink(text, href, currentPath) {
1125
+ if (!href || href === "#") return true;
1126
+ try {
1127
+ const linkPath = new URL(href).pathname;
1128
+ if (linkPath === "/" || linkPath === currentPath) return true;
1129
+ } catch {
1130
+ return true;
1131
+ }
1132
+ return isExcludedLink(text, href);
1133
+ }
1134
+
1135
+ // src/lib/crawl.ts
1136
+ function getActionForType(type) {
1137
+ switch (type) {
1138
+ case "button":
1139
+ case "link":
1140
+ return "click";
1141
+ case "file":
1142
+ return "upload";
1143
+ case "range":
1144
+ return "drag";
1145
+ case "select":
1146
+ return "select";
1147
+ case "checkbox":
1148
+ return "check";
1149
+ default:
1150
+ return "type";
1151
+ }
1152
+ }
1153
+ var noopTask = () => ({ succeed: () => {
1154
+ } });
1155
+ async function crawlPage(url, task = noopTask) {
1156
+ const browserTask = task("Launching browser");
1157
+ const browser = await chromium.launch({ headless: true });
1158
+ const page = await browser.newPage();
1159
+ browserTask.succeed("Browser launched");
1160
+ const crawlTask = task("Crawling page");
1161
+ await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
1162
+ const title = await page.title();
1163
+ const rawElements = await page.evaluate(`
1164
+ (() => {
1165
+ const items = [];
1166
+
1167
+ // Helper to get parent context
1168
+ const getCtx = (el) => {
1169
+ let p = el.parentElement;
1170
+ while (p && p !== document.body) {
1171
+ if (p.id) return '#' + p.id;
1172
+ const cls = Array.from(p.classList).find(c =>
1173
+ /toolbar|header|footer|sidebar|panel|modal|dialog|nav|menu|controls|actions/i.test(c)
1174
+ );
1175
+ if (cls) return '.' + cls;
1176
+ if (['header', 'footer', 'nav', 'main', 'aside', 'form'].includes(p.tagName.toLowerCase())) {
1177
+ return p.tagName.toLowerCase();
1178
+ }
1179
+ p = p.parentElement;
1180
+ }
1181
+ return '';
1182
+ };
1183
+
1184
+ // Buttons (use textContent to catch hidden elements too)
1185
+ document.querySelectorAll('button, [role="button"]').forEach(el => {
1186
+ const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
1187
+ const testId = el.getAttribute('data-testid');
1188
+ const ariaLabel = el.getAttribute('aria-label');
1189
+ if (!text && !testId && !ariaLabel) return;
1190
+ items.push({ type: 'button', text: text || ariaLabel || testId, testId, ctx: getCtx(el) });
1191
+ });
1192
+
1193
+ // Inputs (including file inputs, range sliders, checkboxes, selects)
1194
+ document.querySelectorAll('input, textarea, select').forEach(el => {
1195
+ if (el.type === 'hidden') return;
1196
+ const testId = el.getAttribute('data-testid');
1197
+ const inputType = el.type || 'text';
1198
+
1199
+ // Get label text for any input type
1200
+ let labelText = '';
1201
+ if (el.labels?.[0]) {
1202
+ labelText = el.labels[0].textContent?.trim() || '';
1203
+ } else if (el.id) {
1204
+ const label = document.querySelector('label[for="' + el.id + '"]');
1205
+ if (label) labelText = label.textContent?.trim() || '';
1206
+ }
1207
+
1208
+ const text = labelText || el.placeholder || el.name || el.id || '';
1209
+
1210
+ // Include file, range, and checkbox inputs even without text
1211
+ const alwaysInclude = ['file', 'range', 'checkbox'].includes(inputType);
1212
+ if (!text && !testId && !alwaysInclude) return;
1213
+
1214
+ items.push({
1215
+ type: inputType,
1216
+ text: text || (inputType === 'file' ? 'file upload' : inputType === 'range' ? 'slider' : inputType === 'checkbox' ? 'checkbox' : testId),
1217
+ testId,
1218
+ id: el.id,
1219
+ name: el.name,
1220
+ placeholder: el.placeholder,
1221
+ isFile: inputType === 'file'
1222
+ });
1223
+ });
1224
+
1225
+ // Links (use textContent to catch hidden elements too)
1226
+ document.querySelectorAll('a[href]').forEach(el => {
1227
+ const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
1228
+ if (text && text.length >= 2) items.push({ type: 'link', text });
1229
+ });
1230
+
1231
+ return items.slice(0, 40);
1232
+ })()
1233
+ `);
1234
+ const filteredElements = rawElements.filter((el) => {
1235
+ if (!el.text) return true;
1236
+ const text = el.text.toLowerCase().trim();
1237
+ return !EXCLUDED_LINK_PATTERNS.some((pattern) => text.includes(pattern));
1238
+ });
1239
+ const btnTexts = {};
1240
+ for (const el of filteredElements) {
1241
+ if (el.type === "button" && el.text) {
1242
+ btnTexts[el.text] = (btnTexts[el.text] || 0) + 1;
1243
+ }
1244
+ }
1245
+ const elements = filteredElements.map((el) => {
1246
+ let selector;
1247
+ if (el.testId) {
1248
+ selector = `[data-testid="${el.testId}"]`;
1249
+ } else if (el.isFile || el.type === "file") {
1250
+ selector = 'input[type="file"]';
1251
+ } else if (el.type === "range") {
1252
+ selector = el.id ? `#${el.id}` : 'input[type="range"]';
1253
+ } else if (el.type === "checkbox") {
1254
+ selector = el.id ? `#${el.id}` : 'input[type="checkbox"]';
1255
+ } else if (el.type === "button") {
1256
+ if (btnTexts[el.text] === 1) {
1257
+ selector = `text="${el.text}"`;
1258
+ } else {
1259
+ selector = el.ctx ? `${el.ctx} >> text="${el.text}"` : `button:has-text("${el.text}")`;
1260
+ }
1261
+ } else if (el.type === "link") {
1262
+ selector = `text="${el.text}"`;
1263
+ } else if (el.id) {
1264
+ selector = `#${el.id}`;
1265
+ } else if (el.name) {
1266
+ selector = `[name="${el.name}"]`;
1267
+ } else if (el.placeholder) {
1268
+ selector = `[placeholder="${el.placeholder}"]`;
1269
+ } else {
1270
+ selector = `text="${el.text}"`;
1271
+ }
1272
+ let normalizedType;
1273
+ if (el.isFile || el.type === "file") {
1274
+ normalizedType = "file";
1275
+ } else if (el.type === "range") {
1276
+ normalizedType = "range";
1277
+ } else if (el.type === "checkbox") {
1278
+ normalizedType = "checkbox";
1279
+ } else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
1280
+ normalizedType = "select";
1281
+ } else if (el.type === "button" || el.type === "link") {
1282
+ normalizedType = el.type;
1283
+ } else {
1284
+ normalizedType = "input";
1285
+ }
1286
+ const action = getActionForType(normalizedType);
1287
+ const target = normalizedType === "file" ? selector : (el.text || "").toLowerCase();
1288
+ return { type: normalizedType, text: el.text, selector, action, target };
1289
+ });
1290
+ await browser.close();
1291
+ crawlTask.succeed(`Found ${elements.length} interactive elements`);
1292
+ return { title, elements };
1293
+ }
1294
+
1295
+ // src/lib/cursor.ts
1296
+ var CURSOR_PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAAEsBAMAAAB01OGNAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAtUExURUdwTAAAAAAAAAAAAAAAAAAAAAAAAB0dHVZWVtvb2/X19f///6mpqXV1df////vm1ewAAAAGdFJOUwAltTfpcLO208EAAAABYktHRA5vvTBPAAAAB3RJTUUH6QwFFREKplBWTAAABqdJREFUeNq93c+OFFUUx/EeCHud6H7A4HpCOu6NiWsWZh5Ax8YFbAdNWBPxBcCo+yGjD+CAW1ywh0Rexu6uulX3Vp269/c7f7o3Bpjhk6+H6TrU0LdXR6sDPI4/OgCyvn0I5NsDpKw3B0hZbw6Qst4cIGWLxKdskfiUHRKeskPCU/ZIdMoeiU7pkOCUDglO6ZHYlB6JTUlIaEpCQlMGJDJlQCJTRiQwZUQCUzIkLiVD4lJyJCwlR8JSCiQqpUCiUkokKKVEglImSEzKBIlJmSIhKT3yY2hKjzx7GpnSI7+8iExJyN+RKQl5FZkyIJEpAxKZMiKBKSMSmJIhcSkZEpeSI2EpORKWUiBRKQUSlVIiQSklEpQyQWJSJkhMyhQJSZkiISkzJCJlhkSkzJGAlDkSkCIg/ikC4p8iIe4pEuKeIiLeKSLinSIjziky4pyygPimLCC+KUuIa8oS4pqyiHimLCKeKcuIY8oy4phSQfxSKohfSg1xS6khbilVxCulinil1BGnlDrilNJAfFIaiE9KC3FJaSEuKU3EI6WJeKS0EYeUNuKQAiD2FACxpyCIOQVBzCkQYk2BEGsKhhhTMMSYAiK2FBCxpaCIKQVFTCkwYkmBEUsKjhhScMSQQiD6FALRpzCIOoVB1CkUok2hEG0KhyhTOESZQiK6FBLRpbCIKoVFVCk0okmhEU0KjyhSeESRokD4FAXCp2gQOkWD0CkqhE1RIWyKDiFTdAiZokS4FCXCpWgRKkWLUClqhElRI0yKHiFS9AiRYkDwFAOCp1gQOMWCwCkmBE0xIWiKDQFTbAiYYkSwFCOCpVgRKMWKQClmBEkxI0iKHQFS7AiQ4oC0UxyQdooH0kzxQJopLkgrxQVppfggjRQfpJHihNRTnJB6ihdSTfFCqiluSC3FDaml+CGVFD+kkuKILKc4IsspnshiiieymOKKLKW4Iq8u5RRf5FpO8UUWpuKMyCnOiJzijYgp3oiY4o5IKe6IlEIhL38FHr9fzFK47/0On195PL6YpVDIX4CRP1IKhbxGSvLHbQUy/snhUrg/XW9IpE/hkOcs0qVwyD8s0qWQ349nJ9+lcAg9+S6FfFqhJ79PIZFLGtmlkMifPLJNYf/5wgWvfAYg11f5j951H39+B3/cBZA/3uY/+nccJ/5oItdPi19Lkz91RV5snuQ/fN0jJ57I9svvkTT5rzyR7RX7wW/C5L93RPbPI8Xknysmj7zWQZz8fTeke0J8aJ088vqTR1czl5t8Fel/w2Ly12/6oXghaRn8IEz+nJh8DRkuUc/yn02XYGLy0Ou0ysn3X44+/75rvNb+IE3+OxdkXM/Lr3l+8stIvjSIkz91QMaQzeYn2+QXkWL7KSafnogd/jlcHiJPHn8iXkLKNe7BW9Pk26/73T/+y3/tkp38AjLdR4vJp+XrxIhMQuRL8Jc2ZLZYF5OnL8Gt18ff6f9bTJ5dvkQku6vw6Ub4AHb5ap1ZcPS5MHn2Eiwhxe2Rs8rk0Utw8xyJ/gNMy5eAlPd5bkiTJ5ev5tkeN7+uTP6+FpncsEqTt6zd7fNWpMmTy9cMmd15+0SYPLl8tc/AuVWZPLh8TZH5LURx8unOFzb59rlEafKG5WuCSPdC0+SvhMljyxdwVtSx9DVPTb5ExPvTafIfpMmf8oh4pz1NXr92F8jCtz+kyVPLF3Km2jfdz+mXrxxZCLFPHjnnTpw8s3xlyFLIMHn18gWdPVibPLJ8jchiyPBB5eTf4ZOHzoNMky+eiInlCzrZMk1eWruR5Qs6o/NImjxxCcZOGz2rTB64BGPnpkqTJ5Yv7ARY4/LVIz9XQ4bJK5cv7FRe49oNni/cT165fIEnJdfW7vYTMXjm8w3pax6ePHh6tW35As/hltfui8ZniUglvDb55vKFno0urd3wJRg95b26dp8SSO3/rTh5dPlCT94XJ48uX/B7CKTJa5Yv+N0QLJOH39fBsnavwRBx7X75vv/kExBpftn2k38oGOAraYBnuS+6DxwvwaPRmvwaDZmt3ZkBvlwHuFBPJp8breVrjYZM1u7SaEx+jYaUk58Y58gLj5i/lO0mPzE2d4GZYPetxrWbNDoEu68wrN2ssUfAG3Bp7X7CGnsE/R7F2UZ6tI0dwt5Dpo3dJ8LfbDlWGivm3dpuKY0V875z6RLMGtw76J3pDO69ANc6Y3WPCBn+wkUaq48JY3giJg3ukU8+ysgnH2ek5SvUGCYfaaTJhxr95GONbvLRxm7y4cb2iTjeWN08gLFaaYz/AWAIeJtzdhugAAAAAElFTkSuQmCC";
1297
+
1298
+ // src/lib/index.ts
1299
+ init_ffmpeg2();
1300
+
1301
+ // src/lib/prompts.ts
1302
+ init_utils();
1303
+ function buildSystemPrompt(maxSteps) {
1304
+ let scope;
1305
+ if (maxSteps <= 5) {
1306
+ scope = `SCOPE: Minimal demo (${maxSteps} steps max) - ONE complete user journey only`;
1307
+ } else if (maxSteps <= 8) {
1308
+ scope = `SCOPE: Standard demo (${maxSteps} steps max) - One journey with 1-2 options`;
1309
+ } else {
1310
+ scope = `SCOPE: Extended demo (${maxSteps} steps max) - Journey plus key settings`;
1311
+ }
1312
+ return `You create demo video scripts for web apps.
1313
+
1314
+ ${scope}
1315
+
1316
+ CORE FLOW (always follow this structure):
1317
+ 1. wait - Intro (what does this app do?)
1318
+ 2. Actions - Complete ONE user journey (upload\u2192process\u2192result)
1319
+ 3. wait - Outro (what was accomplished?)
1320
+
1321
+ TARGET SYNTAX (simplified):
1322
+ \u2022 Buttons/Links: Use plain text like "extract frame", "download" (case insensitive, partial match OK)
1323
+ \u2022 File inputs: input[type="file"] - ALWAYS use this exact selector
1324
+ \u2022 Sliders/Range: input[type="range"] - ALWAYS use this for sliders
1325
+ \u2022 Select: select or select[name="..."]
1326
+ \u2022 Text inputs: input[type="text"], input[name="..."], or #id
1327
+
1328
+ SKIP THESE ELEMENTS (never include in demo):
1329
+ \u2022 Navigation: Home, About, Contact, Help, Support, FAQ
1330
+ \u2022 Auth: Login, Sign In, Sign Up, Register, My Account
1331
+ \u2022 Business: Pricing, Plans, Subscribe, Buy, Upgrade
1332
+ \u2022 Footer: Privacy, Terms, Blog, Careers, Social media
1333
+
1334
+ FORBIDDEN:
1335
+ \u2022 Repeating same action multiple times (e.g., multiple clicks on same button)
1336
+ \u2022 Clicking downloaded files or result images
1337
+ \u2022 Vague targets like "slider", "animation", "options" - use CSS selectors instead
1338
+ \u2022 More steps than maxSteps allows
1339
+
1340
+ ACTION FORMATS (STRICT - follow exactly):
1341
+ \u2022 click: { "action": "click", "target": "extract frame" } - use simplified button text
1342
+ \u2022 type: { "action": "type", "target": "input[name=\\"field\\"]", "value": "text to type" }
1343
+ \u2022 upload: { "action": "upload", "target": "input[type=\\"file\\"]", "value": "[file]" }
1344
+ \u2022 wait: { "action": "wait" } - for intro/outro captions
1345
+ \u2022 scroll: { "action": "scroll", "value": "-80" } - negative=zoom out, positive=zoom in
1346
+ \u2022 drag: { "action": "drag", "target": "input[type=\\"range\\"]", "value": "50" }
1347
+ \u2022 select: { "action": "select", "target": "select[name=\\"..\\"]", "value": "option" }
1348
+ \u2022 check: { "action": "check", "target": "input[type=\\"checkbox\\"]" }
1349
+
1350
+ EXAMPLES:
1351
+ \u2022 Click button: "target": "extract frame" or "target": "download"
1352
+ \u2022 File upload: "target": "input[type=\\"file\\"]"
1353
+ \u2022 Slider: "target": "input[type=\\"range\\"]"`;
1354
+ }
1355
+ function buildUserPrompt(url, title, elements, langName, description, maxSteps) {
1356
+ const lang = langName ? `
1357
+ Language: Write ALL captions in ${langName}.` : "";
1358
+ const purpose = description ? `
1359
+ Purpose: ${description}` : "";
1360
+ const minSteps = Math.max(3, Math.floor(maxSteps * 0.6));
1361
+ const clickables = elements.filter((e) => e.action === "click");
1362
+ const uploadables = elements.filter((e) => e.action === "upload");
1363
+ const typables = elements.filter((e) => e.action === "type");
1364
+ const draggables = elements.filter((e) => e.action === "drag");
1365
+ const selectables = elements.filter((e) => e.action === "select");
1366
+ const checkables = elements.filter((e) => e.action === "check");
1367
+ const elementDict = [];
1368
+ if (clickables.length > 0) {
1369
+ elementDict.push(`CLICK targets (use simplified text):
1370
+ ${clickables.map((e) => `\u2022 "${e.target || e.text.toLowerCase()}"`).join("\n")}`);
1371
+ }
1372
+ if (uploadables.length > 0) {
1373
+ elementDict.push(`UPLOAD targets (use selector):
1374
+ ${uploadables.map((e) => `\u2022 ${e.selector}`).join("\n")}`);
1375
+ }
1376
+ if (typables.length > 0) {
1377
+ elementDict.push(`TYPE targets (use selector):
1378
+ ${typables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`);
1379
+ }
1380
+ if (draggables.length > 0) {
1381
+ elementDict.push(`DRAG targets (use selector):
1382
+ ${draggables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`);
1383
+ }
1384
+ if (selectables.length > 0) {
1385
+ elementDict.push(`SELECT targets (use selector):
1386
+ ${selectables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`);
1387
+ }
1388
+ if (checkables.length > 0) {
1389
+ elementDict.push(`CHECK targets (use selector):
1390
+ ${checkables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`);
1391
+ }
1392
+ const elementSummary = elementDict.length > 0 ? elementDict.join("\n\n") : "(no interactive elements found)";
1393
+ return `URL: ${url}
1394
+ Title: ${title}${purpose}
1395
+
1396
+ AVAILABLE ELEMENTS (grouped by action):
1397
+ ${elementSummary}
1398
+
1399
+ REQUIREMENTS:
1400
+ \u2022 ${minSteps}-${maxSteps} steps total (start with wait intro, end with wait outro)
1401
+ \u2022 Complete ONE user journey: input \u2192 process \u2192 output
1402
+ \u2022 For CLICK: use simplified text target like "extract frame" or "download"
1403
+ \u2022 For UPLOAD/DRAG/TYPE: use the CSS selector shown above
1404
+ \u2022 For buttons that appear after actions: use partial text like "extract" or "download"${lang}
1405
+
1406
+ Return JSON:
1407
+ {
1408
+ "url": "${url}",
1409
+ "title": "Short demo title",
1410
+ "description": "What this app does",
1411
+ "steps": [
1412
+ { "id": 1, "action": "wait", "caption": "Welcome! This app..." },
1413
+ { "id": 2, "action": "upload", "target": "input[type=\\"file\\"]", "caption": "Upload your file...", "value": "[file]" },
1414
+ { "id": 3, "action": "drag", "target": "input[type=\\"range\\"]", "caption": "Adjust the slider...", "value": "50" },
1415
+ { "id": 4, "action": "click", "target": "process", "caption": "Click to process..." },
1416
+ { "id": 5, "action": "click", "target": "download", "caption": "Download your result..." },
1417
+ { "id": 6, "action": "wait", "caption": "Done! Your file is ready." }
1418
+ ]
1419
+ }
1420
+
1421
+ TARGET SYNTAX:
1422
+ \u2022 Buttons/Links: Plain text like "extract frame", "download" (case insensitive)
1423
+ \u2022 File inputs: input[type="file"]
1424
+ \u2022 Sliders: input[type="range"]
1425
+ \u2022 Text inputs: Use selector from list above`;
1426
+ }
1427
+
1428
+ // src/lib/styles.ts
1429
+ var CURSOR_MOVE_DURATION = 800;
1430
+ var CURSOR_CSS = `
1431
+ #qp-cursor {
1432
+ position: fixed;
1433
+ width: 32px;
1434
+ height: 38px;
1435
+ background: url("${CURSOR_PNG}") no-repeat;
1436
+ background-size: contain;
1437
+ pointer-events: none;
1438
+ z-index: 2147483647;
1439
+ transform: translate(-3px, -3px);
1440
+ transition: left ${CURSOR_MOVE_DURATION}ms ease-out, top ${CURSOR_MOVE_DURATION}ms ease-out, transform 0.08s ease-out;
1441
+ filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3));
1442
+ }
1443
+ #qp-cursor.clicking {
1444
+ transform: translate(-3px, -3px) scale(0.9);
1445
+ }
1446
+ `;
1447
+ var CURSOR_SCRIPT = `
1448
+ (function() {
1449
+ if (document.getElementById('qp-cursor')) return;
1450
+ const cursor = document.createElement('div');
1451
+ cursor.id = 'qp-cursor';
1452
+ document.body.appendChild(cursor);
1453
+
1454
+ document.addEventListener('mousemove', (e) => {
1455
+ cursor.style.left = e.clientX + 'px';
1456
+ cursor.style.top = e.clientY + 'px';
1457
+ });
1458
+
1459
+ document.addEventListener('mousedown', () => cursor.classList.add('clicking'));
1460
+ document.addEventListener('mouseup', () => cursor.classList.remove('clicking'));
1461
+
1462
+ // Start at center
1463
+ cursor.style.left = window.innerWidth / 2 + 'px';
1464
+ cursor.style.top = window.innerHeight / 2 + 'px';
1465
+ })();
1466
+ `;
1467
+ function generateASSHeader(width, height, font, size, primaryColor, secondaryColor, outlineColor, backColor, bold, outlineWidth, shadow, marginBottom) {
1468
+ return `[Script Info]
1469
+ Title: QuickPeek Demo
1470
+ ScriptType: v4.00+
1471
+ PlayResX: ${width}
1472
+ PlayResY: ${height}
1473
+
1474
+ [V4+ Styles]
1475
+ Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
1476
+ Style: Karaoke,${font},${size},${primaryColor},${secondaryColor},${outlineColor},${backColor},${bold ? 1 : 0},0,0,0,100,100,0,0,1,${outlineWidth},${shadow},2,40,40,${marginBottom},1
1477
+
1478
+ [Events]
1479
+ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
1480
+ `;
1481
+ }
1482
+
1483
+ // src/lib/subscription-client.ts
1484
+ async function registerFreeUser(email) {
1485
+ const APP_ID2 = process.env.SPARK_APP_ID ?? "quickpeek";
1486
+ try {
1487
+ const response = await fetch(`https://sparkstripe.com/api/register-free`, {
1488
+ method: "POST",
1489
+ headers: { "Content-Type": "application/json" },
1490
+ body: JSON.stringify({ email, app_id: APP_ID2, app_name: "QuickPeek" })
1491
+ });
1492
+ const text = await response.text();
1493
+ let data = {};
1494
+ if (text) {
1495
+ try {
1496
+ data = JSON.parse(text);
1497
+ } catch {
1498
+ if (!response.ok) {
1499
+ return { success: false, error: `Server error: ${response.status} ${response.statusText}` };
1500
+ }
1501
+ }
1502
+ }
1503
+ if (!response.ok) {
1504
+ return { success: false, error: data.error || `Registration failed: ${response.status}` };
1505
+ }
1506
+ return {
1507
+ success: true,
1508
+ pendingVerification: data.pendingVerification ?? true,
1509
+ alreadyVerified: data.alreadyVerified ?? false
1510
+ };
1511
+ } catch (err) {
1512
+ return { success: false, error: err instanceof Error ? err.message : "Network error" };
1513
+ }
1514
+ }
1515
+
1516
+ // src/lib/tts/cache.ts
1517
+ init_helpers();
1518
+ import { join as join3 } from "path";
1519
+ function canReuseVoiceover(steps, voice, existingMeta) {
1520
+ if (!existingMeta) {
1521
+ return { canReuse: false, reason: "No existing voiceover found" };
1522
+ }
1523
+ if (existingMeta.voice !== voice) {
1524
+ return { canReuse: false, reason: `Voice changed (${existingMeta.voice} \u2192 ${voice})` };
1525
+ }
1526
+ if (existingMeta.stepCount !== steps.length) {
1527
+ return { canReuse: false, reason: `Step count changed (${existingMeta.stepCount} \u2192 ${steps.length})` };
1528
+ }
1529
+ const currentHash = getCaptionsHash(steps);
1530
+ if (existingMeta.captionsHash === currentHash) {
1531
+ return { canReuse: true };
1532
+ }
1533
+ const currentLength = steps.reduce((sum, s) => sum + (s.caption?.length || 0), 0);
1534
+ const lengthDiff = Math.abs(currentLength - existingMeta.totalCaptionLength);
1535
+ const threshold = existingMeta.totalCaptionLength * 0.1;
1536
+ if (lengthDiff <= threshold) {
1537
+ return { canReuse: true, reason: "Captions similar enough" };
1538
+ }
1539
+ return { canReuse: false, reason: "Captions changed significantly" };
1540
+ }
1541
+ async function loadExistingVoiceover(steps, outputDir, meta, config) {
1542
+ const { estimateWordTimings: estimateWordTimings2 } = await Promise.resolve().then(() => (init_timing(), timing_exports));
1543
+ const voiceoverPath = join3(outputDir, "voiceover.wav");
1544
+ const stepAudio = /* @__PURE__ */ new Map();
1545
+ for (let i = 0; i < steps.length; i++) {
1546
+ const step = steps[i];
1547
+ const stepNum = i + 1;
1548
+ const duration = meta.stepDurations[stepNum] || config.timing.minStepDuration;
1549
+ stepAudio.set(stepNum, {
1550
+ duration,
1551
+ words: estimateWordTimings2(step.caption || "", duration)
1552
+ });
1553
+ }
1554
+ return { voiceoverPath, stepAudio };
1555
+ }
1556
+
1557
+ // src/lib/tts/generate.ts
1558
+ init_ffmpeg2();
1559
+ import { mkdir } from "fs/promises";
1560
+ import { join as join5 } from "path";
1561
+
1562
+ // src/lib/voices.ts
1563
+ var LANGUAGE_VOICES = {
1564
+ en: "en-GB-SoniaNeural",
1565
+ ar: "ar-LB-LaylaNeural",
1566
+ es: "es-ES-ElviraNeural",
1567
+ fr: "fr-FR-DeniseNeural",
1568
+ de: "de-DE-KatjaNeural",
1569
+ it: "it-IT-ElsaNeural",
1570
+ pt: "pt-BR-FranciscaNeural",
1571
+ ru: "ru-RU-SvetlanaNeural",
1572
+ zh: "zh-CN-XiaoxiaoNeural",
1573
+ ja: "ja-JP-NanamiNeural",
1574
+ ko: "ko-KR-SunHiNeural",
1575
+ hi: "hi-IN-SwaraNeural",
1576
+ tr: "tr-TR-EmelNeural",
1577
+ nl: "nl-NL-ColetteNeural",
1578
+ pl: "pl-PL-AgnieszkaNeural",
1579
+ sv: "sv-SE-SofieNeural",
1580
+ he: "he-IL-HilaNeural",
1581
+ th: "th-TH-PremwadeeNeural",
1582
+ vi: "vi-VN-HoaiMyNeural",
1583
+ id: "id-ID-GadisNeural",
1584
+ ms: "ms-MY-YasminNeural",
1585
+ fil: "fil-PH-BlessicaNeural",
1586
+ uk: "uk-UA-PolinaNeural",
1587
+ cs: "cs-CZ-VlastaNeural",
1588
+ el: "el-GR-AthinaNeural",
1589
+ ro: "ro-RO-AlinaNeural",
1590
+ hu: "hu-HU-NoemiNeural",
1591
+ da: "da-DK-ChristelNeural",
1592
+ fi: "fi-FI-NooraNeural",
1593
+ no: "nb-NO-PernilleNeural",
1594
+ sk: "sk-SK-ViktoriaNeural",
1595
+ bg: "bg-BG-KalinaNeural",
1596
+ hr: "hr-HR-GabrijelaNeural",
1597
+ sl: "sl-SI-PetraNeural",
1598
+ et: "et-EE-AnuNeural",
1599
+ lv: "lv-LV-EveritaNeural",
1600
+ lt: "lt-LT-OnaNeural",
1601
+ bn: "bn-IN-TanishaaNeural",
1602
+ ta: "ta-IN-PallaviNeural",
1603
+ te: "te-IN-ShrutiNeural",
1604
+ mr: "mr-IN-AarohiNeural",
1605
+ gu: "gu-IN-DhwaniNeural",
1606
+ kn: "kn-IN-SapnaNeural",
1607
+ ml: "ml-IN-SobhanaNeural",
1608
+ ur: "ur-PK-UzmaNeural",
1609
+ fa: "fa-IR-DilaraNeural",
1610
+ sw: "sw-KE-ZuriNeural",
1611
+ af: "af-ZA-AdriNeural",
1612
+ ca: "ca-ES-JoanaNeural",
1613
+ cy: "cy-GB-NiaNeural",
1614
+ ga: "ga-IE-OrlaNeural",
1615
+ mt: "mt-MT-GraceNeural",
1616
+ is: "is-IS-GudrunNeural",
1617
+ mk: "mk-MK-MarijaNeural",
1618
+ sr: "sr-RS-SophieNeural",
1619
+ bs: "bs-BA-VesnaNeural",
1620
+ sq: "sq-AL-AnilaNeural",
1621
+ am: "am-ET-MekdesNeural",
1622
+ az: "az-AZ-BabekNeural",
1623
+ ka: "ka-GE-EkaNeural",
1624
+ kk: "kk-KZ-AigulNeural",
1625
+ mn: "mn-MN-YesUINeural",
1626
+ ne: "ne-NP-HemkalaNeural",
1627
+ ps: "ps-AF-LatifaNeural",
1628
+ si: "si-LK-ThiliniNeural",
1629
+ so: "so-SO-UbaxNeural",
1630
+ zu: "zu-ZA-ThandoNeural"
1631
+ };
1632
+
1633
+ // src/lib/tts/generate.ts
1634
+ init_helpers();
1635
+
1636
+ // src/lib/tts/synthesize.ts
1637
+ init_helpers();
1638
+ import { exec } from "child_process";
1639
+ import { writeFile as writeFile2 } from "fs/promises";
1640
+ import { join as join4, resolve as resolve2 } from "path";
1641
+ import { promisify } from "util";
1642
+ var execAsync = promisify(exec);
1643
+ async function synthesizeSpeech(text, outputPath, voice) {
1644
+ const tempScriptPath = join4(resolve2(outputPath, ".."), `tts-${Date.now()}.py`);
1645
+ const wslScriptPath = isWindows ? toWSLPathForTTS(resolve2(tempScriptPath)) : tempScriptPath;
1646
+ const wslOutputPath = isWindows ? toWSLPathForTTS(resolve2(outputPath)) : outputPath;
1647
+ const pythonScript = `
1648
+ import asyncio
1649
+ from edge_tts import Communicate
1650
+
1651
+ async def main():
1652
+ text = ${JSON.stringify(text)}
1653
+ voice = ${JSON.stringify(voice)}
1654
+ output = ${JSON.stringify(wslOutputPath)}
1655
+ communicate = Communicate(text, voice)
1656
+ await communicate.save(output)
1657
+
1658
+ asyncio.run(main())
1659
+ `;
1660
+ try {
1661
+ await writeFile2(tempScriptPath, pythonScript);
1662
+ const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
1663
+ await execAsync(pythonCmd, { timeout: 3e4 });
1664
+ await import("fs/promises").then((fs) => fs.unlink(tempScriptPath).catch(() => {
1665
+ }));
1666
+ return;
1667
+ } catch (pythonErr) {
1668
+ await import("fs/promises").then((fs) => fs.unlink(tempScriptPath).catch(() => {
1669
+ }));
1670
+ const pythonMsg = pythonErr instanceof Error ? pythonErr.message : String(pythonErr);
1671
+ const maxRetries = 2;
1672
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1673
+ try {
1674
+ const { MsEdgeTTS, OUTPUT_FORMAT } = await import("msedge-tts");
1675
+ const tts = new MsEdgeTTS();
1676
+ await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3);
1677
+ await tts.toFile(outputPath, text);
1678
+ return;
1679
+ } catch (nodeErr) {
1680
+ if (attempt === maxRetries) {
1681
+ const nodeMsg = nodeErr instanceof Error ? nodeErr.message : String(nodeErr);
1682
+ throw new Error(`TTS failed (Python: ${pythonMsg.slice(0, 100)}, Node: ${nodeMsg.slice(0, 100)})`);
1683
+ }
1684
+ await new Promise((r) => setTimeout(r, 500));
1685
+ }
1686
+ }
1687
+ }
1688
+ }
1689
+
1690
+ // src/lib/tts/generate.ts
1691
+ init_timing();
1692
+ function estimateDurationFromText(text, wordsPerMinute = 150) {
1693
+ const words = text.split(/\s+/).filter((w) => w.length > 0).length;
1694
+ return Math.max(Math.round(words / wordsPerMinute * 60 * 1e3), 1500);
1695
+ }
1696
+ function resolveVoice(config) {
1697
+ if (config.audio?.voice) return config.audio.voice;
1698
+ if (config.lang) return LANGUAGE_VOICES[config.lang] || null;
1699
+ return null;
1700
+ }
1701
+ async function generateVoiceover(steps, config, workDir, outputDir, logError, task, info) {
1702
+ const voice = resolveVoice(config);
1703
+ if (!voice) {
1704
+ const stepAudio2 = /* @__PURE__ */ new Map();
1705
+ for (let i = 0; i < steps.length; i++) {
1706
+ const step = steps[i];
1707
+ const textDuration = step.caption ? estimateDurationFromText(step.caption) : 0;
1708
+ const actionDuration = estimateActionDuration(step);
1709
+ const manualDuration = (step.duration ?? 0) * 1e3;
1710
+ const mediaDuration = await getMediaStepDuration(step, outputDir);
1711
+ const duration = Math.max(
1712
+ textDuration,
1713
+ actionDuration,
1714
+ manualDuration,
1715
+ mediaDuration,
1716
+ config.timing.minStepDuration
1717
+ );
1718
+ stepAudio2.set(i + 1, { duration, words: step.caption ? estimateWordTimings(step.caption, duration) : [] });
1719
+ }
1720
+ info("No voice configured");
1721
+ return { voiceoverPath: "", stepAudio: stepAudio2 };
1722
+ }
1723
+ const audioDir = join5(workDir, "audio");
1724
+ await mkdir(audioDir, { recursive: true });
1725
+ const segments = [];
1726
+ const stepAudio = /* @__PURE__ */ new Map();
1727
+ const ttsTask = task("Generating voiceover");
1728
+ for (let idx = 0; idx < steps.length; idx++) {
1729
+ const step = steps[idx];
1730
+ const stepNum = idx + 1;
1731
+ if (!step.caption) {
1732
+ const actionDuration = estimateActionDuration(step);
1733
+ const manualDuration = (step.duration ?? 0) * 1e3;
1734
+ const mediaDuration = await getMediaStepDuration(step, outputDir);
1735
+ const silenceDuration = Math.max(actionDuration, manualDuration, mediaDuration, config.timing.minStepDuration);
1736
+ const silencePath = join5(audioDir, `step-${stepNum}-silence.mp3`);
1737
+ await generateSilence(silencePath, silenceDuration);
1738
+ segments.push(silencePath);
1739
+ stepAudio.set(stepNum, { duration: silenceDuration, words: [] });
1740
+ continue;
1741
+ }
1742
+ const audioPath = join5(audioDir, `step-${stepNum}.mp3`);
1743
+ if (segments.length > 0) await new Promise((r) => setTimeout(r, 200));
1744
+ try {
1745
+ await synthesizeSpeech(step.caption, audioPath, voice);
1746
+ segments.push(audioPath);
1747
+ const audioDuration = await getMediaDuration(audioPath);
1748
+ const actionDuration = estimateActionDuration(step);
1749
+ const manualDuration = (step.duration ?? 0) * 1e3;
1750
+ const mediaDuration = await getMediaStepDuration(step, outputDir);
1751
+ const stepDuration = Math.max(
1752
+ audioDuration,
1753
+ config.timing.minStepDuration,
1754
+ actionDuration,
1755
+ manualDuration,
1756
+ mediaDuration
1757
+ );
1758
+ if (stepDuration > audioDuration) {
1759
+ const paddingPath = join5(audioDir, `step-${stepNum}-padding.mp3`);
1760
+ await generateSilence(paddingPath, stepDuration - audioDuration);
1761
+ segments.push(paddingPath);
1762
+ }
1763
+ stepAudio.set(stepNum, { duration: stepDuration, words: estimateWordTimings(step.caption, audioDuration) });
1764
+ } catch (err) {
1765
+ await logError(`TTS Step ${stepNum}`, err);
1766
+ stepAudio.set(stepNum, { duration: config.timing.minStepDuration, words: [] });
1767
+ }
1768
+ }
1769
+ const stepsWithCaptions = steps.filter((s) => s.caption).length;
1770
+ ttsTask.succeed(`${segments.length}/${stepsWithCaptions} clips`);
1771
+ if (segments.length === 0) {
1772
+ return { voiceoverPath: "", stepAudio };
1773
+ }
1774
+ const concatTask = task("Combining audio");
1775
+ await mkdir(outputDir, { recursive: true });
1776
+ const voiceoverPath = join5(outputDir, "voiceover.wav");
1777
+ await concatenateAudio(segments, voiceoverPath, workDir);
1778
+ concatTask.succeed("Done");
1779
+ const meta = {
1780
+ captionsHash: getCaptionsHash(steps),
1781
+ totalCaptionLength: steps.reduce((sum, s) => sum + (s.caption?.length || 0), 0),
1782
+ stepCount: steps.length,
1783
+ voice,
1784
+ stepDurations: Object.fromEntries(Array.from(stepAudio.entries()).map(([k, v]) => [k, v.duration]))
1785
+ };
1786
+ await saveVoiceoverMeta(outputDir, meta);
1787
+ return { voiceoverPath, stepAudio };
1788
+ }
1789
+
1790
+ // src/lib/tts/index.ts
1791
+ init_timing();
1792
+
1793
+ // src/lib/index.ts
1794
+ init_utils();
1795
+ init_watermark();
1796
+ export {
1797
+ CONFIG_FILE,
1798
+ CURSOR_CSS,
1799
+ CURSOR_PNG,
1800
+ CURSOR_SCRIPT,
1801
+ DEFAULT_CAPTIONS,
1802
+ DEFAULT_CONFIG,
1803
+ EXCLUDED_LINK_PATTERNS,
1804
+ FEATURES,
1805
+ INTERACTIVE_SELECTORS,
1806
+ LANGUAGE_VOICES,
1807
+ VERSION,
1808
+ alignVoiceoverToRecording,
1809
+ applyTransitions,
1810
+ buildKeepSegments,
1811
+ buildStepTransitions,
1812
+ buildSystemPrompt,
1813
+ buildUserPrompt,
1814
+ buildWatermarkFilters,
1815
+ callAI,
1816
+ canReuseVoiceover,
1817
+ composeVideo,
1818
+ concatMedia,
1819
+ concatVideoWithOutro,
1820
+ crawlPage,
1821
+ estimateWordTimings,
1822
+ generateASSHeader,
1823
+ generateSilence,
1824
+ generateVoiceover,
1825
+ getErrorMessage,
1826
+ getLanguageName,
1827
+ getMediaDuration,
1828
+ getStatus,
1829
+ getStatusCached,
1830
+ getTierByEmail,
1831
+ getUpgradeUrl,
1832
+ hasAccess,
1833
+ hasTrialRemaining,
1834
+ hexToASS,
1835
+ isCanceling,
1836
+ isExcludedLink,
1837
+ isPaid,
1838
+ isVerified,
1839
+ loadExistingVoiceover,
1840
+ loadVoiceoverMeta,
1841
+ mapTimestampsAfterCompression,
1842
+ normalizeUrl,
1843
+ parseAIPlanResponse,
1844
+ parsePartial,
1845
+ pricingUrl,
1846
+ registerFreeUser,
1847
+ resolveVoice,
1848
+ rgbaToHex,
1849
+ shouldSkipLink,
1850
+ shouldWatermark,
1851
+ showPaywall,
1852
+ speedUpVideo,
1853
+ spliceVideos,
1854
+ synthesizeSpeech,
1855
+ toTitleCase,
1856
+ windowsPathToWSL
1857
+ };