@spark-apps/quickpeek 1.2.2 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/assets/music/calm-drifting-piano.mp3 +0 -0
- package/dist/assets/music/lofi-roof-tops.mp3 +0 -0
- package/dist/assets/music/manifest.json +32 -0
- package/dist/assets/music/upbeat-spring-on-the-horizon.mp3 +0 -0
- package/dist/highlight.css +8 -7
- package/dist/index.d.mts +561 -36
- package/dist/index.mjs +2288 -399
- package/dist/mcp-tools.mjs +6965 -1866
- package/dist/qp.js +6281 -1566
- package/dist/voices-CPpnWn39.d.mts +613 -0
- package/dist/web.d.mts +5 -3
- package/dist/web.mjs +398 -164
- package/mcp.mjs +94 -21
- package/package.json +5 -2
- package/dist/scraping-BgepklP3.d.mts +0 -335
package/dist/index.mjs
CHANGED
|
@@ -38,8 +38,19 @@ function parsePartial(partial) {
|
|
|
38
38
|
function getErrorMessage(err) {
|
|
39
39
|
return err instanceof Error ? err.message : String(err);
|
|
40
40
|
}
|
|
41
|
+
function deStock(text) {
|
|
42
|
+
let out = text;
|
|
43
|
+
for (const [re, replacement] of STOCK_PHRASES) out = out.replace(re, replacement);
|
|
44
|
+
return out.replace(/\s{2,}/g, " ").replace(/\s+([,.!?])/g, "$1").trim();
|
|
45
|
+
}
|
|
46
|
+
function stripLongDashes(text) {
|
|
47
|
+
return text.replace(/(\d)\s*[\u2013\u2014]\s*(\d)/g, "$1-$2").replace(/\s*[\u2013\u2014]\s*/g, ", ").replace(/,\s*,/g, ",").replace(/\s+([,.!?])/g, "$1").replace(/\s{2,}/g, " ").trim();
|
|
48
|
+
}
|
|
41
49
|
function toTitleCase(text) {
|
|
42
|
-
return text.replace(/\w+(?:'\w+)*/g, (word, index) => {
|
|
50
|
+
return deStock(stripLongDashes(text)).replace(/\w+(?:'\w+)*/g, (word, index) => {
|
|
51
|
+
if (/[A-Z]/.test(word.slice(1))) {
|
|
52
|
+
return word;
|
|
53
|
+
}
|
|
43
54
|
if (index === 0) {
|
|
44
55
|
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
45
56
|
}
|
|
@@ -49,7 +60,17 @@ function toTitleCase(text) {
|
|
|
49
60
|
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
50
61
|
});
|
|
51
62
|
}
|
|
52
|
-
|
|
63
|
+
async function runPooled(count, size, task) {
|
|
64
|
+
let cursor = 0;
|
|
65
|
+
const worker = async () => {
|
|
66
|
+
while (cursor < count) {
|
|
67
|
+
const index = cursor++;
|
|
68
|
+
await task(index);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(size, count)) }, worker));
|
|
72
|
+
}
|
|
73
|
+
var LANGUAGE_NAMES, LOWERCASE_WORDS, STOCK_PHRASES;
|
|
53
74
|
var init_utils = __esm({
|
|
54
75
|
"src/lib/utils.ts"() {
|
|
55
76
|
"use strict";
|
|
@@ -99,13 +120,21 @@ var init_utils = __esm({
|
|
|
99
120
|
"under",
|
|
100
121
|
"than"
|
|
101
122
|
]);
|
|
123
|
+
STOCK_PHRASES = [
|
|
124
|
+
[/\bfree to start\b/gi, "free to try"],
|
|
125
|
+
[/\bget started today\b/gi, "start now"],
|
|
126
|
+
[/\bsay goodbye to\b/gi, "no more"],
|
|
127
|
+
[/\bsupercharge\b/gi, "speed up"],
|
|
128
|
+
[/\beffortlessly\b/gi, "easily"],
|
|
129
|
+
[/\bseamlessly\b/gi, ""]
|
|
130
|
+
];
|
|
102
131
|
}
|
|
103
132
|
});
|
|
104
133
|
|
|
105
134
|
// src/lib/watermark.ts
|
|
106
135
|
function buildWatermarkFilters(opts) {
|
|
107
|
-
const { tier, videoDuration } = opts;
|
|
108
|
-
if (tier
|
|
136
|
+
const { tier, videoDuration, endsOnCreditsCard = false } = opts;
|
|
137
|
+
if (tier !== "free") {
|
|
109
138
|
return "";
|
|
110
139
|
}
|
|
111
140
|
if (videoDuration <= WATERMARK_THRESHOLD_SECONDS) {
|
|
@@ -113,6 +142,7 @@ function buildWatermarkFilters(opts) {
|
|
|
113
142
|
}
|
|
114
143
|
const filters = [];
|
|
115
144
|
filters.push(`drawtext=text='qp':fontsize=24:fontcolor=white@0.3:x=w-tw-20:y=h-th-20`);
|
|
145
|
+
if (endsOnCreditsCard) return filters.join(",");
|
|
116
146
|
const endTextStart = Math.max(0, videoDuration - END_TEXT_DURATION);
|
|
117
147
|
const fadeEndTime = endTextStart + END_TEXT_FADE_IN;
|
|
118
148
|
const alphaExpr = `if(lt(t,${endTextStart}),0,if(lt(t,${fadeEndTime}),(t-${endTextStart})*0.6,0.6))`;
|
|
@@ -131,8 +161,226 @@ var init_watermark = __esm({
|
|
|
131
161
|
}
|
|
132
162
|
});
|
|
133
163
|
|
|
134
|
-
// src/lib/
|
|
164
|
+
// src/lib/lifecycle.ts
|
|
165
|
+
function trackChild(child) {
|
|
166
|
+
installLifecycle();
|
|
167
|
+
children.add(child);
|
|
168
|
+
const drop = () => children.delete(child);
|
|
169
|
+
child.once("close", drop);
|
|
170
|
+
child.once("error", drop);
|
|
171
|
+
return child;
|
|
172
|
+
}
|
|
173
|
+
function teardown() {
|
|
174
|
+
tearingDown ??= (async () => {
|
|
175
|
+
for (const child of children) {
|
|
176
|
+
try {
|
|
177
|
+
child.kill("SIGKILL");
|
|
178
|
+
} catch {
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
children.clear();
|
|
182
|
+
const pending = [...cleanups];
|
|
183
|
+
cleanups.clear();
|
|
184
|
+
await Promise.allSettled(pending.map((fn) => fn()));
|
|
185
|
+
})();
|
|
186
|
+
return tearingDown;
|
|
187
|
+
}
|
|
188
|
+
function installLifecycle() {
|
|
189
|
+
if (installed) return;
|
|
190
|
+
installed = true;
|
|
191
|
+
process.once("beforeExit", () => {
|
|
192
|
+
void teardown();
|
|
193
|
+
});
|
|
194
|
+
process.on("exit", () => {
|
|
195
|
+
for (const child of children) {
|
|
196
|
+
try {
|
|
197
|
+
child.kill("SIGKILL");
|
|
198
|
+
} catch {
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
203
|
+
process.once(signal, () => {
|
|
204
|
+
void teardown().finally(() => {
|
|
205
|
+
process.removeAllListeners(signal);
|
|
206
|
+
process.kill(process.pid, signal);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
process.once("uncaughtException", (err) => {
|
|
211
|
+
void teardown().finally(() => {
|
|
212
|
+
console.error(err);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
process.once("unhandledRejection", (err) => {
|
|
217
|
+
void teardown().finally(() => {
|
|
218
|
+
console.error(err);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
var cleanups, children, installed, tearingDown;
|
|
224
|
+
var init_lifecycle = __esm({
|
|
225
|
+
"src/lib/lifecycle.ts"() {
|
|
226
|
+
"use strict";
|
|
227
|
+
cleanups = /* @__PURE__ */ new Set();
|
|
228
|
+
children = /* @__PURE__ */ new Set();
|
|
229
|
+
installed = false;
|
|
230
|
+
tearingDown = null;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// src/lib/ffmpeg/hwaccel.ts
|
|
235
|
+
import { cpus } from "os";
|
|
135
236
|
import { spawn } from "child_process";
|
|
237
|
+
function softwareEncoder(crf, preset) {
|
|
238
|
+
return {
|
|
239
|
+
input: [],
|
|
240
|
+
output: ["-c:v", "libx264", "-preset", preset, "-crf", String(crf), "-pix_fmt", "yuv420p"],
|
|
241
|
+
filter: null,
|
|
242
|
+
name: `libx264 ${preset}`
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function detectHardwareEncoder(quality = 24) {
|
|
246
|
+
probed ??= (async () => {
|
|
247
|
+
for (const build of CANDIDATES) {
|
|
248
|
+
const encoder = build(quality);
|
|
249
|
+
if (await canEncode(encoder)) return encoder;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
})();
|
|
253
|
+
return probed;
|
|
254
|
+
}
|
|
255
|
+
async function resolveEncoder(opts = {
|
|
256
|
+
crf: 23,
|
|
257
|
+
preset: "medium"
|
|
258
|
+
}) {
|
|
259
|
+
if (opts.allowHardware !== false) {
|
|
260
|
+
const hw = await detectHardwareEncoder(opts.quality ?? opts.crf);
|
|
261
|
+
if (hw) {
|
|
262
|
+
if (opts.width && opts.height) {
|
|
263
|
+
const sw = softwareEncoder(opts.crf, opts.preset);
|
|
264
|
+
const won = await faster(hw, sw, opts.width, opts.height);
|
|
265
|
+
return announce(won, won === hw ? "hardware" : "software (faster than the GPU at this size)");
|
|
266
|
+
}
|
|
267
|
+
return announce(hw, "hardware");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return announce(softwareEncoder(opts.crf, opts.preset), "software (no usable GPU encoder found)");
|
|
271
|
+
}
|
|
272
|
+
async function faster(hw, sw, width, height) {
|
|
273
|
+
raced ??= (async () => {
|
|
274
|
+
const decided = Promise.all([timeEncode(hw, width, height), timeEncode(sw, width, height)]).then(
|
|
275
|
+
([hwMs, swMs]) => hwMs === null ? false : swMs === null ? true : hwMs <= swMs
|
|
276
|
+
);
|
|
277
|
+
const bailout = new Promise((r) => setTimeout(() => r(true), 8e3).unref?.());
|
|
278
|
+
return Promise.race([decided, bailout]);
|
|
279
|
+
})();
|
|
280
|
+
return await raced ? hw : sw;
|
|
281
|
+
}
|
|
282
|
+
async function timeEncode(encoder, width, height) {
|
|
283
|
+
const args = [
|
|
284
|
+
"-hide_banner",
|
|
285
|
+
"-loglevel",
|
|
286
|
+
"error",
|
|
287
|
+
...encoder.input,
|
|
288
|
+
"-f",
|
|
289
|
+
"lavfi",
|
|
290
|
+
"-i",
|
|
291
|
+
`testsrc2=size=${width}x${height}:rate=30:duration=1`,
|
|
292
|
+
...encoder.filter ? ["-vf", encoder.filter] : [],
|
|
293
|
+
...encoder.output,
|
|
294
|
+
"-frames:v",
|
|
295
|
+
"12",
|
|
296
|
+
"-f",
|
|
297
|
+
"null",
|
|
298
|
+
"-"
|
|
299
|
+
];
|
|
300
|
+
const began = Date.now();
|
|
301
|
+
const ok = await run(args);
|
|
302
|
+
return ok ? Date.now() - began : null;
|
|
303
|
+
}
|
|
304
|
+
function announce(encoder, how) {
|
|
305
|
+
if (announced) return encoder;
|
|
306
|
+
announced = true;
|
|
307
|
+
console.log(` Video encoder: ${encoder.name} - ${how}`);
|
|
308
|
+
return encoder;
|
|
309
|
+
}
|
|
310
|
+
function canEncode(encoder) {
|
|
311
|
+
return run([
|
|
312
|
+
"-hide_banner",
|
|
313
|
+
"-loglevel",
|
|
314
|
+
"error",
|
|
315
|
+
...encoder.input,
|
|
316
|
+
"-f",
|
|
317
|
+
"lavfi",
|
|
318
|
+
"-i",
|
|
319
|
+
"testsrc2=size=128x128:rate=2:duration=1",
|
|
320
|
+
...encoder.filter ? ["-vf", encoder.filter] : [],
|
|
321
|
+
...encoder.output,
|
|
322
|
+
"-frames:v",
|
|
323
|
+
"2",
|
|
324
|
+
"-f",
|
|
325
|
+
"null",
|
|
326
|
+
"-"
|
|
327
|
+
]);
|
|
328
|
+
}
|
|
329
|
+
function run(args) {
|
|
330
|
+
return new Promise((resolve3) => {
|
|
331
|
+
let settled = false;
|
|
332
|
+
const done = (ok) => {
|
|
333
|
+
if (settled) return;
|
|
334
|
+
settled = true;
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
resolve3(ok);
|
|
337
|
+
};
|
|
338
|
+
const proc = trackChild(spawn("ffmpeg", args, { stdio: "ignore" }));
|
|
339
|
+
const timer = setTimeout(() => {
|
|
340
|
+
proc.kill("SIGKILL");
|
|
341
|
+
done(false);
|
|
342
|
+
}, 1e4);
|
|
343
|
+
proc.on("close", (code) => done(code === 0));
|
|
344
|
+
proc.on("error", () => done(false));
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
function filterThreadArgs() {
|
|
348
|
+
const cores = Math.max(1, cpus().length);
|
|
349
|
+
return ["-filter_threads", String(cores), "-filter_complex_threads", String(cores)];
|
|
350
|
+
}
|
|
351
|
+
var CANDIDATES, probed, raced, announced;
|
|
352
|
+
var init_hwaccel = __esm({
|
|
353
|
+
"src/lib/ffmpeg/hwaccel.ts"() {
|
|
354
|
+
"use strict";
|
|
355
|
+
init_lifecycle();
|
|
356
|
+
CANDIDATES = [
|
|
357
|
+
(q) => ({
|
|
358
|
+
input: ["-init_hw_device", "vaapi=hw:/dev/dri/renderD128", "-filter_hw_device", "hw"],
|
|
359
|
+
output: ["-c:v", "h264_vaapi", "-qp", String(q)],
|
|
360
|
+
filter: "format=nv12,hwupload",
|
|
361
|
+
name: "h264_vaapi"
|
|
362
|
+
}),
|
|
363
|
+
(q) => ({
|
|
364
|
+
input: [],
|
|
365
|
+
output: ["-c:v", "h264_nvenc", "-preset", "p1", "-cq", String(q), "-pix_fmt", "yuv420p"],
|
|
366
|
+
filter: null,
|
|
367
|
+
name: "h264_nvenc"
|
|
368
|
+
}),
|
|
369
|
+
(q) => ({
|
|
370
|
+
input: [],
|
|
371
|
+
output: ["-c:v", "h264_qsv", "-global_quality", String(q), "-pix_fmt", "nv12"],
|
|
372
|
+
filter: null,
|
|
373
|
+
name: "h264_qsv"
|
|
374
|
+
})
|
|
375
|
+
];
|
|
376
|
+
probed = null;
|
|
377
|
+
raced = null;
|
|
378
|
+
announced = false;
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// src/lib/ffmpeg/spawn.ts
|
|
383
|
+
import { spawn as spawn2 } from "child_process";
|
|
136
384
|
function toWSLPath(path) {
|
|
137
385
|
if (!isWindowsRuntime) return path;
|
|
138
386
|
return windowsDrivePathToWsl(path) ?? path.replace(/\\/g, "/");
|
|
@@ -146,16 +394,16 @@ function convertArgToWSL(arg) {
|
|
|
146
394
|
function spawnFFmpeg(cmd, args) {
|
|
147
395
|
if (isWindowsRuntime) {
|
|
148
396
|
const wslArgs = args.map(convertArgToWSL);
|
|
149
|
-
return
|
|
397
|
+
return trackChild(spawn2("wsl", ["-e", cmd, ...wslArgs]));
|
|
150
398
|
}
|
|
151
|
-
return
|
|
399
|
+
return trackChild(spawn2(cmd, args));
|
|
152
400
|
}
|
|
153
401
|
function runFFmpeg(args, errorLabel) {
|
|
154
402
|
return new Promise((resolve3, reject) => {
|
|
155
403
|
const proc = spawnFFmpeg("ffmpeg", args);
|
|
156
404
|
let stderr = "";
|
|
157
405
|
proc.stderr.on("data", (d) => {
|
|
158
|
-
stderr
|
|
406
|
+
stderr = (stderr + d.toString()).slice(-STDERR_TAIL);
|
|
159
407
|
});
|
|
160
408
|
proc.on("close", (code) => {
|
|
161
409
|
if (code !== 0) reject(new Error(`${errorLabel}: ${stderr.slice(-500)}`));
|
|
@@ -164,112 +412,153 @@ function runFFmpeg(args, errorLabel) {
|
|
|
164
412
|
proc.on("error", reject);
|
|
165
413
|
});
|
|
166
414
|
}
|
|
167
|
-
var isWindowsRuntime;
|
|
415
|
+
var isWindowsRuntime, STDERR_TAIL;
|
|
168
416
|
var init_spawn = __esm({
|
|
169
417
|
"src/lib/ffmpeg/spawn.ts"() {
|
|
170
418
|
"use strict";
|
|
419
|
+
init_lifecycle();
|
|
171
420
|
init_utils();
|
|
172
421
|
isWindowsRuntime = process.platform === "win32";
|
|
422
|
+
STDERR_TAIL = 4e3;
|
|
173
423
|
}
|
|
174
424
|
});
|
|
175
425
|
|
|
176
426
|
// src/lib/ffmpeg/compose.ts
|
|
177
427
|
import { MASTER_CHAIN } from "@spark-apps/video-kit";
|
|
178
|
-
function composeVideo(opts) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
428
|
+
async function composeVideo(opts) {
|
|
429
|
+
const {
|
|
430
|
+
videoPath,
|
|
431
|
+
assPath,
|
|
432
|
+
outputPath,
|
|
433
|
+
voiceoverPath,
|
|
434
|
+
musicPath,
|
|
435
|
+
musicVolume = 0.3,
|
|
436
|
+
musicTargetLufs = MUSIC_BED_LUFS,
|
|
437
|
+
musicFadeIn = 2,
|
|
438
|
+
musicFadeOut = 2,
|
|
439
|
+
width,
|
|
440
|
+
height,
|
|
441
|
+
fps,
|
|
442
|
+
preset,
|
|
443
|
+
quality,
|
|
444
|
+
fadeIn,
|
|
445
|
+
fadeOut,
|
|
446
|
+
videoDuration,
|
|
447
|
+
audioBitrate = "192k",
|
|
448
|
+
contrast = 0,
|
|
449
|
+
trimStart = 0,
|
|
450
|
+
userTier = "free",
|
|
451
|
+
endsOnCreditsCard = false
|
|
452
|
+
} = opts;
|
|
453
|
+
const crf = Math.round(28 - quality * 10);
|
|
454
|
+
const encoder = await resolveEncoder({
|
|
455
|
+
crf,
|
|
456
|
+
preset,
|
|
457
|
+
quality: Math.max(16, crf - 4),
|
|
458
|
+
allowHardware: opts.hwaccel !== "off",
|
|
459
|
+
// The race is only meaningful at the size actually being rendered: the
|
|
460
|
+
// GPU's per-frame upload is a fixed cost that a draft's ninth of the
|
|
461
|
+
// pixels cannot amortise.
|
|
462
|
+
width,
|
|
463
|
+
height
|
|
464
|
+
});
|
|
465
|
+
const args = ["-y", ...filterThreadArgs(), ...encoder.input];
|
|
466
|
+
if (trimStart > 0) {
|
|
467
|
+
args.push("-ss", String(trimStart));
|
|
468
|
+
}
|
|
469
|
+
args.push("-i", videoPath);
|
|
470
|
+
const effectiveDuration = videoDuration - trimStart;
|
|
471
|
+
const voiceoverDuration = opts.mainContentDuration ?? effectiveDuration;
|
|
472
|
+
if (voiceoverPath) args.push("-i", voiceoverPath);
|
|
473
|
+
if (musicPath) args.push("-i", musicPath);
|
|
474
|
+
const vf = [`scale=${width}:${height}`, `fps=${fps}`];
|
|
475
|
+
if (contrast > 0) {
|
|
476
|
+
const ffmpegContrast = 1 + contrast;
|
|
477
|
+
vf.push(`eq=contrast=${ffmpegContrast.toFixed(2)}`);
|
|
478
|
+
}
|
|
479
|
+
const watermarkFilters = buildWatermarkFilters({
|
|
480
|
+
tier: userTier,
|
|
481
|
+
videoDuration: effectiveDuration,
|
|
482
|
+
endsOnCreditsCard
|
|
483
|
+
});
|
|
484
|
+
if (watermarkFilters) {
|
|
485
|
+
vf.push(watermarkFilters);
|
|
486
|
+
}
|
|
487
|
+
if (assPath) {
|
|
220
488
|
const assPathForFilter = toWSLPath(assPath).replace(/:/g, "\\:");
|
|
221
489
|
vf.push(`ass=${assPathForFilter}`);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
490
|
+
}
|
|
491
|
+
if (fadeIn > 0) vf.push(`fade=t=in:st=0:d=${fadeIn}:c=black`);
|
|
492
|
+
if (fadeOut > 0 && !endsOnCreditsCard) {
|
|
493
|
+
const videoFadeOutStart = Math.max(0, effectiveDuration - fadeOut);
|
|
494
|
+
vf.push(`fade=t=out:st=${videoFadeOutStart}:d=${fadeOut}:c=black`);
|
|
495
|
+
}
|
|
496
|
+
const clipAudioLabel = opts.hasClipAudio ? "[0:a]" : "";
|
|
497
|
+
const hasVoiceover = !!voiceoverPath;
|
|
498
|
+
const hasMusic = !!musicPath;
|
|
499
|
+
const musicFadeOutStart = Math.max(0, effectiveDuration - musicFadeOut);
|
|
500
|
+
const vfStr = [...vf, ...encoder.filter ? [encoder.filter] : []].join(",");
|
|
501
|
+
if (hasVoiceover && hasMusic) {
|
|
502
|
+
const mixInputs = clipAudioLabel ? 3 : 2;
|
|
503
|
+
const complexFilter = [
|
|
504
|
+
// Voiceover: fade in only (no fade out - stays clear until end). Split,
|
|
505
|
+
// because one copy is mixed and the other is only listened to, by the
|
|
506
|
+
// compressor that pulls the bed down under it.
|
|
507
|
+
`[1:a]adelay=${VOICE_LEAD_MS}|${VOICE_LEAD_MS},apad=whole_dur=${voiceoverDuration},asplit=2[voice][voicekey0]`,
|
|
508
|
+
// The detector's copy runs the length of the PICTURE, not the length of
|
|
509
|
+
// the narration. sidechaincompress is a framesync filter and ends at its
|
|
510
|
+
// shorter input, so a key that stops when the voice does takes the bed
|
|
511
|
+
// with it - and the stretch it would have taken is exactly the closing
|
|
512
|
+
// card, which is the one place the bed is the only thing playing.
|
|
513
|
+
`[voicekey0]apad=whole_dur=${effectiveDuration}[voicekey]`,
|
|
514
|
+
// Music: normalised to a known level, trimmed, then faded.
|
|
515
|
+
`[2:a]aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},loudnorm=I=${musicTargetLufs}:TP=-9:LRA=11,volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut}[bed]`,
|
|
516
|
+
`[bed][voicekey]${DUCK}[music]`,
|
|
517
|
+
// normalize=0, because amix's default divides every input by the number
|
|
518
|
+
// of them: adding the bed used to quietly take 6 dB off the narration,
|
|
519
|
+
// and the master loudnorm then put it back by turning everything up,
|
|
520
|
+
// bed included. Levels set deliberately upstream should survive the mix.
|
|
521
|
+
//
|
|
522
|
+
// Normalise the MIX and hard-limit its true peak - ported from
|
|
523
|
+
// VidLet, where single-pass loudnorm alone measured +0.9 dBTP on
|
|
524
|
+
// sharp TTS consonants. -14 LUFS is the YouTube/IG/TikTok playback
|
|
525
|
+
// target, so the platform leaves the audio untouched.
|
|
526
|
+
`[voice][music]${clipAudioLabel}amix=inputs=${mixInputs}:duration=longest:normalize=0,${MASTER_CHAIN}[aout]`
|
|
527
|
+
].join(";");
|
|
528
|
+
args.push("-vf", vfStr);
|
|
529
|
+
args.push("-filter_complex", complexFilter, "-map", "0:v", "-map", "[aout]");
|
|
530
|
+
args.push(...encoder.output);
|
|
531
|
+
args.push("-c:a", "aac", "-b:a", audioBitrate);
|
|
532
|
+
} else if (hasVoiceover) {
|
|
533
|
+
const af = `adelay=${VOICE_LEAD_MS}|${VOICE_LEAD_MS},apad=whole_dur=${voiceoverDuration},${MASTER_CHAIN}`;
|
|
534
|
+
args.push("-vf", vfStr);
|
|
535
|
+
args.push("-map", "0:v", "-map", "1:a", "-af", af);
|
|
536
|
+
args.push(...encoder.output);
|
|
537
|
+
args.push("-c:a", "aac", "-b:a", audioBitrate);
|
|
538
|
+
} else if (hasMusic) {
|
|
539
|
+
const musicFilter = `aloop=loop=-1:size=2e+09,atrim=0:${effectiveDuration},volume=${musicVolume},afade=t=in:st=0:d=${musicFadeIn},afade=t=out:st=${musicFadeOutStart}:d=${musicFadeOut},${MASTER_CHAIN}`;
|
|
540
|
+
args.push("-vf", vfStr);
|
|
541
|
+
args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
|
|
542
|
+
args.push(...encoder.output);
|
|
543
|
+
args.push("-c:a", "aac", "-b:a", "192k");
|
|
544
|
+
} else {
|
|
545
|
+
args.push("-vf", vfStr);
|
|
546
|
+
args.push(...encoder.output);
|
|
547
|
+
}
|
|
548
|
+
args.push("-movflags", "+faststart");
|
|
549
|
+
args.push(outputPath);
|
|
550
|
+
await runFFmpeg(args, "ffmpeg compose failed");
|
|
267
551
|
}
|
|
552
|
+
var VOICE_LEAD_MS, MUSIC_BED_LUFS, DUCK;
|
|
268
553
|
var init_compose = __esm({
|
|
269
554
|
"src/lib/ffmpeg/compose.ts"() {
|
|
270
555
|
"use strict";
|
|
271
556
|
init_watermark();
|
|
557
|
+
init_hwaccel();
|
|
272
558
|
init_spawn();
|
|
559
|
+
VOICE_LEAD_MS = 200;
|
|
560
|
+
MUSIC_BED_LUFS = -34;
|
|
561
|
+
DUCK = "sidechaincompress=threshold=0.05:ratio=8:attack=20:release=420:level_sc=4:makeup=1";
|
|
273
562
|
}
|
|
274
563
|
});
|
|
275
564
|
|
|
@@ -315,8 +604,130 @@ var init_compress = __esm({
|
|
|
315
604
|
}
|
|
316
605
|
});
|
|
317
606
|
|
|
607
|
+
// src/lib/ffmpeg/gif.ts
|
|
608
|
+
import { unlink } from "fs/promises";
|
|
609
|
+
import { tmpdir } from "os";
|
|
610
|
+
import { join } from "path";
|
|
611
|
+
function buildPaletteArgs(input, palettePath, fps, width, statsMode) {
|
|
612
|
+
return [
|
|
613
|
+
"-y",
|
|
614
|
+
"-i",
|
|
615
|
+
input,
|
|
616
|
+
"-vf",
|
|
617
|
+
`fps=${fps},scale=${width}:-1:flags=lanczos,palettegen=stats_mode=${statsMode}`,
|
|
618
|
+
palettePath
|
|
619
|
+
];
|
|
620
|
+
}
|
|
621
|
+
function buildGifArgs(input, palettePath, output, fps, width, dither) {
|
|
622
|
+
return [
|
|
623
|
+
"-y",
|
|
624
|
+
"-i",
|
|
625
|
+
input,
|
|
626
|
+
"-i",
|
|
627
|
+
palettePath,
|
|
628
|
+
"-lavfi",
|
|
629
|
+
`fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=${dither}`,
|
|
630
|
+
output
|
|
631
|
+
];
|
|
632
|
+
}
|
|
633
|
+
async function convertToGif(options) {
|
|
634
|
+
const { input, output } = options;
|
|
635
|
+
const fps = options.fps ?? GIF_DEFAULTS.fps;
|
|
636
|
+
const width = options.width ?? GIF_DEFAULTS.width;
|
|
637
|
+
const dither = options.dither ?? GIF_DEFAULTS.dither;
|
|
638
|
+
const statsMode = options.statsMode ?? GIF_DEFAULTS.statsMode;
|
|
639
|
+
const palettePath = join(tmpdir(), `quickpeek_palette_${Date.now()}.png`);
|
|
640
|
+
try {
|
|
641
|
+
await runFFmpeg(buildPaletteArgs(input, palettePath, fps, width, statsMode), "GIF palette generation failed");
|
|
642
|
+
await runFFmpeg(buildGifArgs(input, palettePath, output, fps, width, dither), "GIF conversion failed");
|
|
643
|
+
return output;
|
|
644
|
+
} finally {
|
|
645
|
+
try {
|
|
646
|
+
await unlink(palettePath);
|
|
647
|
+
} catch {
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
var GIF_DEFAULTS;
|
|
652
|
+
var init_gif = __esm({
|
|
653
|
+
"src/lib/ffmpeg/gif.ts"() {
|
|
654
|
+
"use strict";
|
|
655
|
+
init_spawn();
|
|
656
|
+
GIF_DEFAULTS = { fps: 15, width: 480, dither: "sierra2_4a", statsMode: "full" };
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
318
660
|
// src/lib/ffmpeg/media.ts
|
|
661
|
+
import { statSync } from "fs";
|
|
662
|
+
function cacheKey(filePath) {
|
|
663
|
+
try {
|
|
664
|
+
const { size, mtimeMs } = statSync(filePath);
|
|
665
|
+
return `${filePath}:${size}:${mtimeMs}`;
|
|
666
|
+
} catch {
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function getVideoDimensions(filePath) {
|
|
671
|
+
return new Promise((resolve3, reject) => {
|
|
672
|
+
const args = [
|
|
673
|
+
"-v",
|
|
674
|
+
"error",
|
|
675
|
+
"-select_streams",
|
|
676
|
+
"v:0",
|
|
677
|
+
"-show_entries",
|
|
678
|
+
"stream=width,height",
|
|
679
|
+
"-of",
|
|
680
|
+
"csv=p=0",
|
|
681
|
+
filePath
|
|
682
|
+
];
|
|
683
|
+
const proc = spawnFFmpeg("ffprobe", args);
|
|
684
|
+
let stdout = "";
|
|
685
|
+
let stderr = "";
|
|
686
|
+
proc.stdout.on("data", (d) => {
|
|
687
|
+
stdout += d;
|
|
688
|
+
});
|
|
689
|
+
proc.stderr.on("data", (d) => {
|
|
690
|
+
stderr += d;
|
|
691
|
+
});
|
|
692
|
+
proc.on("close", (code) => {
|
|
693
|
+
if (code !== 0) {
|
|
694
|
+
reject(new Error(`ffprobe failed: ${stderr}`));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const [w, h] = stdout.trim().split(",").map(Number);
|
|
698
|
+
if (!w || !h) {
|
|
699
|
+
reject(new Error(`ffprobe returned no dimensions for ${filePath}`));
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
resolve3({ width: w, height: h });
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
function hasAudioStream(filePath) {
|
|
707
|
+
return new Promise((resolve3) => {
|
|
708
|
+
const proc = spawnFFmpeg("ffprobe", [
|
|
709
|
+
"-v",
|
|
710
|
+
"error",
|
|
711
|
+
"-select_streams",
|
|
712
|
+
"a:0",
|
|
713
|
+
"-show_entries",
|
|
714
|
+
"stream=codec_type",
|
|
715
|
+
"-of",
|
|
716
|
+
"csv=p=0",
|
|
717
|
+
filePath
|
|
718
|
+
]);
|
|
719
|
+
let out = "";
|
|
720
|
+
proc.stdout.on("data", (d) => {
|
|
721
|
+
out += d;
|
|
722
|
+
});
|
|
723
|
+
proc.on("close", () => resolve3(out.trim().length > 0));
|
|
724
|
+
proc.on("error", () => resolve3(false));
|
|
725
|
+
});
|
|
726
|
+
}
|
|
319
727
|
function getMediaDuration(filePath) {
|
|
728
|
+
const key = cacheKey(filePath);
|
|
729
|
+
const cached = key === null ? void 0 : durationCache.get(key);
|
|
730
|
+
if (cached !== void 0) return Promise.resolve(cached);
|
|
320
731
|
return new Promise((resolve3, reject) => {
|
|
321
732
|
const args = [
|
|
322
733
|
"-v",
|
|
@@ -341,8 +752,10 @@ function getMediaDuration(filePath) {
|
|
|
341
752
|
reject(new Error(`ffprobe failed: ${stderr}`));
|
|
342
753
|
return;
|
|
343
754
|
}
|
|
344
|
-
const
|
|
345
|
-
|
|
755
|
+
const ms = Math.ceil(parseFloat(stdout.trim()) * 1e3);
|
|
756
|
+
const freshKey = cacheKey(filePath);
|
|
757
|
+
if (freshKey !== null && Number.isFinite(ms)) durationCache.set(freshKey, ms);
|
|
758
|
+
resolve3(ms);
|
|
346
759
|
});
|
|
347
760
|
proc.on("error", reject);
|
|
348
761
|
});
|
|
@@ -381,6 +794,12 @@ function buildConcatArgs(listPath, output) {
|
|
|
381
794
|
function concatMedia(listPath, output) {
|
|
382
795
|
return runFFmpeg(buildConcatArgs(listPath, output), "ffmpeg concat failed");
|
|
383
796
|
}
|
|
797
|
+
async function trimEdgeSilence(filePath) {
|
|
798
|
+
const trimmed = filePath.replace(/\.mp3$/i, "-trim.mp3");
|
|
799
|
+
await runFFmpeg(["-y", "-i", filePath, "-af", TRIM_EDGE_SILENCE, trimmed], "ffmpeg silence trim failed");
|
|
800
|
+
const { rename } = await import("fs/promises");
|
|
801
|
+
await rename(trimmed, filePath);
|
|
802
|
+
}
|
|
384
803
|
function generateSilence(outputPath, durationMs) {
|
|
385
804
|
return runFFmpeg(
|
|
386
805
|
[
|
|
@@ -411,7 +830,23 @@ function speedUpVideo(opts) {
|
|
|
411
830
|
remaining /= tempo;
|
|
412
831
|
}
|
|
413
832
|
return runFFmpeg(
|
|
414
|
-
|
|
833
|
+
// faststart for the same reason as compose: this pass can be the last
|
|
834
|
+
// thing to touch the delivered file, and a moov atom written after the
|
|
835
|
+
// payload makes it unplayable in a browser preview.
|
|
836
|
+
[
|
|
837
|
+
"-y",
|
|
838
|
+
"-i",
|
|
839
|
+
inputPath,
|
|
840
|
+
"-vf",
|
|
841
|
+
videoFilter,
|
|
842
|
+
"-af",
|
|
843
|
+
audioFilters.join(","),
|
|
844
|
+
"-preset",
|
|
845
|
+
preset,
|
|
846
|
+
"-movflags",
|
|
847
|
+
"+faststart",
|
|
848
|
+
outputPath
|
|
849
|
+
],
|
|
415
850
|
"ffmpeg speedup failed"
|
|
416
851
|
);
|
|
417
852
|
}
|
|
@@ -426,51 +861,589 @@ function buildOutroFilterComplex(width, height) {
|
|
|
426
861
|
"[v0][v1]concat=n=2:v=1:a=0[outv]"
|
|
427
862
|
].join(";");
|
|
428
863
|
}
|
|
429
|
-
function concatVideoWithOutro(opts) {
|
|
430
|
-
const { inputPath, outroPath, outputPath, width, height, preset } = opts;
|
|
431
|
-
const filterComplex = buildOutroFilterComplex(width, height);
|
|
432
|
-
return runFFmpeg(
|
|
433
|
-
[
|
|
864
|
+
function concatVideoWithOutro(opts) {
|
|
865
|
+
const { inputPath, outroPath, outputPath, width, height, preset } = opts;
|
|
866
|
+
const filterComplex = buildOutroFilterComplex(width, height);
|
|
867
|
+
return runFFmpeg(
|
|
868
|
+
[
|
|
869
|
+
"-y",
|
|
870
|
+
"-i",
|
|
871
|
+
inputPath,
|
|
872
|
+
"-i",
|
|
873
|
+
outroPath,
|
|
874
|
+
"-filter_complex",
|
|
875
|
+
filterComplex,
|
|
876
|
+
"-map",
|
|
877
|
+
"[outv]",
|
|
878
|
+
"-c:v",
|
|
879
|
+
"libx264",
|
|
880
|
+
"-preset",
|
|
881
|
+
preset,
|
|
882
|
+
"-crf",
|
|
883
|
+
"18",
|
|
884
|
+
"-pix_fmt",
|
|
885
|
+
"yuv420p",
|
|
886
|
+
"-an",
|
|
887
|
+
outputPath
|
|
888
|
+
],
|
|
889
|
+
"ffmpeg concat video failed"
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
function concatVideoWithFullFrameOutro(opts) {
|
|
893
|
+
const { inputPath, outroPath, outputPath, width, height, preset } = opts;
|
|
894
|
+
const filterComplex = [
|
|
895
|
+
`[0:v]scale=${width}:${height},setsar=1,fps=30[v0]`,
|
|
896
|
+
`[1:v]scale=${width}:${height},setsar=1,fps=30[v1]`,
|
|
897
|
+
"[v0][v1]concat=n=2:v=1:a=0[outv]"
|
|
898
|
+
].join(";");
|
|
899
|
+
return runFFmpeg(
|
|
900
|
+
[
|
|
901
|
+
"-y",
|
|
902
|
+
"-i",
|
|
903
|
+
inputPath,
|
|
904
|
+
"-i",
|
|
905
|
+
outroPath,
|
|
906
|
+
"-filter_complex",
|
|
907
|
+
filterComplex,
|
|
908
|
+
"-map",
|
|
909
|
+
"[outv]",
|
|
910
|
+
"-c:v",
|
|
911
|
+
"libx264",
|
|
912
|
+
"-preset",
|
|
913
|
+
preset,
|
|
914
|
+
"-crf",
|
|
915
|
+
"18",
|
|
916
|
+
"-pix_fmt",
|
|
917
|
+
"yuv420p",
|
|
918
|
+
"-an",
|
|
919
|
+
outputPath
|
|
920
|
+
],
|
|
921
|
+
"ffmpeg full-frame outro concat failed"
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
var durationCache, TRIM_EDGE_SILENCE;
|
|
925
|
+
var init_media = __esm({
|
|
926
|
+
"src/lib/ffmpeg/media.ts"() {
|
|
927
|
+
"use strict";
|
|
928
|
+
init_spawn();
|
|
929
|
+
durationCache = /* @__PURE__ */ new Map();
|
|
930
|
+
TRIM_EDGE_SILENCE = "silenceremove=start_periods=1:start_silence=0:start_threshold=-45dB:detection=peak,areverse,silenceremove=start_periods=1:start_silence=0:start_threshold=-45dB:detection=peak,areverse";
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// src/lib/ffmpeg/silence.ts
|
|
935
|
+
function getVideoStreamDuration(inputPath) {
|
|
936
|
+
return new Promise((resolve3) => {
|
|
937
|
+
const proc = spawnFFmpeg("ffprobe", [
|
|
938
|
+
"-v",
|
|
939
|
+
"error",
|
|
940
|
+
"-select_streams",
|
|
941
|
+
"v:0",
|
|
942
|
+
"-show_entries",
|
|
943
|
+
"stream=duration",
|
|
944
|
+
"-of",
|
|
945
|
+
"csv=p=0",
|
|
946
|
+
inputPath
|
|
947
|
+
]);
|
|
948
|
+
proc.stderr?.resume();
|
|
949
|
+
let out = "";
|
|
950
|
+
proc.stdout.on("data", (d) => {
|
|
951
|
+
out += d;
|
|
952
|
+
});
|
|
953
|
+
proc.on("error", () => resolve3(0));
|
|
954
|
+
proc.on("close", () => resolve3(Number.parseFloat(out.trim()) || 0));
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
function detectSilences(inputPath, minDurSecs) {
|
|
958
|
+
return new Promise((resolve3) => {
|
|
959
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
960
|
+
"-hide_banner",
|
|
961
|
+
"-nostats",
|
|
962
|
+
// Audio-only analysis: without -vn ffmpeg's default stream selection
|
|
963
|
+
// decodes the video too, for a filter that cannot see it.
|
|
964
|
+
"-vn",
|
|
965
|
+
"-i",
|
|
966
|
+
inputPath,
|
|
967
|
+
"-af",
|
|
968
|
+
`silencedetect=noise=-40dB:d=${minDurSecs}`,
|
|
969
|
+
"-f",
|
|
970
|
+
"null",
|
|
971
|
+
"-"
|
|
972
|
+
]);
|
|
973
|
+
let err = "";
|
|
974
|
+
proc.stderr.on("data", (d) => {
|
|
975
|
+
err += d;
|
|
976
|
+
});
|
|
977
|
+
proc.on("error", () => resolve3([]));
|
|
978
|
+
proc.on("close", () => {
|
|
979
|
+
const spans = [];
|
|
980
|
+
let start = null;
|
|
981
|
+
for (const line of err.split("\n")) {
|
|
982
|
+
const s = line.match(/silence_start:\s*(-?[\d.]+)/);
|
|
983
|
+
if (s?.[1]) start = Math.max(0, Number.parseFloat(s[1]));
|
|
984
|
+
const e = line.match(/silence_end:\s*([\d.]+)/);
|
|
985
|
+
if (e?.[1] && start !== null) {
|
|
986
|
+
const from = start + SPEECH_GUARD_SECS;
|
|
987
|
+
const to = Number.parseFloat(e[1]) - SPEECH_GUARD_SECS;
|
|
988
|
+
if (to > from) spans.push({ start: from, end: to });
|
|
989
|
+
start = null;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
resolve3(spans);
|
|
993
|
+
});
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
function detectFrozenSpans(inputPath, minDurSecs) {
|
|
997
|
+
return new Promise((resolve3) => {
|
|
998
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
999
|
+
"-hide_banner",
|
|
1000
|
+
"-nostats",
|
|
1001
|
+
// The mirror of detectSilences: a video-only filter, so skip the audio.
|
|
1002
|
+
"-an",
|
|
1003
|
+
"-sn",
|
|
1004
|
+
"-i",
|
|
1005
|
+
inputPath,
|
|
1006
|
+
"-vf",
|
|
1007
|
+
// A generous noise floor: video is lossy, so a "still" frame still
|
|
1008
|
+
// differs from the last one by a little.
|
|
1009
|
+
`freezedetect=n=-55dB:d=${minDurSecs}`,
|
|
1010
|
+
"-f",
|
|
1011
|
+
"null",
|
|
1012
|
+
"-"
|
|
1013
|
+
]);
|
|
1014
|
+
let err = "";
|
|
1015
|
+
proc.stderr.on("data", (d) => {
|
|
1016
|
+
err += d;
|
|
1017
|
+
});
|
|
1018
|
+
proc.on("error", () => resolve3([]));
|
|
1019
|
+
proc.on("close", () => {
|
|
1020
|
+
const spans = [];
|
|
1021
|
+
let start = null;
|
|
1022
|
+
for (const line of err.split("\n")) {
|
|
1023
|
+
const s = line.match(/freeze_start:\s*(-?[\d.]+)/);
|
|
1024
|
+
if (s?.[1]) start = Math.max(0, Number.parseFloat(s[1]));
|
|
1025
|
+
const e = line.match(/freeze_end:\s*([\d.]+)/);
|
|
1026
|
+
if (e?.[1] && start !== null) {
|
|
1027
|
+
spans.push({ start, end: Number.parseFloat(e[1]) });
|
|
1028
|
+
start = null;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
resolve3(spans);
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
function detectBlackSpans(inputPath, minDurSecs) {
|
|
1036
|
+
return new Promise((resolve3) => {
|
|
1037
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
1038
|
+
"-hide_banner",
|
|
1039
|
+
"-nostats",
|
|
1040
|
+
"-an",
|
|
1041
|
+
"-sn",
|
|
1042
|
+
"-i",
|
|
1043
|
+
inputPath,
|
|
1044
|
+
"-vf",
|
|
1045
|
+
// Strict on purpose. Loosening these to catch a lingering spinner
|
|
1046
|
+
// (pic_th 0.96, pix_th 0.14) cut 37 seconds out of a 95 second
|
|
1047
|
+
// recording: it was matching real dark-themed pages, and 80s of
|
|
1048
|
+
// narration was left with 58s of picture to play over. A missed second
|
|
1049
|
+
// of loading screen is a blemish; a false positive desyncs the video.
|
|
1050
|
+
`blackdetect=d=${minDurSecs}:pic_th=0.98:pix_th=0.10`,
|
|
1051
|
+
"-f",
|
|
1052
|
+
"null",
|
|
1053
|
+
"-"
|
|
1054
|
+
]);
|
|
1055
|
+
let err = "";
|
|
1056
|
+
proc.stderr.on("data", (d) => {
|
|
1057
|
+
err += d;
|
|
1058
|
+
});
|
|
1059
|
+
proc.on("error", () => resolve3([]));
|
|
1060
|
+
proc.on("close", () => {
|
|
1061
|
+
const spans = [];
|
|
1062
|
+
for (const line of err.split("\n")) {
|
|
1063
|
+
const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
|
|
1064
|
+
if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
|
|
1065
|
+
}
|
|
1066
|
+
resolve3(spans);
|
|
1067
|
+
});
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
function detectWhiteSpans(inputPath, minDurSecs) {
|
|
1071
|
+
return new Promise((resolve3) => {
|
|
1072
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
1073
|
+
"-hide_banner",
|
|
1074
|
+
"-nostats",
|
|
1075
|
+
"-an",
|
|
1076
|
+
"-sn",
|
|
1077
|
+
"-i",
|
|
1078
|
+
inputPath,
|
|
1079
|
+
"-vf",
|
|
1080
|
+
`negate,blackdetect=d=${minDurSecs}:pic_th=0.98:pix_th=0.10`,
|
|
1081
|
+
"-f",
|
|
1082
|
+
"null",
|
|
1083
|
+
"-"
|
|
1084
|
+
]);
|
|
1085
|
+
let err = "";
|
|
1086
|
+
proc.stderr.on("data", (d) => {
|
|
1087
|
+
err += d;
|
|
1088
|
+
});
|
|
1089
|
+
proc.on("error", () => resolve3([]));
|
|
1090
|
+
proc.on("close", () => {
|
|
1091
|
+
const spans = [];
|
|
1092
|
+
for (const line of err.split("\n")) {
|
|
1093
|
+
const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
|
|
1094
|
+
if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
|
|
1095
|
+
}
|
|
1096
|
+
resolve3(spans);
|
|
1097
|
+
});
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
function intersectSpans(a, b) {
|
|
1101
|
+
const out = [];
|
|
1102
|
+
for (const x of a) {
|
|
1103
|
+
for (const y of b) {
|
|
1104
|
+
const start = Math.max(x.start, y.start);
|
|
1105
|
+
const end = Math.min(x.end, y.end);
|
|
1106
|
+
if (end > start) out.push({ start, end });
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return out.sort((p, q) => p.start - q.start);
|
|
1110
|
+
}
|
|
1111
|
+
function mergeSpans(spans) {
|
|
1112
|
+
const sorted = [...spans].sort((a, b) => a.start - b.start);
|
|
1113
|
+
const out = [];
|
|
1114
|
+
for (const span of sorted) {
|
|
1115
|
+
const last = out[out.length - 1];
|
|
1116
|
+
if (last && span.start <= last.end + 0.01) {
|
|
1117
|
+
last.end = Math.max(last.end, span.end);
|
|
1118
|
+
if (span.cap !== void 0) last.cap = Math.min(last.cap ?? span.cap, span.cap);
|
|
1119
|
+
} else out.push({ ...span });
|
|
1120
|
+
}
|
|
1121
|
+
return out;
|
|
1122
|
+
}
|
|
1123
|
+
function atempoChain(factor) {
|
|
1124
|
+
const steps = [];
|
|
1125
|
+
let remaining = factor;
|
|
1126
|
+
while (remaining > 2) {
|
|
1127
|
+
steps.push(2);
|
|
1128
|
+
remaining /= 2;
|
|
1129
|
+
}
|
|
1130
|
+
if (remaining > 1.001) steps.push(remaining);
|
|
1131
|
+
return steps.map((s) => `atempo=${s.toFixed(4)}`).join(",");
|
|
1132
|
+
}
|
|
1133
|
+
function buildCompressionFilter(spans, totalSecs, maxSecs, protectAfterSecs, protectBeforeSecs) {
|
|
1134
|
+
const limit = protectAfterSecs ?? totalSecs;
|
|
1135
|
+
const floor = Math.max(0, protectBeforeSecs ?? 0);
|
|
1136
|
+
const usable = spans.map((s) => ({
|
|
1137
|
+
start: Math.max(floor, s.start),
|
|
1138
|
+
end: Math.min(totalSecs, Math.min(s.end, limit)),
|
|
1139
|
+
cap: Math.min(s.cap ?? maxSecs, maxSecs)
|
|
1140
|
+
})).filter((s) => s.start < limit && s.end - s.start > s.cap + 0.05).sort((a, b) => a.start - b.start);
|
|
1141
|
+
if (usable.length === 0) return null;
|
|
1142
|
+
const parts = [];
|
|
1143
|
+
const labels = [];
|
|
1144
|
+
let cursor = 0;
|
|
1145
|
+
let n = 0;
|
|
1146
|
+
const keep = (from, to) => {
|
|
1147
|
+
if (to - from < 0.04) return;
|
|
1148
|
+
parts.push(`[0:v]trim=${from.toFixed(3)}:${to.toFixed(3)},setpts=PTS-STARTPTS[v${n}]`);
|
|
1149
|
+
parts.push(`[0:a]atrim=${from.toFixed(3)}:${to.toFixed(3)},asetpts=PTS-STARTPTS[a${n}]`);
|
|
1150
|
+
labels.push(`[v${n}][a${n}]`);
|
|
1151
|
+
n++;
|
|
1152
|
+
};
|
|
1153
|
+
for (const span of usable) {
|
|
1154
|
+
keep(cursor, span.start);
|
|
1155
|
+
const factor = (span.end - span.start) / span.cap;
|
|
1156
|
+
const tempo = atempoChain(factor);
|
|
1157
|
+
parts.push(
|
|
1158
|
+
`[0:v]trim=${span.start.toFixed(3)}:${span.end.toFixed(3)},setpts=(PTS-STARTPTS)/${factor.toFixed(4)}[v${n}]`
|
|
1159
|
+
);
|
|
1160
|
+
parts.push(
|
|
1161
|
+
`[0:a]atrim=${span.start.toFixed(3)}:${span.end.toFixed(3)},asetpts=PTS-STARTPTS${tempo ? `,${tempo}` : ""}[a${n}]`
|
|
1162
|
+
);
|
|
1163
|
+
labels.push(`[v${n}][a${n}]`);
|
|
1164
|
+
n++;
|
|
1165
|
+
cursor = span.end;
|
|
1166
|
+
}
|
|
1167
|
+
keep(cursor, totalSecs);
|
|
1168
|
+
if (n < 2) return null;
|
|
1169
|
+
return `${parts.join(";")};${labels.join("")}concat=n=${n}:v=1:a=1[outv][outa]`;
|
|
1170
|
+
}
|
|
1171
|
+
async function passEncoder(preset) {
|
|
1172
|
+
const encoder = await resolveEncoder({ crf: 18, preset, quality: 20 });
|
|
1173
|
+
return {
|
|
1174
|
+
input: encoder.input,
|
|
1175
|
+
output: encoder.output,
|
|
1176
|
+
filterSuffix: encoder.filter ? `;[outv]${encoder.filter}[outvhw]` : "",
|
|
1177
|
+
videoLabel: encoder.filter ? "[outvhw]" : "[outv]"
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
|
|
1181
|
+
const keep = mergeSpans(spans.map((s) => ({ ...s }))).filter((s) => s.end - s.start > 0.15);
|
|
1182
|
+
if (keep.length === 0) return false;
|
|
1183
|
+
const drop = keep.map((s) => `between(t,${s.start.toFixed(3)},${s.end.toFixed(3)})`).join("+");
|
|
1184
|
+
const filter = `[0:v]select='not(${drop})',setpts=N/FRAME_RATE/TB[outv]`;
|
|
1185
|
+
const enc = await passEncoder(preset);
|
|
1186
|
+
return new Promise((resolve3) => {
|
|
1187
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
1188
|
+
"-y",
|
|
1189
|
+
...filterThreadArgs(),
|
|
1190
|
+
...enc.input,
|
|
1191
|
+
"-i",
|
|
1192
|
+
inputPath,
|
|
1193
|
+
"-filter_complex",
|
|
1194
|
+
enc.filterSuffix ? filter + enc.filterSuffix : filter,
|
|
1195
|
+
"-map",
|
|
1196
|
+
enc.videoLabel,
|
|
1197
|
+
"-an",
|
|
1198
|
+
...enc.output,
|
|
1199
|
+
"-movflags",
|
|
1200
|
+
"+faststart",
|
|
1201
|
+
outputPath
|
|
1202
|
+
]);
|
|
1203
|
+
proc.stderr.resume();
|
|
1204
|
+
proc.on("error", () => resolve3(false));
|
|
1205
|
+
proc.on("close", (code) => resolve3(code === 0));
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
function buildRetimeFilter(spans, stepBounds, speed, fps) {
|
|
1209
|
+
const all = mergeSpans(spans.map((s) => ({ ...s })));
|
|
1210
|
+
const totalSecs = stepBounds[stepBounds.length - 1]?.to ?? 0;
|
|
1211
|
+
const frame = 1 / Math.max(1, fps);
|
|
1212
|
+
const parts = [];
|
|
1213
|
+
const labels = [];
|
|
1214
|
+
let n = 0;
|
|
1215
|
+
let reclaimed = 0;
|
|
1216
|
+
let pendingFrom = null;
|
|
1217
|
+
const flush = (to) => {
|
|
1218
|
+
if (pendingFrom === null) return;
|
|
1219
|
+
const from = pendingFrom;
|
|
1220
|
+
pendingFrom = null;
|
|
1221
|
+
if (to - from < 0.04) return;
|
|
1222
|
+
parts.push(`[0:v]trim=${from.toFixed(3)}:${to.toFixed(3)},setpts=PTS-STARTPTS,fps=${fps}[v${n}]`);
|
|
1223
|
+
labels.push(`[v${n}]`);
|
|
1224
|
+
n++;
|
|
1225
|
+
};
|
|
1226
|
+
const segment = (from, to, factor) => {
|
|
1227
|
+
if (to - from < 0.04) return;
|
|
1228
|
+
const pts = factor === 1 ? "PTS-STARTPTS" : `(PTS-STARTPTS)*${factor.toFixed(5)}`;
|
|
1229
|
+
parts.push(`[0:v]trim=${from.toFixed(3)}:${to.toFixed(3)},setpts=${pts},fps=${fps}[v${n}]`);
|
|
1230
|
+
labels.push(`[v${n}]`);
|
|
1231
|
+
n++;
|
|
1232
|
+
};
|
|
1233
|
+
const freeze = (from, to, sourceAt) => {
|
|
1234
|
+
const dur = to - from;
|
|
1235
|
+
if (dur < 0.04) return;
|
|
1236
|
+
const at = sourceAt + frame <= totalSecs ? sourceAt : Math.max(0, from - frame);
|
|
1237
|
+
parts.push(
|
|
1238
|
+
`[0:v]trim=${at.toFixed(3)}:${(at + frame).toFixed(3)},setpts=PTS-STARTPTS,loop=loop=${Math.ceil(dur * fps) + 1}:size=1:start=0,setpts=N/FRAME_RATE/TB,trim=0:${dur.toFixed(3)},setpts=PTS-STARTPTS,fps=${fps}[v${n}]`
|
|
1239
|
+
);
|
|
1240
|
+
labels.push(`[v${n}]`);
|
|
1241
|
+
n++;
|
|
1242
|
+
};
|
|
1243
|
+
for (const step of stepBounds) {
|
|
1244
|
+
const local = all.map((s) => ({ start: Math.max(s.start, step.from), end: Math.min(s.end, step.to), sourceAt: s.end })).filter((s) => s.end - s.start >= MIN_RETIME_SPAN);
|
|
1245
|
+
if (local.length === 0) {
|
|
1246
|
+
if (pendingFrom === null) pendingFrom = step.from;
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
const window = step.to - step.from;
|
|
1250
|
+
const emptyTotal = local.reduce((a, s) => a + (s.end - s.start), 0);
|
|
1251
|
+
const keepTotal = window - emptyTotal;
|
|
1252
|
+
let recovered = emptyTotal * (1 - 1 / speed);
|
|
1253
|
+
let stretch = keepTotal > 0 ? (keepTotal + recovered) / keepTotal : Number.POSITIVE_INFINITY;
|
|
1254
|
+
if (stretch > MAX_STRETCH) {
|
|
1255
|
+
recovered = keepTotal * (MAX_STRETCH - 1);
|
|
1256
|
+
stretch = MAX_STRETCH;
|
|
1257
|
+
}
|
|
1258
|
+
const effective = emptyTotal / Math.max(0.01, emptyTotal - recovered);
|
|
1259
|
+
flush(step.from);
|
|
1260
|
+
let cursor = step.from;
|
|
1261
|
+
if (keepTotal >= MIN_KEEP_SECS && effective >= MIN_EFFECTIVE_SPEED) {
|
|
1262
|
+
for (const span of local) {
|
|
1263
|
+
segment(cursor, span.start, stretch);
|
|
1264
|
+
segment(span.start, span.end, 1 / effective);
|
|
1265
|
+
cursor = span.end;
|
|
1266
|
+
}
|
|
1267
|
+
segment(cursor, step.to, stretch);
|
|
1268
|
+
reclaimed += emptyTotal - emptyTotal / effective;
|
|
1269
|
+
} else {
|
|
1270
|
+
for (const span of local) {
|
|
1271
|
+
segment(cursor, span.start, 1);
|
|
1272
|
+
freeze(span.start, span.end, span.sourceAt);
|
|
1273
|
+
cursor = span.end;
|
|
1274
|
+
}
|
|
1275
|
+
segment(cursor, step.to, 1);
|
|
1276
|
+
reclaimed += emptyTotal;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
flush(totalSecs);
|
|
1280
|
+
if (n < 2 || reclaimed < 0.5) return null;
|
|
1281
|
+
return `${parts.join(";")};${labels.join("")}concat=n=${n}:v=1:a=0[outv]`;
|
|
1282
|
+
}
|
|
1283
|
+
async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, speed, fps, preset) {
|
|
1284
|
+
const filter = buildRetimeFilter(spans, stepBounds, speed, fps);
|
|
1285
|
+
if (!filter) return false;
|
|
1286
|
+
const enc = await passEncoder(preset);
|
|
1287
|
+
return new Promise((resolve3) => {
|
|
1288
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
1289
|
+
"-y",
|
|
1290
|
+
...filterThreadArgs(),
|
|
1291
|
+
...enc.input,
|
|
1292
|
+
"-i",
|
|
1293
|
+
inputPath,
|
|
1294
|
+
"-filter_complex",
|
|
1295
|
+
enc.filterSuffix ? filter + enc.filterSuffix : filter,
|
|
1296
|
+
"-map",
|
|
1297
|
+
enc.videoLabel,
|
|
1298
|
+
"-an",
|
|
1299
|
+
...enc.output,
|
|
1300
|
+
"-movflags",
|
|
1301
|
+
"+faststart",
|
|
1302
|
+
outputPath
|
|
1303
|
+
]);
|
|
1304
|
+
proc.stderr.resume();
|
|
1305
|
+
proc.on("error", () => resolve3(false));
|
|
1306
|
+
proc.on("close", (code) => resolve3(code === 0));
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, preset, protectAfterSecs, narrationPath, protectBeforeSecs, leadSpans, keepSpans) {
|
|
1310
|
+
const audioSource = narrationPath || inputPath;
|
|
1311
|
+
const frozenMax = Math.min(maxSecs, FROZEN_MAX_SECS);
|
|
1312
|
+
const [detected, frozen, black, white] = await Promise.all([
|
|
1313
|
+
detectSilences(audioSource, frozenMax + 0.05),
|
|
1314
|
+
detectFrozenSpans(inputPath, frozenMax + 0.05),
|
|
1315
|
+
// Shorter threshold than the other two: a page transition is often only a
|
|
1316
|
+
// second, and a second of black in a 60s Short is a second too many.
|
|
1317
|
+
detectBlackSpans(inputPath, 0.3),
|
|
1318
|
+
detectWhiteSpans(inputPath, 0.3)
|
|
1319
|
+
]);
|
|
1320
|
+
const silent = detected.filter((span) => span.end - span.start > maxSecs + 0.05);
|
|
1321
|
+
const stuck = intersectSpans(frozen, detected).filter((span) => span.end - span.start > frozenMax).map((span) => ({ ...span, cap: frozenMax }));
|
|
1322
|
+
const cardFloor = Math.max(0, protectBeforeSecs ?? 0);
|
|
1323
|
+
const cardCeiling = protectAfterSecs ?? totalSecs;
|
|
1324
|
+
const empty = intersectSpans(mergeSpans([...black, ...white]), detected).filter((span) => span.end - span.start > BLACK_KEEP_SECS * 2).filter((span) => span.start >= cardFloor && span.end <= cardCeiling).map((span) => ({ ...span, cap: BLACK_KEEP_SECS }));
|
|
1325
|
+
const known = (leadSpans ?? []).filter((span) => span.end - span.start > BLACK_KEEP_SECS * 2).map((span) => ({ ...span, cap: BLACK_KEEP_SECS }));
|
|
1326
|
+
const merged = mergeSpans([...silent, ...stuck, ...empty, ...known]);
|
|
1327
|
+
const overlapsContent = (span) => (keepSpans ?? []).some((keep) => span.start < keep.end && span.end > keep.start);
|
|
1328
|
+
const spans = merged.filter((span) => !overlapsContent(span));
|
|
1329
|
+
const filter = buildCompressionFilter(spans, totalSecs, maxSecs, protectAfterSecs, protectBeforeSecs);
|
|
1330
|
+
if (!filter) return false;
|
|
1331
|
+
const enc = await passEncoder(preset);
|
|
1332
|
+
return new Promise((resolve3) => {
|
|
1333
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
1334
|
+
"-y",
|
|
1335
|
+
...filterThreadArgs(),
|
|
1336
|
+
...enc.input,
|
|
1337
|
+
"-i",
|
|
1338
|
+
inputPath,
|
|
1339
|
+
"-filter_complex",
|
|
1340
|
+
filter + enc.filterSuffix,
|
|
1341
|
+
"-map",
|
|
1342
|
+
enc.videoLabel,
|
|
1343
|
+
"-map",
|
|
1344
|
+
"[outa]",
|
|
1345
|
+
...enc.output,
|
|
1346
|
+
"-c:a",
|
|
1347
|
+
"aac",
|
|
1348
|
+
// No -shortest here. The overhang it was added for is already handled by
|
|
1349
|
+
// bounding every segment to the VIDEO stream's length, and the flag cut
|
|
1350
|
+
// the other way once an outro existed: a credits card carries no
|
|
1351
|
+
// narration, so the audio runs out before the picture does and the card
|
|
1352
|
+
// was truncated away.
|
|
1353
|
+
"-movflags",
|
|
1354
|
+
"+faststart",
|
|
1355
|
+
outputPath
|
|
1356
|
+
]);
|
|
1357
|
+
proc.stderr.resume();
|
|
1358
|
+
proc.on("error", () => resolve3(false));
|
|
1359
|
+
proc.on("close", (code) => resolve3(code === 0));
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, introSecs, outroSecs, maxFactor, preset) {
|
|
1363
|
+
const ends = introSecs + outroSecs;
|
|
1364
|
+
const middle = totalSecs - ends;
|
|
1365
|
+
const budget = targetSecs - ends - FIT_UNDERSHOOT_SECS;
|
|
1366
|
+
if (middle <= 0.5 || budget <= 0.5 || middle <= budget) {
|
|
1367
|
+
return { ok: false, factor: 1, reachedTarget: totalSecs <= targetSecs };
|
|
1368
|
+
}
|
|
1369
|
+
const needed = middle / budget;
|
|
1370
|
+
const factor = Math.min(needed, maxFactor);
|
|
1371
|
+
const tempo = atempoChain(factor);
|
|
1372
|
+
const midEnd = totalSecs - outroSecs;
|
|
1373
|
+
const filter = [
|
|
1374
|
+
`[0:v]trim=0:${introSecs.toFixed(3)},setpts=PTS-STARTPTS[v0]`,
|
|
1375
|
+
`[0:a]atrim=0:${introSecs.toFixed(3)},asetpts=PTS-STARTPTS[a0]`,
|
|
1376
|
+
`[0:v]trim=${introSecs.toFixed(3)}:${midEnd.toFixed(3)},setpts=(PTS-STARTPTS)/${factor.toFixed(4)}[v1]`,
|
|
1377
|
+
`[0:a]atrim=${introSecs.toFixed(3)}:${midEnd.toFixed(3)},asetpts=PTS-STARTPTS${tempo ? `,${tempo}` : ""}[a1]`,
|
|
1378
|
+
`[0:v]trim=${midEnd.toFixed(3)}:${totalSecs.toFixed(3)},setpts=PTS-STARTPTS[v2]`,
|
|
1379
|
+
`[0:a]atrim=${midEnd.toFixed(3)}:${totalSecs.toFixed(3)},asetpts=PTS-STARTPTS[a2]`,
|
|
1380
|
+
"[v0][a0][v1][a1][v2][a2]concat=n=3:v=1:a=1[outv][outa]"
|
|
1381
|
+
].join(";");
|
|
1382
|
+
const enc = await passEncoder(preset);
|
|
1383
|
+
const ok = await new Promise((resolve3) => {
|
|
1384
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
434
1385
|
"-y",
|
|
1386
|
+
...filterThreadArgs(),
|
|
1387
|
+
...enc.input,
|
|
435
1388
|
"-i",
|
|
436
1389
|
inputPath,
|
|
437
|
-
"-i",
|
|
438
|
-
outroPath,
|
|
439
1390
|
"-filter_complex",
|
|
440
|
-
|
|
1391
|
+
filter + enc.filterSuffix,
|
|
441
1392
|
"-map",
|
|
442
|
-
|
|
443
|
-
"-
|
|
444
|
-
"
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
"
|
|
448
|
-
"
|
|
449
|
-
"
|
|
450
|
-
"yuv420p",
|
|
451
|
-
"-an",
|
|
1393
|
+
enc.videoLabel,
|
|
1394
|
+
"-map",
|
|
1395
|
+
"[outa]",
|
|
1396
|
+
...enc.output,
|
|
1397
|
+
"-c:a",
|
|
1398
|
+
"aac",
|
|
1399
|
+
"-movflags",
|
|
1400
|
+
"+faststart",
|
|
452
1401
|
outputPath
|
|
453
|
-
]
|
|
454
|
-
|
|
455
|
-
|
|
1402
|
+
]);
|
|
1403
|
+
proc.stderr.resume();
|
|
1404
|
+
proc.on("error", () => resolve3(false));
|
|
1405
|
+
proc.on("close", (code) => resolve3(code === 0));
|
|
1406
|
+
});
|
|
1407
|
+
return { ok, factor, reachedTarget: needed <= maxFactor };
|
|
456
1408
|
}
|
|
457
|
-
var
|
|
458
|
-
|
|
1409
|
+
var SPEECH_GUARD_SECS, BLACK_KEEP_SECS, FROZEN_MAX_SECS, EMPTY_SPAN_SPEED, MAX_STRETCH, MIN_EFFECTIVE_SPEED, MIN_KEEP_SECS, MIN_RETIME_SPAN, FIT_UNDERSHOOT_SECS;
|
|
1410
|
+
var init_silence = __esm({
|
|
1411
|
+
"src/lib/ffmpeg/silence.ts"() {
|
|
459
1412
|
"use strict";
|
|
1413
|
+
init_hwaccel();
|
|
460
1414
|
init_spawn();
|
|
1415
|
+
SPEECH_GUARD_SECS = 0.12;
|
|
1416
|
+
BLACK_KEEP_SECS = 0.12;
|
|
1417
|
+
FROZEN_MAX_SECS = 0.6;
|
|
1418
|
+
EMPTY_SPAN_SPEED = 5;
|
|
1419
|
+
MAX_STRETCH = 2.5;
|
|
1420
|
+
MIN_EFFECTIVE_SPEED = 1.3;
|
|
1421
|
+
MIN_KEEP_SECS = 0.3;
|
|
1422
|
+
MIN_RETIME_SPAN = 0.2;
|
|
1423
|
+
FIT_UNDERSHOOT_SECS = 0.35;
|
|
461
1424
|
}
|
|
462
1425
|
});
|
|
463
1426
|
|
|
464
1427
|
// src/lib/ffmpeg/splice.ts
|
|
465
|
-
function
|
|
1428
|
+
function coverFilterChain(inputIdx, width, height) {
|
|
1429
|
+
const bg = `cov${inputIdx}bg`;
|
|
1430
|
+
const fg = `cov${inputIdx}fg`;
|
|
1431
|
+
return `split=2[${bg}][${fg}];[${bg}]scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},scale=${COVER_WASH_W}:${COVER_WASH_H},scale=${width}:${height},gblur=sigma=${COVER_BLUR_SIGMA},eq=brightness=-0.12:saturation=0.75[${bg}b];[${fg}]scale=${width}:${height}:force_original_aspect_ratio=decrease[${fg}s];[${bg}b][${fg}s]overlay=(W-w)/2:(H-h)/2`;
|
|
1432
|
+
}
|
|
1433
|
+
async function spliceVideos(opts) {
|
|
1434
|
+
const { inputPath, outputPath, overlays, width, height, preset } = opts;
|
|
1435
|
+
if (overlays.length === 0) {
|
|
1436
|
+
return runFFmpeg(["-y", "-i", inputPath, "-c", "copy", outputPath], "ffmpeg copy failed");
|
|
1437
|
+
}
|
|
1438
|
+
const audioPresent = await Promise.all(
|
|
1439
|
+
overlays.map((o) => o.still ? Promise.resolve(false) : hasAudioStream(o.path))
|
|
1440
|
+
);
|
|
466
1441
|
return new Promise((resolve3, reject) => {
|
|
467
|
-
const { inputPath, outputPath, overlays, width, height, preset } = opts;
|
|
468
|
-
if (overlays.length === 0) {
|
|
469
|
-
runFFmpeg(["-y", "-i", inputPath, "-c", "copy", outputPath], "ffmpeg copy failed").then(resolve3, reject);
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
1442
|
const args = ["-y", "-i", inputPath];
|
|
473
1443
|
for (const overlay of overlays) {
|
|
1444
|
+
if (overlay.still) {
|
|
1445
|
+
args.push("-loop", "1", "-framerate", String(STILL_FRAMERATE), "-t", overlay.duration.toFixed(3));
|
|
1446
|
+
}
|
|
474
1447
|
args.push("-i", overlay.path);
|
|
475
1448
|
}
|
|
476
1449
|
const filterParts = [];
|
|
@@ -490,13 +1463,18 @@ function spliceVideos(opts) {
|
|
|
490
1463
|
}
|
|
491
1464
|
}
|
|
492
1465
|
if (overlay.mode === "stretch") {
|
|
493
|
-
filters.push(`scale=${width}
|
|
1466
|
+
filters.push(`scale=${width}:-2`);
|
|
1467
|
+
filters.push(`pad=${width}:max(ih\\,${height}):0:(oh-ih)/2:color=#1a1a2e`);
|
|
494
1468
|
filters.push(`crop=${width}:${height}`);
|
|
1469
|
+
} else if (overlay.mode === "cover") {
|
|
1470
|
+
filters.push(coverFilterChain(inputIdx, width, height));
|
|
495
1471
|
} else {
|
|
496
|
-
filters.push(
|
|
1472
|
+
filters.push(
|
|
1473
|
+
`scale=${Math.round(width * 0.9)}:${Math.round(height * 0.9)}:force_original_aspect_ratio=decrease`
|
|
1474
|
+
);
|
|
497
1475
|
filters.push(`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=#1a1a2e`);
|
|
498
1476
|
}
|
|
499
|
-
filters.push(
|
|
1477
|
+
filters.push(`setpts=PTS-STARTPTS+${overlay.startTime.toFixed(3)}/TB`);
|
|
500
1478
|
filterParts.push(`[${inputIdx}:v]${filters.join(",")}[v${inputIdx}]`);
|
|
501
1479
|
const nextStream = i === overlays.length - 1 ? "outv" : `tmp${i}`;
|
|
502
1480
|
filterParts.push(
|
|
@@ -504,19 +1482,39 @@ function spliceVideos(opts) {
|
|
|
504
1482
|
);
|
|
505
1483
|
currentStream = nextStream;
|
|
506
1484
|
}
|
|
1485
|
+
const clipAudio = overlays.map((overlay, i) => ({ overlay, inputIdx: i + 1 })).filter(({ inputIdx }) => audioPresent[inputIdx - 1]);
|
|
1486
|
+
for (const { overlay, inputIdx } of clipAudio) {
|
|
1487
|
+
const delayMs = Math.round(overlay.startTime * 1e3);
|
|
1488
|
+
filterParts.push(
|
|
1489
|
+
`[${inputIdx}:a]atrim=0:${overlay.duration.toFixed(3)},asetpts=PTS-STARTPTS,adelay=${delayMs}|${delayMs},volume=${CLIP_AUDIO_GAIN}[a${inputIdx}]`
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
if (clipAudio.length === 1) {
|
|
1493
|
+
filterParts.push(`[a${clipAudio[0].inputIdx}]anull[outa]`);
|
|
1494
|
+
} else if (clipAudio.length > 1) {
|
|
1495
|
+
const labels = clipAudio.map(({ inputIdx }) => `[a${inputIdx}]`).join("");
|
|
1496
|
+
filterParts.push(`${labels}amix=inputs=${clipAudio.length}:normalize=0:dropout_transition=0[outa]`);
|
|
1497
|
+
}
|
|
507
1498
|
args.push("-filter_complex", filterParts.join(";"));
|
|
508
1499
|
args.push("-map", "[outv]");
|
|
509
|
-
args.push("-map", "
|
|
1500
|
+
if (clipAudio.length > 0) args.push("-map", "[outa]");
|
|
510
1501
|
args.push("-c:v", "libx264", "-preset", preset, "-crf", "18", "-pix_fmt", "yuv420p");
|
|
511
|
-
args.push("-c:a", "
|
|
1502
|
+
if (clipAudio.length > 0) args.push("-c:a", "aac");
|
|
512
1503
|
args.push(outputPath);
|
|
513
1504
|
runFFmpeg(args, "ffmpeg splice failed").then(resolve3, reject);
|
|
514
1505
|
});
|
|
515
1506
|
}
|
|
1507
|
+
var CLIP_AUDIO_GAIN, STILL_FRAMERATE, COVER_WASH_W, COVER_WASH_H, COVER_BLUR_SIGMA;
|
|
516
1508
|
var init_splice = __esm({
|
|
517
1509
|
"src/lib/ffmpeg/splice.ts"() {
|
|
518
1510
|
"use strict";
|
|
1511
|
+
init_media();
|
|
519
1512
|
init_spawn();
|
|
1513
|
+
CLIP_AUDIO_GAIN = 0.55;
|
|
1514
|
+
STILL_FRAMERATE = 30;
|
|
1515
|
+
COVER_WASH_W = 6;
|
|
1516
|
+
COVER_WASH_H = 10;
|
|
1517
|
+
COVER_BLUR_SIGMA = 40;
|
|
520
1518
|
}
|
|
521
1519
|
});
|
|
522
1520
|
|
|
@@ -613,7 +1611,9 @@ var init_ffmpeg = __esm({
|
|
|
613
1611
|
"use strict";
|
|
614
1612
|
init_compose();
|
|
615
1613
|
init_compress();
|
|
1614
|
+
init_gif();
|
|
616
1615
|
init_media();
|
|
1616
|
+
init_silence();
|
|
617
1617
|
init_splice();
|
|
618
1618
|
init_transitions();
|
|
619
1619
|
init_types();
|
|
@@ -630,8 +1630,8 @@ var init_ffmpeg2 = __esm({
|
|
|
630
1630
|
|
|
631
1631
|
// src/lib/tts/helpers.ts
|
|
632
1632
|
import { existsSync } from "fs";
|
|
633
|
-
import { readFile, unlink, writeFile } from "fs/promises";
|
|
634
|
-
import { isAbsolute, join, resolve } from "path";
|
|
1633
|
+
import { readFile, unlink as unlink2, writeFile } from "fs/promises";
|
|
1634
|
+
import { isAbsolute, join as join2, resolve } from "path";
|
|
635
1635
|
function toWSLPathForTTS(path) {
|
|
636
1636
|
return windowsDrivePathToWsl(path) ?? path;
|
|
637
1637
|
}
|
|
@@ -684,7 +1684,7 @@ async function getMediaStepDuration(step, baseDir) {
|
|
|
684
1684
|
}
|
|
685
1685
|
}
|
|
686
1686
|
async function loadVoiceoverMeta(outputDir) {
|
|
687
|
-
const metaPath =
|
|
1687
|
+
const metaPath = join2(outputDir, "voiceover.json");
|
|
688
1688
|
try {
|
|
689
1689
|
if (!existsSync(metaPath)) return null;
|
|
690
1690
|
const content = await readFile(metaPath, "utf-8");
|
|
@@ -694,7 +1694,7 @@ async function loadVoiceoverMeta(outputDir) {
|
|
|
694
1694
|
}
|
|
695
1695
|
}
|
|
696
1696
|
async function saveVoiceoverMeta(outputDir, meta) {
|
|
697
|
-
const metaPath =
|
|
1697
|
+
const metaPath = join2(outputDir, "voiceover.json");
|
|
698
1698
|
await writeFile(metaPath, JSON.stringify(meta, null, 2));
|
|
699
1699
|
}
|
|
700
1700
|
async function concatenateAudio(inputs, output, workDir) {
|
|
@@ -703,19 +1703,21 @@ async function concatenateAudio(inputs, output, workDir) {
|
|
|
703
1703
|
return isWindows ? windowsDrivePathToWsl(resolved) ?? resolved : resolved;
|
|
704
1704
|
};
|
|
705
1705
|
const listContent = inputs.map((f) => `file '${formatPath(f)}'`).join("\n");
|
|
706
|
-
const listPath =
|
|
1706
|
+
const listPath = join2(workDir, "concat.txt");
|
|
707
1707
|
await writeFile(listPath, listContent);
|
|
708
1708
|
await concatMedia(listPath, output);
|
|
709
|
-
await
|
|
1709
|
+
await unlink2(listPath).catch(() => {
|
|
710
1710
|
});
|
|
711
1711
|
}
|
|
712
|
-
var isWindows;
|
|
1712
|
+
var isWindows, NARRATION_LEAD_IN_MS, LEAD_IN_CLIP;
|
|
713
1713
|
var init_helpers = __esm({
|
|
714
1714
|
"src/lib/tts/helpers.ts"() {
|
|
715
1715
|
"use strict";
|
|
716
1716
|
init_ffmpeg2();
|
|
717
1717
|
init_utils();
|
|
718
1718
|
isWindows = process.platform === "win32";
|
|
1719
|
+
NARRATION_LEAD_IN_MS = 300;
|
|
1720
|
+
LEAD_IN_CLIP = "lead-in.mp3";
|
|
719
1721
|
}
|
|
720
1722
|
});
|
|
721
1723
|
|
|
@@ -723,10 +1725,12 @@ var init_helpers = __esm({
|
|
|
723
1725
|
var timing_exports = {};
|
|
724
1726
|
__export(timing_exports, {
|
|
725
1727
|
alignVoiceoverToRecording: () => alignVoiceoverToRecording,
|
|
726
|
-
|
|
1728
|
+
alignWordsToCaption: () => alignWordsToCaption,
|
|
1729
|
+
estimateWordTimings: () => estimateWordTimings,
|
|
1730
|
+
spokenWordTimings: () => spokenWordTimings
|
|
727
1731
|
});
|
|
728
1732
|
import { existsSync as existsSync2 } from "fs";
|
|
729
|
-
import { join as
|
|
1733
|
+
import { join as join3 } from "path";
|
|
730
1734
|
function estimateWordTimings(caption, duration) {
|
|
731
1735
|
const words = caption.split(/\s+/).filter((w) => w.length > 0);
|
|
732
1736
|
const wordDuration = duration / words.length;
|
|
@@ -736,6 +1740,36 @@ function estimateWordTimings(caption, duration) {
|
|
|
736
1740
|
end: Math.round((i + 1) * wordDuration)
|
|
737
1741
|
}));
|
|
738
1742
|
}
|
|
1743
|
+
function alignWordsToCaption(caption, spoken) {
|
|
1744
|
+
const captionWords = caption.split(/\s+/).filter((w) => w.length > 0);
|
|
1745
|
+
if (captionWords.length === 0 || spoken.length === 0) return [];
|
|
1746
|
+
if (spoken.length === captionWords.length) {
|
|
1747
|
+
return captionWords.map((word, i) => ({
|
|
1748
|
+
word,
|
|
1749
|
+
start: Math.round(spoken[i].start),
|
|
1750
|
+
end: Math.round(spoken[i].end)
|
|
1751
|
+
}));
|
|
1752
|
+
}
|
|
1753
|
+
const speechStart = spoken[0].start;
|
|
1754
|
+
const speechEnd = spoken[spoken.length - 1].end;
|
|
1755
|
+
const wordDuration = (speechEnd - speechStart) / captionWords.length;
|
|
1756
|
+
return captionWords.map((word, i) => ({
|
|
1757
|
+
word,
|
|
1758
|
+
start: Math.round(speechStart + i * wordDuration),
|
|
1759
|
+
end: Math.round(speechStart + (i + 1) * wordDuration)
|
|
1760
|
+
}));
|
|
1761
|
+
}
|
|
1762
|
+
function spokenWordTimings(caption, spoken) {
|
|
1763
|
+
if (!spoken || spoken.length === 0) return null;
|
|
1764
|
+
const leadTrim = spoken[0].start;
|
|
1765
|
+
const rebased = spoken.map((w) => ({
|
|
1766
|
+
word: w.word,
|
|
1767
|
+
start: Math.max(0, w.start - leadTrim),
|
|
1768
|
+
end: Math.max(0, w.end - leadTrim)
|
|
1769
|
+
}));
|
|
1770
|
+
const aligned = alignWordsToCaption(caption, rebased);
|
|
1771
|
+
return aligned.length > 0 ? aligned : null;
|
|
1772
|
+
}
|
|
739
1773
|
async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, workDir, outputDir) {
|
|
740
1774
|
let needsRealignment = false;
|
|
741
1775
|
for (let i = 0; i < actualRecordingDurations.length; i++) {
|
|
@@ -750,31 +1784,35 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
|
|
|
750
1784
|
if (!needsRealignment) {
|
|
751
1785
|
return stepAudio;
|
|
752
1786
|
}
|
|
753
|
-
const audioDir =
|
|
1787
|
+
const audioDir = join3(workDir, "audio");
|
|
754
1788
|
const segments = [];
|
|
755
1789
|
const alignedStepAudio = /* @__PURE__ */ new Map();
|
|
1790
|
+
const leadInPath = join3(audioDir, LEAD_IN_CLIP);
|
|
1791
|
+
const leadIn = existsSync2(leadInPath) ? NARRATION_LEAD_IN_MS : 0;
|
|
1792
|
+
if (leadIn) segments.push(leadInPath);
|
|
756
1793
|
for (let i = 0; i < actualRecordingDurations.length; i++) {
|
|
757
1794
|
const stepNum = i + 1;
|
|
758
1795
|
const voiceData = stepAudio.get(stepNum);
|
|
759
1796
|
const voiceDuration = voiceData?.duration ?? 0;
|
|
760
1797
|
const recordingDuration = actualRecordingDurations[i] ?? 0;
|
|
761
1798
|
const targetDuration = Math.max(voiceDuration, recordingDuration);
|
|
762
|
-
const audioPath =
|
|
763
|
-
const originalPaddingPath =
|
|
764
|
-
const silencePath =
|
|
1799
|
+
const audioPath = join3(audioDir, `step-${stepNum}.mp3`);
|
|
1800
|
+
const originalPaddingPath = join3(audioDir, `step-${stepNum}-padding.mp3`);
|
|
1801
|
+
const silencePath = join3(audioDir, `step-${stepNum}-silence.mp3`);
|
|
765
1802
|
if (existsSync2(audioPath)) {
|
|
766
1803
|
segments.push(audioPath);
|
|
767
1804
|
if (existsSync2(originalPaddingPath)) {
|
|
768
1805
|
segments.push(originalPaddingPath);
|
|
769
1806
|
}
|
|
770
1807
|
if (recordingDuration > voiceDuration) {
|
|
771
|
-
const alignPaddingPath =
|
|
1808
|
+
const alignPaddingPath = join3(audioDir, `step-${stepNum}-align-padding.mp3`);
|
|
772
1809
|
await generateSilence(alignPaddingPath, recordingDuration - voiceDuration);
|
|
773
1810
|
segments.push(alignPaddingPath);
|
|
774
1811
|
}
|
|
775
1812
|
} else if (existsSync2(silencePath)) {
|
|
776
|
-
|
|
777
|
-
|
|
1813
|
+
const own = targetDuration - (stepNum === 1 ? leadIn : 0);
|
|
1814
|
+
if (targetDuration !== voiceDuration && own > 0) {
|
|
1815
|
+
await generateSilence(silencePath, own);
|
|
778
1816
|
}
|
|
779
1817
|
segments.push(silencePath);
|
|
780
1818
|
}
|
|
@@ -783,10 +1821,14 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
|
|
|
783
1821
|
words: voiceData?.words ?? []
|
|
784
1822
|
});
|
|
785
1823
|
}
|
|
786
|
-
if (segments.length
|
|
787
|
-
|
|
788
|
-
|
|
1824
|
+
if (segments.length === 0) {
|
|
1825
|
+
console.log(
|
|
1826
|
+
` No per-step audio in ${audioDir}: keeping the narration's own timeline, so the captions still follow the voice.`
|
|
1827
|
+
);
|
|
1828
|
+
return stepAudio;
|
|
789
1829
|
}
|
|
1830
|
+
const voiceoverPath = join3(outputDir, "voiceover.wav");
|
|
1831
|
+
await concatenateAudio(segments, voiceoverPath, workDir);
|
|
790
1832
|
const existingMeta = await loadVoiceoverMeta(outputDir);
|
|
791
1833
|
if (existingMeta) {
|
|
792
1834
|
existingMeta.stepDurations = Object.fromEntries(
|
|
@@ -806,6 +1848,7 @@ var init_timing = __esm({
|
|
|
806
1848
|
|
|
807
1849
|
// src/lib/api-client.ts
|
|
808
1850
|
init_utils();
|
|
1851
|
+
var PLAN_MAX_TOKENS = 1500;
|
|
809
1852
|
var SERVER_BACKOFF_MS = [2e3, 4e3];
|
|
810
1853
|
var CLI_BACKOFF_MS = [1e4, 3e4];
|
|
811
1854
|
var SPARK_AI_BASE = "https://sparkbrain.app";
|
|
@@ -885,7 +1928,7 @@ async function getOrRefreshRelayToken(email) {
|
|
|
885
1928
|
}
|
|
886
1929
|
}
|
|
887
1930
|
async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier, opts = {}) {
|
|
888
|
-
const { onRetry, backoffMs = SERVER_BACKOFF_MS } = opts;
|
|
1931
|
+
const { onRetry, backoffMs = SERVER_BACKOFF_MS, maxTokens } = opts;
|
|
889
1932
|
let lastRetriableError = null;
|
|
890
1933
|
const host = hostOf(chatUrl);
|
|
891
1934
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
@@ -900,7 +1943,11 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
|
|
|
900
1943
|
messages: [
|
|
901
1944
|
{ role: "system", content: systemPrompt },
|
|
902
1945
|
{ role: "user", content: userPrompt }
|
|
903
|
-
]
|
|
1946
|
+
],
|
|
1947
|
+
// Explicit numeric test, not truthiness: a 0 or negative budget is a
|
|
1948
|
+
// caller bug, and silently falling back to the tiny backend default
|
|
1949
|
+
// reproduces exactly the failure this option exists to prevent.
|
|
1950
|
+
...typeof maxTokens === "number" && maxTokens > 0 ? { max_tokens: Math.floor(maxTokens) } : {}
|
|
904
1951
|
})
|
|
905
1952
|
});
|
|
906
1953
|
const bodyText = await response.text().catch(() => "");
|
|
@@ -1039,26 +2086,23 @@ var APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
|
|
|
1039
2086
|
var OWNER_SLUG = process.env.SPARK_OWNER_SLUG ?? "spark";
|
|
1040
2087
|
var BASE = "https://sparkpay.dev";
|
|
1041
2088
|
var TIERS = {
|
|
1042
|
-
|
|
1043
|
-
|
|
2089
|
+
// Essentials
|
|
2090
|
+
price_1TCREmEf9opkuWidvhuqzqDM: "pro",
|
|
2091
|
+
// live monthly
|
|
2092
|
+
price_1TCREmEf9opkuWidacf1tiC7: "pro",
|
|
2093
|
+
// live yearly
|
|
1044
2094
|
price_1TADm7Ef9opkuWid3I9tquIB: "pro",
|
|
2095
|
+
// test monthly
|
|
1045
2096
|
price_1TADm8Ef9opkuWid1RSA2wCa: "pro",
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
pro: [
|
|
1056
|
-
"Everything in Free",
|
|
1057
|
-
"No watermark",
|
|
1058
|
-
"Full video composition (voiceover + captions + music + transitions)",
|
|
1059
|
-
"Unlimited videos",
|
|
1060
|
-
"Priority support"
|
|
1061
|
-
]
|
|
2097
|
+
// test yearly
|
|
2098
|
+
// Elite. These were missing entirely, so an Elite subscriber fell through
|
|
2099
|
+
// the `?? 'pro'` fallback below and got Essentials limits for $29.
|
|
2100
|
+
price_1TrHz8Ef9opkuWid6lh2opD8: "elite",
|
|
2101
|
+
// live monthly
|
|
2102
|
+
price_1TrHz9Ef9opkuWid1qS26iMu: "elite",
|
|
2103
|
+
// live yearly
|
|
2104
|
+
price_1TGPCREf9opkuWidR0iJeJNe: "elite"
|
|
2105
|
+
// test monthly
|
|
1062
2106
|
};
|
|
1063
2107
|
async function _fetch(email, init) {
|
|
1064
2108
|
const r = await fetch(
|
|
@@ -1083,7 +2127,6 @@ function getTier(d) {
|
|
|
1083
2127
|
return TIERS[d.subscription.price_id] ?? "pro";
|
|
1084
2128
|
}
|
|
1085
2129
|
var isPaid = (d) => getTier(d) !== "free";
|
|
1086
|
-
var hasAccess = (d, min) => (RANK[getTier(d)] ?? 0) >= (RANK[min] ?? 0);
|
|
1087
2130
|
var hasTrialRemaining = (d) => isPaid(d) || !!d.trial && !d.trial.is_expired && d.trial.uses_remaining > 0;
|
|
1088
2131
|
var showPaywall = (d) => !isPaid(d) && !hasTrialRemaining(d);
|
|
1089
2132
|
var isCanceling = (d) => !!d.subscription && d.subscription.cancel_at_period_end && d.subscription.status !== "canceled";
|
|
@@ -1117,13 +2160,21 @@ function getUpgradeUrl(userEmail) {
|
|
|
1117
2160
|
}
|
|
1118
2161
|
|
|
1119
2162
|
// src/lib/config.ts
|
|
1120
|
-
var VERSION = "1.2.
|
|
2163
|
+
var VERSION = "1.2.3";
|
|
1121
2164
|
var CONFIG_FILE = "quickpeek.config.json";
|
|
2165
|
+
var SIZE_PRESETS = {
|
|
2166
|
+
wide: { width: 1920, height: 1080, zoom: 1 },
|
|
2167
|
+
short: { width: 1080, height: 1920, zoom: 2 }
|
|
2168
|
+
};
|
|
2169
|
+
var RATE_FACTOR = { slow: 0.8, normal: 1, fast: 1.25 };
|
|
2170
|
+
var NO_CLIP_OUTRO = "@card-step";
|
|
1122
2171
|
var DEFAULT_CONFIG = {
|
|
1123
2172
|
plan: { maxSteps: 8 },
|
|
1124
2173
|
video: {
|
|
1125
|
-
|
|
1126
|
-
|
|
2174
|
+
size: "short",
|
|
2175
|
+
width: SIZE_PRESETS.short.width,
|
|
2176
|
+
height: SIZE_PRESETS.short.height,
|
|
2177
|
+
zoom: SIZE_PRESETS.short.zoom,
|
|
1127
2178
|
format: "mp4",
|
|
1128
2179
|
preset: "fast",
|
|
1129
2180
|
quality: 0.5,
|
|
@@ -1136,20 +2187,61 @@ var DEFAULT_CONFIG = {
|
|
|
1136
2187
|
// ms
|
|
1137
2188
|
transitions: "none",
|
|
1138
2189
|
contrast: 0.25,
|
|
1139
|
-
|
|
2190
|
+
// A demo plays a touch faster than it was recorded, because a recording
|
|
2191
|
+
// paced for the narration reads as slow next to anything else on the same
|
|
2192
|
+
// feed. 1.2 is the most that still sounds like a person talking; set 1 in
|
|
2193
|
+
// quickpeek.config.json for the raw pace.
|
|
2194
|
+
tempo: 1.2,
|
|
1140
2195
|
stepDelay: 200
|
|
1141
2196
|
// ms delay between steps
|
|
1142
2197
|
},
|
|
1143
|
-
audio: { format: "mp3", bitrate: "192k" },
|
|
2198
|
+
audio: { gender: "female", rate: "normal", format: "mp3", bitrate: "192k" },
|
|
1144
2199
|
timing: { minStepDuration: 1500 },
|
|
1145
2200
|
output: { dir: "./demo" }
|
|
1146
2201
|
};
|
|
2202
|
+
var VIDEO_PROFILES = {
|
|
2203
|
+
// preset 'veryfast' over 'fast': measured ~20s faster on a 77s 1080p
|
|
2204
|
+
// compose, with identical duration and audio and no visible difference at
|
|
2205
|
+
// these bitrates - the encode is pure wall-clock tax on every run.
|
|
2206
|
+
wide: {
|
|
2207
|
+
video: { width: 1920, height: 1080, zoom: 1.5, fitLimitSecs: 0, maxSpeedup: 1, preset: "veryfast" },
|
|
2208
|
+
audioSpeed: 1.6
|
|
2209
|
+
},
|
|
2210
|
+
short: {
|
|
2211
|
+
// fadeOut 0: the video ends on a card the viewer is meant to read,
|
|
2212
|
+
// and a fade takes it away mid-sentence.
|
|
2213
|
+
video: { width: 1080, height: 1920, zoom: 3, fitLimitSecs: 60, maxSpeedup: 1.7, preset: "veryfast", fadeOut: 0 },
|
|
2214
|
+
audioSpeed: 1.8,
|
|
2215
|
+
// A short lives or dies on energy. Ava is the warmest, most animated of
|
|
2216
|
+
// the free neural voices; Sonia reads a short like the shipping forecast.
|
|
2217
|
+
voice: "en-US-AvaMultilingualNeural"
|
|
2218
|
+
}
|
|
2219
|
+
};
|
|
2220
|
+
function applyProfile(config, profile) {
|
|
2221
|
+
const preset = VIDEO_PROFILES[profile];
|
|
2222
|
+
return {
|
|
2223
|
+
...config,
|
|
2224
|
+
profile,
|
|
2225
|
+
video: { ...config.video, ...preset.video },
|
|
2226
|
+
audio: {
|
|
2227
|
+
format: "mp3",
|
|
2228
|
+
bitrate: "192k",
|
|
2229
|
+
...config.audio,
|
|
2230
|
+
speed: config.audio?.speed ?? preset.audioSpeed,
|
|
2231
|
+
...config.audio?.voice || !preset.voice ? {} : { voice: preset.voice }
|
|
2232
|
+
}
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
1147
2235
|
var DEFAULT_CAPTIONS = {
|
|
1148
2236
|
font: "Arial",
|
|
1149
2237
|
outline: "#000000",
|
|
1150
2238
|
outlineWidth: 3,
|
|
1151
2239
|
shadow: 2,
|
|
1152
2240
|
marginBottom: 80,
|
|
2241
|
+
// The IG/TikTok look is what a demo short is for; karaoke remains available
|
|
2242
|
+
// by name for anyone who wants the sweep instead.
|
|
2243
|
+
preset: "shorts",
|
|
2244
|
+
position: "bottom",
|
|
1153
2245
|
regular: {
|
|
1154
2246
|
color: "#FFFFFF",
|
|
1155
2247
|
size: 56,
|
|
@@ -1159,8 +2251,41 @@ var DEFAULT_CAPTIONS = {
|
|
|
1159
2251
|
color: "#FFFF00",
|
|
1160
2252
|
size: 64,
|
|
1161
2253
|
bold: true
|
|
1162
|
-
}
|
|
2254
|
+
},
|
|
2255
|
+
maxChars: 28
|
|
1163
2256
|
};
|
|
2257
|
+
function defaultCaptionsFor(width, height) {
|
|
2258
|
+
const portrait = height > width;
|
|
2259
|
+
if (!portrait) {
|
|
2260
|
+
return {
|
|
2261
|
+
...DEFAULT_CAPTIONS,
|
|
2262
|
+
preset: "karaoke",
|
|
2263
|
+
position: "bottom",
|
|
2264
|
+
marginBottom: Math.round(height * 0.083),
|
|
2265
|
+
regular: { color: "#FFFFFF", size: Math.round(width * 0.033), bold: true },
|
|
2266
|
+
highlight: { color: "#FFE100", size: Math.round(width * 0.036), bold: true },
|
|
2267
|
+
outlineWidth: 4,
|
|
2268
|
+
shadow: 2,
|
|
2269
|
+
maxChars: 34
|
|
2270
|
+
};
|
|
2271
|
+
}
|
|
2272
|
+
const size = Math.round(width * 0.104);
|
|
2273
|
+
return {
|
|
2274
|
+
...DEFAULT_CAPTIONS,
|
|
2275
|
+
// The whole line stays up and the spoken word recolours: the social-video
|
|
2276
|
+
// convention, and the one that survives being watched muted.
|
|
2277
|
+
preset: "hormozi",
|
|
2278
|
+
position: "bottom",
|
|
2279
|
+
marginBottom: Math.round(height * 0.156),
|
|
2280
|
+
// Equal sizes on purpose. A larger highlight reflows the line every time
|
|
2281
|
+
// the spoken word moves, which on a full-line preset is a visible twitch.
|
|
2282
|
+
regular: { color: "#FFFFFF", size, bold: true },
|
|
2283
|
+
highlight: { color: "#FFE100", size, bold: true },
|
|
2284
|
+
outlineWidth: 8,
|
|
2285
|
+
shadow: 5,
|
|
2286
|
+
maxChars: 20
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
1164
2289
|
|
|
1165
2290
|
// src/lib/crawl.ts
|
|
1166
2291
|
import { chromium } from "playwright";
|
|
@@ -1262,9 +2387,74 @@ function shouldSkipLink(text, href, currentPath) {
|
|
|
1262
2387
|
}
|
|
1263
2388
|
|
|
1264
2389
|
// src/lib/crawl.ts
|
|
2390
|
+
var SETTLE_POLL_MS = 400;
|
|
2391
|
+
var SETTLE_STABLE_POLLS = 2;
|
|
2392
|
+
var SETTLE_CAP_MS = 2e4;
|
|
2393
|
+
async function waitForDomStable(page, cap = SETTLE_CAP_MS) {
|
|
2394
|
+
const deadline = Date.now() + cap;
|
|
2395
|
+
let previous = -1;
|
|
2396
|
+
let unchanged = 0;
|
|
2397
|
+
while (Date.now() < deadline) {
|
|
2398
|
+
const count = await page.evaluate(() => document.getElementsByTagName("*").length).catch(() => -1);
|
|
2399
|
+
if (count < 0) return;
|
|
2400
|
+
if (count === previous) {
|
|
2401
|
+
if (++unchanged >= SETTLE_STABLE_POLLS) return;
|
|
2402
|
+
} else {
|
|
2403
|
+
unchanged = 0;
|
|
2404
|
+
previous = count;
|
|
2405
|
+
}
|
|
2406
|
+
await page.waitForTimeout(SETTLE_POLL_MS);
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
var PLACEHOLDER_POLL_MS = 250;
|
|
2410
|
+
var PLACEHOLDER_CAP_MS = 8e3;
|
|
2411
|
+
var PLACEHOLDER_STATIC_POLLS = 4;
|
|
2412
|
+
var PLACEHOLDER_SELECTOR = [
|
|
2413
|
+
'[aria-busy="true"]',
|
|
2414
|
+
'[role="progressbar"]',
|
|
2415
|
+
'[data-loading="true"]',
|
|
2416
|
+
'[class*="skeleton" i]',
|
|
2417
|
+
'[class*="animate-pulse" i]',
|
|
2418
|
+
'[class*="spinner" i]'
|
|
2419
|
+
].join(",");
|
|
2420
|
+
async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
|
|
2421
|
+
const deadline = Date.now() + cap;
|
|
2422
|
+
let previous = -1;
|
|
2423
|
+
let unchanged = 0;
|
|
2424
|
+
while (Date.now() < deadline) {
|
|
2425
|
+
const count = await page.evaluate((selector) => {
|
|
2426
|
+
let visible = 0;
|
|
2427
|
+
for (const el of Array.from(document.querySelectorAll(selector))) {
|
|
2428
|
+
const rect = el.getBoundingClientRect();
|
|
2429
|
+
if (rect.width > 0 && rect.height > 0) visible++;
|
|
2430
|
+
}
|
|
2431
|
+
return visible;
|
|
2432
|
+
}, PLACEHOLDER_SELECTOR).catch(() => 0);
|
|
2433
|
+
if (count === 0) return;
|
|
2434
|
+
if (count === previous) {
|
|
2435
|
+
if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
|
|
2436
|
+
} else {
|
|
2437
|
+
unchanged = 0;
|
|
2438
|
+
previous = count;
|
|
2439
|
+
}
|
|
2440
|
+
await page.waitForTimeout(PLACEHOLDER_POLL_MS);
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
async function gotoSettled(page, url, timeout = 3e4) {
|
|
2444
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
|
2445
|
+
await page.waitForLoadState("networkidle", { timeout: 2500 }).catch(() => {
|
|
2446
|
+
});
|
|
2447
|
+
await waitForDomStable(page).catch(() => {
|
|
2448
|
+
});
|
|
2449
|
+
await waitForPlaceholdersGone(page).catch(() => {
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
1265
2452
|
function idSelector(id) {
|
|
1266
2453
|
return /^[A-Za-z_-][\w-]*$/.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
|
|
1267
2454
|
}
|
|
2455
|
+
function ariaLabelSelector(label) {
|
|
2456
|
+
return `[aria-label="${label.replace(/(["\\])/g, "\\$1")}"]`;
|
|
2457
|
+
}
|
|
1268
2458
|
function getActionForType(type) {
|
|
1269
2459
|
switch (type) {
|
|
1270
2460
|
case "button":
|
|
@@ -1282,169 +2472,228 @@ function getActionForType(type) {
|
|
|
1282
2472
|
return "type";
|
|
1283
2473
|
}
|
|
1284
2474
|
}
|
|
2475
|
+
async function sitemapRoutes(pageUrl) {
|
|
2476
|
+
let origin;
|
|
2477
|
+
try {
|
|
2478
|
+
origin = new URL(pageUrl).origin;
|
|
2479
|
+
} catch {
|
|
2480
|
+
return [];
|
|
2481
|
+
}
|
|
2482
|
+
try {
|
|
2483
|
+
const res = await fetch(`${origin}/sitemap.xml`, { signal: AbortSignal.timeout(4e3) });
|
|
2484
|
+
if (!res.ok) return [];
|
|
2485
|
+
const xml = await res.text();
|
|
2486
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2487
|
+
const routes = [];
|
|
2488
|
+
for (const m of xml.matchAll(/<loc>([^<]+)<\/loc>/g)) {
|
|
2489
|
+
const raw = (m[1] ?? "").trim();
|
|
2490
|
+
let u;
|
|
2491
|
+
try {
|
|
2492
|
+
u = new URL(raw);
|
|
2493
|
+
} catch {
|
|
2494
|
+
continue;
|
|
2495
|
+
}
|
|
2496
|
+
if (u.origin !== origin) continue;
|
|
2497
|
+
if (/\.(txt|xml|json|png|jpg|svg|ico|pdf)$/i.test(u.pathname)) continue;
|
|
2498
|
+
const path = u.pathname.replace(/\/+$/, "");
|
|
2499
|
+
if (!path || seen.has(u.href)) continue;
|
|
2500
|
+
seen.add(u.href);
|
|
2501
|
+
routes.push({ text: path.slice(1), href: u.href });
|
|
2502
|
+
if (routes.length >= 20) break;
|
|
2503
|
+
}
|
|
2504
|
+
return routes;
|
|
2505
|
+
} catch {
|
|
2506
|
+
return [];
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
1285
2509
|
var noopTask = () => ({ succeed: () => {
|
|
1286
2510
|
} });
|
|
1287
2511
|
var CRAWL_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
1288
2512
|
var CRAWL_CACHE_MAX_ENTRIES = 50;
|
|
1289
2513
|
var crawlCache = /* @__PURE__ */ new Map();
|
|
1290
2514
|
async function crawlPage(url, task = noopTask, storageStatePath, viewport) {
|
|
1291
|
-
const
|
|
1292
|
-
const cached = crawlCache.get(
|
|
2515
|
+
const cacheKey2 = viewport ? `${url}@${viewport.width}x${viewport.height}` : url;
|
|
2516
|
+
const cached = crawlCache.get(cacheKey2);
|
|
1293
2517
|
if (cached && Date.now() - cached.timestamp < CRAWL_CACHE_TTL_MS) {
|
|
1294
2518
|
task("Using cached crawl").succeed(`Found ${cached.elements.length} interactive elements (cached)`);
|
|
1295
2519
|
return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
|
|
1296
2520
|
}
|
|
1297
2521
|
const browserTask = task("Launching browser");
|
|
1298
2522
|
const browser = await chromium.launch({ headless: true });
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
2523
|
+
try {
|
|
2524
|
+
const context = await browser.newContext({
|
|
2525
|
+
...storageStatePath ? { storageState: storageStatePath } : {},
|
|
2526
|
+
// Crawl at the size we will record at, or the measurements below describe
|
|
2527
|
+
// a page nobody will ever see.
|
|
2528
|
+
...viewport ? { viewport } : {}
|
|
2529
|
+
});
|
|
2530
|
+
const page = await context.newPage();
|
|
2531
|
+
browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
|
|
2532
|
+
const crawlTask = task("Crawling page");
|
|
2533
|
+
await gotoSettled(page, url, 3e4);
|
|
2534
|
+
const title = await page.title();
|
|
2535
|
+
const scrollable = await page.evaluate(
|
|
2536
|
+
"Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
|
|
2537
|
+
);
|
|
2538
|
+
const rawElements = await page.evaluate(`
|
|
2539
|
+
(() => {
|
|
2540
|
+
const items = [];
|
|
2541
|
+
|
|
2542
|
+
// Helper to get parent context
|
|
2543
|
+
const getCtx = (el) => {
|
|
2544
|
+
let p = el.parentElement;
|
|
2545
|
+
while (p && p !== document.body) {
|
|
2546
|
+
if (p.id) return '#' + p.id;
|
|
2547
|
+
const cls = Array.from(p.classList).find(c =>
|
|
2548
|
+
/toolbar|header|footer|sidebar|panel|modal|dialog|nav|menu|controls|actions/i.test(c)
|
|
2549
|
+
);
|
|
2550
|
+
if (cls) return '.' + cls;
|
|
2551
|
+
if (['header', 'footer', 'nav', 'main', 'aside', 'form'].includes(p.tagName.toLowerCase())) {
|
|
2552
|
+
return p.tagName.toLowerCase();
|
|
2553
|
+
}
|
|
2554
|
+
p = p.parentElement;
|
|
1328
2555
|
}
|
|
1329
|
-
|
|
1330
|
-
}
|
|
1331
|
-
return '';
|
|
1332
|
-
};
|
|
2556
|
+
return '';
|
|
2557
|
+
};
|
|
1333
2558
|
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
2559
|
+
// Buttons (use textContent to catch hidden elements too)
|
|
2560
|
+
document.querySelectorAll('button, [role="button"]').forEach(el => {
|
|
2561
|
+
const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
|
|
2562
|
+
const testId = el.getAttribute('data-testid');
|
|
2563
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
2564
|
+
if (!text && !testId && !ariaLabel) return;
|
|
2565
|
+
items.push({ type: 'button', text: text || ariaLabel || testId, testId, ctx: getCtx(el) });
|
|
2566
|
+
});
|
|
1342
2567
|
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
// Get label text for any input type
|
|
1350
|
-
let labelText = '';
|
|
1351
|
-
if (el.labels?.[0]) {
|
|
1352
|
-
labelText = el.labels[0].textContent?.trim() || '';
|
|
1353
|
-
} else if (el.id) {
|
|
1354
|
-
const label = document.querySelector('label[for="' + el.id + '"]');
|
|
1355
|
-
if (label) labelText = label.textContent?.trim() || '';
|
|
1356
|
-
}
|
|
2568
|
+
// Inputs (including file inputs, range sliders, checkboxes, selects)
|
|
2569
|
+
document.querySelectorAll('input, textarea, select').forEach(el => {
|
|
2570
|
+
if (el.type === 'hidden') return;
|
|
2571
|
+
const testId = el.getAttribute('data-testid');
|
|
2572
|
+
const inputType = el.type || 'text';
|
|
1357
2573
|
|
|
1358
|
-
|
|
2574
|
+
// Get label text for any input type
|
|
2575
|
+
let labelText = '';
|
|
2576
|
+
if (el.labels?.[0]) {
|
|
2577
|
+
labelText = el.labels[0].textContent?.trim() || '';
|
|
2578
|
+
} else if (el.id) {
|
|
2579
|
+
const label = document.querySelector('label[for="' + el.id + '"]');
|
|
2580
|
+
if (label) labelText = label.textContent?.trim() || '';
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
const text = labelText || el.placeholder || el.name || el.id || '';
|
|
1359
2584
|
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
2585
|
+
// Include file, range, and checkbox inputs even without text
|
|
2586
|
+
const alwaysInclude = ['file', 'range', 'checkbox'].includes(inputType);
|
|
2587
|
+
if (!text && !testId && !alwaysInclude) return;
|
|
1363
2588
|
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
2589
|
+
items.push({
|
|
2590
|
+
type: inputType,
|
|
2591
|
+
text: text || (inputType === 'file' ? 'file upload' : inputType === 'range' ? 'slider' : inputType === 'checkbox' ? 'checkbox' : testId),
|
|
2592
|
+
testId,
|
|
2593
|
+
id: el.id,
|
|
2594
|
+
name: el.name,
|
|
2595
|
+
placeholder: el.placeholder,
|
|
2596
|
+
isFile: inputType === 'file'
|
|
2597
|
+
});
|
|
1372
2598
|
});
|
|
1373
|
-
});
|
|
1374
2599
|
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
2600
|
+
// Links (use textContent to catch hidden elements too)
|
|
2601
|
+
document.querySelectorAll('a[href]').forEach(el => {
|
|
2602
|
+
const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
|
|
2603
|
+
if (!text || text.length < 2) return;
|
|
2604
|
+
// Keep the destination, not just the label. Without it the planner
|
|
2605
|
+
// can name a page in a caption but has no way to go there, so every
|
|
2606
|
+
// line gets forced onto whatever is already on screen.
|
|
2607
|
+
let href = '';
|
|
2608
|
+
try {
|
|
2609
|
+
const u = new URL(el.getAttribute('href') || '', location.href);
|
|
2610
|
+
if (u.origin === location.origin) href = u.href;
|
|
2611
|
+
} catch { /* a javascript: or mailto: href is not somewhere to go */ }
|
|
2612
|
+
items.push({ type: 'link', text, href });
|
|
2613
|
+
});
|
|
1380
2614
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
2615
|
+
return items.slice(0, 40);
|
|
2616
|
+
})()
|
|
2617
|
+
`);
|
|
2618
|
+
const filteredElements = rawElements.filter((el) => {
|
|
2619
|
+
if (!el.text) return true;
|
|
2620
|
+
const text = el.text.toLowerCase().trim();
|
|
2621
|
+
return !EXCLUDED_LINK_PATTERNS.some((pattern) => text.includes(pattern));
|
|
2622
|
+
});
|
|
2623
|
+
const btnTexts = {};
|
|
2624
|
+
for (const el of filteredElements) {
|
|
2625
|
+
if (el.type === "button" && el.text) {
|
|
2626
|
+
btnTexts[el.text] = (btnTexts[el.text] || 0) + 1;
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
const elements = filteredElements.map((el) => {
|
|
2630
|
+
let selector;
|
|
2631
|
+
if (el.testId) {
|
|
2632
|
+
selector = `[data-testid="${el.testId}"]`;
|
|
2633
|
+
} else if (el.isFile || el.type === "file") {
|
|
2634
|
+
selector = 'input[type="file"]';
|
|
2635
|
+
} else if (el.type === "range") {
|
|
2636
|
+
selector = el.id ? idSelector(el.id) : 'input[type="range"]';
|
|
2637
|
+
} else if (el.type === "checkbox") {
|
|
2638
|
+
selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
|
|
2639
|
+
} else if (el.type === "button") {
|
|
2640
|
+
if (btnTexts[el.text] === 1) {
|
|
2641
|
+
selector = `text="${el.text}"`;
|
|
2642
|
+
} else {
|
|
2643
|
+
selector = el.ctx ? `${el.ctx} >> text="${el.text}"` : `button:has-text("${el.text}")`;
|
|
2644
|
+
}
|
|
2645
|
+
} else if (el.type === "link") {
|
|
2646
|
+
selector = `text="${el.text}"`;
|
|
2647
|
+
} else if (el.id) {
|
|
2648
|
+
selector = idSelector(el.id);
|
|
2649
|
+
} else if (el.name) {
|
|
2650
|
+
selector = `[name="${el.name}"]`;
|
|
2651
|
+
} else if (el.placeholder) {
|
|
2652
|
+
selector = `[placeholder="${el.placeholder}"]`;
|
|
2653
|
+
} else {
|
|
1407
2654
|
selector = `text="${el.text}"`;
|
|
2655
|
+
}
|
|
2656
|
+
let normalizedType;
|
|
2657
|
+
if (el.isFile || el.type === "file") {
|
|
2658
|
+
normalizedType = "file";
|
|
2659
|
+
} else if (el.type === "range") {
|
|
2660
|
+
normalizedType = "range";
|
|
2661
|
+
} else if (el.type === "checkbox") {
|
|
2662
|
+
normalizedType = "checkbox";
|
|
2663
|
+
} else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
|
|
2664
|
+
normalizedType = "select";
|
|
2665
|
+
} else if (el.type === "button" || el.type === "link") {
|
|
2666
|
+
normalizedType = el.type;
|
|
1408
2667
|
} else {
|
|
1409
|
-
|
|
2668
|
+
normalizedType = "input";
|
|
1410
2669
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
normalizedType = "range";
|
|
1427
|
-
} else if (el.type === "checkbox") {
|
|
1428
|
-
normalizedType = "checkbox";
|
|
1429
|
-
} else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
|
|
1430
|
-
normalizedType = "select";
|
|
1431
|
-
} else if (el.type === "button" || el.type === "link") {
|
|
1432
|
-
normalizedType = el.type;
|
|
1433
|
-
} else {
|
|
1434
|
-
normalizedType = "input";
|
|
2670
|
+
const action = getActionForType(normalizedType);
|
|
2671
|
+
const target = normalizedType === "file" ? selector : (el.text || "").toLowerCase();
|
|
2672
|
+
return { type: normalizedType, text: el.text, selector, action, target, href: el.href };
|
|
2673
|
+
});
|
|
2674
|
+
await browser.close();
|
|
2675
|
+
for (const route of await sitemapRoutes(url)) {
|
|
2676
|
+
if (elements.some((e) => e.href === route.href)) continue;
|
|
2677
|
+
elements.push({
|
|
2678
|
+
type: "link",
|
|
2679
|
+
text: route.text,
|
|
2680
|
+
selector: "",
|
|
2681
|
+
action: "click",
|
|
2682
|
+
target: route.text,
|
|
2683
|
+
href: route.href
|
|
2684
|
+
});
|
|
1435
2685
|
}
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
2686
|
+
crawlTask.succeed(`Found ${elements.length} interactive elements`);
|
|
2687
|
+
crawlCache.set(cacheKey2, { timestamp: Date.now(), title, elements: [...elements], scrollable });
|
|
2688
|
+
if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
|
|
2689
|
+
const oldest = crawlCache.keys().next().value;
|
|
2690
|
+
if (oldest !== void 0) crawlCache.delete(oldest);
|
|
2691
|
+
}
|
|
2692
|
+
return { title, elements, scrollable };
|
|
2693
|
+
} finally {
|
|
2694
|
+
await browser.close().catch(() => {
|
|
2695
|
+
});
|
|
1446
2696
|
}
|
|
1447
|
-
return { title, elements, scrollable };
|
|
1448
2697
|
}
|
|
1449
2698
|
|
|
1450
2699
|
// src/lib/cursor.ts
|
|
@@ -1557,9 +2806,13 @@ function parseAIJson(content) {
|
|
|
1557
2806
|
|
|
1558
2807
|
// src/lib/prompts.ts
|
|
1559
2808
|
init_utils();
|
|
1560
|
-
function buildSystemPrompt(maxSteps) {
|
|
2809
|
+
function buildSystemPrompt(maxSteps, profile) {
|
|
1561
2810
|
let scope;
|
|
1562
|
-
if (
|
|
2811
|
+
if (profile === "wide") {
|
|
2812
|
+
scope = `SCOPE: Educational tutorial (${maxSteps} steps max) - comprehensive, structured coverage of every major feature area, in a logical teaching order`;
|
|
2813
|
+
} else if (profile === "short") {
|
|
2814
|
+
scope = `SCOPE: Quick-paced short (${maxSteps} steps max) - cover every interesting detail with fast cuts and terse captions; running long is fine, the video is compressed afterwards`;
|
|
2815
|
+
} else if (maxSteps <= 5) {
|
|
1563
2816
|
scope = `SCOPE: Minimal demo (${maxSteps} steps max) - ONE complete user journey only`;
|
|
1564
2817
|
} else if (maxSteps <= 8) {
|
|
1565
2818
|
scope = `SCOPE: Standard demo (${maxSteps} steps max) - One journey with 1-2 options`;
|
|
@@ -1571,9 +2824,47 @@ function buildSystemPrompt(maxSteps) {
|
|
|
1571
2824
|
${scope}
|
|
1572
2825
|
|
|
1573
2826
|
CORE FLOW (always follow this structure):
|
|
1574
|
-
1. wait -
|
|
2827
|
+
1. wait - the hook, over the app's own card (see OPENING AND CLOSING)
|
|
1575
2828
|
2. Actions - Complete ONE user journey (upload\u2192process\u2192result)
|
|
1576
|
-
3.
|
|
2829
|
+
3. Stop. The closing card is appended for you - do not write one
|
|
2830
|
+
|
|
2831
|
+
NARRATION STYLE:
|
|
2832
|
+
\u2022 Professional and direct, like a good tutorial - never condescending, never explaining as if to a child
|
|
2833
|
+
\u2022 NEVER mention step numbers or sequencing ("step one", "first step", "in this step") - just say what is happening
|
|
2834
|
+
\u2022 NEVER build the demo out of marketing surfaces. The landing page and the PRICING page are not the product: a viewer wants to see the thing working, not read plans and prices. If the app is behind a sign-in, demo the signed-in app; if that is impossible, demo its functional public pages (tools, docs, editors), not its sales pages
|
|
2835
|
+
\u2022 A marketing/landing page earns ONE brief opening caption of about a SECOND - a title card, nothing more. Never scroll it, never hover or highlight anything on it, never describe its sections. Optionally return to it for the closing call to action. The demo lives in the app itself, behind the login when a session exists. Never return to the landing page mid-demo to describe a feature the app can show you doing
|
|
2836
|
+
\u2022 NARRATE ONLY WHAT IS ON SCREEN: every caption must describe what the viewer can see at that moment. To talk about a specific component, make it the step's target (hover it, click it, or scroll with that target) so the recorder brings it into frame first
|
|
2837
|
+
\u2022 A blind scroll (no target) lands wherever it lands - use it only for "there is more below", never to reveal something the caption names
|
|
2838
|
+
\u2022 Never combine scrolling with pointing: a step either moves the page or holds still on a component, so the highlight is never dragged across the screen mid-scroll
|
|
2839
|
+
\u2022 A step that starts something asynchronous (processing, generating, analyzing) must WAIT for the result on screen before moving on: give the wait step a "duration" in seconds long enough to show the outcome
|
|
2840
|
+
\u2022 Caption length drives step length - keep captions tight so the video moves
|
|
2841
|
+
|
|
2842
|
+
OPENING AND CLOSING (a fixed template - both cards are built for you):
|
|
2843
|
+
\u2022 Step 1 is ALWAYS a bare { "action": "wait" } with no target. Its caption is the HOOK, and the recorder puts the app's own OG card behind it
|
|
2844
|
+
\u2022 The hook names the VIEWER'S problem in the viewer's words - the cost of not having this app, in one line someone would recognise as their own situation. It is the reason to keep watching
|
|
2845
|
+
\u2022 NEVER open the hook with the product: no "This is X", no "Meet X", no "X helps you..." and no feature. The problem comes first
|
|
2846
|
+
\u2022 Then, in that SAME caption, say the app's address once - "<problem>. <domain> fixes that." A viewer who leaves halfway has to have heard where to go, and the closing card is too late for them. The opening card is both spoken AND captioned, so write the hook to read as well as it sounds: short words, no spelled-out punctuation, and the domain written the way it is typed
|
|
2847
|
+
\u2022 The closing card is appended for you: the app's name, tagline, domain and any live offer, narrated. Never write a sign-off step and never end back on the landing page. Apart from the hook, keep the domain and any discount code out of captions - the card carries both, and repeating them mid-demo is the most common way these scripts read as padding
|
|
2848
|
+
|
|
2849
|
+
HOW IT SHOULD SOUND (this is read aloud, not printed on a slide):
|
|
2850
|
+
\u2022 Write the way a person talks about something they actually like. Warm, quick, a bit of a grin. Never the voice of a landing page
|
|
2851
|
+
\u2022 BANNED, because they are what marketing copy sounds like when nobody meant it: "free to start", "get started today", "unlock", "empower", "seamlessly", "effortlessly", "revolutionise", "supercharge", "take your X to the next level", "say goodbye to". If a line could sit on any product's homepage unchanged, it is not about this product
|
|
2852
|
+
\u2022 The free tier is "try it free", or just "free". Never "free to start", which reads as a pricing table talking
|
|
2853
|
+
\u2022 Short sentences beat long ones out loud. A comma is a breath; a full stop is a beat. Read it back before shipping it
|
|
2854
|
+
\u2022 Say the thing, do not build up to it. No "in this video we will", no "let's take a look at"
|
|
2855
|
+
|
|
2856
|
+
WHAT TO COVER (a tour of tabs is a menu, not a demo):
|
|
2857
|
+
\u2022 Lead with the outcome the app produces, not the screens it has
|
|
2858
|
+
\u2022 Say where that outcome ENDS UP when the app puts it somewhere - search results, AI assistant answers, directories, an app store
|
|
2859
|
+
\u2022 Name any integration that removes work rather than adding a screen - an MCP server, a browser extension that fills the forms, an API, a CLI. An app that can be driven from an assistant is a headline feature, not a footnote
|
|
2860
|
+
\u2022 Do NOT write a pricing step. When the app has a free tier the closing card says so for you, narrated, at the end where it belongs. Never navigate to the pricing page for it
|
|
2861
|
+
\u2022 Cover what surrounds the product as well as the product: a community, a network its users list each other on, a support channel, anything published on the user's behalf. These are reasons to sign up and they are never on screen by themselves
|
|
2862
|
+
\u2022 Say what a feature DOES, never what it is implemented as. Filenames, formats and protocols are not benefits: "so AI assistants can read your product" lands, "it writes llms.txt and robots.txt" is a changelog line read aloud. Same for sitemap.xml, JSON-LD, schema markup, API endpoints and anything ending in .txt, .xml or .json. If a viewer would have to already know what it is, it is not a selling point
|
|
2863
|
+
\u2022 Prefer one feature shown working over three named in passing
|
|
2864
|
+
\u2022 When a step types into a URL or domain field, type a REAL domain, never example.com or example.org. A demo that audits "example.com" on camera shows the tool working on nothing. Use a real site the viewer might recognise, or the demo subject's own domain if nothing better is on the page
|
|
2865
|
+
\u2022 A plan that is mostly bare wait steps is a failed plan. One real run came back 22 waits out of 26: a slideshow of one static screen, narrating pages it never visited. If a caption names a page, NAVIGATE to it; if it names a control, CLICK it. Use wait only for the opening and closing cards
|
|
2866
|
+
\u2022 NEVER narrate a screen you are not on. If a line describes a page the demo has not reached, navigate to it first. A caption saying "start with a free roast" over the dashboard is worse than cutting the line: it makes the whole video look faked
|
|
2867
|
+
\u2022 Cover everything worth covering and let it run long. Length is fixed afterwards by playing the demo faster, never by dropping a feature - a plan trimmed to hit a target is the one mistake the compressor cannot undo
|
|
1577
2868
|
|
|
1578
2869
|
TARGET SYNTAX (simplified):
|
|
1579
2870
|
\u2022 Buttons/Links: Use plain text like "extract frame", "download" (case insensitive, partial match OK)
|
|
@@ -1596,9 +2887,12 @@ FORBIDDEN:
|
|
|
1596
2887
|
|
|
1597
2888
|
ACTION FORMATS (STRICT - follow exactly):
|
|
1598
2889
|
\u2022 click: { "action": "click", "target": "extract frame" } - use simplified button text
|
|
2890
|
+
\u2022 hover: { "action": "hover", "target": "pricing card" } - point the cursor at a feature while the caption explains it, without clicking
|
|
1599
2891
|
\u2022 type: { "action": "type", "target": "input[name=\\"field\\"]", "value": "text to type" }
|
|
1600
2892
|
\u2022 upload: { "action": "upload", "target": "input[type=\\"file\\"]", "value": "[file]" }
|
|
2893
|
+
\u2022 navigate: { "action": "navigate", "target": "https://example.com/roast" } - GO TO ANOTHER PAGE. Use it whenever the caption describes something that is not on the current screen. Any path on the SAME ORIGIN as the demo URL is allowed, not only the NAVIGATE targets listed below: an app's own routes are frequently reached by client-side buttons rather than links, so the list is a hint, not the limit. Never navigate off-origin.
|
|
1601
2894
|
\u2022 wait: { "action": "wait" } - for intro/outro captions
|
|
2895
|
+
\u2022 scroll to a component: { "action": "scroll", "target": "seo files", "value": "down" } - brings that element into frame; PREFER this over a blind scroll whenever the caption names something
|
|
1602
2896
|
\u2022 scroll: { "action": "scroll", "value": "-80" } - negative=zoom out, positive=zoom in
|
|
1603
2897
|
\u2022 drag: { "action": "drag", "target": "input[type=\\"range\\"]", "value": "50" }
|
|
1604
2898
|
\u2022 select: { "action": "select", "target": "select[name=\\"..\\"]", "value": "option" }
|
|
@@ -1623,6 +2917,17 @@ Purpose: ${description}` : "";
|
|
|
1623
2917
|
const selectables = elements.filter((e) => e.action === "select");
|
|
1624
2918
|
const checkables = elements.filter((e) => e.action === "check");
|
|
1625
2919
|
const elementDict = [];
|
|
2920
|
+
const navigables = elements.filter((e) => e.href);
|
|
2921
|
+
const seenHref = /* @__PURE__ */ new Set();
|
|
2922
|
+
const linkTargets = navigables.filter((e) => {
|
|
2923
|
+
if (!e.href || seenHref.has(e.href)) return false;
|
|
2924
|
+
seenHref.add(e.href);
|
|
2925
|
+
return true;
|
|
2926
|
+
});
|
|
2927
|
+
if (linkTargets.length > 0) {
|
|
2928
|
+
elementDict.push(`NAVIGATE targets (links found on the page; same-origin paths not listed here are also allowed):
|
|
2929
|
+
${linkTargets.map((e) => `\u2022 "${e.text}" -> ${e.href}`).join("\n")}`);
|
|
2930
|
+
}
|
|
1626
2931
|
if (clickables.length > 0) {
|
|
1627
2932
|
elementDict.push(`CLICK targets (use simplified text):
|
|
1628
2933
|
${clickables.map((e) => `\u2022 "${e.target || e.text.toLowerCase()}"`).join("\n")}`);
|
|
@@ -1689,7 +2994,16 @@ TARGET SYNTAX:
|
|
|
1689
2994
|
}
|
|
1690
2995
|
|
|
1691
2996
|
// src/lib/styles.ts
|
|
1692
|
-
import {
|
|
2997
|
+
import {
|
|
2998
|
+
assTimeFromMs,
|
|
2999
|
+
buildAssHeader,
|
|
3000
|
+
chunkWordsToLines,
|
|
3001
|
+
fittingMaxChars,
|
|
3002
|
+
hexToAss as hexToAss2,
|
|
3003
|
+
mergePunctuationTokens,
|
|
3004
|
+
scaleToFit,
|
|
3005
|
+
toCaptionCase
|
|
3006
|
+
} from "@spark-apps/video-kit";
|
|
1693
3007
|
var CURSOR_MOVE_DURATION = 800;
|
|
1694
3008
|
var CURSOR_CSS = `
|
|
1695
3009
|
#qp-cursor {
|
|
@@ -1728,16 +3042,247 @@ var CURSOR_SCRIPT = `
|
|
|
1728
3042
|
cursor.style.top = window.innerHeight / 2 + 'px';
|
|
1729
3043
|
})();
|
|
1730
3044
|
`;
|
|
1731
|
-
function generateASSHeader(width, height, font, size, primaryColor, secondaryColor, outlineColor, backColor, bold, outlineWidth, shadow, marginBottom) {
|
|
3045
|
+
function generateASSHeader(width, height, font, size, primaryColor, secondaryColor, outlineColor, backColor, bold, outlineWidth, shadow, marginBottom, alignment = 2) {
|
|
1732
3046
|
return buildAssHeader({
|
|
1733
3047
|
title: "QuickPeek Demo",
|
|
1734
3048
|
width,
|
|
1735
3049
|
height,
|
|
1736
3050
|
styles: [
|
|
1737
|
-
`Style: Karaoke,${font},${size},${primaryColor},${secondaryColor},${outlineColor},${backColor},${bold ? 1 : 0},0,0,0,100,100,0,0,1,${outlineWidth},${shadow},
|
|
3051
|
+
`Style: Karaoke,${font},${size},${primaryColor},${secondaryColor},${outlineColor},${backColor},${bold ? 1 : 0},0,0,0,100,100,0,0,1,${outlineWidth},${shadow},${alignment},40,40,${marginBottom},1`
|
|
1738
3052
|
]
|
|
1739
3053
|
});
|
|
1740
3054
|
}
|
|
3055
|
+
var SIDE_MARGIN = 40;
|
|
3056
|
+
function resolveCaptionFont(font) {
|
|
3057
|
+
if (process.platform === "linux" && font === "Arial Black") return "DejaVu Sans";
|
|
3058
|
+
return font;
|
|
3059
|
+
}
|
|
3060
|
+
function boldOverride(captions) {
|
|
3061
|
+
if (captions.highlight.bold === captions.regular.bold) return { lit: "", rest: "" };
|
|
3062
|
+
return {
|
|
3063
|
+
lit: captions.highlight.bold ? "\\b1" : "\\b0",
|
|
3064
|
+
rest: captions.regular.bold ? "\\b1" : "\\b0"
|
|
3065
|
+
};
|
|
3066
|
+
}
|
|
3067
|
+
function positionToAlignment(position, leftAligned = false) {
|
|
3068
|
+
const base = leftAligned ? 1 : 2;
|
|
3069
|
+
if (position === "center") return base + 3;
|
|
3070
|
+
if (position === "top") return base + 6;
|
|
3071
|
+
return base;
|
|
3072
|
+
}
|
|
3073
|
+
function positionMarginV(position, margin) {
|
|
3074
|
+
return position === "center" ? 0 : margin;
|
|
3075
|
+
}
|
|
3076
|
+
function packLines(words, maxChars) {
|
|
3077
|
+
return chunkWordsToLines(mergePunctuationTokens(words), maxChars).filter((line) => line.length > 0);
|
|
3078
|
+
}
|
|
3079
|
+
function renderKaraokeAss(ctx) {
|
|
3080
|
+
const { captions, width, height } = ctx;
|
|
3081
|
+
const regularColor = hexToAss2(captions.regular.color);
|
|
3082
|
+
const highlightColor = hexToAss2(captions.highlight.color);
|
|
3083
|
+
const outline = hexToAss2(captions.outline);
|
|
3084
|
+
const back = hexToAss2(captions.outline, 128);
|
|
3085
|
+
const highlightScale = Math.round(captions.highlight.size / captions.regular.size * 100);
|
|
3086
|
+
const header = generateASSHeader(
|
|
3087
|
+
width,
|
|
3088
|
+
height,
|
|
3089
|
+
resolveCaptionFont(captions.font),
|
|
3090
|
+
captions.regular.size,
|
|
3091
|
+
highlightColor,
|
|
3092
|
+
regularColor,
|
|
3093
|
+
outline,
|
|
3094
|
+
back,
|
|
3095
|
+
captions.regular.bold,
|
|
3096
|
+
captions.outlineWidth,
|
|
3097
|
+
captions.shadow,
|
|
3098
|
+
positionMarginV(captions.position, captions.marginBottom),
|
|
3099
|
+
positionToAlignment(captions.position)
|
|
3100
|
+
);
|
|
3101
|
+
const usable = width - SIDE_MARGIN * 2;
|
|
3102
|
+
const maxChars = fittingMaxChars(width, captions.regular.size, SIDE_MARGIN, captions.maxChars, true);
|
|
3103
|
+
const lines = [];
|
|
3104
|
+
for (const cue of ctx.cues) {
|
|
3105
|
+
for (const line of packLines(cue.words, maxChars)) {
|
|
3106
|
+
const plain = line.map((w) => w.word).join(" ");
|
|
3107
|
+
const lineScale = scaleToFit(plain, captions.regular.size, usable, true);
|
|
3108
|
+
const popScale = Math.round(highlightScale * lineScale / 100);
|
|
3109
|
+
const boldTags = boldOverride(captions);
|
|
3110
|
+
let karaokeText = "";
|
|
3111
|
+
for (const word of line) {
|
|
3112
|
+
const duration = Math.round((word.end - word.start) / 10);
|
|
3113
|
+
karaokeText += `{\\kf${duration}\\fscx${popScale}\\fscy${popScale}${boldTags.lit}}${word.word}{\\fscx${lineScale}\\fscy${lineScale}${boldTags.rest}} `;
|
|
3114
|
+
}
|
|
3115
|
+
const start = assTimeFromMs(line[0].start);
|
|
3116
|
+
const end = assTimeFromMs(line[line.length - 1].end);
|
|
3117
|
+
lines.push(`Dialogue: 0,${start},${end},Karaoke,,0,0,0,,${karaokeText.trim()}`);
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
return `${header}${lines.join("\n")}
|
|
3121
|
+
`;
|
|
3122
|
+
}
|
|
3123
|
+
function renderHormoziAss(ctx) {
|
|
3124
|
+
const { captions, width, height } = ctx;
|
|
3125
|
+
const font = resolveCaptionFont(captions.font);
|
|
3126
|
+
const primary = hexToAss2(captions.regular.color);
|
|
3127
|
+
const highlight = hexToAss2(captions.highlight.color);
|
|
3128
|
+
const outline = hexToAss2(captions.outline);
|
|
3129
|
+
const back = hexToAss2(captions.outline, 128);
|
|
3130
|
+
const alignment = positionToAlignment(captions.position);
|
|
3131
|
+
const marginV = positionMarginV(captions.position, captions.marginBottom);
|
|
3132
|
+
const bold = captions.regular.bold ? -1 : 0;
|
|
3133
|
+
const header = buildAssHeader({
|
|
3134
|
+
title: "QuickPeek Demo",
|
|
3135
|
+
width,
|
|
3136
|
+
height,
|
|
3137
|
+
styles: [
|
|
3138
|
+
`Style: Default,${font},${captions.regular.size},${primary},${highlight},${outline},${back},${bold},0,0,0,100,100,0,0,1,${captions.outlineWidth},${captions.shadow},${alignment},${SIDE_MARGIN},${SIDE_MARGIN},${marginV},1`
|
|
3139
|
+
],
|
|
3140
|
+
scaledBorderAndShadow: true
|
|
3141
|
+
});
|
|
3142
|
+
const maxChars = fittingMaxChars(width, captions.regular.size, SIDE_MARGIN, captions.maxChars, true);
|
|
3143
|
+
const lines = [];
|
|
3144
|
+
for (const cue of ctx.cues) {
|
|
3145
|
+
for (const line of packLines(cue.words, maxChars)) {
|
|
3146
|
+
const plain = line.map((w) => w.word).join(" ");
|
|
3147
|
+
const lineScale = scaleToFit(plain, captions.regular.size, width - SIDE_MARGIN * 2, true);
|
|
3148
|
+
const scaleTag = lineScale < 100 ? `{\\fscx${lineScale}\\fscy${lineScale}}` : "";
|
|
3149
|
+
const boldTags = boldOverride(captions);
|
|
3150
|
+
for (let i = 0; i < line.length; i++) {
|
|
3151
|
+
const word = line[i];
|
|
3152
|
+
const nextStart = i + 1 < line.length ? line[i + 1].start : line[line.length - 1].end;
|
|
3153
|
+
const text = line.map(
|
|
3154
|
+
(other, j) => j === i ? `{\\1c${highlight}${boldTags.lit}}${other.word}{\\1c${primary}${boldTags.rest}}` : other.word
|
|
3155
|
+
).join(" ");
|
|
3156
|
+
lines.push(
|
|
3157
|
+
`Dialogue: 0,${assTimeFromMs(word.start)},${assTimeFromMs(nextStart)},Default,,0,0,0,,${scaleTag}${text}`
|
|
3158
|
+
);
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
return `${header}${lines.join("\n")}
|
|
3163
|
+
`;
|
|
3164
|
+
}
|
|
3165
|
+
function renderShortsAss(ctx) {
|
|
3166
|
+
const { captions, width, height } = ctx;
|
|
3167
|
+
const font = resolveCaptionFont(captions.font === "Arial" ? "Arial Black" : captions.font);
|
|
3168
|
+
const primary = hexToAss2(captions.regular.color);
|
|
3169
|
+
const highlight = hexToAss2(captions.highlight.color);
|
|
3170
|
+
const fontSize = Math.max(captions.regular.size, Math.round(height / 20));
|
|
3171
|
+
const marginV = captions.position === "bottom" ? Math.round(height * 0.24) : captions.position === "top" ? Math.round(height * 0.1) : 0;
|
|
3172
|
+
const alignment = positionToAlignment(captions.position);
|
|
3173
|
+
const outline = Math.max(2, Math.round(fontSize / 22));
|
|
3174
|
+
const upper = true;
|
|
3175
|
+
const caseOf = (t) => toCaptionCase(t, upper);
|
|
3176
|
+
const maxChars = fittingMaxChars(width, fontSize, SIDE_MARGIN, captions.maxChars, upper);
|
|
3177
|
+
const header = `[Script Info]
|
|
3178
|
+
Title: QuickPeek Demo
|
|
3179
|
+
ScriptType: v4.00+
|
|
3180
|
+
PlayResX: ${width}
|
|
3181
|
+
PlayResY: ${height}
|
|
3182
|
+
ScaledBorderAndShadow: yes
|
|
3183
|
+
WrapStyle: 0
|
|
3184
|
+
|
|
3185
|
+
[V4+ Styles]
|
|
3186
|
+
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
3187
|
+
Style: Shorts,${font},${fontSize},${primary},${primary},&H00000000,&H00000000,${captions.regular.bold ? -1 : 0},0,0,0,100,100,0,0,1,${outline},2,${alignment},${SIDE_MARGIN},${SIDE_MARGIN},${marginV},1
|
|
3188
|
+
|
|
3189
|
+
[Events]
|
|
3190
|
+
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
3191
|
+
`;
|
|
3192
|
+
const lines = [];
|
|
3193
|
+
for (const cue of ctx.cues) {
|
|
3194
|
+
for (const lineWords of packLines(cue.words, maxChars)) {
|
|
3195
|
+
const plain = lineWords.map((w) => caseOf(w.word)).join(" ");
|
|
3196
|
+
const scale = scaleToFit(plain, fontSize, width - SIDE_MARGIN * 2, upper);
|
|
3197
|
+
const scaleTag = scale < 100 ? `{\\fscx${scale}\\fscy${scale}}` : "";
|
|
3198
|
+
const boldTags = boldOverride(captions);
|
|
3199
|
+
for (let i = 0; i < lineWords.length; i++) {
|
|
3200
|
+
const text = lineWords.map(
|
|
3201
|
+
(w, j) => j === i ? `{\\c${highlight}${boldTags.lit}}${caseOf(w.word)}{\\c${primary}${boldTags.rest}}` : caseOf(w.word)
|
|
3202
|
+
).join(" ");
|
|
3203
|
+
const end = i === lineWords.length - 1 ? lineWords[i].end : lineWords[i + 1].start;
|
|
3204
|
+
lines.push(
|
|
3205
|
+
`Dialogue: 0,${assTimeFromMs(lineWords[i].start)},${assTimeFromMs(end)},Shorts,,0,0,0,,${scaleTag}${text}`
|
|
3206
|
+
);
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
return `${header}${lines.join("\n")}
|
|
3211
|
+
`;
|
|
3212
|
+
}
|
|
3213
|
+
function renderMinimalAss(ctx) {
|
|
3214
|
+
const { captions, width, height } = ctx;
|
|
3215
|
+
const font = resolveCaptionFont(captions.font);
|
|
3216
|
+
const primary = hexToAss2(captions.regular.color);
|
|
3217
|
+
const fontSize = Math.round(captions.regular.size * 0.6);
|
|
3218
|
+
const alignment = positionToAlignment(captions.position, true);
|
|
3219
|
+
const marginV = positionMarginV(captions.position, 30);
|
|
3220
|
+
const header = buildAssHeader({
|
|
3221
|
+
title: "QuickPeek Demo",
|
|
3222
|
+
width,
|
|
3223
|
+
height,
|
|
3224
|
+
styles: [
|
|
3225
|
+
// BorderStyle 3 draws an opaque box (&HB0...: mostly-opaque black) behind the text
|
|
3226
|
+
`Style: MinimalBg,${font},${fontSize},${primary},${primary},&H00000000,&HB0000000,${captions.regular.bold ? -1 : 0},0,0,0,100,100,0,0,3,0,0,${alignment},20,20,${marginV},1`
|
|
3227
|
+
],
|
|
3228
|
+
scaledBorderAndShadow: true
|
|
3229
|
+
});
|
|
3230
|
+
const maxChars = fittingMaxChars(width, fontSize, 20, captions.maxChars * 2, true);
|
|
3231
|
+
const lines = [];
|
|
3232
|
+
for (const cue of ctx.cues) {
|
|
3233
|
+
for (const line of packLines(cue.words, maxChars)) {
|
|
3234
|
+
const start = assTimeFromMs(line[0].start);
|
|
3235
|
+
const end = assTimeFromMs(line[line.length - 1].end);
|
|
3236
|
+
lines.push(`Dialogue: 0,${start},${end},MinimalBg,,0,0,0,,${line.map((w) => w.word).join(" ")}`);
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
return `${header}${lines.join("\n")}
|
|
3240
|
+
`;
|
|
3241
|
+
}
|
|
3242
|
+
var CAPTION_DESIGN_LONG_EDGE = 1920;
|
|
3243
|
+
function captionCanvas(width, height) {
|
|
3244
|
+
const longEdge = Math.max(width, height);
|
|
3245
|
+
if (!Number.isFinite(longEdge) || longEdge <= 0) return { width, height };
|
|
3246
|
+
const scale = CAPTION_DESIGN_LONG_EDGE / longEdge;
|
|
3247
|
+
return { width: Math.round(width * scale), height: Math.round(height * scale) };
|
|
3248
|
+
}
|
|
3249
|
+
function resolveCaptions(css, video) {
|
|
3250
|
+
const { width, height } = captionCanvas(video.width, video.height);
|
|
3251
|
+
const canvas = defaultCaptionsFor(width, height);
|
|
3252
|
+
const base = {
|
|
3253
|
+
...canvas,
|
|
3254
|
+
font: css.font,
|
|
3255
|
+
outline: css.outline,
|
|
3256
|
+
regular: { ...canvas.regular, color: css.regular.color },
|
|
3257
|
+
highlight: { ...canvas.highlight, color: css.highlight.color }
|
|
3258
|
+
};
|
|
3259
|
+
const over = video.captions;
|
|
3260
|
+
if (!over) return base;
|
|
3261
|
+
return {
|
|
3262
|
+
...base,
|
|
3263
|
+
...over,
|
|
3264
|
+
regular: { ...base.regular, ...over.regular },
|
|
3265
|
+
highlight: { ...base.highlight, ...over.highlight }
|
|
3266
|
+
};
|
|
3267
|
+
}
|
|
3268
|
+
function renderCaptionsAss(context) {
|
|
3269
|
+
const { width, height } = captionCanvas(context.width, context.height);
|
|
3270
|
+
const cues = context.cues.map((cue) => ({
|
|
3271
|
+
...cue,
|
|
3272
|
+
words: cue.words.map((w) => ({ ...w, word: toCaptionCase(w.word, true) }))
|
|
3273
|
+
}));
|
|
3274
|
+
const ctx = { ...context, width, height, cues };
|
|
3275
|
+
switch (ctx.captions.preset) {
|
|
3276
|
+
case "hormozi":
|
|
3277
|
+
return renderHormoziAss(ctx);
|
|
3278
|
+
case "shorts":
|
|
3279
|
+
return renderShortsAss(ctx);
|
|
3280
|
+
case "minimal":
|
|
3281
|
+
return renderMinimalAss(ctx);
|
|
3282
|
+
default:
|
|
3283
|
+
return renderKaraokeAss(ctx);
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
1741
3286
|
|
|
1742
3287
|
// src/lib/subscription-client.ts
|
|
1743
3288
|
async function registerFreeUser(email) {
|
|
@@ -1774,14 +3319,17 @@ async function registerFreeUser(email) {
|
|
|
1774
3319
|
|
|
1775
3320
|
// src/lib/tts/cache.ts
|
|
1776
3321
|
init_helpers();
|
|
1777
|
-
import { join as
|
|
1778
|
-
function canReuseVoiceover(steps, voice, existingMeta) {
|
|
3322
|
+
import { join as join4 } from "path";
|
|
3323
|
+
function canReuseVoiceover(steps, voice, rate, existingMeta) {
|
|
1779
3324
|
if (!existingMeta) {
|
|
1780
3325
|
return { canReuse: false, reason: "No existing voiceover found" };
|
|
1781
3326
|
}
|
|
1782
3327
|
if (existingMeta.voice !== voice) {
|
|
1783
3328
|
return { canReuse: false, reason: `Voice changed (${existingMeta.voice} \u2192 ${voice})` };
|
|
1784
3329
|
}
|
|
3330
|
+
if (existingMeta.rate !== rate) {
|
|
3331
|
+
return { canReuse: false, reason: `Narration speed changed (${existingMeta.rate ?? "unknown"} \u2192 ${rate})` };
|
|
3332
|
+
}
|
|
1785
3333
|
if (existingMeta.stepCount !== steps.length) {
|
|
1786
3334
|
return { canReuse: false, reason: `Step count changed (${existingMeta.stepCount} \u2192 ${steps.length})` };
|
|
1787
3335
|
}
|
|
@@ -1789,37 +3337,54 @@ function canReuseVoiceover(steps, voice, existingMeta) {
|
|
|
1789
3337
|
if (existingMeta.captionsHash === currentHash) {
|
|
1790
3338
|
return { canReuse: true };
|
|
1791
3339
|
}
|
|
1792
|
-
|
|
1793
|
-
const lengthDiff = Math.abs(currentLength - existingMeta.totalCaptionLength);
|
|
1794
|
-
const threshold = existingMeta.totalCaptionLength * 0.1;
|
|
1795
|
-
if (lengthDiff <= threshold) {
|
|
1796
|
-
return { canReuse: true, reason: "Captions similar enough" };
|
|
1797
|
-
}
|
|
1798
|
-
return { canReuse: false, reason: "Captions changed significantly" };
|
|
3340
|
+
return { canReuse: false, reason: "Captions changed" };
|
|
1799
3341
|
}
|
|
1800
3342
|
async function loadExistingVoiceover(steps, outputDir, meta, config) {
|
|
1801
3343
|
const { estimateWordTimings: estimateWordTimings2 } = await Promise.resolve().then(() => (init_timing(), timing_exports));
|
|
1802
|
-
const voiceoverPath =
|
|
3344
|
+
const voiceoverPath = join4(outputDir, "voiceover.wav");
|
|
1803
3345
|
const stepAudio = /* @__PURE__ */ new Map();
|
|
3346
|
+
const captionsUnchanged = meta.captionsHash === getCaptionsHash(steps);
|
|
1804
3347
|
for (let i = 0; i < steps.length; i++) {
|
|
1805
3348
|
const step = steps[i];
|
|
1806
3349
|
const stepNum = i + 1;
|
|
1807
3350
|
const duration = meta.stepDurations[stepNum] || config.timing.minStepDuration;
|
|
3351
|
+
const savedWords = captionsUnchanged ? meta.stepWords?.[stepNum] : void 0;
|
|
1808
3352
|
stepAudio.set(stepNum, {
|
|
1809
3353
|
duration,
|
|
1810
|
-
words: estimateWordTimings2(step.caption || "", duration)
|
|
3354
|
+
words: savedWords && savedWords.length > 0 ? savedWords : estimateWordTimings2(step.caption || "", duration)
|
|
1811
3355
|
});
|
|
1812
3356
|
}
|
|
1813
3357
|
return { voiceoverPath, stepAudio };
|
|
1814
3358
|
}
|
|
1815
3359
|
|
|
1816
3360
|
// src/lib/tts/generate.ts
|
|
3361
|
+
import { createHash } from "crypto";
|
|
3362
|
+
import { existsSync as existsSync3 } from "fs";
|
|
3363
|
+
import { copyFile, mkdir, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
|
|
3364
|
+
import { join as join6 } from "path";
|
|
3365
|
+
import { toSpokenForm } from "@spark-apps/video-kit";
|
|
1817
3366
|
init_ffmpeg2();
|
|
1818
|
-
|
|
1819
|
-
import { join as join5 } from "path";
|
|
3367
|
+
init_utils();
|
|
1820
3368
|
|
|
1821
3369
|
// src/lib/voices.ts
|
|
1822
|
-
import { WIDE_VOICES } from "@spark-apps/video-kit";
|
|
3370
|
+
import { MALE_VOICES, MULTILINGUAL_VOICES, resolveVoice, WIDE_VOICES } from "@spark-apps/video-kit";
|
|
3371
|
+
import { MALE_VOICES as MALE_VOICES2, MULTILINGUAL_VOICES as MULTILINGUAL_VOICES2, WIDE_VOICES as WIDE_VOICES2 } from "@spark-apps/video-kit";
|
|
3372
|
+
var INTERNATIONAL_VOICE = "international";
|
|
3373
|
+
function internationalVoiceFor(lang, gender = "female") {
|
|
3374
|
+
if (!hasVoice(lang)) return null;
|
|
3375
|
+
const raw = lang.trim().toLowerCase();
|
|
3376
|
+
const multilingual = MULTILINGUAL_VOICES[raw] ?? MULTILINGUAL_VOICES[raw.slice(0, 2)];
|
|
3377
|
+
if (gender === "male") return voiceFor(lang, gender);
|
|
3378
|
+
return multilingual ?? voiceFor(lang, gender);
|
|
3379
|
+
}
|
|
3380
|
+
function hasVoice(lang) {
|
|
3381
|
+
const raw = lang.trim().toLowerCase();
|
|
3382
|
+
return Boolean(WIDE_VOICES[raw] ?? WIDE_VOICES[raw.slice(0, 2)]);
|
|
3383
|
+
}
|
|
3384
|
+
function voiceFor(lang, gender = "female") {
|
|
3385
|
+
if (!hasVoice(lang)) return null;
|
|
3386
|
+
return resolveVoice(lang, { gender, table: WIDE_VOICES, maleTable: MALE_VOICES });
|
|
3387
|
+
}
|
|
1823
3388
|
|
|
1824
3389
|
// src/lib/tts/generate.ts
|
|
1825
3390
|
init_helpers();
|
|
@@ -1828,39 +3393,78 @@ init_helpers();
|
|
|
1828
3393
|
init_helpers();
|
|
1829
3394
|
import { exec } from "child_process";
|
|
1830
3395
|
import { createWriteStream } from "fs";
|
|
1831
|
-
import { stat, unlink as
|
|
1832
|
-
import { join as
|
|
3396
|
+
import { readFile as readFile2, stat, unlink as unlink3, writeFile as writeFile2 } from "fs/promises";
|
|
3397
|
+
import { join as join5, resolve as resolve2 } from "path";
|
|
1833
3398
|
import { pipeline } from "stream/promises";
|
|
1834
3399
|
import { promisify } from "util";
|
|
1835
3400
|
var execAsync = promisify(exec);
|
|
1836
3401
|
var PYTHON_ATTEMPTS = 2;
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
3402
|
+
var TICKS_PER_MS = 1e4;
|
|
3403
|
+
var wordsSidecar = (audioPath) => `${audioPath}.words.json`;
|
|
3404
|
+
var pythonEdgeTts = null;
|
|
3405
|
+
function pythonEdgeTtsAvailable() {
|
|
3406
|
+
pythonEdgeTts ??= execAsync(isWindows ? 'wsl python3 -c "import edge_tts"' : 'python3 -c "import edge_tts"', {
|
|
3407
|
+
timeout: 1e4
|
|
3408
|
+
}).then(
|
|
3409
|
+
() => true,
|
|
3410
|
+
() => false
|
|
3411
|
+
);
|
|
3412
|
+
return pythonEdgeTts;
|
|
3413
|
+
}
|
|
3414
|
+
var lockedEngine = null;
|
|
3415
|
+
function resetVoiceEngine() {
|
|
3416
|
+
lockedEngine = null;
|
|
3417
|
+
}
|
|
3418
|
+
function xmlSafe(text) {
|
|
3419
|
+
return text.replace(/[&<>]/g, (c) => c === "&" ? " and " : " ").replace(/\s{2,}/g, " ");
|
|
3420
|
+
}
|
|
3421
|
+
async function synthesizeSpeech(rawText, outputPath, voice, rate) {
|
|
3422
|
+
const text = xmlSafe(rawText);
|
|
3423
|
+
let pythonMsg = "python edge-tts not installed";
|
|
3424
|
+
const tryPython = lockedEngine !== "node" && await pythonEdgeTtsAvailable();
|
|
3425
|
+
if (tryPython) {
|
|
3426
|
+
const attempts = lockedEngine === "python" ? PYTHON_ATTEMPTS * 2 : PYTHON_ATTEMPTS;
|
|
3427
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
3428
|
+
try {
|
|
3429
|
+
const words2 = await synthesizeWithPython(text, outputPath, voice, rate);
|
|
3430
|
+
lockedEngine = "python";
|
|
3431
|
+
return words2;
|
|
3432
|
+
} catch (err) {
|
|
3433
|
+
pythonMsg = err instanceof Error ? err.message : String(err);
|
|
3434
|
+
if (attempt < attempts - 1) await new Promise((r) => setTimeout(r, 500));
|
|
3435
|
+
}
|
|
1846
3436
|
}
|
|
1847
3437
|
}
|
|
1848
|
-
await synthesizeWithNode(text, outputPath, voice, pythonMsg);
|
|
3438
|
+
const words = await synthesizeWithNode(text, outputPath, voice, pythonMsg, rate);
|
|
3439
|
+
lockedEngine = "node";
|
|
3440
|
+
return words;
|
|
1849
3441
|
}
|
|
1850
|
-
async function synthesizeWithPython(text, outputPath, voice) {
|
|
1851
|
-
const tempScriptPath =
|
|
3442
|
+
async function synthesizeWithPython(text, outputPath, voice, rate) {
|
|
3443
|
+
const tempScriptPath = join5(resolve2(outputPath, ".."), `tts-${Date.now()}.py`);
|
|
1852
3444
|
const wslScriptPath = isWindows ? toWSLPathForTTS(resolve2(tempScriptPath)) : tempScriptPath;
|
|
1853
3445
|
const wslOutputPath = isWindows ? toWSLPathForTTS(resolve2(outputPath)) : outputPath;
|
|
1854
3446
|
const pythonScript = `
|
|
1855
|
-
import asyncio
|
|
3447
|
+
import asyncio, json
|
|
1856
3448
|
from edge_tts import Communicate
|
|
1857
3449
|
|
|
1858
3450
|
async def main():
|
|
1859
3451
|
text = ${JSON.stringify(text)}
|
|
1860
3452
|
voice = ${JSON.stringify(voice)}
|
|
1861
3453
|
output = ${JSON.stringify(wslOutputPath)}
|
|
1862
|
-
communicate = Communicate(text, voice)
|
|
1863
|
-
|
|
3454
|
+
communicate = Communicate(text, voice${rate ? `, rate=${JSON.stringify(rate)}` : ""})
|
|
3455
|
+
words = []
|
|
3456
|
+
with open(output, "wb") as f:
|
|
3457
|
+
async for chunk in communicate.stream():
|
|
3458
|
+
if chunk["type"] == "audio":
|
|
3459
|
+
f.write(chunk["data"])
|
|
3460
|
+
elif chunk["type"] == "WordBoundary":
|
|
3461
|
+
words.append({
|
|
3462
|
+
"word": chunk["text"],
|
|
3463
|
+
"start": chunk["offset"] / ${TICKS_PER_MS},
|
|
3464
|
+
"end": (chunk["offset"] + chunk["duration"]) / ${TICKS_PER_MS},
|
|
3465
|
+
})
|
|
3466
|
+
with open(output + ".words.json", "w") as f:
|
|
3467
|
+
json.dump(words, f)
|
|
1864
3468
|
|
|
1865
3469
|
asyncio.run(main())
|
|
1866
3470
|
`;
|
|
@@ -1868,24 +3472,40 @@ asyncio.run(main())
|
|
|
1868
3472
|
await writeFile2(tempScriptPath, pythonScript);
|
|
1869
3473
|
const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
|
|
1870
3474
|
await execAsync(pythonCmd, { timeout: 3e4 });
|
|
3475
|
+
return await readWordsSidecar(outputPath);
|
|
1871
3476
|
} finally {
|
|
1872
|
-
await
|
|
3477
|
+
await unlink3(tempScriptPath).catch(() => {
|
|
1873
3478
|
});
|
|
1874
3479
|
}
|
|
1875
3480
|
}
|
|
1876
|
-
async function
|
|
3481
|
+
async function readWordsSidecar(outputPath) {
|
|
3482
|
+
const sidecar = wordsSidecar(outputPath);
|
|
3483
|
+
try {
|
|
3484
|
+
const parsed = JSON.parse(await readFile2(sidecar, "utf-8"));
|
|
3485
|
+
return parsed.length > 0 ? parsed : null;
|
|
3486
|
+
} catch {
|
|
3487
|
+
return null;
|
|
3488
|
+
} finally {
|
|
3489
|
+
await unlink3(sidecar).catch(() => {
|
|
3490
|
+
});
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
async function synthesizeWithNode(text, outputPath, voice, pythonMsg, rate, maxRetries = 2) {
|
|
1877
3494
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1878
3495
|
try {
|
|
1879
3496
|
const { MsEdgeTTS, OUTPUT_FORMAT } = await import("msedge-tts");
|
|
1880
3497
|
const tts = new MsEdgeTTS();
|
|
1881
|
-
await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3);
|
|
1882
|
-
const { audioStream } = tts.toStream(text);
|
|
3498
|
+
await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3, { wordBoundaryEnabled: true });
|
|
3499
|
+
const { audioStream, metadataStream } = tts.toStream(text, rate ? { rate } : void 0);
|
|
3500
|
+
const metadata = [];
|
|
3501
|
+
metadataStream?.on("data", (chunk) => metadata.push(chunk.toString()));
|
|
1883
3502
|
await pipeline(audioStream, createWriteStream(outputPath));
|
|
3503
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1884
3504
|
const { size } = await stat(outputPath);
|
|
1885
3505
|
if (size === 0) throw new Error("No audio data received");
|
|
1886
|
-
return;
|
|
3506
|
+
return parseWordBoundaries(metadata.join(""));
|
|
1887
3507
|
} catch (nodeErr) {
|
|
1888
|
-
await
|
|
3508
|
+
await unlink3(outputPath).catch(() => {
|
|
1889
3509
|
});
|
|
1890
3510
|
if (attempt === maxRetries) {
|
|
1891
3511
|
const nodeMsg = nodeErr instanceof Error ? nodeErr.message : String(nodeErr);
|
|
@@ -1894,21 +3514,197 @@ async function synthesizeWithNode(text, outputPath, voice, pythonMsg, maxRetries
|
|
|
1894
3514
|
await new Promise((r) => setTimeout(r, 500));
|
|
1895
3515
|
}
|
|
1896
3516
|
}
|
|
3517
|
+
return null;
|
|
3518
|
+
}
|
|
3519
|
+
function parseWordBoundaries(raw) {
|
|
3520
|
+
const words = [];
|
|
3521
|
+
for (const frame of splitJsonObjects(raw)) {
|
|
3522
|
+
let parsed;
|
|
3523
|
+
try {
|
|
3524
|
+
parsed = JSON.parse(frame);
|
|
3525
|
+
} catch {
|
|
3526
|
+
continue;
|
|
3527
|
+
}
|
|
3528
|
+
for (const entry of parsed.Metadata ?? []) {
|
|
3529
|
+
if (entry.Type !== "WordBoundary") continue;
|
|
3530
|
+
const word = entry.Data?.text?.Text;
|
|
3531
|
+
const offset = entry.Data?.Offset;
|
|
3532
|
+
const duration = entry.Data?.Duration;
|
|
3533
|
+
if (!word || offset === void 0 || duration === void 0) continue;
|
|
3534
|
+
words.push({
|
|
3535
|
+
word,
|
|
3536
|
+
start: offset / TICKS_PER_MS,
|
|
3537
|
+
end: (offset + duration) / TICKS_PER_MS
|
|
3538
|
+
});
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
return words.length > 0 ? words : null;
|
|
3542
|
+
}
|
|
3543
|
+
function splitJsonObjects(raw) {
|
|
3544
|
+
const out = [];
|
|
3545
|
+
let depth = 0;
|
|
3546
|
+
let start = -1;
|
|
3547
|
+
let inString = false;
|
|
3548
|
+
let escaped = false;
|
|
3549
|
+
for (let i = 0; i < raw.length; i++) {
|
|
3550
|
+
const ch = raw[i];
|
|
3551
|
+
if (escaped) {
|
|
3552
|
+
escaped = false;
|
|
3553
|
+
continue;
|
|
3554
|
+
}
|
|
3555
|
+
if (inString) {
|
|
3556
|
+
if (ch === "\\") escaped = true;
|
|
3557
|
+
else if (ch === '"') inString = false;
|
|
3558
|
+
continue;
|
|
3559
|
+
}
|
|
3560
|
+
if (ch === '"') inString = true;
|
|
3561
|
+
else if (ch === "{") {
|
|
3562
|
+
if (depth === 0) start = i;
|
|
3563
|
+
depth++;
|
|
3564
|
+
} else if (ch === "}") {
|
|
3565
|
+
depth--;
|
|
3566
|
+
if (depth === 0 && start >= 0) {
|
|
3567
|
+
out.push(raw.slice(start, i + 1));
|
|
3568
|
+
start = -1;
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
return out;
|
|
1897
3573
|
}
|
|
1898
3574
|
|
|
1899
3575
|
// src/lib/tts/generate.ts
|
|
1900
3576
|
init_timing();
|
|
3577
|
+
init_helpers();
|
|
3578
|
+
function clipKey(spokenText, voice, rate) {
|
|
3579
|
+
return createHash("sha1").update(`${spokenText}\0${voice}\0${rate}`).digest("hex");
|
|
3580
|
+
}
|
|
3581
|
+
async function reuseClip(cacheDir, key, audioPath) {
|
|
3582
|
+
const mp3 = join6(cacheDir, `${key}.mp3`);
|
|
3583
|
+
const meta = join6(cacheDir, `${key}.json`);
|
|
3584
|
+
if (!existsSync3(mp3) || !existsSync3(meta)) return null;
|
|
3585
|
+
try {
|
|
3586
|
+
const boundaries = JSON.parse(await readFile3(meta, "utf-8"));
|
|
3587
|
+
await copyFile(mp3, audioPath);
|
|
3588
|
+
return boundaries;
|
|
3589
|
+
} catch {
|
|
3590
|
+
return null;
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
async function storeClip(cacheDir, key, audioPath, spoken) {
|
|
3594
|
+
try {
|
|
3595
|
+
await mkdir(cacheDir, { recursive: true });
|
|
3596
|
+
await copyFile(audioPath, join6(cacheDir, `${key}.mp3`));
|
|
3597
|
+
await writeFile3(join6(cacheDir, `${key}.json`), JSON.stringify(spoken ?? null));
|
|
3598
|
+
} catch {
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
var TTS_POOL = 4;
|
|
3602
|
+
function capStep(config, ms) {
|
|
3603
|
+
const cap = config.timing.maxStepDuration;
|
|
3604
|
+
return cap && cap > 0 ? Math.min(ms, cap) : ms;
|
|
3605
|
+
}
|
|
1901
3606
|
function estimateDurationFromText(text, wordsPerMinute = 150) {
|
|
1902
3607
|
const words = text.split(/\s+/).filter((w) => w.length > 0).length;
|
|
1903
3608
|
return Math.max(Math.round(words / wordsPerMinute * 60 * 1e3), 1500);
|
|
1904
3609
|
}
|
|
1905
|
-
function
|
|
1906
|
-
|
|
1907
|
-
if (
|
|
3610
|
+
function resolveVoice2(config) {
|
|
3611
|
+
const named = config.audio?.voice;
|
|
3612
|
+
if (named === INTERNATIONAL_VOICE) {
|
|
3613
|
+
return internationalVoiceFor(config.lang ?? "en", config.audio?.gender);
|
|
3614
|
+
}
|
|
3615
|
+
if (named) return named;
|
|
3616
|
+
if (config.lang) return voiceFor(config.lang, config.audio?.gender);
|
|
1908
3617
|
return null;
|
|
1909
3618
|
}
|
|
3619
|
+
function resolveRate(config) {
|
|
3620
|
+
return config.audio?.rate ?? "normal";
|
|
3621
|
+
}
|
|
3622
|
+
var ONES = [
|
|
3623
|
+
"zero",
|
|
3624
|
+
"one",
|
|
3625
|
+
"two",
|
|
3626
|
+
"three",
|
|
3627
|
+
"four",
|
|
3628
|
+
"five",
|
|
3629
|
+
"six",
|
|
3630
|
+
"seven",
|
|
3631
|
+
"eight",
|
|
3632
|
+
"nine",
|
|
3633
|
+
"ten",
|
|
3634
|
+
"eleven",
|
|
3635
|
+
"twelve",
|
|
3636
|
+
"thirteen",
|
|
3637
|
+
"fourteen",
|
|
3638
|
+
"fifteen",
|
|
3639
|
+
"sixteen",
|
|
3640
|
+
"seventeen",
|
|
3641
|
+
"eighteen",
|
|
3642
|
+
"nineteen"
|
|
3643
|
+
];
|
|
3644
|
+
var TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
|
|
3645
|
+
function speakInteger(n) {
|
|
3646
|
+
if (!Number.isInteger(n) || n < 0 || n > 999) return null;
|
|
3647
|
+
if (n < 20) return ONES[n];
|
|
3648
|
+
if (n < 100) {
|
|
3649
|
+
const t = TENS[Math.floor(n / 10)];
|
|
3650
|
+
const r2 = n % 10;
|
|
3651
|
+
return r2 ? `${t}-${ONES[r2]}` : t;
|
|
3652
|
+
}
|
|
3653
|
+
const h = `${ONES[Math.floor(n / 100)]} hundred`;
|
|
3654
|
+
const r = n % 100;
|
|
3655
|
+
return r ? `${h} and ${speakInteger(r)}` : h;
|
|
3656
|
+
}
|
|
3657
|
+
function speakNumbers(text) {
|
|
3658
|
+
return text.replace(/(\d+)\s*%/g, (m, d) => {
|
|
3659
|
+
const spoken = speakInteger(Number(d));
|
|
3660
|
+
return spoken ? `${spoken} percent` : m;
|
|
3661
|
+
}).replace(/(?<![\d.,£$€])\b(\d{1,3})\b(?![\d.,%])/g, (m, d) => speakInteger(Number(d)) ?? m);
|
|
3662
|
+
}
|
|
3663
|
+
var SPELLED_OUT = /\b([A-Z]{2,5})\b/g;
|
|
3664
|
+
var LETTER_SOUNDS = {
|
|
3665
|
+
A: "ay",
|
|
3666
|
+
B: "bee",
|
|
3667
|
+
C: "see",
|
|
3668
|
+
D: "dee",
|
|
3669
|
+
E: "ee",
|
|
3670
|
+
F: "ef",
|
|
3671
|
+
G: "gee",
|
|
3672
|
+
H: "aitch",
|
|
3673
|
+
I: "eye",
|
|
3674
|
+
J: "jay",
|
|
3675
|
+
K: "kay",
|
|
3676
|
+
L: "el",
|
|
3677
|
+
M: "em",
|
|
3678
|
+
N: "en",
|
|
3679
|
+
O: "oh",
|
|
3680
|
+
P: "pee",
|
|
3681
|
+
Q: "cue",
|
|
3682
|
+
R: "ar",
|
|
3683
|
+
S: "ess",
|
|
3684
|
+
T: "tee",
|
|
3685
|
+
U: "you",
|
|
3686
|
+
V: "vee",
|
|
3687
|
+
W: "double-you",
|
|
3688
|
+
X: "ex",
|
|
3689
|
+
Y: "why",
|
|
3690
|
+
Z: "zed"
|
|
3691
|
+
};
|
|
3692
|
+
function spellAcronyms(text) {
|
|
3693
|
+
return text.replace(
|
|
3694
|
+
SPELLED_OUT,
|
|
3695
|
+
(m) => m.split("").map((c) => LETTER_SOUNDS[c] ?? c).join(" ")
|
|
3696
|
+
);
|
|
3697
|
+
}
|
|
3698
|
+
function bindDomains(text) {
|
|
3699
|
+
return text.replace(/\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi, "$1-dot-$2");
|
|
3700
|
+
}
|
|
3701
|
+
function speakSymbols(text) {
|
|
3702
|
+
return text.replace(/\s*&\s*/g, " and ").replace(/[<>]/g, " ").replace(/\s{2,}/g, " ").trim();
|
|
3703
|
+
}
|
|
1910
3704
|
async function generateVoiceover(steps, config, workDir, outputDir, logError, task, info) {
|
|
1911
|
-
|
|
3705
|
+
resetVoiceEngine();
|
|
3706
|
+
const voice = resolveVoice2(config);
|
|
3707
|
+
const rate = resolveRate(config);
|
|
1912
3708
|
if (!voice) {
|
|
1913
3709
|
const stepAudio2 = /* @__PURE__ */ new Map();
|
|
1914
3710
|
for (let i = 0; i < steps.length; i++) {
|
|
@@ -1917,72 +3713,105 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError, ta
|
|
|
1917
3713
|
const actionDuration = estimateActionDuration(step);
|
|
1918
3714
|
const manualDuration = (step.duration ?? 0) * 1e3;
|
|
1919
3715
|
const mediaDuration = await getMediaStepDuration(step, outputDir);
|
|
1920
|
-
const duration =
|
|
1921
|
-
|
|
1922
|
-
actionDuration,
|
|
1923
|
-
manualDuration,
|
|
1924
|
-
mediaDuration,
|
|
1925
|
-
config.timing.minStepDuration
|
|
3716
|
+
const duration = capStep(
|
|
3717
|
+
config,
|
|
3718
|
+
Math.max(textDuration, actionDuration, manualDuration, mediaDuration, config.timing.minStepDuration)
|
|
1926
3719
|
);
|
|
1927
3720
|
stepAudio2.set(i + 1, { duration, words: step.caption ? estimateWordTimings(step.caption, duration) : [] });
|
|
1928
3721
|
}
|
|
1929
3722
|
info("No voice configured");
|
|
1930
3723
|
return { voiceoverPath: "", stepAudio: stepAudio2 };
|
|
1931
3724
|
}
|
|
1932
|
-
const audioDir =
|
|
3725
|
+
const audioDir = join6(workDir, "audio");
|
|
1933
3726
|
await mkdir(audioDir, { recursive: true });
|
|
1934
|
-
const segments = [];
|
|
1935
|
-
const stepAudio = /* @__PURE__ */ new Map();
|
|
1936
3727
|
const ttsTask = task("Generating voiceover");
|
|
1937
|
-
|
|
3728
|
+
const speed = config.audio?.speed ?? 1;
|
|
3729
|
+
const factor = speed * RATE_FACTOR[rate];
|
|
3730
|
+
const ratePercent = Math.abs(factor - 1) < 5e-3 ? void 0 : `${factor > 1 ? "+" : ""}${Math.round((factor - 1) * 100)}%`;
|
|
3731
|
+
const perStep = new Array(steps.length);
|
|
3732
|
+
const buildStep = async (idx) => {
|
|
1938
3733
|
const step = steps[idx];
|
|
1939
3734
|
const stepNum = idx + 1;
|
|
1940
3735
|
if (!step.caption) {
|
|
1941
3736
|
const actionDuration = estimateActionDuration(step);
|
|
1942
3737
|
const manualDuration = (step.duration ?? 0) * 1e3;
|
|
1943
3738
|
const mediaDuration = await getMediaStepDuration(step, outputDir);
|
|
1944
|
-
const silenceDuration =
|
|
1945
|
-
|
|
3739
|
+
const silenceDuration = capStep(
|
|
3740
|
+
config,
|
|
3741
|
+
Math.max(actionDuration, manualDuration, mediaDuration, config.timing.minStepDuration)
|
|
3742
|
+
);
|
|
3743
|
+
const silencePath = join6(audioDir, `step-${stepNum}-silence.mp3`);
|
|
1946
3744
|
await generateSilence(silencePath, silenceDuration);
|
|
1947
|
-
segments
|
|
1948
|
-
|
|
1949
|
-
continue;
|
|
3745
|
+
perStep[idx] = { segments: [silencePath], audio: { duration: silenceDuration, words: [] } };
|
|
3746
|
+
return;
|
|
1950
3747
|
}
|
|
1951
|
-
const audioPath =
|
|
1952
|
-
if (segments.length > 0) await new Promise((r) => setTimeout(r, 200));
|
|
3748
|
+
const audioPath = join6(audioDir, `step-${stepNum}.mp3`);
|
|
1953
3749
|
try {
|
|
1954
|
-
|
|
1955
|
-
|
|
3750
|
+
const spokenText = speakNumbers(spellAcronyms(toSpokenForm(bindDomains(speakSymbols(step.caption)))));
|
|
3751
|
+
const key = clipKey(spokenText, voice, ratePercent ?? "");
|
|
3752
|
+
const cacheDir = join6(outputDir, ".tts-clips");
|
|
3753
|
+
const cached = await reuseClip(cacheDir, key, audioPath);
|
|
3754
|
+
let spoken;
|
|
3755
|
+
if (cached) {
|
|
3756
|
+
spoken = cached;
|
|
3757
|
+
} else {
|
|
3758
|
+
spoken = await synthesizeSpeech(spokenText, audioPath, voice, ratePercent);
|
|
3759
|
+
await trimEdgeSilence(audioPath).catch(() => {
|
|
3760
|
+
});
|
|
3761
|
+
await storeClip(cacheDir, key, audioPath, spoken);
|
|
3762
|
+
}
|
|
3763
|
+
const clips = [audioPath];
|
|
1956
3764
|
const audioDuration = await getMediaDuration(audioPath);
|
|
1957
3765
|
const actionDuration = estimateActionDuration(step);
|
|
1958
3766
|
const manualDuration = (step.duration ?? 0) * 1e3;
|
|
1959
3767
|
const mediaDuration = await getMediaStepDuration(step, outputDir);
|
|
1960
|
-
const stepDuration =
|
|
1961
|
-
|
|
1962
|
-
config.timing.minStepDuration,
|
|
1963
|
-
actionDuration,
|
|
1964
|
-
manualDuration,
|
|
1965
|
-
mediaDuration
|
|
3768
|
+
const stepDuration = capStep(
|
|
3769
|
+
config,
|
|
3770
|
+
Math.max(audioDuration, config.timing.minStepDuration, actionDuration, manualDuration, mediaDuration)
|
|
1966
3771
|
);
|
|
1967
3772
|
if (stepDuration > audioDuration) {
|
|
1968
|
-
const paddingPath =
|
|
3773
|
+
const paddingPath = join6(audioDir, `step-${stepNum}-padding.mp3`);
|
|
1969
3774
|
await generateSilence(paddingPath, stepDuration - audioDuration);
|
|
1970
|
-
|
|
3775
|
+
clips.push(paddingPath);
|
|
1971
3776
|
}
|
|
1972
|
-
|
|
3777
|
+
const words = (config.audio?.transcribe === false ? null : spokenWordTimings(step.caption, spoken)) ?? estimateWordTimings(step.caption, audioDuration);
|
|
3778
|
+
perStep[idx] = { segments: clips, audio: { duration: stepDuration, words } };
|
|
1973
3779
|
} catch (err) {
|
|
1974
3780
|
await logError(`TTS Step ${stepNum}`, err);
|
|
1975
|
-
|
|
3781
|
+
perStep[idx] = { segments: [], audio: { duration: config.timing.minStepDuration, words: [] } };
|
|
1976
3782
|
}
|
|
3783
|
+
};
|
|
3784
|
+
await runPooled(steps.length, TTS_POOL, buildStep);
|
|
3785
|
+
const segments = [];
|
|
3786
|
+
const stepAudio = /* @__PURE__ */ new Map();
|
|
3787
|
+
for (let idx = 0; idx < steps.length; idx++) {
|
|
3788
|
+
const built = perStep[idx];
|
|
3789
|
+
if (!built) continue;
|
|
3790
|
+
segments.push(...built.segments);
|
|
3791
|
+
stepAudio.set(idx + 1, built.audio);
|
|
1977
3792
|
}
|
|
1978
3793
|
const stepsWithCaptions = steps.filter((s) => s.caption).length;
|
|
1979
3794
|
ttsTask.succeed(`${segments.length}/${stepsWithCaptions} clips`);
|
|
1980
3795
|
if (segments.length === 0) {
|
|
1981
3796
|
return { voiceoverPath: "", stepAudio };
|
|
1982
3797
|
}
|
|
3798
|
+
const leadInPath = join6(audioDir, LEAD_IN_CLIP);
|
|
3799
|
+
await generateSilence(leadInPath, NARRATION_LEAD_IN_MS);
|
|
3800
|
+
segments.unshift(leadInPath);
|
|
3801
|
+
const firstStep = stepAudio.get(1);
|
|
3802
|
+
if (firstStep) {
|
|
3803
|
+
stepAudio.set(1, {
|
|
3804
|
+
duration: firstStep.duration + NARRATION_LEAD_IN_MS,
|
|
3805
|
+
words: firstStep.words.map((w) => ({
|
|
3806
|
+
...w,
|
|
3807
|
+
start: w.start + NARRATION_LEAD_IN_MS,
|
|
3808
|
+
end: w.end + NARRATION_LEAD_IN_MS
|
|
3809
|
+
}))
|
|
3810
|
+
});
|
|
3811
|
+
}
|
|
1983
3812
|
const concatTask = task("Combining audio");
|
|
1984
3813
|
await mkdir(outputDir, { recursive: true });
|
|
1985
|
-
const voiceoverPath =
|
|
3814
|
+
const voiceoverPath = join6(outputDir, "voiceover.wav");
|
|
1986
3815
|
await concatenateAudio(segments, voiceoverPath, workDir);
|
|
1987
3816
|
concatTask.succeed("Done");
|
|
1988
3817
|
const meta = {
|
|
@@ -1990,7 +3819,11 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError, ta
|
|
|
1990
3819
|
totalCaptionLength: steps.reduce((sum, s) => sum + (s.caption?.length || 0), 0),
|
|
1991
3820
|
stepCount: steps.length,
|
|
1992
3821
|
voice,
|
|
1993
|
-
|
|
3822
|
+
rate,
|
|
3823
|
+
stepDurations: Object.fromEntries(Array.from(stepAudio.entries()).map(([k, v]) => [k, v.duration])),
|
|
3824
|
+
stepWords: Object.fromEntries(
|
|
3825
|
+
Array.from(stepAudio.entries()).filter(([, v]) => v.words.length > 0).map(([k, v]) => [k, v.words])
|
|
3826
|
+
)
|
|
1994
3827
|
};
|
|
1995
3828
|
await saveVoiceoverMeta(outputDir, meta);
|
|
1996
3829
|
return { voiceoverPath, stepAudio };
|
|
@@ -2010,16 +3843,37 @@ export {
|
|
|
2010
3843
|
CURSOR_SCRIPT,
|
|
2011
3844
|
DEFAULT_CAPTIONS,
|
|
2012
3845
|
DEFAULT_CONFIG,
|
|
3846
|
+
EMPTY_SPAN_SPEED,
|
|
2013
3847
|
EXCLUDED_LINK_PATTERNS,
|
|
2014
|
-
|
|
3848
|
+
GIF_DEFAULTS,
|
|
2015
3849
|
INTERACTIVE_SELECTORS,
|
|
2016
|
-
|
|
3850
|
+
INTERNATIONAL_VOICE,
|
|
3851
|
+
MALE_VOICES2 as MALE_VOICES,
|
|
3852
|
+
MULTILINGUAL_VOICES2 as MULTILINGUAL_VOICES,
|
|
3853
|
+
MUSIC_BED_LUFS,
|
|
3854
|
+
NARRATION_LEAD_IN_MS,
|
|
3855
|
+
NO_CLIP_OUTRO,
|
|
3856
|
+
PLAN_MAX_TOKENS,
|
|
3857
|
+
RATE_FACTOR,
|
|
2017
3858
|
SERVER_BACKOFF_MS,
|
|
3859
|
+
SIZE_PRESETS,
|
|
3860
|
+
TRIM_EDGE_SILENCE,
|
|
2018
3861
|
VERSION,
|
|
3862
|
+
VIDEO_PROFILES,
|
|
3863
|
+
VOICE_LEAD_MS,
|
|
3864
|
+
WIDE_VOICES2 as WIDE_VOICES,
|
|
2019
3865
|
alignVoiceoverToRecording,
|
|
3866
|
+
alignWordsToCaption,
|
|
3867
|
+
applyProfile,
|
|
2020
3868
|
applyTransitions,
|
|
3869
|
+
ariaLabelSelector,
|
|
3870
|
+
atempoChain,
|
|
3871
|
+
buildCompressionFilter,
|
|
2021
3872
|
buildConcatArgs,
|
|
3873
|
+
buildGifArgs,
|
|
2022
3874
|
buildKeepSegments,
|
|
3875
|
+
buildPaletteArgs,
|
|
3876
|
+
buildRetimeFilter,
|
|
2023
3877
|
buildStepTransitions,
|
|
2024
3878
|
buildSystemPrompt,
|
|
2025
3879
|
buildUserPrompt,
|
|
@@ -2027,10 +3881,22 @@ export {
|
|
|
2027
3881
|
callAI,
|
|
2028
3882
|
callAIViaRelay,
|
|
2029
3883
|
canReuseVoiceover,
|
|
3884
|
+
captionCanvas,
|
|
2030
3885
|
composeVideo,
|
|
3886
|
+
compressSilentSpans,
|
|
2031
3887
|
concatMedia,
|
|
3888
|
+
concatVideoWithFullFrameOutro,
|
|
2032
3889
|
concatVideoWithOutro,
|
|
3890
|
+
convertToGif,
|
|
3891
|
+
coverFilterChain,
|
|
2033
3892
|
crawlPage,
|
|
3893
|
+
cutSpansFromVideo,
|
|
3894
|
+
deStock,
|
|
3895
|
+
defaultCaptionsFor,
|
|
3896
|
+
detectBlackSpans,
|
|
3897
|
+
detectFrozenSpans,
|
|
3898
|
+
detectSilences,
|
|
3899
|
+
detectWhiteSpans,
|
|
2034
3900
|
estimateWordTimings,
|
|
2035
3901
|
extractJsonCandidates,
|
|
2036
3902
|
generateASSHeader,
|
|
@@ -2044,10 +3910,16 @@ export {
|
|
|
2044
3910
|
getStatusCached,
|
|
2045
3911
|
getTierByEmail,
|
|
2046
3912
|
getUpgradeUrl,
|
|
2047
|
-
|
|
3913
|
+
getVideoDimensions,
|
|
3914
|
+
getVideoStreamDuration,
|
|
3915
|
+
gotoSettled,
|
|
3916
|
+
hasAudioStream,
|
|
2048
3917
|
hasTrialRemaining,
|
|
3918
|
+
hasVoice,
|
|
2049
3919
|
hexToAss as hexToASS,
|
|
2050
3920
|
idSelector,
|
|
3921
|
+
internationalVoiceFor,
|
|
3922
|
+
intersectSpans,
|
|
2051
3923
|
isCanceling,
|
|
2052
3924
|
isExcludedLink,
|
|
2053
3925
|
isHonestWav,
|
|
@@ -2056,22 +3928,39 @@ export {
|
|
|
2056
3928
|
loadExistingVoiceover,
|
|
2057
3929
|
loadVoiceoverMeta,
|
|
2058
3930
|
mapTimestampsAfterCompression,
|
|
3931
|
+
mergeSpans,
|
|
2059
3932
|
normalizeUrl,
|
|
2060
3933
|
parseAIJson,
|
|
2061
3934
|
parseAIPlanResponse,
|
|
2062
3935
|
parsePartial,
|
|
3936
|
+
parseWordBoundaries,
|
|
3937
|
+
positionToAlignment,
|
|
2063
3938
|
pricingUrl,
|
|
2064
3939
|
registerFreeUser,
|
|
3940
|
+
renderCaptionsAss,
|
|
2065
3941
|
repairUnicodeEscapes,
|
|
2066
|
-
|
|
3942
|
+
resetVoiceEngine,
|
|
3943
|
+
resolveCaptionFont,
|
|
3944
|
+
resolveCaptions,
|
|
3945
|
+
resolveRate,
|
|
3946
|
+
resolveVoice2 as resolveVoice,
|
|
3947
|
+
retimeSpansInVideo,
|
|
2067
3948
|
rgbaToHex,
|
|
3949
|
+
runPooled,
|
|
2068
3950
|
salvageTruncatedJson,
|
|
2069
3951
|
shouldSkipLink,
|
|
2070
3952
|
shouldWatermark,
|
|
2071
3953
|
showPaywall,
|
|
3954
|
+
speakSymbols,
|
|
3955
|
+
speedUpMiddle,
|
|
2072
3956
|
speedUpVideo,
|
|
2073
3957
|
spliceVideos,
|
|
3958
|
+
spokenWordTimings,
|
|
3959
|
+
stripLongDashes,
|
|
2074
3960
|
synthesizeSpeech,
|
|
2075
3961
|
toTitleCase,
|
|
3962
|
+
trimEdgeSilence,
|
|
3963
|
+
voiceFor,
|
|
3964
|
+
waitForPlaceholdersGone,
|
|
2076
3965
|
windowsDrivePathToWsl as windowsPathToWSL
|
|
2077
3966
|
};
|