@spark-apps/quickpeek 1.2.4 → 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -13
- package/dist/assets/music/manifest.json +1 -1
- package/dist/index.d.mts +49 -10
- package/dist/index.mjs +69 -22
- package/dist/mcp-tools.mjs +771 -270
- package/dist/qp.js +817 -312
- package/dist/{voices-CPpnWn39.d.mts → voices-ufga0oOn.d.mts} +37 -3
- package/dist/web.d.mts +2 -2
- package/dist/web.mjs +8 -1
- package/package.json +12 -2
package/dist/mcp-tools.mjs
CHANGED
|
@@ -230,7 +230,7 @@ function spawnFFmpeg(cmd2, args) {
|
|
|
230
230
|
return trackChild(spawn(cmd2, args));
|
|
231
231
|
}
|
|
232
232
|
function runFFmpeg(args, errorLabel) {
|
|
233
|
-
return new Promise((
|
|
233
|
+
return new Promise((resolve13, reject) => {
|
|
234
234
|
const proc = spawnFFmpeg("ffmpeg", args);
|
|
235
235
|
let stderr = "";
|
|
236
236
|
proc.stderr.on("data", (d) => {
|
|
@@ -238,7 +238,7 @@ function runFFmpeg(args, errorLabel) {
|
|
|
238
238
|
});
|
|
239
239
|
proc.on("close", (code) => {
|
|
240
240
|
if (code !== 0) reject(new Error(`${errorLabel}: ${stderr.slice(-500)}`));
|
|
241
|
-
else
|
|
241
|
+
else resolve13();
|
|
242
242
|
});
|
|
243
243
|
proc.on("error", reject);
|
|
244
244
|
});
|
|
@@ -254,6 +254,48 @@ var init_spawn = __esm({
|
|
|
254
254
|
}
|
|
255
255
|
});
|
|
256
256
|
|
|
257
|
+
// src/lib/image-kind.ts
|
|
258
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
259
|
+
function mimeForKind(kind) {
|
|
260
|
+
return MIME[kind];
|
|
261
|
+
}
|
|
262
|
+
function sniffImageKind(buf) {
|
|
263
|
+
if (buf.length < 12) return null;
|
|
264
|
+
if (buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "png";
|
|
265
|
+
if (buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "jpeg";
|
|
266
|
+
if (buf.subarray(0, 3).toString("latin1") === "GIF") return "gif";
|
|
267
|
+
if (buf.subarray(0, 4).toString("latin1") === "RIFF" && buf.subarray(8, 12).toString("latin1") === "WEBP") {
|
|
268
|
+
return "webp";
|
|
269
|
+
}
|
|
270
|
+
const head = buf.subarray(0, 1024).toString("utf8").replace(/^/, "").trimStart();
|
|
271
|
+
if (/^<(\?xml|!--|!doctype svg|svg)[\s>]/i.test(head) && /<svg[\s>]/i.test(head)) return "svg";
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
function imageDataUri(path) {
|
|
275
|
+
try {
|
|
276
|
+
const buf = readFileSync3(path);
|
|
277
|
+
const kind = sniffImageKind(buf);
|
|
278
|
+
if (!kind) return null;
|
|
279
|
+
return `data:${mimeForKind(kind)};base64,${buf.toString("base64")}`;
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
var MIME, MIN_ICON_PX;
|
|
285
|
+
var init_image_kind = __esm({
|
|
286
|
+
"src/lib/image-kind.ts"() {
|
|
287
|
+
"use strict";
|
|
288
|
+
MIME = {
|
|
289
|
+
png: "image/png",
|
|
290
|
+
jpeg: "image/jpeg",
|
|
291
|
+
gif: "image/gif",
|
|
292
|
+
webp: "image/webp",
|
|
293
|
+
svg: "image/svg+xml"
|
|
294
|
+
};
|
|
295
|
+
MIN_ICON_PX = 48;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
257
299
|
// src/lib/api-client.ts
|
|
258
300
|
function apiKey() {
|
|
259
301
|
return process.env.SPARK_AI_KEY || "";
|
|
@@ -370,7 +412,7 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
|
|
|
370
412
|
...detail ? { detail } : {},
|
|
371
413
|
...retryAfter2 ? { retryAfterSeconds: parseInt(retryAfter2, 10) } : {}
|
|
372
414
|
};
|
|
373
|
-
await new Promise((
|
|
415
|
+
await new Promise((resolve13) => setTimeout(resolve13, Math.min(delayMs, 3e4)));
|
|
374
416
|
continue;
|
|
375
417
|
}
|
|
376
418
|
const retryAfter = response.headers.get("Retry-After");
|
|
@@ -410,7 +452,7 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
|
|
|
410
452
|
return { success: false, error: `${host} unreachable: ${getErrorMessage(err)}`, host };
|
|
411
453
|
}
|
|
412
454
|
const delayMs = 1e3 * 2 ** (attempt - 1);
|
|
413
|
-
await new Promise((
|
|
455
|
+
await new Promise((resolve13) => setTimeout(resolve13, delayMs));
|
|
414
456
|
}
|
|
415
457
|
}
|
|
416
458
|
return lastRetriableError || { success: false, error: `${host}: request failed after retries`, host };
|
|
@@ -572,9 +614,6 @@ var init_SparkStripe_feature_gating = __esm({
|
|
|
572
614
|
});
|
|
573
615
|
|
|
574
616
|
// src/lib/billing.ts
|
|
575
|
-
function shouldWatermark(tier, videoDurationSeconds) {
|
|
576
|
-
return tier === "free" && videoDurationSeconds > 20;
|
|
577
|
-
}
|
|
578
617
|
function getUpgradeUrl(userEmail) {
|
|
579
618
|
return pricingUrl(userEmail ? { email: userEmail } : void 0);
|
|
580
619
|
}
|
|
@@ -609,7 +648,10 @@ function applyProfile(config, profile) {
|
|
|
609
648
|
format: "mp3",
|
|
610
649
|
bitrate: "192k",
|
|
611
650
|
...config.audio,
|
|
612
|
-
|
|
651
|
+
// outroOnly is on unless turned off, matching the video phase. The
|
|
652
|
+
// speedup buys the song its window, so it applies to the profile's own
|
|
653
|
+
// pace only: a speed somebody set by hand is the speed they wanted.
|
|
654
|
+
speed: config.audio?.speed ?? preset.audioSpeed * (config.music?.outroOnly !== false ? OUTRO_SONG_SPEEDUP : 1),
|
|
613
655
|
...config.audio?.voice || !preset.voice ? {} : { voice: preset.voice }
|
|
614
656
|
}
|
|
615
657
|
};
|
|
@@ -646,11 +688,11 @@ function defaultCaptionsFor(width, height) {
|
|
|
646
688
|
maxChars: 20
|
|
647
689
|
};
|
|
648
690
|
}
|
|
649
|
-
var VERSION, CONFIG_FILE, SIZE_PRESETS, RATE_FACTOR, NO_CLIP_OUTRO, DEFAULT_CONFIG, VIDEO_PROFILES, DEFAULT_CAPTIONS;
|
|
691
|
+
var VERSION, CONFIG_FILE, SIZE_PRESETS, RATE_FACTOR, NO_CLIP_OUTRO, DEFAULT_CONFIG, VIDEO_PROFILES, OUTRO_SONG_SPEEDUP, DEFAULT_CAPTIONS;
|
|
650
692
|
var init_config = __esm({
|
|
651
693
|
"src/lib/config.ts"() {
|
|
652
694
|
"use strict";
|
|
653
|
-
VERSION = "1.2.
|
|
695
|
+
VERSION = "1.2.5";
|
|
654
696
|
CONFIG_FILE = "quickpeek.config.json";
|
|
655
697
|
SIZE_PRESETS = {
|
|
656
698
|
wide: { width: 1920, height: 1080, zoom: 1 },
|
|
@@ -677,11 +719,12 @@ var init_config = __esm({
|
|
|
677
719
|
// ms
|
|
678
720
|
transitions: "none",
|
|
679
721
|
contrast: 0.25,
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
tempo
|
|
722
|
+
// The recording plays at the pace it was filmed at. A tempo above 1 is
|
|
723
|
+
// multiplied by the narration speed in VIDEO_PROFILES and by the fit
|
|
724
|
+
// pass's squeeze, and the three together read as frantic rather than
|
|
725
|
+
// brisk. Speed belongs in one place, so it lives in audioSpeed; set a
|
|
726
|
+
// tempo in quickpeek.config.json to push the picture on top of that.
|
|
727
|
+
tempo: 1,
|
|
685
728
|
stepDelay: 200
|
|
686
729
|
// ms delay between steps
|
|
687
730
|
},
|
|
@@ -695,18 +738,24 @@ var init_config = __esm({
|
|
|
695
738
|
// these bitrates - the encode is pure wall-clock tax on every run.
|
|
696
739
|
wide: {
|
|
697
740
|
video: { width: 1920, height: 1080, zoom: 1.5, fitLimitSecs: 0, maxSpeedup: 1, preset: "veryfast" },
|
|
698
|
-
|
|
741
|
+
// A tutorial is watched to learn something, so the narrator is allowed to
|
|
742
|
+
// be brisk but never hurried.
|
|
743
|
+
audioSpeed: 1.15
|
|
699
744
|
},
|
|
700
745
|
short: {
|
|
701
746
|
// fadeOut 0: the video ends on a card the viewer is meant to read,
|
|
702
747
|
// and a fade takes it away mid-sentence.
|
|
703
748
|
video: { width: 1080, height: 1920, zoom: 3, fitLimitSecs: 60, maxSpeedup: 1.7, preset: "veryfast", fadeOut: 0 },
|
|
704
|
-
|
|
749
|
+
// 1.8 was unwatchable: stacked on tempo 1.2 and the outro speedup it ran
|
|
750
|
+
// the voice at better than twice its natural rate. A short wants energy,
|
|
751
|
+
// which is the voice and the cutting, not the tape running fast.
|
|
752
|
+
audioSpeed: 1,
|
|
705
753
|
// A short lives or dies on energy. Ava is the warmest, most animated of
|
|
706
754
|
// the free neural voices; Sonia reads a short like the shipping forecast.
|
|
707
755
|
voice: "en-US-AvaMultilingualNeural"
|
|
708
756
|
}
|
|
709
757
|
};
|
|
758
|
+
OUTRO_SONG_SPEEDUP = 1.13;
|
|
710
759
|
DEFAULT_CAPTIONS = {
|
|
711
760
|
font: "Arial",
|
|
712
761
|
outline: "#000000",
|
|
@@ -856,6 +905,7 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
|
|
|
856
905
|
const deadline = Date.now() + cap;
|
|
857
906
|
let previous = -1;
|
|
858
907
|
let unchanged = 0;
|
|
908
|
+
let clear = 0;
|
|
859
909
|
while (Date.now() < deadline) {
|
|
860
910
|
const count = await page.evaluate((selector) => {
|
|
861
911
|
let visible = 0;
|
|
@@ -865,7 +915,12 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
|
|
|
865
915
|
}
|
|
866
916
|
return visible;
|
|
867
917
|
}, PLACEHOLDER_SELECTOR).catch(() => 0);
|
|
868
|
-
if (count === 0)
|
|
918
|
+
if (count === 0) {
|
|
919
|
+
if (++clear >= PLACEHOLDER_CLEAR_POLLS) return;
|
|
920
|
+
await page.waitForTimeout(PLACEHOLDER_POLL_MS);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
clear = 0;
|
|
869
924
|
if (count === previous) {
|
|
870
925
|
if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
|
|
871
926
|
} else {
|
|
@@ -1125,7 +1180,7 @@ async function crawlPage(url, task2 = noopTask, storageStatePath, viewport) {
|
|
|
1125
1180
|
});
|
|
1126
1181
|
}
|
|
1127
1182
|
}
|
|
1128
|
-
var SETTLE_POLL_MS, SETTLE_STABLE_POLLS, SETTLE_CAP_MS, PLACEHOLDER_POLL_MS, PLACEHOLDER_CAP_MS, PLACEHOLDER_STATIC_POLLS, PLACEHOLDER_SELECTOR, noopTask, CRAWL_CACHE_TTL_MS, CRAWL_CACHE_MAX_ENTRIES, crawlCache;
|
|
1183
|
+
var SETTLE_POLL_MS, SETTLE_STABLE_POLLS, SETTLE_CAP_MS, PLACEHOLDER_POLL_MS, PLACEHOLDER_CAP_MS, PLACEHOLDER_STATIC_POLLS, PLACEHOLDER_CLEAR_POLLS, PLACEHOLDER_SELECTOR, noopTask, CRAWL_CACHE_TTL_MS, CRAWL_CACHE_MAX_ENTRIES, crawlCache;
|
|
1129
1184
|
var init_crawl = __esm({
|
|
1130
1185
|
"src/lib/crawl.ts"() {
|
|
1131
1186
|
"use strict";
|
|
@@ -1136,6 +1191,7 @@ var init_crawl = __esm({
|
|
|
1136
1191
|
PLACEHOLDER_POLL_MS = 250;
|
|
1137
1192
|
PLACEHOLDER_CAP_MS = 8e3;
|
|
1138
1193
|
PLACEHOLDER_STATIC_POLLS = 4;
|
|
1194
|
+
PLACEHOLDER_CLEAR_POLLS = 3;
|
|
1139
1195
|
PLACEHOLDER_SELECTOR = [
|
|
1140
1196
|
'[aria-busy="true"]',
|
|
1141
1197
|
'[role="progressbar"]',
|
|
@@ -1287,13 +1343,13 @@ function canEncode(encoder) {
|
|
|
1287
1343
|
]);
|
|
1288
1344
|
}
|
|
1289
1345
|
function run(args) {
|
|
1290
|
-
return new Promise((
|
|
1346
|
+
return new Promise((resolve13) => {
|
|
1291
1347
|
let settled = false;
|
|
1292
1348
|
const done = (ok) => {
|
|
1293
1349
|
if (settled) return;
|
|
1294
1350
|
settled = true;
|
|
1295
1351
|
clearTimeout(timer);
|
|
1296
|
-
|
|
1352
|
+
resolve13(ok);
|
|
1297
1353
|
};
|
|
1298
1354
|
const proc = trackChild(spawn3("ffmpeg", args, { stdio: "ignore" }));
|
|
1299
1355
|
const timer = setTimeout(() => {
|
|
@@ -1350,8 +1406,13 @@ async function composeVideo(opts) {
|
|
|
1350
1406
|
musicPath,
|
|
1351
1407
|
musicVolume = 0.3,
|
|
1352
1408
|
musicTargetLufs = MUSIC_BED_LUFS,
|
|
1353
|
-
|
|
1354
|
-
|
|
1409
|
+
// Sized for the outro window: the song has 5.5s, so a 2s fade at each end
|
|
1410
|
+
// left 1.5s of music anyone could actually hear. Up fast, hold, out slow.
|
|
1411
|
+
musicFadeIn = 1,
|
|
1412
|
+
musicFadeOut = 1.5,
|
|
1413
|
+
tempo = 1,
|
|
1414
|
+
musicStartSecs = 0,
|
|
1415
|
+
musicDurationSecs = 0,
|
|
1355
1416
|
width,
|
|
1356
1417
|
height,
|
|
1357
1418
|
fps,
|
|
@@ -1412,7 +1473,19 @@ async function composeVideo(opts) {
|
|
|
1412
1473
|
const clipAudioLabel = opts.hasClipAudio ? "[0:a]" : "";
|
|
1413
1474
|
const hasVoiceover = !!voiceoverPath;
|
|
1414
1475
|
const hasMusic = !!musicPath;
|
|
1415
|
-
const
|
|
1476
|
+
const bedStart = Math.max(0, Math.min(musicStartSecs, Math.max(0, effectiveDuration - 0.5)));
|
|
1477
|
+
const bedLength = Math.max(0.5, effectiveDuration - bedStart);
|
|
1478
|
+
const bedDelay = bedStart > 0 ? `adelay=${Math.round(bedStart * 1e3)}|${Math.round(bedStart * 1e3)},` : "";
|
|
1479
|
+
const bedTempo = tempo > 1 ? 1 / tempo : 1;
|
|
1480
|
+
const bedSlow = bedTempo === 1 ? "" : `atempo=${bedTempo.toFixed(6)},`;
|
|
1481
|
+
const bedSource = bedLength * bedTempo;
|
|
1482
|
+
const bedSeek = musicDurationSecs > bedSource + 0.05 ? musicDurationSecs - bedSource : 0;
|
|
1483
|
+
const bedTrimIn = bedSeek > 0 ? `atrim=start=${bedSeek.toFixed(3)},asetpts=PTS-STARTPTS,` : "";
|
|
1484
|
+
const bedFadeOut = bedSeek > 0 ? 0 : musicFadeOut * tempo;
|
|
1485
|
+
const bedLoop = bedSeek > 0 ? "" : "aloop=loop=-1:size=2e+09,";
|
|
1486
|
+
const bedFadeIn = musicFadeIn * tempo;
|
|
1487
|
+
const musicFadeOutStart = Math.max(0, bedLength - bedFadeOut);
|
|
1488
|
+
const bedFadeOutFilter = bedFadeOut > 0 ? `afade=t=out:st=${musicFadeOutStart}:d=${bedFadeOut},` : "";
|
|
1416
1489
|
const vfStr = [...vf, ...encoder.filter ? [encoder.filter] : []].join(",");
|
|
1417
1490
|
if (hasVoiceover && hasMusic) {
|
|
1418
1491
|
const mixInputs = clipAudioLabel ? 3 : 2;
|
|
@@ -1428,7 +1501,7 @@ async function composeVideo(opts) {
|
|
|
1428
1501
|
// card, which is the one place the bed is the only thing playing.
|
|
1429
1502
|
`[voicekey0]apad=whole_dur=${effectiveDuration}[voicekey]`,
|
|
1430
1503
|
// Music: normalised to a known level, trimmed, then faded.
|
|
1431
|
-
`[2:a]
|
|
1504
|
+
`[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]`,
|
|
1432
1505
|
`[bed][voicekey]${DUCK}[music]`,
|
|
1433
1506
|
// normalize=0, because amix's default divides every input by the number
|
|
1434
1507
|
// of them: adding the bed used to quietly take 6 dB off the narration,
|
|
@@ -1452,7 +1525,7 @@ async function composeVideo(opts) {
|
|
|
1452
1525
|
args.push(...encoder.output);
|
|
1453
1526
|
args.push("-c:a", "aac", "-b:a", audioBitrate);
|
|
1454
1527
|
} else if (hasMusic) {
|
|
1455
|
-
const musicFilter =
|
|
1528
|
+
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}`;
|
|
1456
1529
|
args.push("-vf", vfStr);
|
|
1457
1530
|
args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
|
|
1458
1531
|
args.push(...encoder.output);
|
|
@@ -1584,7 +1657,7 @@ function cacheKey(filePath) {
|
|
|
1584
1657
|
}
|
|
1585
1658
|
}
|
|
1586
1659
|
function getVideoDimensions(filePath) {
|
|
1587
|
-
return new Promise((
|
|
1660
|
+
return new Promise((resolve13, reject) => {
|
|
1588
1661
|
const args = [
|
|
1589
1662
|
"-v",
|
|
1590
1663
|
"error",
|
|
@@ -1615,12 +1688,12 @@ function getVideoDimensions(filePath) {
|
|
|
1615
1688
|
reject(new Error(`ffprobe returned no dimensions for ${filePath}`));
|
|
1616
1689
|
return;
|
|
1617
1690
|
}
|
|
1618
|
-
|
|
1691
|
+
resolve13({ width: w, height: h });
|
|
1619
1692
|
});
|
|
1620
1693
|
});
|
|
1621
1694
|
}
|
|
1622
1695
|
function hasAudioStream(filePath) {
|
|
1623
|
-
return new Promise((
|
|
1696
|
+
return new Promise((resolve13) => {
|
|
1624
1697
|
const proc = spawnFFmpeg("ffprobe", [
|
|
1625
1698
|
"-v",
|
|
1626
1699
|
"error",
|
|
@@ -1636,15 +1709,15 @@ function hasAudioStream(filePath) {
|
|
|
1636
1709
|
proc.stdout.on("data", (d) => {
|
|
1637
1710
|
out += d;
|
|
1638
1711
|
});
|
|
1639
|
-
proc.on("close", () =>
|
|
1640
|
-
proc.on("error", () =>
|
|
1712
|
+
proc.on("close", () => resolve13(out.trim().length > 0));
|
|
1713
|
+
proc.on("error", () => resolve13(false));
|
|
1641
1714
|
});
|
|
1642
1715
|
}
|
|
1643
1716
|
function getMediaDuration(filePath) {
|
|
1644
1717
|
const key = cacheKey(filePath);
|
|
1645
1718
|
const cached = key === null ? void 0 : durationCache.get(key);
|
|
1646
1719
|
if (cached !== void 0) return Promise.resolve(cached);
|
|
1647
|
-
return new Promise((
|
|
1720
|
+
return new Promise((resolve13, reject) => {
|
|
1648
1721
|
const args = [
|
|
1649
1722
|
"-v",
|
|
1650
1723
|
"error",
|
|
@@ -1671,13 +1744,13 @@ function getMediaDuration(filePath) {
|
|
|
1671
1744
|
const ms = Math.ceil(parseFloat(stdout.trim()) * 1e3);
|
|
1672
1745
|
const freshKey = cacheKey(filePath);
|
|
1673
1746
|
if (freshKey !== null && Number.isFinite(ms)) durationCache.set(freshKey, ms);
|
|
1674
|
-
|
|
1747
|
+
resolve13(ms);
|
|
1675
1748
|
});
|
|
1676
1749
|
proc.on("error", reject);
|
|
1677
1750
|
});
|
|
1678
1751
|
}
|
|
1679
1752
|
function getAudioCodec(filePath) {
|
|
1680
|
-
return new Promise((
|
|
1753
|
+
return new Promise((resolve13) => {
|
|
1681
1754
|
const args = [
|
|
1682
1755
|
"-v",
|
|
1683
1756
|
"error",
|
|
@@ -1694,8 +1767,8 @@ function getAudioCodec(filePath) {
|
|
|
1694
1767
|
proc.stdout.on("data", (data) => {
|
|
1695
1768
|
stdout += data;
|
|
1696
1769
|
});
|
|
1697
|
-
proc.on("close", (code) =>
|
|
1698
|
-
proc.on("error", () =>
|
|
1770
|
+
proc.on("close", (code) => resolve13(code === 0 ? stdout.trim() : ""));
|
|
1771
|
+
proc.on("error", () => resolve13(""));
|
|
1699
1772
|
});
|
|
1700
1773
|
}
|
|
1701
1774
|
async function isHonestWav(filePath) {
|
|
@@ -1849,7 +1922,7 @@ var init_media = __esm({
|
|
|
1849
1922
|
|
|
1850
1923
|
// src/lib/ffmpeg/silence.ts
|
|
1851
1924
|
function getVideoStreamDuration(inputPath) {
|
|
1852
|
-
return new Promise((
|
|
1925
|
+
return new Promise((resolve13) => {
|
|
1853
1926
|
const proc = spawnFFmpeg("ffprobe", [
|
|
1854
1927
|
"-v",
|
|
1855
1928
|
"error",
|
|
@@ -1866,12 +1939,12 @@ function getVideoStreamDuration(inputPath) {
|
|
|
1866
1939
|
proc.stdout.on("data", (d) => {
|
|
1867
1940
|
out += d;
|
|
1868
1941
|
});
|
|
1869
|
-
proc.on("error", () =>
|
|
1870
|
-
proc.on("close", () =>
|
|
1942
|
+
proc.on("error", () => resolve13(0));
|
|
1943
|
+
proc.on("close", () => resolve13(Number.parseFloat(out.trim()) || 0));
|
|
1871
1944
|
});
|
|
1872
1945
|
}
|
|
1873
1946
|
function detectSilences(inputPath, minDurSecs) {
|
|
1874
|
-
return new Promise((
|
|
1947
|
+
return new Promise((resolve13) => {
|
|
1875
1948
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
1876
1949
|
"-hide_banner",
|
|
1877
1950
|
"-nostats",
|
|
@@ -1890,7 +1963,7 @@ function detectSilences(inputPath, minDurSecs) {
|
|
|
1890
1963
|
proc.stderr.on("data", (d) => {
|
|
1891
1964
|
err += d;
|
|
1892
1965
|
});
|
|
1893
|
-
proc.on("error", () =>
|
|
1966
|
+
proc.on("error", () => resolve13([]));
|
|
1894
1967
|
proc.on("close", () => {
|
|
1895
1968
|
const spans = [];
|
|
1896
1969
|
let start = null;
|
|
@@ -1905,12 +1978,12 @@ function detectSilences(inputPath, minDurSecs) {
|
|
|
1905
1978
|
start = null;
|
|
1906
1979
|
}
|
|
1907
1980
|
}
|
|
1908
|
-
|
|
1981
|
+
resolve13(spans);
|
|
1909
1982
|
});
|
|
1910
1983
|
});
|
|
1911
1984
|
}
|
|
1912
1985
|
function detectFrozenSpans(inputPath, minDurSecs) {
|
|
1913
|
-
return new Promise((
|
|
1986
|
+
return new Promise((resolve13) => {
|
|
1914
1987
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
1915
1988
|
"-hide_banner",
|
|
1916
1989
|
"-nostats",
|
|
@@ -1931,7 +2004,7 @@ function detectFrozenSpans(inputPath, minDurSecs) {
|
|
|
1931
2004
|
proc.stderr.on("data", (d) => {
|
|
1932
2005
|
err += d;
|
|
1933
2006
|
});
|
|
1934
|
-
proc.on("error", () =>
|
|
2007
|
+
proc.on("error", () => resolve13([]));
|
|
1935
2008
|
proc.on("close", () => {
|
|
1936
2009
|
const spans = [];
|
|
1937
2010
|
let start = null;
|
|
@@ -1944,12 +2017,12 @@ function detectFrozenSpans(inputPath, minDurSecs) {
|
|
|
1944
2017
|
start = null;
|
|
1945
2018
|
}
|
|
1946
2019
|
}
|
|
1947
|
-
|
|
2020
|
+
resolve13(spans);
|
|
1948
2021
|
});
|
|
1949
2022
|
});
|
|
1950
2023
|
}
|
|
1951
2024
|
function detectBlackSpans(inputPath, minDurSecs) {
|
|
1952
|
-
return new Promise((
|
|
2025
|
+
return new Promise((resolve13) => {
|
|
1953
2026
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
1954
2027
|
"-hide_banner",
|
|
1955
2028
|
"-nostats",
|
|
@@ -1972,19 +2045,19 @@ function detectBlackSpans(inputPath, minDurSecs) {
|
|
|
1972
2045
|
proc.stderr.on("data", (d) => {
|
|
1973
2046
|
err += d;
|
|
1974
2047
|
});
|
|
1975
|
-
proc.on("error", () =>
|
|
2048
|
+
proc.on("error", () => resolve13([]));
|
|
1976
2049
|
proc.on("close", () => {
|
|
1977
2050
|
const spans = [];
|
|
1978
2051
|
for (const line of err.split("\n")) {
|
|
1979
2052
|
const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
|
|
1980
2053
|
if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
|
|
1981
2054
|
}
|
|
1982
|
-
|
|
2055
|
+
resolve13(spans);
|
|
1983
2056
|
});
|
|
1984
2057
|
});
|
|
1985
2058
|
}
|
|
1986
2059
|
function detectWhiteSpans(inputPath, minDurSecs) {
|
|
1987
|
-
return new Promise((
|
|
2060
|
+
return new Promise((resolve13) => {
|
|
1988
2061
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
1989
2062
|
"-hide_banner",
|
|
1990
2063
|
"-nostats",
|
|
@@ -2002,14 +2075,14 @@ function detectWhiteSpans(inputPath, minDurSecs) {
|
|
|
2002
2075
|
proc.stderr.on("data", (d) => {
|
|
2003
2076
|
err += d;
|
|
2004
2077
|
});
|
|
2005
|
-
proc.on("error", () =>
|
|
2078
|
+
proc.on("error", () => resolve13([]));
|
|
2006
2079
|
proc.on("close", () => {
|
|
2007
2080
|
const spans = [];
|
|
2008
2081
|
for (const line of err.split("\n")) {
|
|
2009
2082
|
const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
|
|
2010
2083
|
if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
|
|
2011
2084
|
}
|
|
2012
|
-
|
|
2085
|
+
resolve13(spans);
|
|
2013
2086
|
});
|
|
2014
2087
|
});
|
|
2015
2088
|
}
|
|
@@ -2099,7 +2172,7 @@ async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
|
|
|
2099
2172
|
const drop = keep.map((s) => `between(t,${s.start.toFixed(3)},${s.end.toFixed(3)})`).join("+");
|
|
2100
2173
|
const filter = `[0:v]select='not(${drop})',setpts=N/FRAME_RATE/TB[outv]`;
|
|
2101
2174
|
const enc = await passEncoder(preset);
|
|
2102
|
-
return new Promise((
|
|
2175
|
+
return new Promise((resolve13) => {
|
|
2103
2176
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
2104
2177
|
"-y",
|
|
2105
2178
|
...filterThreadArgs(),
|
|
@@ -2117,8 +2190,8 @@ async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
|
|
|
2117
2190
|
outputPath
|
|
2118
2191
|
]);
|
|
2119
2192
|
proc.stderr.resume();
|
|
2120
|
-
proc.on("error", () =>
|
|
2121
|
-
proc.on("close", (code) =>
|
|
2193
|
+
proc.on("error", () => resolve13(false));
|
|
2194
|
+
proc.on("close", (code) => resolve13(code === 0));
|
|
2122
2195
|
});
|
|
2123
2196
|
}
|
|
2124
2197
|
function buildRetimeFilter(spans, stepBounds, speed, fps) {
|
|
@@ -2200,7 +2273,7 @@ async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, spee
|
|
|
2200
2273
|
const filter = buildRetimeFilter(spans, stepBounds, speed, fps);
|
|
2201
2274
|
if (!filter) return false;
|
|
2202
2275
|
const enc = await passEncoder(preset);
|
|
2203
|
-
return new Promise((
|
|
2276
|
+
return new Promise((resolve13) => {
|
|
2204
2277
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
2205
2278
|
"-y",
|
|
2206
2279
|
...filterThreadArgs(),
|
|
@@ -2218,8 +2291,8 @@ async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, spee
|
|
|
2218
2291
|
outputPath
|
|
2219
2292
|
]);
|
|
2220
2293
|
proc.stderr.resume();
|
|
2221
|
-
proc.on("error", () =>
|
|
2222
|
-
proc.on("close", (code) =>
|
|
2294
|
+
proc.on("error", () => resolve13(false));
|
|
2295
|
+
proc.on("close", (code) => resolve13(code === 0));
|
|
2223
2296
|
});
|
|
2224
2297
|
}
|
|
2225
2298
|
async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, preset, protectAfterSecs, narrationPath, protectBeforeSecs, leadSpans, keepSpans) {
|
|
@@ -2245,7 +2318,7 @@ async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, pr
|
|
|
2245
2318
|
const filter = buildCompressionFilter(spans, totalSecs, maxSecs, protectAfterSecs, protectBeforeSecs);
|
|
2246
2319
|
if (!filter) return false;
|
|
2247
2320
|
const enc = await passEncoder(preset);
|
|
2248
|
-
return new Promise((
|
|
2321
|
+
return new Promise((resolve13) => {
|
|
2249
2322
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
2250
2323
|
"-y",
|
|
2251
2324
|
...filterThreadArgs(),
|
|
@@ -2271,8 +2344,8 @@ async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, pr
|
|
|
2271
2344
|
outputPath
|
|
2272
2345
|
]);
|
|
2273
2346
|
proc.stderr.resume();
|
|
2274
|
-
proc.on("error", () =>
|
|
2275
|
-
proc.on("close", (code) =>
|
|
2347
|
+
proc.on("error", () => resolve13(false));
|
|
2348
|
+
proc.on("close", (code) => resolve13(code === 0));
|
|
2276
2349
|
});
|
|
2277
2350
|
}
|
|
2278
2351
|
async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, introSecs, outroSecs, maxFactor, preset) {
|
|
@@ -2296,7 +2369,7 @@ async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, intro
|
|
|
2296
2369
|
"[v0][a0][v1][a1][v2][a2]concat=n=3:v=1:a=1[outv][outa]"
|
|
2297
2370
|
].join(";");
|
|
2298
2371
|
const enc = await passEncoder(preset);
|
|
2299
|
-
const ok = await new Promise((
|
|
2372
|
+
const ok = await new Promise((resolve13) => {
|
|
2300
2373
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
2301
2374
|
"-y",
|
|
2302
2375
|
...filterThreadArgs(),
|
|
@@ -2317,8 +2390,8 @@ async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, intro
|
|
|
2317
2390
|
outputPath
|
|
2318
2391
|
]);
|
|
2319
2392
|
proc.stderr.resume();
|
|
2320
|
-
proc.on("error", () =>
|
|
2321
|
-
proc.on("close", (code) =>
|
|
2393
|
+
proc.on("error", () => resolve13(false));
|
|
2394
|
+
proc.on("close", (code) => resolve13(code === 0));
|
|
2322
2395
|
});
|
|
2323
2396
|
return { ok, factor, reachedTarget: needed <= maxFactor };
|
|
2324
2397
|
}
|
|
@@ -2354,7 +2427,7 @@ async function spliceVideos(opts) {
|
|
|
2354
2427
|
const audioPresent = await Promise.all(
|
|
2355
2428
|
overlays.map((o) => o.still ? Promise.resolve(false) : hasAudioStream(o.path))
|
|
2356
2429
|
);
|
|
2357
|
-
return new Promise((
|
|
2430
|
+
return new Promise((resolve13, reject) => {
|
|
2358
2431
|
const args = ["-y", "-i", inputPath];
|
|
2359
2432
|
for (const overlay of overlays) {
|
|
2360
2433
|
if (overlay.still) {
|
|
@@ -2417,7 +2490,7 @@ async function spliceVideos(opts) {
|
|
|
2417
2490
|
args.push("-c:v", "libx264", "-preset", preset, "-crf", "18", "-pix_fmt", "yuv420p");
|
|
2418
2491
|
if (clipAudio.length > 0) args.push("-c:a", "aac");
|
|
2419
2492
|
args.push(outputPath);
|
|
2420
|
-
runFFmpeg(args, "ffmpeg splice failed").then(
|
|
2493
|
+
runFFmpeg(args, "ffmpeg splice failed").then(resolve13, reject);
|
|
2421
2494
|
});
|
|
2422
2495
|
}
|
|
2423
2496
|
var CLIP_AUDIO_GAIN, STILL_FRAMERATE, COVER_WASH_W, COVER_WASH_H, COVER_BLUR_SIGMA;
|
|
@@ -2453,10 +2526,10 @@ function buildStepTransitions(steps, stepDurations, defaultTransition) {
|
|
|
2453
2526
|
return transitions;
|
|
2454
2527
|
}
|
|
2455
2528
|
function applyTransitions(opts) {
|
|
2456
|
-
return new Promise((
|
|
2529
|
+
return new Promise((resolve13, reject) => {
|
|
2457
2530
|
const { inputVideo, outputVideo, transitions, videoDuration, preset = "fast" } = opts;
|
|
2458
2531
|
if (transitions.length === 0) {
|
|
2459
|
-
runFFmpeg(["-y", "-i", inputVideo, "-c", "copy", outputVideo], "FFmpeg copy failed").then(
|
|
2532
|
+
runFFmpeg(["-y", "-i", inputVideo, "-c", "copy", outputVideo], "FFmpeg copy failed").then(resolve13, reject);
|
|
2460
2533
|
return;
|
|
2461
2534
|
}
|
|
2462
2535
|
const sorted = [...transitions].sort((a, b) => a.timestamp - b.timestamp);
|
|
@@ -2504,7 +2577,7 @@ function applyTransitions(opts) {
|
|
|
2504
2577
|
"yuv420p",
|
|
2505
2578
|
outputVideo
|
|
2506
2579
|
];
|
|
2507
|
-
runFFmpeg(args, "FFmpeg transitions failed").then(
|
|
2580
|
+
runFFmpeg(args, "FFmpeg transitions failed").then(resolve13, reject);
|
|
2508
2581
|
});
|
|
2509
2582
|
}
|
|
2510
2583
|
var init_transitions = __esm({
|
|
@@ -2977,6 +3050,9 @@ function renderHormoziAss(ctx) {
|
|
|
2977
3050
|
return `${header2}${lines.join("\n")}
|
|
2978
3051
|
`;
|
|
2979
3052
|
}
|
|
3053
|
+
function looksLikeAddress(token) {
|
|
3054
|
+
return /^(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?[.,!?]?$/i.test(token);
|
|
3055
|
+
}
|
|
2980
3056
|
function renderShortsAss(ctx) {
|
|
2981
3057
|
const { captions, width, height } = ctx;
|
|
2982
3058
|
const font = resolveCaptionFont(captions.font === "Arial" ? "Arial Black" : captions.font);
|
|
@@ -2987,7 +3063,7 @@ function renderShortsAss(ctx) {
|
|
|
2987
3063
|
const alignment = positionToAlignment(captions.position);
|
|
2988
3064
|
const outline = Math.max(2, Math.round(fontSize / 22));
|
|
2989
3065
|
const upper = true;
|
|
2990
|
-
const caseOf = (t) => toCaptionCase(t, upper);
|
|
3066
|
+
const caseOf = (t) => looksLikeAddress(t) ? t : toCaptionCase(t, upper);
|
|
2991
3067
|
const maxChars = fittingMaxChars(width, fontSize, SIDE_MARGIN, captions.maxChars, upper);
|
|
2992
3068
|
const header2 = `[Script Info]
|
|
2993
3069
|
Title: QuickPeek Demo
|
|
@@ -3549,6 +3625,8 @@ asyncio.run(main())
|
|
|
3549
3625
|
await writeFile3(tempScriptPath, pythonScript);
|
|
3550
3626
|
const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
|
|
3551
3627
|
await execAsync(pythonCmd, { timeout: 3e4 });
|
|
3628
|
+
const written = await stat(outputPath).catch(() => null);
|
|
3629
|
+
if (!written?.size) throw new Error("edge-tts produced no audio");
|
|
3552
3630
|
return await readWordsSidecar(outputPath);
|
|
3553
3631
|
} finally {
|
|
3554
3632
|
await unlink4(tempScriptPath).catch(() => {
|
|
@@ -3736,11 +3814,17 @@ function spellAcronyms(text) {
|
|
|
3736
3814
|
);
|
|
3737
3815
|
}
|
|
3738
3816
|
function bindDomains(text) {
|
|
3739
|
-
return text.replace(/\b([a-z0-9][a-z0-9
|
|
3817
|
+
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(
|
|
3818
|
+
/\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi,
|
|
3819
|
+
(_m, host, tld) => `${host}-dot-${SPOKEN_TLD[tld.toLowerCase()] ?? tld}`
|
|
3820
|
+
);
|
|
3740
3821
|
}
|
|
3741
3822
|
function speakSymbols(text) {
|
|
3742
3823
|
return text.replace(/\s*&\s*/g, " and ").replace(/[<>]/g, " ").replace(/\s{2,}/g, " ").trim();
|
|
3743
3824
|
}
|
|
3825
|
+
function spokenForm(caption) {
|
|
3826
|
+
return speakNumbers(spellAcronyms(toSpokenForm(bindDomains(speakSymbols(caption)))));
|
|
3827
|
+
}
|
|
3744
3828
|
async function generateVoiceover(steps, config, workDir, outputDir, logError2, task2, info2) {
|
|
3745
3829
|
resetVoiceEngine();
|
|
3746
3830
|
const voice = resolveVoice2(config);
|
|
@@ -3787,7 +3871,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
|
|
|
3787
3871
|
}
|
|
3788
3872
|
const audioPath = join9(audioDir, `step-${stepNum}.mp3`);
|
|
3789
3873
|
try {
|
|
3790
|
-
const spokenText =
|
|
3874
|
+
const spokenText = spokenForm(step.caption);
|
|
3791
3875
|
const key = clipKey(spokenText, voice, ratePercent ?? "");
|
|
3792
3876
|
const cacheDir = join9(outputDir, ".tts-clips");
|
|
3793
3877
|
const cached = await reuseClip(cacheDir, key, audioPath);
|
|
@@ -3868,7 +3952,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
|
|
|
3868
3952
|
await saveVoiceoverMeta(outputDir, meta);
|
|
3869
3953
|
return { voiceoverPath, stepAudio };
|
|
3870
3954
|
}
|
|
3871
|
-
var TTS_POOL, ONES, TENS, SPELLED_OUT, LETTER_SOUNDS;
|
|
3955
|
+
var TTS_POOL, ONES, TENS, SPELLED_OUT, LETTER_SOUNDS, SPOKEN_TLD;
|
|
3872
3956
|
var init_generate = __esm({
|
|
3873
3957
|
"src/lib/tts/generate.ts"() {
|
|
3874
3958
|
"use strict";
|
|
@@ -3933,6 +4017,9 @@ var init_generate = __esm({
|
|
|
3933
4017
|
Y: "why",
|
|
3934
4018
|
Z: "zed"
|
|
3935
4019
|
};
|
|
4020
|
+
SPOKEN_TLD = {
|
|
4021
|
+
ai: "ay-eye"
|
|
4022
|
+
};
|
|
3936
4023
|
}
|
|
3937
4024
|
});
|
|
3938
4025
|
|
|
@@ -3992,6 +4079,7 @@ __export(lib_exports, {
|
|
|
3992
4079
|
NO_CLIP_OUTRO: () => NO_CLIP_OUTRO,
|
|
3993
4080
|
PLAN_MAX_TOKENS: () => PLAN_MAX_TOKENS,
|
|
3994
4081
|
RATE_FACTOR: () => RATE_FACTOR,
|
|
4082
|
+
RELAY_BASE: () => RELAY_BASE,
|
|
3995
4083
|
SERVER_BACKOFF_MS: () => SERVER_BACKOFF_MS,
|
|
3996
4084
|
SIZE_PRESETS: () => SIZE_PRESETS,
|
|
3997
4085
|
TRIM_EDGE_SILENCE: () => TRIM_EDGE_SILENCE,
|
|
@@ -4043,6 +4131,7 @@ __export(lib_exports, {
|
|
|
4043
4131
|
getErrorMessage: () => getErrorMessage,
|
|
4044
4132
|
getLanguageName: () => getLanguageName,
|
|
4045
4133
|
getMediaDuration: () => getMediaDuration,
|
|
4134
|
+
getOrRefreshRelayToken: () => getOrRefreshRelayToken,
|
|
4046
4135
|
getStatus: () => getStatus,
|
|
4047
4136
|
getStatusCached: () => getStatusCached,
|
|
4048
4137
|
getTierByEmail: () => getTierByEmail,
|
|
@@ -4086,12 +4175,12 @@ __export(lib_exports, {
|
|
|
4086
4175
|
runPooled: () => runPooled,
|
|
4087
4176
|
salvageTruncatedJson: () => salvageTruncatedJson,
|
|
4088
4177
|
shouldSkipLink: () => shouldSkipLink,
|
|
4089
|
-
shouldWatermark: () => shouldWatermark,
|
|
4090
4178
|
showPaywall: () => showPaywall,
|
|
4091
4179
|
speakSymbols: () => speakSymbols,
|
|
4092
4180
|
speedUpMiddle: () => speedUpMiddle,
|
|
4093
4181
|
speedUpVideo: () => speedUpVideo,
|
|
4094
4182
|
spliceVideos: () => spliceVideos,
|
|
4183
|
+
spokenForm: () => spokenForm,
|
|
4095
4184
|
spokenWordTimings: () => spokenWordTimings,
|
|
4096
4185
|
stripLongDashes: () => stripLongDashes,
|
|
4097
4186
|
synthesizeSpeech: () => synthesizeSpeech,
|
|
@@ -4401,7 +4490,10 @@ async function loadCaptions() {
|
|
|
4401
4490
|
async function savePlan(plan, planPath) {
|
|
4402
4491
|
const cleanPlan = {
|
|
4403
4492
|
...plan,
|
|
4404
|
-
steps: plan.steps.map(({ duration, ...step }) =>
|
|
4493
|
+
steps: plan.steps.map(({ duration, ...step }) => ({
|
|
4494
|
+
...step,
|
|
4495
|
+
...typeof duration === "number" && duration > 0 ? { duration: Math.min(duration, 30) } : {}
|
|
4496
|
+
}))
|
|
4405
4497
|
};
|
|
4406
4498
|
await mkdir4(dirname2(planPath), { recursive: true });
|
|
4407
4499
|
await writeFile5(planPath, JSON.stringify(cleanPlan, null, 2));
|
|
@@ -4514,55 +4606,16 @@ var init_mcp_context = __esm({
|
|
|
4514
4606
|
}
|
|
4515
4607
|
});
|
|
4516
4608
|
|
|
4517
|
-
// src/lib/image-kind.ts
|
|
4518
|
-
function mimeForKind(kind) {
|
|
4519
|
-
return MIME[kind];
|
|
4520
|
-
}
|
|
4521
|
-
function sniffImageKind(buf) {
|
|
4522
|
-
if (buf.length < 12) return null;
|
|
4523
|
-
if (buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "png";
|
|
4524
|
-
if (buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "jpeg";
|
|
4525
|
-
if (buf.subarray(0, 3).toString("latin1") === "GIF") return "gif";
|
|
4526
|
-
if (buf.subarray(0, 4).toString("latin1") === "RIFF" && buf.subarray(8, 12).toString("latin1") === "WEBP") {
|
|
4527
|
-
return "webp";
|
|
4528
|
-
}
|
|
4529
|
-
const head = buf.subarray(0, 1024).toString("utf8").replace(/^/, "").trimStart();
|
|
4530
|
-
if (/^<(\?xml|!--|!doctype svg|svg)[\s>]/i.test(head) && /<svg[\s>]/i.test(head)) return "svg";
|
|
4531
|
-
return null;
|
|
4532
|
-
}
|
|
4533
|
-
var MIME, MIN_ICON_PX;
|
|
4534
|
-
var init_image_kind = __esm({
|
|
4535
|
-
"src/lib/image-kind.ts"() {
|
|
4536
|
-
"use strict";
|
|
4537
|
-
MIME = {
|
|
4538
|
-
png: "image/png",
|
|
4539
|
-
jpeg: "image/jpeg",
|
|
4540
|
-
gif: "image/gif",
|
|
4541
|
-
webp: "image/webp",
|
|
4542
|
-
svg: "image/svg+xml"
|
|
4543
|
-
};
|
|
4544
|
-
MIN_ICON_PX = 48;
|
|
4545
|
-
}
|
|
4546
|
-
});
|
|
4547
|
-
|
|
4548
4609
|
// src/lib/outro-card.ts
|
|
4549
|
-
import { readFileSync as readFileSync5 } from "fs";
|
|
4550
|
-
function dataUri(path) {
|
|
4551
|
-
try {
|
|
4552
|
-
const buf = readFileSync5(path);
|
|
4553
|
-
const kind = sniffImageKind(buf);
|
|
4554
|
-
if (!kind) return null;
|
|
4555
|
-
return `data:${mimeForKind(kind)};base64,${buf.toString("base64")}`;
|
|
4556
|
-
} catch {
|
|
4557
|
-
return null;
|
|
4558
|
-
}
|
|
4559
|
-
}
|
|
4560
4610
|
function escapeHtml(text) {
|
|
4561
4611
|
return text.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
4562
4612
|
}
|
|
4563
4613
|
function buildOutroCardHtml(options) {
|
|
4564
4614
|
const { subject, credits = [], promo, width, height } = options;
|
|
4565
4615
|
const unit = Math.min(width, height);
|
|
4616
|
+
const markFraction = width > height ? 0.28 : 0.46;
|
|
4617
|
+
const markSize = unit * markFraction;
|
|
4618
|
+
const padTop = width > height ? unit * 0.08 : unit * 0.23;
|
|
4566
4619
|
const creditRows = credits.map((credit) => {
|
|
4567
4620
|
const icon = credit.iconPath ? dataUri(credit.iconPath) : null;
|
|
4568
4621
|
return `<div class="credit">
|
|
@@ -4571,7 +4624,8 @@ function buildOutroCardHtml(options) {
|
|
|
4571
4624
|
${icon ? `<img class="cicon" src="${icon}" alt="" />` : ""}
|
|
4572
4625
|
</div>`;
|
|
4573
4626
|
}).join("");
|
|
4574
|
-
const
|
|
4627
|
+
const animated = subject.animatedMark;
|
|
4628
|
+
const subjectIcon = animated ? null : subject.iconPath ? dataUri(subject.iconPath) : null;
|
|
4575
4629
|
return `<!doctype html>
|
|
4576
4630
|
<html><head><meta charset="utf-8" /><style>
|
|
4577
4631
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
@@ -4589,7 +4643,7 @@ function buildOutroCardHtml(options) {
|
|
|
4589
4643
|
position: relative;
|
|
4590
4644
|
display: flex; flex-direction: column;
|
|
4591
4645
|
align-items: center; justify-content: flex-start;
|
|
4592
|
-
padding-top: ${
|
|
4646
|
+
padding-top: ${padTop}px;
|
|
4593
4647
|
gap: ${unit * 0.028}px;
|
|
4594
4648
|
}
|
|
4595
4649
|
/* Toolchain credits: top right, deliberately small. */
|
|
@@ -4607,7 +4661,7 @@ function buildOutroCardHtml(options) {
|
|
|
4607
4661
|
/* The app the video is about. */
|
|
4608
4662
|
/* The app's mark leads the card, and it is meant to be seen: at 46% of
|
|
4609
4663
|
the short edge it reads at a glance on a phone. */
|
|
4610
|
-
.mark { width: ${
|
|
4664
|
+
.mark { width: ${markSize}px; height: ${markSize}px; object-fit: contain; border-radius: ${unit * 0.09}px; }
|
|
4611
4665
|
/* A dark mark on a dark card is invisible, which looks exactly like a
|
|
4612
4666
|
failed image. Sites that publish a black-on-transparent logo are common
|
|
4613
4667
|
enough to be worth catching, and a light plate is what their own site
|
|
@@ -4629,8 +4683,12 @@ function buildOutroCardHtml(options) {
|
|
|
4629
4683
|
}
|
|
4630
4684
|
|
|
4631
4685
|
/* Today's offer, along the very bottom edge. */
|
|
4686
|
+
/* In the flow, not pinned to the bottom edge. Pinned, it was laid out
|
|
4687
|
+
without reference to the text above it, so on a short canvas it simply
|
|
4688
|
+
covered whatever had grown into its space. margin-top pushes it clear of
|
|
4689
|
+
the domain instead. */
|
|
4632
4690
|
.promo {
|
|
4633
|
-
|
|
4691
|
+
margin-top: ${unit * 0.05}px;
|
|
4634
4692
|
display: flex; align-items: baseline; gap: ${unit * 0.022}px;
|
|
4635
4693
|
padding: ${unit * 0.026}px ${unit * 0.055}px;
|
|
4636
4694
|
border: 2px solid rgba(255, 209, 64, .85); border-radius: ${unit * 0.024}px;
|
|
@@ -4638,9 +4696,60 @@ function buildOutroCardHtml(options) {
|
|
|
4638
4696
|
}
|
|
4639
4697
|
.ppct { font-size: ${unit * 0.056}px; font-weight: 700; color: #ffd140; }
|
|
4640
4698
|
.pcode { font-size: ${unit * 0.048}px; font-weight: 700; letter-spacing: .05em; }
|
|
4699
|
+
|
|
4700
|
+
/* The animated mark, sized to the same 46% slot the still one occupies so
|
|
4701
|
+
the rest of the card's rhythm is unchanged. Ported from VidLet's
|
|
4702
|
+
src/gui/css/loader.css: the amber tip flies off and snaps back into the
|
|
4703
|
+
cut - a jump cut, on a loop. Pure CSS, because the renderer steps these
|
|
4704
|
+
animations frame by frame and a JS-driven one could not be stepped.
|
|
4705
|
+
|
|
4706
|
+
No prefers-reduced-motion fallback here, unlike the original: this runs
|
|
4707
|
+
in a headless Chromium rendering a video, where there is no viewer whose
|
|
4708
|
+
preference could be read, and honouring the default would silently flatten
|
|
4709
|
+
the animation to a fade in CI. */
|
|
4710
|
+
.amark { width: ${markSize}px; height: ${markSize}px; display: block; }
|
|
4711
|
+
.amark path {
|
|
4712
|
+
/* Without fill-box the origin is the whole SVG viewport, which swings the
|
|
4713
|
+
tip off its own centre on the scale steps. */
|
|
4714
|
+
transform-box: fill-box;
|
|
4715
|
+
transform-origin: center;
|
|
4716
|
+
}
|
|
4717
|
+
.amark .vl-body { fill: #fff6e5; stroke: #fff6e5; animation: vl-nudge 1.4s cubic-bezier(.4,0,.2,1) infinite; }
|
|
4718
|
+
.amark .vl-tip { fill: #ffc53d; stroke: #ffc53d; animation: vl-cut 1.4s cubic-bezier(.4,0,.2,1) infinite; }
|
|
4719
|
+
.amark .vl-ghost { opacity: .35; }
|
|
4720
|
+
.amark .vl-ghost .vl-tip { animation-delay: -.1s; }
|
|
4721
|
+
|
|
4722
|
+
/* Translations are in viewBox units, so the motion scales with the mark. */
|
|
4723
|
+
@keyframes vl-cut {
|
|
4724
|
+
0%, 30% { transform: translateX(0) scale(1); opacity: 1; }
|
|
4725
|
+
48% { transform: translateX(62px) scale(.94); opacity: 1; }
|
|
4726
|
+
60% { transform: translateX(88px) scale(.86); opacity: 0; }
|
|
4727
|
+
61%, 74% { transform: translateX(-58px) scale(.86); opacity: 0; }
|
|
4728
|
+
86% { transform: translateX(6px) scale(1.06); opacity: 1; }
|
|
4729
|
+
100% { transform: translateX(0) scale(1); opacity: 1; }
|
|
4730
|
+
}
|
|
4731
|
+
/* The body takes the recoil, so the cut lands rather than just sliding. */
|
|
4732
|
+
@keyframes vl-nudge {
|
|
4733
|
+
0%, 30% { transform: scaleX(1); }
|
|
4734
|
+
55% { transform: scaleX(1.04); }
|
|
4735
|
+
86% { transform: scaleX(.97); }
|
|
4736
|
+
100% { transform: scaleX(1); }
|
|
4737
|
+
}
|
|
4738
|
+
${/* The card normally reads its palette off the mark's pixels, and an SVG
|
|
4739
|
+
drawn by this stylesheet has none to read - the measuring script finds
|
|
4740
|
+
no <img class="mark"> and leaves the defaults, which would put VidLet's
|
|
4741
|
+
amber on a teal card. Stated instead of measured, last so it wins. */
|
|
4742
|
+
animated === "vidlet" ? ":root { --bg-in: #1f1a10; --bg-out: #07060a; --accent: #ffc53d; }" : ""}
|
|
4641
4743
|
</style></head>
|
|
4642
4744
|
<body>
|
|
4643
4745
|
${credits.length ? `<div class="credits">${creditRows}</div>` : ""}
|
|
4746
|
+
${animated === "vidlet" ? `<svg class="amark" viewBox="0 0 512 512" aria-hidden="true">
|
|
4747
|
+
<g stroke-linejoin="round" stroke-width="20">
|
|
4748
|
+
<g class="vl-ghost"><path class="vl-tip" d="M318 246 L410 302 L318 358 Z"/></g>
|
|
4749
|
+
<path class="vl-body" d="M176 124 L300 200 L300 312 L176 388 Z"/>
|
|
4750
|
+
<path class="vl-tip" d="M318 246 L410 302 L318 358 Z"/>
|
|
4751
|
+
</g>
|
|
4752
|
+
</svg>` : ""}
|
|
4644
4753
|
${subjectIcon ? `<img class="mark" src="${subjectIcon}" alt="" />` : ""}
|
|
4645
4754
|
<div class="name">${escapeHtml(subject.name)}</div>
|
|
4646
4755
|
${subject.tagline ? `<div class="tagline">${escapeHtml(subject.tagline)}</div>` : ""}
|
|
@@ -4743,10 +4852,18 @@ function buildOutroCardHtml(options) {
|
|
|
4743
4852
|
</script>
|
|
4744
4853
|
</body></html>`;
|
|
4745
4854
|
}
|
|
4855
|
+
var ANIMATED_MARK_LOOP_SECONDS, dataUri;
|
|
4746
4856
|
var init_outro_card = __esm({
|
|
4747
4857
|
"src/lib/outro-card.ts"() {
|
|
4748
4858
|
"use strict";
|
|
4749
4859
|
init_image_kind();
|
|
4860
|
+
ANIMATED_MARK_LOOP_SECONDS = {
|
|
4861
|
+
// Matches `vl-cut` / `vl-nudge` in VidLet's src/gui/css/loader.css. Changing
|
|
4862
|
+
// it there without changing it here makes the encoded loop stutter at the
|
|
4863
|
+
// seam, because the clip is cut to exactly this length and then repeated.
|
|
4864
|
+
vidlet: 1.4
|
|
4865
|
+
};
|
|
4866
|
+
dataUri = imageDataUri;
|
|
4750
4867
|
}
|
|
4751
4868
|
});
|
|
4752
4869
|
|
|
@@ -4756,8 +4873,8 @@ __export(render_outro_exports, {
|
|
|
4756
4873
|
renderOutroClip: () => renderOutroClip,
|
|
4757
4874
|
renderOutroPng: () => renderOutroPng
|
|
4758
4875
|
});
|
|
4759
|
-
import { writeFile as writeFile8 } from "fs/promises";
|
|
4760
|
-
import { join as join15, resolve as
|
|
4876
|
+
import { mkdir as mkdir7, rm as rm2, writeFile as writeFile8 } from "fs/promises";
|
|
4877
|
+
import { join as join15, resolve as resolve6 } from "path";
|
|
4761
4878
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
4762
4879
|
import { chromium as chromium6 } from "playwright";
|
|
4763
4880
|
async function renderOutroPng(options) {
|
|
@@ -4768,7 +4885,7 @@ async function renderOutroPng(options) {
|
|
|
4768
4885
|
const browser = await chromium6.launch({ headless: true });
|
|
4769
4886
|
try {
|
|
4770
4887
|
const page = await browser.newPage({ viewport: { width, height } });
|
|
4771
|
-
await page.goto(pathToFileURL2(
|
|
4888
|
+
await page.goto(pathToFileURL2(resolve6(htmlPath)).href, { waitUntil: "load" });
|
|
4772
4889
|
await page.waitForSelector('body[data-cards-ready="1"]', { timeout: 3e3 }).catch(() => page.waitForTimeout(300));
|
|
4773
4890
|
await page.screenshot({ path: pngPath });
|
|
4774
4891
|
} finally {
|
|
@@ -4781,11 +4898,14 @@ async function renderOutroClip(options) {
|
|
|
4781
4898
|
const htmlPath = join15(workDir, "outro-card.html");
|
|
4782
4899
|
const pngPath = join15(workDir, "outro-card.png");
|
|
4783
4900
|
const clipPath = join15(workDir, "outro-card.mp4");
|
|
4901
|
+
if (options.subject.animatedMark) {
|
|
4902
|
+
return renderAnimatedOutroClip(options, { htmlPath, clipPath, seconds, fps });
|
|
4903
|
+
}
|
|
4784
4904
|
await writeFile8(htmlPath, buildOutroCardHtml(options));
|
|
4785
4905
|
const browser = await chromium6.launch({ headless: true });
|
|
4786
4906
|
try {
|
|
4787
4907
|
const page = await browser.newPage({ viewport: { width, height } });
|
|
4788
|
-
await page.goto(pathToFileURL2(
|
|
4908
|
+
await page.goto(pathToFileURL2(resolve6(htmlPath)).href, { waitUntil: "load" });
|
|
4789
4909
|
await page.waitForSelector('body[data-cards-ready="1"]', { timeout: 3e3 }).catch(() => {
|
|
4790
4910
|
});
|
|
4791
4911
|
await page.waitForTimeout(300);
|
|
@@ -4823,6 +4943,70 @@ async function renderOutroClip(options) {
|
|
|
4823
4943
|
);
|
|
4824
4944
|
return clipPath;
|
|
4825
4945
|
}
|
|
4946
|
+
async function renderAnimatedOutroClip(options, paths) {
|
|
4947
|
+
const { htmlPath, clipPath, seconds, fps } = paths;
|
|
4948
|
+
const { workDir, width, height } = options;
|
|
4949
|
+
const loopSeconds = ANIMATED_MARK_LOOP_SECONDS[options.subject.animatedMark];
|
|
4950
|
+
const frameCount = Math.max(1, Math.round(loopSeconds * fps));
|
|
4951
|
+
const framesDir = join15(workDir, "outro-frames");
|
|
4952
|
+
await rm2(framesDir, { recursive: true, force: true });
|
|
4953
|
+
await mkdir7(framesDir, { recursive: true });
|
|
4954
|
+
await writeFile8(htmlPath, buildOutroCardHtml(options));
|
|
4955
|
+
const browser = await chromium6.launch({ headless: true });
|
|
4956
|
+
try {
|
|
4957
|
+
const page = await browser.newPage({ viewport: { width, height } });
|
|
4958
|
+
await page.goto(pathToFileURL2(resolve6(htmlPath)).href, { waitUntil: "load" });
|
|
4959
|
+
await page.waitForSelector('body[data-cards-ready="1"]', { timeout: 3e3 }).catch(() => {
|
|
4960
|
+
});
|
|
4961
|
+
await page.waitForTimeout(300);
|
|
4962
|
+
await page.evaluate(() => {
|
|
4963
|
+
for (const animation of document.getAnimations()) animation.pause();
|
|
4964
|
+
});
|
|
4965
|
+
for (let frame = 0; frame < frameCount; frame++) {
|
|
4966
|
+
const elapsedMs = frame / frameCount * loopSeconds * 1e3;
|
|
4967
|
+
await page.evaluate((ms) => {
|
|
4968
|
+
for (const animation of document.getAnimations()) animation.currentTime = ms;
|
|
4969
|
+
}, elapsedMs);
|
|
4970
|
+
await page.screenshot({ path: join15(framesDir, `f-${String(frame).padStart(4, "0")}.png`) });
|
|
4971
|
+
}
|
|
4972
|
+
} finally {
|
|
4973
|
+
await browser.close();
|
|
4974
|
+
}
|
|
4975
|
+
await runFFmpeg(
|
|
4976
|
+
[
|
|
4977
|
+
"-y",
|
|
4978
|
+
// -stream_loop applies to the input that FOLLOWS it, so it has to sit
|
|
4979
|
+
// before -framerate/-i, not after.
|
|
4980
|
+
"-stream_loop",
|
|
4981
|
+
"-1",
|
|
4982
|
+
"-framerate",
|
|
4983
|
+
String(fps),
|
|
4984
|
+
"-i",
|
|
4985
|
+
join15(framesDir, "f-%04d.png"),
|
|
4986
|
+
"-f",
|
|
4987
|
+
"lavfi",
|
|
4988
|
+
"-i",
|
|
4989
|
+
"anullsrc=channel_layout=stereo:sample_rate=44100",
|
|
4990
|
+
"-t",
|
|
4991
|
+
String(seconds),
|
|
4992
|
+
"-r",
|
|
4993
|
+
String(fps),
|
|
4994
|
+
"-pix_fmt",
|
|
4995
|
+
"yuv420p",
|
|
4996
|
+
"-c:v",
|
|
4997
|
+
"libx264",
|
|
4998
|
+
"-preset",
|
|
4999
|
+
"veryfast",
|
|
5000
|
+
"-c:a",
|
|
5001
|
+
"aac",
|
|
5002
|
+
"-shortest",
|
|
5003
|
+
clipPath
|
|
5004
|
+
],
|
|
5005
|
+
"ffmpeg animated outro card render failed"
|
|
5006
|
+
);
|
|
5007
|
+
await rm2(framesDir, { recursive: true, force: true });
|
|
5008
|
+
return clipPath;
|
|
5009
|
+
}
|
|
4826
5010
|
var init_render_outro = __esm({
|
|
4827
5011
|
"src/browser/render-outro.ts"() {
|
|
4828
5012
|
"use strict";
|
|
@@ -4890,11 +5074,11 @@ __export(external_clips_exports, {
|
|
|
4890
5074
|
findStepClips: () => findStepClips,
|
|
4891
5075
|
loadExternalNarration: () => loadExternalNarration
|
|
4892
5076
|
});
|
|
4893
|
-
import { existsSync as
|
|
5077
|
+
import { existsSync as existsSync22, mkdirSync as mkdirSync2, readdirSync as readdirSync4, rmSync as rmSync3 } from "fs";
|
|
4894
5078
|
import { join as join27 } from "path";
|
|
4895
5079
|
function findStepClips(dir) {
|
|
4896
5080
|
const clips = /* @__PURE__ */ new Map();
|
|
4897
|
-
if (!
|
|
5081
|
+
if (!existsSync22(dir)) return clips;
|
|
4898
5082
|
for (const name of readdirSync4(dir)) {
|
|
4899
5083
|
const match = /^step-0*(\d+)\.(wav|mp3|m4a|flac|ogg)$/i.exec(name);
|
|
4900
5084
|
if (match?.[1]) clips.set(Number(match[1]), join27(dir, name));
|
|
@@ -5147,8 +5331,8 @@ async function fileResult(output, data, poster) {
|
|
|
5147
5331
|
}
|
|
5148
5332
|
|
|
5149
5333
|
// src/mcp/tools.ts
|
|
5150
|
-
import { existsSync as
|
|
5151
|
-
import { basename as basename5, join as join33, resolve as
|
|
5334
|
+
import { existsSync as existsSync27, mkdirSync as mkdirSync3, readdirSync as readdirSync6, readFileSync as readFileSync10, statSync as statSync4 } from "fs";
|
|
5335
|
+
import { basename as basename5, dirname as dirname5, join as join33, resolve as resolve12 } from "path";
|
|
5152
5336
|
import { MALE_VOICES as MALE_VOICES3, WIDE_VOICES as WIDE_VOICES3 } from "@spark-apps/video-kit";
|
|
5153
5337
|
|
|
5154
5338
|
// src/browser/auth-state.ts
|
|
@@ -5263,9 +5447,100 @@ init_lifecycle();
|
|
|
5263
5447
|
import { mkdir as mkdir2 } from "fs/promises";
|
|
5264
5448
|
import { chromium as chromium2 } from "playwright";
|
|
5265
5449
|
|
|
5450
|
+
// src/browser/brand-watermark.ts
|
|
5451
|
+
init_image_kind();
|
|
5452
|
+
function watermarkInitScript(payload) {
|
|
5453
|
+
return `(() => {
|
|
5454
|
+
var ID = '__quickpeek_brand_watermark';
|
|
5455
|
+
var P = ${embedJson(payload)};
|
|
5456
|
+
var vertical = P.position.indexOf('top') === 0 ? 'top:22px' : 'bottom:22px';
|
|
5457
|
+
var horizontal = P.position.indexOf('right') > -1 ? 'right:24px' : 'left:24px';
|
|
5458
|
+
var SHELL = [
|
|
5459
|
+
'position:fixed',
|
|
5460
|
+
vertical,
|
|
5461
|
+
horizontal,
|
|
5462
|
+
'z-index:2147483646',
|
|
5463
|
+
'display:flex',
|
|
5464
|
+
'align-items:center',
|
|
5465
|
+
'gap:10px',
|
|
5466
|
+
'padding:8px 16px 8px 10px',
|
|
5467
|
+
'border-radius:999px',
|
|
5468
|
+
// A hairline and a blur rather than a solid plate: the mark sits on top of
|
|
5469
|
+
// whatever the app is showing, and a hard box reads as part of the UI.
|
|
5470
|
+
// The alpha is high enough to stay dark over a LIGHT app: at 0.55 the blur
|
|
5471
|
+
// averaged a pale page up into grey and the white label sat on it at about
|
|
5472
|
+
// 2:1, which photographs as a smudge.
|
|
5473
|
+
'background:rgba(12,12,18,0.78)',
|
|
5474
|
+
'border:1px solid rgba(255,255,255,0.14)',
|
|
5475
|
+
'backdrop-filter:blur(14px) saturate(1.3)',
|
|
5476
|
+
'-webkit-backdrop-filter:blur(14px) saturate(1.3)',
|
|
5477
|
+
'box-shadow:0 6px 24px rgba(0,0,0,0.30)',
|
|
5478
|
+
'opacity:' + P.opacity,
|
|
5479
|
+
'pointer-events:none',
|
|
5480
|
+
'user-select:none'
|
|
5481
|
+
].join(';');
|
|
5482
|
+
var ICON = 'width:26px;height:26px;border-radius:7px;object-fit:contain;display:block';
|
|
5483
|
+
var NAME = [
|
|
5484
|
+
'font:600 15px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
|
|
5485
|
+
'letter-spacing:0.015em',
|
|
5486
|
+
'color:#ffffff',
|
|
5487
|
+
'white-space:nowrap'
|
|
5488
|
+
].join(';');
|
|
5489
|
+
|
|
5490
|
+
function mount() {
|
|
5491
|
+
try {
|
|
5492
|
+
if (!document.body || document.getElementById(ID)) return;
|
|
5493
|
+
var el = document.createElement('div');
|
|
5494
|
+
el.id = ID;
|
|
5495
|
+
el.setAttribute('style', SHELL);
|
|
5496
|
+
if (P.icon) {
|
|
5497
|
+
var img = document.createElement('img');
|
|
5498
|
+
img.setAttribute('style', ICON);
|
|
5499
|
+
img.src = P.icon;
|
|
5500
|
+
img.alt = '';
|
|
5501
|
+
el.appendChild(img);
|
|
5502
|
+
}
|
|
5503
|
+
var label = document.createElement('span');
|
|
5504
|
+
label.setAttribute('style', NAME);
|
|
5505
|
+
label.textContent = P.name;
|
|
5506
|
+
el.appendChild(label);
|
|
5507
|
+
document.body.appendChild(el);
|
|
5508
|
+
} catch (e) {
|
|
5509
|
+
// A page that refuses the node still records; the mark is cosmetic.
|
|
5510
|
+
}
|
|
5511
|
+
}
|
|
5512
|
+
|
|
5513
|
+
if (document.body) mount();
|
|
5514
|
+
else document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
5515
|
+
setInterval(mount, 400);
|
|
5516
|
+
})();`;
|
|
5517
|
+
}
|
|
5518
|
+
async function applyBrandWatermark(context, watermark) {
|
|
5519
|
+
if (!watermark?.name) return false;
|
|
5520
|
+
const payload = {
|
|
5521
|
+
name: watermark.name,
|
|
5522
|
+
icon: watermark.iconPath ? imageDataUri(watermark.iconPath) : null,
|
|
5523
|
+
position: watermark.position ?? "top-right",
|
|
5524
|
+
opacity: clampOpacity(watermark.opacity)
|
|
5525
|
+
};
|
|
5526
|
+
try {
|
|
5527
|
+
await context.addInitScript(watermarkInitScript(payload));
|
|
5528
|
+
return true;
|
|
5529
|
+
} catch {
|
|
5530
|
+
return false;
|
|
5531
|
+
}
|
|
5532
|
+
}
|
|
5533
|
+
function embedJson(value) {
|
|
5534
|
+
return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
5535
|
+
}
|
|
5536
|
+
function clampOpacity(value) {
|
|
5537
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 0.92;
|
|
5538
|
+
return Math.min(1, Math.max(0.35, value));
|
|
5539
|
+
}
|
|
5540
|
+
|
|
5266
5541
|
// src/browser/wsl.ts
|
|
5267
5542
|
import { execSync, spawn as spawn2 } from "child_process";
|
|
5268
|
-
import { existsSync as existsSync3, readFileSync as
|
|
5543
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
|
|
5269
5544
|
import { request } from "http";
|
|
5270
5545
|
import { chromium } from "playwright";
|
|
5271
5546
|
function isWSL() {
|
|
@@ -5310,7 +5585,7 @@ function getWindowsHostIP() {
|
|
|
5310
5585
|
} catch {
|
|
5311
5586
|
}
|
|
5312
5587
|
try {
|
|
5313
|
-
const m =
|
|
5588
|
+
const m = readFileSync4("/etc/resolv.conf", "utf-8").match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
|
|
5314
5589
|
if (m?.[1]) return m[1];
|
|
5315
5590
|
} catch {
|
|
5316
5591
|
}
|
|
@@ -5318,7 +5593,7 @@ function getWindowsHostIP() {
|
|
|
5318
5593
|
}
|
|
5319
5594
|
function startRelay(nodePath, windowsHost, targetPort) {
|
|
5320
5595
|
const script = `var n=require('net'),s=n.createServer(function(c){var r=n.createConnection(${targetPort},'127.0.0.1');c.pipe(r);r.pipe(c);r.on('error',function(){c.destroy()});c.on('error',function(){r.destroy()});});s.listen(0,'${windowsHost}',function(){process.stdout.write('PORT:'+s.address().port+'\\n')});`;
|
|
5321
|
-
return new Promise((
|
|
5596
|
+
return new Promise((resolve13, reject) => {
|
|
5322
5597
|
const proc = spawn2(nodePath, ["-e", script], { stdio: ["ignore", "pipe", "ignore"], detached: true });
|
|
5323
5598
|
const timer = setTimeout(() => reject(new Error("relay startup timed out")), 6e3);
|
|
5324
5599
|
let buf = "";
|
|
@@ -5327,7 +5602,7 @@ function startRelay(nodePath, windowsHost, targetPort) {
|
|
|
5327
5602
|
const m = buf.match(/PORT:(\d+)/);
|
|
5328
5603
|
if (m) {
|
|
5329
5604
|
clearTimeout(timer);
|
|
5330
|
-
|
|
5605
|
+
resolve13({ proc, port: +m[1] });
|
|
5331
5606
|
}
|
|
5332
5607
|
});
|
|
5333
5608
|
proc.on("error", (e) => {
|
|
@@ -5337,7 +5612,7 @@ function startRelay(nodePath, windowsHost, targetPort) {
|
|
|
5337
5612
|
});
|
|
5338
5613
|
}
|
|
5339
5614
|
function waitForChrome(host, port) {
|
|
5340
|
-
return new Promise((
|
|
5615
|
+
return new Promise((resolve13, reject) => {
|
|
5341
5616
|
const deadline = Date.now() + 2e4;
|
|
5342
5617
|
const poll = () => {
|
|
5343
5618
|
if (Date.now() > deadline) {
|
|
@@ -5346,7 +5621,7 @@ function waitForChrome(host, port) {
|
|
|
5346
5621
|
}
|
|
5347
5622
|
const req = request({ host, port, path: "/json/version", method: "GET" }, (res) => {
|
|
5348
5623
|
res.resume();
|
|
5349
|
-
|
|
5624
|
+
resolve13();
|
|
5350
5625
|
});
|
|
5351
5626
|
req.on("error", () => setTimeout(poll, 600));
|
|
5352
5627
|
req.setTimeout(1e3, () => {
|
|
@@ -5489,6 +5764,7 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
|
|
|
5489
5764
|
});
|
|
5490
5765
|
}
|
|
5491
5766
|
await hideDevOverlays(context);
|
|
5767
|
+
await applyBrandWatermark(context, config.video.watermark);
|
|
5492
5768
|
const session = { context, browser, chromeSession };
|
|
5493
5769
|
session.release = onCleanup(() => closeBrowserSession(session));
|
|
5494
5770
|
return session;
|
|
@@ -5716,7 +5992,7 @@ async function importBrowserSession(url) {
|
|
|
5716
5992
|
}
|
|
5717
5993
|
|
|
5718
5994
|
// src/commands/edit-cmd.ts
|
|
5719
|
-
import { existsSync as
|
|
5995
|
+
import { existsSync as existsSync10, readdirSync, readFileSync as readFileSync5 } from "fs";
|
|
5720
5996
|
import { join as join14 } from "path";
|
|
5721
5997
|
import chalk7 from "chalk";
|
|
5722
5998
|
|
|
@@ -5791,8 +6067,8 @@ function editorBaseUrl() {
|
|
|
5791
6067
|
function openInBrowser(url) {
|
|
5792
6068
|
const isWSL2 = process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME);
|
|
5793
6069
|
const command = isWSL2 ? `cmd.exe /c start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
5794
|
-
return new Promise((
|
|
5795
|
-
exec2(command, () =>
|
|
6070
|
+
return new Promise((resolve13) => {
|
|
6071
|
+
exec2(command, () => resolve13());
|
|
5796
6072
|
});
|
|
5797
6073
|
}
|
|
5798
6074
|
|
|
@@ -5951,8 +6227,72 @@ function planEditorUrl(baseUrl, plan) {
|
|
|
5951
6227
|
return url.length <= MAX_HANDOFF_URL_CHARS ? url : null;
|
|
5952
6228
|
}
|
|
5953
6229
|
|
|
6230
|
+
// src/lib/plan-thumbs.ts
|
|
6231
|
+
init_spawn();
|
|
6232
|
+
import { existsSync as existsSync9 } from "fs";
|
|
6233
|
+
import { isAbsolute as isAbsolute2, resolve as resolve4 } from "path";
|
|
6234
|
+
var MEDIA_ACTIONS = ["image", "media", "video"];
|
|
6235
|
+
var THUMB_WIDTH = 160;
|
|
6236
|
+
var MAX_THUMB_CHARS = 1e4;
|
|
6237
|
+
var MAX_TOTAL_THUMB_CHARS = 16e3;
|
|
6238
|
+
var THUMB_TIMEOUT_MS = 3e3;
|
|
6239
|
+
function thumbnail(path) {
|
|
6240
|
+
return new Promise((done) => {
|
|
6241
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
6242
|
+
"-v",
|
|
6243
|
+
"error",
|
|
6244
|
+
"-i",
|
|
6245
|
+
path,
|
|
6246
|
+
"-frames:v",
|
|
6247
|
+
"1",
|
|
6248
|
+
"-vf",
|
|
6249
|
+
`scale=${THUMB_WIDTH}:-2`,
|
|
6250
|
+
"-q:v",
|
|
6251
|
+
"14",
|
|
6252
|
+
"-f",
|
|
6253
|
+
"mjpeg",
|
|
6254
|
+
"-"
|
|
6255
|
+
]);
|
|
6256
|
+
const chunks = [];
|
|
6257
|
+
const timer = setTimeout(() => {
|
|
6258
|
+
proc.kill();
|
|
6259
|
+
done(null);
|
|
6260
|
+
}, THUMB_TIMEOUT_MS);
|
|
6261
|
+
const finish = (result) => {
|
|
6262
|
+
clearTimeout(timer);
|
|
6263
|
+
done(result);
|
|
6264
|
+
};
|
|
6265
|
+
proc.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
6266
|
+
proc.on("error", () => finish(null));
|
|
6267
|
+
proc.on("close", (code) => {
|
|
6268
|
+
const bytes = Buffer.concat(chunks);
|
|
6269
|
+
if (code !== 0 || bytes.length === 0) return finish(null);
|
|
6270
|
+
const base64 = bytes.toString("base64");
|
|
6271
|
+
finish(base64.length > MAX_THUMB_CHARS ? null : `data:image/jpeg;base64,${base64}`);
|
|
6272
|
+
});
|
|
6273
|
+
});
|
|
6274
|
+
}
|
|
6275
|
+
async function withEditorThumbs(plan, baseDir) {
|
|
6276
|
+
const steps = [...plan.steps];
|
|
6277
|
+
let budget = MAX_TOTAL_THUMB_CHARS;
|
|
6278
|
+
for (const [index, step] of steps.entries()) {
|
|
6279
|
+
if (!step.value || !MEDIA_ACTIONS.includes(step.action)) continue;
|
|
6280
|
+
const path = isAbsolute2(step.value) ? step.value : resolve4(baseDir, step.value);
|
|
6281
|
+
if (!existsSync9(path)) continue;
|
|
6282
|
+
const thumb = await thumbnail(path);
|
|
6283
|
+
if (!thumb || thumb.length > budget) continue;
|
|
6284
|
+
budget -= thumb.length;
|
|
6285
|
+
steps[index] = { ...step, thumb };
|
|
6286
|
+
}
|
|
6287
|
+
return { ...plan, steps };
|
|
6288
|
+
}
|
|
6289
|
+
async function editorUrlWithThumbs(baseUrl, plan, baseDir) {
|
|
6290
|
+
const withThumbs = await withEditorThumbs(plan, baseDir);
|
|
6291
|
+
return planEditorUrl(baseUrl, withThumbs) ?? planEditorUrl(baseUrl, plan);
|
|
6292
|
+
}
|
|
6293
|
+
|
|
5954
6294
|
// src/utils/ui.ts
|
|
5955
|
-
import { resolve as
|
|
6295
|
+
import { resolve as resolve5 } from "path";
|
|
5956
6296
|
import { createInterface } from "readline";
|
|
5957
6297
|
import chalk2 from "chalk";
|
|
5958
6298
|
import ora from "ora";
|
|
@@ -6019,7 +6359,7 @@ function waitForEnter(prompt2 = `${INDENT}Press Enter when done...`) {
|
|
|
6019
6359
|
)
|
|
6020
6360
|
);
|
|
6021
6361
|
}
|
|
6022
|
-
return new Promise((
|
|
6362
|
+
return new Promise((resolve13, reject) => {
|
|
6023
6363
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
6024
6364
|
let done = false;
|
|
6025
6365
|
rl.on("close", () => {
|
|
@@ -6030,13 +6370,13 @@ function waitForEnter(prompt2 = `${INDENT}Press Enter when done...`) {
|
|
|
6030
6370
|
rl.question(prompt2, () => {
|
|
6031
6371
|
done = true;
|
|
6032
6372
|
rl.close();
|
|
6033
|
-
|
|
6373
|
+
resolve13();
|
|
6034
6374
|
});
|
|
6035
6375
|
});
|
|
6036
6376
|
}
|
|
6037
6377
|
var _isWSL = process.platform === "linux" && process.env.WSL_DISTRO_NAME;
|
|
6038
6378
|
function formatClickablePath(filePath) {
|
|
6039
|
-
const absolutePath =
|
|
6379
|
+
const absolutePath = resolve5(filePath);
|
|
6040
6380
|
if (_isWSL || process.platform !== "win32") {
|
|
6041
6381
|
return absolutePath;
|
|
6042
6382
|
}
|
|
@@ -6077,13 +6417,13 @@ import chalk3 from "chalk";
|
|
|
6077
6417
|
init_SparkStripe_feature_gating();
|
|
6078
6418
|
var TIMEOUT_MS = 12e4;
|
|
6079
6419
|
async function browserLogin() {
|
|
6080
|
-
return new Promise((
|
|
6420
|
+
return new Promise((resolve13) => {
|
|
6081
6421
|
let server;
|
|
6082
6422
|
let timer;
|
|
6083
6423
|
const cleanup = (email) => {
|
|
6084
6424
|
clearTimeout(timer);
|
|
6085
6425
|
server?.close();
|
|
6086
|
-
|
|
6426
|
+
resolve13(email);
|
|
6087
6427
|
};
|
|
6088
6428
|
server = createServer((req, res) => {
|
|
6089
6429
|
const url = new URL2(req.url || "/", `http://localhost`);
|
|
@@ -6234,10 +6574,10 @@ function getLastRateLimitInfo() {
|
|
|
6234
6574
|
}
|
|
6235
6575
|
function prompt(question) {
|
|
6236
6576
|
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
6237
|
-
return new Promise((
|
|
6577
|
+
return new Promise((resolve13) => {
|
|
6238
6578
|
rl.question(question, (answer) => {
|
|
6239
6579
|
rl.close();
|
|
6240
|
-
|
|
6580
|
+
resolve13(answer.trim());
|
|
6241
6581
|
});
|
|
6242
6582
|
});
|
|
6243
6583
|
}
|
|
@@ -6246,8 +6586,10 @@ async function setupUserEmail() {
|
|
|
6246
6586
|
console.log(chalk4.yellow(" \u2709 Account Setup"));
|
|
6247
6587
|
console.log(chalk4.dim(" \u2500".repeat(20)));
|
|
6248
6588
|
console.log();
|
|
6249
|
-
console.log(chalk4.white("
|
|
6250
|
-
console.log(chalk4.white("
|
|
6589
|
+
console.log(chalk4.white(" The AI that plans your demo runs on our servers, so it"));
|
|
6590
|
+
console.log(chalk4.white(" needs an address to meter. No card, and no verification"));
|
|
6591
|
+
console.log(chalk4.white(" needed for your first 5 videos."));
|
|
6592
|
+
console.log(chalk4.dim(" After that, signing in gets you 10 videos a day."));
|
|
6251
6593
|
const browserEmail = await browserLogin();
|
|
6252
6594
|
if (browserEmail) {
|
|
6253
6595
|
console.log();
|
|
@@ -6256,11 +6598,15 @@ async function setupUserEmail() {
|
|
|
6256
6598
|
return browserEmail;
|
|
6257
6599
|
}
|
|
6258
6600
|
console.log();
|
|
6259
|
-
console.log(chalk4.dim(" Or enter your email
|
|
6601
|
+
console.log(chalk4.dim(" Or enter your email (Enter to skip):"));
|
|
6260
6602
|
const email = await prompt(chalk4.white(" Email: "));
|
|
6261
6603
|
if (!email) {
|
|
6262
6604
|
console.log();
|
|
6263
|
-
console.log(chalk4.
|
|
6605
|
+
console.log(chalk4.yellow(" No email given, so the AI planner cannot be reached."));
|
|
6606
|
+
console.log(chalk4.dim(" Nothing has been recorded. Run qp again when you are ready."));
|
|
6607
|
+
console.log(chalk4.dim(" To record with no account at all, use the MCP server:"));
|
|
6608
|
+
console.log(chalk4.dim(" https://quickpeek.co/mcp"));
|
|
6609
|
+
console.log();
|
|
6264
6610
|
process.exit(1);
|
|
6265
6611
|
}
|
|
6266
6612
|
if (!email.includes("@") || !email.includes(".")) {
|
|
@@ -6268,6 +6614,9 @@ async function setupUserEmail() {
|
|
|
6268
6614
|
console.log(chalk4.yellow(" \u26A0 That doesn't look like a valid email address."));
|
|
6269
6615
|
const proceed = await prompt(chalk4.white(" Continue anyway? ") + chalk4.dim("[y/N] "));
|
|
6270
6616
|
if (proceed.toLowerCase() !== "y") {
|
|
6617
|
+
console.log();
|
|
6618
|
+
console.log(chalk4.dim(" Nothing has been recorded. Run qp again when you are ready."));
|
|
6619
|
+
console.log();
|
|
6271
6620
|
process.exit(1);
|
|
6272
6621
|
}
|
|
6273
6622
|
}
|
|
@@ -6394,6 +6743,17 @@ async function parseAIJsonWithRepair(content, opts) {
|
|
|
6394
6743
|
init_mcp_context();
|
|
6395
6744
|
init_lib();
|
|
6396
6745
|
init_json_repair();
|
|
6746
|
+
var WRITTEN_ADDRESS = /\b(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?/gi;
|
|
6747
|
+
var SPELLED_OUT_ACRONYM = /\b[A-Z](?:\s+[A-Z])+\b/g;
|
|
6748
|
+
function spokenCaption(caption) {
|
|
6749
|
+
const held = [];
|
|
6750
|
+
const masked = caption.replace(SPELLED_OUT_ACRONYM, (run2) => run2.replace(/\s+/g, "")).replace(WRITTEN_ADDRESS, (address) => {
|
|
6751
|
+
held.push(address);
|
|
6752
|
+
return `qpaddr${held.length - 1}qp`;
|
|
6753
|
+
});
|
|
6754
|
+
const shaped = toTitleCase(toSpokenForm2(masked));
|
|
6755
|
+
return shaped.replace(/qpaddr(\d+)qp/gi, (_match, index) => held[Number(index)] ?? "");
|
|
6756
|
+
}
|
|
6397
6757
|
async function crawlExtension(url, extensionPath, workDir, task2) {
|
|
6398
6758
|
const browserTask = task2("Launching browser with extension");
|
|
6399
6759
|
const userDataDir = join12(workDir, ".browser-data");
|
|
@@ -6522,8 +6882,18 @@ async function crawlAndAnalyze(url, lang, description, maxSteps, task2, logError
|
|
|
6522
6882
|
// The spoken form is what the voice will actually say, so it is what
|
|
6523
6883
|
// the caption shows: burning the written form instead left the words on
|
|
6524
6884
|
// screen out of step with the words in the ear wherever the two differ
|
|
6525
|
-
// (a
|
|
6526
|
-
|
|
6885
|
+
// (a number read as words).
|
|
6886
|
+
//
|
|
6887
|
+
// Addresses are the exception. Expanding one here turned "quickpeek.co"
|
|
6888
|
+
// into three caption words, "Quickpeek Dot Co", which the line packer
|
|
6889
|
+
// is then free to break across lines: the call to action arrived on
|
|
6890
|
+
// screen as "DOT C O" with the name on the row above. An address is the
|
|
6891
|
+
// one thing in a demo a viewer has to be able to read and type, so it
|
|
6892
|
+
// is held back verbatim from both passes and rejoins as written. The
|
|
6893
|
+
// voice is unaffected: generate.ts derives its own spoken form from
|
|
6894
|
+
// this caption at synthesis time, and word timings fall back to an even
|
|
6895
|
+
// spread across the speech envelope when the counts no longer line up.
|
|
6896
|
+
caption: s.caption ? spokenCaption(s.caption) : s.caption
|
|
6527
6897
|
}));
|
|
6528
6898
|
aiTask.succeed(`Plan: ${plan.steps.length} steps`);
|
|
6529
6899
|
return plan;
|
|
@@ -6701,9 +7071,9 @@ var PLAN_FILE = /^plan-([a-z]{2}(?:-[A-Z]{2})?)\.json$/;
|
|
|
6701
7071
|
function findPlanFile(dir, lang) {
|
|
6702
7072
|
if (lang) {
|
|
6703
7073
|
const exact = join14(dir, `plan-${lang}.json`);
|
|
6704
|
-
return
|
|
7074
|
+
return existsSync10(exact) ? exact : null;
|
|
6705
7075
|
}
|
|
6706
|
-
if (!
|
|
7076
|
+
if (!existsSync10(dir)) return null;
|
|
6707
7077
|
const plans = readdirSync(dir).filter((f) => PLAN_FILE.test(f));
|
|
6708
7078
|
const preferred = plans.find((f) => f === "plan-en.json") ?? plans[0];
|
|
6709
7079
|
return preferred ? join14(dir, preferred) : null;
|
|
@@ -6711,10 +7081,35 @@ function findPlanFile(dir, lang) {
|
|
|
6711
7081
|
|
|
6712
7082
|
// src/commands/record-cmd.ts
|
|
6713
7083
|
init_mcp_context();
|
|
6714
|
-
import { existsSync as
|
|
6715
|
-
import { mkdir as
|
|
6716
|
-
import { basename as basename4, isAbsolute as
|
|
7084
|
+
import { existsSync as existsSync26 } from "fs";
|
|
7085
|
+
import { mkdir as mkdir10, readFile as readFile12 } from "fs/promises";
|
|
7086
|
+
import { basename as basename4, isAbsolute as isAbsolute7, join as join31, resolve as resolve11 } from "path";
|
|
6717
7087
|
import chalk17 from "chalk";
|
|
7088
|
+
|
|
7089
|
+
// src/lib/demo-registry.ts
|
|
7090
|
+
init_api_client();
|
|
7091
|
+
var TIMEOUT_MS2 = 4e3;
|
|
7092
|
+
async function registerDemoRun(email, run2) {
|
|
7093
|
+
if (!email || email === "anonymous") return false;
|
|
7094
|
+
const controller = new AbortController();
|
|
7095
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS2);
|
|
7096
|
+
try {
|
|
7097
|
+
const token = await getOrRefreshRelayToken(email);
|
|
7098
|
+
const response = await fetch(`${RELAY_BASE}/api/cli/demos`, {
|
|
7099
|
+
method: "POST",
|
|
7100
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
7101
|
+
body: JSON.stringify(run2),
|
|
7102
|
+
signal: controller.signal
|
|
7103
|
+
});
|
|
7104
|
+
return response.ok;
|
|
7105
|
+
} catch {
|
|
7106
|
+
return false;
|
|
7107
|
+
} finally {
|
|
7108
|
+
clearTimeout(timer);
|
|
7109
|
+
}
|
|
7110
|
+
}
|
|
7111
|
+
|
|
7112
|
+
// src/commands/record-cmd.ts
|
|
6718
7113
|
init_lib();
|
|
6719
7114
|
init_config_loader();
|
|
6720
7115
|
init_run_root();
|
|
@@ -6734,7 +7129,7 @@ async function handleRegistrationGate() {
|
|
|
6734
7129
|
console.log(chalk8.white(" Sign in to continue creating videos."));
|
|
6735
7130
|
console.log();
|
|
6736
7131
|
console.log(chalk8.dim(" Benefits of signing in:"));
|
|
6737
|
-
console.log(chalk8.dim(" \u2022
|
|
7132
|
+
console.log(chalk8.dim(" \u2022 10 videos a day instead of 5 in total"));
|
|
6738
7133
|
console.log(chalk8.dim(" \u2022 Keep your video history"));
|
|
6739
7134
|
console.log(chalk8.dim(" \u2022 Upgrade to Pro anytime"));
|
|
6740
7135
|
let email = await browserLogin();
|
|
@@ -6785,9 +7180,12 @@ async function runAuthPhase(input) {
|
|
|
6785
7180
|
const { isVerified: isVerified2 } = input;
|
|
6786
7181
|
const mcpTool = currentMcpTool();
|
|
6787
7182
|
if (mcpTool && !(userEmail && isVerified2)) {
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
|
|
7183
|
+
if (localVideoCount >= FREE_VIDEO_LIMIT) {
|
|
7184
|
+
throw new Error(
|
|
7185
|
+
`"${mcpTool}" has used all ${FREE_VIDEO_LIMIT} free videos on this machine and needs a verified QuickPeek account to continue${userEmail ? ` (${userEmail} is registered but unverified)` : ""}. Sign in once from a terminal with \`qp login\`, a browser flow that cannot run inside an MCP call, then retry. Use check_account to confirm.`
|
|
7186
|
+
);
|
|
7187
|
+
}
|
|
7188
|
+
return { userEmail: "anonymous", userTier: "free", localVideoCount, isVerified: false };
|
|
6791
7189
|
}
|
|
6792
7190
|
if (userEmail && isVerified2) {
|
|
6793
7191
|
const tierTask = task("Checking subscription");
|
|
@@ -6878,7 +7276,7 @@ async function runAuthPhase(input) {
|
|
|
6878
7276
|
const remaining2 = FREE_VIDEO_LIMIT - localVideoCount;
|
|
6879
7277
|
console.log();
|
|
6880
7278
|
console.log(chalk9.cyan(` \u{1F3AC} Video ${localVideoCount + 1}/${FREE_VIDEO_LIMIT} free`));
|
|
6881
|
-
console.log(chalk9.dim(` ${remaining2 - 1} more after this \u2022 Sign up for
|
|
7279
|
+
console.log(chalk9.dim(` ${remaining2 - 1} more after this \u2022 Sign up for 10 a day`));
|
|
6882
7280
|
userEmail = await setupUserEmail();
|
|
6883
7281
|
let registeredVerified = false;
|
|
6884
7282
|
try {
|
|
@@ -6897,7 +7295,7 @@ async function runAuthPhase(input) {
|
|
|
6897
7295
|
}
|
|
6898
7296
|
|
|
6899
7297
|
// src/commands/phases/plan-phase.ts
|
|
6900
|
-
import { existsSync as
|
|
7298
|
+
import { existsSync as existsSync21, readdirSync as readdirSync3 } from "fs";
|
|
6901
7299
|
import { readFile as readFile9 } from "fs/promises";
|
|
6902
7300
|
import { basename as basename3, join as join26 } from "path";
|
|
6903
7301
|
import chalk13 from "chalk";
|
|
@@ -7059,13 +7457,13 @@ init_config_loader();
|
|
|
7059
7457
|
|
|
7060
7458
|
// src/commands/phases/plan-cards.ts
|
|
7061
7459
|
init_render_outro();
|
|
7062
|
-
import { existsSync as
|
|
7063
|
-
import { isAbsolute as
|
|
7460
|
+
import { existsSync as existsSync20 } from "fs";
|
|
7461
|
+
import { isAbsolute as isAbsolute6, join as join25 } from "path";
|
|
7064
7462
|
import chalk12 from "chalk";
|
|
7065
7463
|
|
|
7066
7464
|
// src/lib/og-card.ts
|
|
7067
7465
|
init_image_kind();
|
|
7068
|
-
import { existsSync as
|
|
7466
|
+
import { existsSync as existsSync11, readFileSync as readFileSync6 } from "fs";
|
|
7069
7467
|
import { writeFile as writeFile9 } from "fs/promises";
|
|
7070
7468
|
import { join as join16 } from "path";
|
|
7071
7469
|
var PORTRAIT_PATHS = ["/opengraph-image/portrait", "/opengraph-image-portrait", "/og/portrait"];
|
|
@@ -7235,7 +7633,7 @@ function existingIcon(dir) {
|
|
|
7235
7633
|
for (const ext of ["png", "svg", "jpg", "webp", "gif"]) {
|
|
7236
7634
|
const path = join16(dir, `app-icon.${ext}`);
|
|
7237
7635
|
try {
|
|
7238
|
-
if (!
|
|
7636
|
+
if (!existsSync11(path)) continue;
|
|
7239
7637
|
if (!sniffImageKind(readFileSync6(path))) continue;
|
|
7240
7638
|
return path;
|
|
7241
7639
|
} catch {
|
|
@@ -7279,9 +7677,9 @@ function jpegSize(buf) {
|
|
|
7279
7677
|
}
|
|
7280
7678
|
|
|
7281
7679
|
// src/commands/phases/video-phase.ts
|
|
7282
|
-
import { existsSync as
|
|
7680
|
+
import { existsSync as existsSync19, statSync as statSync3 } from "fs";
|
|
7283
7681
|
import { copyFile as copyFile3 } from "fs/promises";
|
|
7284
|
-
import { join as join24, resolve as
|
|
7682
|
+
import { join as join24, resolve as resolve10 } from "path";
|
|
7285
7683
|
|
|
7286
7684
|
// src/browser/signed-in.ts
|
|
7287
7685
|
function pathDepth(raw) {
|
|
@@ -7387,7 +7785,7 @@ async function hasHeadroom() {
|
|
|
7387
7785
|
async function withRenderSlot(render, onWait) {
|
|
7388
7786
|
if (active >= MAX_CONCURRENT_RENDERS) {
|
|
7389
7787
|
onWait?.(`waiting for a render slot (${active}/${MAX_CONCURRENT_RENDERS} busy)`);
|
|
7390
|
-
await new Promise((
|
|
7788
|
+
await new Promise((resolve13) => waiting.push(resolve13));
|
|
7391
7789
|
}
|
|
7392
7790
|
active++;
|
|
7393
7791
|
try {
|
|
@@ -7416,20 +7814,20 @@ init_lib();
|
|
|
7416
7814
|
init_helpers();
|
|
7417
7815
|
|
|
7418
7816
|
// src/record.ts
|
|
7419
|
-
import { existsSync as
|
|
7420
|
-
import { mkdir as
|
|
7817
|
+
import { existsSync as existsSync16 } from "fs";
|
|
7818
|
+
import { mkdir as mkdir8, rm as rm3 } from "fs/promises";
|
|
7421
7819
|
import { join as join21 } from "path";
|
|
7422
7820
|
import chalk10 from "chalk";
|
|
7423
7821
|
|
|
7424
7822
|
// src/browser/actions.ts
|
|
7425
7823
|
init_lib();
|
|
7426
|
-
import { existsSync as
|
|
7824
|
+
import { existsSync as existsSync13 } from "fs";
|
|
7427
7825
|
import { readFile as readFile7 } from "fs/promises";
|
|
7428
|
-
import { isAbsolute as
|
|
7826
|
+
import { isAbsolute as isAbsolute3, resolve as resolve7 } from "path";
|
|
7429
7827
|
|
|
7430
7828
|
// src/browser/effects.ts
|
|
7431
7829
|
init_lib();
|
|
7432
|
-
import { existsSync as
|
|
7830
|
+
import { existsSync as existsSync12, readFileSync as readFileSync7 } from "fs";
|
|
7433
7831
|
import { join as join17 } from "path";
|
|
7434
7832
|
|
|
7435
7833
|
// src/browser/page-eval.ts
|
|
@@ -7449,7 +7847,7 @@ function loadHighlightCSS() {
|
|
|
7449
7847
|
// src/browser/ → src/
|
|
7450
7848
|
];
|
|
7451
7849
|
for (const p of candidates) {
|
|
7452
|
-
if (
|
|
7850
|
+
if (existsSync12(p)) return readFileSync7(p, "utf-8");
|
|
7453
7851
|
}
|
|
7454
7852
|
return "";
|
|
7455
7853
|
}
|
|
@@ -7662,6 +8060,7 @@ function locatorFor(page, keyword) {
|
|
|
7662
8060
|
var WAIT_MS = 450;
|
|
7663
8061
|
var HANDLE_MS = 3e3;
|
|
7664
8062
|
var RESOLVE_BUDGET_MS2 = 2e4;
|
|
8063
|
+
var DOM_POLL_MS = 150;
|
|
7665
8064
|
function remaining(deadline) {
|
|
7666
8065
|
return Math.max(1, deadline - Date.now());
|
|
7667
8066
|
}
|
|
@@ -7772,7 +8171,7 @@ async function resolveElementSettled(page, target, deadlineMs = RESOLVE_DEADLINE
|
|
|
7772
8171
|
}
|
|
7773
8172
|
}
|
|
7774
8173
|
async function resolvePlainText(page, text, deadline) {
|
|
7775
|
-
|
|
8174
|
+
let scanned = await domTextMatch(page, text);
|
|
7776
8175
|
const stages = plainTextStages(page, text);
|
|
7777
8176
|
while (Date.now() < deadline) {
|
|
7778
8177
|
for (const { locator, state } of stages) {
|
|
@@ -7783,6 +8182,12 @@ async function resolvePlainText(page, text, deadline) {
|
|
|
7783
8182
|
}
|
|
7784
8183
|
}
|
|
7785
8184
|
if (scanned) return scanned;
|
|
8185
|
+
while (Date.now() < deadline) {
|
|
8186
|
+
await page.waitForTimeout(Math.min(DOM_POLL_MS, remaining(deadline)));
|
|
8187
|
+
scanned = await domTextMatch(page, text);
|
|
8188
|
+
if (scanned) break;
|
|
8189
|
+
}
|
|
8190
|
+
if (!scanned) return null;
|
|
7786
8191
|
}
|
|
7787
8192
|
return scanned ?? null;
|
|
7788
8193
|
}
|
|
@@ -8207,8 +8612,8 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
|
|
|
8207
8612
|
const isPlaceholder = !rawPath || rawPath.startsWith("[") || rawPath.includes("example");
|
|
8208
8613
|
if (!isPlaceholder) {
|
|
8209
8614
|
const normalizedPath = rawPath.startsWith("mnt/") ? `/${rawPath}` : rawPath;
|
|
8210
|
-
const filePath =
|
|
8211
|
-
if (
|
|
8615
|
+
const filePath = isAbsolute3(normalizedPath) ? normalizedPath : resolve7(baseDir, normalizedPath);
|
|
8616
|
+
if (existsSync13(filePath)) {
|
|
8212
8617
|
await fileInput.setInputFiles(filePath);
|
|
8213
8618
|
await page.waitForTimeout(2e3);
|
|
8214
8619
|
break;
|
|
@@ -8324,8 +8729,8 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
|
|
|
8324
8729
|
case "wait":
|
|
8325
8730
|
break;
|
|
8326
8731
|
case "image": {
|
|
8327
|
-
const imagePath = step.value ?
|
|
8328
|
-
if (imagePath &&
|
|
8732
|
+
const imagePath = step.value ? isAbsolute3(step.value) ? step.value : resolve7(baseDir, step.value) : null;
|
|
8733
|
+
if (imagePath && existsSync13(imagePath)) {
|
|
8329
8734
|
const imgStyle = step.target === "stretch" ? "width: 100vw; height: 100vh; object-fit: cover;" : "max-width: 90%; max-height: 90%;";
|
|
8330
8735
|
if (await showImagePage(page, imagePath, imgStyle, config, cursorInjected, false)) injected = true;
|
|
8331
8736
|
}
|
|
@@ -8354,7 +8759,7 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
|
|
|
8354
8759
|
await pendingDownload;
|
|
8355
8760
|
pendingDownload = null;
|
|
8356
8761
|
}
|
|
8357
|
-
if (lastDownloadPath &&
|
|
8762
|
+
if (lastDownloadPath && existsSync13(lastDownloadPath)) {
|
|
8358
8763
|
const imgStyle = step.target === "stretch" ? "width: 100%; height: 100%; object-fit: contain;" : "max-width: 90%; max-height: 90%; object-fit: contain;";
|
|
8359
8764
|
if (await showImagePage(page, lastDownloadPath, imgStyle, config, cursorInjected)) injected = true;
|
|
8360
8765
|
}
|
|
@@ -8462,13 +8867,13 @@ async function getPageElementSelectors(page, query, limit, keyword = "") {
|
|
|
8462
8867
|
// src/browser/step-validator.ts
|
|
8463
8868
|
init_crawl();
|
|
8464
8869
|
init_utils();
|
|
8465
|
-
import { existsSync as
|
|
8466
|
-
import { basename as basename2, isAbsolute as
|
|
8870
|
+
import { existsSync as existsSync14, readdirSync as readdirSync2 } from "fs";
|
|
8871
|
+
import { basename as basename2, isAbsolute as isAbsolute4, join as join18, resolve as resolve8 } from "path";
|
|
8467
8872
|
function findMediaFile(filename, baseDir) {
|
|
8468
8873
|
const name = basename2(filename);
|
|
8469
8874
|
const searchDirs = [baseDir, join18(baseDir, "res"), join18(baseDir, "assets"), join18(baseDir, "media")];
|
|
8470
8875
|
for (const dir of searchDirs) {
|
|
8471
|
-
if (!
|
|
8876
|
+
if (!existsSync14(dir)) continue;
|
|
8472
8877
|
try {
|
|
8473
8878
|
const files = readdirSync2(dir);
|
|
8474
8879
|
if (files.includes(name)) return join18(dir, name);
|
|
@@ -8521,8 +8926,8 @@ function resolveMediaStep(step, baseDir, label, startTime) {
|
|
|
8521
8926
|
if (!step.value) {
|
|
8522
8927
|
return { success: false, error: `Missing ${label} path`, executionTimeMs: Date.now() - startTime };
|
|
8523
8928
|
}
|
|
8524
|
-
const specifiedPath =
|
|
8525
|
-
if (
|
|
8929
|
+
const specifiedPath = isAbsolute4(step.value) ? step.value : resolve8(baseDir, step.value);
|
|
8930
|
+
if (existsSync14(specifiedPath)) return { success: true, executionTimeMs: Date.now() - startTime };
|
|
8526
8931
|
const foundPath = findMediaFile(step.value, baseDir);
|
|
8527
8932
|
if (foundPath) {
|
|
8528
8933
|
step.value = `./${foundPath.replace(`${baseDir}/`, "").replace(`${baseDir}\\`, "")}`;
|
|
@@ -8650,8 +9055,8 @@ async function validateAndExecuteStep(page, step, plan, baseDir) {
|
|
|
8650
9055
|
const isPlaceholder = !rawPath || rawPath.startsWith("[") || rawPath.includes("example");
|
|
8651
9056
|
if (!isPlaceholder) {
|
|
8652
9057
|
const normalizedPath = rawPath.startsWith("mnt/") ? `/${rawPath}` : rawPath;
|
|
8653
|
-
let filePath =
|
|
8654
|
-
if (!
|
|
9058
|
+
let filePath = isAbsolute4(normalizedPath) ? normalizedPath : resolve8(baseDir, normalizedPath);
|
|
9059
|
+
if (!existsSync14(filePath)) {
|
|
8655
9060
|
const foundPath = findMediaFile(rawPath, baseDir);
|
|
8656
9061
|
if (foundPath) {
|
|
8657
9062
|
const relativePath = `./${foundPath.replace(`${baseDir}/`, "").replace(`${baseDir}\\`, "")}`;
|
|
@@ -8793,7 +9198,7 @@ async function validateStepsOnPage(plan, config, logError2, baseDir, onStepStart
|
|
|
8793
9198
|
}
|
|
8794
9199
|
|
|
8795
9200
|
// src/browser/meta.ts
|
|
8796
|
-
import { existsSync as
|
|
9201
|
+
import { existsSync as existsSync15 } from "fs";
|
|
8797
9202
|
import { readFile as readFile8, writeFile as writeFile10 } from "fs/promises";
|
|
8798
9203
|
import { join as join20 } from "path";
|
|
8799
9204
|
var RECORDING_FILE = "recording.mp4";
|
|
@@ -8803,7 +9208,7 @@ function getRecordingPath(outputDir) {
|
|
|
8803
9208
|
}
|
|
8804
9209
|
async function loadRecordingMeta(outputDir) {
|
|
8805
9210
|
const metaPath = join20(outputDir, RECORDING_META_FILE);
|
|
8806
|
-
if (!
|
|
9211
|
+
if (!existsSync15(metaPath)) return null;
|
|
8807
9212
|
try {
|
|
8808
9213
|
const content = await readFile8(metaPath, "utf-8");
|
|
8809
9214
|
return JSON.parse(content);
|
|
@@ -8910,7 +9315,7 @@ async function leavesApp(el, step, plannedUrl) {
|
|
|
8910
9315
|
}
|
|
8911
9316
|
async function recordSession(plan, config, stepDurations, workDir, baseDir, task2, logError2, opts = {}) {
|
|
8912
9317
|
const videoDir = join21(workDir, "video");
|
|
8913
|
-
await
|
|
9318
|
+
await mkdir8(videoDir, { recursive: true });
|
|
8914
9319
|
const getStepDuration = (stepNum) => {
|
|
8915
9320
|
return stepDurations[stepNum - 1] ?? config.timing.minStepDuration;
|
|
8916
9321
|
};
|
|
@@ -8921,11 +9326,33 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
|
|
|
8921
9326
|
const authState = findAuthState(plan.url);
|
|
8922
9327
|
const { context, browser, chromeSession } = await launchBrowserContext(config, userDataDir, recordVideo, authState);
|
|
8923
9328
|
try {
|
|
9329
|
+
const isExtensionPage = plan.url.startsWith("chrome-extension://");
|
|
9330
|
+
if (!isExtensionPage) {
|
|
9331
|
+
const warm = await context.newPage();
|
|
9332
|
+
const warmVideo = warm.video();
|
|
9333
|
+
const warmUrls = [plan.url];
|
|
9334
|
+
for (const step of plan.steps) {
|
|
9335
|
+
if (step.action !== "navigate" || !step.target) continue;
|
|
9336
|
+
if (!warmUrls.includes(step.target)) warmUrls.push(step.target);
|
|
9337
|
+
}
|
|
9338
|
+
try {
|
|
9339
|
+
for (const url of warmUrls) {
|
|
9340
|
+
await gotoSettled(warm, url).catch(() => {
|
|
9341
|
+
});
|
|
9342
|
+
}
|
|
9343
|
+
} catch {
|
|
9344
|
+
} finally {
|
|
9345
|
+
await warm.close().catch(() => {
|
|
9346
|
+
});
|
|
9347
|
+
const stray = await warmVideo?.path().catch(() => void 0);
|
|
9348
|
+
if (stray) await rm3(stray, { force: true }).catch(() => {
|
|
9349
|
+
});
|
|
9350
|
+
}
|
|
9351
|
+
}
|
|
8924
9352
|
const page = context.pages()[0] || await context.newPage();
|
|
8925
9353
|
setupDownloadTracking(page);
|
|
8926
9354
|
context.on("page", (p) => setupDownloadTracking(p));
|
|
8927
9355
|
const firstStepIsMedia = ["media", "image", "video"].includes(plan.steps[0]?.action ?? "");
|
|
8928
|
-
const isExtensionPage = plan.url.startsWith("chrome-extension://");
|
|
8929
9356
|
let signInChecked = false;
|
|
8930
9357
|
const guardSignedIn = async () => {
|
|
8931
9358
|
if (signInChecked || opts.allowLoggedOut || isExtensionPage) return;
|
|
@@ -9002,7 +9429,7 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
|
|
|
9002
9429
|
if (!targetEl) {
|
|
9003
9430
|
console.log(
|
|
9004
9431
|
`
|
|
9005
|
-
\u26A0 target "${step.target}" not found on ${page.url()}
|
|
9432
|
+
\u26A0 target "${step.target}" not found on ${page.url()}; step ${stepNum} recorded without the action`
|
|
9006
9433
|
);
|
|
9007
9434
|
}
|
|
9008
9435
|
}
|
|
@@ -9141,7 +9568,7 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
|
|
|
9141
9568
|
if (!pageClosedEarly) await page.close();
|
|
9142
9569
|
const videoPath = await video.path();
|
|
9143
9570
|
await cleanupBrowserSession({ context, browser, chromeSession });
|
|
9144
|
-
if (!videoPath || !
|
|
9571
|
+
if (!videoPath || !existsSync16(videoPath)) {
|
|
9145
9572
|
throw new Error("Video file was not created");
|
|
9146
9573
|
}
|
|
9147
9574
|
saveTask.succeed("Video saved");
|
|
@@ -9156,20 +9583,20 @@ init_run_root();
|
|
|
9156
9583
|
|
|
9157
9584
|
// src/utils/video-pipeline.ts
|
|
9158
9585
|
init_lib();
|
|
9159
|
-
import { existsSync as
|
|
9160
|
-
import { mkdir as
|
|
9161
|
-
import { isAbsolute as
|
|
9586
|
+
import { existsSync as existsSync18 } from "fs";
|
|
9587
|
+
import { mkdir as mkdir9, writeFile as writeFile11 } from "fs/promises";
|
|
9588
|
+
import { isAbsolute as isAbsolute5, join as join23, resolve as resolve9 } from "path";
|
|
9162
9589
|
import chalk11 from "chalk";
|
|
9163
9590
|
|
|
9164
9591
|
// src/utils/music.ts
|
|
9165
|
-
import { existsSync as
|
|
9592
|
+
import { existsSync as existsSync17, readFileSync as readFileSync8 } from "fs";
|
|
9166
9593
|
import { dirname as dirname4, join as join22 } from "path";
|
|
9167
|
-
var DEFAULT_MOOD = "
|
|
9594
|
+
var DEFAULT_MOOD = "corporate";
|
|
9168
9595
|
function musicDir() {
|
|
9169
9596
|
let dir = getScriptDir();
|
|
9170
9597
|
for (let i = 0; i < 6; i++) {
|
|
9171
9598
|
const candidate = join22(dir, "assets", "music");
|
|
9172
|
-
if (
|
|
9599
|
+
if (existsSync17(join22(candidate, "manifest.json"))) return candidate;
|
|
9173
9600
|
const parent = dirname4(dir);
|
|
9174
9601
|
if (parent === dir) break;
|
|
9175
9602
|
dir = parent;
|
|
@@ -9182,7 +9609,7 @@ function listBundledMusic() {
|
|
|
9182
9609
|
try {
|
|
9183
9610
|
const parsed = JSON.parse(readFileSync8(join22(dir, "manifest.json"), "utf8"));
|
|
9184
9611
|
const tracks = Array.isArray(parsed.tracks) ? parsed.tracks : [];
|
|
9185
|
-
return tracks.filter((t) => typeof t?.file === "string").map((t) => ({ ...t, path: join22(dir, t.file) })).filter((t) =>
|
|
9612
|
+
return tracks.filter((t) => typeof t?.file === "string").map((t) => ({ ...t, path: join22(dir, t.file) })).filter((t) => existsSync17(t.path));
|
|
9186
9613
|
} catch {
|
|
9187
9614
|
return [];
|
|
9188
9615
|
}
|
|
@@ -9196,6 +9623,16 @@ function resolveBundledBed(mood = DEFAULT_MOOD) {
|
|
|
9196
9623
|
// src/utils/video-pipeline.ts
|
|
9197
9624
|
init_run_root();
|
|
9198
9625
|
var INDENT3 = " ";
|
|
9626
|
+
function captionWordTimings(caption, spoken, duration) {
|
|
9627
|
+
const captionWords = caption.split(/\s+/).filter(Boolean);
|
|
9628
|
+
if (spoken.length === 0 || captionWords.length === 0) {
|
|
9629
|
+
return estimateWordTimings(caption, duration);
|
|
9630
|
+
}
|
|
9631
|
+
if (spoken.length === captionWords.length) {
|
|
9632
|
+
return spoken.map((w, i) => ({ ...w, word: captionWords[i] ?? w.word }));
|
|
9633
|
+
}
|
|
9634
|
+
return estimateWordTimings(caption, duration);
|
|
9635
|
+
}
|
|
9199
9636
|
async function generateASS(plan, stepAudio, workDir, config, captions) {
|
|
9200
9637
|
const cues = [];
|
|
9201
9638
|
let currentTime = VOICE_LEAD_MS;
|
|
@@ -9206,7 +9643,7 @@ async function generateASS(plan, stepAudio, workDir, config, captions) {
|
|
|
9206
9643
|
currentTime += config.timing.minStepDuration;
|
|
9207
9644
|
continue;
|
|
9208
9645
|
}
|
|
9209
|
-
const words =
|
|
9646
|
+
const words = captionWordTimings(step.caption || "", audio.words, audio.duration);
|
|
9210
9647
|
if (words.length > 0 && !step.spokenOnly) {
|
|
9211
9648
|
const offset = currentTime;
|
|
9212
9649
|
cues.push({
|
|
@@ -9226,7 +9663,7 @@ async function generateASS(plan, stepAudio, workDir, config, captions) {
|
|
|
9226
9663
|
return assPath;
|
|
9227
9664
|
}
|
|
9228
9665
|
var STILL_EXTENSIONS = ["png", "jpg", "jpeg", "webp"];
|
|
9229
|
-
function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
|
|
9666
|
+
function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0, totalSecs, blackSpans) {
|
|
9230
9667
|
const overlays = [];
|
|
9231
9668
|
let cumulativeTime = leadInSecs;
|
|
9232
9669
|
for (let i = 0; i < steps.length; i++) {
|
|
@@ -9239,13 +9676,20 @@ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
|
|
|
9239
9676
|
const ext = step.value.split(".").pop()?.toLowerCase() || "";
|
|
9240
9677
|
const still = isCard && STILL_EXTENSIONS.includes(ext);
|
|
9241
9678
|
if (still || isClip && ["mp4", "webm", "mov"].includes(ext)) {
|
|
9242
|
-
const mediaPath =
|
|
9243
|
-
if (
|
|
9679
|
+
const mediaPath = isAbsolute5(step.value) ? step.value : resolve9(baseDir, step.value);
|
|
9680
|
+
if (existsSync18(mediaPath) && durationSec > 0) {
|
|
9244
9681
|
const partial = parsePartial(step.partial);
|
|
9682
|
+
const runsToEnd = still && i === steps.length - 1 && totalSecs !== void 0;
|
|
9683
|
+
let duration = runsToEnd ? Math.max(durationSec, totalSecs - cumulativeTime) : durationSec;
|
|
9684
|
+
if (still && !runsToEnd && blackSpans) {
|
|
9685
|
+
const endsAt = cumulativeTime + duration;
|
|
9686
|
+
const following = blackSpans.find((sp) => sp.start <= endsAt + 0.25 && sp.end > endsAt);
|
|
9687
|
+
if (following) duration = following.end - cumulativeTime;
|
|
9688
|
+
}
|
|
9245
9689
|
overlays.push({
|
|
9246
9690
|
path: mediaPath,
|
|
9247
9691
|
startTime: cumulativeTime,
|
|
9248
|
-
duration
|
|
9692
|
+
duration,
|
|
9249
9693
|
mode: step.target === "stretch" ? "stretch" : step.target === "cover" ? "cover" : "center",
|
|
9250
9694
|
...still ? { still: true } : {},
|
|
9251
9695
|
...partial.trimStart !== void 0 ? { trimStart: partial.trimStart } : {},
|
|
@@ -9258,7 +9702,7 @@ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
|
|
|
9258
9702
|
}
|
|
9259
9703
|
return overlays;
|
|
9260
9704
|
}
|
|
9261
|
-
async function composeAndFinish(videoPath, audioPath, assPath, config, userTier = "free", mainContentDuration, trimStart = 0.5, stepTransitions, hasClipAudio = false, endsOnCreditsCard = false, _stepSync) {
|
|
9705
|
+
async function composeAndFinish(videoPath, audioPath, assPath, config, userTier = "free", mainContentDuration, trimStart = 0.5, stepTransitions, hasClipAudio = false, endsOnCreditsCard = false, _stepSync, musicStartSecs = 0) {
|
|
9262
9706
|
header("Compose");
|
|
9263
9707
|
const configPath = join23(runRoot(), CONFIG_FILE);
|
|
9264
9708
|
console.log(`${INDENT3}${chalk11.dim(`Config: ${formatClickablePath(configPath)}`)}`);
|
|
@@ -9270,13 +9714,13 @@ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier
|
|
|
9270
9714
|
process.exit(0);
|
|
9271
9715
|
}
|
|
9272
9716
|
const composeTask = task("Building final video");
|
|
9273
|
-
await
|
|
9717
|
+
await mkdir9(config.output.dir, { recursive: true });
|
|
9274
9718
|
const suffix = config.lang ? `-${config.lang}` : "";
|
|
9275
9719
|
const finalPath = join23(config.output.dir, `demo${suffix}.${config.video.format}`);
|
|
9276
9720
|
const videoDuration = await getMediaDuration(videoPath) / 1e3;
|
|
9277
|
-
const hasVoiceover = audioPath &&
|
|
9278
|
-
const musicPath = config.music?.path ?
|
|
9279
|
-
let hasMusic = musicPath &&
|
|
9721
|
+
const hasVoiceover = audioPath && existsSync18(audioPath);
|
|
9722
|
+
const musicPath = config.music?.path ? isAbsolute5(config.music.path) ? config.music.path : resolve9(config.output.dir, config.music.path) : "";
|
|
9723
|
+
let hasMusic = musicPath && existsSync18(musicPath);
|
|
9280
9724
|
let bedPath = "";
|
|
9281
9725
|
let bedCredit = "";
|
|
9282
9726
|
if (!hasMusic && config.audio?.ambience !== false) {
|
|
@@ -9328,7 +9772,14 @@ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier
|
|
|
9328
9772
|
hasClipAudio,
|
|
9329
9773
|
mainContentDuration,
|
|
9330
9774
|
stepTransitions,
|
|
9331
|
-
hwaccel: config.video.hwaccel
|
|
9775
|
+
hwaccel: config.video.hwaccel,
|
|
9776
|
+
// The tempo pass runs after this and speeds the whole file up, music
|
|
9777
|
+
// included. Told about it, compose lays the bed down pre-slowed so the
|
|
9778
|
+
// song comes out at its own speed.
|
|
9779
|
+
tempo: config.video.tempo,
|
|
9780
|
+
musicStartSecs,
|
|
9781
|
+
// So the bed can be landed with its last note on the last frame.
|
|
9782
|
+
musicDurationSecs: hasMusic ? await getMediaDuration(backingPath).then((ms) => ms / 1e3).catch(() => 0) : 0
|
|
9332
9783
|
});
|
|
9333
9784
|
composeTask.succeed("Video composed");
|
|
9334
9785
|
return finalPath;
|
|
@@ -9390,18 +9841,18 @@ function resolveOutro(config, userTier, userEmail) {
|
|
|
9390
9841
|
if (!usesQuickPeekOutro(userTier, userEmail)) {
|
|
9391
9842
|
const custom = config.video.outro?.trim();
|
|
9392
9843
|
if (!custom) return void 0;
|
|
9393
|
-
const path =
|
|
9394
|
-
if (
|
|
9844
|
+
const path = resolve10(custom);
|
|
9845
|
+
if (existsSync19(path)) return path;
|
|
9395
9846
|
info(`${INDENT4}\u26A0 outro not found: ${path}`);
|
|
9396
9847
|
return void 0;
|
|
9397
9848
|
}
|
|
9398
9849
|
const scriptDir = getScriptDir();
|
|
9399
9850
|
const bundled = join24(scriptDir, "assets", "qp.wm");
|
|
9400
|
-
if (
|
|
9851
|
+
if (existsSync19(bundled)) return bundled;
|
|
9401
9852
|
const fallback = join24(scriptDir, "..", "dist", "assets", "qp.wm");
|
|
9402
|
-
return
|
|
9853
|
+
return existsSync19(fallback) ? fallback : void 0;
|
|
9403
9854
|
}
|
|
9404
|
-
var EMPTY_MIN_SECS = 0.
|
|
9855
|
+
var EMPTY_MIN_SECS = 0.4;
|
|
9405
9856
|
var OUTRO_TAIL_MS = 900;
|
|
9406
9857
|
var FIT_SECOND_PASS_CUSHION_SECS = 1;
|
|
9407
9858
|
var FIT_SECOND_PASS_MAX_SPEEDUP = 1.45;
|
|
@@ -9426,11 +9877,12 @@ async function renderVideoPhase(params) {
|
|
|
9426
9877
|
})
|
|
9427
9878
|
);
|
|
9428
9879
|
const recordingPath = getRecordingPath(config.output.dir);
|
|
9429
|
-
const hasExistingRecording = existingRecordingMeta &&
|
|
9880
|
+
const hasExistingRecording = existingRecordingMeta && existsSync19(recordingPath);
|
|
9430
9881
|
let videoPath = recordingPath;
|
|
9431
9882
|
const firstStep = plan.steps[0];
|
|
9432
9883
|
const firstStepIsVideo = firstStep && (firstStep.action === "video" || firstStep.action === "media");
|
|
9433
9884
|
let trimStart = 0;
|
|
9885
|
+
let recordedStepLengths = null;
|
|
9434
9886
|
const doRecord = async () => {
|
|
9435
9887
|
header("Record");
|
|
9436
9888
|
const result = await recordSession(plan, config, stepDurations, workDir, config.output.dir, task, logError, {
|
|
@@ -9442,6 +9894,7 @@ async function renderVideoPhase(params) {
|
|
|
9442
9894
|
]);
|
|
9443
9895
|
const detectedEmpty = mergeSpans([...result.leadSpans ?? [], ...emptyBlack, ...emptyWhite]);
|
|
9444
9896
|
let stepLengths = result.actualStepDurations;
|
|
9897
|
+
let zeroAnchored = false;
|
|
9445
9898
|
if (detectedEmpty.length > 0) {
|
|
9446
9899
|
const bounds = [];
|
|
9447
9900
|
let at = 0;
|
|
@@ -9466,6 +9919,7 @@ async function renderVideoPhase(params) {
|
|
|
9466
9919
|
const sum = result.actualStepDurations.reduce((a, b2) => a + b2, 0);
|
|
9467
9920
|
const factor = sum > 0 && realSecs > 0 ? realSecs * 1e3 / sum : 1;
|
|
9468
9921
|
stepLengths = result.actualStepDurations.map((ms) => ms * factor);
|
|
9922
|
+
zeroAnchored = true;
|
|
9469
9923
|
info(`Sped up ${emptySecs.toFixed(1)}s of loading screens at ${EMPTY_SPAN_SPEED}x`);
|
|
9470
9924
|
} else {
|
|
9471
9925
|
await discardPartial(retimed);
|
|
@@ -9482,16 +9936,19 @@ async function renderVideoPhase(params) {
|
|
|
9482
9936
|
const meta = buildRecordingMeta(plan, config, stepDurations);
|
|
9483
9937
|
meta.actualStepDurations = stepLengths;
|
|
9484
9938
|
meta.pageLoadTime = result.pageLoadTime;
|
|
9939
|
+
meta.zeroAnchored = zeroAnchored;
|
|
9485
9940
|
await saveRecordingMeta(config.output.dir, meta);
|
|
9486
9941
|
videoPath = recordingPath;
|
|
9487
|
-
|
|
9942
|
+
recordedStepLengths = stepLengths;
|
|
9943
|
+
trimStart = firstStepIsVideo || zeroAnchored ? 0 : result.pageLoadTime;
|
|
9488
9944
|
};
|
|
9489
9945
|
if (hasExistingRecording) {
|
|
9490
9946
|
const reuseCheck = canReuseRecording(plan, config, stepDurations, existingRecordingMeta);
|
|
9491
9947
|
if (reuseCheck.canReuse) {
|
|
9492
9948
|
info("Reusing existing recording");
|
|
9493
9949
|
videoPath = recordingPath;
|
|
9494
|
-
|
|
9950
|
+
recordedStepLengths = existingRecordingMeta.actualStepDurations ?? existingRecordingMeta.stepDurations;
|
|
9951
|
+
trimStart = firstStepIsVideo || existingRecordingMeta.zeroAnchored ? 0 : existingRecordingMeta?.pageLoadTime ?? 0.5;
|
|
9495
9952
|
stepAudio = await alignVoiceoverToRecording(
|
|
9496
9953
|
stepAudio,
|
|
9497
9954
|
existingRecordingMeta.actualStepDurations ?? existingRecordingMeta.stepDurations,
|
|
@@ -9505,9 +9962,23 @@ async function renderVideoPhase(params) {
|
|
|
9505
9962
|
} else {
|
|
9506
9963
|
await doRecord();
|
|
9507
9964
|
}
|
|
9508
|
-
const alignedStepDurations = plan.steps.map(
|
|
9965
|
+
const alignedStepDurations = plan.steps.map(
|
|
9966
|
+
(step, i) => Math.max(
|
|
9967
|
+
stepAudio.get(i + 1)?.duration ?? recordedStepLengths?.[i] ?? stepDurations[i] ?? 0,
|
|
9968
|
+
step.duration ? step.duration * 1e3 : 0
|
|
9969
|
+
)
|
|
9970
|
+
);
|
|
9509
9971
|
let splicedClipAudio = false;
|
|
9510
|
-
const
|
|
9972
|
+
const recordingSecs = await getMediaDuration(videoPath).then((ms) => ms / 1e3).catch(() => void 0);
|
|
9973
|
+
const blackSpans = await detectBlackSpans(videoPath, 0.3).catch(() => []);
|
|
9974
|
+
const videoOverlays = buildVideoOverlays(
|
|
9975
|
+
plan.steps,
|
|
9976
|
+
alignedStepDurations,
|
|
9977
|
+
config.output.dir,
|
|
9978
|
+
trimStart,
|
|
9979
|
+
recordingSecs,
|
|
9980
|
+
blackSpans
|
|
9981
|
+
);
|
|
9511
9982
|
if (videoOverlays.length > 0) {
|
|
9512
9983
|
const spliceTask = task("Painting cards and clips into recording");
|
|
9513
9984
|
try {
|
|
@@ -9561,11 +10032,13 @@ async function renderVideoPhase(params) {
|
|
|
9561
10032
|
const cardStepSeconds = (index) => {
|
|
9562
10033
|
const action = plan.steps[index]?.action;
|
|
9563
10034
|
if (action !== "image" && action !== "media") return 0;
|
|
9564
|
-
|
|
10035
|
+
const audioSecs = (stepAudio.get(index + 1)?.duration ?? 0) / 1e3;
|
|
10036
|
+
const tail = index === plan.steps.length - 1 && action === "image" ? OUTRO_TAIL_MS / 1e3 : 0;
|
|
10037
|
+
return Math.max(audioSecs, plan.steps[index]?.duration ?? 0) + tail;
|
|
9565
10038
|
};
|
|
9566
10039
|
const introSecs = cardStepSeconds(0);
|
|
9567
10040
|
const outroStepSecs = cardStepSeconds(plan.steps.length - 1);
|
|
9568
|
-
const outroSeconds = config.video.outroCard?.seconds ??
|
|
10041
|
+
const outroSeconds = Math.max(config.video.outroCard?.seconds ?? 3.5, outroStepSecs);
|
|
9569
10042
|
const card = config.video.outroCard;
|
|
9570
10043
|
if (card?.subject?.name) {
|
|
9571
10044
|
const cardTask = task("Rendering credits card");
|
|
@@ -9609,6 +10082,20 @@ async function renderVideoPhase(params) {
|
|
|
9609
10082
|
mainContentDuration = void 0;
|
|
9610
10083
|
}
|
|
9611
10084
|
}
|
|
10085
|
+
const outroOnly = config.music?.outroOnly !== false;
|
|
10086
|
+
let musicStartSecs = 0;
|
|
10087
|
+
if (outroOnly) {
|
|
10088
|
+
let trailingCards = 0;
|
|
10089
|
+
for (let i = plan.steps.length - 1; i >= 0; i--) {
|
|
10090
|
+
const action = plan.steps[i]?.action;
|
|
10091
|
+
if (action !== "image" && action !== "media") break;
|
|
10092
|
+
trailingCards++;
|
|
10093
|
+
}
|
|
10094
|
+
if (trailingCards > 0 && trailingCards < plan.steps.length) {
|
|
10095
|
+
const upTo = plan.steps.length - trailingCards;
|
|
10096
|
+
musicStartSecs = alignedStepDurations.slice(0, upTo).reduce((a, b) => a + b, 0) / 1e3;
|
|
10097
|
+
}
|
|
10098
|
+
}
|
|
9612
10099
|
let finalPath = await composeAndFinish(
|
|
9613
10100
|
videoToCompose,
|
|
9614
10101
|
voiceoverPath,
|
|
@@ -9620,7 +10107,9 @@ async function renderVideoPhase(params) {
|
|
|
9620
10107
|
stepTransitions,
|
|
9621
10108
|
// The splice writes a clip-audio track only when a spliced clip had one.
|
|
9622
10109
|
splicedClipAudio,
|
|
9623
|
-
endsOnCreditsCard
|
|
10110
|
+
endsOnCreditsCard,
|
|
10111
|
+
void 0,
|
|
10112
|
+
musicStartSecs
|
|
9624
10113
|
);
|
|
9625
10114
|
const maxSilence = config.video.maxSilenceSecs ?? 0.6;
|
|
9626
10115
|
if (maxSilence > 0) {
|
|
@@ -9785,7 +10274,7 @@ async function discardPartial(path) {
|
|
|
9785
10274
|
}
|
|
9786
10275
|
var MIN_ARTIFACT_BYTES = 1024;
|
|
9787
10276
|
function assertArtifact(finalPath) {
|
|
9788
|
-
const size =
|
|
10277
|
+
const size = existsSync19(finalPath) ? statSync3(finalPath).size : 0;
|
|
9789
10278
|
if (size >= MIN_ARTIFACT_BYTES) return;
|
|
9790
10279
|
throw new Error(
|
|
9791
10280
|
`The run finished without producing a video: ${finalPath} ${size === 0 ? "does not exist" : `is only ${size} bytes`}. Check the log above for the failed ffmpeg pass.`
|
|
@@ -9839,7 +10328,7 @@ async function useOgCardAsIntro(plan, url, config, fresh = false) {
|
|
|
9839
10328
|
const rewritable = first.action === "wait" && !first.target;
|
|
9840
10329
|
const portrait = (config.video.height ?? 0) > (config.video.width ?? 0);
|
|
9841
10330
|
const existing = join25(config.output.dir, "og-card.png");
|
|
9842
|
-
const fetched = !fresh &&
|
|
10331
|
+
const fetched = !fresh && existsSync20(existing) ? existing : await fetchOgCard(url, config.output.dir, portrait);
|
|
9843
10332
|
const ogPath = fetched && coversFrame(fetched, config) ? fetched : null;
|
|
9844
10333
|
const cardPath = ogPath ?? await renderGeneratedIntroCard(url, config);
|
|
9845
10334
|
if (!cardPath) {
|
|
@@ -9929,30 +10418,20 @@ function fitFor(cardPath, config) {
|
|
|
9929
10418
|
return Math.abs(size.width / size.height - frame) < 0.06 ? "stretch" : "cover";
|
|
9930
10419
|
}
|
|
9931
10420
|
function resolveCardPath(value, config) {
|
|
9932
|
-
return
|
|
10421
|
+
return isAbsolute6(value) ? value : join25(config.output.dir, value.replace(/^\.\//, ""));
|
|
9933
10422
|
}
|
|
9934
10423
|
var DEFAULT_INTRO_CARD_SECONDS = 2.5;
|
|
9935
10424
|
function packageAsset(relative) {
|
|
9936
10425
|
let dir = getScriptDir();
|
|
9937
10426
|
for (let up = 0; up < 6; up++) {
|
|
9938
10427
|
const candidate = join25(dir, relative);
|
|
9939
|
-
if (
|
|
9940
|
-
dir = join25(dir, "..");
|
|
9941
|
-
}
|
|
9942
|
-
return void 0;
|
|
9943
|
-
}
|
|
9944
|
-
function siblingAsset(relative) {
|
|
9945
|
-
let dir = join25(getScriptDir(), "..");
|
|
9946
|
-
for (let up = 0; up < 7; up++) {
|
|
9947
|
-
const candidate = join25(dir, relative);
|
|
9948
|
-
if (existsSync19(candidate)) return candidate;
|
|
10428
|
+
if (existsSync20(candidate)) return candidate;
|
|
9949
10429
|
dir = join25(dir, "..");
|
|
9950
10430
|
}
|
|
9951
10431
|
return void 0;
|
|
9952
10432
|
}
|
|
9953
10433
|
var CREDIT_TOOLS = [
|
|
9954
|
-
{ name: "QuickPeek", role: "Video generated by", icon: () => packageAsset("imgs/qp.png") }
|
|
9955
|
-
{ name: "Vidlet", role: "Video edited by", icon: () => siblingAsset("VidLet/res/icons/vidlet-512.png") }
|
|
10434
|
+
{ name: "QuickPeek", role: "Video generated by", icon: () => packageAsset("imgs/qp.png") }
|
|
9956
10435
|
];
|
|
9957
10436
|
function oneTagline(text) {
|
|
9958
10437
|
if (!text) return void 0;
|
|
@@ -10044,7 +10523,7 @@ async function runPlanPhase(params) {
|
|
|
10044
10523
|
const extensionPath = config.browser?.extension;
|
|
10045
10524
|
let existingPlan = null;
|
|
10046
10525
|
let existingPlanPath = null;
|
|
10047
|
-
if (
|
|
10526
|
+
if (existsSync21(planPath)) {
|
|
10048
10527
|
try {
|
|
10049
10528
|
const parsed = JSON.parse(await readFile9(planPath, "utf-8"));
|
|
10050
10529
|
if (parsed.url && parsed.steps?.length > 0) {
|
|
@@ -10074,7 +10553,22 @@ async function runPlanPhase(params) {
|
|
|
10074
10553
|
}
|
|
10075
10554
|
}
|
|
10076
10555
|
const normalizeUrl2 = (u) => u.replace(/\/+$/, "").toLowerCase();
|
|
10077
|
-
const
|
|
10556
|
+
const isBareOrigin = (u) => {
|
|
10557
|
+
try {
|
|
10558
|
+
const parsed = new URL(u);
|
|
10559
|
+
return parsed.pathname === "/" && !parsed.search && !parsed.hash;
|
|
10560
|
+
} catch {
|
|
10561
|
+
return false;
|
|
10562
|
+
}
|
|
10563
|
+
};
|
|
10564
|
+
const sameOrigin = (a, b) => {
|
|
10565
|
+
try {
|
|
10566
|
+
return new URL(a).origin === new URL(b).origin;
|
|
10567
|
+
} catch {
|
|
10568
|
+
return false;
|
|
10569
|
+
}
|
|
10570
|
+
};
|
|
10571
|
+
const urlMatches = existingPlan && (normalizeUrl2(existingPlan.url) === normalizeUrl2(url) || isBareOrigin(existingPlan.url) && sameOrigin(existingPlan.url, url));
|
|
10078
10572
|
if (existingPlan && !urlMatches && isAutoYes()) {
|
|
10079
10573
|
console.log();
|
|
10080
10574
|
console.log(chalk13.dim(` Existing plan is for ${existingPlan.url}, not ${url}, regenerating.`));
|
|
@@ -10218,7 +10712,7 @@ async function runPlanPhase(params) {
|
|
|
10218
10712
|
const planLang = plan.lang || "en";
|
|
10219
10713
|
if (planLang !== targetLang) {
|
|
10220
10714
|
let useExistingTranslation = false;
|
|
10221
|
-
if (
|
|
10715
|
+
if (existsSync21(planPath) && existingPlanPath !== planPath) {
|
|
10222
10716
|
try {
|
|
10223
10717
|
const translatedPlan = JSON.parse(await readFile9(planPath, "utf-8"));
|
|
10224
10718
|
if (translatedPlan.steps?.length === plan.steps.length && translatedPlan.lang === targetLang) {
|
|
@@ -10397,7 +10891,7 @@ async function runValidationPhase(params) {
|
|
|
10397
10891
|
|
|
10398
10892
|
// src/commands/phases/voiceover-phase.ts
|
|
10399
10893
|
init_lib();
|
|
10400
|
-
import { existsSync as
|
|
10894
|
+
import { existsSync as existsSync23 } from "fs";
|
|
10401
10895
|
import { join as join28 } from "path";
|
|
10402
10896
|
import chalk15 from "chalk";
|
|
10403
10897
|
var INDENT6 = " ";
|
|
@@ -10458,14 +10952,14 @@ async function clearCachedFiles(outputDir) {
|
|
|
10458
10952
|
const { unlink: unlink5 } = await import("fs/promises");
|
|
10459
10953
|
for (const file of CACHED_FILES) {
|
|
10460
10954
|
const filePath = join28(outputDir, file);
|
|
10461
|
-
if (
|
|
10955
|
+
if (existsSync23(filePath)) await unlink5(filePath).catch(() => {
|
|
10462
10956
|
});
|
|
10463
10957
|
}
|
|
10464
10958
|
info("Cleared cached files (--fresh)");
|
|
10465
10959
|
}
|
|
10466
10960
|
async function usableVoiceover(outputDir, meta) {
|
|
10467
10961
|
const voiceoverFile = join28(outputDir, "voiceover.wav");
|
|
10468
|
-
if (!meta || !
|
|
10962
|
+
if (!meta || !existsSync23(voiceoverFile)) return null;
|
|
10469
10963
|
if (await isHonestWav(voiceoverFile)) return meta;
|
|
10470
10964
|
info("Existing voiceover has a stale container, regenerating");
|
|
10471
10965
|
return null;
|
|
@@ -10543,7 +11037,7 @@ async function ensureCliSignIn(url, opts) {
|
|
|
10543
11037
|
}
|
|
10544
11038
|
|
|
10545
11039
|
// src/commands/utils/chrome-extension.ts
|
|
10546
|
-
import { existsSync as
|
|
11040
|
+
import { existsSync as existsSync24, readdirSync as readdirSync5, readFileSync as readFileSync9 } from "fs";
|
|
10547
11041
|
import { homedir as homedir4 } from "os";
|
|
10548
11042
|
import { join as join29 } from "path";
|
|
10549
11043
|
function resolveChromeExtension(extensionId) {
|
|
@@ -10579,7 +11073,7 @@ function resolveChromeExtension(extensionId) {
|
|
|
10579
11073
|
}
|
|
10580
11074
|
for (const chromeExtDir of possibleDirs) {
|
|
10581
11075
|
const extDir = join29(chromeExtDir, extensionId);
|
|
10582
|
-
if (!
|
|
11076
|
+
if (!existsSync24(extDir)) continue;
|
|
10583
11077
|
try {
|
|
10584
11078
|
const versions = readdirSync5(extDir).filter((f) => !f.startsWith("."));
|
|
10585
11079
|
if (versions.length === 0) continue;
|
|
@@ -10588,7 +11082,7 @@ function resolveChromeExtension(extensionId) {
|
|
|
10588
11082
|
let name = extensionId;
|
|
10589
11083
|
let popupPath = "popup.html";
|
|
10590
11084
|
const manifestPath = join29(extPath, "manifest.json");
|
|
10591
|
-
if (
|
|
11085
|
+
if (existsSync24(manifestPath)) {
|
|
10592
11086
|
try {
|
|
10593
11087
|
const manifest = JSON.parse(readFileSync9(manifestPath, "utf-8"));
|
|
10594
11088
|
if (manifest.name && !manifest.name.startsWith("__MSG_")) {
|
|
@@ -10611,7 +11105,7 @@ function resolveChromeExtension(extensionId) {
|
|
|
10611
11105
|
|
|
10612
11106
|
// src/commands/utils/plan-review.ts
|
|
10613
11107
|
import { createHash as createHash2 } from "crypto";
|
|
10614
|
-
import { existsSync as
|
|
11108
|
+
import { existsSync as existsSync25 } from "fs";
|
|
10615
11109
|
import { readFile as readFile11, writeFile as writeFile12 } from "fs/promises";
|
|
10616
11110
|
import { join as join30 } from "path";
|
|
10617
11111
|
var REVIEW_FILE = "plan-review.json";
|
|
@@ -10624,7 +11118,7 @@ async function hashPlanFile(planPath) {
|
|
|
10624
11118
|
}
|
|
10625
11119
|
async function loadPlanReview(outputDir) {
|
|
10626
11120
|
const path = join30(outputDir, REVIEW_FILE);
|
|
10627
|
-
if (!
|
|
11121
|
+
if (!existsSync25(path)) return {};
|
|
10628
11122
|
try {
|
|
10629
11123
|
return JSON.parse(await readFile11(path, "utf-8"));
|
|
10630
11124
|
} catch {
|
|
@@ -10746,8 +11240,8 @@ async function recordCommand(url, options = {}) {
|
|
|
10746
11240
|
}
|
|
10747
11241
|
if (langOverride) config.lang = langOverride;
|
|
10748
11242
|
if (config.music?.path) {
|
|
10749
|
-
const musicPath =
|
|
10750
|
-
if (!
|
|
11243
|
+
const musicPath = isAbsolute7(config.music.path) ? config.music.path : resolve11(config.output.dir, config.music.path);
|
|
11244
|
+
if (!existsSync26(musicPath)) {
|
|
10751
11245
|
console.log();
|
|
10752
11246
|
console.log(chalk17.yellow(` \u26A0 Music file not found: ${config.music.path}`));
|
|
10753
11247
|
console.log(chalk17.dim(` Config: ${join31(runRoot(), CONFIG_FILE)}`));
|
|
@@ -10768,7 +11262,7 @@ async function recordCommand(url, options = {}) {
|
|
|
10768
11262
|
const targetLang = config.lang || "en";
|
|
10769
11263
|
const planPath = join31(config.output.dir, `plan-${targetLang}.json`);
|
|
10770
11264
|
const isExtensionUrl = url.startsWith("chrome-extension://");
|
|
10771
|
-
await
|
|
11265
|
+
await mkdir10(config.output.dir, { recursive: true });
|
|
10772
11266
|
await setLogFilePath(config.output.dir);
|
|
10773
11267
|
const preRunHash = await hashPlanFile(planPath);
|
|
10774
11268
|
try {
|
|
@@ -10857,6 +11351,13 @@ async function recordCommand(url, options = {}) {
|
|
|
10857
11351
|
userTier,
|
|
10858
11352
|
userEmail
|
|
10859
11353
|
});
|
|
11354
|
+
await registerDemoRun(userEmail, {
|
|
11355
|
+
source: currentMcpTool2() ? "mcp" : "cli",
|
|
11356
|
+
targetUrl: validatedPlan.url || url,
|
|
11357
|
+
localPath: finalPath,
|
|
11358
|
+
title: validatedPlan.title,
|
|
11359
|
+
...config.lang ? { language: config.lang } : {}
|
|
11360
|
+
});
|
|
10860
11361
|
if (!userEmail || userEmail === "anonymous") {
|
|
10861
11362
|
const newCount = localVideoCount + 1;
|
|
10862
11363
|
await saveUserData({ localVideoCount: newCount });
|
|
@@ -11287,7 +11788,7 @@ function withTierNote(result, tier) {
|
|
|
11287
11788
|
...result.content,
|
|
11288
11789
|
{
|
|
11289
11790
|
type: "text",
|
|
11290
|
-
text: `Free tier:
|
|
11791
|
+
text: `Free tier: a demo of 20 seconds or under comes out clean; over 20 seconds it carries a watermark. The free video limit applies. Upgrade at ${pricingUrl()}`
|
|
11291
11792
|
}
|
|
11292
11793
|
]
|
|
11293
11794
|
};
|
|
@@ -11383,7 +11884,7 @@ var FORMAT_PROPERTY = {
|
|
|
11383
11884
|
var APPROVE_PLAN_PROPERTY = {
|
|
11384
11885
|
approve_plan: {
|
|
11385
11886
|
type: "boolean",
|
|
11386
|
-
description: "The user has reviewed and approved the planned steps this tool returned as plan_ready
|
|
11887
|
+
description: "The user has reviewed and approved the planned steps this tool returned as plan_ready. Record them. Only honored while the plan file is unchanged since the call started, so it can never approve steps the user was not shown. Never pass it on a first call."
|
|
11387
11888
|
}
|
|
11388
11889
|
};
|
|
11389
11890
|
var DRAFT_PROPERTY = {
|
|
@@ -11430,7 +11931,7 @@ var OUTRO_PROPERTY = {
|
|
|
11430
11931
|
var QUICKPEEK_TOOLS = [
|
|
11431
11932
|
{
|
|
11432
11933
|
name: "generate_demo",
|
|
11433
|
-
description: "Record a narrated demo video of a web app, end to end: an AI plans the walkthrough, Playwright drives a real browser, and the result is composed with voiceover and burned karaoke captions. This is the headline tool. It takes minutes, not seconds, because a real browser session is recorded. Returns the finished MP4 path and a file:// url. The flow is interactive: with no saved session the user is asked to sign in first (a visible browser opens; QuickPeek never sees the password) so the demo starts inside the app instead of the logged-out landing page; the user picks voice gender, pace and video size on the first run (remembered afterwards); and the planned steps pause for the user\u2019s review before recording. On clients that cannot prompt, the tool returns setup_required / plan_ready statuses instead
|
|
11934
|
+
description: "Record a narrated demo video of a web app, end to end: an AI plans the walkthrough, Playwright drives a real browser, and the result is composed with voiceover and burned karaoke captions. This is the headline tool. It takes minutes, not seconds, because a real browser session is recorded. Returns the finished MP4 path and a file:// url. The flow is interactive: with no saved session the user is asked to sign in first (a visible browser opens; QuickPeek never sees the password) so the demo starts inside the app instead of the logged-out landing page; the user picks voice gender, pace and video size on the first run (remembered afterwards); and the planned steps pause for the user\u2019s review before recording. On clients that cannot prompt, the tool returns setup_required / plan_ready statuses instead. Ask the USER the questions they carry (never answer them yourself) and re-call with the answers. Pass skip_auth for public sites. The result says whether this recording was signed in.",
|
|
11434
11935
|
inputSchema: {
|
|
11435
11936
|
type: "object",
|
|
11436
11937
|
properties: {
|
|
@@ -11506,7 +12007,7 @@ var QUICKPEEK_TOOLS = [
|
|
|
11506
12007
|
},
|
|
11507
12008
|
{
|
|
11508
12009
|
name: "start_auth",
|
|
11509
|
-
description: "Open a visible browser on a site so the user can sign in, once, by hand. Everything recorded afterwards starts from that session, and demos of the bare URL begin on the page the user leaves open when the session is saved
|
|
12010
|
+
description: "Open a visible browser on a site so the user can sign in, once, by hand. Everything recorded afterwards starts from that session, and demos of the bare URL begin on the page the user leaves open when the session is saved. Tell them to finish on their dashboard. QuickPeek never sees the password: only the resulting cookies are saved. On clients that support prompting, this saves the session as soon as the user confirms; otherwise call finish_auth when they say they are done.",
|
|
11510
12011
|
inputSchema: {
|
|
11511
12012
|
type: "object",
|
|
11512
12013
|
properties: { ...URL_PROPERTY },
|
|
@@ -11543,7 +12044,7 @@ var QUICKPEEK_TOOLS = [
|
|
|
11543
12044
|
},
|
|
11544
12045
|
{
|
|
11545
12046
|
name: "check_account",
|
|
11546
|
-
description: "Report the signed-in account and its tier, so you know whether a demo will carry a watermark or hit the free video limit before spending minutes recording one.",
|
|
12047
|
+
description: "Report the signed-in account and its tier, so you know whether a demo will carry a watermark or hit the free video limit before spending minutes recording one. Over MCP a free demo of 20 seconds or under is clean; only a longer one is watermarked.",
|
|
11547
12048
|
inputSchema: { type: "object", properties: {} }
|
|
11548
12049
|
}
|
|
11549
12050
|
];
|
|
@@ -11628,7 +12129,7 @@ async function runDemoGates(url, args) {
|
|
|
11628
12129
|
...missing.length ? { options_needed: missing } : {},
|
|
11629
12130
|
next_steps: [
|
|
11630
12131
|
...authNeeded ? ["Ask the user whether to sign in; start_auth then finish_auth, or skip_auth: true."] : [],
|
|
11631
|
-
...missing.length ? [`Ask the USER to choose ${missing.join(", ")}
|
|
12132
|
+
...missing.length ? [`Ask the USER to choose ${missing.join(", ")}, do not pick for them, and pass the answers as params.`] : [],
|
|
11632
12133
|
"Then call this tool again with the same arguments plus the answers."
|
|
11633
12134
|
]
|
|
11634
12135
|
});
|
|
@@ -11664,7 +12165,7 @@ async function runDemoGates(url, args) {
|
|
|
11664
12165
|
return jsonContent({
|
|
11665
12166
|
status: "setup_required",
|
|
11666
12167
|
host,
|
|
11667
|
-
sign_in: `The client could not show the sign-in prompt. Call start_auth with this url, then finish_auth once the user confirms
|
|
12168
|
+
sign_in: `The client could not show the sign-in prompt. Call start_auth with this url, then finish_auth once the user confirms, or pass skip_auth: true for a logged-out demo.`
|
|
11668
12169
|
});
|
|
11669
12170
|
}
|
|
11670
12171
|
}
|
|
@@ -11714,7 +12215,7 @@ async function runDemoGates(url, args) {
|
|
|
11714
12215
|
}
|
|
11715
12216
|
function mcpPlanReview() {
|
|
11716
12217
|
return async (plan, planPath) => {
|
|
11717
|
-
const editorUrl =
|
|
12218
|
+
const editorUrl = await editorUrlWithThumbs(editorBaseUrl(), plan, dirname5(planPath));
|
|
11718
12219
|
if (!canElicit()) throw new PlanReviewPending(plan, planPath, editorUrl);
|
|
11719
12220
|
if (editorUrl) await openInBrowser(editorUrl);
|
|
11720
12221
|
const steps = plan.steps.map((s, i) => `${i + 1}. [${s.action}] ${s.caption ?? ""}`).join("\n");
|
|
@@ -11723,7 +12224,7 @@ function mcpPlanReview() {
|
|
|
11723
12224
|
|
|
11724
12225
|
${steps}
|
|
11725
12226
|
|
|
11726
|
-
` + (editorUrl ? `The plan is open in your browser editor
|
|
12227
|
+
` + (editorUrl ? `The plan is open in your browser editor. To change it, edit there and Save over the plan file first. ` : "") + `Accept to record, decline to cancel.`,
|
|
11727
12228
|
"approved",
|
|
11728
12229
|
"Record this plan?"
|
|
11729
12230
|
);
|
|
@@ -11741,14 +12242,14 @@ function planReadyResult(pending2) {
|
|
|
11741
12242
|
next_steps: [
|
|
11742
12243
|
"Show these steps to the USER and ask whether to record them (they can also fine-tune in editor_url).",
|
|
11743
12244
|
"If approved, call this tool again with the same arguments plus approve_plan: true.",
|
|
11744
|
-
"To change the story instead, re-call with fresh: true and a `description` saying what to cover
|
|
12245
|
+
"To change the story instead, re-call with fresh: true and a `description` saying what to cover. Without fresh, the cached plan is reused as-is."
|
|
11745
12246
|
]
|
|
11746
12247
|
});
|
|
11747
12248
|
}
|
|
11748
12249
|
function planRejectedResult(e) {
|
|
11749
12250
|
return jsonContent({
|
|
11750
12251
|
status: e.outcome === "decline" ? "plan_rejected" : "cancelled",
|
|
11751
|
-
note: e.outcome === "decline" ? "The user declined the planned steps; nothing was recorded. Ask what the demo should cover, then re-call with a `description` that says so (or let them edit the plan file)
|
|
12252
|
+
note: e.outcome === "decline" ? "The user declined the planned steps; nothing was recorded. Ask what the demo should cover, then re-call with a `description` that says so (or let them edit the plan file). Passing fresh: true regenerates from scratch." : "The review prompt was dismissed; nothing was recorded."
|
|
11752
12253
|
});
|
|
11753
12254
|
}
|
|
11754
12255
|
async function handleGenerateDemo(args) {
|
|
@@ -11841,7 +12342,7 @@ async function handleOpenInEditor(args) {
|
|
|
11841
12342
|
const parsed = parsePlanText(readFileSync10(planPath, "utf8"));
|
|
11842
12343
|
if (!parsed.ok) return errorContent(`${planPath} cannot be opened: ${parsed.error}`);
|
|
11843
12344
|
const base = editorBaseUrl();
|
|
11844
|
-
const editorUrl =
|
|
12345
|
+
const editorUrl = await editorUrlWithThumbs(base, parsed.plan, dir);
|
|
11845
12346
|
await openInBrowser(editorUrl ?? `${base}/edit`);
|
|
11846
12347
|
return jsonContent({
|
|
11847
12348
|
opened: true,
|
|
@@ -11930,7 +12431,7 @@ async function handleCheckAccount() {
|
|
|
11930
12431
|
return jsonContent({
|
|
11931
12432
|
signedIn: false,
|
|
11932
12433
|
tier: "free",
|
|
11933
|
-
note: "Not signed in.
|
|
12434
|
+
note: "Not signed in. Over MCP a demo of 20 seconds or under comes out clean; a longer one carries a watermark. The free video limit applies."
|
|
11934
12435
|
});
|
|
11935
12436
|
}
|
|
11936
12437
|
const tier = await getTierByEmail(userData.email);
|
|
@@ -11938,15 +12439,15 @@ async function handleCheckAccount() {
|
|
|
11938
12439
|
signedIn: true,
|
|
11939
12440
|
email: userData.email,
|
|
11940
12441
|
tier,
|
|
11941
|
-
note: tier === "free" ? "Free tier:
|
|
12442
|
+
note: tier === "free" ? "Free tier: a demo of 20 seconds or under comes out clean; a longer one carries a watermark. The free video limit applies." : `${tier} tier: no watermark.`
|
|
11942
12443
|
});
|
|
11943
12444
|
});
|
|
11944
12445
|
}
|
|
11945
12446
|
var PROMO_DIR = "promo";
|
|
11946
12447
|
function resolveRunRoot(projectDir) {
|
|
11947
12448
|
if (typeof projectDir !== "string" || !projectDir.trim()) return process.cwd();
|
|
11948
|
-
const abs =
|
|
11949
|
-
if (!
|
|
12449
|
+
const abs = resolve12(projectDir.trim());
|
|
12450
|
+
if (!existsSync27(abs) || !statSync4(abs).isDirectory()) {
|
|
11950
12451
|
throw new Error(`project_dir is not an existing directory: ${abs}`);
|
|
11951
12452
|
}
|
|
11952
12453
|
const root = basename5(abs) === PROMO_DIR ? abs : join33(abs, PROMO_DIR);
|