@spark-apps/quickpeek 1.2.4 → 1.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -22
- package/dist/assets/music/manifest.json +1 -1
- package/dist/index.d.mts +49 -10
- package/dist/index.mjs +215 -143
- package/dist/mcp-tools.mjs +968 -431
- package/dist/qp.js +1014 -473
- package/dist/{voices-CPpnWn39.d.mts → voices-5AFt1GDp.d.mts} +80 -4
- package/dist/web.d.mts +2 -2
- package/dist/web.mjs +154 -20
- package/package.json +9 -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,181 @@ 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
|
+
|
|
299
|
+
// src/browser/headless.ts
|
|
300
|
+
import { chromium } from "playwright";
|
|
301
|
+
async function launchHeadlessChromium(options = {}) {
|
|
302
|
+
const opts = { ...options, headless: true };
|
|
303
|
+
try {
|
|
304
|
+
return await chromium.launch(opts);
|
|
305
|
+
} catch (bundledError) {
|
|
306
|
+
try {
|
|
307
|
+
return await chromium.launch({ ...opts, channel: "chrome" });
|
|
308
|
+
} catch {
|
|
309
|
+
const detail = bundledError instanceof Error ? bundledError.message : String(bundledError);
|
|
310
|
+
throw new Error(`${NO_BROWSER_MESSAGE}
|
|
311
|
+
|
|
312
|
+
Playwright reported: ${detail}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
var NO_BROWSER_MESSAGE;
|
|
317
|
+
var init_headless = __esm({
|
|
318
|
+
"src/browser/headless.ts"() {
|
|
319
|
+
"use strict";
|
|
320
|
+
NO_BROWSER_MESSAGE = "QuickPeek needs a browser to record and could not find one.\n Run this once, then run QuickPeek again:\n npx playwright install chromium";
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// src/lib/json-repair.ts
|
|
325
|
+
function repairUnicodeEscapes(content) {
|
|
326
|
+
return content.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (match, hex) => {
|
|
327
|
+
const code = Number.parseInt(hex, 16);
|
|
328
|
+
return code <= 1114111 ? String.fromCodePoint(code) : match;
|
|
329
|
+
}).replace(/\\u(?![0-9a-fA-F]{4})/g, "\\\\u");
|
|
330
|
+
}
|
|
331
|
+
function salvageTruncatedJson(content) {
|
|
332
|
+
const stack = [];
|
|
333
|
+
const cuts = [];
|
|
334
|
+
let inString = false;
|
|
335
|
+
let escaped = false;
|
|
336
|
+
const record = (index, closesContainer) => cuts.push({ index, open: stack.join(""), closesContainer });
|
|
337
|
+
for (let i = 0; i < content.length; i++) {
|
|
338
|
+
const ch = content[i];
|
|
339
|
+
if (inString) {
|
|
340
|
+
if (escaped) escaped = false;
|
|
341
|
+
else if (ch === "\\") escaped = true;
|
|
342
|
+
else if (ch === '"') {
|
|
343
|
+
inString = false;
|
|
344
|
+
record(i + 1, false);
|
|
345
|
+
}
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (ch === '"') {
|
|
349
|
+
inString = true;
|
|
350
|
+
} else if (ch === "{" || ch === "[") {
|
|
351
|
+
stack.push(ch);
|
|
352
|
+
} else if (ch === "}" || ch === "]") {
|
|
353
|
+
stack.pop();
|
|
354
|
+
record(i + 1, true);
|
|
355
|
+
} else if (ch === ",") {
|
|
356
|
+
record(i, content[i - 1] === "}" || content[i - 1] === "]");
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const tryCut = (cut) => {
|
|
360
|
+
const head = content.slice(0, cut.index).replace(/[\s,]+$/, "");
|
|
361
|
+
if (head.endsWith(":")) return null;
|
|
362
|
+
let closed = head;
|
|
363
|
+
for (let d = cut.open.length - 1; d >= 0; d--) {
|
|
364
|
+
closed += CLOSERS[cut.open[d]];
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
JSON.parse(closed);
|
|
368
|
+
return closed;
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
const attempts = Math.min(cuts.length, MAX_SALVAGE_ATTEMPTS);
|
|
374
|
+
for (const containerOnly of [true, false]) {
|
|
375
|
+
for (let n = 0; n < attempts; n++) {
|
|
376
|
+
const cut = cuts[cuts.length - 1 - n];
|
|
377
|
+
if (containerOnly && !cut.closesContainer) continue;
|
|
378
|
+
const salvaged = tryCut(cut);
|
|
379
|
+
if (salvaged !== null) return salvaged;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
function extractJsonCandidates(content) {
|
|
385
|
+
let text = content.trim();
|
|
386
|
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)(?:```|$)/);
|
|
387
|
+
if (fence?.[1]?.trim()) text = fence[1].trim();
|
|
388
|
+
const start = text.search(/[{[]/);
|
|
389
|
+
if (start === -1) return [];
|
|
390
|
+
const closer = text[start] === "{" ? "}" : "]";
|
|
391
|
+
const end = text.lastIndexOf(closer);
|
|
392
|
+
const candidates = [text.slice(start)];
|
|
393
|
+
if (end > start && end < text.length - 1) candidates.unshift(text.slice(start, end + 1));
|
|
394
|
+
return candidates;
|
|
395
|
+
}
|
|
396
|
+
function parseAIJson(content) {
|
|
397
|
+
const candidates = extractJsonCandidates(content);
|
|
398
|
+
if (candidates.length === 0) return null;
|
|
399
|
+
for (const text of candidates) {
|
|
400
|
+
try {
|
|
401
|
+
return { value: JSON.parse(text), repaired: false };
|
|
402
|
+
} catch {
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
for (const text of candidates) {
|
|
406
|
+
try {
|
|
407
|
+
return { value: JSON.parse(repairUnicodeEscapes(text)), repaired: true };
|
|
408
|
+
} catch {
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
for (const text of candidates) {
|
|
412
|
+
for (const variant of [repairUnicodeEscapes(text), text]) {
|
|
413
|
+
const salvaged = salvageTruncatedJson(variant);
|
|
414
|
+
if (salvaged === null) continue;
|
|
415
|
+
try {
|
|
416
|
+
return { value: JSON.parse(salvaged), repaired: true };
|
|
417
|
+
} catch {
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
var MAX_SALVAGE_ATTEMPTS, CLOSERS;
|
|
424
|
+
var init_json_repair = __esm({
|
|
425
|
+
"src/lib/json-repair.ts"() {
|
|
426
|
+
"use strict";
|
|
427
|
+
MAX_SALVAGE_ATTEMPTS = 500;
|
|
428
|
+
CLOSERS = { "{": "}", "[": "]" };
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
257
432
|
// src/lib/api-client.ts
|
|
258
433
|
function apiKey() {
|
|
259
434
|
return process.env.SPARK_AI_KEY || "";
|
|
@@ -370,7 +545,7 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
|
|
|
370
545
|
...detail ? { detail } : {},
|
|
371
546
|
...retryAfter2 ? { retryAfterSeconds: parseInt(retryAfter2, 10) } : {}
|
|
372
547
|
};
|
|
373
|
-
await new Promise((
|
|
548
|
+
await new Promise((resolve13) => setTimeout(resolve13, Math.min(delayMs, 3e4)));
|
|
374
549
|
continue;
|
|
375
550
|
}
|
|
376
551
|
const retryAfter = response.headers.get("Retry-After");
|
|
@@ -410,7 +585,7 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
|
|
|
410
585
|
return { success: false, error: `${host} unreachable: ${getErrorMessage(err)}`, host };
|
|
411
586
|
}
|
|
412
587
|
const delayMs = 1e3 * 2 ** (attempt - 1);
|
|
413
|
-
await new Promise((
|
|
588
|
+
await new Promise((resolve13) => setTimeout(resolve13, delayMs));
|
|
414
589
|
}
|
|
415
590
|
}
|
|
416
591
|
return lastRetriableError || { success: false, error: `${host}: request failed after retries`, host };
|
|
@@ -452,24 +627,26 @@ async function callAIViaRelay(systemPrompt, userPrompt, email, tier = "free", op
|
|
|
452
627
|
}
|
|
453
628
|
}
|
|
454
629
|
function parseAIPlanResponse(content) {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
if (codeBlockMatch?.[1]) {
|
|
459
|
-
processedContent = codeBlockMatch[1].trim();
|
|
460
|
-
}
|
|
461
|
-
const jsonMatch = processedContent.match(/\{[\s\S]*\}/);
|
|
462
|
-
if (!jsonMatch) {
|
|
463
|
-
return { success: false, error: "No valid JSON found in AI response" };
|
|
464
|
-
}
|
|
465
|
-
const parsed = JSON.parse(jsonMatch[0]);
|
|
466
|
-
return { success: true, plan: parsed };
|
|
467
|
-
} catch (err) {
|
|
468
|
-
return {
|
|
469
|
-
success: false,
|
|
470
|
-
error: `Failed to parse AI response: ${err instanceof Error ? err.message : String(err)}`
|
|
471
|
-
};
|
|
630
|
+
const parsed = parseAIJson(content);
|
|
631
|
+
if (!parsed) {
|
|
632
|
+
return { success: false, error: "No valid JSON found in AI response" };
|
|
472
633
|
}
|
|
634
|
+
if (!Array.isArray(parsed.value.steps) || parsed.value.steps.length === 0) {
|
|
635
|
+
return { success: false, error: "AI response has no steps" };
|
|
636
|
+
}
|
|
637
|
+
return { success: true, plan: { ...parsed.value, steps: parsed.value.steps } };
|
|
638
|
+
}
|
|
639
|
+
async function callAIForPlan(systemPrompt, userPrompt, tier = "free", opts = {}) {
|
|
640
|
+
let lastParseError = "No valid JSON found in AI response";
|
|
641
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
642
|
+
const aiResult = await callAI(systemPrompt, userPrompt, tier, opts);
|
|
643
|
+
if (!aiResult.success) return aiResult;
|
|
644
|
+
const parsed = parseAIPlanResponse(aiResult.content);
|
|
645
|
+
if (parsed.success) return { success: true, plan: parsed.plan, limits: aiResult.limits };
|
|
646
|
+
lastParseError = parsed.error;
|
|
647
|
+
console.error(`Plan parse failed (attempt ${attempt}/2):`, parsed.error);
|
|
648
|
+
}
|
|
649
|
+
return { success: false, error: lastParseError, isParseError: true };
|
|
473
650
|
}
|
|
474
651
|
function normalizeUrl(input) {
|
|
475
652
|
let url = input.trim();
|
|
@@ -480,6 +657,7 @@ var PLAN_MAX_TOKENS, SERVER_BACKOFF_MS, CLI_BACKOFF_MS, SPARK_AI_BASE, SPARK_AI_
|
|
|
480
657
|
var init_api_client = __esm({
|
|
481
658
|
"src/lib/api-client.ts"() {
|
|
482
659
|
"use strict";
|
|
660
|
+
init_json_repair();
|
|
483
661
|
init_utils();
|
|
484
662
|
PLAN_MAX_TOKENS = 1500;
|
|
485
663
|
SERVER_BACKOFF_MS = [2e3, 4e3];
|
|
@@ -572,9 +750,6 @@ var init_SparkStripe_feature_gating = __esm({
|
|
|
572
750
|
});
|
|
573
751
|
|
|
574
752
|
// src/lib/billing.ts
|
|
575
|
-
function shouldWatermark(tier, videoDurationSeconds) {
|
|
576
|
-
return tier === "free" && videoDurationSeconds > 20;
|
|
577
|
-
}
|
|
578
753
|
function getUpgradeUrl(userEmail) {
|
|
579
754
|
return pricingUrl(userEmail ? { email: userEmail } : void 0);
|
|
580
755
|
}
|
|
@@ -609,7 +784,10 @@ function applyProfile(config, profile) {
|
|
|
609
784
|
format: "mp3",
|
|
610
785
|
bitrate: "192k",
|
|
611
786
|
...config.audio,
|
|
612
|
-
|
|
787
|
+
// outroOnly is on unless turned off, matching the video phase. The
|
|
788
|
+
// speedup buys the song its window, so it applies to the profile's own
|
|
789
|
+
// pace only: a speed somebody set by hand is the speed they wanted.
|
|
790
|
+
speed: config.audio?.speed ?? preset.audioSpeed * (config.music?.outroOnly !== false ? OUTRO_SONG_SPEEDUP : 1),
|
|
613
791
|
...config.audio?.voice || !preset.voice ? {} : { voice: preset.voice }
|
|
614
792
|
}
|
|
615
793
|
};
|
|
@@ -646,11 +824,11 @@ function defaultCaptionsFor(width, height) {
|
|
|
646
824
|
maxChars: 20
|
|
647
825
|
};
|
|
648
826
|
}
|
|
649
|
-
var VERSION, CONFIG_FILE, SIZE_PRESETS, RATE_FACTOR, NO_CLIP_OUTRO, DEFAULT_CONFIG, VIDEO_PROFILES, DEFAULT_CAPTIONS;
|
|
827
|
+
var VERSION, CONFIG_FILE, SIZE_PRESETS, RATE_FACTOR, NO_CLIP_OUTRO, DEFAULT_CONFIG, VIDEO_PROFILES, OUTRO_SONG_SPEEDUP, DEFAULT_CAPTIONS;
|
|
650
828
|
var init_config = __esm({
|
|
651
829
|
"src/lib/config.ts"() {
|
|
652
830
|
"use strict";
|
|
653
|
-
VERSION = "1.2.
|
|
831
|
+
VERSION = "1.2.6";
|
|
654
832
|
CONFIG_FILE = "quickpeek.config.json";
|
|
655
833
|
SIZE_PRESETS = {
|
|
656
834
|
wide: { width: 1920, height: 1080, zoom: 1 },
|
|
@@ -677,11 +855,12 @@ var init_config = __esm({
|
|
|
677
855
|
// ms
|
|
678
856
|
transitions: "none",
|
|
679
857
|
contrast: 0.25,
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
tempo
|
|
858
|
+
// The recording plays at the pace it was filmed at. A tempo above 1 is
|
|
859
|
+
// multiplied by the narration speed in VIDEO_PROFILES and by the fit
|
|
860
|
+
// pass's squeeze, and the three together read as frantic rather than
|
|
861
|
+
// brisk. Speed belongs in one place, so it lives in audioSpeed; set a
|
|
862
|
+
// tempo in quickpeek.config.json to push the picture on top of that.
|
|
863
|
+
tempo: 1,
|
|
685
864
|
stepDelay: 200
|
|
686
865
|
// ms delay between steps
|
|
687
866
|
},
|
|
@@ -695,18 +874,24 @@ var init_config = __esm({
|
|
|
695
874
|
// these bitrates - the encode is pure wall-clock tax on every run.
|
|
696
875
|
wide: {
|
|
697
876
|
video: { width: 1920, height: 1080, zoom: 1.5, fitLimitSecs: 0, maxSpeedup: 1, preset: "veryfast" },
|
|
698
|
-
|
|
877
|
+
// A tutorial is watched to learn something, so the narrator is allowed to
|
|
878
|
+
// be brisk but never hurried.
|
|
879
|
+
audioSpeed: 1.15
|
|
699
880
|
},
|
|
700
881
|
short: {
|
|
701
882
|
// fadeOut 0: the video ends on a card the viewer is meant to read,
|
|
702
883
|
// and a fade takes it away mid-sentence.
|
|
703
884
|
video: { width: 1080, height: 1920, zoom: 3, fitLimitSecs: 60, maxSpeedup: 1.7, preset: "veryfast", fadeOut: 0 },
|
|
704
|
-
|
|
885
|
+
// 1.8 was unwatchable: stacked on tempo 1.2 and the outro speedup it ran
|
|
886
|
+
// the voice at better than twice its natural rate. A short wants energy,
|
|
887
|
+
// which is the voice and the cutting, not the tape running fast.
|
|
888
|
+
audioSpeed: 1,
|
|
705
889
|
// A short lives or dies on energy. Ava is the warmest, most animated of
|
|
706
890
|
// the free neural voices; Sonia reads a short like the shipping forecast.
|
|
707
891
|
voice: "en-US-AvaMultilingualNeural"
|
|
708
892
|
}
|
|
709
893
|
};
|
|
894
|
+
OUTRO_SONG_SPEEDUP = 1.13;
|
|
710
895
|
DEFAULT_CAPTIONS = {
|
|
711
896
|
font: "Arial",
|
|
712
897
|
outline: "#000000",
|
|
@@ -835,7 +1020,6 @@ var init_scraping = __esm({
|
|
|
835
1020
|
});
|
|
836
1021
|
|
|
837
1022
|
// src/lib/crawl.ts
|
|
838
|
-
import { chromium as chromium4 } from "playwright";
|
|
839
1023
|
async function waitForDomStable(page, cap = SETTLE_CAP_MS) {
|
|
840
1024
|
const deadline = Date.now() + cap;
|
|
841
1025
|
let previous = -1;
|
|
@@ -856,6 +1040,7 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
|
|
|
856
1040
|
const deadline = Date.now() + cap;
|
|
857
1041
|
let previous = -1;
|
|
858
1042
|
let unchanged = 0;
|
|
1043
|
+
let clear = 0;
|
|
859
1044
|
while (Date.now() < deadline) {
|
|
860
1045
|
const count = await page.evaluate((selector) => {
|
|
861
1046
|
let visible = 0;
|
|
@@ -865,7 +1050,12 @@ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
|
|
|
865
1050
|
}
|
|
866
1051
|
return visible;
|
|
867
1052
|
}, PLACEHOLDER_SELECTOR).catch(() => 0);
|
|
868
|
-
if (count === 0)
|
|
1053
|
+
if (count === 0) {
|
|
1054
|
+
if (++clear >= PLACEHOLDER_CLEAR_POLLS) return;
|
|
1055
|
+
await page.waitForTimeout(PLACEHOLDER_POLL_MS);
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
clear = 0;
|
|
869
1059
|
if (count === previous) {
|
|
870
1060
|
if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
|
|
871
1061
|
} else {
|
|
@@ -949,7 +1139,13 @@ async function crawlPage(url, task2 = noopTask, storageStatePath, viewport) {
|
|
|
949
1139
|
return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
|
|
950
1140
|
}
|
|
951
1141
|
const browserTask = task2("Launching browser");
|
|
952
|
-
|
|
1142
|
+
let browser;
|
|
1143
|
+
try {
|
|
1144
|
+
browser = await launchHeadlessChromium();
|
|
1145
|
+
} catch (err) {
|
|
1146
|
+
browserTask.succeed("Could not launch a browser");
|
|
1147
|
+
throw err;
|
|
1148
|
+
}
|
|
953
1149
|
try {
|
|
954
1150
|
const context = await browser.newContext({
|
|
955
1151
|
...storageStatePath ? { storageState: storageStatePath } : {},
|
|
@@ -1125,10 +1321,11 @@ async function crawlPage(url, task2 = noopTask, storageStatePath, viewport) {
|
|
|
1125
1321
|
});
|
|
1126
1322
|
}
|
|
1127
1323
|
}
|
|
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;
|
|
1324
|
+
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
1325
|
var init_crawl = __esm({
|
|
1130
1326
|
"src/lib/crawl.ts"() {
|
|
1131
1327
|
"use strict";
|
|
1328
|
+
init_headless();
|
|
1132
1329
|
init_scraping();
|
|
1133
1330
|
SETTLE_POLL_MS = 400;
|
|
1134
1331
|
SETTLE_STABLE_POLLS = 2;
|
|
@@ -1136,6 +1333,7 @@ var init_crawl = __esm({
|
|
|
1136
1333
|
PLACEHOLDER_POLL_MS = 250;
|
|
1137
1334
|
PLACEHOLDER_CAP_MS = 8e3;
|
|
1138
1335
|
PLACEHOLDER_STATIC_POLLS = 4;
|
|
1336
|
+
PLACEHOLDER_CLEAR_POLLS = 3;
|
|
1139
1337
|
PLACEHOLDER_SELECTOR = [
|
|
1140
1338
|
'[aria-busy="true"]',
|
|
1141
1339
|
'[role="progressbar"]',
|
|
@@ -1287,13 +1485,13 @@ function canEncode(encoder) {
|
|
|
1287
1485
|
]);
|
|
1288
1486
|
}
|
|
1289
1487
|
function run(args) {
|
|
1290
|
-
return new Promise((
|
|
1488
|
+
return new Promise((resolve13) => {
|
|
1291
1489
|
let settled = false;
|
|
1292
1490
|
const done = (ok) => {
|
|
1293
1491
|
if (settled) return;
|
|
1294
1492
|
settled = true;
|
|
1295
1493
|
clearTimeout(timer);
|
|
1296
|
-
|
|
1494
|
+
resolve13(ok);
|
|
1297
1495
|
};
|
|
1298
1496
|
const proc = trackChild(spawn3("ffmpeg", args, { stdio: "ignore" }));
|
|
1299
1497
|
const timer = setTimeout(() => {
|
|
@@ -1350,8 +1548,13 @@ async function composeVideo(opts) {
|
|
|
1350
1548
|
musicPath,
|
|
1351
1549
|
musicVolume = 0.3,
|
|
1352
1550
|
musicTargetLufs = MUSIC_BED_LUFS,
|
|
1353
|
-
|
|
1354
|
-
|
|
1551
|
+
// Sized for the outro window: the song has 5.5s, so a 2s fade at each end
|
|
1552
|
+
// left 1.5s of music anyone could actually hear. Up fast, hold, out slow.
|
|
1553
|
+
musicFadeIn = 1,
|
|
1554
|
+
musicFadeOut = 1.5,
|
|
1555
|
+
tempo = 1,
|
|
1556
|
+
musicStartSecs = 0,
|
|
1557
|
+
musicDurationSecs = 0,
|
|
1355
1558
|
width,
|
|
1356
1559
|
height,
|
|
1357
1560
|
fps,
|
|
@@ -1412,7 +1615,19 @@ async function composeVideo(opts) {
|
|
|
1412
1615
|
const clipAudioLabel = opts.hasClipAudio ? "[0:a]" : "";
|
|
1413
1616
|
const hasVoiceover = !!voiceoverPath;
|
|
1414
1617
|
const hasMusic = !!musicPath;
|
|
1415
|
-
const
|
|
1618
|
+
const bedStart = Math.max(0, Math.min(musicStartSecs, Math.max(0, effectiveDuration - 0.5)));
|
|
1619
|
+
const bedLength = Math.max(0.5, effectiveDuration - bedStart);
|
|
1620
|
+
const bedDelay = bedStart > 0 ? `adelay=${Math.round(bedStart * 1e3)}|${Math.round(bedStart * 1e3)},` : "";
|
|
1621
|
+
const bedTempo = tempo > 1 ? 1 / tempo : 1;
|
|
1622
|
+
const bedSlow = bedTempo === 1 ? "" : `atempo=${bedTempo.toFixed(6)},`;
|
|
1623
|
+
const bedSource = bedLength * bedTempo;
|
|
1624
|
+
const bedSeek = musicDurationSecs > bedSource + 0.05 ? musicDurationSecs - bedSource : 0;
|
|
1625
|
+
const bedTrimIn = bedSeek > 0 ? `atrim=start=${bedSeek.toFixed(3)},asetpts=PTS-STARTPTS,` : "";
|
|
1626
|
+
const bedFadeOut = bedSeek > 0 ? 0 : musicFadeOut * tempo;
|
|
1627
|
+
const bedLoop = bedSeek > 0 ? "" : "aloop=loop=-1:size=2e+09,";
|
|
1628
|
+
const bedFadeIn = musicFadeIn * tempo;
|
|
1629
|
+
const musicFadeOutStart = Math.max(0, bedLength - bedFadeOut);
|
|
1630
|
+
const bedFadeOutFilter = bedFadeOut > 0 ? `afade=t=out:st=${musicFadeOutStart}:d=${bedFadeOut},` : "";
|
|
1416
1631
|
const vfStr = [...vf, ...encoder.filter ? [encoder.filter] : []].join(",");
|
|
1417
1632
|
if (hasVoiceover && hasMusic) {
|
|
1418
1633
|
const mixInputs = clipAudioLabel ? 3 : 2;
|
|
@@ -1428,7 +1643,7 @@ async function composeVideo(opts) {
|
|
|
1428
1643
|
// card, which is the one place the bed is the only thing playing.
|
|
1429
1644
|
`[voicekey0]apad=whole_dur=${effectiveDuration}[voicekey]`,
|
|
1430
1645
|
// Music: normalised to a known level, trimmed, then faded.
|
|
1431
|
-
`[2:a]
|
|
1646
|
+
`[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
1647
|
`[bed][voicekey]${DUCK}[music]`,
|
|
1433
1648
|
// normalize=0, because amix's default divides every input by the number
|
|
1434
1649
|
// of them: adding the bed used to quietly take 6 dB off the narration,
|
|
@@ -1452,7 +1667,7 @@ async function composeVideo(opts) {
|
|
|
1452
1667
|
args.push(...encoder.output);
|
|
1453
1668
|
args.push("-c:a", "aac", "-b:a", audioBitrate);
|
|
1454
1669
|
} else if (hasMusic) {
|
|
1455
|
-
const musicFilter =
|
|
1670
|
+
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
1671
|
args.push("-vf", vfStr);
|
|
1457
1672
|
args.push("-map", "0:v", "-map", "1:a", "-af", musicFilter);
|
|
1458
1673
|
args.push(...encoder.output);
|
|
@@ -1584,7 +1799,7 @@ function cacheKey(filePath) {
|
|
|
1584
1799
|
}
|
|
1585
1800
|
}
|
|
1586
1801
|
function getVideoDimensions(filePath) {
|
|
1587
|
-
return new Promise((
|
|
1802
|
+
return new Promise((resolve13, reject) => {
|
|
1588
1803
|
const args = [
|
|
1589
1804
|
"-v",
|
|
1590
1805
|
"error",
|
|
@@ -1615,12 +1830,12 @@ function getVideoDimensions(filePath) {
|
|
|
1615
1830
|
reject(new Error(`ffprobe returned no dimensions for ${filePath}`));
|
|
1616
1831
|
return;
|
|
1617
1832
|
}
|
|
1618
|
-
|
|
1833
|
+
resolve13({ width: w, height: h });
|
|
1619
1834
|
});
|
|
1620
1835
|
});
|
|
1621
1836
|
}
|
|
1622
1837
|
function hasAudioStream(filePath) {
|
|
1623
|
-
return new Promise((
|
|
1838
|
+
return new Promise((resolve13) => {
|
|
1624
1839
|
const proc = spawnFFmpeg("ffprobe", [
|
|
1625
1840
|
"-v",
|
|
1626
1841
|
"error",
|
|
@@ -1636,15 +1851,15 @@ function hasAudioStream(filePath) {
|
|
|
1636
1851
|
proc.stdout.on("data", (d) => {
|
|
1637
1852
|
out += d;
|
|
1638
1853
|
});
|
|
1639
|
-
proc.on("close", () =>
|
|
1640
|
-
proc.on("error", () =>
|
|
1854
|
+
proc.on("close", () => resolve13(out.trim().length > 0));
|
|
1855
|
+
proc.on("error", () => resolve13(false));
|
|
1641
1856
|
});
|
|
1642
1857
|
}
|
|
1643
1858
|
function getMediaDuration(filePath) {
|
|
1644
1859
|
const key = cacheKey(filePath);
|
|
1645
1860
|
const cached = key === null ? void 0 : durationCache.get(key);
|
|
1646
1861
|
if (cached !== void 0) return Promise.resolve(cached);
|
|
1647
|
-
return new Promise((
|
|
1862
|
+
return new Promise((resolve13, reject) => {
|
|
1648
1863
|
const args = [
|
|
1649
1864
|
"-v",
|
|
1650
1865
|
"error",
|
|
@@ -1671,13 +1886,13 @@ function getMediaDuration(filePath) {
|
|
|
1671
1886
|
const ms = Math.ceil(parseFloat(stdout.trim()) * 1e3);
|
|
1672
1887
|
const freshKey = cacheKey(filePath);
|
|
1673
1888
|
if (freshKey !== null && Number.isFinite(ms)) durationCache.set(freshKey, ms);
|
|
1674
|
-
|
|
1889
|
+
resolve13(ms);
|
|
1675
1890
|
});
|
|
1676
1891
|
proc.on("error", reject);
|
|
1677
1892
|
});
|
|
1678
1893
|
}
|
|
1679
1894
|
function getAudioCodec(filePath) {
|
|
1680
|
-
return new Promise((
|
|
1895
|
+
return new Promise((resolve13) => {
|
|
1681
1896
|
const args = [
|
|
1682
1897
|
"-v",
|
|
1683
1898
|
"error",
|
|
@@ -1694,8 +1909,8 @@ function getAudioCodec(filePath) {
|
|
|
1694
1909
|
proc.stdout.on("data", (data) => {
|
|
1695
1910
|
stdout += data;
|
|
1696
1911
|
});
|
|
1697
|
-
proc.on("close", (code) =>
|
|
1698
|
-
proc.on("error", () =>
|
|
1912
|
+
proc.on("close", (code) => resolve13(code === 0 ? stdout.trim() : ""));
|
|
1913
|
+
proc.on("error", () => resolve13(""));
|
|
1699
1914
|
});
|
|
1700
1915
|
}
|
|
1701
1916
|
async function isHonestWav(filePath) {
|
|
@@ -1849,7 +2064,7 @@ var init_media = __esm({
|
|
|
1849
2064
|
|
|
1850
2065
|
// src/lib/ffmpeg/silence.ts
|
|
1851
2066
|
function getVideoStreamDuration(inputPath) {
|
|
1852
|
-
return new Promise((
|
|
2067
|
+
return new Promise((resolve13) => {
|
|
1853
2068
|
const proc = spawnFFmpeg("ffprobe", [
|
|
1854
2069
|
"-v",
|
|
1855
2070
|
"error",
|
|
@@ -1866,12 +2081,12 @@ function getVideoStreamDuration(inputPath) {
|
|
|
1866
2081
|
proc.stdout.on("data", (d) => {
|
|
1867
2082
|
out += d;
|
|
1868
2083
|
});
|
|
1869
|
-
proc.on("error", () =>
|
|
1870
|
-
proc.on("close", () =>
|
|
2084
|
+
proc.on("error", () => resolve13(0));
|
|
2085
|
+
proc.on("close", () => resolve13(Number.parseFloat(out.trim()) || 0));
|
|
1871
2086
|
});
|
|
1872
2087
|
}
|
|
1873
2088
|
function detectSilences(inputPath, minDurSecs) {
|
|
1874
|
-
return new Promise((
|
|
2089
|
+
return new Promise((resolve13) => {
|
|
1875
2090
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
1876
2091
|
"-hide_banner",
|
|
1877
2092
|
"-nostats",
|
|
@@ -1890,7 +2105,7 @@ function detectSilences(inputPath, minDurSecs) {
|
|
|
1890
2105
|
proc.stderr.on("data", (d) => {
|
|
1891
2106
|
err += d;
|
|
1892
2107
|
});
|
|
1893
|
-
proc.on("error", () =>
|
|
2108
|
+
proc.on("error", () => resolve13([]));
|
|
1894
2109
|
proc.on("close", () => {
|
|
1895
2110
|
const spans = [];
|
|
1896
2111
|
let start = null;
|
|
@@ -1905,12 +2120,12 @@ function detectSilences(inputPath, minDurSecs) {
|
|
|
1905
2120
|
start = null;
|
|
1906
2121
|
}
|
|
1907
2122
|
}
|
|
1908
|
-
|
|
2123
|
+
resolve13(spans);
|
|
1909
2124
|
});
|
|
1910
2125
|
});
|
|
1911
2126
|
}
|
|
1912
2127
|
function detectFrozenSpans(inputPath, minDurSecs) {
|
|
1913
|
-
return new Promise((
|
|
2128
|
+
return new Promise((resolve13) => {
|
|
1914
2129
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
1915
2130
|
"-hide_banner",
|
|
1916
2131
|
"-nostats",
|
|
@@ -1931,7 +2146,7 @@ function detectFrozenSpans(inputPath, minDurSecs) {
|
|
|
1931
2146
|
proc.stderr.on("data", (d) => {
|
|
1932
2147
|
err += d;
|
|
1933
2148
|
});
|
|
1934
|
-
proc.on("error", () =>
|
|
2149
|
+
proc.on("error", () => resolve13([]));
|
|
1935
2150
|
proc.on("close", () => {
|
|
1936
2151
|
const spans = [];
|
|
1937
2152
|
let start = null;
|
|
@@ -1944,12 +2159,12 @@ function detectFrozenSpans(inputPath, minDurSecs) {
|
|
|
1944
2159
|
start = null;
|
|
1945
2160
|
}
|
|
1946
2161
|
}
|
|
1947
|
-
|
|
2162
|
+
resolve13(spans);
|
|
1948
2163
|
});
|
|
1949
2164
|
});
|
|
1950
2165
|
}
|
|
1951
2166
|
function detectBlackSpans(inputPath, minDurSecs) {
|
|
1952
|
-
return new Promise((
|
|
2167
|
+
return new Promise((resolve13) => {
|
|
1953
2168
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
1954
2169
|
"-hide_banner",
|
|
1955
2170
|
"-nostats",
|
|
@@ -1972,19 +2187,19 @@ function detectBlackSpans(inputPath, minDurSecs) {
|
|
|
1972
2187
|
proc.stderr.on("data", (d) => {
|
|
1973
2188
|
err += d;
|
|
1974
2189
|
});
|
|
1975
|
-
proc.on("error", () =>
|
|
2190
|
+
proc.on("error", () => resolve13([]));
|
|
1976
2191
|
proc.on("close", () => {
|
|
1977
2192
|
const spans = [];
|
|
1978
2193
|
for (const line of err.split("\n")) {
|
|
1979
2194
|
const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
|
|
1980
2195
|
if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
|
|
1981
2196
|
}
|
|
1982
|
-
|
|
2197
|
+
resolve13(spans);
|
|
1983
2198
|
});
|
|
1984
2199
|
});
|
|
1985
2200
|
}
|
|
1986
2201
|
function detectWhiteSpans(inputPath, minDurSecs) {
|
|
1987
|
-
return new Promise((
|
|
2202
|
+
return new Promise((resolve13) => {
|
|
1988
2203
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
1989
2204
|
"-hide_banner",
|
|
1990
2205
|
"-nostats",
|
|
@@ -2002,14 +2217,14 @@ function detectWhiteSpans(inputPath, minDurSecs) {
|
|
|
2002
2217
|
proc.stderr.on("data", (d) => {
|
|
2003
2218
|
err += d;
|
|
2004
2219
|
});
|
|
2005
|
-
proc.on("error", () =>
|
|
2220
|
+
proc.on("error", () => resolve13([]));
|
|
2006
2221
|
proc.on("close", () => {
|
|
2007
2222
|
const spans = [];
|
|
2008
2223
|
for (const line of err.split("\n")) {
|
|
2009
2224
|
const m = line.match(/black_start:\s*([\d.]+)\s+black_end:\s*([\d.]+)/);
|
|
2010
2225
|
if (m?.[1] && m[2]) spans.push({ start: Number.parseFloat(m[1]), end: Number.parseFloat(m[2]) });
|
|
2011
2226
|
}
|
|
2012
|
-
|
|
2227
|
+
resolve13(spans);
|
|
2013
2228
|
});
|
|
2014
2229
|
});
|
|
2015
2230
|
}
|
|
@@ -2099,7 +2314,7 @@ async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
|
|
|
2099
2314
|
const drop = keep.map((s) => `between(t,${s.start.toFixed(3)},${s.end.toFixed(3)})`).join("+");
|
|
2100
2315
|
const filter = `[0:v]select='not(${drop})',setpts=N/FRAME_RATE/TB[outv]`;
|
|
2101
2316
|
const enc = await passEncoder(preset);
|
|
2102
|
-
return new Promise((
|
|
2317
|
+
return new Promise((resolve13) => {
|
|
2103
2318
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
2104
2319
|
"-y",
|
|
2105
2320
|
...filterThreadArgs(),
|
|
@@ -2117,8 +2332,8 @@ async function cutSpansFromVideo(inputPath, outputPath, spans, preset) {
|
|
|
2117
2332
|
outputPath
|
|
2118
2333
|
]);
|
|
2119
2334
|
proc.stderr.resume();
|
|
2120
|
-
proc.on("error", () =>
|
|
2121
|
-
proc.on("close", (code) =>
|
|
2335
|
+
proc.on("error", () => resolve13(false));
|
|
2336
|
+
proc.on("close", (code) => resolve13(code === 0));
|
|
2122
2337
|
});
|
|
2123
2338
|
}
|
|
2124
2339
|
function buildRetimeFilter(spans, stepBounds, speed, fps) {
|
|
@@ -2200,7 +2415,7 @@ async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, spee
|
|
|
2200
2415
|
const filter = buildRetimeFilter(spans, stepBounds, speed, fps);
|
|
2201
2416
|
if (!filter) return false;
|
|
2202
2417
|
const enc = await passEncoder(preset);
|
|
2203
|
-
return new Promise((
|
|
2418
|
+
return new Promise((resolve13) => {
|
|
2204
2419
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
2205
2420
|
"-y",
|
|
2206
2421
|
...filterThreadArgs(),
|
|
@@ -2218,8 +2433,8 @@ async function retimeSpansInVideo(inputPath, outputPath, spans, stepBounds, spee
|
|
|
2218
2433
|
outputPath
|
|
2219
2434
|
]);
|
|
2220
2435
|
proc.stderr.resume();
|
|
2221
|
-
proc.on("error", () =>
|
|
2222
|
-
proc.on("close", (code) =>
|
|
2436
|
+
proc.on("error", () => resolve13(false));
|
|
2437
|
+
proc.on("close", (code) => resolve13(code === 0));
|
|
2223
2438
|
});
|
|
2224
2439
|
}
|
|
2225
2440
|
async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, preset, protectAfterSecs, narrationPath, protectBeforeSecs, leadSpans, keepSpans) {
|
|
@@ -2245,7 +2460,7 @@ async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, pr
|
|
|
2245
2460
|
const filter = buildCompressionFilter(spans, totalSecs, maxSecs, protectAfterSecs, protectBeforeSecs);
|
|
2246
2461
|
if (!filter) return false;
|
|
2247
2462
|
const enc = await passEncoder(preset);
|
|
2248
|
-
return new Promise((
|
|
2463
|
+
return new Promise((resolve13) => {
|
|
2249
2464
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
2250
2465
|
"-y",
|
|
2251
2466
|
...filterThreadArgs(),
|
|
@@ -2271,8 +2486,8 @@ async function compressSilentSpans(inputPath, outputPath, totalSecs, maxSecs, pr
|
|
|
2271
2486
|
outputPath
|
|
2272
2487
|
]);
|
|
2273
2488
|
proc.stderr.resume();
|
|
2274
|
-
proc.on("error", () =>
|
|
2275
|
-
proc.on("close", (code) =>
|
|
2489
|
+
proc.on("error", () => resolve13(false));
|
|
2490
|
+
proc.on("close", (code) => resolve13(code === 0));
|
|
2276
2491
|
});
|
|
2277
2492
|
}
|
|
2278
2493
|
async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, introSecs, outroSecs, maxFactor, preset) {
|
|
@@ -2296,7 +2511,7 @@ async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, intro
|
|
|
2296
2511
|
"[v0][a0][v1][a1][v2][a2]concat=n=3:v=1:a=1[outv][outa]"
|
|
2297
2512
|
].join(";");
|
|
2298
2513
|
const enc = await passEncoder(preset);
|
|
2299
|
-
const ok = await new Promise((
|
|
2514
|
+
const ok = await new Promise((resolve13) => {
|
|
2300
2515
|
const proc = spawnFFmpeg("ffmpeg", [
|
|
2301
2516
|
"-y",
|
|
2302
2517
|
...filterThreadArgs(),
|
|
@@ -2317,8 +2532,8 @@ async function speedUpMiddle(inputPath, outputPath, totalSecs, targetSecs, intro
|
|
|
2317
2532
|
outputPath
|
|
2318
2533
|
]);
|
|
2319
2534
|
proc.stderr.resume();
|
|
2320
|
-
proc.on("error", () =>
|
|
2321
|
-
proc.on("close", (code) =>
|
|
2535
|
+
proc.on("error", () => resolve13(false));
|
|
2536
|
+
proc.on("close", (code) => resolve13(code === 0));
|
|
2322
2537
|
});
|
|
2323
2538
|
return { ok, factor, reachedTarget: needed <= maxFactor };
|
|
2324
2539
|
}
|
|
@@ -2354,7 +2569,7 @@ async function spliceVideos(opts) {
|
|
|
2354
2569
|
const audioPresent = await Promise.all(
|
|
2355
2570
|
overlays.map((o) => o.still ? Promise.resolve(false) : hasAudioStream(o.path))
|
|
2356
2571
|
);
|
|
2357
|
-
return new Promise((
|
|
2572
|
+
return new Promise((resolve13, reject) => {
|
|
2358
2573
|
const args = ["-y", "-i", inputPath];
|
|
2359
2574
|
for (const overlay of overlays) {
|
|
2360
2575
|
if (overlay.still) {
|
|
@@ -2417,7 +2632,7 @@ async function spliceVideos(opts) {
|
|
|
2417
2632
|
args.push("-c:v", "libx264", "-preset", preset, "-crf", "18", "-pix_fmt", "yuv420p");
|
|
2418
2633
|
if (clipAudio.length > 0) args.push("-c:a", "aac");
|
|
2419
2634
|
args.push(outputPath);
|
|
2420
|
-
runFFmpeg(args, "ffmpeg splice failed").then(
|
|
2635
|
+
runFFmpeg(args, "ffmpeg splice failed").then(resolve13, reject);
|
|
2421
2636
|
});
|
|
2422
2637
|
}
|
|
2423
2638
|
var CLIP_AUDIO_GAIN, STILL_FRAMERATE, COVER_WASH_W, COVER_WASH_H, COVER_BLUR_SIGMA;
|
|
@@ -2453,10 +2668,10 @@ function buildStepTransitions(steps, stepDurations, defaultTransition) {
|
|
|
2453
2668
|
return transitions;
|
|
2454
2669
|
}
|
|
2455
2670
|
function applyTransitions(opts) {
|
|
2456
|
-
return new Promise((
|
|
2671
|
+
return new Promise((resolve13, reject) => {
|
|
2457
2672
|
const { inputVideo, outputVideo, transitions, videoDuration, preset = "fast" } = opts;
|
|
2458
2673
|
if (transitions.length === 0) {
|
|
2459
|
-
runFFmpeg(["-y", "-i", inputVideo, "-c", "copy", outputVideo], "FFmpeg copy failed").then(
|
|
2674
|
+
runFFmpeg(["-y", "-i", inputVideo, "-c", "copy", outputVideo], "FFmpeg copy failed").then(resolve13, reject);
|
|
2460
2675
|
return;
|
|
2461
2676
|
}
|
|
2462
2677
|
const sorted = [...transitions].sort((a, b) => a.timestamp - b.timestamp);
|
|
@@ -2504,7 +2719,7 @@ function applyTransitions(opts) {
|
|
|
2504
2719
|
"yuv420p",
|
|
2505
2720
|
outputVideo
|
|
2506
2721
|
];
|
|
2507
|
-
runFFmpeg(args, "FFmpeg transitions failed").then(
|
|
2722
|
+
runFFmpeg(args, "FFmpeg transitions failed").then(resolve13, reject);
|
|
2508
2723
|
});
|
|
2509
2724
|
}
|
|
2510
2725
|
var init_transitions = __esm({
|
|
@@ -2544,114 +2759,6 @@ var init_ffmpeg2 = __esm({
|
|
|
2544
2759
|
}
|
|
2545
2760
|
});
|
|
2546
2761
|
|
|
2547
|
-
// src/lib/json-repair.ts
|
|
2548
|
-
function repairUnicodeEscapes(content) {
|
|
2549
|
-
return content.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (match, hex) => {
|
|
2550
|
-
const code = Number.parseInt(hex, 16);
|
|
2551
|
-
return code <= 1114111 ? String.fromCodePoint(code) : match;
|
|
2552
|
-
}).replace(/\\u(?![0-9a-fA-F]{4})/g, "\\\\u");
|
|
2553
|
-
}
|
|
2554
|
-
function salvageTruncatedJson(content) {
|
|
2555
|
-
const stack = [];
|
|
2556
|
-
const cuts = [];
|
|
2557
|
-
let inString = false;
|
|
2558
|
-
let escaped = false;
|
|
2559
|
-
const record = (index, closesContainer) => cuts.push({ index, open: stack.join(""), closesContainer });
|
|
2560
|
-
for (let i = 0; i < content.length; i++) {
|
|
2561
|
-
const ch = content[i];
|
|
2562
|
-
if (inString) {
|
|
2563
|
-
if (escaped) escaped = false;
|
|
2564
|
-
else if (ch === "\\") escaped = true;
|
|
2565
|
-
else if (ch === '"') {
|
|
2566
|
-
inString = false;
|
|
2567
|
-
record(i + 1, false);
|
|
2568
|
-
}
|
|
2569
|
-
continue;
|
|
2570
|
-
}
|
|
2571
|
-
if (ch === '"') {
|
|
2572
|
-
inString = true;
|
|
2573
|
-
} else if (ch === "{" || ch === "[") {
|
|
2574
|
-
stack.push(ch);
|
|
2575
|
-
} else if (ch === "}" || ch === "]") {
|
|
2576
|
-
stack.pop();
|
|
2577
|
-
record(i + 1, true);
|
|
2578
|
-
} else if (ch === ",") {
|
|
2579
|
-
record(i, content[i - 1] === "}" || content[i - 1] === "]");
|
|
2580
|
-
}
|
|
2581
|
-
}
|
|
2582
|
-
const tryCut = (cut) => {
|
|
2583
|
-
const head = content.slice(0, cut.index).replace(/[\s,]+$/, "");
|
|
2584
|
-
if (head.endsWith(":")) return null;
|
|
2585
|
-
let closed = head;
|
|
2586
|
-
for (let d = cut.open.length - 1; d >= 0; d--) {
|
|
2587
|
-
closed += CLOSERS[cut.open[d]];
|
|
2588
|
-
}
|
|
2589
|
-
try {
|
|
2590
|
-
JSON.parse(closed);
|
|
2591
|
-
return closed;
|
|
2592
|
-
} catch {
|
|
2593
|
-
return null;
|
|
2594
|
-
}
|
|
2595
|
-
};
|
|
2596
|
-
const attempts = Math.min(cuts.length, MAX_SALVAGE_ATTEMPTS);
|
|
2597
|
-
for (const containerOnly of [true, false]) {
|
|
2598
|
-
for (let n = 0; n < attempts; n++) {
|
|
2599
|
-
const cut = cuts[cuts.length - 1 - n];
|
|
2600
|
-
if (containerOnly && !cut.closesContainer) continue;
|
|
2601
|
-
const salvaged = tryCut(cut);
|
|
2602
|
-
if (salvaged !== null) return salvaged;
|
|
2603
|
-
}
|
|
2604
|
-
}
|
|
2605
|
-
return null;
|
|
2606
|
-
}
|
|
2607
|
-
function extractJsonCandidates(content) {
|
|
2608
|
-
let text = content.trim();
|
|
2609
|
-
const fence = text.match(/```(?:json)?\s*([\s\S]*?)(?:```|$)/);
|
|
2610
|
-
if (fence?.[1]?.trim()) text = fence[1].trim();
|
|
2611
|
-
const start = text.search(/[{[]/);
|
|
2612
|
-
if (start === -1) return [];
|
|
2613
|
-
const closer = text[start] === "{" ? "}" : "]";
|
|
2614
|
-
const end = text.lastIndexOf(closer);
|
|
2615
|
-
const candidates = [text.slice(start)];
|
|
2616
|
-
if (end > start && end < text.length - 1) candidates.unshift(text.slice(start, end + 1));
|
|
2617
|
-
return candidates;
|
|
2618
|
-
}
|
|
2619
|
-
function parseAIJson(content) {
|
|
2620
|
-
const candidates = extractJsonCandidates(content);
|
|
2621
|
-
if (candidates.length === 0) return null;
|
|
2622
|
-
for (const text of candidates) {
|
|
2623
|
-
try {
|
|
2624
|
-
return { value: JSON.parse(text), repaired: false };
|
|
2625
|
-
} catch {
|
|
2626
|
-
}
|
|
2627
|
-
}
|
|
2628
|
-
for (const text of candidates) {
|
|
2629
|
-
try {
|
|
2630
|
-
return { value: JSON.parse(repairUnicodeEscapes(text)), repaired: true };
|
|
2631
|
-
} catch {
|
|
2632
|
-
}
|
|
2633
|
-
}
|
|
2634
|
-
for (const text of candidates) {
|
|
2635
|
-
for (const variant of [repairUnicodeEscapes(text), text]) {
|
|
2636
|
-
const salvaged = salvageTruncatedJson(variant);
|
|
2637
|
-
if (salvaged === null) continue;
|
|
2638
|
-
try {
|
|
2639
|
-
return { value: JSON.parse(salvaged), repaired: true };
|
|
2640
|
-
} catch {
|
|
2641
|
-
}
|
|
2642
|
-
}
|
|
2643
|
-
}
|
|
2644
|
-
return null;
|
|
2645
|
-
}
|
|
2646
|
-
var MAX_SALVAGE_ATTEMPTS, CLOSERS;
|
|
2647
|
-
var init_json_repair = __esm({
|
|
2648
|
-
"src/lib/json-repair.ts"() {
|
|
2649
|
-
"use strict";
|
|
2650
|
-
MAX_SALVAGE_ATTEMPTS = 500;
|
|
2651
|
-
CLOSERS = { "{": "}", "[": "]" };
|
|
2652
|
-
}
|
|
2653
|
-
});
|
|
2654
|
-
|
|
2655
2762
|
// src/lib/prompts.ts
|
|
2656
2763
|
function buildSystemPrompt(maxSteps, profile) {
|
|
2657
2764
|
let scope;
|
|
@@ -2977,6 +3084,9 @@ function renderHormoziAss(ctx) {
|
|
|
2977
3084
|
return `${header2}${lines.join("\n")}
|
|
2978
3085
|
`;
|
|
2979
3086
|
}
|
|
3087
|
+
function looksLikeAddress(token) {
|
|
3088
|
+
return /^(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?[.,!?]?$/i.test(token);
|
|
3089
|
+
}
|
|
2980
3090
|
function renderShortsAss(ctx) {
|
|
2981
3091
|
const { captions, width, height } = ctx;
|
|
2982
3092
|
const font = resolveCaptionFont(captions.font === "Arial" ? "Arial Black" : captions.font);
|
|
@@ -2987,7 +3097,7 @@ function renderShortsAss(ctx) {
|
|
|
2987
3097
|
const alignment = positionToAlignment(captions.position);
|
|
2988
3098
|
const outline = Math.max(2, Math.round(fontSize / 22));
|
|
2989
3099
|
const upper = true;
|
|
2990
|
-
const caseOf = (t) => toCaptionCase(t, upper);
|
|
3100
|
+
const caseOf = (t) => looksLikeAddress(t) ? t : toCaptionCase(t, upper);
|
|
2991
3101
|
const maxChars = fittingMaxChars(width, fontSize, SIDE_MARGIN, captions.maxChars, upper);
|
|
2992
3102
|
const header2 = `[Script Info]
|
|
2993
3103
|
Title: QuickPeek Demo
|
|
@@ -3549,6 +3659,8 @@ asyncio.run(main())
|
|
|
3549
3659
|
await writeFile3(tempScriptPath, pythonScript);
|
|
3550
3660
|
const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
|
|
3551
3661
|
await execAsync(pythonCmd, { timeout: 3e4 });
|
|
3662
|
+
const written = await stat(outputPath).catch(() => null);
|
|
3663
|
+
if (!written?.size) throw new Error("edge-tts produced no audio");
|
|
3552
3664
|
return await readWordsSidecar(outputPath);
|
|
3553
3665
|
} finally {
|
|
3554
3666
|
await unlink4(tempScriptPath).catch(() => {
|
|
@@ -3736,11 +3848,17 @@ function spellAcronyms(text) {
|
|
|
3736
3848
|
);
|
|
3737
3849
|
}
|
|
3738
3850
|
function bindDomains(text) {
|
|
3739
|
-
return text.replace(/\b([a-z0-9][a-z0-9
|
|
3851
|
+
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(
|
|
3852
|
+
/\b([a-z0-9][a-z0-9-]*)\.([a-z]{2,24})\b/gi,
|
|
3853
|
+
(_m, host, tld) => `${host}-dot-${SPOKEN_TLD[tld.toLowerCase()] ?? tld}`
|
|
3854
|
+
);
|
|
3740
3855
|
}
|
|
3741
3856
|
function speakSymbols(text) {
|
|
3742
3857
|
return text.replace(/\s*&\s*/g, " and ").replace(/[<>]/g, " ").replace(/\s{2,}/g, " ").trim();
|
|
3743
3858
|
}
|
|
3859
|
+
function spokenForm(caption) {
|
|
3860
|
+
return speakNumbers(spellAcronyms(toSpokenForm(bindDomains(speakSymbols(caption)))));
|
|
3861
|
+
}
|
|
3744
3862
|
async function generateVoiceover(steps, config, workDir, outputDir, logError2, task2, info2) {
|
|
3745
3863
|
resetVoiceEngine();
|
|
3746
3864
|
const voice = resolveVoice2(config);
|
|
@@ -3787,7 +3905,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
|
|
|
3787
3905
|
}
|
|
3788
3906
|
const audioPath = join9(audioDir, `step-${stepNum}.mp3`);
|
|
3789
3907
|
try {
|
|
3790
|
-
const spokenText =
|
|
3908
|
+
const spokenText = spokenForm(step.caption);
|
|
3791
3909
|
const key = clipKey(spokenText, voice, ratePercent ?? "");
|
|
3792
3910
|
const cacheDir = join9(outputDir, ".tts-clips");
|
|
3793
3911
|
const cached = await reuseClip(cacheDir, key, audioPath);
|
|
@@ -3868,7 +3986,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
|
|
|
3868
3986
|
await saveVoiceoverMeta(outputDir, meta);
|
|
3869
3987
|
return { voiceoverPath, stepAudio };
|
|
3870
3988
|
}
|
|
3871
|
-
var TTS_POOL, ONES, TENS, SPELLED_OUT, LETTER_SOUNDS;
|
|
3989
|
+
var TTS_POOL, ONES, TENS, SPELLED_OUT, LETTER_SOUNDS, SPOKEN_TLD;
|
|
3872
3990
|
var init_generate = __esm({
|
|
3873
3991
|
"src/lib/tts/generate.ts"() {
|
|
3874
3992
|
"use strict";
|
|
@@ -3933,6 +4051,9 @@ var init_generate = __esm({
|
|
|
3933
4051
|
Y: "why",
|
|
3934
4052
|
Z: "zed"
|
|
3935
4053
|
};
|
|
4054
|
+
SPOKEN_TLD = {
|
|
4055
|
+
ai: "ay-eye"
|
|
4056
|
+
};
|
|
3936
4057
|
}
|
|
3937
4058
|
});
|
|
3938
4059
|
|
|
@@ -3992,6 +4113,7 @@ __export(lib_exports, {
|
|
|
3992
4113
|
NO_CLIP_OUTRO: () => NO_CLIP_OUTRO,
|
|
3993
4114
|
PLAN_MAX_TOKENS: () => PLAN_MAX_TOKENS,
|
|
3994
4115
|
RATE_FACTOR: () => RATE_FACTOR,
|
|
4116
|
+
RELAY_BASE: () => RELAY_BASE,
|
|
3995
4117
|
SERVER_BACKOFF_MS: () => SERVER_BACKOFF_MS,
|
|
3996
4118
|
SIZE_PRESETS: () => SIZE_PRESETS,
|
|
3997
4119
|
TRIM_EDGE_SILENCE: () => TRIM_EDGE_SILENCE,
|
|
@@ -4016,6 +4138,7 @@ __export(lib_exports, {
|
|
|
4016
4138
|
buildUserPrompt: () => buildUserPrompt,
|
|
4017
4139
|
buildWatermarkFilters: () => buildWatermarkFilters,
|
|
4018
4140
|
callAI: () => callAI,
|
|
4141
|
+
callAIForPlan: () => callAIForPlan,
|
|
4019
4142
|
callAIViaRelay: () => callAIViaRelay,
|
|
4020
4143
|
canReuseVoiceover: () => canReuseVoiceover,
|
|
4021
4144
|
captionCanvas: () => captionCanvas,
|
|
@@ -4043,6 +4166,7 @@ __export(lib_exports, {
|
|
|
4043
4166
|
getErrorMessage: () => getErrorMessage,
|
|
4044
4167
|
getLanguageName: () => getLanguageName,
|
|
4045
4168
|
getMediaDuration: () => getMediaDuration,
|
|
4169
|
+
getOrRefreshRelayToken: () => getOrRefreshRelayToken,
|
|
4046
4170
|
getStatus: () => getStatus,
|
|
4047
4171
|
getStatusCached: () => getStatusCached,
|
|
4048
4172
|
getTierByEmail: () => getTierByEmail,
|
|
@@ -4086,12 +4210,12 @@ __export(lib_exports, {
|
|
|
4086
4210
|
runPooled: () => runPooled,
|
|
4087
4211
|
salvageTruncatedJson: () => salvageTruncatedJson,
|
|
4088
4212
|
shouldSkipLink: () => shouldSkipLink,
|
|
4089
|
-
shouldWatermark: () => shouldWatermark,
|
|
4090
4213
|
showPaywall: () => showPaywall,
|
|
4091
4214
|
speakSymbols: () => speakSymbols,
|
|
4092
4215
|
speedUpMiddle: () => speedUpMiddle,
|
|
4093
4216
|
speedUpVideo: () => speedUpVideo,
|
|
4094
4217
|
spliceVideos: () => spliceVideos,
|
|
4218
|
+
spokenForm: () => spokenForm,
|
|
4095
4219
|
spokenWordTimings: () => spokenWordTimings,
|
|
4096
4220
|
stripLongDashes: () => stripLongDashes,
|
|
4097
4221
|
synthesizeSpeech: () => synthesizeSpeech,
|
|
@@ -4401,7 +4525,10 @@ async function loadCaptions() {
|
|
|
4401
4525
|
async function savePlan(plan, planPath) {
|
|
4402
4526
|
const cleanPlan = {
|
|
4403
4527
|
...plan,
|
|
4404
|
-
steps: plan.steps.map(({ duration, ...step }) =>
|
|
4528
|
+
steps: plan.steps.map(({ duration, ...step }) => ({
|
|
4529
|
+
...step,
|
|
4530
|
+
...typeof duration === "number" && duration > 0 ? { duration: Math.min(duration, 30) } : {}
|
|
4531
|
+
}))
|
|
4405
4532
|
};
|
|
4406
4533
|
await mkdir4(dirname2(planPath), { recursive: true });
|
|
4407
4534
|
await writeFile5(planPath, JSON.stringify(cleanPlan, null, 2));
|
|
@@ -4514,55 +4641,16 @@ var init_mcp_context = __esm({
|
|
|
4514
4641
|
}
|
|
4515
4642
|
});
|
|
4516
4643
|
|
|
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
4644
|
// 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
4645
|
function escapeHtml(text) {
|
|
4561
4646
|
return text.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
4562
4647
|
}
|
|
4563
4648
|
function buildOutroCardHtml(options) {
|
|
4564
4649
|
const { subject, credits = [], promo, width, height } = options;
|
|
4565
4650
|
const unit = Math.min(width, height);
|
|
4651
|
+
const markFraction = width > height ? 0.28 : 0.46;
|
|
4652
|
+
const markSize = unit * markFraction;
|
|
4653
|
+
const padTop = width > height ? unit * 0.08 : unit * 0.23;
|
|
4566
4654
|
const creditRows = credits.map((credit) => {
|
|
4567
4655
|
const icon = credit.iconPath ? dataUri(credit.iconPath) : null;
|
|
4568
4656
|
return `<div class="credit">
|
|
@@ -4571,7 +4659,8 @@ function buildOutroCardHtml(options) {
|
|
|
4571
4659
|
${icon ? `<img class="cicon" src="${icon}" alt="" />` : ""}
|
|
4572
4660
|
</div>`;
|
|
4573
4661
|
}).join("");
|
|
4574
|
-
const
|
|
4662
|
+
const animated = subject.animatedMark;
|
|
4663
|
+
const subjectIcon = animated ? null : subject.iconPath ? dataUri(subject.iconPath) : null;
|
|
4575
4664
|
return `<!doctype html>
|
|
4576
4665
|
<html><head><meta charset="utf-8" /><style>
|
|
4577
4666
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
@@ -4589,7 +4678,7 @@ function buildOutroCardHtml(options) {
|
|
|
4589
4678
|
position: relative;
|
|
4590
4679
|
display: flex; flex-direction: column;
|
|
4591
4680
|
align-items: center; justify-content: flex-start;
|
|
4592
|
-
padding-top: ${
|
|
4681
|
+
padding-top: ${padTop}px;
|
|
4593
4682
|
gap: ${unit * 0.028}px;
|
|
4594
4683
|
}
|
|
4595
4684
|
/* Toolchain credits: top right, deliberately small. */
|
|
@@ -4607,7 +4696,7 @@ function buildOutroCardHtml(options) {
|
|
|
4607
4696
|
/* The app the video is about. */
|
|
4608
4697
|
/* The app's mark leads the card, and it is meant to be seen: at 46% of
|
|
4609
4698
|
the short edge it reads at a glance on a phone. */
|
|
4610
|
-
.mark { width: ${
|
|
4699
|
+
.mark { width: ${markSize}px; height: ${markSize}px; object-fit: contain; border-radius: ${unit * 0.09}px; }
|
|
4611
4700
|
/* A dark mark on a dark card is invisible, which looks exactly like a
|
|
4612
4701
|
failed image. Sites that publish a black-on-transparent logo are common
|
|
4613
4702
|
enough to be worth catching, and a light plate is what their own site
|
|
@@ -4629,8 +4718,12 @@ function buildOutroCardHtml(options) {
|
|
|
4629
4718
|
}
|
|
4630
4719
|
|
|
4631
4720
|
/* Today's offer, along the very bottom edge. */
|
|
4721
|
+
/* In the flow, not pinned to the bottom edge. Pinned, it was laid out
|
|
4722
|
+
without reference to the text above it, so on a short canvas it simply
|
|
4723
|
+
covered whatever had grown into its space. margin-top pushes it clear of
|
|
4724
|
+
the domain instead. */
|
|
4632
4725
|
.promo {
|
|
4633
|
-
|
|
4726
|
+
margin-top: ${unit * 0.05}px;
|
|
4634
4727
|
display: flex; align-items: baseline; gap: ${unit * 0.022}px;
|
|
4635
4728
|
padding: ${unit * 0.026}px ${unit * 0.055}px;
|
|
4636
4729
|
border: 2px solid rgba(255, 209, 64, .85); border-radius: ${unit * 0.024}px;
|
|
@@ -4638,9 +4731,60 @@ function buildOutroCardHtml(options) {
|
|
|
4638
4731
|
}
|
|
4639
4732
|
.ppct { font-size: ${unit * 0.056}px; font-weight: 700; color: #ffd140; }
|
|
4640
4733
|
.pcode { font-size: ${unit * 0.048}px; font-weight: 700; letter-spacing: .05em; }
|
|
4734
|
+
|
|
4735
|
+
/* The animated mark, sized to the same 46% slot the still one occupies so
|
|
4736
|
+
the rest of the card's rhythm is unchanged. Ported from VidLet's
|
|
4737
|
+
src/gui/css/loader.css: the amber tip flies off and snaps back into the
|
|
4738
|
+
cut - a jump cut, on a loop. Pure CSS, because the renderer steps these
|
|
4739
|
+
animations frame by frame and a JS-driven one could not be stepped.
|
|
4740
|
+
|
|
4741
|
+
No prefers-reduced-motion fallback here, unlike the original: this runs
|
|
4742
|
+
in a headless Chromium rendering a video, where there is no viewer whose
|
|
4743
|
+
preference could be read, and honouring the default would silently flatten
|
|
4744
|
+
the animation to a fade in CI. */
|
|
4745
|
+
.amark { width: ${markSize}px; height: ${markSize}px; display: block; }
|
|
4746
|
+
.amark path {
|
|
4747
|
+
/* Without fill-box the origin is the whole SVG viewport, which swings the
|
|
4748
|
+
tip off its own centre on the scale steps. */
|
|
4749
|
+
transform-box: fill-box;
|
|
4750
|
+
transform-origin: center;
|
|
4751
|
+
}
|
|
4752
|
+
.amark .vl-body { fill: #fff6e5; stroke: #fff6e5; animation: vl-nudge 1.4s cubic-bezier(.4,0,.2,1) infinite; }
|
|
4753
|
+
.amark .vl-tip { fill: #ffc53d; stroke: #ffc53d; animation: vl-cut 1.4s cubic-bezier(.4,0,.2,1) infinite; }
|
|
4754
|
+
.amark .vl-ghost { opacity: .35; }
|
|
4755
|
+
.amark .vl-ghost .vl-tip { animation-delay: -.1s; }
|
|
4756
|
+
|
|
4757
|
+
/* Translations are in viewBox units, so the motion scales with the mark. */
|
|
4758
|
+
@keyframes vl-cut {
|
|
4759
|
+
0%, 30% { transform: translateX(0) scale(1); opacity: 1; }
|
|
4760
|
+
48% { transform: translateX(62px) scale(.94); opacity: 1; }
|
|
4761
|
+
60% { transform: translateX(88px) scale(.86); opacity: 0; }
|
|
4762
|
+
61%, 74% { transform: translateX(-58px) scale(.86); opacity: 0; }
|
|
4763
|
+
86% { transform: translateX(6px) scale(1.06); opacity: 1; }
|
|
4764
|
+
100% { transform: translateX(0) scale(1); opacity: 1; }
|
|
4765
|
+
}
|
|
4766
|
+
/* The body takes the recoil, so the cut lands rather than just sliding. */
|
|
4767
|
+
@keyframes vl-nudge {
|
|
4768
|
+
0%, 30% { transform: scaleX(1); }
|
|
4769
|
+
55% { transform: scaleX(1.04); }
|
|
4770
|
+
86% { transform: scaleX(.97); }
|
|
4771
|
+
100% { transform: scaleX(1); }
|
|
4772
|
+
}
|
|
4773
|
+
${/* The card normally reads its palette off the mark's pixels, and an SVG
|
|
4774
|
+
drawn by this stylesheet has none to read - the measuring script finds
|
|
4775
|
+
no <img class="mark"> and leaves the defaults, which would put VidLet's
|
|
4776
|
+
amber on a teal card. Stated instead of measured, last so it wins. */
|
|
4777
|
+
animated === "vidlet" ? ":root { --bg-in: #1f1a10; --bg-out: #07060a; --accent: #ffc53d; }" : ""}
|
|
4641
4778
|
</style></head>
|
|
4642
4779
|
<body>
|
|
4643
4780
|
${credits.length ? `<div class="credits">${creditRows}</div>` : ""}
|
|
4781
|
+
${animated === "vidlet" ? `<svg class="amark" viewBox="0 0 512 512" aria-hidden="true">
|
|
4782
|
+
<g stroke-linejoin="round" stroke-width="20">
|
|
4783
|
+
<g class="vl-ghost"><path class="vl-tip" d="M318 246 L410 302 L318 358 Z"/></g>
|
|
4784
|
+
<path class="vl-body" d="M176 124 L300 200 L300 312 L176 388 Z"/>
|
|
4785
|
+
<path class="vl-tip" d="M318 246 L410 302 L318 358 Z"/>
|
|
4786
|
+
</g>
|
|
4787
|
+
</svg>` : ""}
|
|
4644
4788
|
${subjectIcon ? `<img class="mark" src="${subjectIcon}" alt="" />` : ""}
|
|
4645
4789
|
<div class="name">${escapeHtml(subject.name)}</div>
|
|
4646
4790
|
${subject.tagline ? `<div class="tagline">${escapeHtml(subject.tagline)}</div>` : ""}
|
|
@@ -4743,10 +4887,18 @@ function buildOutroCardHtml(options) {
|
|
|
4743
4887
|
</script>
|
|
4744
4888
|
</body></html>`;
|
|
4745
4889
|
}
|
|
4890
|
+
var ANIMATED_MARK_LOOP_SECONDS, dataUri;
|
|
4746
4891
|
var init_outro_card = __esm({
|
|
4747
4892
|
"src/lib/outro-card.ts"() {
|
|
4748
4893
|
"use strict";
|
|
4749
4894
|
init_image_kind();
|
|
4895
|
+
ANIMATED_MARK_LOOP_SECONDS = {
|
|
4896
|
+
// Matches `vl-cut` / `vl-nudge` in VidLet's src/gui/css/loader.css. Changing
|
|
4897
|
+
// it there without changing it here makes the encoded loop stutter at the
|
|
4898
|
+
// seam, because the clip is cut to exactly this length and then repeated.
|
|
4899
|
+
vidlet: 1.4
|
|
4900
|
+
};
|
|
4901
|
+
dataUri = imageDataUri;
|
|
4750
4902
|
}
|
|
4751
4903
|
});
|
|
4752
4904
|
|
|
@@ -4756,8 +4908,8 @@ __export(render_outro_exports, {
|
|
|
4756
4908
|
renderOutroClip: () => renderOutroClip,
|
|
4757
4909
|
renderOutroPng: () => renderOutroPng
|
|
4758
4910
|
});
|
|
4759
|
-
import { writeFile as writeFile8 } from "fs/promises";
|
|
4760
|
-
import { join as join15, resolve as
|
|
4911
|
+
import { mkdir as mkdir7, rm as rm2, writeFile as writeFile8 } from "fs/promises";
|
|
4912
|
+
import { join as join15, resolve as resolve6 } from "path";
|
|
4761
4913
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
4762
4914
|
import { chromium as chromium6 } from "playwright";
|
|
4763
4915
|
async function renderOutroPng(options) {
|
|
@@ -4768,7 +4920,7 @@ async function renderOutroPng(options) {
|
|
|
4768
4920
|
const browser = await chromium6.launch({ headless: true });
|
|
4769
4921
|
try {
|
|
4770
4922
|
const page = await browser.newPage({ viewport: { width, height } });
|
|
4771
|
-
await page.goto(pathToFileURL2(
|
|
4923
|
+
await page.goto(pathToFileURL2(resolve6(htmlPath)).href, { waitUntil: "load" });
|
|
4772
4924
|
await page.waitForSelector('body[data-cards-ready="1"]', { timeout: 3e3 }).catch(() => page.waitForTimeout(300));
|
|
4773
4925
|
await page.screenshot({ path: pngPath });
|
|
4774
4926
|
} finally {
|
|
@@ -4781,11 +4933,14 @@ async function renderOutroClip(options) {
|
|
|
4781
4933
|
const htmlPath = join15(workDir, "outro-card.html");
|
|
4782
4934
|
const pngPath = join15(workDir, "outro-card.png");
|
|
4783
4935
|
const clipPath = join15(workDir, "outro-card.mp4");
|
|
4936
|
+
if (options.subject.animatedMark) {
|
|
4937
|
+
return renderAnimatedOutroClip(options, { htmlPath, clipPath, seconds, fps });
|
|
4938
|
+
}
|
|
4784
4939
|
await writeFile8(htmlPath, buildOutroCardHtml(options));
|
|
4785
4940
|
const browser = await chromium6.launch({ headless: true });
|
|
4786
4941
|
try {
|
|
4787
4942
|
const page = await browser.newPage({ viewport: { width, height } });
|
|
4788
|
-
await page.goto(pathToFileURL2(
|
|
4943
|
+
await page.goto(pathToFileURL2(resolve6(htmlPath)).href, { waitUntil: "load" });
|
|
4789
4944
|
await page.waitForSelector('body[data-cards-ready="1"]', { timeout: 3e3 }).catch(() => {
|
|
4790
4945
|
});
|
|
4791
4946
|
await page.waitForTimeout(300);
|
|
@@ -4823,6 +4978,70 @@ async function renderOutroClip(options) {
|
|
|
4823
4978
|
);
|
|
4824
4979
|
return clipPath;
|
|
4825
4980
|
}
|
|
4981
|
+
async function renderAnimatedOutroClip(options, paths) {
|
|
4982
|
+
const { htmlPath, clipPath, seconds, fps } = paths;
|
|
4983
|
+
const { workDir, width, height } = options;
|
|
4984
|
+
const loopSeconds = ANIMATED_MARK_LOOP_SECONDS[options.subject.animatedMark];
|
|
4985
|
+
const frameCount = Math.max(1, Math.round(loopSeconds * fps));
|
|
4986
|
+
const framesDir = join15(workDir, "outro-frames");
|
|
4987
|
+
await rm2(framesDir, { recursive: true, force: true });
|
|
4988
|
+
await mkdir7(framesDir, { recursive: true });
|
|
4989
|
+
await writeFile8(htmlPath, buildOutroCardHtml(options));
|
|
4990
|
+
const browser = await chromium6.launch({ headless: true });
|
|
4991
|
+
try {
|
|
4992
|
+
const page = await browser.newPage({ viewport: { width, height } });
|
|
4993
|
+
await page.goto(pathToFileURL2(resolve6(htmlPath)).href, { waitUntil: "load" });
|
|
4994
|
+
await page.waitForSelector('body[data-cards-ready="1"]', { timeout: 3e3 }).catch(() => {
|
|
4995
|
+
});
|
|
4996
|
+
await page.waitForTimeout(300);
|
|
4997
|
+
await page.evaluate(() => {
|
|
4998
|
+
for (const animation of document.getAnimations()) animation.pause();
|
|
4999
|
+
});
|
|
5000
|
+
for (let frame = 0; frame < frameCount; frame++) {
|
|
5001
|
+
const elapsedMs = frame / frameCount * loopSeconds * 1e3;
|
|
5002
|
+
await page.evaluate((ms) => {
|
|
5003
|
+
for (const animation of document.getAnimations()) animation.currentTime = ms;
|
|
5004
|
+
}, elapsedMs);
|
|
5005
|
+
await page.screenshot({ path: join15(framesDir, `f-${String(frame).padStart(4, "0")}.png`) });
|
|
5006
|
+
}
|
|
5007
|
+
} finally {
|
|
5008
|
+
await browser.close();
|
|
5009
|
+
}
|
|
5010
|
+
await runFFmpeg(
|
|
5011
|
+
[
|
|
5012
|
+
"-y",
|
|
5013
|
+
// -stream_loop applies to the input that FOLLOWS it, so it has to sit
|
|
5014
|
+
// before -framerate/-i, not after.
|
|
5015
|
+
"-stream_loop",
|
|
5016
|
+
"-1",
|
|
5017
|
+
"-framerate",
|
|
5018
|
+
String(fps),
|
|
5019
|
+
"-i",
|
|
5020
|
+
join15(framesDir, "f-%04d.png"),
|
|
5021
|
+
"-f",
|
|
5022
|
+
"lavfi",
|
|
5023
|
+
"-i",
|
|
5024
|
+
"anullsrc=channel_layout=stereo:sample_rate=44100",
|
|
5025
|
+
"-t",
|
|
5026
|
+
String(seconds),
|
|
5027
|
+
"-r",
|
|
5028
|
+
String(fps),
|
|
5029
|
+
"-pix_fmt",
|
|
5030
|
+
"yuv420p",
|
|
5031
|
+
"-c:v",
|
|
5032
|
+
"libx264",
|
|
5033
|
+
"-preset",
|
|
5034
|
+
"veryfast",
|
|
5035
|
+
"-c:a",
|
|
5036
|
+
"aac",
|
|
5037
|
+
"-shortest",
|
|
5038
|
+
clipPath
|
|
5039
|
+
],
|
|
5040
|
+
"ffmpeg animated outro card render failed"
|
|
5041
|
+
);
|
|
5042
|
+
await rm2(framesDir, { recursive: true, force: true });
|
|
5043
|
+
return clipPath;
|
|
5044
|
+
}
|
|
4826
5045
|
var init_render_outro = __esm({
|
|
4827
5046
|
"src/browser/render-outro.ts"() {
|
|
4828
5047
|
"use strict";
|
|
@@ -4890,11 +5109,11 @@ __export(external_clips_exports, {
|
|
|
4890
5109
|
findStepClips: () => findStepClips,
|
|
4891
5110
|
loadExternalNarration: () => loadExternalNarration
|
|
4892
5111
|
});
|
|
4893
|
-
import { existsSync as
|
|
5112
|
+
import { existsSync as existsSync22, mkdirSync as mkdirSync2, readdirSync as readdirSync4, rmSync as rmSync3 } from "fs";
|
|
4894
5113
|
import { join as join27 } from "path";
|
|
4895
5114
|
function findStepClips(dir) {
|
|
4896
5115
|
const clips = /* @__PURE__ */ new Map();
|
|
4897
|
-
if (!
|
|
5116
|
+
if (!existsSync22(dir)) return clips;
|
|
4898
5117
|
for (const name of readdirSync4(dir)) {
|
|
4899
5118
|
const match = /^step-0*(\d+)\.(wav|mp3|m4a|flac|ogg)$/i.exec(name);
|
|
4900
5119
|
if (match?.[1]) clips.set(Number(match[1]), join27(dir, name));
|
|
@@ -5147,8 +5366,8 @@ async function fileResult(output, data, poster) {
|
|
|
5147
5366
|
}
|
|
5148
5367
|
|
|
5149
5368
|
// src/mcp/tools.ts
|
|
5150
|
-
import { existsSync as
|
|
5151
|
-
import { basename as basename5, join as join33, resolve as
|
|
5369
|
+
import { existsSync as existsSync27, mkdirSync as mkdirSync3, readdirSync as readdirSync6, readFileSync as readFileSync10, statSync as statSync4 } from "fs";
|
|
5370
|
+
import { basename as basename5, dirname as dirname5, join as join33, resolve as resolve12 } from "path";
|
|
5152
5371
|
import { MALE_VOICES as MALE_VOICES3, WIDE_VOICES as WIDE_VOICES3 } from "@spark-apps/video-kit";
|
|
5153
5372
|
|
|
5154
5373
|
// src/browser/auth-state.ts
|
|
@@ -5261,13 +5480,107 @@ function resolveStartUrl(url) {
|
|
|
5261
5480
|
// src/browser/launch.ts
|
|
5262
5481
|
init_lifecycle();
|
|
5263
5482
|
import { mkdir as mkdir2 } from "fs/promises";
|
|
5264
|
-
import { chromium as
|
|
5483
|
+
import { chromium as chromium3 } from "playwright";
|
|
5484
|
+
|
|
5485
|
+
// src/browser/brand-watermark.ts
|
|
5486
|
+
init_image_kind();
|
|
5487
|
+
function watermarkInitScript(payload) {
|
|
5488
|
+
return `(() => {
|
|
5489
|
+
var ID = '__quickpeek_brand_watermark';
|
|
5490
|
+
var P = ${embedJson(payload)};
|
|
5491
|
+
var vertical = P.position.indexOf('top') === 0 ? 'top:22px' : 'bottom:22px';
|
|
5492
|
+
var horizontal = P.position.indexOf('right') > -1 ? 'right:24px' : 'left:24px';
|
|
5493
|
+
var SHELL = [
|
|
5494
|
+
'position:fixed',
|
|
5495
|
+
vertical,
|
|
5496
|
+
horizontal,
|
|
5497
|
+
'z-index:2147483646',
|
|
5498
|
+
'display:flex',
|
|
5499
|
+
'align-items:center',
|
|
5500
|
+
'gap:10px',
|
|
5501
|
+
'padding:8px 16px 8px 10px',
|
|
5502
|
+
'border-radius:999px',
|
|
5503
|
+
// A hairline and a blur rather than a solid plate: the mark sits on top of
|
|
5504
|
+
// whatever the app is showing, and a hard box reads as part of the UI.
|
|
5505
|
+
// The alpha is high enough to stay dark over a LIGHT app: at 0.55 the blur
|
|
5506
|
+
// averaged a pale page up into grey and the white label sat on it at about
|
|
5507
|
+
// 2:1, which photographs as a smudge.
|
|
5508
|
+
'background:rgba(12,12,18,0.78)',
|
|
5509
|
+
'border:1px solid rgba(255,255,255,0.14)',
|
|
5510
|
+
'backdrop-filter:blur(14px) saturate(1.3)',
|
|
5511
|
+
'-webkit-backdrop-filter:blur(14px) saturate(1.3)',
|
|
5512
|
+
'box-shadow:0 6px 24px rgba(0,0,0,0.30)',
|
|
5513
|
+
'opacity:' + P.opacity,
|
|
5514
|
+
'pointer-events:none',
|
|
5515
|
+
'user-select:none'
|
|
5516
|
+
].join(';');
|
|
5517
|
+
var ICON = 'width:26px;height:26px;border-radius:7px;object-fit:contain;display:block';
|
|
5518
|
+
var NAME = [
|
|
5519
|
+
'font:600 15px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
|
|
5520
|
+
'letter-spacing:0.015em',
|
|
5521
|
+
'color:#ffffff',
|
|
5522
|
+
'white-space:nowrap'
|
|
5523
|
+
].join(';');
|
|
5524
|
+
|
|
5525
|
+
function mount() {
|
|
5526
|
+
try {
|
|
5527
|
+
if (!document.body || document.getElementById(ID)) return;
|
|
5528
|
+
var el = document.createElement('div');
|
|
5529
|
+
el.id = ID;
|
|
5530
|
+
el.setAttribute('style', SHELL);
|
|
5531
|
+
if (P.icon) {
|
|
5532
|
+
var img = document.createElement('img');
|
|
5533
|
+
img.setAttribute('style', ICON);
|
|
5534
|
+
img.src = P.icon;
|
|
5535
|
+
img.alt = '';
|
|
5536
|
+
el.appendChild(img);
|
|
5537
|
+
}
|
|
5538
|
+
var label = document.createElement('span');
|
|
5539
|
+
label.setAttribute('style', NAME);
|
|
5540
|
+
label.textContent = P.name;
|
|
5541
|
+
el.appendChild(label);
|
|
5542
|
+
document.body.appendChild(el);
|
|
5543
|
+
} catch (e) {
|
|
5544
|
+
// A page that refuses the node still records; the mark is cosmetic.
|
|
5545
|
+
}
|
|
5546
|
+
}
|
|
5547
|
+
|
|
5548
|
+
if (document.body) mount();
|
|
5549
|
+
else document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
5550
|
+
setInterval(mount, 400);
|
|
5551
|
+
})();`;
|
|
5552
|
+
}
|
|
5553
|
+
async function applyBrandWatermark(context, watermark) {
|
|
5554
|
+
if (!watermark?.name) return false;
|
|
5555
|
+
const payload = {
|
|
5556
|
+
name: watermark.name,
|
|
5557
|
+
icon: watermark.iconPath ? imageDataUri(watermark.iconPath) : null,
|
|
5558
|
+
position: watermark.position ?? "top-right",
|
|
5559
|
+
opacity: clampOpacity(watermark.opacity)
|
|
5560
|
+
};
|
|
5561
|
+
try {
|
|
5562
|
+
await context.addInitScript(watermarkInitScript(payload));
|
|
5563
|
+
return true;
|
|
5564
|
+
} catch {
|
|
5565
|
+
return false;
|
|
5566
|
+
}
|
|
5567
|
+
}
|
|
5568
|
+
function embedJson(value) {
|
|
5569
|
+
return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
5570
|
+
}
|
|
5571
|
+
function clampOpacity(value) {
|
|
5572
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 0.92;
|
|
5573
|
+
return Math.min(1, Math.max(0.35, value));
|
|
5574
|
+
}
|
|
5575
|
+
|
|
5576
|
+
// src/browser/launch.ts
|
|
5577
|
+
init_headless();
|
|
5265
5578
|
|
|
5266
5579
|
// src/browser/wsl.ts
|
|
5267
5580
|
import { execSync, spawn as spawn2 } from "child_process";
|
|
5268
|
-
import { existsSync as existsSync3, readFileSync as
|
|
5581
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
|
|
5269
5582
|
import { request } from "http";
|
|
5270
|
-
import { chromium } from "playwright";
|
|
5583
|
+
import { chromium as chromium2 } from "playwright";
|
|
5271
5584
|
function isWSL() {
|
|
5272
5585
|
if (process.platform !== "linux") return false;
|
|
5273
5586
|
try {
|
|
@@ -5310,7 +5623,7 @@ function getWindowsHostIP() {
|
|
|
5310
5623
|
} catch {
|
|
5311
5624
|
}
|
|
5312
5625
|
try {
|
|
5313
|
-
const m =
|
|
5626
|
+
const m = readFileSync4("/etc/resolv.conf", "utf-8").match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
|
|
5314
5627
|
if (m?.[1]) return m[1];
|
|
5315
5628
|
} catch {
|
|
5316
5629
|
}
|
|
@@ -5318,7 +5631,7 @@ function getWindowsHostIP() {
|
|
|
5318
5631
|
}
|
|
5319
5632
|
function startRelay(nodePath, windowsHost, targetPort) {
|
|
5320
5633
|
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((
|
|
5634
|
+
return new Promise((resolve13, reject) => {
|
|
5322
5635
|
const proc = spawn2(nodePath, ["-e", script], { stdio: ["ignore", "pipe", "ignore"], detached: true });
|
|
5323
5636
|
const timer = setTimeout(() => reject(new Error("relay startup timed out")), 6e3);
|
|
5324
5637
|
let buf = "";
|
|
@@ -5327,7 +5640,7 @@ function startRelay(nodePath, windowsHost, targetPort) {
|
|
|
5327
5640
|
const m = buf.match(/PORT:(\d+)/);
|
|
5328
5641
|
if (m) {
|
|
5329
5642
|
clearTimeout(timer);
|
|
5330
|
-
|
|
5643
|
+
resolve13({ proc, port: +m[1] });
|
|
5331
5644
|
}
|
|
5332
5645
|
});
|
|
5333
5646
|
proc.on("error", (e) => {
|
|
@@ -5337,7 +5650,7 @@ function startRelay(nodePath, windowsHost, targetPort) {
|
|
|
5337
5650
|
});
|
|
5338
5651
|
}
|
|
5339
5652
|
function waitForChrome(host, port) {
|
|
5340
|
-
return new Promise((
|
|
5653
|
+
return new Promise((resolve13, reject) => {
|
|
5341
5654
|
const deadline = Date.now() + 2e4;
|
|
5342
5655
|
const poll = () => {
|
|
5343
5656
|
if (Date.now() > deadline) {
|
|
@@ -5346,7 +5659,7 @@ function waitForChrome(host, port) {
|
|
|
5346
5659
|
}
|
|
5347
5660
|
const req = request({ host, port, path: "/json/version", method: "GET" }, (res) => {
|
|
5348
5661
|
res.resume();
|
|
5349
|
-
|
|
5662
|
+
resolve13();
|
|
5350
5663
|
});
|
|
5351
5664
|
req.on("error", () => setTimeout(poll, 600));
|
|
5352
5665
|
req.setTimeout(1e3, () => {
|
|
@@ -5386,7 +5699,7 @@ async function launchWindowsChrome(extensionPath, userDataDir, options = {}) {
|
|
|
5386
5699
|
const { proc: relay, port: proxyPort } = await startRelay(nodePath, windowsHost, debugPort);
|
|
5387
5700
|
try {
|
|
5388
5701
|
await waitForChrome(windowsHost, proxyPort);
|
|
5389
|
-
const browser = await
|
|
5702
|
+
const browser = await chromium2.connectOverCDP(`http://${windowsHost}:${proxyPort}`, { timeout: 5e3 });
|
|
5390
5703
|
return {
|
|
5391
5704
|
browser,
|
|
5392
5705
|
cleanup: async () => {
|
|
@@ -5443,7 +5756,7 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
|
|
|
5443
5756
|
acceptDownloads: true
|
|
5444
5757
|
});
|
|
5445
5758
|
} catch {
|
|
5446
|
-
context = await
|
|
5759
|
+
context = await chromium3.launchPersistentContext(userDataDir, {
|
|
5447
5760
|
headless: false,
|
|
5448
5761
|
args: persistentArgs,
|
|
5449
5762
|
viewport,
|
|
@@ -5454,7 +5767,7 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
|
|
|
5454
5767
|
});
|
|
5455
5768
|
}
|
|
5456
5769
|
} else {
|
|
5457
|
-
context = await
|
|
5770
|
+
context = await chromium3.launchPersistentContext(userDataDir, {
|
|
5458
5771
|
headless: false,
|
|
5459
5772
|
args: persistentArgs,
|
|
5460
5773
|
viewport,
|
|
@@ -5472,12 +5785,12 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
|
|
|
5472
5785
|
ignoreDefaultArgs: ["--enable-automation"]
|
|
5473
5786
|
};
|
|
5474
5787
|
try {
|
|
5475
|
-
browser = await
|
|
5788
|
+
browser = await chromium3.launch({ ...headedOpts, channel: "chrome" });
|
|
5476
5789
|
} catch {
|
|
5477
|
-
browser = await
|
|
5790
|
+
browser = await chromium3.launch(headedOpts);
|
|
5478
5791
|
}
|
|
5479
5792
|
} else {
|
|
5480
|
-
browser = await
|
|
5793
|
+
browser = await launchHeadlessChromium();
|
|
5481
5794
|
}
|
|
5482
5795
|
context = await browser.newContext({
|
|
5483
5796
|
viewport,
|
|
@@ -5489,6 +5802,7 @@ async function launchBrowserContext(config, userDataDir, recordVideo, storageSta
|
|
|
5489
5802
|
});
|
|
5490
5803
|
}
|
|
5491
5804
|
await hideDevOverlays(context);
|
|
5805
|
+
await applyBrandWatermark(context, config.video.watermark);
|
|
5492
5806
|
const session = { context, browser, chromeSession };
|
|
5493
5807
|
session.release = onCleanup(() => closeBrowserSession(session));
|
|
5494
5808
|
return session;
|
|
@@ -5513,9 +5827,9 @@ async function launchSignInBrowser() {
|
|
|
5513
5827
|
ignoreDefaultArgs: ["--enable-automation"]
|
|
5514
5828
|
};
|
|
5515
5829
|
try {
|
|
5516
|
-
return await
|
|
5830
|
+
return await chromium3.launch({ ...opts, channel: "chrome" });
|
|
5517
5831
|
} catch {
|
|
5518
|
-
return await
|
|
5832
|
+
return await chromium3.launch(opts);
|
|
5519
5833
|
}
|
|
5520
5834
|
}
|
|
5521
5835
|
async function cleanupBrowserSession(session) {
|
|
@@ -5587,7 +5901,7 @@ async function cancelSignIn() {
|
|
|
5587
5901
|
import { copyFileSync, existsSync as existsSync4, mkdirSync, mkdtempSync, rmSync as rmSync2 } from "fs";
|
|
5588
5902
|
import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
|
|
5589
5903
|
import { dirname, join as join3 } from "path";
|
|
5590
|
-
import { chromium as
|
|
5904
|
+
import { chromium as chromium4 } from "playwright";
|
|
5591
5905
|
var BROWSERS = [
|
|
5592
5906
|
{
|
|
5593
5907
|
key: "chrome",
|
|
@@ -5697,7 +6011,7 @@ async function readState(profileDir, channel, store2) {
|
|
|
5697
6011
|
];
|
|
5698
6012
|
if (store2) args.push(`--password-store=${store2}`);
|
|
5699
6013
|
try {
|
|
5700
|
-
const context = await
|
|
6014
|
+
const context = await chromium4.launchPersistentContext(profileDir, { headless: true, channel, args });
|
|
5701
6015
|
try {
|
|
5702
6016
|
return await context.storageState();
|
|
5703
6017
|
} finally {
|
|
@@ -5716,7 +6030,7 @@ async function importBrowserSession(url) {
|
|
|
5716
6030
|
}
|
|
5717
6031
|
|
|
5718
6032
|
// src/commands/edit-cmd.ts
|
|
5719
|
-
import { existsSync as
|
|
6033
|
+
import { existsSync as existsSync10, readdirSync, readFileSync as readFileSync5 } from "fs";
|
|
5720
6034
|
import { join as join14 } from "path";
|
|
5721
6035
|
import chalk7 from "chalk";
|
|
5722
6036
|
|
|
@@ -5791,8 +6105,8 @@ function editorBaseUrl() {
|
|
|
5791
6105
|
function openInBrowser(url) {
|
|
5792
6106
|
const isWSL2 = process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME);
|
|
5793
6107
|
const command = isWSL2 ? `cmd.exe /c start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
5794
|
-
return new Promise((
|
|
5795
|
-
exec2(command, () =>
|
|
6108
|
+
return new Promise((resolve13) => {
|
|
6109
|
+
exec2(command, () => resolve13());
|
|
5796
6110
|
});
|
|
5797
6111
|
}
|
|
5798
6112
|
|
|
@@ -5951,8 +6265,72 @@ function planEditorUrl(baseUrl, plan) {
|
|
|
5951
6265
|
return url.length <= MAX_HANDOFF_URL_CHARS ? url : null;
|
|
5952
6266
|
}
|
|
5953
6267
|
|
|
6268
|
+
// src/lib/plan-thumbs.ts
|
|
6269
|
+
init_spawn();
|
|
6270
|
+
import { existsSync as existsSync9 } from "fs";
|
|
6271
|
+
import { isAbsolute as isAbsolute2, resolve as resolve4 } from "path";
|
|
6272
|
+
var MEDIA_ACTIONS = ["image", "media", "video"];
|
|
6273
|
+
var THUMB_WIDTH = 160;
|
|
6274
|
+
var MAX_THUMB_CHARS = 1e4;
|
|
6275
|
+
var MAX_TOTAL_THUMB_CHARS = 16e3;
|
|
6276
|
+
var THUMB_TIMEOUT_MS = 3e3;
|
|
6277
|
+
function thumbnail(path) {
|
|
6278
|
+
return new Promise((done) => {
|
|
6279
|
+
const proc = spawnFFmpeg("ffmpeg", [
|
|
6280
|
+
"-v",
|
|
6281
|
+
"error",
|
|
6282
|
+
"-i",
|
|
6283
|
+
path,
|
|
6284
|
+
"-frames:v",
|
|
6285
|
+
"1",
|
|
6286
|
+
"-vf",
|
|
6287
|
+
`scale=${THUMB_WIDTH}:-2`,
|
|
6288
|
+
"-q:v",
|
|
6289
|
+
"14",
|
|
6290
|
+
"-f",
|
|
6291
|
+
"mjpeg",
|
|
6292
|
+
"-"
|
|
6293
|
+
]);
|
|
6294
|
+
const chunks = [];
|
|
6295
|
+
const timer = setTimeout(() => {
|
|
6296
|
+
proc.kill();
|
|
6297
|
+
done(null);
|
|
6298
|
+
}, THUMB_TIMEOUT_MS);
|
|
6299
|
+
const finish = (result) => {
|
|
6300
|
+
clearTimeout(timer);
|
|
6301
|
+
done(result);
|
|
6302
|
+
};
|
|
6303
|
+
proc.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
6304
|
+
proc.on("error", () => finish(null));
|
|
6305
|
+
proc.on("close", (code) => {
|
|
6306
|
+
const bytes = Buffer.concat(chunks);
|
|
6307
|
+
if (code !== 0 || bytes.length === 0) return finish(null);
|
|
6308
|
+
const base64 = bytes.toString("base64");
|
|
6309
|
+
finish(base64.length > MAX_THUMB_CHARS ? null : `data:image/jpeg;base64,${base64}`);
|
|
6310
|
+
});
|
|
6311
|
+
});
|
|
6312
|
+
}
|
|
6313
|
+
async function withEditorThumbs(plan, baseDir) {
|
|
6314
|
+
const steps = [...plan.steps];
|
|
6315
|
+
let budget = MAX_TOTAL_THUMB_CHARS;
|
|
6316
|
+
for (const [index, step] of steps.entries()) {
|
|
6317
|
+
if (!step.value || !MEDIA_ACTIONS.includes(step.action)) continue;
|
|
6318
|
+
const path = isAbsolute2(step.value) ? step.value : resolve4(baseDir, step.value);
|
|
6319
|
+
if (!existsSync9(path)) continue;
|
|
6320
|
+
const thumb = await thumbnail(path);
|
|
6321
|
+
if (!thumb || thumb.length > budget) continue;
|
|
6322
|
+
budget -= thumb.length;
|
|
6323
|
+
steps[index] = { ...step, thumb };
|
|
6324
|
+
}
|
|
6325
|
+
return { ...plan, steps };
|
|
6326
|
+
}
|
|
6327
|
+
async function editorUrlWithThumbs(baseUrl, plan, baseDir) {
|
|
6328
|
+
const withThumbs = await withEditorThumbs(plan, baseDir);
|
|
6329
|
+
return planEditorUrl(baseUrl, withThumbs) ?? planEditorUrl(baseUrl, plan);
|
|
6330
|
+
}
|
|
6331
|
+
|
|
5954
6332
|
// src/utils/ui.ts
|
|
5955
|
-
import { resolve as
|
|
6333
|
+
import { resolve as resolve5 } from "path";
|
|
5956
6334
|
import { createInterface } from "readline";
|
|
5957
6335
|
import chalk2 from "chalk";
|
|
5958
6336
|
import ora from "ora";
|
|
@@ -6019,7 +6397,7 @@ function waitForEnter(prompt2 = `${INDENT}Press Enter when done...`) {
|
|
|
6019
6397
|
)
|
|
6020
6398
|
);
|
|
6021
6399
|
}
|
|
6022
|
-
return new Promise((
|
|
6400
|
+
return new Promise((resolve13, reject) => {
|
|
6023
6401
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
6024
6402
|
let done = false;
|
|
6025
6403
|
rl.on("close", () => {
|
|
@@ -6030,13 +6408,13 @@ function waitForEnter(prompt2 = `${INDENT}Press Enter when done...`) {
|
|
|
6030
6408
|
rl.question(prompt2, () => {
|
|
6031
6409
|
done = true;
|
|
6032
6410
|
rl.close();
|
|
6033
|
-
|
|
6411
|
+
resolve13();
|
|
6034
6412
|
});
|
|
6035
6413
|
});
|
|
6036
6414
|
}
|
|
6037
6415
|
var _isWSL = process.platform === "linux" && process.env.WSL_DISTRO_NAME;
|
|
6038
6416
|
function formatClickablePath(filePath) {
|
|
6039
|
-
const absolutePath =
|
|
6417
|
+
const absolutePath = resolve5(filePath);
|
|
6040
6418
|
if (_isWSL || process.platform !== "win32") {
|
|
6041
6419
|
return absolutePath;
|
|
6042
6420
|
}
|
|
@@ -6077,13 +6455,13 @@ import chalk3 from "chalk";
|
|
|
6077
6455
|
init_SparkStripe_feature_gating();
|
|
6078
6456
|
var TIMEOUT_MS = 12e4;
|
|
6079
6457
|
async function browserLogin() {
|
|
6080
|
-
return new Promise((
|
|
6458
|
+
return new Promise((resolve13) => {
|
|
6081
6459
|
let server;
|
|
6082
6460
|
let timer;
|
|
6083
6461
|
const cleanup = (email) => {
|
|
6084
6462
|
clearTimeout(timer);
|
|
6085
6463
|
server?.close();
|
|
6086
|
-
|
|
6464
|
+
resolve13(email);
|
|
6087
6465
|
};
|
|
6088
6466
|
server = createServer((req, res) => {
|
|
6089
6467
|
const url = new URL2(req.url || "/", `http://localhost`);
|
|
@@ -6234,10 +6612,10 @@ function getLastRateLimitInfo() {
|
|
|
6234
6612
|
}
|
|
6235
6613
|
function prompt(question) {
|
|
6236
6614
|
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
6237
|
-
return new Promise((
|
|
6615
|
+
return new Promise((resolve13) => {
|
|
6238
6616
|
rl.question(question, (answer) => {
|
|
6239
6617
|
rl.close();
|
|
6240
|
-
|
|
6618
|
+
resolve13(answer.trim());
|
|
6241
6619
|
});
|
|
6242
6620
|
});
|
|
6243
6621
|
}
|
|
@@ -6246,30 +6624,27 @@ async function setupUserEmail() {
|
|
|
6246
6624
|
console.log(chalk4.yellow(" \u2709 Account Setup"));
|
|
6247
6625
|
console.log(chalk4.dim(" \u2500".repeat(20)));
|
|
6248
6626
|
console.log();
|
|
6249
|
-
console.log(chalk4.white("
|
|
6250
|
-
console.log(chalk4.white("
|
|
6251
|
-
|
|
6252
|
-
|
|
6627
|
+
console.log(chalk4.white(" The AI that plans your demo runs on our servers, so it"));
|
|
6628
|
+
console.log(chalk4.white(" needs an address to meter. No card, and no verification"));
|
|
6629
|
+
console.log(chalk4.white(" needed for your first 5 videos."));
|
|
6630
|
+
console.log(chalk4.dim(" After that, signing in gets you 10 videos a day."));
|
|
6631
|
+
console.log();
|
|
6632
|
+
console.log(chalk4.dim(" Type your email, or 'b' to sign in through a browser."));
|
|
6633
|
+
const email = await prompt(chalk4.white(" Email: "));
|
|
6634
|
+
if (!email) return null;
|
|
6635
|
+
if (email.toLowerCase() === "b" || email.toLowerCase() === "browser") {
|
|
6636
|
+
const browserEmail = await browserLogin();
|
|
6637
|
+
if (!browserEmail) return null;
|
|
6253
6638
|
console.log();
|
|
6254
6639
|
console.log(chalk4.green(` \u2713 Signed in as ${browserEmail}`));
|
|
6255
6640
|
console.log();
|
|
6256
6641
|
return browserEmail;
|
|
6257
6642
|
}
|
|
6258
|
-
console.log();
|
|
6259
|
-
console.log(chalk4.dim(" Or enter your email manually:"));
|
|
6260
|
-
const email = await prompt(chalk4.white(" Email: "));
|
|
6261
|
-
if (!email) {
|
|
6262
|
-
console.log();
|
|
6263
|
-
console.log(chalk4.red(" No email provided. Exiting."));
|
|
6264
|
-
process.exit(1);
|
|
6265
|
-
}
|
|
6266
6643
|
if (!email.includes("@") || !email.includes(".")) {
|
|
6267
6644
|
console.log();
|
|
6268
6645
|
console.log(chalk4.yellow(" \u26A0 That doesn't look like a valid email address."));
|
|
6269
6646
|
const proceed = await prompt(chalk4.white(" Continue anyway? ") + chalk4.dim("[y/N] "));
|
|
6270
|
-
if (proceed.toLowerCase() !== "y")
|
|
6271
|
-
process.exit(1);
|
|
6272
|
-
}
|
|
6647
|
+
if (proceed.toLowerCase() !== "y") return null;
|
|
6273
6648
|
}
|
|
6274
6649
|
console.log();
|
|
6275
6650
|
console.log(chalk4.green(" \u2713 Email registered"));
|
|
@@ -6394,6 +6769,17 @@ async function parseAIJsonWithRepair(content, opts) {
|
|
|
6394
6769
|
init_mcp_context();
|
|
6395
6770
|
init_lib();
|
|
6396
6771
|
init_json_repair();
|
|
6772
|
+
var WRITTEN_ADDRESS = /\b(?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?/gi;
|
|
6773
|
+
var SPELLED_OUT_ACRONYM = /\b[A-Z](?:\s+[A-Z])+\b/g;
|
|
6774
|
+
function spokenCaption(caption) {
|
|
6775
|
+
const held = [];
|
|
6776
|
+
const masked = caption.replace(SPELLED_OUT_ACRONYM, (run2) => run2.replace(/\s+/g, "")).replace(WRITTEN_ADDRESS, (address) => {
|
|
6777
|
+
held.push(address);
|
|
6778
|
+
return `qpaddr${held.length - 1}qp`;
|
|
6779
|
+
});
|
|
6780
|
+
const shaped = toTitleCase(toSpokenForm2(masked));
|
|
6781
|
+
return shaped.replace(/qpaddr(\d+)qp/gi, (_match, index) => held[Number(index)] ?? "");
|
|
6782
|
+
}
|
|
6397
6783
|
async function crawlExtension(url, extensionPath, workDir, task2) {
|
|
6398
6784
|
const browserTask = task2("Launching browser with extension");
|
|
6399
6785
|
const userDataDir = join12(workDir, ".browser-data");
|
|
@@ -6522,8 +6908,18 @@ async function crawlAndAnalyze(url, lang, description, maxSteps, task2, logError
|
|
|
6522
6908
|
// The spoken form is what the voice will actually say, so it is what
|
|
6523
6909
|
// the caption shows: burning the written form instead left the words on
|
|
6524
6910
|
// screen out of step with the words in the ear wherever the two differ
|
|
6525
|
-
// (a
|
|
6526
|
-
|
|
6911
|
+
// (a number read as words).
|
|
6912
|
+
//
|
|
6913
|
+
// Addresses are the exception. Expanding one here turned "quickpeek.co"
|
|
6914
|
+
// into three caption words, "Quickpeek Dot Co", which the line packer
|
|
6915
|
+
// is then free to break across lines: the call to action arrived on
|
|
6916
|
+
// screen as "DOT C O" with the name on the row above. An address is the
|
|
6917
|
+
// one thing in a demo a viewer has to be able to read and type, so it
|
|
6918
|
+
// is held back verbatim from both passes and rejoins as written. The
|
|
6919
|
+
// voice is unaffected: generate.ts derives its own spoken form from
|
|
6920
|
+
// this caption at synthesis time, and word timings fall back to an even
|
|
6921
|
+
// spread across the speech envelope when the counts no longer line up.
|
|
6922
|
+
caption: s.caption ? spokenCaption(s.caption) : s.caption
|
|
6527
6923
|
}));
|
|
6528
6924
|
aiTask.succeed(`Plan: ${plan.steps.length} steps`);
|
|
6529
6925
|
return plan;
|
|
@@ -6701,9 +7097,9 @@ var PLAN_FILE = /^plan-([a-z]{2}(?:-[A-Z]{2})?)\.json$/;
|
|
|
6701
7097
|
function findPlanFile(dir, lang) {
|
|
6702
7098
|
if (lang) {
|
|
6703
7099
|
const exact = join14(dir, `plan-${lang}.json`);
|
|
6704
|
-
return
|
|
7100
|
+
return existsSync10(exact) ? exact : null;
|
|
6705
7101
|
}
|
|
6706
|
-
if (!
|
|
7102
|
+
if (!existsSync10(dir)) return null;
|
|
6707
7103
|
const plans = readdirSync(dir).filter((f) => PLAN_FILE.test(f));
|
|
6708
7104
|
const preferred = plans.find((f) => f === "plan-en.json") ?? plans[0];
|
|
6709
7105
|
return preferred ? join14(dir, preferred) : null;
|
|
@@ -6711,10 +7107,35 @@ function findPlanFile(dir, lang) {
|
|
|
6711
7107
|
|
|
6712
7108
|
// src/commands/record-cmd.ts
|
|
6713
7109
|
init_mcp_context();
|
|
6714
|
-
import { existsSync as
|
|
6715
|
-
import { mkdir as
|
|
6716
|
-
import { basename as basename4, isAbsolute as
|
|
7110
|
+
import { existsSync as existsSync26 } from "fs";
|
|
7111
|
+
import { mkdir as mkdir10, readFile as readFile12 } from "fs/promises";
|
|
7112
|
+
import { basename as basename4, isAbsolute as isAbsolute7, join as join31, resolve as resolve11 } from "path";
|
|
6717
7113
|
import chalk17 from "chalk";
|
|
7114
|
+
|
|
7115
|
+
// src/lib/demo-registry.ts
|
|
7116
|
+
init_api_client();
|
|
7117
|
+
var TIMEOUT_MS2 = 4e3;
|
|
7118
|
+
async function registerDemoRun(email, run2) {
|
|
7119
|
+
if (!email || email === "anonymous") return false;
|
|
7120
|
+
const controller = new AbortController();
|
|
7121
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS2);
|
|
7122
|
+
try {
|
|
7123
|
+
const token = await getOrRefreshRelayToken(email);
|
|
7124
|
+
const response = await fetch(`${RELAY_BASE}/api/cli/demos`, {
|
|
7125
|
+
method: "POST",
|
|
7126
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
7127
|
+
body: JSON.stringify(run2),
|
|
7128
|
+
signal: controller.signal
|
|
7129
|
+
});
|
|
7130
|
+
return response.ok;
|
|
7131
|
+
} catch {
|
|
7132
|
+
return false;
|
|
7133
|
+
} finally {
|
|
7134
|
+
clearTimeout(timer);
|
|
7135
|
+
}
|
|
7136
|
+
}
|
|
7137
|
+
|
|
7138
|
+
// src/commands/record-cmd.ts
|
|
6718
7139
|
init_lib();
|
|
6719
7140
|
init_config_loader();
|
|
6720
7141
|
init_run_root();
|
|
@@ -6734,13 +7155,14 @@ async function handleRegistrationGate() {
|
|
|
6734
7155
|
console.log(chalk8.white(" Sign in to continue creating videos."));
|
|
6735
7156
|
console.log();
|
|
6736
7157
|
console.log(chalk8.dim(" Benefits of signing in:"));
|
|
6737
|
-
console.log(chalk8.dim(" \u2022
|
|
7158
|
+
console.log(chalk8.dim(" \u2022 10 videos a day instead of 5 in total"));
|
|
6738
7159
|
console.log(chalk8.dim(" \u2022 Keep your video history"));
|
|
6739
7160
|
console.log(chalk8.dim(" \u2022 Upgrade to Pro anytime"));
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
7161
|
+
console.log();
|
|
7162
|
+
console.log(chalk8.dim(" Type your email, or 'b' to sign in through a browser."));
|
|
7163
|
+
let email = await promptInput("Email: ");
|
|
7164
|
+
if (email.toLowerCase() === "b" || email.toLowerCase() === "browser") {
|
|
7165
|
+
email = await browserLogin() ?? "";
|
|
6744
7166
|
}
|
|
6745
7167
|
if (!email || !email.includes("@")) {
|
|
6746
7168
|
console.log(chalk8.red(" Invalid email. Run qp again to try again."));
|
|
@@ -6785,9 +7207,12 @@ async function runAuthPhase(input) {
|
|
|
6785
7207
|
const { isVerified: isVerified2 } = input;
|
|
6786
7208
|
const mcpTool = currentMcpTool();
|
|
6787
7209
|
if (mcpTool && !(userEmail && isVerified2)) {
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
|
|
7210
|
+
if (localVideoCount >= FREE_VIDEO_LIMIT) {
|
|
7211
|
+
throw new Error(
|
|
7212
|
+
`"${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.`
|
|
7213
|
+
);
|
|
7214
|
+
}
|
|
7215
|
+
return { userEmail: "anonymous", userTier: "free", localVideoCount, isVerified: false };
|
|
6791
7216
|
}
|
|
6792
7217
|
if (userEmail && isVerified2) {
|
|
6793
7218
|
const tierTask = task("Checking subscription");
|
|
@@ -6812,7 +7237,7 @@ async function runAuthPhase(input) {
|
|
|
6812
7237
|
console.log();
|
|
6813
7238
|
const input2 = await promptInput(`New email or Enter to retry (${userEmail}): `);
|
|
6814
7239
|
if (input2.toLowerCase() === "exit") {
|
|
6815
|
-
gracefulExit();
|
|
7240
|
+
gracefulExit(1);
|
|
6816
7241
|
return null;
|
|
6817
7242
|
}
|
|
6818
7243
|
if (input2?.includes("@")) {
|
|
@@ -6821,7 +7246,7 @@ async function runAuthPhase(input) {
|
|
|
6821
7246
|
const result = await registerFreeUser(newEmail);
|
|
6822
7247
|
if (!result.success) {
|
|
6823
7248
|
registerTask.fail(result.error || "Registration failed");
|
|
6824
|
-
gracefulExit();
|
|
7249
|
+
gracefulExit(1);
|
|
6825
7250
|
return null;
|
|
6826
7251
|
}
|
|
6827
7252
|
if (result.alreadyVerified) {
|
|
@@ -6836,7 +7261,7 @@ async function runAuthPhase(input) {
|
|
|
6836
7261
|
console.log(chalk9.cyan(` \u{1F4E7} Verification sent to: ${newEmail}`));
|
|
6837
7262
|
console.log(chalk9.dim(" Click the link in the email, then run qp again."));
|
|
6838
7263
|
console.log();
|
|
6839
|
-
gracefulExit();
|
|
7264
|
+
gracefulExit(1);
|
|
6840
7265
|
return null;
|
|
6841
7266
|
}
|
|
6842
7267
|
} else {
|
|
@@ -6852,7 +7277,7 @@ async function runAuthPhase(input) {
|
|
|
6852
7277
|
console.log();
|
|
6853
7278
|
console.log(chalk9.cyan(` Register/upgrade at: ${getUpgradeUrl(userEmail)}`));
|
|
6854
7279
|
console.log();
|
|
6855
|
-
gracefulExit();
|
|
7280
|
+
gracefulExit(1);
|
|
6856
7281
|
return null;
|
|
6857
7282
|
}
|
|
6858
7283
|
}
|
|
@@ -6860,7 +7285,7 @@ async function runAuthPhase(input) {
|
|
|
6860
7285
|
} else if (localVideoCount >= FREE_VIDEO_LIMIT) {
|
|
6861
7286
|
const registration = await handleRegistrationGate();
|
|
6862
7287
|
if (!registration) {
|
|
6863
|
-
gracefulExit();
|
|
7288
|
+
gracefulExit(1);
|
|
6864
7289
|
return null;
|
|
6865
7290
|
}
|
|
6866
7291
|
userEmail = registration.email;
|
|
@@ -6870,7 +7295,7 @@ async function runAuthPhase(input) {
|
|
|
6870
7295
|
localVideoCount
|
|
6871
7296
|
});
|
|
6872
7297
|
if (!registration.verified) {
|
|
6873
|
-
gracefulExit();
|
|
7298
|
+
gracefulExit(1);
|
|
6874
7299
|
return null;
|
|
6875
7300
|
}
|
|
6876
7301
|
await displayUsageStatus(userEmail, userTier, localVideoCount);
|
|
@@ -6878,8 +7303,17 @@ async function runAuthPhase(input) {
|
|
|
6878
7303
|
const remaining2 = FREE_VIDEO_LIMIT - localVideoCount;
|
|
6879
7304
|
console.log();
|
|
6880
7305
|
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
|
|
6882
|
-
|
|
7306
|
+
console.log(chalk9.dim(` ${remaining2 - 1} more after this \u2022 Sign up for 10 a day`));
|
|
7307
|
+
const enteredEmail = await setupUserEmail();
|
|
7308
|
+
if (!enteredEmail) {
|
|
7309
|
+
console.log();
|
|
7310
|
+
console.log(chalk9.yellow(" No email, so no video was recorded."));
|
|
7311
|
+
console.log(chalk9.white(" Next: run `qp login` to sign in, then run qp again."));
|
|
7312
|
+
console.log();
|
|
7313
|
+
gracefulExit(1);
|
|
7314
|
+
return null;
|
|
7315
|
+
}
|
|
7316
|
+
userEmail = enteredEmail;
|
|
6883
7317
|
let registeredVerified = false;
|
|
6884
7318
|
try {
|
|
6885
7319
|
const reg = await registerFreeUser(userEmail);
|
|
@@ -6897,7 +7331,7 @@ async function runAuthPhase(input) {
|
|
|
6897
7331
|
}
|
|
6898
7332
|
|
|
6899
7333
|
// src/commands/phases/plan-phase.ts
|
|
6900
|
-
import { existsSync as
|
|
7334
|
+
import { existsSync as existsSync21, readdirSync as readdirSync3 } from "fs";
|
|
6901
7335
|
import { readFile as readFile9 } from "fs/promises";
|
|
6902
7336
|
import { basename as basename3, join as join26 } from "path";
|
|
6903
7337
|
import chalk13 from "chalk";
|
|
@@ -7059,13 +7493,13 @@ init_config_loader();
|
|
|
7059
7493
|
|
|
7060
7494
|
// src/commands/phases/plan-cards.ts
|
|
7061
7495
|
init_render_outro();
|
|
7062
|
-
import { existsSync as
|
|
7063
|
-
import { isAbsolute as
|
|
7496
|
+
import { existsSync as existsSync20 } from "fs";
|
|
7497
|
+
import { isAbsolute as isAbsolute6, join as join25 } from "path";
|
|
7064
7498
|
import chalk12 from "chalk";
|
|
7065
7499
|
|
|
7066
7500
|
// src/lib/og-card.ts
|
|
7067
7501
|
init_image_kind();
|
|
7068
|
-
import { existsSync as
|
|
7502
|
+
import { existsSync as existsSync11, readFileSync as readFileSync6 } from "fs";
|
|
7069
7503
|
import { writeFile as writeFile9 } from "fs/promises";
|
|
7070
7504
|
import { join as join16 } from "path";
|
|
7071
7505
|
var PORTRAIT_PATHS = ["/opengraph-image/portrait", "/opengraph-image-portrait", "/og/portrait"];
|
|
@@ -7235,7 +7669,7 @@ function existingIcon(dir) {
|
|
|
7235
7669
|
for (const ext of ["png", "svg", "jpg", "webp", "gif"]) {
|
|
7236
7670
|
const path = join16(dir, `app-icon.${ext}`);
|
|
7237
7671
|
try {
|
|
7238
|
-
if (!
|
|
7672
|
+
if (!existsSync11(path)) continue;
|
|
7239
7673
|
if (!sniffImageKind(readFileSync6(path))) continue;
|
|
7240
7674
|
return path;
|
|
7241
7675
|
} catch {
|
|
@@ -7279,9 +7713,9 @@ function jpegSize(buf) {
|
|
|
7279
7713
|
}
|
|
7280
7714
|
|
|
7281
7715
|
// src/commands/phases/video-phase.ts
|
|
7282
|
-
import { existsSync as
|
|
7716
|
+
import { existsSync as existsSync19, statSync as statSync3 } from "fs";
|
|
7283
7717
|
import { copyFile as copyFile3 } from "fs/promises";
|
|
7284
|
-
import { join as join24, resolve as
|
|
7718
|
+
import { join as join24, resolve as resolve10 } from "path";
|
|
7285
7719
|
|
|
7286
7720
|
// src/browser/signed-in.ts
|
|
7287
7721
|
function pathDepth(raw) {
|
|
@@ -7387,7 +7821,7 @@ async function hasHeadroom() {
|
|
|
7387
7821
|
async function withRenderSlot(render, onWait) {
|
|
7388
7822
|
if (active >= MAX_CONCURRENT_RENDERS) {
|
|
7389
7823
|
onWait?.(`waiting for a render slot (${active}/${MAX_CONCURRENT_RENDERS} busy)`);
|
|
7390
|
-
await new Promise((
|
|
7824
|
+
await new Promise((resolve13) => waiting.push(resolve13));
|
|
7391
7825
|
}
|
|
7392
7826
|
active++;
|
|
7393
7827
|
try {
|
|
@@ -7416,20 +7850,20 @@ init_lib();
|
|
|
7416
7850
|
init_helpers();
|
|
7417
7851
|
|
|
7418
7852
|
// src/record.ts
|
|
7419
|
-
import { existsSync as
|
|
7420
|
-
import { mkdir as
|
|
7853
|
+
import { existsSync as existsSync16 } from "fs";
|
|
7854
|
+
import { mkdir as mkdir8, rm as rm3 } from "fs/promises";
|
|
7421
7855
|
import { join as join21 } from "path";
|
|
7422
7856
|
import chalk10 from "chalk";
|
|
7423
7857
|
|
|
7424
7858
|
// src/browser/actions.ts
|
|
7425
7859
|
init_lib();
|
|
7426
|
-
import { existsSync as
|
|
7860
|
+
import { existsSync as existsSync13 } from "fs";
|
|
7427
7861
|
import { readFile as readFile7 } from "fs/promises";
|
|
7428
|
-
import { isAbsolute as
|
|
7862
|
+
import { isAbsolute as isAbsolute3, resolve as resolve7 } from "path";
|
|
7429
7863
|
|
|
7430
7864
|
// src/browser/effects.ts
|
|
7431
7865
|
init_lib();
|
|
7432
|
-
import { existsSync as
|
|
7866
|
+
import { existsSync as existsSync12, readFileSync as readFileSync7 } from "fs";
|
|
7433
7867
|
import { join as join17 } from "path";
|
|
7434
7868
|
|
|
7435
7869
|
// src/browser/page-eval.ts
|
|
@@ -7449,7 +7883,7 @@ function loadHighlightCSS() {
|
|
|
7449
7883
|
// src/browser/ → src/
|
|
7450
7884
|
];
|
|
7451
7885
|
for (const p of candidates) {
|
|
7452
|
-
if (
|
|
7886
|
+
if (existsSync12(p)) return readFileSync7(p, "utf-8");
|
|
7453
7887
|
}
|
|
7454
7888
|
return "";
|
|
7455
7889
|
}
|
|
@@ -7662,6 +8096,7 @@ function locatorFor(page, keyword) {
|
|
|
7662
8096
|
var WAIT_MS = 450;
|
|
7663
8097
|
var HANDLE_MS = 3e3;
|
|
7664
8098
|
var RESOLVE_BUDGET_MS2 = 2e4;
|
|
8099
|
+
var DOM_POLL_MS = 150;
|
|
7665
8100
|
function remaining(deadline) {
|
|
7666
8101
|
return Math.max(1, deadline - Date.now());
|
|
7667
8102
|
}
|
|
@@ -7772,7 +8207,7 @@ async function resolveElementSettled(page, target, deadlineMs = RESOLVE_DEADLINE
|
|
|
7772
8207
|
}
|
|
7773
8208
|
}
|
|
7774
8209
|
async function resolvePlainText(page, text, deadline) {
|
|
7775
|
-
|
|
8210
|
+
let scanned = await domTextMatch(page, text);
|
|
7776
8211
|
const stages = plainTextStages(page, text);
|
|
7777
8212
|
while (Date.now() < deadline) {
|
|
7778
8213
|
for (const { locator, state } of stages) {
|
|
@@ -7783,6 +8218,12 @@ async function resolvePlainText(page, text, deadline) {
|
|
|
7783
8218
|
}
|
|
7784
8219
|
}
|
|
7785
8220
|
if (scanned) return scanned;
|
|
8221
|
+
while (Date.now() < deadline) {
|
|
8222
|
+
await page.waitForTimeout(Math.min(DOM_POLL_MS, remaining(deadline)));
|
|
8223
|
+
scanned = await domTextMatch(page, text);
|
|
8224
|
+
if (scanned) break;
|
|
8225
|
+
}
|
|
8226
|
+
if (!scanned) return null;
|
|
7786
8227
|
}
|
|
7787
8228
|
return scanned ?? null;
|
|
7788
8229
|
}
|
|
@@ -8207,8 +8648,8 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
|
|
|
8207
8648
|
const isPlaceholder = !rawPath || rawPath.startsWith("[") || rawPath.includes("example");
|
|
8208
8649
|
if (!isPlaceholder) {
|
|
8209
8650
|
const normalizedPath = rawPath.startsWith("mnt/") ? `/${rawPath}` : rawPath;
|
|
8210
|
-
const filePath =
|
|
8211
|
-
if (
|
|
8651
|
+
const filePath = isAbsolute3(normalizedPath) ? normalizedPath : resolve7(baseDir, normalizedPath);
|
|
8652
|
+
if (existsSync13(filePath)) {
|
|
8212
8653
|
await fileInput.setInputFiles(filePath);
|
|
8213
8654
|
await page.waitForTimeout(2e3);
|
|
8214
8655
|
break;
|
|
@@ -8324,8 +8765,8 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
|
|
|
8324
8765
|
case "wait":
|
|
8325
8766
|
break;
|
|
8326
8767
|
case "image": {
|
|
8327
|
-
const imagePath = step.value ?
|
|
8328
|
-
if (imagePath &&
|
|
8768
|
+
const imagePath = step.value ? isAbsolute3(step.value) ? step.value : resolve7(baseDir, step.value) : null;
|
|
8769
|
+
if (imagePath && existsSync13(imagePath)) {
|
|
8329
8770
|
const imgStyle = step.target === "stretch" ? "width: 100vw; height: 100vh; object-fit: cover;" : "max-width: 90%; max-height: 90%;";
|
|
8330
8771
|
if (await showImagePage(page, imagePath, imgStyle, config, cursorInjected, false)) injected = true;
|
|
8331
8772
|
}
|
|
@@ -8354,7 +8795,7 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir, pr
|
|
|
8354
8795
|
await pendingDownload;
|
|
8355
8796
|
pendingDownload = null;
|
|
8356
8797
|
}
|
|
8357
|
-
if (lastDownloadPath &&
|
|
8798
|
+
if (lastDownloadPath && existsSync13(lastDownloadPath)) {
|
|
8358
8799
|
const imgStyle = step.target === "stretch" ? "width: 100%; height: 100%; object-fit: contain;" : "max-width: 90%; max-height: 90%; object-fit: contain;";
|
|
8359
8800
|
if (await showImagePage(page, lastDownloadPath, imgStyle, config, cursorInjected)) injected = true;
|
|
8360
8801
|
}
|
|
@@ -8462,13 +8903,13 @@ async function getPageElementSelectors(page, query, limit, keyword = "") {
|
|
|
8462
8903
|
// src/browser/step-validator.ts
|
|
8463
8904
|
init_crawl();
|
|
8464
8905
|
init_utils();
|
|
8465
|
-
import { existsSync as
|
|
8466
|
-
import { basename as basename2, isAbsolute as
|
|
8906
|
+
import { existsSync as existsSync14, readdirSync as readdirSync2 } from "fs";
|
|
8907
|
+
import { basename as basename2, isAbsolute as isAbsolute4, join as join18, resolve as resolve8 } from "path";
|
|
8467
8908
|
function findMediaFile(filename, baseDir) {
|
|
8468
8909
|
const name = basename2(filename);
|
|
8469
8910
|
const searchDirs = [baseDir, join18(baseDir, "res"), join18(baseDir, "assets"), join18(baseDir, "media")];
|
|
8470
8911
|
for (const dir of searchDirs) {
|
|
8471
|
-
if (!
|
|
8912
|
+
if (!existsSync14(dir)) continue;
|
|
8472
8913
|
try {
|
|
8473
8914
|
const files = readdirSync2(dir);
|
|
8474
8915
|
if (files.includes(name)) return join18(dir, name);
|
|
@@ -8521,8 +8962,8 @@ function resolveMediaStep(step, baseDir, label, startTime) {
|
|
|
8521
8962
|
if (!step.value) {
|
|
8522
8963
|
return { success: false, error: `Missing ${label} path`, executionTimeMs: Date.now() - startTime };
|
|
8523
8964
|
}
|
|
8524
|
-
const specifiedPath =
|
|
8525
|
-
if (
|
|
8965
|
+
const specifiedPath = isAbsolute4(step.value) ? step.value : resolve8(baseDir, step.value);
|
|
8966
|
+
if (existsSync14(specifiedPath)) return { success: true, executionTimeMs: Date.now() - startTime };
|
|
8526
8967
|
const foundPath = findMediaFile(step.value, baseDir);
|
|
8527
8968
|
if (foundPath) {
|
|
8528
8969
|
step.value = `./${foundPath.replace(`${baseDir}/`, "").replace(`${baseDir}\\`, "")}`;
|
|
@@ -8650,8 +9091,8 @@ async function validateAndExecuteStep(page, step, plan, baseDir) {
|
|
|
8650
9091
|
const isPlaceholder = !rawPath || rawPath.startsWith("[") || rawPath.includes("example");
|
|
8651
9092
|
if (!isPlaceholder) {
|
|
8652
9093
|
const normalizedPath = rawPath.startsWith("mnt/") ? `/${rawPath}` : rawPath;
|
|
8653
|
-
let filePath =
|
|
8654
|
-
if (!
|
|
9094
|
+
let filePath = isAbsolute4(normalizedPath) ? normalizedPath : resolve8(baseDir, normalizedPath);
|
|
9095
|
+
if (!existsSync14(filePath)) {
|
|
8655
9096
|
const foundPath = findMediaFile(rawPath, baseDir);
|
|
8656
9097
|
if (foundPath) {
|
|
8657
9098
|
const relativePath = `./${foundPath.replace(`${baseDir}/`, "").replace(`${baseDir}\\`, "")}`;
|
|
@@ -8793,7 +9234,7 @@ async function validateStepsOnPage(plan, config, logError2, baseDir, onStepStart
|
|
|
8793
9234
|
}
|
|
8794
9235
|
|
|
8795
9236
|
// src/browser/meta.ts
|
|
8796
|
-
import { existsSync as
|
|
9237
|
+
import { existsSync as existsSync15 } from "fs";
|
|
8797
9238
|
import { readFile as readFile8, writeFile as writeFile10 } from "fs/promises";
|
|
8798
9239
|
import { join as join20 } from "path";
|
|
8799
9240
|
var RECORDING_FILE = "recording.mp4";
|
|
@@ -8803,7 +9244,7 @@ function getRecordingPath(outputDir) {
|
|
|
8803
9244
|
}
|
|
8804
9245
|
async function loadRecordingMeta(outputDir) {
|
|
8805
9246
|
const metaPath = join20(outputDir, RECORDING_META_FILE);
|
|
8806
|
-
if (!
|
|
9247
|
+
if (!existsSync15(metaPath)) return null;
|
|
8807
9248
|
try {
|
|
8808
9249
|
const content = await readFile8(metaPath, "utf-8");
|
|
8809
9250
|
return JSON.parse(content);
|
|
@@ -8910,7 +9351,7 @@ async function leavesApp(el, step, plannedUrl) {
|
|
|
8910
9351
|
}
|
|
8911
9352
|
async function recordSession(plan, config, stepDurations, workDir, baseDir, task2, logError2, opts = {}) {
|
|
8912
9353
|
const videoDir = join21(workDir, "video");
|
|
8913
|
-
await
|
|
9354
|
+
await mkdir8(videoDir, { recursive: true });
|
|
8914
9355
|
const getStepDuration = (stepNum) => {
|
|
8915
9356
|
return stepDurations[stepNum - 1] ?? config.timing.minStepDuration;
|
|
8916
9357
|
};
|
|
@@ -8921,11 +9362,33 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
|
|
|
8921
9362
|
const authState = findAuthState(plan.url);
|
|
8922
9363
|
const { context, browser, chromeSession } = await launchBrowserContext(config, userDataDir, recordVideo, authState);
|
|
8923
9364
|
try {
|
|
9365
|
+
const isExtensionPage = plan.url.startsWith("chrome-extension://");
|
|
9366
|
+
if (!isExtensionPage) {
|
|
9367
|
+
const warm = await context.newPage();
|
|
9368
|
+
const warmVideo = warm.video();
|
|
9369
|
+
const warmUrls = [plan.url];
|
|
9370
|
+
for (const step of plan.steps) {
|
|
9371
|
+
if (step.action !== "navigate" || !step.target) continue;
|
|
9372
|
+
if (!warmUrls.includes(step.target)) warmUrls.push(step.target);
|
|
9373
|
+
}
|
|
9374
|
+
try {
|
|
9375
|
+
for (const url of warmUrls) {
|
|
9376
|
+
await gotoSettled(warm, url).catch(() => {
|
|
9377
|
+
});
|
|
9378
|
+
}
|
|
9379
|
+
} catch {
|
|
9380
|
+
} finally {
|
|
9381
|
+
await warm.close().catch(() => {
|
|
9382
|
+
});
|
|
9383
|
+
const stray = await warmVideo?.path().catch(() => void 0);
|
|
9384
|
+
if (stray) await rm3(stray, { force: true }).catch(() => {
|
|
9385
|
+
});
|
|
9386
|
+
}
|
|
9387
|
+
}
|
|
8924
9388
|
const page = context.pages()[0] || await context.newPage();
|
|
8925
9389
|
setupDownloadTracking(page);
|
|
8926
9390
|
context.on("page", (p) => setupDownloadTracking(p));
|
|
8927
9391
|
const firstStepIsMedia = ["media", "image", "video"].includes(plan.steps[0]?.action ?? "");
|
|
8928
|
-
const isExtensionPage = plan.url.startsWith("chrome-extension://");
|
|
8929
9392
|
let signInChecked = false;
|
|
8930
9393
|
const guardSignedIn = async () => {
|
|
8931
9394
|
if (signInChecked || opts.allowLoggedOut || isExtensionPage) return;
|
|
@@ -9002,7 +9465,7 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
|
|
|
9002
9465
|
if (!targetEl) {
|
|
9003
9466
|
console.log(
|
|
9004
9467
|
`
|
|
9005
|
-
\u26A0 target "${step.target}" not found on ${page.url()}
|
|
9468
|
+
\u26A0 target "${step.target}" not found on ${page.url()}; step ${stepNum} recorded without the action`
|
|
9006
9469
|
);
|
|
9007
9470
|
}
|
|
9008
9471
|
}
|
|
@@ -9141,7 +9604,7 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
|
|
|
9141
9604
|
if (!pageClosedEarly) await page.close();
|
|
9142
9605
|
const videoPath = await video.path();
|
|
9143
9606
|
await cleanupBrowserSession({ context, browser, chromeSession });
|
|
9144
|
-
if (!videoPath || !
|
|
9607
|
+
if (!videoPath || !existsSync16(videoPath)) {
|
|
9145
9608
|
throw new Error("Video file was not created");
|
|
9146
9609
|
}
|
|
9147
9610
|
saveTask.succeed("Video saved");
|
|
@@ -9156,20 +9619,20 @@ init_run_root();
|
|
|
9156
9619
|
|
|
9157
9620
|
// src/utils/video-pipeline.ts
|
|
9158
9621
|
init_lib();
|
|
9159
|
-
import { existsSync as
|
|
9160
|
-
import { mkdir as
|
|
9161
|
-
import { isAbsolute as
|
|
9622
|
+
import { existsSync as existsSync18 } from "fs";
|
|
9623
|
+
import { mkdir as mkdir9, writeFile as writeFile11 } from "fs/promises";
|
|
9624
|
+
import { isAbsolute as isAbsolute5, join as join23, resolve as resolve9 } from "path";
|
|
9162
9625
|
import chalk11 from "chalk";
|
|
9163
9626
|
|
|
9164
9627
|
// src/utils/music.ts
|
|
9165
|
-
import { existsSync as
|
|
9628
|
+
import { existsSync as existsSync17, readFileSync as readFileSync8 } from "fs";
|
|
9166
9629
|
import { dirname as dirname4, join as join22 } from "path";
|
|
9167
|
-
var DEFAULT_MOOD = "
|
|
9630
|
+
var DEFAULT_MOOD = "corporate";
|
|
9168
9631
|
function musicDir() {
|
|
9169
9632
|
let dir = getScriptDir();
|
|
9170
9633
|
for (let i = 0; i < 6; i++) {
|
|
9171
9634
|
const candidate = join22(dir, "assets", "music");
|
|
9172
|
-
if (
|
|
9635
|
+
if (existsSync17(join22(candidate, "manifest.json"))) return candidate;
|
|
9173
9636
|
const parent = dirname4(dir);
|
|
9174
9637
|
if (parent === dir) break;
|
|
9175
9638
|
dir = parent;
|
|
@@ -9182,7 +9645,7 @@ function listBundledMusic() {
|
|
|
9182
9645
|
try {
|
|
9183
9646
|
const parsed = JSON.parse(readFileSync8(join22(dir, "manifest.json"), "utf8"));
|
|
9184
9647
|
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) =>
|
|
9648
|
+
return tracks.filter((t) => typeof t?.file === "string").map((t) => ({ ...t, path: join22(dir, t.file) })).filter((t) => existsSync17(t.path));
|
|
9186
9649
|
} catch {
|
|
9187
9650
|
return [];
|
|
9188
9651
|
}
|
|
@@ -9196,6 +9659,16 @@ function resolveBundledBed(mood = DEFAULT_MOOD) {
|
|
|
9196
9659
|
// src/utils/video-pipeline.ts
|
|
9197
9660
|
init_run_root();
|
|
9198
9661
|
var INDENT3 = " ";
|
|
9662
|
+
function captionWordTimings(caption, spoken, duration) {
|
|
9663
|
+
const captionWords = caption.split(/\s+/).filter(Boolean);
|
|
9664
|
+
if (spoken.length === 0 || captionWords.length === 0) {
|
|
9665
|
+
return estimateWordTimings(caption, duration);
|
|
9666
|
+
}
|
|
9667
|
+
if (spoken.length === captionWords.length) {
|
|
9668
|
+
return spoken.map((w, i) => ({ ...w, word: captionWords[i] ?? w.word }));
|
|
9669
|
+
}
|
|
9670
|
+
return estimateWordTimings(caption, duration);
|
|
9671
|
+
}
|
|
9199
9672
|
async function generateASS(plan, stepAudio, workDir, config, captions) {
|
|
9200
9673
|
const cues = [];
|
|
9201
9674
|
let currentTime = VOICE_LEAD_MS;
|
|
@@ -9206,7 +9679,7 @@ async function generateASS(plan, stepAudio, workDir, config, captions) {
|
|
|
9206
9679
|
currentTime += config.timing.minStepDuration;
|
|
9207
9680
|
continue;
|
|
9208
9681
|
}
|
|
9209
|
-
const words =
|
|
9682
|
+
const words = captionWordTimings(step.caption || "", audio.words, audio.duration);
|
|
9210
9683
|
if (words.length > 0 && !step.spokenOnly) {
|
|
9211
9684
|
const offset = currentTime;
|
|
9212
9685
|
cues.push({
|
|
@@ -9226,7 +9699,7 @@ async function generateASS(plan, stepAudio, workDir, config, captions) {
|
|
|
9226
9699
|
return assPath;
|
|
9227
9700
|
}
|
|
9228
9701
|
var STILL_EXTENSIONS = ["png", "jpg", "jpeg", "webp"];
|
|
9229
|
-
function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
|
|
9702
|
+
function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0, totalSecs, blackSpans) {
|
|
9230
9703
|
const overlays = [];
|
|
9231
9704
|
let cumulativeTime = leadInSecs;
|
|
9232
9705
|
for (let i = 0; i < steps.length; i++) {
|
|
@@ -9239,13 +9712,20 @@ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
|
|
|
9239
9712
|
const ext = step.value.split(".").pop()?.toLowerCase() || "";
|
|
9240
9713
|
const still = isCard && STILL_EXTENSIONS.includes(ext);
|
|
9241
9714
|
if (still || isClip && ["mp4", "webm", "mov"].includes(ext)) {
|
|
9242
|
-
const mediaPath =
|
|
9243
|
-
if (
|
|
9715
|
+
const mediaPath = isAbsolute5(step.value) ? step.value : resolve9(baseDir, step.value);
|
|
9716
|
+
if (existsSync18(mediaPath) && durationSec > 0) {
|
|
9244
9717
|
const partial = parsePartial(step.partial);
|
|
9718
|
+
const runsToEnd = still && i === steps.length - 1 && totalSecs !== void 0;
|
|
9719
|
+
let duration = runsToEnd ? Math.max(durationSec, totalSecs - cumulativeTime) : durationSec;
|
|
9720
|
+
if (still && !runsToEnd && blackSpans) {
|
|
9721
|
+
const endsAt = cumulativeTime + duration;
|
|
9722
|
+
const following = blackSpans.find((sp) => sp.start <= endsAt + 0.25 && sp.end > endsAt);
|
|
9723
|
+
if (following) duration = following.end - cumulativeTime;
|
|
9724
|
+
}
|
|
9245
9725
|
overlays.push({
|
|
9246
9726
|
path: mediaPath,
|
|
9247
9727
|
startTime: cumulativeTime,
|
|
9248
|
-
duration
|
|
9728
|
+
duration,
|
|
9249
9729
|
mode: step.target === "stretch" ? "stretch" : step.target === "cover" ? "cover" : "center",
|
|
9250
9730
|
...still ? { still: true } : {},
|
|
9251
9731
|
...partial.trimStart !== void 0 ? { trimStart: partial.trimStart } : {},
|
|
@@ -9258,7 +9738,7 @@ function buildVideoOverlays(steps, stepDurations, baseDir, leadInSecs = 0) {
|
|
|
9258
9738
|
}
|
|
9259
9739
|
return overlays;
|
|
9260
9740
|
}
|
|
9261
|
-
async function composeAndFinish(videoPath, audioPath, assPath, config, userTier = "free", mainContentDuration, trimStart = 0.5, stepTransitions, hasClipAudio = false, endsOnCreditsCard = false, _stepSync) {
|
|
9741
|
+
async function composeAndFinish(videoPath, audioPath, assPath, config, userTier = "free", mainContentDuration, trimStart = 0.5, stepTransitions, hasClipAudio = false, endsOnCreditsCard = false, _stepSync, musicStartSecs = 0) {
|
|
9262
9742
|
header("Compose");
|
|
9263
9743
|
const configPath = join23(runRoot(), CONFIG_FILE);
|
|
9264
9744
|
console.log(`${INDENT3}${chalk11.dim(`Config: ${formatClickablePath(configPath)}`)}`);
|
|
@@ -9270,13 +9750,13 @@ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier
|
|
|
9270
9750
|
process.exit(0);
|
|
9271
9751
|
}
|
|
9272
9752
|
const composeTask = task("Building final video");
|
|
9273
|
-
await
|
|
9753
|
+
await mkdir9(config.output.dir, { recursive: true });
|
|
9274
9754
|
const suffix = config.lang ? `-${config.lang}` : "";
|
|
9275
9755
|
const finalPath = join23(config.output.dir, `demo${suffix}.${config.video.format}`);
|
|
9276
9756
|
const videoDuration = await getMediaDuration(videoPath) / 1e3;
|
|
9277
|
-
const hasVoiceover = audioPath &&
|
|
9278
|
-
const musicPath = config.music?.path ?
|
|
9279
|
-
let hasMusic = musicPath &&
|
|
9757
|
+
const hasVoiceover = audioPath && existsSync18(audioPath);
|
|
9758
|
+
const musicPath = config.music?.path ? isAbsolute5(config.music.path) ? config.music.path : resolve9(config.output.dir, config.music.path) : "";
|
|
9759
|
+
let hasMusic = musicPath && existsSync18(musicPath);
|
|
9280
9760
|
let bedPath = "";
|
|
9281
9761
|
let bedCredit = "";
|
|
9282
9762
|
if (!hasMusic && config.audio?.ambience !== false) {
|
|
@@ -9328,7 +9808,14 @@ async function composeAndFinish(videoPath, audioPath, assPath, config, userTier
|
|
|
9328
9808
|
hasClipAudio,
|
|
9329
9809
|
mainContentDuration,
|
|
9330
9810
|
stepTransitions,
|
|
9331
|
-
hwaccel: config.video.hwaccel
|
|
9811
|
+
hwaccel: config.video.hwaccel,
|
|
9812
|
+
// The tempo pass runs after this and speeds the whole file up, music
|
|
9813
|
+
// included. Told about it, compose lays the bed down pre-slowed so the
|
|
9814
|
+
// song comes out at its own speed.
|
|
9815
|
+
tempo: config.video.tempo,
|
|
9816
|
+
musicStartSecs,
|
|
9817
|
+
// So the bed can be landed with its last note on the last frame.
|
|
9818
|
+
musicDurationSecs: hasMusic ? await getMediaDuration(backingPath).then((ms) => ms / 1e3).catch(() => 0) : 0
|
|
9332
9819
|
});
|
|
9333
9820
|
composeTask.succeed("Video composed");
|
|
9334
9821
|
return finalPath;
|
|
@@ -9390,18 +9877,18 @@ function resolveOutro(config, userTier, userEmail) {
|
|
|
9390
9877
|
if (!usesQuickPeekOutro(userTier, userEmail)) {
|
|
9391
9878
|
const custom = config.video.outro?.trim();
|
|
9392
9879
|
if (!custom) return void 0;
|
|
9393
|
-
const path =
|
|
9394
|
-
if (
|
|
9880
|
+
const path = resolve10(custom);
|
|
9881
|
+
if (existsSync19(path)) return path;
|
|
9395
9882
|
info(`${INDENT4}\u26A0 outro not found: ${path}`);
|
|
9396
9883
|
return void 0;
|
|
9397
9884
|
}
|
|
9398
9885
|
const scriptDir = getScriptDir();
|
|
9399
9886
|
const bundled = join24(scriptDir, "assets", "qp.wm");
|
|
9400
|
-
if (
|
|
9887
|
+
if (existsSync19(bundled)) return bundled;
|
|
9401
9888
|
const fallback = join24(scriptDir, "..", "dist", "assets", "qp.wm");
|
|
9402
|
-
return
|
|
9889
|
+
return existsSync19(fallback) ? fallback : void 0;
|
|
9403
9890
|
}
|
|
9404
|
-
var EMPTY_MIN_SECS = 0.
|
|
9891
|
+
var EMPTY_MIN_SECS = 0.4;
|
|
9405
9892
|
var OUTRO_TAIL_MS = 900;
|
|
9406
9893
|
var FIT_SECOND_PASS_CUSHION_SECS = 1;
|
|
9407
9894
|
var FIT_SECOND_PASS_MAX_SPEEDUP = 1.45;
|
|
@@ -9426,11 +9913,12 @@ async function renderVideoPhase(params) {
|
|
|
9426
9913
|
})
|
|
9427
9914
|
);
|
|
9428
9915
|
const recordingPath = getRecordingPath(config.output.dir);
|
|
9429
|
-
const hasExistingRecording = existingRecordingMeta &&
|
|
9916
|
+
const hasExistingRecording = existingRecordingMeta && existsSync19(recordingPath);
|
|
9430
9917
|
let videoPath = recordingPath;
|
|
9431
9918
|
const firstStep = plan.steps[0];
|
|
9432
9919
|
const firstStepIsVideo = firstStep && (firstStep.action === "video" || firstStep.action === "media");
|
|
9433
9920
|
let trimStart = 0;
|
|
9921
|
+
let recordedStepLengths = null;
|
|
9434
9922
|
const doRecord = async () => {
|
|
9435
9923
|
header("Record");
|
|
9436
9924
|
const result = await recordSession(plan, config, stepDurations, workDir, config.output.dir, task, logError, {
|
|
@@ -9442,6 +9930,7 @@ async function renderVideoPhase(params) {
|
|
|
9442
9930
|
]);
|
|
9443
9931
|
const detectedEmpty = mergeSpans([...result.leadSpans ?? [], ...emptyBlack, ...emptyWhite]);
|
|
9444
9932
|
let stepLengths = result.actualStepDurations;
|
|
9933
|
+
let zeroAnchored = false;
|
|
9445
9934
|
if (detectedEmpty.length > 0) {
|
|
9446
9935
|
const bounds = [];
|
|
9447
9936
|
let at = 0;
|
|
@@ -9466,6 +9955,7 @@ async function renderVideoPhase(params) {
|
|
|
9466
9955
|
const sum = result.actualStepDurations.reduce((a, b2) => a + b2, 0);
|
|
9467
9956
|
const factor = sum > 0 && realSecs > 0 ? realSecs * 1e3 / sum : 1;
|
|
9468
9957
|
stepLengths = result.actualStepDurations.map((ms) => ms * factor);
|
|
9958
|
+
zeroAnchored = true;
|
|
9469
9959
|
info(`Sped up ${emptySecs.toFixed(1)}s of loading screens at ${EMPTY_SPAN_SPEED}x`);
|
|
9470
9960
|
} else {
|
|
9471
9961
|
await discardPartial(retimed);
|
|
@@ -9482,16 +9972,19 @@ async function renderVideoPhase(params) {
|
|
|
9482
9972
|
const meta = buildRecordingMeta(plan, config, stepDurations);
|
|
9483
9973
|
meta.actualStepDurations = stepLengths;
|
|
9484
9974
|
meta.pageLoadTime = result.pageLoadTime;
|
|
9975
|
+
meta.zeroAnchored = zeroAnchored;
|
|
9485
9976
|
await saveRecordingMeta(config.output.dir, meta);
|
|
9486
9977
|
videoPath = recordingPath;
|
|
9487
|
-
|
|
9978
|
+
recordedStepLengths = stepLengths;
|
|
9979
|
+
trimStart = firstStepIsVideo || zeroAnchored ? 0 : result.pageLoadTime;
|
|
9488
9980
|
};
|
|
9489
9981
|
if (hasExistingRecording) {
|
|
9490
9982
|
const reuseCheck = canReuseRecording(plan, config, stepDurations, existingRecordingMeta);
|
|
9491
9983
|
if (reuseCheck.canReuse) {
|
|
9492
9984
|
info("Reusing existing recording");
|
|
9493
9985
|
videoPath = recordingPath;
|
|
9494
|
-
|
|
9986
|
+
recordedStepLengths = existingRecordingMeta.actualStepDurations ?? existingRecordingMeta.stepDurations;
|
|
9987
|
+
trimStart = firstStepIsVideo || existingRecordingMeta.zeroAnchored ? 0 : existingRecordingMeta?.pageLoadTime ?? 0.5;
|
|
9495
9988
|
stepAudio = await alignVoiceoverToRecording(
|
|
9496
9989
|
stepAudio,
|
|
9497
9990
|
existingRecordingMeta.actualStepDurations ?? existingRecordingMeta.stepDurations,
|
|
@@ -9505,9 +9998,23 @@ async function renderVideoPhase(params) {
|
|
|
9505
9998
|
} else {
|
|
9506
9999
|
await doRecord();
|
|
9507
10000
|
}
|
|
9508
|
-
const alignedStepDurations = plan.steps.map(
|
|
10001
|
+
const alignedStepDurations = plan.steps.map(
|
|
10002
|
+
(step, i) => Math.max(
|
|
10003
|
+
stepAudio.get(i + 1)?.duration ?? recordedStepLengths?.[i] ?? stepDurations[i] ?? 0,
|
|
10004
|
+
step.duration ? step.duration * 1e3 : 0
|
|
10005
|
+
)
|
|
10006
|
+
);
|
|
9509
10007
|
let splicedClipAudio = false;
|
|
9510
|
-
const
|
|
10008
|
+
const recordingSecs = await getMediaDuration(videoPath).then((ms) => ms / 1e3).catch(() => void 0);
|
|
10009
|
+
const blackSpans = await detectBlackSpans(videoPath, 0.3).catch(() => []);
|
|
10010
|
+
const videoOverlays = buildVideoOverlays(
|
|
10011
|
+
plan.steps,
|
|
10012
|
+
alignedStepDurations,
|
|
10013
|
+
config.output.dir,
|
|
10014
|
+
trimStart,
|
|
10015
|
+
recordingSecs,
|
|
10016
|
+
blackSpans
|
|
10017
|
+
);
|
|
9511
10018
|
if (videoOverlays.length > 0) {
|
|
9512
10019
|
const spliceTask = task("Painting cards and clips into recording");
|
|
9513
10020
|
try {
|
|
@@ -9561,11 +10068,13 @@ async function renderVideoPhase(params) {
|
|
|
9561
10068
|
const cardStepSeconds = (index) => {
|
|
9562
10069
|
const action = plan.steps[index]?.action;
|
|
9563
10070
|
if (action !== "image" && action !== "media") return 0;
|
|
9564
|
-
|
|
10071
|
+
const audioSecs = (stepAudio.get(index + 1)?.duration ?? 0) / 1e3;
|
|
10072
|
+
const tail = index === plan.steps.length - 1 && action === "image" ? OUTRO_TAIL_MS / 1e3 : 0;
|
|
10073
|
+
return Math.max(audioSecs, plan.steps[index]?.duration ?? 0) + tail;
|
|
9565
10074
|
};
|
|
9566
10075
|
const introSecs = cardStepSeconds(0);
|
|
9567
10076
|
const outroStepSecs = cardStepSeconds(plan.steps.length - 1);
|
|
9568
|
-
const outroSeconds = config.video.outroCard?.seconds ??
|
|
10077
|
+
const outroSeconds = Math.max(config.video.outroCard?.seconds ?? 3.5, outroStepSecs);
|
|
9569
10078
|
const card = config.video.outroCard;
|
|
9570
10079
|
if (card?.subject?.name) {
|
|
9571
10080
|
const cardTask = task("Rendering credits card");
|
|
@@ -9609,6 +10118,20 @@ async function renderVideoPhase(params) {
|
|
|
9609
10118
|
mainContentDuration = void 0;
|
|
9610
10119
|
}
|
|
9611
10120
|
}
|
|
10121
|
+
const outroOnly = config.music?.outroOnly !== false;
|
|
10122
|
+
let musicStartSecs = 0;
|
|
10123
|
+
if (outroOnly) {
|
|
10124
|
+
let trailingCards = 0;
|
|
10125
|
+
for (let i = plan.steps.length - 1; i >= 0; i--) {
|
|
10126
|
+
const action = plan.steps[i]?.action;
|
|
10127
|
+
if (action !== "image" && action !== "media") break;
|
|
10128
|
+
trailingCards++;
|
|
10129
|
+
}
|
|
10130
|
+
if (trailingCards > 0 && trailingCards < plan.steps.length) {
|
|
10131
|
+
const upTo = plan.steps.length - trailingCards;
|
|
10132
|
+
musicStartSecs = alignedStepDurations.slice(0, upTo).reduce((a, b) => a + b, 0) / 1e3;
|
|
10133
|
+
}
|
|
10134
|
+
}
|
|
9612
10135
|
let finalPath = await composeAndFinish(
|
|
9613
10136
|
videoToCompose,
|
|
9614
10137
|
voiceoverPath,
|
|
@@ -9620,7 +10143,9 @@ async function renderVideoPhase(params) {
|
|
|
9620
10143
|
stepTransitions,
|
|
9621
10144
|
// The splice writes a clip-audio track only when a spliced clip had one.
|
|
9622
10145
|
splicedClipAudio,
|
|
9623
|
-
endsOnCreditsCard
|
|
10146
|
+
endsOnCreditsCard,
|
|
10147
|
+
void 0,
|
|
10148
|
+
musicStartSecs
|
|
9624
10149
|
);
|
|
9625
10150
|
const maxSilence = config.video.maxSilenceSecs ?? 0.6;
|
|
9626
10151
|
if (maxSilence > 0) {
|
|
@@ -9785,7 +10310,7 @@ async function discardPartial(path) {
|
|
|
9785
10310
|
}
|
|
9786
10311
|
var MIN_ARTIFACT_BYTES = 1024;
|
|
9787
10312
|
function assertArtifact(finalPath) {
|
|
9788
|
-
const size =
|
|
10313
|
+
const size = existsSync19(finalPath) ? statSync3(finalPath).size : 0;
|
|
9789
10314
|
if (size >= MIN_ARTIFACT_BYTES) return;
|
|
9790
10315
|
throw new Error(
|
|
9791
10316
|
`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 +10364,7 @@ async function useOgCardAsIntro(plan, url, config, fresh = false) {
|
|
|
9839
10364
|
const rewritable = first.action === "wait" && !first.target;
|
|
9840
10365
|
const portrait = (config.video.height ?? 0) > (config.video.width ?? 0);
|
|
9841
10366
|
const existing = join25(config.output.dir, "og-card.png");
|
|
9842
|
-
const fetched = !fresh &&
|
|
10367
|
+
const fetched = !fresh && existsSync20(existing) ? existing : await fetchOgCard(url, config.output.dir, portrait);
|
|
9843
10368
|
const ogPath = fetched && coversFrame(fetched, config) ? fetched : null;
|
|
9844
10369
|
const cardPath = ogPath ?? await renderGeneratedIntroCard(url, config);
|
|
9845
10370
|
if (!cardPath) {
|
|
@@ -9929,30 +10454,20 @@ function fitFor(cardPath, config) {
|
|
|
9929
10454
|
return Math.abs(size.width / size.height - frame) < 0.06 ? "stretch" : "cover";
|
|
9930
10455
|
}
|
|
9931
10456
|
function resolveCardPath(value, config) {
|
|
9932
|
-
return
|
|
10457
|
+
return isAbsolute6(value) ? value : join25(config.output.dir, value.replace(/^\.\//, ""));
|
|
9933
10458
|
}
|
|
9934
10459
|
var DEFAULT_INTRO_CARD_SECONDS = 2.5;
|
|
9935
10460
|
function packageAsset(relative) {
|
|
9936
10461
|
let dir = getScriptDir();
|
|
9937
10462
|
for (let up = 0; up < 6; up++) {
|
|
9938
10463
|
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;
|
|
10464
|
+
if (existsSync20(candidate)) return candidate;
|
|
9949
10465
|
dir = join25(dir, "..");
|
|
9950
10466
|
}
|
|
9951
10467
|
return void 0;
|
|
9952
10468
|
}
|
|
9953
10469
|
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") }
|
|
10470
|
+
{ name: "QuickPeek", role: "Video generated by", icon: () => packageAsset("imgs/qp.png") }
|
|
9956
10471
|
];
|
|
9957
10472
|
function oneTagline(text) {
|
|
9958
10473
|
if (!text) return void 0;
|
|
@@ -10044,7 +10559,7 @@ async function runPlanPhase(params) {
|
|
|
10044
10559
|
const extensionPath = config.browser?.extension;
|
|
10045
10560
|
let existingPlan = null;
|
|
10046
10561
|
let existingPlanPath = null;
|
|
10047
|
-
if (
|
|
10562
|
+
if (existsSync21(planPath)) {
|
|
10048
10563
|
try {
|
|
10049
10564
|
const parsed = JSON.parse(await readFile9(planPath, "utf-8"));
|
|
10050
10565
|
if (parsed.url && parsed.steps?.length > 0) {
|
|
@@ -10074,7 +10589,22 @@ async function runPlanPhase(params) {
|
|
|
10074
10589
|
}
|
|
10075
10590
|
}
|
|
10076
10591
|
const normalizeUrl2 = (u) => u.replace(/\/+$/, "").toLowerCase();
|
|
10077
|
-
const
|
|
10592
|
+
const isBareOrigin = (u) => {
|
|
10593
|
+
try {
|
|
10594
|
+
const parsed = new URL(u);
|
|
10595
|
+
return parsed.pathname === "/" && !parsed.search && !parsed.hash;
|
|
10596
|
+
} catch {
|
|
10597
|
+
return false;
|
|
10598
|
+
}
|
|
10599
|
+
};
|
|
10600
|
+
const sameOrigin = (a, b) => {
|
|
10601
|
+
try {
|
|
10602
|
+
return new URL(a).origin === new URL(b).origin;
|
|
10603
|
+
} catch {
|
|
10604
|
+
return false;
|
|
10605
|
+
}
|
|
10606
|
+
};
|
|
10607
|
+
const urlMatches = existingPlan && (normalizeUrl2(existingPlan.url) === normalizeUrl2(url) || isBareOrigin(existingPlan.url) && sameOrigin(existingPlan.url, url));
|
|
10078
10608
|
if (existingPlan && !urlMatches && isAutoYes()) {
|
|
10079
10609
|
console.log();
|
|
10080
10610
|
console.log(chalk13.dim(` Existing plan is for ${existingPlan.url}, not ${url}, regenerating.`));
|
|
@@ -10218,7 +10748,7 @@ async function runPlanPhase(params) {
|
|
|
10218
10748
|
const planLang = plan.lang || "en";
|
|
10219
10749
|
if (planLang !== targetLang) {
|
|
10220
10750
|
let useExistingTranslation = false;
|
|
10221
|
-
if (
|
|
10751
|
+
if (existsSync21(planPath) && existingPlanPath !== planPath) {
|
|
10222
10752
|
try {
|
|
10223
10753
|
const translatedPlan = JSON.parse(await readFile9(planPath, "utf-8"));
|
|
10224
10754
|
if (translatedPlan.steps?.length === plan.steps.length && translatedPlan.lang === targetLang) {
|
|
@@ -10397,7 +10927,7 @@ async function runValidationPhase(params) {
|
|
|
10397
10927
|
|
|
10398
10928
|
// src/commands/phases/voiceover-phase.ts
|
|
10399
10929
|
init_lib();
|
|
10400
|
-
import { existsSync as
|
|
10930
|
+
import { existsSync as existsSync23 } from "fs";
|
|
10401
10931
|
import { join as join28 } from "path";
|
|
10402
10932
|
import chalk15 from "chalk";
|
|
10403
10933
|
var INDENT6 = " ";
|
|
@@ -10458,14 +10988,14 @@ async function clearCachedFiles(outputDir) {
|
|
|
10458
10988
|
const { unlink: unlink5 } = await import("fs/promises");
|
|
10459
10989
|
for (const file of CACHED_FILES) {
|
|
10460
10990
|
const filePath = join28(outputDir, file);
|
|
10461
|
-
if (
|
|
10991
|
+
if (existsSync23(filePath)) await unlink5(filePath).catch(() => {
|
|
10462
10992
|
});
|
|
10463
10993
|
}
|
|
10464
10994
|
info("Cleared cached files (--fresh)");
|
|
10465
10995
|
}
|
|
10466
10996
|
async function usableVoiceover(outputDir, meta) {
|
|
10467
10997
|
const voiceoverFile = join28(outputDir, "voiceover.wav");
|
|
10468
|
-
if (!meta || !
|
|
10998
|
+
if (!meta || !existsSync23(voiceoverFile)) return null;
|
|
10469
10999
|
if (await isHonestWav(voiceoverFile)) return meta;
|
|
10470
11000
|
info("Existing voiceover has a stale container, regenerating");
|
|
10471
11001
|
return null;
|
|
@@ -10543,7 +11073,7 @@ async function ensureCliSignIn(url, opts) {
|
|
|
10543
11073
|
}
|
|
10544
11074
|
|
|
10545
11075
|
// src/commands/utils/chrome-extension.ts
|
|
10546
|
-
import { existsSync as
|
|
11076
|
+
import { existsSync as existsSync24, readdirSync as readdirSync5, readFileSync as readFileSync9 } from "fs";
|
|
10547
11077
|
import { homedir as homedir4 } from "os";
|
|
10548
11078
|
import { join as join29 } from "path";
|
|
10549
11079
|
function resolveChromeExtension(extensionId) {
|
|
@@ -10579,7 +11109,7 @@ function resolveChromeExtension(extensionId) {
|
|
|
10579
11109
|
}
|
|
10580
11110
|
for (const chromeExtDir of possibleDirs) {
|
|
10581
11111
|
const extDir = join29(chromeExtDir, extensionId);
|
|
10582
|
-
if (!
|
|
11112
|
+
if (!existsSync24(extDir)) continue;
|
|
10583
11113
|
try {
|
|
10584
11114
|
const versions = readdirSync5(extDir).filter((f) => !f.startsWith("."));
|
|
10585
11115
|
if (versions.length === 0) continue;
|
|
@@ -10588,7 +11118,7 @@ function resolveChromeExtension(extensionId) {
|
|
|
10588
11118
|
let name = extensionId;
|
|
10589
11119
|
let popupPath = "popup.html";
|
|
10590
11120
|
const manifestPath = join29(extPath, "manifest.json");
|
|
10591
|
-
if (
|
|
11121
|
+
if (existsSync24(manifestPath)) {
|
|
10592
11122
|
try {
|
|
10593
11123
|
const manifest = JSON.parse(readFileSync9(manifestPath, "utf-8"));
|
|
10594
11124
|
if (manifest.name && !manifest.name.startsWith("__MSG_")) {
|
|
@@ -10611,7 +11141,7 @@ function resolveChromeExtension(extensionId) {
|
|
|
10611
11141
|
|
|
10612
11142
|
// src/commands/utils/plan-review.ts
|
|
10613
11143
|
import { createHash as createHash2 } from "crypto";
|
|
10614
|
-
import { existsSync as
|
|
11144
|
+
import { existsSync as existsSync25 } from "fs";
|
|
10615
11145
|
import { readFile as readFile11, writeFile as writeFile12 } from "fs/promises";
|
|
10616
11146
|
import { join as join30 } from "path";
|
|
10617
11147
|
var REVIEW_FILE = "plan-review.json";
|
|
@@ -10624,7 +11154,7 @@ async function hashPlanFile(planPath) {
|
|
|
10624
11154
|
}
|
|
10625
11155
|
async function loadPlanReview(outputDir) {
|
|
10626
11156
|
const path = join30(outputDir, REVIEW_FILE);
|
|
10627
|
-
if (!
|
|
11157
|
+
if (!existsSync25(path)) return {};
|
|
10628
11158
|
try {
|
|
10629
11159
|
return JSON.parse(await readFile11(path, "utf-8"));
|
|
10630
11160
|
} catch {
|
|
@@ -10746,8 +11276,8 @@ async function recordCommand(url, options = {}) {
|
|
|
10746
11276
|
}
|
|
10747
11277
|
if (langOverride) config.lang = langOverride;
|
|
10748
11278
|
if (config.music?.path) {
|
|
10749
|
-
const musicPath =
|
|
10750
|
-
if (!
|
|
11279
|
+
const musicPath = isAbsolute7(config.music.path) ? config.music.path : resolve11(config.output.dir, config.music.path);
|
|
11280
|
+
if (!existsSync26(musicPath)) {
|
|
10751
11281
|
console.log();
|
|
10752
11282
|
console.log(chalk17.yellow(` \u26A0 Music file not found: ${config.music.path}`));
|
|
10753
11283
|
console.log(chalk17.dim(` Config: ${join31(runRoot(), CONFIG_FILE)}`));
|
|
@@ -10768,7 +11298,7 @@ async function recordCommand(url, options = {}) {
|
|
|
10768
11298
|
const targetLang = config.lang || "en";
|
|
10769
11299
|
const planPath = join31(config.output.dir, `plan-${targetLang}.json`);
|
|
10770
11300
|
const isExtensionUrl = url.startsWith("chrome-extension://");
|
|
10771
|
-
await
|
|
11301
|
+
await mkdir10(config.output.dir, { recursive: true });
|
|
10772
11302
|
await setLogFilePath(config.output.dir);
|
|
10773
11303
|
const preRunHash = await hashPlanFile(planPath);
|
|
10774
11304
|
try {
|
|
@@ -10857,6 +11387,13 @@ async function recordCommand(url, options = {}) {
|
|
|
10857
11387
|
userTier,
|
|
10858
11388
|
userEmail
|
|
10859
11389
|
});
|
|
11390
|
+
await registerDemoRun(userEmail, {
|
|
11391
|
+
source: currentMcpTool2() ? "mcp" : "cli",
|
|
11392
|
+
targetUrl: validatedPlan.url || url,
|
|
11393
|
+
localPath: finalPath,
|
|
11394
|
+
title: validatedPlan.title,
|
|
11395
|
+
...config.lang ? { language: config.lang } : {}
|
|
11396
|
+
});
|
|
10860
11397
|
if (!userEmail || userEmail === "anonymous") {
|
|
10861
11398
|
const newCount = localVideoCount + 1;
|
|
10862
11399
|
await saveUserData({ localVideoCount: newCount });
|
|
@@ -11287,7 +11824,7 @@ function withTierNote(result, tier) {
|
|
|
11287
11824
|
...result.content,
|
|
11288
11825
|
{
|
|
11289
11826
|
type: "text",
|
|
11290
|
-
text: `Free tier:
|
|
11827
|
+
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
11828
|
}
|
|
11292
11829
|
]
|
|
11293
11830
|
};
|
|
@@ -11383,7 +11920,7 @@ var FORMAT_PROPERTY = {
|
|
|
11383
11920
|
var APPROVE_PLAN_PROPERTY = {
|
|
11384
11921
|
approve_plan: {
|
|
11385
11922
|
type: "boolean",
|
|
11386
|
-
description: "The user has reviewed and approved the planned steps this tool returned as plan_ready
|
|
11923
|
+
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
11924
|
}
|
|
11388
11925
|
};
|
|
11389
11926
|
var DRAFT_PROPERTY = {
|
|
@@ -11430,7 +11967,7 @@ var OUTRO_PROPERTY = {
|
|
|
11430
11967
|
var QUICKPEEK_TOOLS = [
|
|
11431
11968
|
{
|
|
11432
11969
|
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
|
|
11970
|
+
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
11971
|
inputSchema: {
|
|
11435
11972
|
type: "object",
|
|
11436
11973
|
properties: {
|
|
@@ -11506,7 +12043,7 @@ var QUICKPEEK_TOOLS = [
|
|
|
11506
12043
|
},
|
|
11507
12044
|
{
|
|
11508
12045
|
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
|
|
12046
|
+
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
12047
|
inputSchema: {
|
|
11511
12048
|
type: "object",
|
|
11512
12049
|
properties: { ...URL_PROPERTY },
|
|
@@ -11543,7 +12080,7 @@ var QUICKPEEK_TOOLS = [
|
|
|
11543
12080
|
},
|
|
11544
12081
|
{
|
|
11545
12082
|
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.",
|
|
12083
|
+
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
12084
|
inputSchema: { type: "object", properties: {} }
|
|
11548
12085
|
}
|
|
11549
12086
|
];
|
|
@@ -11628,7 +12165,7 @@ async function runDemoGates(url, args) {
|
|
|
11628
12165
|
...missing.length ? { options_needed: missing } : {},
|
|
11629
12166
|
next_steps: [
|
|
11630
12167
|
...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(", ")}
|
|
12168
|
+
...missing.length ? [`Ask the USER to choose ${missing.join(", ")}, do not pick for them, and pass the answers as params.`] : [],
|
|
11632
12169
|
"Then call this tool again with the same arguments plus the answers."
|
|
11633
12170
|
]
|
|
11634
12171
|
});
|
|
@@ -11664,7 +12201,7 @@ async function runDemoGates(url, args) {
|
|
|
11664
12201
|
return jsonContent({
|
|
11665
12202
|
status: "setup_required",
|
|
11666
12203
|
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
|
|
12204
|
+
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
12205
|
});
|
|
11669
12206
|
}
|
|
11670
12207
|
}
|
|
@@ -11714,7 +12251,7 @@ async function runDemoGates(url, args) {
|
|
|
11714
12251
|
}
|
|
11715
12252
|
function mcpPlanReview() {
|
|
11716
12253
|
return async (plan, planPath) => {
|
|
11717
|
-
const editorUrl =
|
|
12254
|
+
const editorUrl = await editorUrlWithThumbs(editorBaseUrl(), plan, dirname5(planPath));
|
|
11718
12255
|
if (!canElicit()) throw new PlanReviewPending(plan, planPath, editorUrl);
|
|
11719
12256
|
if (editorUrl) await openInBrowser(editorUrl);
|
|
11720
12257
|
const steps = plan.steps.map((s, i) => `${i + 1}. [${s.action}] ${s.caption ?? ""}`).join("\n");
|
|
@@ -11723,7 +12260,7 @@ function mcpPlanReview() {
|
|
|
11723
12260
|
|
|
11724
12261
|
${steps}
|
|
11725
12262
|
|
|
11726
|
-
` + (editorUrl ? `The plan is open in your browser editor
|
|
12263
|
+
` + (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
12264
|
"approved",
|
|
11728
12265
|
"Record this plan?"
|
|
11729
12266
|
);
|
|
@@ -11741,14 +12278,14 @@ function planReadyResult(pending2) {
|
|
|
11741
12278
|
next_steps: [
|
|
11742
12279
|
"Show these steps to the USER and ask whether to record them (they can also fine-tune in editor_url).",
|
|
11743
12280
|
"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
|
|
12281
|
+
"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
12282
|
]
|
|
11746
12283
|
});
|
|
11747
12284
|
}
|
|
11748
12285
|
function planRejectedResult(e) {
|
|
11749
12286
|
return jsonContent({
|
|
11750
12287
|
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)
|
|
12288
|
+
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
12289
|
});
|
|
11753
12290
|
}
|
|
11754
12291
|
async function handleGenerateDemo(args) {
|
|
@@ -11841,7 +12378,7 @@ async function handleOpenInEditor(args) {
|
|
|
11841
12378
|
const parsed = parsePlanText(readFileSync10(planPath, "utf8"));
|
|
11842
12379
|
if (!parsed.ok) return errorContent(`${planPath} cannot be opened: ${parsed.error}`);
|
|
11843
12380
|
const base = editorBaseUrl();
|
|
11844
|
-
const editorUrl =
|
|
12381
|
+
const editorUrl = await editorUrlWithThumbs(base, parsed.plan, dir);
|
|
11845
12382
|
await openInBrowser(editorUrl ?? `${base}/edit`);
|
|
11846
12383
|
return jsonContent({
|
|
11847
12384
|
opened: true,
|
|
@@ -11930,7 +12467,7 @@ async function handleCheckAccount() {
|
|
|
11930
12467
|
return jsonContent({
|
|
11931
12468
|
signedIn: false,
|
|
11932
12469
|
tier: "free",
|
|
11933
|
-
note: "Not signed in.
|
|
12470
|
+
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
12471
|
});
|
|
11935
12472
|
}
|
|
11936
12473
|
const tier = await getTierByEmail(userData.email);
|
|
@@ -11938,15 +12475,15 @@ async function handleCheckAccount() {
|
|
|
11938
12475
|
signedIn: true,
|
|
11939
12476
|
email: userData.email,
|
|
11940
12477
|
tier,
|
|
11941
|
-
note: tier === "free" ? "Free tier:
|
|
12478
|
+
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
12479
|
});
|
|
11943
12480
|
});
|
|
11944
12481
|
}
|
|
11945
12482
|
var PROMO_DIR = "promo";
|
|
11946
12483
|
function resolveRunRoot(projectDir) {
|
|
11947
12484
|
if (typeof projectDir !== "string" || !projectDir.trim()) return process.cwd();
|
|
11948
|
-
const abs =
|
|
11949
|
-
if (!
|
|
12485
|
+
const abs = resolve12(projectDir.trim());
|
|
12486
|
+
if (!existsSync27(abs) || !statSync4(abs).isDirectory()) {
|
|
11950
12487
|
throw new Error(`project_dir is not an existing directory: ${abs}`);
|
|
11951
12488
|
}
|
|
11952
12489
|
const root = basename5(abs) === PROMO_DIR ? abs : join33(abs, PROMO_DIR);
|