@spark-apps/quickpeek 1.2.3 → 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -13
- 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 +607 -43
- package/dist/index.mjs +2333 -397
- package/dist/mcp-tools.mjs +7439 -1839
- package/dist/qp.js +6929 -1709
- package/dist/voices-ufga0oOn.d.mts +647 -0
- package/dist/web.d.mts +5 -3
- package/dist/web.mjs +405 -164
- package/mcp.mjs +94 -21
- package/package.json +16 -3
- package/dist/scraping-C334VxX5.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,170 @@ 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
|
+
// Sized for the outro window: the song has 5.5s, so a 2s fade at each end
|
|
438
|
+
// left 1.5s of music anyone could actually hear. Up fast, hold, out slow.
|
|
439
|
+
musicFadeIn = 1,
|
|
440
|
+
musicFadeOut = 1.5,
|
|
441
|
+
tempo = 1,
|
|
442
|
+
musicStartSecs = 0,
|
|
443
|
+
musicDurationSecs = 0,
|
|
444
|
+
width,
|
|
445
|
+
height,
|
|
446
|
+
fps,
|
|
447
|
+
preset,
|
|
448
|
+
quality,
|
|
449
|
+
fadeIn,
|
|
450
|
+
fadeOut,
|
|
451
|
+
videoDuration,
|
|
452
|
+
audioBitrate = "192k",
|
|
453
|
+
contrast = 0,
|
|
454
|
+
trimStart = 0,
|
|
455
|
+
userTier = "free",
|
|
456
|
+
endsOnCreditsCard = false
|
|
457
|
+
} = opts;
|
|
458
|
+
const crf = Math.round(28 - quality * 10);
|
|
459
|
+
const encoder = await resolveEncoder({
|
|
460
|
+
crf,
|
|
461
|
+
preset,
|
|
462
|
+
quality: Math.max(16, crf - 4),
|
|
463
|
+
allowHardware: opts.hwaccel !== "off",
|
|
464
|
+
// The race is only meaningful at the size actually being rendered: the
|
|
465
|
+
// GPU's per-frame upload is a fixed cost that a draft's ninth of the
|
|
466
|
+
// pixels cannot amortise.
|
|
467
|
+
width,
|
|
468
|
+
height
|
|
469
|
+
});
|
|
470
|
+
const args = ["-y", ...filterThreadArgs(), ...encoder.input];
|
|
471
|
+
if (trimStart > 0) {
|
|
472
|
+
args.push("-ss", String(trimStart));
|
|
473
|
+
}
|
|
474
|
+
args.push("-i", videoPath);
|
|
475
|
+
const effectiveDuration = videoDuration - trimStart;
|
|
476
|
+
const voiceoverDuration = opts.mainContentDuration ?? effectiveDuration;
|
|
477
|
+
if (voiceoverPath) args.push("-i", voiceoverPath);
|
|
478
|
+
if (musicPath) args.push("-i", musicPath);
|
|
479
|
+
const vf = [`scale=${width}:${height}`, `fps=${fps}`];
|
|
480
|
+
if (contrast > 0) {
|
|
481
|
+
const ffmpegContrast = 1 + contrast;
|
|
482
|
+
vf.push(`eq=contrast=${ffmpegContrast.toFixed(2)}`);
|
|
483
|
+
}
|
|
484
|
+
const watermarkFilters = buildWatermarkFilters({
|
|
485
|
+
tier: userTier,
|
|
486
|
+
videoDuration: effectiveDuration,
|
|
487
|
+
endsOnCreditsCard
|
|
488
|
+
});
|
|
489
|
+
if (watermarkFilters) {
|
|
490
|
+
vf.push(watermarkFilters);
|
|
491
|
+
}
|
|
492
|
+
if (assPath) {
|
|
220
493
|
const assPathForFilter = toWSLPath(assPath).replace(/:/g, "\\:");
|
|
221
494
|
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
|
-
|
|
495
|
+
}
|
|
496
|
+
if (fadeIn > 0) vf.push(`fade=t=in:st=0:d=${fadeIn}:c=black`);
|
|
497
|
+
if (fadeOut > 0 && !endsOnCreditsCard) {
|
|
498
|
+
const videoFadeOutStart = Math.max(0, effectiveDuration - fadeOut);
|
|
499
|
+
vf.push(`fade=t=out:st=${videoFadeOutStart}:d=${fadeOut}:c=black`);
|
|
500
|
+
}
|
|
501
|
+
const clipAudioLabel = opts.hasClipAudio ? "[0:a]" : "";
|
|
502
|
+
const hasVoiceover = !!voiceoverPath;
|
|
503
|
+
const hasMusic = !!musicPath;
|
|
504
|
+
const bedStart = Math.max(0, Math.min(musicStartSecs, Math.max(0, effectiveDuration - 0.5)));
|
|
505
|
+
const bedLength = Math.max(0.5, effectiveDuration - bedStart);
|
|
506
|
+
const bedDelay = bedStart > 0 ? `adelay=${Math.round(bedStart * 1e3)}|${Math.round(bedStart * 1e3)},` : "";
|
|
507
|
+
const bedTempo = tempo > 1 ? 1 / tempo : 1;
|
|
508
|
+
const bedSlow = bedTempo === 1 ? "" : `atempo=${bedTempo.toFixed(6)},`;
|
|
509
|
+
const bedSource = bedLength * bedTempo;
|
|
510
|
+
const bedSeek = musicDurationSecs > bedSource + 0.05 ? musicDurationSecs - bedSource : 0;
|
|
511
|
+
const bedTrimIn = bedSeek > 0 ? `atrim=start=${bedSeek.toFixed(3)},asetpts=PTS-STARTPTS,` : "";
|
|
512
|
+
const bedFadeOut = bedSeek > 0 ? 0 : musicFadeOut * tempo;
|
|
513
|
+
const bedLoop = bedSeek > 0 ? "" : "aloop=loop=-1:size=2e+09,";
|
|
514
|
+
const bedFadeIn = musicFadeIn * tempo;
|
|
515
|
+
const musicFadeOutStart = Math.max(0, bedLength - bedFadeOut);
|
|
516
|
+
const bedFadeOutFilter = bedFadeOut > 0 ? `afade=t=out:st=${musicFadeOutStart}:d=${bedFadeOut},` : "";
|
|
517
|
+
const vfStr = [...vf, ...encoder.filter ? [encoder.filter] : []].join(",");
|
|
518
|
+
if (hasVoiceover && hasMusic) {
|
|
519
|
+
const mixInputs = clipAudioLabel ? 3 : 2;
|
|
520
|
+
const complexFilter = [
|
|
521
|
+
// Voiceover: fade in only (no fade out - stays clear until end). Split,
|
|
522
|
+
// because one copy is mixed and the other is only listened to, by the
|
|
523
|
+
// compressor that pulls the bed down under it.
|
|
524
|
+
`[1:a]adelay=${VOICE_LEAD_MS}|${VOICE_LEAD_MS},apad=whole_dur=${voiceoverDuration},asplit=2[voice][voicekey0]`,
|
|
525
|
+
// The detector's copy runs the length of the PICTURE, not the length of
|
|
526
|
+
// the narration. sidechaincompress is a framesync filter and ends at its
|
|
527
|
+
// shorter input, so a key that stops when the voice does takes the bed
|
|
528
|
+
// with it - and the stretch it would have taken is exactly the closing
|
|
529
|
+
// card, which is the one place the bed is the only thing playing.
|
|
530
|
+
`[voicekey0]apad=whole_dur=${effectiveDuration}[voicekey]`,
|
|
531
|
+
// Music: normalised to a known level, trimmed, then faded.
|
|
532
|
+
`[2:a]${bedTrimIn}${bedLoop}${bedSlow}atrim=0:${bedLength},loudnorm=I=${musicTargetLufs}:TP=-9:LRA=11,volume=${musicVolume},afade=t=in:st=0:d=${bedFadeIn},${bedFadeOutFilter}${bedDelay}apad=whole_dur=${effectiveDuration}[bed]`,
|
|
533
|
+
`[bed][voicekey]${DUCK}[music]`,
|
|
534
|
+
// normalize=0, because amix's default divides every input by the number
|
|
535
|
+
// of them: adding the bed used to quietly take 6 dB off the narration,
|
|
536
|
+
// and the master loudnorm then put it back by turning everything up,
|
|
537
|
+
// bed included. Levels set deliberately upstream should survive the mix.
|
|
538
|
+
//
|
|
539
|
+
// Normalise the MIX and hard-limit its true peak - ported from
|
|
540
|
+
// VidLet, where single-pass loudnorm alone measured +0.9 dBTP on
|
|
541
|
+
// sharp TTS consonants. -14 LUFS is the YouTube/IG/TikTok playback
|
|
542
|
+
// target, so the platform leaves the audio untouched.
|
|
543
|
+
`[voice][music]${clipAudioLabel}amix=inputs=${mixInputs}:duration=longest:normalize=0,${MASTER_CHAIN}[aout]`
|
|
544
|
+
].join(";");
|
|
545
|
+
args.push("-vf", vfStr);
|
|
546
|
+
args.push("-filter_complex", complexFilter, "-map", "0:v", "-map", "[aout]");
|
|
547
|
+
args.push(...encoder.output);
|
|
548
|
+
args.push("-c:a", "aac", "-b:a", audioBitrate);
|
|
549
|
+
} else if (hasVoiceover) {
|
|
550
|
+
const af = `adelay=${VOICE_LEAD_MS}|${VOICE_LEAD_MS},apad=whole_dur=${voiceoverDuration},${MASTER_CHAIN}`;
|
|
551
|
+
args.push("-vf", vfStr);
|
|
552
|
+
args.push("-map", "0:v", "-map", "1:a", "-af", af);
|
|
553
|
+
args.push(...encoder.output);
|
|
554
|
+
args.push("-c:a", "aac", "-b:a", audioBitrate);
|
|
555
|
+
} else if (hasMusic) {
|
|
556
|
+
const musicFilter = `${bedTrimIn}${bedLoop}${bedSlow}atrim=0:${bedLength},volume=${musicVolume},afade=t=in:st=0:d=${bedFadeIn},${bedFadeOutFilter}${bedDelay}apad=whole_dur=${effectiveDuration},${MASTER_CHAIN}`;
|
|
557
|
+
args.push("-vf", vfStr);
|
|
558
|
+
args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
|
|
559
|
+
args.push(...encoder.output);
|
|
560
|
+
args.push("-c:a", "aac", "-b:a", "192k");
|
|
561
|
+
} else {
|
|
562
|
+
args.push("-vf", vfStr);
|
|
563
|
+
args.push(...encoder.output);
|
|
564
|
+
}
|
|
565
|
+
args.push("-movflags", "+faststart");
|
|
566
|
+
args.push(outputPath);
|
|
567
|
+
await runFFmpeg(args, "ffmpeg compose failed");
|
|
267
568
|
}
|
|
569
|
+
var VOICE_LEAD_MS, MUSIC_BED_LUFS, DUCK;
|
|
268
570
|
var init_compose = __esm({
|
|
269
571
|
"src/lib/ffmpeg/compose.ts"() {
|
|
270
572
|
"use strict";
|
|
271
573
|
init_watermark();
|
|
574
|
+
init_hwaccel();
|
|
272
575
|
init_spawn();
|
|
576
|
+
VOICE_LEAD_MS = 200;
|
|
577
|
+
MUSIC_BED_LUFS = -34;
|
|
578
|
+
DUCK = "sidechaincompress=threshold=0.05:ratio=8:attack=20:release=420:level_sc=4:makeup=1";
|
|
273
579
|
}
|
|
274
580
|
});
|
|
275
581
|
|
|
@@ -315,8 +621,130 @@ var init_compress = __esm({
|
|
|
315
621
|
}
|
|
316
622
|
});
|
|
317
623
|
|
|
624
|
+
// src/lib/ffmpeg/gif.ts
|
|
625
|
+
import { unlink } from "fs/promises";
|
|
626
|
+
import { tmpdir } from "os";
|
|
627
|
+
import { join } from "path";
|
|
628
|
+
function buildPaletteArgs(input, palettePath, fps, width, statsMode) {
|
|
629
|
+
return [
|
|
630
|
+
"-y",
|
|
631
|
+
"-i",
|
|
632
|
+
input,
|
|
633
|
+
"-vf",
|
|
634
|
+
`fps=${fps},scale=${width}:-1:flags=lanczos,palettegen=stats_mode=${statsMode}`,
|
|
635
|
+
palettePath
|
|
636
|
+
];
|
|
637
|
+
}
|
|
638
|
+
function buildGifArgs(input, palettePath, output, fps, width, dither) {
|
|
639
|
+
return [
|
|
640
|
+
"-y",
|
|
641
|
+
"-i",
|
|
642
|
+
input,
|
|
643
|
+
"-i",
|
|
644
|
+
palettePath,
|
|
645
|
+
"-lavfi",
|
|
646
|
+
`fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=${dither}`,
|
|
647
|
+
output
|
|
648
|
+
];
|
|
649
|
+
}
|
|
650
|
+
async function convertToGif(options) {
|
|
651
|
+
const { input, output } = options;
|
|
652
|
+
const fps = options.fps ?? GIF_DEFAULTS.fps;
|
|
653
|
+
const width = options.width ?? GIF_DEFAULTS.width;
|
|
654
|
+
const dither = options.dither ?? GIF_DEFAULTS.dither;
|
|
655
|
+
const statsMode = options.statsMode ?? GIF_DEFAULTS.statsMode;
|
|
656
|
+
const palettePath = join(tmpdir(), `quickpeek_palette_${Date.now()}.png`);
|
|
657
|
+
try {
|
|
658
|
+
await runFFmpeg(buildPaletteArgs(input, palettePath, fps, width, statsMode), "GIF palette generation failed");
|
|
659
|
+
await runFFmpeg(buildGifArgs(input, palettePath, output, fps, width, dither), "GIF conversion failed");
|
|
660
|
+
return output;
|
|
661
|
+
} finally {
|
|
662
|
+
try {
|
|
663
|
+
await unlink(palettePath);
|
|
664
|
+
} catch {
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
var GIF_DEFAULTS;
|
|
669
|
+
var init_gif = __esm({
|
|
670
|
+
"src/lib/ffmpeg/gif.ts"() {
|
|
671
|
+
"use strict";
|
|
672
|
+
init_spawn();
|
|
673
|
+
GIF_DEFAULTS = { fps: 15, width: 480, dither: "sierra2_4a", statsMode: "full" };
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
|
|
318
677
|
// src/lib/ffmpeg/media.ts
|
|
678
|
+
import { statSync } from "fs";
|
|
679
|
+
function cacheKey(filePath) {
|
|
680
|
+
try {
|
|
681
|
+
const { size, mtimeMs } = statSync(filePath);
|
|
682
|
+
return `${filePath}:${size}:${mtimeMs}`;
|
|
683
|
+
} catch {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function getVideoDimensions(filePath) {
|
|
688
|
+
return new Promise((resolve3, reject) => {
|
|
689
|
+
const args = [
|
|
690
|
+
"-v",
|
|
691
|
+
"error",
|
|
692
|
+
"-select_streams",
|
|
693
|
+
"v:0",
|
|
694
|
+
"-show_entries",
|
|
695
|
+
"stream=width,height",
|
|
696
|
+
"-of",
|
|
697
|
+
"csv=p=0",
|
|
698
|
+
filePath
|
|
699
|
+
];
|
|
700
|
+
const proc = spawnFFmpeg("ffprobe", args);
|
|
701
|
+
let stdout = "";
|
|
702
|
+
let stderr = "";
|
|
703
|
+
proc.stdout.on("data", (d) => {
|
|
704
|
+
stdout += d;
|
|
705
|
+
});
|
|
706
|
+
proc.stderr.on("data", (d) => {
|
|
707
|
+
stderr += d;
|
|
708
|
+
});
|
|
709
|
+
proc.on("close", (code) => {
|
|
710
|
+
if (code !== 0) {
|
|
711
|
+
reject(new Error(`ffprobe failed: ${stderr}`));
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const [w, h] = stdout.trim().split(",").map(Number);
|
|
715
|
+
if (!w || !h) {
|
|
716
|
+
reject(new Error(`ffprobe returned no dimensions for ${filePath}`));
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
resolve3({ width: w, height: h });
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
function hasAudioStream(filePath) {
|
|
724
|
+
return new Promise((resolve3) => {
|
|
725
|
+
const proc = spawnFFmpeg("ffprobe", [
|
|
726
|
+
"-v",
|
|
727
|
+
"error",
|
|
728
|
+
"-select_streams",
|
|
729
|
+
"a:0",
|
|
730
|
+
"-show_entries",
|
|
731
|
+
"stream=codec_type",
|
|
732
|
+
"-of",
|
|
733
|
+
"csv=p=0",
|
|
734
|
+
filePath
|
|
735
|
+
]);
|
|
736
|
+
let out = "";
|
|
737
|
+
proc.stdout.on("data", (d) => {
|
|
738
|
+
out += d;
|
|
739
|
+
});
|
|
740
|
+
proc.on("close", () => resolve3(out.trim().length > 0));
|
|
741
|
+
proc.on("error", () => resolve3(false));
|
|
742
|
+
});
|
|
743
|
+
}
|
|
319
744
|
function getMediaDuration(filePath) {
|
|
745
|
+
const key = cacheKey(filePath);
|
|
746
|
+
const cached = key === null ? void 0 : durationCache.get(key);
|
|
747
|
+
if (cached !== void 0) return Promise.resolve(cached);
|
|
320
748
|
return new Promise((resolve3, reject) => {
|
|
321
749
|
const args = [
|
|
322
750
|
"-v",
|
|
@@ -341,8 +769,10 @@ function getMediaDuration(filePath) {
|
|
|
341
769
|
reject(new Error(`ffprobe failed: ${stderr}`));
|
|
342
770
|
return;
|
|
343
771
|
}
|
|
344
|
-
const
|
|
345
|
-
|
|
772
|
+
const ms = Math.ceil(parseFloat(stdout.trim()) * 1e3);
|
|
773
|
+
const freshKey = cacheKey(filePath);
|
|
774
|
+
if (freshKey !== null && Number.isFinite(ms)) durationCache.set(freshKey, ms);
|
|
775
|
+
resolve3(ms);
|
|
346
776
|
});
|
|
347
777
|
proc.on("error", reject);
|
|
348
778
|
});
|
|
@@ -381,6 +811,12 @@ function buildConcatArgs(listPath, output) {
|
|
|
381
811
|
function concatMedia(listPath, output) {
|
|
382
812
|
return runFFmpeg(buildConcatArgs(listPath, output), "ffmpeg concat failed");
|
|
383
813
|
}
|
|
814
|
+
async function trimEdgeSilence(filePath) {
|
|
815
|
+
const trimmed = filePath.replace(/\.mp3$/i, "-trim.mp3");
|
|
816
|
+
await runFFmpeg(["-y", "-i", filePath, "-af", TRIM_EDGE_SILENCE, trimmed], "ffmpeg silence trim failed");
|
|
817
|
+
const { rename } = await import("fs/promises");
|
|
818
|
+
await rename(trimmed, filePath);
|
|
819
|
+
}
|
|
384
820
|
function generateSilence(outputPath, durationMs) {
|
|
385
821
|
return runFFmpeg(
|
|
386
822
|
[
|
|
@@ -411,7 +847,23 @@ function speedUpVideo(opts) {
|
|
|
411
847
|
remaining /= tempo;
|
|
412
848
|
}
|
|
413
849
|
return runFFmpeg(
|
|
414
|
-
|
|
850
|
+
// faststart for the same reason as compose: this pass can be the last
|
|
851
|
+
// thing to touch the delivered file, and a moov atom written after the
|
|
852
|
+
// payload makes it unplayable in a browser preview.
|
|
853
|
+
[
|
|
854
|
+
"-y",
|
|
855
|
+
"-i",
|
|
856
|
+
inputPath,
|
|
857
|
+
"-vf",
|
|
858
|
+
videoFilter,
|
|
859
|
+
"-af",
|
|
860
|
+
audioFilters.join(","),
|
|
861
|
+
"-preset",
|
|
862
|
+
preset,
|
|
863
|
+
"-movflags",
|
|
864
|
+
"+faststart",
|
|
865
|
+
outputPath
|
|
866
|
+
],
|
|
415
867
|
"ffmpeg speedup failed"
|
|
416
868
|
);
|
|
417
869
|
}
|
|
@@ -434,43 +886,581 @@ function concatVideoWithOutro(opts) {
|
|
|
434
886
|
"-y",
|
|
435
887
|
"-i",
|
|
436
888
|
inputPath,
|
|
437
|
-
"-i",
|
|
438
|
-
outroPath,
|
|
889
|
+
"-i",
|
|
890
|
+
outroPath,
|
|
891
|
+
"-filter_complex",
|
|
892
|
+
filterComplex,
|
|
893
|
+
"-map",
|
|
894
|
+
"[outv]",
|
|
895
|
+
"-c:v",
|
|
896
|
+
"libx264",
|
|
897
|
+
"-preset",
|
|
898
|
+
preset,
|
|
899
|
+
"-crf",
|
|
900
|
+
"18",
|
|
901
|
+
"-pix_fmt",
|
|
902
|
+
"yuv420p",
|
|
903
|
+
"-an",
|
|
904
|
+
outputPath
|
|
905
|
+
],
|
|
906
|
+
"ffmpeg concat video failed"
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
function concatVideoWithFullFrameOutro(opts) {
|
|
910
|
+
const { inputPath, outroPath, outputPath, width, height, preset } = opts;
|
|
911
|
+
const filterComplex = [
|
|
912
|
+
`[0:v]scale=${width}:${height},setsar=1,fps=30[v0]`,
|
|
913
|
+
`[1:v]scale=${width}:${height},setsar=1,fps=30[v1]`,
|
|
914
|
+
"[v0][v1]concat=n=2:v=1:a=0[outv]"
|
|
915
|
+
].join(";");
|
|
916
|
+
return runFFmpeg(
|
|
917
|
+
[
|
|
918
|
+
"-y",
|
|
919
|
+
"-i",
|
|
920
|
+
inputPath,
|
|
921
|
+
"-i",
|
|
922
|
+
outroPath,
|
|
923
|
+
"-filter_complex",
|
|
924
|
+
filterComplex,
|
|
925
|
+
"-map",
|
|
926
|
+
"[outv]",
|
|
927
|
+
"-c:v",
|
|
928
|
+
"libx264",
|
|
929
|
+
"-preset",
|
|
930
|
+
preset,
|
|
931
|
+
"-crf",
|
|
932
|
+
"18",
|
|
933
|
+
"-pix_fmt",
|
|
934
|
+
"yuv420p",
|
|
935
|
+
"-an",
|
|
936
|
+
outputPath
|
|
937
|
+
],
|
|
938
|
+
"ffmpeg full-frame outro concat failed"
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
var durationCache, TRIM_EDGE_SILENCE;
|
|
942
|
+
var init_media = __esm({
|
|
943
|
+
"src/lib/ffmpeg/media.ts"() {
|
|
944
|
+
"use strict";
|
|
945
|
+
init_spawn();
|
|
946
|
+
durationCache = /* @__PURE__ */ new Map();
|
|
947
|
+
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";
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
// src/lib/ffmpeg/silence.ts
|
|
952
|
+
function getVideoStreamDuration(inputPath) {
|
|
953
|
+
return new Promise((resolve3) => {
|
|
954
|
+
const proc = spawnFFmpeg("ffprobe", [
|
|
955
|
+
"-v",
|
|
956
|
+
"error",
|
|
957
|
+
"-select_streams",
|
|
958
|
+
"v:0",
|
|
959
|
+
"-show_entries",
|
|
960
|
+
"stream=duration",
|
|
961
|
+
"-of",
|
|
962
|
+
"csv=p=0",
|
|
963
|
+
inputPath
|
|
964
|
+
]);
|
|
965
|
+
proc.stderr?.resume();
|
|
966
|
+
let out = "";
|
|
967
|
+
proc.stdout.on("data", (d) => {
|
|
968
|
+
out += d;
|
|
969
|
+
});
|
|
970
|
+
proc.on("error", () => resolve3(0));
|
|
971
|
+
proc.on("close", () => resolve3(Number.parseFloat(out.trim()) || 0));
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
function detectSilences(inputPath, minDurSecs) {
|
|
975
|
+
return new Promise((resolve3) => {
|
|
976
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
977
|
+
"-hide_banner",
|
|
978
|
+
"-nostats",
|
|
979
|
+
// Audio-only analysis: without -vn ffmpeg's default stream selection
|
|
980
|
+
// decodes the video too, for a filter that cannot see it.
|
|
981
|
+
"-vn",
|
|
982
|
+
"-i",
|
|
983
|
+
inputPath,
|
|
984
|
+
"-af",
|
|
985
|
+
`silencedetect=noise=-40dB:d=${minDurSecs}`,
|
|
986
|
+
"-f",
|
|
987
|
+
"null",
|
|
988
|
+
"-"
|
|
989
|
+
]);
|
|
990
|
+
let err = "";
|
|
991
|
+
proc.stderr.on("data", (d) => {
|
|
992
|
+
err += d;
|
|
993
|
+
});
|
|
994
|
+
proc.on("error", () => resolve3([]));
|
|
995
|
+
proc.on("close", () => {
|
|
996
|
+
const spans = [];
|
|
997
|
+
let start = null;
|
|
998
|
+
for (const line of err.split("\n")) {
|
|
999
|
+
const s = line.match(/silence_start:\s*(-?[\d.]+)/);
|
|
1000
|
+
if (s?.[1]) start = Math.max(0, Number.parseFloat(s[1]));
|
|
1001
|
+
const e = line.match(/silence_end:\s*([\d.]+)/);
|
|
1002
|
+
if (e?.[1] && start !== null) {
|
|
1003
|
+
const from = start + SPEECH_GUARD_SECS;
|
|
1004
|
+
const to = Number.parseFloat(e[1]) - SPEECH_GUARD_SECS;
|
|
1005
|
+
if (to > from) spans.push({ start: from, end: to });
|
|
1006
|
+
start = null;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
resolve3(spans);
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
function detectFrozenSpans(inputPath, minDurSecs) {
|
|
1014
|
+
return new Promise((resolve3) => {
|
|
1015
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
1016
|
+
"-hide_banner",
|
|
1017
|
+
"-nostats",
|
|
1018
|
+
// The mirror of detectSilences: a video-only filter, so skip the audio.
|
|
1019
|
+
"-an",
|
|
1020
|
+
"-sn",
|
|
1021
|
+
"-i",
|
|
1022
|
+
inputPath,
|
|
1023
|
+
"-vf",
|
|
1024
|
+
// A generous noise floor: video is lossy, so a "still" frame still
|
|
1025
|
+
// differs from the last one by a little.
|
|
1026
|
+
`freezedetect=n=-55dB:d=${minDurSecs}`,
|
|
1027
|
+
"-f",
|
|
1028
|
+
"null",
|
|
1029
|
+
"-"
|
|
1030
|
+
]);
|
|
1031
|
+
let err = "";
|
|
1032
|
+
proc.stderr.on("data", (d) => {
|
|
1033
|
+
err += d;
|
|
1034
|
+
});
|
|
1035
|
+
proc.on("error", () => resolve3([]));
|
|
1036
|
+
proc.on("close", () => {
|
|
1037
|
+
const spans = [];
|
|
1038
|
+
let start = null;
|
|
1039
|
+
for (const line of err.split("\n")) {
|
|
1040
|
+
const s = line.match(/freeze_start:\s*(-?[\d.]+)/);
|
|
1041
|
+
if (s?.[1]) start = Math.max(0, Number.parseFloat(s[1]));
|
|
1042
|
+
const e = line.match(/freeze_end:\s*([\d.]+)/);
|
|
1043
|
+
if (e?.[1] && start !== null) {
|
|
1044
|
+
spans.push({ start, end: Number.parseFloat(e[1]) });
|
|
1045
|
+
start = null;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
resolve3(spans);
|
|
1049
|
+
});
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
function detectBlackSpans(inputPath, minDurSecs) {
|
|
1053
|
+
return new Promise((resolve3) => {
|
|
1054
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
1055
|
+
"-hide_banner",
|
|
1056
|
+
"-nostats",
|
|
1057
|
+
"-an",
|
|
1058
|
+
"-sn",
|
|
1059
|
+
"-i",
|
|
1060
|
+
inputPath,
|
|
1061
|
+
"-vf",
|
|
1062
|
+
// Strict on purpose. Loosening these to catch a lingering spinner
|
|
1063
|
+
// (pic_th 0.96, pix_th 0.14) cut 37 seconds out of a 95 second
|
|
1064
|
+
// recording: it was matching real dark-themed pages, and 80s of
|
|
1065
|
+
// narration was left with 58s of picture to play over. A missed second
|
|
1066
|
+
// of loading screen is a blemish; a false positive desyncs the video.
|
|
1067
|
+
`blackdetect=d=${minDurSecs}:pic_th=0.98:pix_th=0.10`,
|
|
1068
|
+
"-f",
|
|
1069
|
+
"null",
|
|
1070
|
+
"-"
|
|
1071
|
+
]);
|
|
1072
|
+
let err = "";
|
|
1073
|
+
proc.stderr.on("data", (d) => {
|
|
1074
|
+
err += d;
|
|
1075
|
+
});
|
|
1076
|
+
proc.on("error", () => resolve3([]));
|
|
1077
|
+
proc.on("close", () => {
|
|
1078
|
+
const spans = [];
|
|
1079
|
+
for (const line of err.split("\n")) {
|
|
1080
|
+
const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
|
|
1081
|
+
if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
|
|
1082
|
+
}
|
|
1083
|
+
resolve3(spans);
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
function detectWhiteSpans(inputPath, minDurSecs) {
|
|
1088
|
+
return new Promise((resolve3) => {
|
|
1089
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
1090
|
+
"-hide_banner",
|
|
1091
|
+
"-nostats",
|
|
1092
|
+
"-an",
|
|
1093
|
+
"-sn",
|
|
1094
|
+
"-i",
|
|
1095
|
+
inputPath,
|
|
1096
|
+
"-vf",
|
|
1097
|
+
`negate,blackdetect=d=${minDurSecs}:pic_th=0.98:pix_th=0.10`,
|
|
1098
|
+
"-f",
|
|
1099
|
+
"null",
|
|
1100
|
+
"-"
|
|
1101
|
+
]);
|
|
1102
|
+
let err = "";
|
|
1103
|
+
proc.stderr.on("data", (d) => {
|
|
1104
|
+
err += d;
|
|
1105
|
+
});
|
|
1106
|
+
proc.on("error", () => resolve3([]));
|
|
1107
|
+
proc.on("close", () => {
|
|
1108
|
+
const spans = [];
|
|
1109
|
+
for (const line of err.split("\n")) {
|
|
1110
|
+
const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
|
|
1111
|
+
if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
|
|
1112
|
+
}
|
|
1113
|
+
resolve3(spans);
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
function intersectSpans(a, b) {
|
|
1118
|
+
const out = [];
|
|
1119
|
+
for (const x of a) {
|
|
1120
|
+
for (const y of b) {
|
|
1121
|
+
const start = Math.max(x.start, y.start);
|
|
1122
|
+
const end = Math.min(x.end, y.end);
|
|
1123
|
+
if (end > start) out.push({ start, end });
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
return out.sort((p, q) => p.start - q.start);
|
|
1127
|
+
}
|
|
1128
|
+
function mergeSpans(spans) {
|
|
1129
|
+
const sorted = [...spans].sort((a, b) => a.start - b.start);
|
|
1130
|
+
const out = [];
|
|
1131
|
+
for (const span of sorted) {
|
|
1132
|
+
const last = out[out.length - 1];
|
|
1133
|
+
if (last && span.start <= last.end + 0.01) {
|
|
1134
|
+
last.end = Math.max(last.end, span.end);
|
|
1135
|
+
if (span.cap !== void 0) last.cap = Math.min(last.cap ?? span.cap, span.cap);
|
|
1136
|
+
} else out.push({ ...span });
|
|
1137
|
+
}
|
|
1138
|
+
return out;
|
|
1139
|
+
}
|
|
1140
|
+
function atempoChain(factor) {
|
|
1141
|
+
const steps = [];
|
|
1142
|
+
let remaining = factor;
|
|
1143
|
+
while (remaining > 2) {
|
|
1144
|
+
steps.push(2);
|
|
1145
|
+
remaining /= 2;
|
|
1146
|
+
}
|
|
1147
|
+
if (remaining > 1.001) steps.push(remaining);
|
|
1148
|
+
return steps.map((s) => `atempo=${s.toFixed(4)}`).join(",");
|
|
1149
|
+
}
|
|
1150
|
+
function buildCompressionFilter(spans, totalSecs, maxSecs, protectAfterSecs, protectBeforeSecs) {
|
|
1151
|
+
const limit = protectAfterSecs ?? totalSecs;
|
|
1152
|
+
const floor = Math.max(0, protectBeforeSecs ?? 0);
|
|
1153
|
+
const usable = spans.map((s) => ({
|
|
1154
|
+
start: Math.max(floor, s.start),
|
|
1155
|
+
end: Math.min(totalSecs, Math.min(s.end, limit)),
|
|
1156
|
+
cap: Math.min(s.cap ?? maxSecs, maxSecs)
|
|
1157
|
+
})).filter((s) => s.start < limit && s.end - s.start > s.cap + 0.05).sort((a, b) => a.start - b.start);
|
|
1158
|
+
if (usable.length === 0) return null;
|
|
1159
|
+
const parts = [];
|
|
1160
|
+
const labels = [];
|
|
1161
|
+
let cursor = 0;
|
|
1162
|
+
let n = 0;
|
|
1163
|
+
const keep = (from, to) => {
|
|
1164
|
+
if (to - from < 0.04) return;
|
|
1165
|
+
parts.push(`[0:v]trim=${from.toFixed(3)}:${to.toFixed(3)},setpts=PTS-STARTPTS[v${n}]`);
|
|
1166
|
+
parts.push(`[0:a]atrim=${from.toFixed(3)}:${to.toFixed(3)},asetpts=PTS-STARTPTS[a${n}]`);
|
|
1167
|
+
labels.push(`[v${n}][a${n}]`);
|
|
1168
|
+
n++;
|
|
1169
|
+
};
|
|
1170
|
+
for (const span of usable) {
|
|
1171
|
+
keep(cursor, span.start);
|
|
1172
|
+
const factor = (span.end - span.start) / span.cap;
|
|
1173
|
+
const tempo = atempoChain(factor);
|
|
1174
|
+
parts.push(
|
|
1175
|
+
`[0:v]trim=${span.start.toFixed(3)}:${span.end.toFixed(3)},setpts=(PTS-STARTPTS)/${factor.toFixed(4)}[v${n}]`
|
|
1176
|
+
);
|
|
1177
|
+
parts.push(
|
|
1178
|
+
`[0:a]atrim=${span.start.toFixed(3)}:${span.end.toFixed(3)},asetpts=PTS-STARTPTS${tempo ? `,${tempo}` : ""}[a${n}]`
|
|
1179
|
+
);
|
|
1180
|
+
labels.push(`[v${n}][a${n}]`);
|
|
1181
|
+
n++;
|
|
1182
|
+
cursor = span.end;
|
|
1183
|
+
}
|
|
1184
|
+
keep(cursor, totalSecs);
|
|
1185
|
+
if (n < 2) return null;
|
|
1186
|
+
return `${parts.join(";")};${labels.join("")}concat=n=${n}:v=1:a=1[outv][outa]`;
|
|
1187
|
+
}
|
|
1188
|
+
async function passEncoder(preset) {
|
|
1189
|
+
const encoder = await resolveEncoder({ crf: 18, preset, quality: 20 });
|
|
1190
|
+
return {
|
|
1191
|
+
input: encoder.input,
|
|
1192
|
+
output: encoder.output,
|
|
1193
|
+
filterSuffix: encoder.filter ? `;[outv]${encoder.filter}[outvhw]` : "",
|
|
1194
|
+
videoLabel: encoder.filter ? "[outvhw]" : "[outv]"
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
|
|
1198
|
+
const keep = mergeSpans(spans.map((s) => ({ ...s }))).filter((s) => s.end - s.start > 0.15);
|
|
1199
|
+
if (keep.length === 0) return false;
|
|
1200
|
+
const drop = keep.map((s) => `between(t,${s.start.toFixed(3)},${s.end.toFixed(3)})`).join("+");
|
|
1201
|
+
const filter = `[0:v]select='not(${drop})',setpts=N/FRAME_RATE/TB[outv]`;
|
|
1202
|
+
const enc = await passEncoder(preset);
|
|
1203
|
+
return new Promise((resolve3) => {
|
|
1204
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
1205
|
+
"-y",
|
|
1206
|
+
...filterThreadArgs(),
|
|
1207
|
+
...enc.input,
|
|
1208
|
+
"-i",
|
|
1209
|
+
inputPath,
|
|
1210
|
+
"-filter_complex",
|
|
1211
|
+
enc.filterSuffix ? filter + enc.filterSuffix : filter,
|
|
1212
|
+
"-map",
|
|
1213
|
+
enc.videoLabel,
|
|
1214
|
+
"-an",
|
|
1215
|
+
...enc.output,
|
|
1216
|
+
"-movflags",
|
|
1217
|
+
"+faststart",
|
|
1218
|
+
outputPath
|
|
1219
|
+
]);
|
|
1220
|
+
proc.stderr.resume();
|
|
1221
|
+
proc.on("error", () => resolve3(false));
|
|
1222
|
+
proc.on("close", (code) => resolve3(code === 0));
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
function buildRetimeFilter(spans, stepBounds, speed, fps) {
|
|
1226
|
+
const all = mergeSpans(spans.map((s) => ({ ...s })));
|
|
1227
|
+
const totalSecs = stepBounds[stepBounds.length - 1]?.to ?? 0;
|
|
1228
|
+
const frame = 1 / Math.max(1, fps);
|
|
1229
|
+
const parts = [];
|
|
1230
|
+
const labels = [];
|
|
1231
|
+
let n = 0;
|
|
1232
|
+
let reclaimed = 0;
|
|
1233
|
+
let pendingFrom = null;
|
|
1234
|
+
const flush = (to) => {
|
|
1235
|
+
if (pendingFrom === null) return;
|
|
1236
|
+
const from = pendingFrom;
|
|
1237
|
+
pendingFrom = null;
|
|
1238
|
+
if (to - from < 0.04) return;
|
|
1239
|
+
parts.push(`[0:v]trim=${from.toFixed(3)}:${to.toFixed(3)},setpts=PTS-STARTPTS,fps=${fps}[v${n}]`);
|
|
1240
|
+
labels.push(`[v${n}]`);
|
|
1241
|
+
n++;
|
|
1242
|
+
};
|
|
1243
|
+
const segment = (from, to, factor) => {
|
|
1244
|
+
if (to - from < 0.04) return;
|
|
1245
|
+
const pts = factor === 1 ? "PTS-STARTPTS" : `(PTS-STARTPTS)*${factor.toFixed(5)}`;
|
|
1246
|
+
parts.push(`[0:v]trim=${from.toFixed(3)}:${to.toFixed(3)},setpts=${pts},fps=${fps}[v${n}]`);
|
|
1247
|
+
labels.push(`[v${n}]`);
|
|
1248
|
+
n++;
|
|
1249
|
+
};
|
|
1250
|
+
const freeze = (from, to, sourceAt) => {
|
|
1251
|
+
const dur = to - from;
|
|
1252
|
+
if (dur < 0.04) return;
|
|
1253
|
+
const at = sourceAt + frame <= totalSecs ? sourceAt : Math.max(0, from - frame);
|
|
1254
|
+
parts.push(
|
|
1255
|
+
`[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}]`
|
|
1256
|
+
);
|
|
1257
|
+
labels.push(`[v${n}]`);
|
|
1258
|
+
n++;
|
|
1259
|
+
};
|
|
1260
|
+
for (const step of stepBounds) {
|
|
1261
|
+
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);
|
|
1262
|
+
if (local.length === 0) {
|
|
1263
|
+
if (pendingFrom === null) pendingFrom = step.from;
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
const window = step.to - step.from;
|
|
1267
|
+
const emptyTotal = local.reduce((a, s) => a + (s.end - s.start), 0);
|
|
1268
|
+
const keepTotal = window - emptyTotal;
|
|
1269
|
+
let recovered = emptyTotal * (1 - 1 / speed);
|
|
1270
|
+
let stretch = keepTotal > 0 ? (keepTotal + recovered) / keepTotal : Number.POSITIVE_INFINITY;
|
|
1271
|
+
if (stretch > MAX_STRETCH) {
|
|
1272
|
+
recovered = keepTotal * (MAX_STRETCH - 1);
|
|
1273
|
+
stretch = MAX_STRETCH;
|
|
1274
|
+
}
|
|
1275
|
+
const effective = emptyTotal / Math.max(0.01, emptyTotal - recovered);
|
|
1276
|
+
flush(step.from);
|
|
1277
|
+
let cursor = step.from;
|
|
1278
|
+
if (keepTotal >= MIN_KEEP_SECS && effective >= MIN_EFFECTIVE_SPEED) {
|
|
1279
|
+
for (const span of local) {
|
|
1280
|
+
segment(cursor, span.start, stretch);
|
|
1281
|
+
segment(span.start, span.end, 1 / effective);
|
|
1282
|
+
cursor = span.end;
|
|
1283
|
+
}
|
|
1284
|
+
segment(cursor, step.to, stretch);
|
|
1285
|
+
reclaimed += emptyTotal - emptyTotal / effective;
|
|
1286
|
+
} else {
|
|
1287
|
+
for (const span of local) {
|
|
1288
|
+
segment(cursor, span.start, 1);
|
|
1289
|
+
freeze(span.start, span.end, span.sourceAt);
|
|
1290
|
+
cursor = span.end;
|
|
1291
|
+
}
|
|
1292
|
+
segment(cursor, step.to, 1);
|
|
1293
|
+
reclaimed += emptyTotal;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
flush(totalSecs);
|
|
1297
|
+
if (n < 2 || reclaimed < 0.5) return null;
|
|
1298
|
+
return `${parts.join(";")};${labels.join("")}concat=n=${n}:v=1:a=0[outv]`;
|
|
1299
|
+
}
|
|
1300
|
+
async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, speed, fps, preset) {
|
|
1301
|
+
const filter = buildRetimeFilter(spans, stepBounds, speed, fps);
|
|
1302
|
+
if (!filter) return false;
|
|
1303
|
+
const enc = await passEncoder(preset);
|
|
1304
|
+
return new Promise((resolve3) => {
|
|
1305
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
1306
|
+
"-y",
|
|
1307
|
+
...filterThreadArgs(),
|
|
1308
|
+
...enc.input,
|
|
1309
|
+
"-i",
|
|
1310
|
+
inputPath,
|
|
1311
|
+
"-filter_complex",
|
|
1312
|
+
enc.filterSuffix ? filter + enc.filterSuffix : filter,
|
|
1313
|
+
"-map",
|
|
1314
|
+
enc.videoLabel,
|
|
1315
|
+
"-an",
|
|
1316
|
+
...enc.output,
|
|
1317
|
+
"-movflags",
|
|
1318
|
+
"+faststart",
|
|
1319
|
+
outputPath
|
|
1320
|
+
]);
|
|
1321
|
+
proc.stderr.resume();
|
|
1322
|
+
proc.on("error", () => resolve3(false));
|
|
1323
|
+
proc.on("close", (code) => resolve3(code === 0));
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, preset, protectAfterSecs, narrationPath, protectBeforeSecs, leadSpans, keepSpans) {
|
|
1327
|
+
const audioSource = narrationPath || inputPath;
|
|
1328
|
+
const frozenMax = Math.min(maxSecs, FROZEN_MAX_SECS);
|
|
1329
|
+
const [detected, frozen, black, white] = await Promise.all([
|
|
1330
|
+
detectSilences(audioSource, frozenMax + 0.05),
|
|
1331
|
+
detectFrozenSpans(inputPath, frozenMax + 0.05),
|
|
1332
|
+
// Shorter threshold than the other two: a page transition is often only a
|
|
1333
|
+
// second, and a second of black in a 60s Short is a second too many.
|
|
1334
|
+
detectBlackSpans(inputPath, 0.3),
|
|
1335
|
+
detectWhiteSpans(inputPath, 0.3)
|
|
1336
|
+
]);
|
|
1337
|
+
const silent = detected.filter((span) => span.end - span.start > maxSecs + 0.05);
|
|
1338
|
+
const stuck = intersectSpans(frozen, detected).filter((span) => span.end - span.start > frozenMax).map((span) => ({ ...span, cap: frozenMax }));
|
|
1339
|
+
const cardFloor = Math.max(0, protectBeforeSecs ?? 0);
|
|
1340
|
+
const cardCeiling = protectAfterSecs ?? totalSecs;
|
|
1341
|
+
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 }));
|
|
1342
|
+
const known = (leadSpans ?? []).filter((span) => span.end - span.start > BLACK_KEEP_SECS * 2).map((span) => ({ ...span, cap: BLACK_KEEP_SECS }));
|
|
1343
|
+
const merged = mergeSpans([...silent, ...stuck, ...empty, ...known]);
|
|
1344
|
+
const overlapsContent = (span) => (keepSpans ?? []).some((keep) => span.start < keep.end && span.end > keep.start);
|
|
1345
|
+
const spans = merged.filter((span) => !overlapsContent(span));
|
|
1346
|
+
const filter = buildCompressionFilter(spans, totalSecs, maxSecs, protectAfterSecs, protectBeforeSecs);
|
|
1347
|
+
if (!filter) return false;
|
|
1348
|
+
const enc = await passEncoder(preset);
|
|
1349
|
+
return new Promise((resolve3) => {
|
|
1350
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
1351
|
+
"-y",
|
|
1352
|
+
...filterThreadArgs(),
|
|
1353
|
+
...enc.input,
|
|
1354
|
+
"-i",
|
|
1355
|
+
inputPath,
|
|
1356
|
+
"-filter_complex",
|
|
1357
|
+
filter + enc.filterSuffix,
|
|
1358
|
+
"-map",
|
|
1359
|
+
enc.videoLabel,
|
|
1360
|
+
"-map",
|
|
1361
|
+
"[outa]",
|
|
1362
|
+
...enc.output,
|
|
1363
|
+
"-c:a",
|
|
1364
|
+
"aac",
|
|
1365
|
+
// No -shortest here. The overhang it was added for is already handled by
|
|
1366
|
+
// bounding every segment to the VIDEO stream's length, and the flag cut
|
|
1367
|
+
// the other way once an outro existed: a credits card carries no
|
|
1368
|
+
// narration, so the audio runs out before the picture does and the card
|
|
1369
|
+
// was truncated away.
|
|
1370
|
+
"-movflags",
|
|
1371
|
+
"+faststart",
|
|
1372
|
+
outputPath
|
|
1373
|
+
]);
|
|
1374
|
+
proc.stderr.resume();
|
|
1375
|
+
proc.on("error", () => resolve3(false));
|
|
1376
|
+
proc.on("close", (code) => resolve3(code === 0));
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, introSecs, outroSecs, maxFactor, preset) {
|
|
1380
|
+
const ends = introSecs + outroSecs;
|
|
1381
|
+
const middle = totalSecs - ends;
|
|
1382
|
+
const budget = targetSecs - ends - FIT_UNDERSHOOT_SECS;
|
|
1383
|
+
if (middle <= 0.5 || budget <= 0.5 || middle <= budget) {
|
|
1384
|
+
return { ok: false, factor: 1, reachedTarget: totalSecs <= targetSecs };
|
|
1385
|
+
}
|
|
1386
|
+
const needed = middle / budget;
|
|
1387
|
+
const factor = Math.min(needed, maxFactor);
|
|
1388
|
+
const tempo = atempoChain(factor);
|
|
1389
|
+
const midEnd = totalSecs - outroSecs;
|
|
1390
|
+
const filter = [
|
|
1391
|
+
`[0:v]trim=0:${introSecs.toFixed(3)},setpts=PTS-STARTPTS[v0]`,
|
|
1392
|
+
`[0:a]atrim=0:${introSecs.toFixed(3)},asetpts=PTS-STARTPTS[a0]`,
|
|
1393
|
+
`[0:v]trim=${introSecs.toFixed(3)}:${midEnd.toFixed(3)},setpts=(PTS-STARTPTS)/${factor.toFixed(4)}[v1]`,
|
|
1394
|
+
`[0:a]atrim=${introSecs.toFixed(3)}:${midEnd.toFixed(3)},asetpts=PTS-STARTPTS${tempo ? `,${tempo}` : ""}[a1]`,
|
|
1395
|
+
`[0:v]trim=${midEnd.toFixed(3)}:${totalSecs.toFixed(3)},setpts=PTS-STARTPTS[v2]`,
|
|
1396
|
+
`[0:a]atrim=${midEnd.toFixed(3)}:${totalSecs.toFixed(3)},asetpts=PTS-STARTPTS[a2]`,
|
|
1397
|
+
"[v0][a0][v1][a1][v2][a2]concat=n=3:v=1:a=1[outv][outa]"
|
|
1398
|
+
].join(";");
|
|
1399
|
+
const enc = await passEncoder(preset);
|
|
1400
|
+
const ok = await new Promise((resolve3) => {
|
|
1401
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
1402
|
+
"-y",
|
|
1403
|
+
...filterThreadArgs(),
|
|
1404
|
+
...enc.input,
|
|
1405
|
+
"-i",
|
|
1406
|
+
inputPath,
|
|
439
1407
|
"-filter_complex",
|
|
440
|
-
|
|
1408
|
+
filter + enc.filterSuffix,
|
|
441
1409
|
"-map",
|
|
442
|
-
|
|
443
|
-
"-
|
|
444
|
-
"
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
"
|
|
448
|
-
"
|
|
449
|
-
"
|
|
450
|
-
"yuv420p",
|
|
451
|
-
"-an",
|
|
1410
|
+
enc.videoLabel,
|
|
1411
|
+
"-map",
|
|
1412
|
+
"[outa]",
|
|
1413
|
+
...enc.output,
|
|
1414
|
+
"-c:a",
|
|
1415
|
+
"aac",
|
|
1416
|
+
"-movflags",
|
|
1417
|
+
"+faststart",
|
|
452
1418
|
outputPath
|
|
453
|
-
]
|
|
454
|
-
|
|
455
|
-
|
|
1419
|
+
]);
|
|
1420
|
+
proc.stderr.resume();
|
|
1421
|
+
proc.on("error", () => resolve3(false));
|
|
1422
|
+
proc.on("close", (code) => resolve3(code === 0));
|
|
1423
|
+
});
|
|
1424
|
+
return { ok, factor, reachedTarget: needed <= maxFactor };
|
|
456
1425
|
}
|
|
457
|
-
var
|
|
458
|
-
|
|
1426
|
+
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;
|
|
1427
|
+
var init_silence = __esm({
|
|
1428
|
+
"src/lib/ffmpeg/silence.ts"() {
|
|
459
1429
|
"use strict";
|
|
1430
|
+
init_hwaccel();
|
|
460
1431
|
init_spawn();
|
|
1432
|
+
SPEECH_GUARD_SECS = 0.12;
|
|
1433
|
+
BLACK_KEEP_SECS = 0.12;
|
|
1434
|
+
FROZEN_MAX_SECS = 0.6;
|
|
1435
|
+
EMPTY_SPAN_SPEED = 5;
|
|
1436
|
+
MAX_STRETCH = 2.5;
|
|
1437
|
+
MIN_EFFECTIVE_SPEED = 1.3;
|
|
1438
|
+
MIN_KEEP_SECS = 0.3;
|
|
1439
|
+
MIN_RETIME_SPAN = 0.2;
|
|
1440
|
+
FIT_UNDERSHOOT_SECS = 0.35;
|
|
461
1441
|
}
|
|
462
1442
|
});
|
|
463
1443
|
|
|
464
1444
|
// src/lib/ffmpeg/splice.ts
|
|
465
|
-
function
|
|
1445
|
+
function coverFilterChain(inputIdx, width, height) {
|
|
1446
|
+
const bg = `cov${inputIdx}bg`;
|
|
1447
|
+
const fg = `cov${inputIdx}fg`;
|
|
1448
|
+
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`;
|
|
1449
|
+
}
|
|
1450
|
+
async function spliceVideos(opts) {
|
|
1451
|
+
const { inputPath, outputPath, overlays, width, height, preset } = opts;
|
|
1452
|
+
if (overlays.length === 0) {
|
|
1453
|
+
return runFFmpeg(["-y", "-i", inputPath, "-c", "copy", outputPath], "ffmpeg copy failed");
|
|
1454
|
+
}
|
|
1455
|
+
const audioPresent = await Promise.all(
|
|
1456
|
+
overlays.map((o) => o.still ? Promise.resolve(false) : hasAudioStream(o.path))
|
|
1457
|
+
);
|
|
466
1458
|
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
1459
|
const args = ["-y", "-i", inputPath];
|
|
473
1460
|
for (const overlay of overlays) {
|
|
1461
|
+
if (overlay.still) {
|
|
1462
|
+
args.push("-loop", "1", "-framerate", String(STILL_FRAMERATE), "-t", overlay.duration.toFixed(3));
|
|
1463
|
+
}
|
|
474
1464
|
args.push("-i", overlay.path);
|
|
475
1465
|
}
|
|
476
1466
|
const filterParts = [];
|
|
@@ -490,13 +1480,18 @@ function spliceVideos(opts) {
|
|
|
490
1480
|
}
|
|
491
1481
|
}
|
|
492
1482
|
if (overlay.mode === "stretch") {
|
|
493
|
-
filters.push(`scale=${width}
|
|
1483
|
+
filters.push(`scale=${width}:-2`);
|
|
1484
|
+
filters.push(`pad=${width}:max(ih\\,${height}):0:(oh-ih)/2:color=#1a1a2e`);
|
|
494
1485
|
filters.push(`crop=${width}:${height}`);
|
|
1486
|
+
} else if (overlay.mode === "cover") {
|
|
1487
|
+
filters.push(coverFilterChain(inputIdx, width, height));
|
|
495
1488
|
} else {
|
|
496
|
-
filters.push(
|
|
1489
|
+
filters.push(
|
|
1490
|
+
`scale=${Math.round(width * 0.9)}:${Math.round(height * 0.9)}:force_original_aspect_ratio=decrease`
|
|
1491
|
+
);
|
|
497
1492
|
filters.push(`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=#1a1a2e`);
|
|
498
1493
|
}
|
|
499
|
-
filters.push(
|
|
1494
|
+
filters.push(`setpts=PTS-STARTPTS+${overlay.startTime.toFixed(3)}/TB`);
|
|
500
1495
|
filterParts.push(`[${inputIdx}:v]${filters.join(",")}[v${inputIdx}]`);
|
|
501
1496
|
const nextStream = i === overlays.length - 1 ? "outv" : `tmp${i}`;
|
|
502
1497
|
filterParts.push(
|
|
@@ -504,19 +1499,39 @@ function spliceVideos(opts) {
|
|
|
504
1499
|
);
|
|
505
1500
|
currentStream = nextStream;
|
|
506
1501
|
}
|
|
1502
|
+
const clipAudio = overlays.map((overlay, i) => ({ overlay, inputIdx: i + 1 })).filter(({ inputIdx }) => audioPresent[inputIdx - 1]);
|
|
1503
|
+
for (const { overlay, inputIdx } of clipAudio) {
|
|
1504
|
+
const delayMs = Math.round(overlay.startTime * 1e3);
|
|
1505
|
+
filterParts.push(
|
|
1506
|
+
`[${inputIdx}:a]atrim=0:${overlay.duration.toFixed(3)},asetpts=PTS-STARTPTS,adelay=${delayMs}|${delayMs},volume=${CLIP_AUDIO_GAIN}[a${inputIdx}]`
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
if (clipAudio.length === 1) {
|
|
1510
|
+
filterParts.push(`[a${clipAudio[0].inputIdx}]anull[outa]`);
|
|
1511
|
+
} else if (clipAudio.length > 1) {
|
|
1512
|
+
const labels = clipAudio.map(({ inputIdx }) => `[a${inputIdx}]`).join("");
|
|
1513
|
+
filterParts.push(`${labels}amix=inputs=${clipAudio.length}:normalize=0:dropout_transition=0[outa]`);
|
|
1514
|
+
}
|
|
507
1515
|
args.push("-filter_complex", filterParts.join(";"));
|
|
508
1516
|
args.push("-map", "[outv]");
|
|
509
|
-
args.push("-map", "
|
|
1517
|
+
if (clipAudio.length > 0) args.push("-map", "[outa]");
|
|
510
1518
|
args.push("-c:v", "libx264", "-preset", preset, "-crf", "18", "-pix_fmt", "yuv420p");
|
|
511
|
-
args.push("-c:a", "
|
|
1519
|
+
if (clipAudio.length > 0) args.push("-c:a", "aac");
|
|
512
1520
|
args.push(outputPath);
|
|
513
1521
|
runFFmpeg(args, "ffmpeg splice failed").then(resolve3, reject);
|
|
514
1522
|
});
|
|
515
1523
|
}
|
|
1524
|
+
var CLIP_AUDIO_GAIN, STILL_FRAMERATE, COVER_WASH_W, COVER_WASH_H, COVER_BLUR_SIGMA;
|
|
516
1525
|
var init_splice = __esm({
|
|
517
1526
|
"src/lib/ffmpeg/splice.ts"() {
|
|
518
1527
|
"use strict";
|
|
1528
|
+
init_media();
|
|
519
1529
|
init_spawn();
|
|
1530
|
+
CLIP_AUDIO_GAIN = 0.55;
|
|
1531
|
+
STILL_FRAMERATE = 30;
|
|
1532
|
+
COVER_WASH_W = 6;
|
|
1533
|
+
COVER_WASH_H = 10;
|
|
1534
|
+
COVER_BLUR_SIGMA = 40;
|
|
520
1535
|
}
|
|
521
1536
|
});
|
|
522
1537
|
|
|
@@ -613,7 +1628,9 @@ var init_ffmpeg = __esm({
|
|
|
613
1628
|
"use strict";
|
|
614
1629
|
init_compose();
|
|
615
1630
|
init_compress();
|
|
1631
|
+
init_gif();
|
|
616
1632
|
init_media();
|
|
1633
|
+
init_silence();
|
|
617
1634
|
init_splice();
|
|
618
1635
|
init_transitions();
|
|
619
1636
|
init_types();
|
|
@@ -630,8 +1647,8 @@ var init_ffmpeg2 = __esm({
|
|
|
630
1647
|
|
|
631
1648
|
// src/lib/tts/helpers.ts
|
|
632
1649
|
import { existsSync } from "fs";
|
|
633
|
-
import { readFile, unlink, writeFile } from "fs/promises";
|
|
634
|
-
import { isAbsolute, join, resolve } from "path";
|
|
1650
|
+
import { readFile, unlink as unlink2, writeFile } from "fs/promises";
|
|
1651
|
+
import { isAbsolute, join as join2, resolve } from "path";
|
|
635
1652
|
function toWSLPathForTTS(path) {
|
|
636
1653
|
return windowsDrivePathToWsl(path) ?? path;
|
|
637
1654
|
}
|
|
@@ -684,7 +1701,7 @@ async function getMediaStepDuration(step, baseDir) {
|
|
|
684
1701
|
}
|
|
685
1702
|
}
|
|
686
1703
|
async function loadVoiceoverMeta(outputDir) {
|
|
687
|
-
const metaPath =
|
|
1704
|
+
const metaPath = join2(outputDir, "voiceover.json");
|
|
688
1705
|
try {
|
|
689
1706
|
if (!existsSync(metaPath)) return null;
|
|
690
1707
|
const content = await readFile(metaPath, "utf-8");
|
|
@@ -694,7 +1711,7 @@ async function loadVoiceoverMeta(outputDir) {
|
|
|
694
1711
|
}
|
|
695
1712
|
}
|
|
696
1713
|
async function saveVoiceoverMeta(outputDir, meta) {
|
|
697
|
-
const metaPath =
|
|
1714
|
+
const metaPath = join2(outputDir, "voiceover.json");
|
|
698
1715
|
await writeFile(metaPath, JSON.stringify(meta, null, 2));
|
|
699
1716
|
}
|
|
700
1717
|
async function concatenateAudio(inputs, output, workDir) {
|
|
@@ -703,19 +1720,21 @@ async function concatenateAudio(inputs, output, workDir) {
|
|
|
703
1720
|
return isWindows ? windowsDrivePathToWsl(resolved) ?? resolved : resolved;
|
|
704
1721
|
};
|
|
705
1722
|
const listContent = inputs.map((f) => `file '${formatPath(f)}'`).join("\n");
|
|
706
|
-
const listPath =
|
|
1723
|
+
const listPath = join2(workDir, "concat.txt");
|
|
707
1724
|
await writeFile(listPath, listContent);
|
|
708
1725
|
await concatMedia(listPath, output);
|
|
709
|
-
await
|
|
1726
|
+
await unlink2(listPath).catch(() => {
|
|
710
1727
|
});
|
|
711
1728
|
}
|
|
712
|
-
var isWindows;
|
|
1729
|
+
var isWindows, NARRATION_LEAD_IN_MS, LEAD_IN_CLIP;
|
|
713
1730
|
var init_helpers = __esm({
|
|
714
1731
|
"src/lib/tts/helpers.ts"() {
|
|
715
1732
|
"use strict";
|
|
716
1733
|
init_ffmpeg2();
|
|
717
1734
|
init_utils();
|
|
718
1735
|
isWindows = process.platform === "win32";
|
|
1736
|
+
NARRATION_LEAD_IN_MS = 300;
|
|
1737
|
+
LEAD_IN_CLIP = "lead-in.mp3";
|
|
719
1738
|
}
|
|
720
1739
|
});
|
|
721
1740
|
|
|
@@ -723,10 +1742,12 @@ var init_helpers = __esm({
|
|
|
723
1742
|
var timing_exports = {};
|
|
724
1743
|
__export(timing_exports, {
|
|
725
1744
|
alignVoiceoverToRecording: () => alignVoiceoverToRecording,
|
|
726
|
-
|
|
1745
|
+
alignWordsToCaption: () => alignWordsToCaption,
|
|
1746
|
+
estimateWordTimings: () => estimateWordTimings,
|
|
1747
|
+
spokenWordTimings: () => spokenWordTimings
|
|
727
1748
|
});
|
|
728
1749
|
import { existsSync as existsSync2 } from "fs";
|
|
729
|
-
import { join as
|
|
1750
|
+
import { join as join3 } from "path";
|
|
730
1751
|
function estimateWordTimings(caption, duration) {
|
|
731
1752
|
const words = caption.split(/\s+/).filter((w) => w.length > 0);
|
|
732
1753
|
const wordDuration = duration / words.length;
|
|
@@ -736,6 +1757,36 @@ function estimateWordTimings(caption, duration) {
|
|
|
736
1757
|
end: Math.round((i + 1) * wordDuration)
|
|
737
1758
|
}));
|
|
738
1759
|
}
|
|
1760
|
+
function alignWordsToCaption(caption, spoken) {
|
|
1761
|
+
const captionWords = caption.split(/\s+/).filter((w) => w.length > 0);
|
|
1762
|
+
if (captionWords.length === 0 || spoken.length === 0) return [];
|
|
1763
|
+
if (spoken.length === captionWords.length) {
|
|
1764
|
+
return captionWords.map((word, i) => ({
|
|
1765
|
+
word,
|
|
1766
|
+
start: Math.round(spoken[i].start),
|
|
1767
|
+
end: Math.round(spoken[i].end)
|
|
1768
|
+
}));
|
|
1769
|
+
}
|
|
1770
|
+
const speechStart = spoken[0].start;
|
|
1771
|
+
const speechEnd = spoken[spoken.length - 1].end;
|
|
1772
|
+
const wordDuration = (speechEnd - speechStart) / captionWords.length;
|
|
1773
|
+
return captionWords.map((word, i) => ({
|
|
1774
|
+
word,
|
|
1775
|
+
start: Math.round(speechStart + i * wordDuration),
|
|
1776
|
+
end: Math.round(speechStart + (i + 1) * wordDuration)
|
|
1777
|
+
}));
|
|
1778
|
+
}
|
|
1779
|
+
function spokenWordTimings(caption, spoken) {
|
|
1780
|
+
if (!spoken || spoken.length === 0) return null;
|
|
1781
|
+
const leadTrim = spoken[0].start;
|
|
1782
|
+
const rebased = spoken.map((w) => ({
|
|
1783
|
+
word: w.word,
|
|
1784
|
+
start: Math.max(0, w.start - leadTrim),
|
|
1785
|
+
end: Math.max(0, w.end - leadTrim)
|
|
1786
|
+
}));
|
|
1787
|
+
const aligned = alignWordsToCaption(caption, rebased);
|
|
1788
|
+
return aligned.length > 0 ? aligned : null;
|
|
1789
|
+
}
|
|
739
1790
|
async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, workDir, outputDir) {
|
|
740
1791
|
let needsRealignment = false;
|
|
741
1792
|
for (let i = 0; i < actualRecordingDurations.length; i++) {
|
|
@@ -750,31 +1801,35 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
|
|
|
750
1801
|
if (!needsRealignment) {
|
|
751
1802
|
return stepAudio;
|
|
752
1803
|
}
|
|
753
|
-
const audioDir =
|
|
1804
|
+
const audioDir = join3(workDir, "audio");
|
|
754
1805
|
const segments = [];
|
|
755
1806
|
const alignedStepAudio = /* @__PURE__ */ new Map();
|
|
1807
|
+
const leadInPath = join3(audioDir, LEAD_IN_CLIP);
|
|
1808
|
+
const leadIn = existsSync2(leadInPath) ? NARRATION_LEAD_IN_MS : 0;
|
|
1809
|
+
if (leadIn) segments.push(leadInPath);
|
|
756
1810
|
for (let i = 0; i < actualRecordingDurations.length; i++) {
|
|
757
1811
|
const stepNum = i + 1;
|
|
758
1812
|
const voiceData = stepAudio.get(stepNum);
|
|
759
1813
|
const voiceDuration = voiceData?.duration ?? 0;
|
|
760
1814
|
const recordingDuration = actualRecordingDurations[i] ?? 0;
|
|
761
1815
|
const targetDuration = Math.max(voiceDuration, recordingDuration);
|
|
762
|
-
const audioPath =
|
|
763
|
-
const originalPaddingPath =
|
|
764
|
-
const silencePath =
|
|
1816
|
+
const audioPath = join3(audioDir, `step-${stepNum}.mp3`);
|
|
1817
|
+
const originalPaddingPath = join3(audioDir, `step-${stepNum}-padding.mp3`);
|
|
1818
|
+
const silencePath = join3(audioDir, `step-${stepNum}-silence.mp3`);
|
|
765
1819
|
if (existsSync2(audioPath)) {
|
|
766
1820
|
segments.push(audioPath);
|
|
767
1821
|
if (existsSync2(originalPaddingPath)) {
|
|
768
1822
|
segments.push(originalPaddingPath);
|
|
769
1823
|
}
|
|
770
1824
|
if (recordingDuration > voiceDuration) {
|
|
771
|
-
const alignPaddingPath =
|
|
1825
|
+
const alignPaddingPath = join3(audioDir, `step-${stepNum}-align-padding.mp3`);
|
|
772
1826
|
await generateSilence(alignPaddingPath, recordingDuration - voiceDuration);
|
|
773
1827
|
segments.push(alignPaddingPath);
|
|
774
1828
|
}
|
|
775
1829
|
} else if (existsSync2(silencePath)) {
|
|
776
|
-
|
|
777
|
-
|
|
1830
|
+
const own = targetDuration - (stepNum === 1 ? leadIn : 0);
|
|
1831
|
+
if (targetDuration !== voiceDuration && own > 0) {
|
|
1832
|
+
await generateSilence(silencePath, own);
|
|
778
1833
|
}
|
|
779
1834
|
segments.push(silencePath);
|
|
780
1835
|
}
|
|
@@ -783,10 +1838,14 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
|
|
|
783
1838
|
words: voiceData?.words ?? []
|
|
784
1839
|
});
|
|
785
1840
|
}
|
|
786
|
-
if (segments.length
|
|
787
|
-
|
|
788
|
-
|
|
1841
|
+
if (segments.length === 0) {
|
|
1842
|
+
console.log(
|
|
1843
|
+
` No per-step audio in ${audioDir}: keeping the narration's own timeline, so the captions still follow the voice.`
|
|
1844
|
+
);
|
|
1845
|
+
return stepAudio;
|
|
789
1846
|
}
|
|
1847
|
+
const voiceoverPath = join3(outputDir, "voiceover.wav");
|
|
1848
|
+
await concatenateAudio(segments, voiceoverPath, workDir);
|
|
790
1849
|
const existingMeta = await loadVoiceoverMeta(outputDir);
|
|
791
1850
|
if (existingMeta) {
|
|
792
1851
|
existingMeta.stepDurations = Object.fromEntries(
|
|
@@ -806,6 +1865,7 @@ var init_timing = __esm({
|
|
|
806
1865
|
|
|
807
1866
|
// src/lib/api-client.ts
|
|
808
1867
|
init_utils();
|
|
1868
|
+
var PLAN_MAX_TOKENS = 1500;
|
|
809
1869
|
var SERVER_BACKOFF_MS = [2e3, 4e3];
|
|
810
1870
|
var CLI_BACKOFF_MS = [1e4, 3e4];
|
|
811
1871
|
var SPARK_AI_BASE = "https://sparkbrain.app";
|
|
@@ -885,7 +1945,7 @@ async function getOrRefreshRelayToken(email) {
|
|
|
885
1945
|
}
|
|
886
1946
|
}
|
|
887
1947
|
async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier, opts = {}) {
|
|
888
|
-
const { onRetry, backoffMs = SERVER_BACKOFF_MS } = opts;
|
|
1948
|
+
const { onRetry, backoffMs = SERVER_BACKOFF_MS, maxTokens } = opts;
|
|
889
1949
|
let lastRetriableError = null;
|
|
890
1950
|
const host = hostOf(chatUrl);
|
|
891
1951
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
@@ -900,7 +1960,11 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
|
|
|
900
1960
|
messages: [
|
|
901
1961
|
{ role: "system", content: systemPrompt },
|
|
902
1962
|
{ role: "user", content: userPrompt }
|
|
903
|
-
]
|
|
1963
|
+
],
|
|
1964
|
+
// Explicit numeric test, not truthiness: a 0 or negative budget is a
|
|
1965
|
+
// caller bug, and silently falling back to the tiny backend default
|
|
1966
|
+
// reproduces exactly the failure this option exists to prevent.
|
|
1967
|
+
...typeof maxTokens === "number" && maxTokens > 0 ? { max_tokens: Math.floor(maxTokens) } : {}
|
|
904
1968
|
})
|
|
905
1969
|
});
|
|
906
1970
|
const bodyText = await response.text().catch(() => "");
|
|
@@ -1039,26 +2103,23 @@ var APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
|
|
|
1039
2103
|
var OWNER_SLUG = process.env.SPARK_OWNER_SLUG ?? "spark";
|
|
1040
2104
|
var BASE = "https://sparkpay.dev";
|
|
1041
2105
|
var TIERS = {
|
|
1042
|
-
|
|
1043
|
-
|
|
2106
|
+
// Essentials
|
|
2107
|
+
price_1TCREmEf9opkuWidvhuqzqDM: "pro",
|
|
2108
|
+
// live monthly
|
|
2109
|
+
price_1TCREmEf9opkuWidacf1tiC7: "pro",
|
|
2110
|
+
// live yearly
|
|
1044
2111
|
price_1TADm7Ef9opkuWid3I9tquIB: "pro",
|
|
2112
|
+
// test monthly
|
|
1045
2113
|
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
|
-
]
|
|
2114
|
+
// test yearly
|
|
2115
|
+
// Elite. These were missing entirely, so an Elite subscriber fell through
|
|
2116
|
+
// the `?? 'pro'` fallback below and got Essentials limits for $29.
|
|
2117
|
+
price_1TrHz8Ef9opkuWid6lh2opD8: "elite",
|
|
2118
|
+
// live monthly
|
|
2119
|
+
price_1TrHz9Ef9opkuWid1qS26iMu: "elite",
|
|
2120
|
+
// live yearly
|
|
2121
|
+
price_1TGPCREf9opkuWidR0iJeJNe: "elite"
|
|
2122
|
+
// test monthly
|
|
1062
2123
|
};
|
|
1063
2124
|
async function _fetch(email, init) {
|
|
1064
2125
|
const r = await fetch(
|
|
@@ -1083,7 +2144,6 @@ function getTier(d) {
|
|
|
1083
2144
|
return TIERS[d.subscription.price_id] ?? "pro";
|
|
1084
2145
|
}
|
|
1085
2146
|
var isPaid = (d) => getTier(d) !== "free";
|
|
1086
|
-
var hasAccess = (d, min) => (RANK[getTier(d)] ?? 0) >= (RANK[min] ?? 0);
|
|
1087
2147
|
var hasTrialRemaining = (d) => isPaid(d) || !!d.trial && !d.trial.is_expired && d.trial.uses_remaining > 0;
|
|
1088
2148
|
var showPaywall = (d) => !isPaid(d) && !hasTrialRemaining(d);
|
|
1089
2149
|
var isCanceling = (d) => !!d.subscription && d.subscription.cancel_at_period_end && d.subscription.status !== "canceled";
|
|
@@ -1109,21 +2169,26 @@ function pricingUrl(opts) {
|
|
|
1109
2169
|
}
|
|
1110
2170
|
|
|
1111
2171
|
// src/lib/billing.ts
|
|
1112
|
-
function shouldWatermark(tier, videoDurationSeconds) {
|
|
1113
|
-
return tier === "free" && videoDurationSeconds > 20;
|
|
1114
|
-
}
|
|
1115
2172
|
function getUpgradeUrl(userEmail) {
|
|
1116
2173
|
return pricingUrl(userEmail ? { email: userEmail } : void 0);
|
|
1117
2174
|
}
|
|
1118
2175
|
|
|
1119
2176
|
// src/lib/config.ts
|
|
1120
|
-
var VERSION = "1.2.
|
|
2177
|
+
var VERSION = "1.2.5";
|
|
1121
2178
|
var CONFIG_FILE = "quickpeek.config.json";
|
|
2179
|
+
var SIZE_PRESETS = {
|
|
2180
|
+
wide: { width: 1920, height: 1080, zoom: 1 },
|
|
2181
|
+
short: { width: 1080, height: 1920, zoom: 2 }
|
|
2182
|
+
};
|
|
2183
|
+
var RATE_FACTOR = { slow: 0.8, normal: 1, fast: 1.25 };
|
|
2184
|
+
var NO_CLIP_OUTRO = "@card-step";
|
|
1122
2185
|
var DEFAULT_CONFIG = {
|
|
1123
2186
|
plan: { maxSteps: 8 },
|
|
1124
2187
|
video: {
|
|
1125
|
-
|
|
1126
|
-
|
|
2188
|
+
size: "short",
|
|
2189
|
+
width: SIZE_PRESETS.short.width,
|
|
2190
|
+
height: SIZE_PRESETS.short.height,
|
|
2191
|
+
zoom: SIZE_PRESETS.short.zoom,
|
|
1127
2192
|
format: "mp4",
|
|
1128
2193
|
preset: "fast",
|
|
1129
2194
|
quality: 0.5,
|
|
@@ -1136,20 +2201,71 @@ var DEFAULT_CONFIG = {
|
|
|
1136
2201
|
// ms
|
|
1137
2202
|
transitions: "none",
|
|
1138
2203
|
contrast: 0.25,
|
|
2204
|
+
// The recording plays at the pace it was filmed at. A tempo above 1 is
|
|
2205
|
+
// multiplied by the narration speed in VIDEO_PROFILES and by the fit
|
|
2206
|
+
// pass's squeeze, and the three together read as frantic rather than
|
|
2207
|
+
// brisk. Speed belongs in one place, so it lives in audioSpeed; set a
|
|
2208
|
+
// tempo in quickpeek.config.json to push the picture on top of that.
|
|
1139
2209
|
tempo: 1,
|
|
1140
2210
|
stepDelay: 200
|
|
1141
2211
|
// ms delay between steps
|
|
1142
2212
|
},
|
|
1143
|
-
audio: { format: "mp3", bitrate: "192k" },
|
|
2213
|
+
audio: { gender: "female", rate: "normal", format: "mp3", bitrate: "192k" },
|
|
1144
2214
|
timing: { minStepDuration: 1500 },
|
|
1145
2215
|
output: { dir: "./demo" }
|
|
1146
2216
|
};
|
|
2217
|
+
var VIDEO_PROFILES = {
|
|
2218
|
+
// preset 'veryfast' over 'fast': measured ~20s faster on a 77s 1080p
|
|
2219
|
+
// compose, with identical duration and audio and no visible difference at
|
|
2220
|
+
// these bitrates - the encode is pure wall-clock tax on every run.
|
|
2221
|
+
wide: {
|
|
2222
|
+
video: { width: 1920, height: 1080, zoom: 1.5, fitLimitSecs: 0, maxSpeedup: 1, preset: "veryfast" },
|
|
2223
|
+
// A tutorial is watched to learn something, so the narrator is allowed to
|
|
2224
|
+
// be brisk but never hurried.
|
|
2225
|
+
audioSpeed: 1.15
|
|
2226
|
+
},
|
|
2227
|
+
short: {
|
|
2228
|
+
// fadeOut 0: the video ends on a card the viewer is meant to read,
|
|
2229
|
+
// and a fade takes it away mid-sentence.
|
|
2230
|
+
video: { width: 1080, height: 1920, zoom: 3, fitLimitSecs: 60, maxSpeedup: 1.7, preset: "veryfast", fadeOut: 0 },
|
|
2231
|
+
// 1.8 was unwatchable: stacked on tempo 1.2 and the outro speedup it ran
|
|
2232
|
+
// the voice at better than twice its natural rate. A short wants energy,
|
|
2233
|
+
// which is the voice and the cutting, not the tape running fast.
|
|
2234
|
+
audioSpeed: 1,
|
|
2235
|
+
// A short lives or dies on energy. Ava is the warmest, most animated of
|
|
2236
|
+
// the free neural voices; Sonia reads a short like the shipping forecast.
|
|
2237
|
+
voice: "en-US-AvaMultilingualNeural"
|
|
2238
|
+
}
|
|
2239
|
+
};
|
|
2240
|
+
function applyProfile(config, profile) {
|
|
2241
|
+
const preset = VIDEO_PROFILES[profile];
|
|
2242
|
+
return {
|
|
2243
|
+
...config,
|
|
2244
|
+
profile,
|
|
2245
|
+
video: { ...config.video, ...preset.video },
|
|
2246
|
+
audio: {
|
|
2247
|
+
format: "mp3",
|
|
2248
|
+
bitrate: "192k",
|
|
2249
|
+
...config.audio,
|
|
2250
|
+
// outroOnly is on unless turned off, matching the video phase. The
|
|
2251
|
+
// speedup buys the song its window, so it applies to the profile's own
|
|
2252
|
+
// pace only: a speed somebody set by hand is the speed they wanted.
|
|
2253
|
+
speed: config.audio?.speed ?? preset.audioSpeed * (config.music?.outroOnly !== false ? OUTRO_SONG_SPEEDUP : 1),
|
|
2254
|
+
...config.audio?.voice || !preset.voice ? {} : { voice: preset.voice }
|
|
2255
|
+
}
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
var OUTRO_SONG_SPEEDUP = 1.13;
|
|
1147
2259
|
var DEFAULT_CAPTIONS = {
|
|
1148
2260
|
font: "Arial",
|
|
1149
2261
|
outline: "#000000",
|
|
1150
2262
|
outlineWidth: 3,
|
|
1151
2263
|
shadow: 2,
|
|
1152
2264
|
marginBottom: 80,
|
|
2265
|
+
// The IG/TikTok look is what a demo short is for; karaoke remains available
|
|
2266
|
+
// by name for anyone who wants the sweep instead.
|
|
2267
|
+
preset: "shorts",
|
|
2268
|
+
position: "bottom",
|
|
1153
2269
|
regular: {
|
|
1154
2270
|
color: "#FFFFFF",
|
|
1155
2271
|
size: 56,
|
|
@@ -1159,8 +2275,41 @@ var DEFAULT_CAPTIONS = {
|
|
|
1159
2275
|
color: "#FFFF00",
|
|
1160
2276
|
size: 64,
|
|
1161
2277
|
bold: true
|
|
1162
|
-
}
|
|
2278
|
+
},
|
|
2279
|
+
maxChars: 28
|
|
1163
2280
|
};
|
|
2281
|
+
function defaultCaptionsFor(width, height) {
|
|
2282
|
+
const portrait = height > width;
|
|
2283
|
+
if (!portrait) {
|
|
2284
|
+
return {
|
|
2285
|
+
...DEFAULT_CAPTIONS,
|
|
2286
|
+
preset: "karaoke",
|
|
2287
|
+
position: "bottom",
|
|
2288
|
+
marginBottom: Math.round(height * 0.083),
|
|
2289
|
+
regular: { color: "#FFFFFF", size: Math.round(width * 0.033), bold: true },
|
|
2290
|
+
highlight: { color: "#FFE100", size: Math.round(width * 0.036), bold: true },
|
|
2291
|
+
outlineWidth: 4,
|
|
2292
|
+
shadow: 2,
|
|
2293
|
+
maxChars: 34
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
const size = Math.round(width * 0.104);
|
|
2297
|
+
return {
|
|
2298
|
+
...DEFAULT_CAPTIONS,
|
|
2299
|
+
// The whole line stays up and the spoken word recolours: the social-video
|
|
2300
|
+
// convention, and the one that survives being watched muted.
|
|
2301
|
+
preset: "hormozi",
|
|
2302
|
+
position: "bottom",
|
|
2303
|
+
marginBottom: Math.round(height * 0.156),
|
|
2304
|
+
// Equal sizes on purpose. A larger highlight reflows the line every time
|
|
2305
|
+
// the spoken word moves, which on a full-line preset is a visible twitch.
|
|
2306
|
+
regular: { color: "#FFFFFF", size, bold: true },
|
|
2307
|
+
highlight: { color: "#FFE100", size, bold: true },
|
|
2308
|
+
outlineWidth: 8,
|
|
2309
|
+
shadow: 5,
|
|
2310
|
+
maxChars: 20
|
|
2311
|
+
};
|
|
2312
|
+
}
|
|
1164
2313
|
|
|
1165
2314
|
// src/lib/crawl.ts
|
|
1166
2315
|
import { chromium } from "playwright";
|
|
@@ -1262,9 +2411,81 @@ function shouldSkipLink(text, href, currentPath) {
|
|
|
1262
2411
|
}
|
|
1263
2412
|
|
|
1264
2413
|
// src/lib/crawl.ts
|
|
2414
|
+
var SETTLE_POLL_MS = 400;
|
|
2415
|
+
var SETTLE_STABLE_POLLS = 2;
|
|
2416
|
+
var SETTLE_CAP_MS = 2e4;
|
|
2417
|
+
async function waitForDomStable(page, cap = SETTLE_CAP_MS) {
|
|
2418
|
+
const deadline = Date.now() + cap;
|
|
2419
|
+
let previous = -1;
|
|
2420
|
+
let unchanged = 0;
|
|
2421
|
+
while (Date.now() < deadline) {
|
|
2422
|
+
const count = await page.evaluate(() => document.getElementsByTagName("*").length).catch(() => -1);
|
|
2423
|
+
if (count < 0) return;
|
|
2424
|
+
if (count === previous) {
|
|
2425
|
+
if (++unchanged >= SETTLE_STABLE_POLLS) return;
|
|
2426
|
+
} else {
|
|
2427
|
+
unchanged = 0;
|
|
2428
|
+
previous = count;
|
|
2429
|
+
}
|
|
2430
|
+
await page.waitForTimeout(SETTLE_POLL_MS);
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
var PLACEHOLDER_POLL_MS = 250;
|
|
2434
|
+
var PLACEHOLDER_CAP_MS = 8e3;
|
|
2435
|
+
var PLACEHOLDER_STATIC_POLLS = 4;
|
|
2436
|
+
var PLACEHOLDER_CLEAR_POLLS = 3;
|
|
2437
|
+
var PLACEHOLDER_SELECTOR = [
|
|
2438
|
+
'[aria-busy="true"]',
|
|
2439
|
+
'[role="progressbar"]',
|
|
2440
|
+
'[data-loading="true"]',
|
|
2441
|
+
'[class*="skeleton" i]',
|
|
2442
|
+
'[class*="animate-pulse" i]',
|
|
2443
|
+
'[class*="spinner" i]'
|
|
2444
|
+
].join(",");
|
|
2445
|
+
async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
|
|
2446
|
+
const deadline = Date.now() + cap;
|
|
2447
|
+
let previous = -1;
|
|
2448
|
+
let unchanged = 0;
|
|
2449
|
+
let clear = 0;
|
|
2450
|
+
while (Date.now() < deadline) {
|
|
2451
|
+
const count = await page.evaluate((selector) => {
|
|
2452
|
+
let visible = 0;
|
|
2453
|
+
for (const el of Array.from(document.querySelectorAll(selector))) {
|
|
2454
|
+
const rect = el.getBoundingClientRect();
|
|
2455
|
+
if (rect.width > 0 && rect.height > 0) visible++;
|
|
2456
|
+
}
|
|
2457
|
+
return visible;
|
|
2458
|
+
}, PLACEHOLDER_SELECTOR).catch(() => 0);
|
|
2459
|
+
if (count === 0) {
|
|
2460
|
+
if (++clear >= PLACEHOLDER_CLEAR_POLLS) return;
|
|
2461
|
+
await page.waitForTimeout(PLACEHOLDER_POLL_MS);
|
|
2462
|
+
continue;
|
|
2463
|
+
}
|
|
2464
|
+
clear = 0;
|
|
2465
|
+
if (count === previous) {
|
|
2466
|
+
if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
|
|
2467
|
+
} else {
|
|
2468
|
+
unchanged = 0;
|
|
2469
|
+
previous = count;
|
|
2470
|
+
}
|
|
2471
|
+
await page.waitForTimeout(PLACEHOLDER_POLL_MS);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
async function gotoSettled(page, url, timeout = 3e4) {
|
|
2475
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
|
2476
|
+
await page.waitForLoadState("networkidle", { timeout: 2500 }).catch(() => {
|
|
2477
|
+
});
|
|
2478
|
+
await waitForDomStable(page).catch(() => {
|
|
2479
|
+
});
|
|
2480
|
+
await waitForPlaceholdersGone(page).catch(() => {
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
1265
2483
|
function idSelector(id) {
|
|
1266
2484
|
return /^[A-Za-z_-][\w-]*$/.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
|
|
1267
2485
|
}
|
|
2486
|
+
function ariaLabelSelector(label) {
|
|
2487
|
+
return `[aria-label="${label.replace(/(["\\])/g, "\\$1")}"]`;
|
|
2488
|
+
}
|
|
1268
2489
|
function getActionForType(type) {
|
|
1269
2490
|
switch (type) {
|
|
1270
2491
|
case "button":
|
|
@@ -1282,169 +2503,228 @@ function getActionForType(type) {
|
|
|
1282
2503
|
return "type";
|
|
1283
2504
|
}
|
|
1284
2505
|
}
|
|
2506
|
+
async function sitemapRoutes(pageUrl) {
|
|
2507
|
+
let origin;
|
|
2508
|
+
try {
|
|
2509
|
+
origin = new URL(pageUrl).origin;
|
|
2510
|
+
} catch {
|
|
2511
|
+
return [];
|
|
2512
|
+
}
|
|
2513
|
+
try {
|
|
2514
|
+
const res = await fetch(`${origin}/sitemap.xml`, { signal: AbortSignal.timeout(4e3) });
|
|
2515
|
+
if (!res.ok) return [];
|
|
2516
|
+
const xml = await res.text();
|
|
2517
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2518
|
+
const routes = [];
|
|
2519
|
+
for (const m of xml.matchAll(/<loc>([^<]+)<\/loc>/g)) {
|
|
2520
|
+
const raw = (m[1] ?? "").trim();
|
|
2521
|
+
let u;
|
|
2522
|
+
try {
|
|
2523
|
+
u = new URL(raw);
|
|
2524
|
+
} catch {
|
|
2525
|
+
continue;
|
|
2526
|
+
}
|
|
2527
|
+
if (u.origin !== origin) continue;
|
|
2528
|
+
if (/\.(txt|xml|json|png|jpg|svg|ico|pdf)$/i.test(u.pathname)) continue;
|
|
2529
|
+
const path = u.pathname.replace(/\/+$/, "");
|
|
2530
|
+
if (!path || seen.has(u.href)) continue;
|
|
2531
|
+
seen.add(u.href);
|
|
2532
|
+
routes.push({ text: path.slice(1), href: u.href });
|
|
2533
|
+
if (routes.length >= 20) break;
|
|
2534
|
+
}
|
|
2535
|
+
return routes;
|
|
2536
|
+
} catch {
|
|
2537
|
+
return [];
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
1285
2540
|
var noopTask = () => ({ succeed: () => {
|
|
1286
2541
|
} });
|
|
1287
2542
|
var CRAWL_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
1288
2543
|
var CRAWL_CACHE_MAX_ENTRIES = 50;
|
|
1289
2544
|
var crawlCache = /* @__PURE__ */ new Map();
|
|
1290
2545
|
async function crawlPage(url, task = noopTask, storageStatePath, viewport) {
|
|
1291
|
-
const
|
|
1292
|
-
const cached = crawlCache.get(
|
|
2546
|
+
const cacheKey2 = viewport ? `${url}@${viewport.width}x${viewport.height}` : url;
|
|
2547
|
+
const cached = crawlCache.get(cacheKey2);
|
|
1293
2548
|
if (cached && Date.now() - cached.timestamp < CRAWL_CACHE_TTL_MS) {
|
|
1294
2549
|
task("Using cached crawl").succeed(`Found ${cached.elements.length} interactive elements (cached)`);
|
|
1295
2550
|
return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
|
|
1296
2551
|
}
|
|
1297
2552
|
const browserTask = task("Launching browser");
|
|
1298
2553
|
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
|
-
|
|
2554
|
+
try {
|
|
2555
|
+
const context = await browser.newContext({
|
|
2556
|
+
...storageStatePath ? { storageState: storageStatePath } : {},
|
|
2557
|
+
// Crawl at the size we will record at, or the measurements below describe
|
|
2558
|
+
// a page nobody will ever see.
|
|
2559
|
+
...viewport ? { viewport } : {}
|
|
2560
|
+
});
|
|
2561
|
+
const page = await context.newPage();
|
|
2562
|
+
browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
|
|
2563
|
+
const crawlTask = task("Crawling page");
|
|
2564
|
+
await gotoSettled(page, url, 3e4);
|
|
2565
|
+
const title = await page.title();
|
|
2566
|
+
const scrollable = await page.evaluate(
|
|
2567
|
+
"Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
|
|
2568
|
+
);
|
|
2569
|
+
const rawElements = await page.evaluate(`
|
|
2570
|
+
(() => {
|
|
2571
|
+
const items = [];
|
|
2572
|
+
|
|
2573
|
+
// Helper to get parent context
|
|
2574
|
+
const getCtx = (el) => {
|
|
2575
|
+
let p = el.parentElement;
|
|
2576
|
+
while (p && p !== document.body) {
|
|
2577
|
+
if (p.id) return '#' + p.id;
|
|
2578
|
+
const cls = Array.from(p.classList).find(c =>
|
|
2579
|
+
/toolbar|header|footer|sidebar|panel|modal|dialog|nav|menu|controls|actions/i.test(c)
|
|
2580
|
+
);
|
|
2581
|
+
if (cls) return '.' + cls;
|
|
2582
|
+
if (['header', 'footer', 'nav', 'main', 'aside', 'form'].includes(p.tagName.toLowerCase())) {
|
|
2583
|
+
return p.tagName.toLowerCase();
|
|
2584
|
+
}
|
|
2585
|
+
p = p.parentElement;
|
|
1328
2586
|
}
|
|
1329
|
-
|
|
1330
|
-
}
|
|
1331
|
-
return '';
|
|
1332
|
-
};
|
|
2587
|
+
return '';
|
|
2588
|
+
};
|
|
1333
2589
|
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
2590
|
+
// Buttons (use textContent to catch hidden elements too)
|
|
2591
|
+
document.querySelectorAll('button, [role="button"]').forEach(el => {
|
|
2592
|
+
const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
|
|
2593
|
+
const testId = el.getAttribute('data-testid');
|
|
2594
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
2595
|
+
if (!text && !testId && !ariaLabel) return;
|
|
2596
|
+
items.push({ type: 'button', text: text || ariaLabel || testId, testId, ctx: getCtx(el) });
|
|
2597
|
+
});
|
|
1342
2598
|
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
2599
|
+
// Inputs (including file inputs, range sliders, checkboxes, selects)
|
|
2600
|
+
document.querySelectorAll('input, textarea, select').forEach(el => {
|
|
2601
|
+
if (el.type === 'hidden') return;
|
|
2602
|
+
const testId = el.getAttribute('data-testid');
|
|
2603
|
+
const inputType = el.type || 'text';
|
|
2604
|
+
|
|
2605
|
+
// Get label text for any input type
|
|
2606
|
+
let labelText = '';
|
|
2607
|
+
if (el.labels?.[0]) {
|
|
2608
|
+
labelText = el.labels[0].textContent?.trim() || '';
|
|
2609
|
+
} else if (el.id) {
|
|
2610
|
+
const label = document.querySelector('label[for="' + el.id + '"]');
|
|
2611
|
+
if (label) labelText = label.textContent?.trim() || '';
|
|
2612
|
+
}
|
|
1357
2613
|
|
|
1358
|
-
|
|
2614
|
+
const text = labelText || el.placeholder || el.name || el.id || '';
|
|
1359
2615
|
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
2616
|
+
// Include file, range, and checkbox inputs even without text
|
|
2617
|
+
const alwaysInclude = ['file', 'range', 'checkbox'].includes(inputType);
|
|
2618
|
+
if (!text && !testId && !alwaysInclude) return;
|
|
1363
2619
|
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
2620
|
+
items.push({
|
|
2621
|
+
type: inputType,
|
|
2622
|
+
text: text || (inputType === 'file' ? 'file upload' : inputType === 'range' ? 'slider' : inputType === 'checkbox' ? 'checkbox' : testId),
|
|
2623
|
+
testId,
|
|
2624
|
+
id: el.id,
|
|
2625
|
+
name: el.name,
|
|
2626
|
+
placeholder: el.placeholder,
|
|
2627
|
+
isFile: inputType === 'file'
|
|
2628
|
+
});
|
|
1372
2629
|
});
|
|
1373
|
-
});
|
|
1374
2630
|
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
2631
|
+
// Links (use textContent to catch hidden elements too)
|
|
2632
|
+
document.querySelectorAll('a[href]').forEach(el => {
|
|
2633
|
+
const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
|
|
2634
|
+
if (!text || text.length < 2) return;
|
|
2635
|
+
// Keep the destination, not just the label. Without it the planner
|
|
2636
|
+
// can name a page in a caption but has no way to go there, so every
|
|
2637
|
+
// line gets forced onto whatever is already on screen.
|
|
2638
|
+
let href = '';
|
|
2639
|
+
try {
|
|
2640
|
+
const u = new URL(el.getAttribute('href') || '', location.href);
|
|
2641
|
+
if (u.origin === location.origin) href = u.href;
|
|
2642
|
+
} catch { /* a javascript: or mailto: href is not somewhere to go */ }
|
|
2643
|
+
items.push({ type: 'link', text, href });
|
|
2644
|
+
});
|
|
1380
2645
|
|
|
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
|
-
|
|
2646
|
+
return items.slice(0, 40);
|
|
2647
|
+
})()
|
|
2648
|
+
`);
|
|
2649
|
+
const filteredElements = rawElements.filter((el) => {
|
|
2650
|
+
if (!el.text) return true;
|
|
2651
|
+
const text = el.text.toLowerCase().trim();
|
|
2652
|
+
return !EXCLUDED_LINK_PATTERNS.some((pattern) => text.includes(pattern));
|
|
2653
|
+
});
|
|
2654
|
+
const btnTexts = {};
|
|
2655
|
+
for (const el of filteredElements) {
|
|
2656
|
+
if (el.type === "button" && el.text) {
|
|
2657
|
+
btnTexts[el.text] = (btnTexts[el.text] || 0) + 1;
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
const elements = filteredElements.map((el) => {
|
|
2661
|
+
let selector;
|
|
2662
|
+
if (el.testId) {
|
|
2663
|
+
selector = `[data-testid="${el.testId}"]`;
|
|
2664
|
+
} else if (el.isFile || el.type === "file") {
|
|
2665
|
+
selector = 'input[type="file"]';
|
|
2666
|
+
} else if (el.type === "range") {
|
|
2667
|
+
selector = el.id ? idSelector(el.id) : 'input[type="range"]';
|
|
2668
|
+
} else if (el.type === "checkbox") {
|
|
2669
|
+
selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
|
|
2670
|
+
} else if (el.type === "button") {
|
|
2671
|
+
if (btnTexts[el.text] === 1) {
|
|
2672
|
+
selector = `text="${el.text}"`;
|
|
2673
|
+
} else {
|
|
2674
|
+
selector = el.ctx ? `${el.ctx} >> text="${el.text}"` : `button:has-text("${el.text}")`;
|
|
2675
|
+
}
|
|
2676
|
+
} else if (el.type === "link") {
|
|
1407
2677
|
selector = `text="${el.text}"`;
|
|
2678
|
+
} else if (el.id) {
|
|
2679
|
+
selector = idSelector(el.id);
|
|
2680
|
+
} else if (el.name) {
|
|
2681
|
+
selector = `[name="${el.name}"]`;
|
|
2682
|
+
} else if (el.placeholder) {
|
|
2683
|
+
selector = `[placeholder="${el.placeholder}"]`;
|
|
1408
2684
|
} else {
|
|
1409
|
-
selector =
|
|
2685
|
+
selector = `text="${el.text}"`;
|
|
1410
2686
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
2687
|
+
let normalizedType;
|
|
2688
|
+
if (el.isFile || el.type === "file") {
|
|
2689
|
+
normalizedType = "file";
|
|
2690
|
+
} else if (el.type === "range") {
|
|
2691
|
+
normalizedType = "range";
|
|
2692
|
+
} else if (el.type === "checkbox") {
|
|
2693
|
+
normalizedType = "checkbox";
|
|
2694
|
+
} else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
|
|
2695
|
+
normalizedType = "select";
|
|
2696
|
+
} else if (el.type === "button" || el.type === "link") {
|
|
2697
|
+
normalizedType = el.type;
|
|
2698
|
+
} else {
|
|
2699
|
+
normalizedType = "input";
|
|
2700
|
+
}
|
|
2701
|
+
const action = getActionForType(normalizedType);
|
|
2702
|
+
const target = normalizedType === "file" ? selector : (el.text || "").toLowerCase();
|
|
2703
|
+
return { type: normalizedType, text: el.text, selector, action, target, href: el.href };
|
|
2704
|
+
});
|
|
2705
|
+
await browser.close();
|
|
2706
|
+
for (const route of await sitemapRoutes(url)) {
|
|
2707
|
+
if (elements.some((e) => e.href === route.href)) continue;
|
|
2708
|
+
elements.push({
|
|
2709
|
+
type: "link",
|
|
2710
|
+
text: route.text,
|
|
2711
|
+
selector: "",
|
|
2712
|
+
action: "click",
|
|
2713
|
+
target: route.text,
|
|
2714
|
+
href: route.href
|
|
2715
|
+
});
|
|
1435
2716
|
}
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
2717
|
+
crawlTask.succeed(`Found ${elements.length} interactive elements`);
|
|
2718
|
+
crawlCache.set(cacheKey2, { timestamp: Date.now(), title, elements: [...elements], scrollable });
|
|
2719
|
+
if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
|
|
2720
|
+
const oldest = crawlCache.keys().next().value;
|
|
2721
|
+
if (oldest !== void 0) crawlCache.delete(oldest);
|
|
2722
|
+
}
|
|
2723
|
+
return { title, elements, scrollable };
|
|
2724
|
+
} finally {
|
|
2725
|
+
await browser.close().catch(() => {
|
|
2726
|
+
});
|
|
1446
2727
|
}
|
|
1447
|
-
return { title, elements, scrollable };
|
|
1448
2728
|
}
|
|
1449
2729
|
|
|
1450
2730
|
// src/lib/cursor.ts
|
|
@@ -1557,9 +2837,13 @@ function parseAIJson(content) {
|
|
|
1557
2837
|
|
|
1558
2838
|
// src/lib/prompts.ts
|
|
1559
2839
|
init_utils();
|
|
1560
|
-
function buildSystemPrompt(maxSteps) {
|
|
2840
|
+
function buildSystemPrompt(maxSteps, profile) {
|
|
1561
2841
|
let scope;
|
|
1562
|
-
if (
|
|
2842
|
+
if (profile === "wide") {
|
|
2843
|
+
scope = `SCOPE: Educational tutorial (${maxSteps} steps max) - comprehensive, structured coverage of every major feature area, in a logical teaching order`;
|
|
2844
|
+
} else if (profile === "short") {
|
|
2845
|
+
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`;
|
|
2846
|
+
} else if (maxSteps <= 5) {
|
|
1563
2847
|
scope = `SCOPE: Minimal demo (${maxSteps} steps max) - ONE complete user journey only`;
|
|
1564
2848
|
} else if (maxSteps <= 8) {
|
|
1565
2849
|
scope = `SCOPE: Standard demo (${maxSteps} steps max) - One journey with 1-2 options`;
|
|
@@ -1571,9 +2855,47 @@ function buildSystemPrompt(maxSteps) {
|
|
|
1571
2855
|
${scope}
|
|
1572
2856
|
|
|
1573
2857
|
CORE FLOW (always follow this structure):
|
|
1574
|
-
1. wait -
|
|
2858
|
+
1. wait - the hook, over the app's own card (see OPENING AND CLOSING)
|
|
1575
2859
|
2. Actions - Complete ONE user journey (upload\u2192process\u2192result)
|
|
1576
|
-
3.
|
|
2860
|
+
3. Stop. The closing card is appended for you - do not write one
|
|
2861
|
+
|
|
2862
|
+
NARRATION STYLE:
|
|
2863
|
+
\u2022 Professional and direct, like a good tutorial - never condescending, never explaining as if to a child
|
|
2864
|
+
\u2022 NEVER mention step numbers or sequencing ("step one", "first step", "in this step") - just say what is happening
|
|
2865
|
+
\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
|
|
2866
|
+
\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
|
|
2867
|
+
\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
|
|
2868
|
+
\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
|
|
2869
|
+
\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
|
|
2870
|
+
\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
|
|
2871
|
+
\u2022 Caption length drives step length - keep captions tight so the video moves
|
|
2872
|
+
|
|
2873
|
+
OPENING AND CLOSING (a fixed template - both cards are built for you):
|
|
2874
|
+
\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
|
|
2875
|
+
\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
|
|
2876
|
+
\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
|
|
2877
|
+
\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
|
|
2878
|
+
\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
|
|
2879
|
+
|
|
2880
|
+
HOW IT SHOULD SOUND (this is read aloud, not printed on a slide):
|
|
2881
|
+
\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
|
|
2882
|
+
\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
|
|
2883
|
+
\u2022 The free tier is "try it free", or just "free". Never "free to start", which reads as a pricing table talking
|
|
2884
|
+
\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
|
|
2885
|
+
\u2022 Say the thing, do not build up to it. No "in this video we will", no "let's take a look at"
|
|
2886
|
+
|
|
2887
|
+
WHAT TO COVER (a tour of tabs is a menu, not a demo):
|
|
2888
|
+
\u2022 Lead with the outcome the app produces, not the screens it has
|
|
2889
|
+
\u2022 Say where that outcome ENDS UP when the app puts it somewhere - search results, AI assistant answers, directories, an app store
|
|
2890
|
+
\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
|
|
2891
|
+
\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
|
|
2892
|
+
\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
|
|
2893
|
+
\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
|
|
2894
|
+
\u2022 Prefer one feature shown working over three named in passing
|
|
2895
|
+
\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
|
|
2896
|
+
\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
|
|
2897
|
+
\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
|
|
2898
|
+
\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
2899
|
|
|
1578
2900
|
TARGET SYNTAX (simplified):
|
|
1579
2901
|
\u2022 Buttons/Links: Use plain text like "extract frame", "download" (case insensitive, partial match OK)
|
|
@@ -1596,9 +2918,12 @@ FORBIDDEN:
|
|
|
1596
2918
|
|
|
1597
2919
|
ACTION FORMATS (STRICT - follow exactly):
|
|
1598
2920
|
\u2022 click: { "action": "click", "target": "extract frame" } - use simplified button text
|
|
2921
|
+
\u2022 hover: { "action": "hover", "target": "pricing card" } - point the cursor at a feature while the caption explains it, without clicking
|
|
1599
2922
|
\u2022 type: { "action": "type", "target": "input[name=\\"field\\"]", "value": "text to type" }
|
|
1600
2923
|
\u2022 upload: { "action": "upload", "target": "input[type=\\"file\\"]", "value": "[file]" }
|
|
2924
|
+
\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
2925
|
\u2022 wait: { "action": "wait" } - for intro/outro captions
|
|
2926
|
+
\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
2927
|
\u2022 scroll: { "action": "scroll", "value": "-80" } - negative=zoom out, positive=zoom in
|
|
1603
2928
|
\u2022 drag: { "action": "drag", "target": "input[type=\\"range\\"]", "value": "50" }
|
|
1604
2929
|
\u2022 select: { "action": "select", "target": "select[name=\\"..\\"]", "value": "option" }
|
|
@@ -1623,6 +2948,17 @@ Purpose: ${description}` : "";
|
|
|
1623
2948
|
const selectables = elements.filter((e) => e.action === "select");
|
|
1624
2949
|
const checkables = elements.filter((e) => e.action === "check");
|
|
1625
2950
|
const elementDict = [];
|
|
2951
|
+
const navigables = elements.filter((e) => e.href);
|
|
2952
|
+
const seenHref = /* @__PURE__ */ new Set();
|
|
2953
|
+
const linkTargets = navigables.filter((e) => {
|
|
2954
|
+
if (!e.href || seenHref.has(e.href)) return false;
|
|
2955
|
+
seenHref.add(e.href);
|
|
2956
|
+
return true;
|
|
2957
|
+
});
|
|
2958
|
+
if (linkTargets.length > 0) {
|
|
2959
|
+
elementDict.push(`NAVIGATE targets (links found on the page; same-origin paths not listed here are also allowed):
|
|
2960
|
+
${linkTargets.map((e) => `\u2022 "${e.text}" -> ${e.href}`).join("\n")}`);
|
|
2961
|
+
}
|
|
1626
2962
|
if (clickables.length > 0) {
|
|
1627
2963
|
elementDict.push(`CLICK targets (use simplified text):
|
|
1628
2964
|
${clickables.map((e) => `\u2022 "${e.target || e.text.toLowerCase()}"`).join("\n")}`);
|
|
@@ -1689,7 +3025,16 @@ TARGET SYNTAX:
|
|
|
1689
3025
|
}
|
|
1690
3026
|
|
|
1691
3027
|
// src/lib/styles.ts
|
|
1692
|
-
import {
|
|
3028
|
+
import {
|
|
3029
|
+
assTimeFromMs,
|
|
3030
|
+
buildAssHeader,
|
|
3031
|
+
chunkWordsToLines,
|
|
3032
|
+
fittingMaxChars,
|
|
3033
|
+
hexToAss as hexToAss2,
|
|
3034
|
+
mergePunctuationTokens,
|
|
3035
|
+
scaleToFit,
|
|
3036
|
+
toCaptionCase
|
|
3037
|
+
} from "@spark-apps/video-kit";
|
|
1693
3038
|
var CURSOR_MOVE_DURATION = 800;
|
|
1694
3039
|
var CURSOR_CSS = `
|
|
1695
3040
|
#qp-cursor {
|
|
@@ -1728,16 +3073,250 @@ var CURSOR_SCRIPT = `
|
|
|
1728
3073
|
cursor.style.top = window.innerHeight / 2 + 'px';
|
|
1729
3074
|
})();
|
|
1730
3075
|
`;
|
|
1731
|
-
function generateASSHeader(width, height, font, size, primaryColor, secondaryColor, outlineColor, backColor, bold, outlineWidth, shadow, marginBottom) {
|
|
3076
|
+
function generateASSHeader(width, height, font, size, primaryColor, secondaryColor, outlineColor, backColor, bold, outlineWidth, shadow, marginBottom, alignment = 2) {
|
|
1732
3077
|
return buildAssHeader({
|
|
1733
3078
|
title: "QuickPeek Demo",
|
|
1734
3079
|
width,
|
|
1735
3080
|
height,
|
|
1736
3081
|
styles: [
|
|
1737
|
-
`Style: Karaoke,${font},${size},${primaryColor},${secondaryColor},${outlineColor},${backColor},${bold ? 1 : 0},0,0,0,100,100,0,0,1,${outlineWidth},${shadow},
|
|
3082
|
+
`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
3083
|
]
|
|
1739
3084
|
});
|
|
1740
3085
|
}
|
|
3086
|
+
var SIDE_MARGIN = 40;
|
|
3087
|
+
function resolveCaptionFont(font) {
|
|
3088
|
+
if (process.platform === "linux" && font === "Arial Black") return "DejaVu Sans";
|
|
3089
|
+
return font;
|
|
3090
|
+
}
|
|
3091
|
+
function boldOverride(captions) {
|
|
3092
|
+
if (captions.highlight.bold === captions.regular.bold) return { lit: "", rest: "" };
|
|
3093
|
+
return {
|
|
3094
|
+
lit: captions.highlight.bold ? "\\b1" : "\\b0",
|
|
3095
|
+
rest: captions.regular.bold ? "\\b1" : "\\b0"
|
|
3096
|
+
};
|
|
3097
|
+
}
|
|
3098
|
+
function positionToAlignment(position, leftAligned = false) {
|
|
3099
|
+
const base = leftAligned ? 1 : 2;
|
|
3100
|
+
if (position === "center") return base + 3;
|
|
3101
|
+
if (position === "top") return base + 6;
|
|
3102
|
+
return base;
|
|
3103
|
+
}
|
|
3104
|
+
function positionMarginV(position, margin) {
|
|
3105
|
+
return position === "center" ? 0 : margin;
|
|
3106
|
+
}
|
|
3107
|
+
function packLines(words, maxChars) {
|
|
3108
|
+
return chunkWordsToLines(mergePunctuationTokens(words), maxChars).filter((line) => line.length > 0);
|
|
3109
|
+
}
|
|
3110
|
+
function renderKaraokeAss(ctx) {
|
|
3111
|
+
const { captions, width, height } = ctx;
|
|
3112
|
+
const regularColor = hexToAss2(captions.regular.color);
|
|
3113
|
+
const highlightColor = hexToAss2(captions.highlight.color);
|
|
3114
|
+
const outline = hexToAss2(captions.outline);
|
|
3115
|
+
const back = hexToAss2(captions.outline, 128);
|
|
3116
|
+
const highlightScale = Math.round(captions.highlight.size / captions.regular.size * 100);
|
|
3117
|
+
const header = generateASSHeader(
|
|
3118
|
+
width,
|
|
3119
|
+
height,
|
|
3120
|
+
resolveCaptionFont(captions.font),
|
|
3121
|
+
captions.regular.size,
|
|
3122
|
+
highlightColor,
|
|
3123
|
+
regularColor,
|
|
3124
|
+
outline,
|
|
3125
|
+
back,
|
|
3126
|
+
captions.regular.bold,
|
|
3127
|
+
captions.outlineWidth,
|
|
3128
|
+
captions.shadow,
|
|
3129
|
+
positionMarginV(captions.position, captions.marginBottom),
|
|
3130
|
+
positionToAlignment(captions.position)
|
|
3131
|
+
);
|
|
3132
|
+
const usable = width - SIDE_MARGIN * 2;
|
|
3133
|
+
const maxChars = fittingMaxChars(width, captions.regular.size, SIDE_MARGIN, captions.maxChars, true);
|
|
3134
|
+
const lines = [];
|
|
3135
|
+
for (const cue of ctx.cues) {
|
|
3136
|
+
for (const line of packLines(cue.words, maxChars)) {
|
|
3137
|
+
const plain = line.map((w) => w.word).join(" ");
|
|
3138
|
+
const lineScale = scaleToFit(plain, captions.regular.size, usable, true);
|
|
3139
|
+
const popScale = Math.round(highlightScale * lineScale / 100);
|
|
3140
|
+
const boldTags = boldOverride(captions);
|
|
3141
|
+
let karaokeText = "";
|
|
3142
|
+
for (const word of line) {
|
|
3143
|
+
const duration = Math.round((word.end - word.start) / 10);
|
|
3144
|
+
karaokeText += `{\\kf${duration}\\fscx${popScale}\\fscy${popScale}${boldTags.lit}}${word.word}{\\fscx${lineScale}\\fscy${lineScale}${boldTags.rest}} `;
|
|
3145
|
+
}
|
|
3146
|
+
const start = assTimeFromMs(line[0].start);
|
|
3147
|
+
const end = assTimeFromMs(line[line.length - 1].end);
|
|
3148
|
+
lines.push(`Dialogue: 0,${start},${end},Karaoke,,0,0,0,,${karaokeText.trim()}`);
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
return `${header}${lines.join("\n")}
|
|
3152
|
+
`;
|
|
3153
|
+
}
|
|
3154
|
+
function renderHormoziAss(ctx) {
|
|
3155
|
+
const { captions, width, height } = ctx;
|
|
3156
|
+
const font = resolveCaptionFont(captions.font);
|
|
3157
|
+
const primary = hexToAss2(captions.regular.color);
|
|
3158
|
+
const highlight = hexToAss2(captions.highlight.color);
|
|
3159
|
+
const outline = hexToAss2(captions.outline);
|
|
3160
|
+
const back = hexToAss2(captions.outline, 128);
|
|
3161
|
+
const alignment = positionToAlignment(captions.position);
|
|
3162
|
+
const marginV = positionMarginV(captions.position, captions.marginBottom);
|
|
3163
|
+
const bold = captions.regular.bold ? -1 : 0;
|
|
3164
|
+
const header = buildAssHeader({
|
|
3165
|
+
title: "QuickPeek Demo",
|
|
3166
|
+
width,
|
|
3167
|
+
height,
|
|
3168
|
+
styles: [
|
|
3169
|
+
`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`
|
|
3170
|
+
],
|
|
3171
|
+
scaledBorderAndShadow: true
|
|
3172
|
+
});
|
|
3173
|
+
const maxChars = fittingMaxChars(width, captions.regular.size, SIDE_MARGIN, captions.maxChars, true);
|
|
3174
|
+
const lines = [];
|
|
3175
|
+
for (const cue of ctx.cues) {
|
|
3176
|
+
for (const line of packLines(cue.words, maxChars)) {
|
|
3177
|
+
const plain = line.map((w) => w.word).join(" ");
|
|
3178
|
+
const lineScale = scaleToFit(plain, captions.regular.size, width - SIDE_MARGIN * 2, true);
|
|
3179
|
+
const scaleTag = lineScale < 100 ? `{\\fscx${lineScale}\\fscy${lineScale}}` : "";
|
|
3180
|
+
const boldTags = boldOverride(captions);
|
|
3181
|
+
for (let i = 0; i < line.length; i++) {
|
|
3182
|
+
const word = line[i];
|
|
3183
|
+
const nextStart = i + 1 < line.length ? line[i + 1].start : line[line.length - 1].end;
|
|
3184
|
+
const text = line.map(
|
|
3185
|
+
(other, j) => j === i ? `{\\1c${highlight}${boldTags.lit}}${other.word}{\\1c${primary}${boldTags.rest}}` : other.word
|
|
3186
|
+
).join(" ");
|
|
3187
|
+
lines.push(
|
|
3188
|
+
`Dialogue: 0,${assTimeFromMs(word.start)},${assTimeFromMs(nextStart)},Default,,0,0,0,,${scaleTag}${text}`
|
|
3189
|
+
);
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
return `${header}${lines.join("\n")}
|
|
3194
|
+
`;
|
|
3195
|
+
}
|
|
3196
|
+
function looksLikeAddress(token) {
|
|
3197
|
+
return /^(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?[.,!?]?$/i.test(token);
|
|
3198
|
+
}
|
|
3199
|
+
function renderShortsAss(ctx) {
|
|
3200
|
+
const { captions, width, height } = ctx;
|
|
3201
|
+
const font = resolveCaptionFont(captions.font === "Arial" ? "Arial Black" : captions.font);
|
|
3202
|
+
const primary = hexToAss2(captions.regular.color);
|
|
3203
|
+
const highlight = hexToAss2(captions.highlight.color);
|
|
3204
|
+
const fontSize = Math.max(captions.regular.size, Math.round(height / 20));
|
|
3205
|
+
const marginV = captions.position === "bottom" ? Math.round(height * 0.24) : captions.position === "top" ? Math.round(height * 0.1) : 0;
|
|
3206
|
+
const alignment = positionToAlignment(captions.position);
|
|
3207
|
+
const outline = Math.max(2, Math.round(fontSize / 22));
|
|
3208
|
+
const upper = true;
|
|
3209
|
+
const caseOf = (t) => looksLikeAddress(t) ? t : toCaptionCase(t, upper);
|
|
3210
|
+
const maxChars = fittingMaxChars(width, fontSize, SIDE_MARGIN, captions.maxChars, upper);
|
|
3211
|
+
const header = `[Script Info]
|
|
3212
|
+
Title: QuickPeek Demo
|
|
3213
|
+
ScriptType: v4.00+
|
|
3214
|
+
PlayResX: ${width}
|
|
3215
|
+
PlayResY: ${height}
|
|
3216
|
+
ScaledBorderAndShadow: yes
|
|
3217
|
+
WrapStyle: 0
|
|
3218
|
+
|
|
3219
|
+
[V4+ Styles]
|
|
3220
|
+
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
3221
|
+
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
|
|
3222
|
+
|
|
3223
|
+
[Events]
|
|
3224
|
+
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
3225
|
+
`;
|
|
3226
|
+
const lines = [];
|
|
3227
|
+
for (const cue of ctx.cues) {
|
|
3228
|
+
for (const lineWords of packLines(cue.words, maxChars)) {
|
|
3229
|
+
const plain = lineWords.map((w) => caseOf(w.word)).join(" ");
|
|
3230
|
+
const scale = scaleToFit(plain, fontSize, width - SIDE_MARGIN * 2, upper);
|
|
3231
|
+
const scaleTag = scale < 100 ? `{\\fscx${scale}\\fscy${scale}}` : "";
|
|
3232
|
+
const boldTags = boldOverride(captions);
|
|
3233
|
+
for (let i = 0; i < lineWords.length; i++) {
|
|
3234
|
+
const text = lineWords.map(
|
|
3235
|
+
(w, j) => j === i ? `{\\c${highlight}${boldTags.lit}}${caseOf(w.word)}{\\c${primary}${boldTags.rest}}` : caseOf(w.word)
|
|
3236
|
+
).join(" ");
|
|
3237
|
+
const end = i === lineWords.length - 1 ? lineWords[i].end : lineWords[i + 1].start;
|
|
3238
|
+
lines.push(
|
|
3239
|
+
`Dialogue: 0,${assTimeFromMs(lineWords[i].start)},${assTimeFromMs(end)},Shorts,,0,0,0,,${scaleTag}${text}`
|
|
3240
|
+
);
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
return `${header}${lines.join("\n")}
|
|
3245
|
+
`;
|
|
3246
|
+
}
|
|
3247
|
+
function renderMinimalAss(ctx) {
|
|
3248
|
+
const { captions, width, height } = ctx;
|
|
3249
|
+
const font = resolveCaptionFont(captions.font);
|
|
3250
|
+
const primary = hexToAss2(captions.regular.color);
|
|
3251
|
+
const fontSize = Math.round(captions.regular.size * 0.6);
|
|
3252
|
+
const alignment = positionToAlignment(captions.position, true);
|
|
3253
|
+
const marginV = positionMarginV(captions.position, 30);
|
|
3254
|
+
const header = buildAssHeader({
|
|
3255
|
+
title: "QuickPeek Demo",
|
|
3256
|
+
width,
|
|
3257
|
+
height,
|
|
3258
|
+
styles: [
|
|
3259
|
+
// BorderStyle 3 draws an opaque box (&HB0...: mostly-opaque black) behind the text
|
|
3260
|
+
`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`
|
|
3261
|
+
],
|
|
3262
|
+
scaledBorderAndShadow: true
|
|
3263
|
+
});
|
|
3264
|
+
const maxChars = fittingMaxChars(width, fontSize, 20, captions.maxChars * 2, true);
|
|
3265
|
+
const lines = [];
|
|
3266
|
+
for (const cue of ctx.cues) {
|
|
3267
|
+
for (const line of packLines(cue.words, maxChars)) {
|
|
3268
|
+
const start = assTimeFromMs(line[0].start);
|
|
3269
|
+
const end = assTimeFromMs(line[line.length - 1].end);
|
|
3270
|
+
lines.push(`Dialogue: 0,${start},${end},MinimalBg,,0,0,0,,${line.map((w) => w.word).join(" ")}`);
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
return `${header}${lines.join("\n")}
|
|
3274
|
+
`;
|
|
3275
|
+
}
|
|
3276
|
+
var CAPTION_DESIGN_LONG_EDGE = 1920;
|
|
3277
|
+
function captionCanvas(width, height) {
|
|
3278
|
+
const longEdge = Math.max(width, height);
|
|
3279
|
+
if (!Number.isFinite(longEdge) || longEdge <= 0) return { width, height };
|
|
3280
|
+
const scale = CAPTION_DESIGN_LONG_EDGE / longEdge;
|
|
3281
|
+
return { width: Math.round(width * scale), height: Math.round(height * scale) };
|
|
3282
|
+
}
|
|
3283
|
+
function resolveCaptions(css, video) {
|
|
3284
|
+
const { width, height } = captionCanvas(video.width, video.height);
|
|
3285
|
+
const canvas = defaultCaptionsFor(width, height);
|
|
3286
|
+
const base = {
|
|
3287
|
+
...canvas,
|
|
3288
|
+
font: css.font,
|
|
3289
|
+
outline: css.outline,
|
|
3290
|
+
regular: { ...canvas.regular, color: css.regular.color },
|
|
3291
|
+
highlight: { ...canvas.highlight, color: css.highlight.color }
|
|
3292
|
+
};
|
|
3293
|
+
const over = video.captions;
|
|
3294
|
+
if (!over) return base;
|
|
3295
|
+
return {
|
|
3296
|
+
...base,
|
|
3297
|
+
...over,
|
|
3298
|
+
regular: { ...base.regular, ...over.regular },
|
|
3299
|
+
highlight: { ...base.highlight, ...over.highlight }
|
|
3300
|
+
};
|
|
3301
|
+
}
|
|
3302
|
+
function renderCaptionsAss(context) {
|
|
3303
|
+
const { width, height } = captionCanvas(context.width, context.height);
|
|
3304
|
+
const cues = context.cues.map((cue) => ({
|
|
3305
|
+
...cue,
|
|
3306
|
+
words: cue.words.map((w) => ({ ...w, word: toCaptionCase(w.word, true) }))
|
|
3307
|
+
}));
|
|
3308
|
+
const ctx = { ...context, width, height, cues };
|
|
3309
|
+
switch (ctx.captions.preset) {
|
|
3310
|
+
case "hormozi":
|
|
3311
|
+
return renderHormoziAss(ctx);
|
|
3312
|
+
case "shorts":
|
|
3313
|
+
return renderShortsAss(ctx);
|
|
3314
|
+
case "minimal":
|
|
3315
|
+
return renderMinimalAss(ctx);
|
|
3316
|
+
default:
|
|
3317
|
+
return renderKaraokeAss(ctx);
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
1741
3320
|
|
|
1742
3321
|
// src/lib/subscription-client.ts
|
|
1743
3322
|
async function registerFreeUser(email) {
|
|
@@ -1774,14 +3353,17 @@ async function registerFreeUser(email) {
|
|
|
1774
3353
|
|
|
1775
3354
|
// src/lib/tts/cache.ts
|
|
1776
3355
|
init_helpers();
|
|
1777
|
-
import { join as
|
|
1778
|
-
function canReuseVoiceover(steps, voice, existingMeta) {
|
|
3356
|
+
import { join as join4 } from "path";
|
|
3357
|
+
function canReuseVoiceover(steps, voice, rate, existingMeta) {
|
|
1779
3358
|
if (!existingMeta) {
|
|
1780
3359
|
return { canReuse: false, reason: "No existing voiceover found" };
|
|
1781
3360
|
}
|
|
1782
3361
|
if (existingMeta.voice !== voice) {
|
|
1783
3362
|
return { canReuse: false, reason: `Voice changed (${existingMeta.voice} \u2192 ${voice})` };
|
|
1784
3363
|
}
|
|
3364
|
+
if (existingMeta.rate !== rate) {
|
|
3365
|
+
return { canReuse: false, reason: `Narration speed changed (${existingMeta.rate ?? "unknown"} \u2192 ${rate})` };
|
|
3366
|
+
}
|
|
1785
3367
|
if (existingMeta.stepCount !== steps.length) {
|
|
1786
3368
|
return { canReuse: false, reason: `Step count changed (${existingMeta.stepCount} \u2192 ${steps.length})` };
|
|
1787
3369
|
}
|
|
@@ -1789,37 +3371,54 @@ function canReuseVoiceover(steps, voice, existingMeta) {
|
|
|
1789
3371
|
if (existingMeta.captionsHash === currentHash) {
|
|
1790
3372
|
return { canReuse: true };
|
|
1791
3373
|
}
|
|
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" };
|
|
3374
|
+
return { canReuse: false, reason: "Captions changed" };
|
|
1799
3375
|
}
|
|
1800
3376
|
async function loadExistingVoiceover(steps, outputDir, meta, config) {
|
|
1801
3377
|
const { estimateWordTimings: estimateWordTimings2 } = await Promise.resolve().then(() => (init_timing(), timing_exports));
|
|
1802
|
-
const voiceoverPath =
|
|
3378
|
+
const voiceoverPath = join4(outputDir, "voiceover.wav");
|
|
1803
3379
|
const stepAudio = /* @__PURE__ */ new Map();
|
|
3380
|
+
const captionsUnchanged = meta.captionsHash === getCaptionsHash(steps);
|
|
1804
3381
|
for (let i = 0; i < steps.length; i++) {
|
|
1805
3382
|
const step = steps[i];
|
|
1806
3383
|
const stepNum = i + 1;
|
|
1807
3384
|
const duration = meta.stepDurations[stepNum] || config.timing.minStepDuration;
|
|
3385
|
+
const savedWords = captionsUnchanged ? meta.stepWords?.[stepNum] : void 0;
|
|
1808
3386
|
stepAudio.set(stepNum, {
|
|
1809
3387
|
duration,
|
|
1810
|
-
words: estimateWordTimings2(step.caption || "", duration)
|
|
3388
|
+
words: savedWords && savedWords.length > 0 ? savedWords : estimateWordTimings2(step.caption || "", duration)
|
|
1811
3389
|
});
|
|
1812
3390
|
}
|
|
1813
3391
|
return { voiceoverPath, stepAudio };
|
|
1814
3392
|
}
|
|
1815
3393
|
|
|
1816
3394
|
// src/lib/tts/generate.ts
|
|
3395
|
+
import { createHash } from "crypto";
|
|
3396
|
+
import { existsSync as existsSync3 } from "fs";
|
|
3397
|
+
import { copyFile, mkdir, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
|
|
3398
|
+
import { join as join6 } from "path";
|
|
3399
|
+
import { toSpokenForm } from "@spark-apps/video-kit";
|
|
1817
3400
|
init_ffmpeg2();
|
|
1818
|
-
|
|
1819
|
-
import { join as join5 } from "path";
|
|
3401
|
+
init_utils();
|
|
1820
3402
|
|
|
1821
3403
|
// src/lib/voices.ts
|
|
1822
|
-
import { WIDE_VOICES } from "@spark-apps/video-kit";
|
|
3404
|
+
import { MALE_VOICES, MULTILINGUAL_VOICES, resolveVoice, WIDE_VOICES } from "@spark-apps/video-kit";
|
|
3405
|
+
import { MALE_VOICES as MALE_VOICES2, MULTILINGUAL_VOICES as MULTILINGUAL_VOICES2, WIDE_VOICES as WIDE_VOICES2 } from "@spark-apps/video-kit";
|
|
3406
|
+
var INTERNATIONAL_VOICE = "international";
|
|
3407
|
+
function internationalVoiceFor(lang, gender = "female") {
|
|
3408
|
+
if (!hasVoice(lang)) return null;
|
|
3409
|
+
const raw = lang.trim().toLowerCase();
|
|
3410
|
+
const multilingual = MULTILINGUAL_VOICES[raw] ?? MULTILINGUAL_VOICES[raw.slice(0, 2)];
|
|
3411
|
+
if (gender === "male") return voiceFor(lang, gender);
|
|
3412
|
+
return multilingual ?? voiceFor(lang, gender);
|
|
3413
|
+
}
|
|
3414
|
+
function hasVoice(lang) {
|
|
3415
|
+
const raw = lang.trim().toLowerCase();
|
|
3416
|
+
return Boolean(WIDE_VOICES[raw] ?? WIDE_VOICES[raw.slice(0, 2)]);
|
|
3417
|
+
}
|
|
3418
|
+
function voiceFor(lang, gender = "female") {
|
|
3419
|
+
if (!hasVoice(lang)) return null;
|
|
3420
|
+
return resolveVoice(lang, { gender, table: WIDE_VOICES, maleTable: MALE_VOICES });
|
|
3421
|
+
}
|
|
1823
3422
|
|
|
1824
3423
|
// src/lib/tts/generate.ts
|
|
1825
3424
|
init_helpers();
|
|
@@ -1828,39 +3427,78 @@ init_helpers();
|
|
|
1828
3427
|
init_helpers();
|
|
1829
3428
|
import { exec } from "child_process";
|
|
1830
3429
|
import { createWriteStream } from "fs";
|
|
1831
|
-
import { stat, unlink as
|
|
1832
|
-
import { join as
|
|
3430
|
+
import { readFile as readFile2, stat, unlink as unlink3, writeFile as writeFile2 } from "fs/promises";
|
|
3431
|
+
import { join as join5, resolve as resolve2 } from "path";
|
|
1833
3432
|
import { pipeline } from "stream/promises";
|
|
1834
3433
|
import { promisify } from "util";
|
|
1835
3434
|
var execAsync = promisify(exec);
|
|
1836
3435
|
var PYTHON_ATTEMPTS = 2;
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
3436
|
+
var TICKS_PER_MS = 1e4;
|
|
3437
|
+
var wordsSidecar = (audioPath) => `${audioPath}.words.json`;
|
|
3438
|
+
var pythonEdgeTts = null;
|
|
3439
|
+
function pythonEdgeTtsAvailable() {
|
|
3440
|
+
pythonEdgeTts ??= execAsync(isWindows ? 'wsl python3 -c "import edge_tts"' : 'python3 -c "import edge_tts"', {
|
|
3441
|
+
timeout: 1e4
|
|
3442
|
+
}).then(
|
|
3443
|
+
() => true,
|
|
3444
|
+
() => false
|
|
3445
|
+
);
|
|
3446
|
+
return pythonEdgeTts;
|
|
3447
|
+
}
|
|
3448
|
+
var lockedEngine = null;
|
|
3449
|
+
function resetVoiceEngine() {
|
|
3450
|
+
lockedEngine = null;
|
|
3451
|
+
}
|
|
3452
|
+
function xmlSafe(text) {
|
|
3453
|
+
return text.replace(/[&<>]/g, (c) => c === "&" ? " and " : " ").replace(/\s{2,}/g, " ");
|
|
3454
|
+
}
|
|
3455
|
+
async function synthesizeSpeech(rawText, outputPath, voice, rate) {
|
|
3456
|
+
const text = xmlSafe(rawText);
|
|
3457
|
+
let pythonMsg = "python edge-tts not installed";
|
|
3458
|
+
const tryPython = lockedEngine !== "node" && await pythonEdgeTtsAvailable();
|
|
3459
|
+
if (tryPython) {
|
|
3460
|
+
const attempts = lockedEngine === "python" ? PYTHON_ATTEMPTS * 2 : PYTHON_ATTEMPTS;
|
|
3461
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
3462
|
+
try {
|
|
3463
|
+
const words2 = await synthesizeWithPython(text, outputPath, voice, rate);
|
|
3464
|
+
lockedEngine = "python";
|
|
3465
|
+
return words2;
|
|
3466
|
+
} catch (err) {
|
|
3467
|
+
pythonMsg = err instanceof Error ? err.message : String(err);
|
|
3468
|
+
if (attempt < attempts - 1) await new Promise((r) => setTimeout(r, 500));
|
|
3469
|
+
}
|
|
1846
3470
|
}
|
|
1847
3471
|
}
|
|
1848
|
-
await synthesizeWithNode(text, outputPath, voice, pythonMsg);
|
|
3472
|
+
const words = await synthesizeWithNode(text, outputPath, voice, pythonMsg, rate);
|
|
3473
|
+
lockedEngine = "node";
|
|
3474
|
+
return words;
|
|
1849
3475
|
}
|
|
1850
|
-
async function synthesizeWithPython(text, outputPath, voice) {
|
|
1851
|
-
const tempScriptPath =
|
|
3476
|
+
async function synthesizeWithPython(text, outputPath, voice, rate) {
|
|
3477
|
+
const tempScriptPath = join5(resolve2(outputPath, ".."), `tts-${Date.now()}.py`);
|
|
1852
3478
|
const wslScriptPath = isWindows ? toWSLPathForTTS(resolve2(tempScriptPath)) : tempScriptPath;
|
|
1853
3479
|
const wslOutputPath = isWindows ? toWSLPathForTTS(resolve2(outputPath)) : outputPath;
|
|
1854
3480
|
const pythonScript = `
|
|
1855
|
-
import asyncio
|
|
3481
|
+
import asyncio, json
|
|
1856
3482
|
from edge_tts import Communicate
|
|
1857
3483
|
|
|
1858
3484
|
async def main():
|
|
1859
3485
|
text = ${JSON.stringify(text)}
|
|
1860
3486
|
voice = ${JSON.stringify(voice)}
|
|
1861
3487
|
output = ${JSON.stringify(wslOutputPath)}
|
|
1862
|
-
communicate = Communicate(text, voice)
|
|
1863
|
-
|
|
3488
|
+
communicate = Communicate(text, voice${rate ? `, rate=${JSON.stringify(rate)}` : ""})
|
|
3489
|
+
words = []
|
|
3490
|
+
with open(output, "wb") as f:
|
|
3491
|
+
async for chunk in communicate.stream():
|
|
3492
|
+
if chunk["type"] == "audio":
|
|
3493
|
+
f.write(chunk["data"])
|
|
3494
|
+
elif chunk["type"] == "WordBoundary":
|
|
3495
|
+
words.append({
|
|
3496
|
+
"word": chunk["text"],
|
|
3497
|
+
"start": chunk["offset"] / ${TICKS_PER_MS},
|
|
3498
|
+
"end": (chunk["offset"] + chunk["duration"]) / ${TICKS_PER_MS},
|
|
3499
|
+
})
|
|
3500
|
+
with open(output + ".words.json", "w") as f:
|
|
3501
|
+
json.dump(words, f)
|
|
1864
3502
|
|
|
1865
3503
|
asyncio.run(main())
|
|
1866
3504
|
`;
|
|
@@ -1868,24 +3506,42 @@ asyncio.run(main())
|
|
|
1868
3506
|
await writeFile2(tempScriptPath, pythonScript);
|
|
1869
3507
|
const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
|
|
1870
3508
|
await execAsync(pythonCmd, { timeout: 3e4 });
|
|
3509
|
+
const written = await stat(outputPath).catch(() => null);
|
|
3510
|
+
if (!written?.size) throw new Error("edge-tts produced no audio");
|
|
3511
|
+
return await readWordsSidecar(outputPath);
|
|
3512
|
+
} finally {
|
|
3513
|
+
await unlink3(tempScriptPath).catch(() => {
|
|
3514
|
+
});
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
async function readWordsSidecar(outputPath) {
|
|
3518
|
+
const sidecar = wordsSidecar(outputPath);
|
|
3519
|
+
try {
|
|
3520
|
+
const parsed = JSON.parse(await readFile2(sidecar, "utf-8"));
|
|
3521
|
+
return parsed.length > 0 ? parsed : null;
|
|
3522
|
+
} catch {
|
|
3523
|
+
return null;
|
|
1871
3524
|
} finally {
|
|
1872
|
-
await
|
|
3525
|
+
await unlink3(sidecar).catch(() => {
|
|
1873
3526
|
});
|
|
1874
3527
|
}
|
|
1875
3528
|
}
|
|
1876
|
-
async function synthesizeWithNode(text, outputPath, voice, pythonMsg, maxRetries = 2) {
|
|
3529
|
+
async function synthesizeWithNode(text, outputPath, voice, pythonMsg, rate, maxRetries = 2) {
|
|
1877
3530
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1878
3531
|
try {
|
|
1879
3532
|
const { MsEdgeTTS, OUTPUT_FORMAT } = await import("msedge-tts");
|
|
1880
3533
|
const tts = new MsEdgeTTS();
|
|
1881
|
-
await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3);
|
|
1882
|
-
const { audioStream } = tts.toStream(text);
|
|
3534
|
+
await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3, { wordBoundaryEnabled: true });
|
|
3535
|
+
const { audioStream, metadataStream } = tts.toStream(text, rate ? { rate } : void 0);
|
|
3536
|
+
const metadata = [];
|
|
3537
|
+
metadataStream?.on("data", (chunk) => metadata.push(chunk.toString()));
|
|
1883
3538
|
await pipeline(audioStream, createWriteStream(outputPath));
|
|
3539
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1884
3540
|
const { size } = await stat(outputPath);
|
|
1885
3541
|
if (size === 0) throw new Error("No audio data received");
|
|
1886
|
-
return;
|
|
3542
|
+
return parseWordBoundaries(metadata.join(""));
|
|
1887
3543
|
} catch (nodeErr) {
|
|
1888
|
-
await
|
|
3544
|
+
await unlink3(outputPath).catch(() => {
|
|
1889
3545
|
});
|
|
1890
3546
|
if (attempt === maxRetries) {
|
|
1891
3547
|
const nodeMsg = nodeErr instanceof Error ? nodeErr.message : String(nodeErr);
|
|
@@ -1894,21 +3550,206 @@ async function synthesizeWithNode(text, outputPath, voice, pythonMsg, maxRetries
|
|
|
1894
3550
|
await new Promise((r) => setTimeout(r, 500));
|
|
1895
3551
|
}
|
|
1896
3552
|
}
|
|
3553
|
+
return null;
|
|
3554
|
+
}
|
|
3555
|
+
function parseWordBoundaries(raw) {
|
|
3556
|
+
const words = [];
|
|
3557
|
+
for (const frame of splitJsonObjects(raw)) {
|
|
3558
|
+
let parsed;
|
|
3559
|
+
try {
|
|
3560
|
+
parsed = JSON.parse(frame);
|
|
3561
|
+
} catch {
|
|
3562
|
+
continue;
|
|
3563
|
+
}
|
|
3564
|
+
for (const entry of parsed.Metadata ?? []) {
|
|
3565
|
+
if (entry.Type !== "WordBoundary") continue;
|
|
3566
|
+
const word = entry.Data?.text?.Text;
|
|
3567
|
+
const offset = entry.Data?.Offset;
|
|
3568
|
+
const duration = entry.Data?.Duration;
|
|
3569
|
+
if (!word || offset === void 0 || duration === void 0) continue;
|
|
3570
|
+
words.push({
|
|
3571
|
+
word,
|
|
3572
|
+
start: offset / TICKS_PER_MS,
|
|
3573
|
+
end: (offset + duration) / TICKS_PER_MS
|
|
3574
|
+
});
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
return words.length > 0 ? words : null;
|
|
3578
|
+
}
|
|
3579
|
+
function splitJsonObjects(raw) {
|
|
3580
|
+
const out = [];
|
|
3581
|
+
let depth = 0;
|
|
3582
|
+
let start = -1;
|
|
3583
|
+
let inString = false;
|
|
3584
|
+
let escaped = false;
|
|
3585
|
+
for (let i = 0; i < raw.length; i++) {
|
|
3586
|
+
const ch = raw[i];
|
|
3587
|
+
if (escaped) {
|
|
3588
|
+
escaped = false;
|
|
3589
|
+
continue;
|
|
3590
|
+
}
|
|
3591
|
+
if (inString) {
|
|
3592
|
+
if (ch === "\\") escaped = true;
|
|
3593
|
+
else if (ch === '"') inString = false;
|
|
3594
|
+
continue;
|
|
3595
|
+
}
|
|
3596
|
+
if (ch === '"') inString = true;
|
|
3597
|
+
else if (ch === "{") {
|
|
3598
|
+
if (depth === 0) start = i;
|
|
3599
|
+
depth++;
|
|
3600
|
+
} else if (ch === "}") {
|
|
3601
|
+
depth--;
|
|
3602
|
+
if (depth === 0 && start >= 0) {
|
|
3603
|
+
out.push(raw.slice(start, i + 1));
|
|
3604
|
+
start = -1;
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
return out;
|
|
1897
3609
|
}
|
|
1898
3610
|
|
|
1899
3611
|
// src/lib/tts/generate.ts
|
|
1900
3612
|
init_timing();
|
|
3613
|
+
init_helpers();
|
|
3614
|
+
function clipKey(spokenText, voice, rate) {
|
|
3615
|
+
return createHash("sha1").update(`${spokenText}\0${voice}\0${rate}`).digest("hex");
|
|
3616
|
+
}
|
|
3617
|
+
async function reuseClip(cacheDir, key, audioPath) {
|
|
3618
|
+
const mp3 = join6(cacheDir, `${key}.mp3`);
|
|
3619
|
+
const meta = join6(cacheDir, `${key}.json`);
|
|
3620
|
+
if (!existsSync3(mp3) || !existsSync3(meta)) return null;
|
|
3621
|
+
try {
|
|
3622
|
+
const boundaries = JSON.parse(await readFile3(meta, "utf-8"));
|
|
3623
|
+
await copyFile(mp3, audioPath);
|
|
3624
|
+
return boundaries;
|
|
3625
|
+
} catch {
|
|
3626
|
+
return null;
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
async function storeClip(cacheDir, key, audioPath, spoken) {
|
|
3630
|
+
try {
|
|
3631
|
+
await mkdir(cacheDir, { recursive: true });
|
|
3632
|
+
await copyFile(audioPath, join6(cacheDir, `${key}.mp3`));
|
|
3633
|
+
await writeFile3(join6(cacheDir, `${key}.json`), JSON.stringify(spoken ?? null));
|
|
3634
|
+
} catch {
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
var TTS_POOL = 4;
|
|
3638
|
+
function capStep(config, ms) {
|
|
3639
|
+
const cap = config.timing.maxStepDuration;
|
|
3640
|
+
return cap && cap > 0 ? Math.min(ms, cap) : ms;
|
|
3641
|
+
}
|
|
1901
3642
|
function estimateDurationFromText(text, wordsPerMinute = 150) {
|
|
1902
3643
|
const words = text.split(/\s+/).filter((w) => w.length > 0).length;
|
|
1903
3644
|
return Math.max(Math.round(words / wordsPerMinute * 60 * 1e3), 1500);
|
|
1904
3645
|
}
|
|
1905
|
-
function
|
|
1906
|
-
|
|
1907
|
-
if (
|
|
3646
|
+
function resolveVoice2(config) {
|
|
3647
|
+
const named = config.audio?.voice;
|
|
3648
|
+
if (named === INTERNATIONAL_VOICE) {
|
|
3649
|
+
return internationalVoiceFor(config.lang ?? "en", config.audio?.gender);
|
|
3650
|
+
}
|
|
3651
|
+
if (named) return named;
|
|
3652
|
+
if (config.lang) return voiceFor(config.lang, config.audio?.gender);
|
|
1908
3653
|
return null;
|
|
1909
3654
|
}
|
|
3655
|
+
function resolveRate(config) {
|
|
3656
|
+
return config.audio?.rate ?? "normal";
|
|
3657
|
+
}
|
|
3658
|
+
var ONES = [
|
|
3659
|
+
"zero",
|
|
3660
|
+
"one",
|
|
3661
|
+
"two",
|
|
3662
|
+
"three",
|
|
3663
|
+
"four",
|
|
3664
|
+
"five",
|
|
3665
|
+
"six",
|
|
3666
|
+
"seven",
|
|
3667
|
+
"eight",
|
|
3668
|
+
"nine",
|
|
3669
|
+
"ten",
|
|
3670
|
+
"eleven",
|
|
3671
|
+
"twelve",
|
|
3672
|
+
"thirteen",
|
|
3673
|
+
"fourteen",
|
|
3674
|
+
"fifteen",
|
|
3675
|
+
"sixteen",
|
|
3676
|
+
"seventeen",
|
|
3677
|
+
"eighteen",
|
|
3678
|
+
"nineteen"
|
|
3679
|
+
];
|
|
3680
|
+
var TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
|
|
3681
|
+
function speakInteger(n) {
|
|
3682
|
+
if (!Number.isInteger(n) || n < 0 || n > 999) return null;
|
|
3683
|
+
if (n < 20) return ONES[n];
|
|
3684
|
+
if (n < 100) {
|
|
3685
|
+
const t = TENS[Math.floor(n / 10)];
|
|
3686
|
+
const r2 = n % 10;
|
|
3687
|
+
return r2 ? `${t}-${ONES[r2]}` : t;
|
|
3688
|
+
}
|
|
3689
|
+
const h = `${ONES[Math.floor(n / 100)]} hundred`;
|
|
3690
|
+
const r = n % 100;
|
|
3691
|
+
return r ? `${h} and ${speakInteger(r)}` : h;
|
|
3692
|
+
}
|
|
3693
|
+
function speakNumbers(text) {
|
|
3694
|
+
return text.replace(/(\d+)\s*%/g, (m, d) => {
|
|
3695
|
+
const spoken = speakInteger(Number(d));
|
|
3696
|
+
return spoken ? `${spoken} percent` : m;
|
|
3697
|
+
}).replace(/(?<![\d.,£$€])\b(\d{1,3})\b(?![\d.,%])/g, (m, d) => speakInteger(Number(d)) ?? m);
|
|
3698
|
+
}
|
|
3699
|
+
var SPELLED_OUT = /\b([A-Z]{2,5})\b/g;
|
|
3700
|
+
var LETTER_SOUNDS = {
|
|
3701
|
+
A: "ay",
|
|
3702
|
+
B: "bee",
|
|
3703
|
+
C: "see",
|
|
3704
|
+
D: "dee",
|
|
3705
|
+
E: "ee",
|
|
3706
|
+
F: "ef",
|
|
3707
|
+
G: "gee",
|
|
3708
|
+
H: "aitch",
|
|
3709
|
+
I: "eye",
|
|
3710
|
+
J: "jay",
|
|
3711
|
+
K: "kay",
|
|
3712
|
+
L: "el",
|
|
3713
|
+
M: "em",
|
|
3714
|
+
N: "en",
|
|
3715
|
+
O: "oh",
|
|
3716
|
+
P: "pee",
|
|
3717
|
+
Q: "cue",
|
|
3718
|
+
R: "ar",
|
|
3719
|
+
S: "ess",
|
|
3720
|
+
T: "tee",
|
|
3721
|
+
U: "you",
|
|
3722
|
+
V: "vee",
|
|
3723
|
+
W: "double-you",
|
|
3724
|
+
X: "ex",
|
|
3725
|
+
Y: "why",
|
|
3726
|
+
Z: "zed"
|
|
3727
|
+
};
|
|
3728
|
+
function spellAcronyms(text) {
|
|
3729
|
+
return text.replace(
|
|
3730
|
+
SPELLED_OUT,
|
|
3731
|
+
(m) => m.split("").map((c) => LETTER_SOUNDS[c] ?? c).join(" ")
|
|
3732
|
+
);
|
|
3733
|
+
}
|
|
3734
|
+
var SPOKEN_TLD = {
|
|
3735
|
+
ai: "ay-eye"
|
|
3736
|
+
};
|
|
3737
|
+
function bindDomains(text) {
|
|
3738
|
+
return text.replace(/\b([a-z0-9][a-z0-9._+-]*)@(?=[a-z0-9][a-z0-9-]*\.[a-z]{2,24}\b)/gi, "$1-at-").replace(
|
|
3739
|
+
/\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi,
|
|
3740
|
+
(_m, host, tld) => `${host}-dot-${SPOKEN_TLD[tld.toLowerCase()] ?? tld}`
|
|
3741
|
+
);
|
|
3742
|
+
}
|
|
3743
|
+
function speakSymbols(text) {
|
|
3744
|
+
return text.replace(/\s*&\s*/g, " and ").replace(/[<>]/g, " ").replace(/\s{2,}/g, " ").trim();
|
|
3745
|
+
}
|
|
3746
|
+
function spokenForm(caption) {
|
|
3747
|
+
return speakNumbers(spellAcronyms(toSpokenForm(bindDomains(speakSymbols(caption)))));
|
|
3748
|
+
}
|
|
1910
3749
|
async function generateVoiceover(steps, config, workDir, outputDir, logError, task, info) {
|
|
1911
|
-
|
|
3750
|
+
resetVoiceEngine();
|
|
3751
|
+
const voice = resolveVoice2(config);
|
|
3752
|
+
const rate = resolveRate(config);
|
|
1912
3753
|
if (!voice) {
|
|
1913
3754
|
const stepAudio2 = /* @__PURE__ */ new Map();
|
|
1914
3755
|
for (let i = 0; i < steps.length; i++) {
|
|
@@ -1917,72 +3758,105 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError, ta
|
|
|
1917
3758
|
const actionDuration = estimateActionDuration(step);
|
|
1918
3759
|
const manualDuration = (step.duration ?? 0) * 1e3;
|
|
1919
3760
|
const mediaDuration = await getMediaStepDuration(step, outputDir);
|
|
1920
|
-
const duration =
|
|
1921
|
-
|
|
1922
|
-
actionDuration,
|
|
1923
|
-
manualDuration,
|
|
1924
|
-
mediaDuration,
|
|
1925
|
-
config.timing.minStepDuration
|
|
3761
|
+
const duration = capStep(
|
|
3762
|
+
config,
|
|
3763
|
+
Math.max(textDuration, actionDuration, manualDuration, mediaDuration, config.timing.minStepDuration)
|
|
1926
3764
|
);
|
|
1927
3765
|
stepAudio2.set(i + 1, { duration, words: step.caption ? estimateWordTimings(step.caption, duration) : [] });
|
|
1928
3766
|
}
|
|
1929
3767
|
info("No voice configured");
|
|
1930
3768
|
return { voiceoverPath: "", stepAudio: stepAudio2 };
|
|
1931
3769
|
}
|
|
1932
|
-
const audioDir =
|
|
3770
|
+
const audioDir = join6(workDir, "audio");
|
|
1933
3771
|
await mkdir(audioDir, { recursive: true });
|
|
1934
|
-
const segments = [];
|
|
1935
|
-
const stepAudio = /* @__PURE__ */ new Map();
|
|
1936
3772
|
const ttsTask = task("Generating voiceover");
|
|
1937
|
-
|
|
3773
|
+
const speed = config.audio?.speed ?? 1;
|
|
3774
|
+
const factor = speed * RATE_FACTOR[rate];
|
|
3775
|
+
const ratePercent = Math.abs(factor - 1) < 5e-3 ? void 0 : `${factor > 1 ? "+" : ""}${Math.round((factor - 1) * 100)}%`;
|
|
3776
|
+
const perStep = new Array(steps.length);
|
|
3777
|
+
const buildStep = async (idx) => {
|
|
1938
3778
|
const step = steps[idx];
|
|
1939
3779
|
const stepNum = idx + 1;
|
|
1940
3780
|
if (!step.caption) {
|
|
1941
3781
|
const actionDuration = estimateActionDuration(step);
|
|
1942
3782
|
const manualDuration = (step.duration ?? 0) * 1e3;
|
|
1943
3783
|
const mediaDuration = await getMediaStepDuration(step, outputDir);
|
|
1944
|
-
const silenceDuration =
|
|
1945
|
-
|
|
3784
|
+
const silenceDuration = capStep(
|
|
3785
|
+
config,
|
|
3786
|
+
Math.max(actionDuration, manualDuration, mediaDuration, config.timing.minStepDuration)
|
|
3787
|
+
);
|
|
3788
|
+
const silencePath = join6(audioDir, `step-${stepNum}-silence.mp3`);
|
|
1946
3789
|
await generateSilence(silencePath, silenceDuration);
|
|
1947
|
-
segments
|
|
1948
|
-
|
|
1949
|
-
continue;
|
|
3790
|
+
perStep[idx] = { segments: [silencePath], audio: { duration: silenceDuration, words: [] } };
|
|
3791
|
+
return;
|
|
1950
3792
|
}
|
|
1951
|
-
const audioPath =
|
|
1952
|
-
if (segments.length > 0) await new Promise((r) => setTimeout(r, 200));
|
|
3793
|
+
const audioPath = join6(audioDir, `step-${stepNum}.mp3`);
|
|
1953
3794
|
try {
|
|
1954
|
-
|
|
1955
|
-
|
|
3795
|
+
const spokenText = spokenForm(step.caption);
|
|
3796
|
+
const key = clipKey(spokenText, voice, ratePercent ?? "");
|
|
3797
|
+
const cacheDir = join6(outputDir, ".tts-clips");
|
|
3798
|
+
const cached = await reuseClip(cacheDir, key, audioPath);
|
|
3799
|
+
let spoken;
|
|
3800
|
+
if (cached) {
|
|
3801
|
+
spoken = cached;
|
|
3802
|
+
} else {
|
|
3803
|
+
spoken = await synthesizeSpeech(spokenText, audioPath, voice, ratePercent);
|
|
3804
|
+
await trimEdgeSilence(audioPath).catch(() => {
|
|
3805
|
+
});
|
|
3806
|
+
await storeClip(cacheDir, key, audioPath, spoken);
|
|
3807
|
+
}
|
|
3808
|
+
const clips = [audioPath];
|
|
1956
3809
|
const audioDuration = await getMediaDuration(audioPath);
|
|
1957
3810
|
const actionDuration = estimateActionDuration(step);
|
|
1958
3811
|
const manualDuration = (step.duration ?? 0) * 1e3;
|
|
1959
3812
|
const mediaDuration = await getMediaStepDuration(step, outputDir);
|
|
1960
|
-
const stepDuration =
|
|
1961
|
-
|
|
1962
|
-
config.timing.minStepDuration,
|
|
1963
|
-
actionDuration,
|
|
1964
|
-
manualDuration,
|
|
1965
|
-
mediaDuration
|
|
3813
|
+
const stepDuration = capStep(
|
|
3814
|
+
config,
|
|
3815
|
+
Math.max(audioDuration, config.timing.minStepDuration, actionDuration, manualDuration, mediaDuration)
|
|
1966
3816
|
);
|
|
1967
3817
|
if (stepDuration > audioDuration) {
|
|
1968
|
-
const paddingPath =
|
|
3818
|
+
const paddingPath = join6(audioDir, `step-${stepNum}-padding.mp3`);
|
|
1969
3819
|
await generateSilence(paddingPath, stepDuration - audioDuration);
|
|
1970
|
-
|
|
3820
|
+
clips.push(paddingPath);
|
|
1971
3821
|
}
|
|
1972
|
-
|
|
3822
|
+
const words = (config.audio?.transcribe === false ? null : spokenWordTimings(step.caption, spoken)) ?? estimateWordTimings(step.caption, audioDuration);
|
|
3823
|
+
perStep[idx] = { segments: clips, audio: { duration: stepDuration, words } };
|
|
1973
3824
|
} catch (err) {
|
|
1974
3825
|
await logError(`TTS Step ${stepNum}`, err);
|
|
1975
|
-
|
|
3826
|
+
perStep[idx] = { segments: [], audio: { duration: config.timing.minStepDuration, words: [] } };
|
|
1976
3827
|
}
|
|
3828
|
+
};
|
|
3829
|
+
await runPooled(steps.length, TTS_POOL, buildStep);
|
|
3830
|
+
const segments = [];
|
|
3831
|
+
const stepAudio = /* @__PURE__ */ new Map();
|
|
3832
|
+
for (let idx = 0; idx < steps.length; idx++) {
|
|
3833
|
+
const built = perStep[idx];
|
|
3834
|
+
if (!built) continue;
|
|
3835
|
+
segments.push(...built.segments);
|
|
3836
|
+
stepAudio.set(idx + 1, built.audio);
|
|
1977
3837
|
}
|
|
1978
3838
|
const stepsWithCaptions = steps.filter((s) => s.caption).length;
|
|
1979
3839
|
ttsTask.succeed(`${segments.length}/${stepsWithCaptions} clips`);
|
|
1980
3840
|
if (segments.length === 0) {
|
|
1981
3841
|
return { voiceoverPath: "", stepAudio };
|
|
1982
3842
|
}
|
|
3843
|
+
const leadInPath = join6(audioDir, LEAD_IN_CLIP);
|
|
3844
|
+
await generateSilence(leadInPath, NARRATION_LEAD_IN_MS);
|
|
3845
|
+
segments.unshift(leadInPath);
|
|
3846
|
+
const firstStep = stepAudio.get(1);
|
|
3847
|
+
if (firstStep) {
|
|
3848
|
+
stepAudio.set(1, {
|
|
3849
|
+
duration: firstStep.duration + NARRATION_LEAD_IN_MS,
|
|
3850
|
+
words: firstStep.words.map((w) => ({
|
|
3851
|
+
...w,
|
|
3852
|
+
start: w.start + NARRATION_LEAD_IN_MS,
|
|
3853
|
+
end: w.end + NARRATION_LEAD_IN_MS
|
|
3854
|
+
}))
|
|
3855
|
+
});
|
|
3856
|
+
}
|
|
1983
3857
|
const concatTask = task("Combining audio");
|
|
1984
3858
|
await mkdir(outputDir, { recursive: true });
|
|
1985
|
-
const voiceoverPath =
|
|
3859
|
+
const voiceoverPath = join6(outputDir, "voiceover.wav");
|
|
1986
3860
|
await concatenateAudio(segments, voiceoverPath, workDir);
|
|
1987
3861
|
concatTask.succeed("Done");
|
|
1988
3862
|
const meta = {
|
|
@@ -1990,7 +3864,11 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError, ta
|
|
|
1990
3864
|
totalCaptionLength: steps.reduce((sum, s) => sum + (s.caption?.length || 0), 0),
|
|
1991
3865
|
stepCount: steps.length,
|
|
1992
3866
|
voice,
|
|
1993
|
-
|
|
3867
|
+
rate,
|
|
3868
|
+
stepDurations: Object.fromEntries(Array.from(stepAudio.entries()).map(([k, v]) => [k, v.duration])),
|
|
3869
|
+
stepWords: Object.fromEntries(
|
|
3870
|
+
Array.from(stepAudio.entries()).filter(([, v]) => v.words.length > 0).map(([k, v]) => [k, v.words])
|
|
3871
|
+
)
|
|
1994
3872
|
};
|
|
1995
3873
|
await saveVoiceoverMeta(outputDir, meta);
|
|
1996
3874
|
return { voiceoverPath, stepAudio };
|
|
@@ -2010,16 +3888,38 @@ export {
|
|
|
2010
3888
|
CURSOR_SCRIPT,
|
|
2011
3889
|
DEFAULT_CAPTIONS,
|
|
2012
3890
|
DEFAULT_CONFIG,
|
|
3891
|
+
EMPTY_SPAN_SPEED,
|
|
2013
3892
|
EXCLUDED_LINK_PATTERNS,
|
|
2014
|
-
|
|
3893
|
+
GIF_DEFAULTS,
|
|
2015
3894
|
INTERACTIVE_SELECTORS,
|
|
2016
|
-
|
|
3895
|
+
INTERNATIONAL_VOICE,
|
|
3896
|
+
MALE_VOICES2 as MALE_VOICES,
|
|
3897
|
+
MULTILINGUAL_VOICES2 as MULTILINGUAL_VOICES,
|
|
3898
|
+
MUSIC_BED_LUFS,
|
|
3899
|
+
NARRATION_LEAD_IN_MS,
|
|
3900
|
+
NO_CLIP_OUTRO,
|
|
3901
|
+
PLAN_MAX_TOKENS,
|
|
3902
|
+
RATE_FACTOR,
|
|
3903
|
+
RELAY_BASE,
|
|
2017
3904
|
SERVER_BACKOFF_MS,
|
|
3905
|
+
SIZE_PRESETS,
|
|
3906
|
+
TRIM_EDGE_SILENCE,
|
|
2018
3907
|
VERSION,
|
|
3908
|
+
VIDEO_PROFILES,
|
|
3909
|
+
VOICE_LEAD_MS,
|
|
3910
|
+
WIDE_VOICES2 as WIDE_VOICES,
|
|
2019
3911
|
alignVoiceoverToRecording,
|
|
3912
|
+
alignWordsToCaption,
|
|
3913
|
+
applyProfile,
|
|
2020
3914
|
applyTransitions,
|
|
3915
|
+
ariaLabelSelector,
|
|
3916
|
+
atempoChain,
|
|
3917
|
+
buildCompressionFilter,
|
|
2021
3918
|
buildConcatArgs,
|
|
3919
|
+
buildGifArgs,
|
|
2022
3920
|
buildKeepSegments,
|
|
3921
|
+
buildPaletteArgs,
|
|
3922
|
+
buildRetimeFilter,
|
|
2023
3923
|
buildStepTransitions,
|
|
2024
3924
|
buildSystemPrompt,
|
|
2025
3925
|
buildUserPrompt,
|
|
@@ -2027,10 +3927,22 @@ export {
|
|
|
2027
3927
|
callAI,
|
|
2028
3928
|
callAIViaRelay,
|
|
2029
3929
|
canReuseVoiceover,
|
|
3930
|
+
captionCanvas,
|
|
2030
3931
|
composeVideo,
|
|
3932
|
+
compressSilentSpans,
|
|
2031
3933
|
concatMedia,
|
|
3934
|
+
concatVideoWithFullFrameOutro,
|
|
2032
3935
|
concatVideoWithOutro,
|
|
3936
|
+
convertToGif,
|
|
3937
|
+
coverFilterChain,
|
|
2033
3938
|
crawlPage,
|
|
3939
|
+
cutSpansFromVideo,
|
|
3940
|
+
deStock,
|
|
3941
|
+
defaultCaptionsFor,
|
|
3942
|
+
detectBlackSpans,
|
|
3943
|
+
detectFrozenSpans,
|
|
3944
|
+
detectSilences,
|
|
3945
|
+
detectWhiteSpans,
|
|
2034
3946
|
estimateWordTimings,
|
|
2035
3947
|
extractJsonCandidates,
|
|
2036
3948
|
generateASSHeader,
|
|
@@ -2040,14 +3952,21 @@ export {
|
|
|
2040
3952
|
getErrorMessage,
|
|
2041
3953
|
getLanguageName,
|
|
2042
3954
|
getMediaDuration,
|
|
3955
|
+
getOrRefreshRelayToken,
|
|
2043
3956
|
getStatus,
|
|
2044
3957
|
getStatusCached,
|
|
2045
3958
|
getTierByEmail,
|
|
2046
3959
|
getUpgradeUrl,
|
|
2047
|
-
|
|
3960
|
+
getVideoDimensions,
|
|
3961
|
+
getVideoStreamDuration,
|
|
3962
|
+
gotoSettled,
|
|
3963
|
+
hasAudioStream,
|
|
2048
3964
|
hasTrialRemaining,
|
|
3965
|
+
hasVoice,
|
|
2049
3966
|
hexToAss as hexToASS,
|
|
2050
3967
|
idSelector,
|
|
3968
|
+
internationalVoiceFor,
|
|
3969
|
+
intersectSpans,
|
|
2051
3970
|
isCanceling,
|
|
2052
3971
|
isExcludedLink,
|
|
2053
3972
|
isHonestWav,
|
|
@@ -2056,22 +3975,39 @@ export {
|
|
|
2056
3975
|
loadExistingVoiceover,
|
|
2057
3976
|
loadVoiceoverMeta,
|
|
2058
3977
|
mapTimestampsAfterCompression,
|
|
3978
|
+
mergeSpans,
|
|
2059
3979
|
normalizeUrl,
|
|
2060
3980
|
parseAIJson,
|
|
2061
3981
|
parseAIPlanResponse,
|
|
2062
3982
|
parsePartial,
|
|
3983
|
+
parseWordBoundaries,
|
|
3984
|
+
positionToAlignment,
|
|
2063
3985
|
pricingUrl,
|
|
2064
3986
|
registerFreeUser,
|
|
3987
|
+
renderCaptionsAss,
|
|
2065
3988
|
repairUnicodeEscapes,
|
|
2066
|
-
|
|
3989
|
+
resetVoiceEngine,
|
|
3990
|
+
resolveCaptionFont,
|
|
3991
|
+
resolveCaptions,
|
|
3992
|
+
resolveRate,
|
|
3993
|
+
resolveVoice2 as resolveVoice,
|
|
3994
|
+
retimeSpansInVideo,
|
|
2067
3995
|
rgbaToHex,
|
|
3996
|
+
runPooled,
|
|
2068
3997
|
salvageTruncatedJson,
|
|
2069
3998
|
shouldSkipLink,
|
|
2070
|
-
shouldWatermark,
|
|
2071
3999
|
showPaywall,
|
|
4000
|
+
speakSymbols,
|
|
4001
|
+
speedUpMiddle,
|
|
2072
4002
|
speedUpVideo,
|
|
2073
4003
|
spliceVideos,
|
|
4004
|
+
spokenForm,
|
|
4005
|
+
spokenWordTimings,
|
|
4006
|
+
stripLongDashes,
|
|
2074
4007
|
synthesizeSpeech,
|
|
2075
4008
|
toTitleCase,
|
|
4009
|
+
trimEdgeSilence,
|
|
4010
|
+
voiceFor,
|
|
4011
|
+
waitForPlaceholdersGone,
|
|
2076
4012
|
windowsDrivePathToWsl as windowsPathToWSL
|
|
2077
4013
|
};
|