@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.d.mts +308 -0
- package/dist/index.mjs +1857 -0
- package/dist/qp.js +636 -364
- package/dist/voices-BtYZey4Y.d.mts +287 -0
- package/dist/web.d.mts +102 -0
- package/dist/web.mjs +1025 -0
- package/package.json +13 -1
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
|
+
};
|