@hypersocial/decks-cli 0.1.11 → 0.2.0
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 +103 -0
- package/package.json +2 -1
- package/skills/hyperdecks/SKILL.md +153 -1
- package/src/check.mjs +316 -0
- package/src/commands.mjs +134 -12
- package/src/narrate.mjs +95 -0
- package/src/ops.mjs +182 -0
- package/src/preview-capture.mjs +93 -20
- package/src/release.mjs +133 -0
- package/src/runtime.mjs +34 -3
- package/src/scaffold.mjs +43 -0
- package/src/voice.mjs +87 -0
- package/templates/evidence.md +16 -0
- package/templates/narrated-deck.html +125 -0
- package/templates/script.md +42 -0
package/src/commands.mjs
CHANGED
|
@@ -241,6 +241,13 @@ commands:
|
|
|
241
241
|
logout [--dev]
|
|
242
242
|
whoami [--json] [--dev]
|
|
243
243
|
deck list|stats|open|rm
|
|
244
|
+
deck new <slug> "<title>" --narrated [--dir <path>]
|
|
245
|
+
scaffold decks/<slug>/: index.html (8 slides, player/v1), script.md, evidence.md
|
|
246
|
+
check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--json]
|
|
247
|
+
the narrated-deck gate: slides vs manifest, timing, captions, clip URLs,
|
|
248
|
+
local refs, pinned player, a data-note per slide, mobile titles
|
|
249
|
+
release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
|
|
250
|
+
on a committed deck: check → narrate → import → share new per --to
|
|
244
251
|
share new|list|on|off|mute|unmute|rm|url
|
|
245
252
|
share new <slug> <recipient> [--qa] [--no-notify]
|
|
246
253
|
recipient archive|unarchive
|
|
@@ -251,12 +258,24 @@ commands:
|
|
|
251
258
|
activity [--deck <slug>] [--limit <n>] [--json]
|
|
252
259
|
gen-image "<prompt>" [--transparent] [--image <url>] [--size <s>] [--quality <q>] [--count <n>] [--slug <deck>] [--download <file>]
|
|
253
260
|
image-job <jobId> [--json]
|
|
261
|
+
gen-video "<prompt>" [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]
|
|
262
|
+
a 3–10s clip (no audio) via fal; --image animates that still and keeps its aspect
|
|
263
|
+
video-job <jobId> [--json]
|
|
254
264
|
credits [--limit <n>] [--json] show your credit balance and recent history
|
|
255
265
|
|
|
256
266
|
preview create <slug> --out <directory> [--share <token>] [--upload] [--json]
|
|
257
267
|
preview create --file <index.html> --out <directory> [--duration 4] [--width 600] [--delay 1000] [--label "Watch"] [--aspect-ratio 16:9]
|
|
258
268
|
preview setup install isolated Chromium (Linux: see README for system dependencies)
|
|
259
269
|
|
|
270
|
+
narrate <deck-dir> [--slide <id>] [--voice <name|id>] [--slug <slug>] [--dry-run] [--json]
|
|
271
|
+
script.md → voiced clips, timelines and a manifest, rendered server-side; only changed slides re-render
|
|
272
|
+
voice list [--json] your voices and their status
|
|
273
|
+
voice clone <file...> --name <name>
|
|
274
|
+
clone your voice from 1-3 mp3/m4a/wav recordings (30 s to 3 min in total, 10 MB each);
|
|
275
|
+
asks you to type "I consent" before anything is uploaded
|
|
276
|
+
voice upload <file...> upload recordings and print their sample ids (for the MCP clone_voice tool)
|
|
277
|
+
voice rm <name|id> delete a voice, also at the provider
|
|
278
|
+
|
|
260
279
|
global:
|
|
261
280
|
--json
|
|
262
281
|
--prod production target (default)
|
|
@@ -326,25 +345,104 @@ async function imageJob(pos, opts) {
|
|
|
326
345
|
for (const im of job.results || []) if (im.url) console.log(im.url);
|
|
327
346
|
}
|
|
328
347
|
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
return 0;
|
|
348
|
+
async function genVideo(pos, opts) {
|
|
349
|
+
const prompt = pos.join(" ").trim() || opts.prompt;
|
|
350
|
+
if (!prompt) {
|
|
351
|
+
throw new Error('usage: gen-video "<prompt>" [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]');
|
|
334
352
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
353
|
+
const queued = await ops.generateVideo(opts, {
|
|
354
|
+
prompt,
|
|
355
|
+
image: opts.image,
|
|
356
|
+
seconds: opts.seconds,
|
|
357
|
+
aspect: opts.aspect,
|
|
358
|
+
slug: opts.slug,
|
|
359
|
+
name: opts.name,
|
|
360
|
+
});
|
|
361
|
+
// A clip takes a minute or more; poll so gen-video stays one blocking command.
|
|
362
|
+
// Progress goes to stderr so stdout stays the URL (or --json).
|
|
363
|
+
process.stderr.write(`queued a ${queued.seconds}s clip — job ${queued.jobId}\n`);
|
|
364
|
+
const job = await ops.pollVideoJob(opts, queued.jobId, {
|
|
365
|
+
onProgress: (j, ms) => process.stderr.write(` ${j.status} ${Math.round(ms / 1000)}s…\r`),
|
|
366
|
+
});
|
|
367
|
+
process.stderr.write("\n");
|
|
368
|
+
const url = job.video?.url;
|
|
369
|
+
if (!url) throw new Error(`video job ${queued.jobId} finished without a URL; check it with: video-job ${queued.jobId}`);
|
|
370
|
+
// Print the paid clip's URL before downloading, so a failed download never hides it.
|
|
371
|
+
console.log(opts.json ? JSON.stringify(job, null, 2) : url);
|
|
372
|
+
if (job.creditsRemaining != null) process.stderr.write(`credits left: ${job.creditsRemaining}\n`);
|
|
373
|
+
if (opts.download) {
|
|
374
|
+
const res = await fetch(url);
|
|
375
|
+
if (!res.ok) throw new Error(`download of ${url} failed (${res.status})`);
|
|
376
|
+
writeFileSync(opts.download, Buffer.from(await res.arrayBuffer()));
|
|
377
|
+
process.stderr.write(`saved to ${opts.download}\n`);
|
|
338
378
|
}
|
|
379
|
+
}
|
|
339
380
|
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
381
|
+
async function videoJob(pos, opts) {
|
|
382
|
+
const [jobId] = pos;
|
|
383
|
+
if (!jobId) throw new Error("usage: video-job <jobId>");
|
|
384
|
+
const job = await ops.checkVideoJob(opts, jobId);
|
|
385
|
+
if (opts.json) {
|
|
386
|
+
console.log(JSON.stringify(job, null, 2));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
console.log(`status: ${job.status}`);
|
|
390
|
+
if (job.error) console.log(`error: ${job.error}`);
|
|
391
|
+
if (job.video?.url) console.log(job.video.url);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// The command table: "<group> <cmd>" or "<group>" → handler. Exported (via
|
|
395
|
+
// COMMANDS) so the CLI/MCP parity test can enumerate it.
|
|
396
|
+
function commandTable(pos, rest, opts) {
|
|
397
|
+
return {
|
|
343
398
|
"preview create": async () => {
|
|
344
399
|
const { createPreview } = await import("./preview.mjs");
|
|
345
400
|
const result = await createPreview(rest, opts);
|
|
346
401
|
console.log(opts.json ? JSON.stringify(result) : `Preview saved: ${result.manifestPath}\n${result.email.sendable ? result.email.path : "Insert the GIF or PNG manually; upload with an existing share for linked email HTML."}`);
|
|
347
402
|
},
|
|
403
|
+
"deck new": async () => {
|
|
404
|
+
const { deckNew } = await import("./scaffold.mjs");
|
|
405
|
+
const out = await deckNew(rest, opts, opts.json ? { log: (line) => console.error(line) } : {});
|
|
406
|
+
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
407
|
+
},
|
|
408
|
+
check: async () => {
|
|
409
|
+
const { check } = await import("./check.mjs");
|
|
410
|
+
const result = await check(pos.slice(1), opts);
|
|
411
|
+
if (opts.json) console.log(JSON.stringify(result, null, 2));
|
|
412
|
+
if (result.errors.length) throw new Error(`check found ${result.errors.length} issue(s)`);
|
|
413
|
+
},
|
|
414
|
+
release: async () => {
|
|
415
|
+
const { release } = await import("./release.mjs");
|
|
416
|
+
const out = await release(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
|
|
417
|
+
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
418
|
+
},
|
|
419
|
+
narrate: async () => {
|
|
420
|
+
const { narrate } = await import("./narrate.mjs");
|
|
421
|
+
const result = await narrate(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
|
|
422
|
+
if (opts.json) console.log(JSON.stringify(result, null, 2));
|
|
423
|
+
},
|
|
424
|
+
"voice list": async () => {
|
|
425
|
+
const voices = await ops.listVoices(opts);
|
|
426
|
+
if (opts.json) return void console.log(JSON.stringify(voices, null, 2));
|
|
427
|
+
for (const x of voices) console.log(`${x.name}\t${x.status}\t${x.handle ?? "-"}\t${x.id}${x.error ? `\t${x.error}` : ""}`);
|
|
428
|
+
if (!voices.length) console.log("(no voices; voice clone <file...> --name <name> makes one)");
|
|
429
|
+
},
|
|
430
|
+
"voice clone": async () => {
|
|
431
|
+
const { voiceClone } = await import("./voice.mjs");
|
|
432
|
+
const voice = await voiceClone(rest, opts);
|
|
433
|
+
if (opts.json) console.log(JSON.stringify(voice, null, 2));
|
|
434
|
+
},
|
|
435
|
+
"voice upload": async () => {
|
|
436
|
+
const { voiceUpload } = await import("./voice.mjs");
|
|
437
|
+
const out = await voiceUpload(rest, opts);
|
|
438
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : out.sampleIds.join("\n"));
|
|
439
|
+
},
|
|
440
|
+
"voice rm": async () => {
|
|
441
|
+
if (!rest[0]) throw new Error("usage: voice rm <name|id>");
|
|
442
|
+
const out = await ops.deleteVoice(opts, rest.join(" "));
|
|
443
|
+
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
444
|
+
console.log(`OK removed voice "${out.name}"${out.removedAtProvider ? " (deleted at the provider; decks already narrated with it keep playing)" : ""}`);
|
|
445
|
+
},
|
|
348
446
|
"preview setup": async () => {
|
|
349
447
|
const { setupCapture } = await import("./preview-capture.mjs");
|
|
350
448
|
await setupCapture(rest, opts);
|
|
@@ -373,9 +471,33 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
373
471
|
activity: () => activity(pos.slice(1), opts),
|
|
374
472
|
"gen-image": () => genImage(pos.slice(1), opts),
|
|
375
473
|
"image-job": () => imageJob(pos.slice(1), opts),
|
|
474
|
+
"gen-video": () => genVideo(pos.slice(1), opts),
|
|
475
|
+
"video-job": () => videoJob(pos.slice(1), opts),
|
|
376
476
|
credits: () => credits(pos.slice(1), opts),
|
|
377
477
|
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export const COMMANDS = Object.keys(commandTable([], [], {}));
|
|
481
|
+
|
|
482
|
+
export async function runCli(argv = process.argv.slice(2)) {
|
|
483
|
+
let pos, opts;
|
|
484
|
+
try {
|
|
485
|
+
({ pos, opts } = parse(argv));
|
|
486
|
+
} catch (e) {
|
|
487
|
+
console.error("Error:", e.message);
|
|
488
|
+
return 1;
|
|
489
|
+
}
|
|
490
|
+
if (opts.version) {
|
|
491
|
+
console.log(VERSION);
|
|
492
|
+
return 0;
|
|
493
|
+
}
|
|
494
|
+
if (opts.help || pos.length === 0) {
|
|
495
|
+
help();
|
|
496
|
+
return opts.help || pos.length === 0 ? 0 : 1;
|
|
497
|
+
}
|
|
378
498
|
|
|
499
|
+
const [group, cmd] = pos;
|
|
500
|
+
const handlers = commandTable(pos, pos.slice(2), opts);
|
|
379
501
|
const handler = handlers[`${group} ${cmd}`] || handlers[group];
|
|
380
502
|
if (!handler) {
|
|
381
503
|
help();
|
|
@@ -386,7 +508,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
386
508
|
await handler();
|
|
387
509
|
return 0;
|
|
388
510
|
} catch (e) {
|
|
389
|
-
console.error("Error:", e
|
|
511
|
+
console.error("Error:", ops.errorText(e));
|
|
390
512
|
return 1;
|
|
391
513
|
}
|
|
392
514
|
}
|
package/src/narrate.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// hyperdecks narrate <deck-dir> [--slide <id>[,<id>]] [--voice <name|id>] [--slug <slug>] [--dry-run] [--json]
|
|
2
|
+
//
|
|
3
|
+
// A thin client: sends decks/<slug>/script.md (and the deck's <section id>s) to
|
|
4
|
+
// the backend, which compiles it, renders the changed slides with the platform
|
|
5
|
+
// ElevenLabs key and writes clips, timelines and the manifest as deck assets.
|
|
6
|
+
// This polls the job, saves the manifest into <deck-dir>/narration/ and points the
|
|
7
|
+
// player script's data-manifest in index.html at the new manifest URL.
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { basename, join, resolve } from "node:path";
|
|
10
|
+
import * as ops from "./ops.mjs";
|
|
11
|
+
|
|
12
|
+
// Same rule as sectionIds() in lib/deckScript.ts (the published CLI cannot import it).
|
|
13
|
+
export const sectionIds = (html) => [...html.matchAll(/<section\b[^>]*?\sid\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
|
|
14
|
+
|
|
15
|
+
// Set data-manifest on the hosted player's <script> tag; html is unchanged when
|
|
16
|
+
// there is no player tag or it already names this URL.
|
|
17
|
+
export function setManifestUrl(html, url) {
|
|
18
|
+
return html.replace(/<script\b[^>]*\/player\/v\d+\/player\.js"[^>]*>/, (tag) =>
|
|
19
|
+
/\sdata-manifest\s*=\s*"[^"]*"/.test(tag)
|
|
20
|
+
? tag.replace(/(\sdata-manifest\s*=\s*)"[^"]*"/, (_, lead) => `${lead}"${url}"`)
|
|
21
|
+
: tag.replace(/\s*>$/, () => ` data-manifest="${url}">`));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function slideList(value) {
|
|
25
|
+
const ids = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
26
|
+
if (!ids.length) throw new Error("--slide needs at least one slide id");
|
|
27
|
+
return ids;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function narrationRequest(pos, opts) {
|
|
31
|
+
const [dirArg] = pos;
|
|
32
|
+
if (!dirArg) throw new Error("usage: narrate <deck-dir> [--slide <id>] [--voice <name|id>] [--slug <slug>] [--dry-run]");
|
|
33
|
+
for (const k of ["slide", "voice", "slug"]) if (k in opts && typeof opts[k] !== "string") throw new Error(`--${k} requires a value`);
|
|
34
|
+
const deckDir = resolve(dirArg);
|
|
35
|
+
const scriptPath = join(deckDir, "script.md");
|
|
36
|
+
if (!existsSync(scriptPath)) throw new Error(`no script.md in ${deckDir}`);
|
|
37
|
+
const htmlPath = join(deckDir, "index.html");
|
|
38
|
+
const ids = existsSync(htmlPath) ? sectionIds(readFileSync(htmlPath, "utf8")) : [];
|
|
39
|
+
return {
|
|
40
|
+
deckDir,
|
|
41
|
+
fields: {
|
|
42
|
+
slug: opts.slug || basename(deckDir),
|
|
43
|
+
script: readFileSync(scriptPath, "utf8"),
|
|
44
|
+
voice: opts.voice,
|
|
45
|
+
slides: opts.slide !== undefined ? slideList(opts.slide) : undefined,
|
|
46
|
+
sectionIds: ids.length ? ids : undefined,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function narrate(pos, opts = {}, { log = (line) => console.log(line) } = {}) {
|
|
52
|
+
const { deckDir, fields } = narrationRequest(pos, opts);
|
|
53
|
+
if (opts["dry-run"]) {
|
|
54
|
+
const preview = await ops.previewNarration(opts, fields);
|
|
55
|
+
for (const s of preview.slides) log(`${s.render ? "render" : s.cached ? "cached" : "skip "} ${s.id} ${s.hash.slice(0, 12)} ${s.chars} chars`);
|
|
56
|
+
const render = preview.slides.filter((s) => s.render);
|
|
57
|
+
log(`${render.length} of ${preview.slides.length} slide(s) would render in voice "${preview.voice}", ~${render.reduce((n, s) => n + s.chars, 0)} characters of TTS`);
|
|
58
|
+
return preview;
|
|
59
|
+
}
|
|
60
|
+
const { jobId, slides } = await ops.narrateDeck(opts, fields);
|
|
61
|
+
log(`narration job ${jobId}: ${slides.filter((s) => s.status === "render").length} to render, ${slides.filter((s) => s.status === "cached").length} cached`);
|
|
62
|
+
const seen = new Map();
|
|
63
|
+
const job = await ops.pollNarrationJob(opts, jobId, {
|
|
64
|
+
onProgress: (j) => {
|
|
65
|
+
for (const s of j.slides) {
|
|
66
|
+
if (seen.get(s.id) === s.status) continue;
|
|
67
|
+
seen.set(s.id, s.status);
|
|
68
|
+
if (s.status === "done") log(` ✓ ${s.id}${s.duration ? ` ${s.duration.toFixed(2)}s` : ""}`);
|
|
69
|
+
else if (s.status === "error") log(` ✗ ${s.id}`);
|
|
70
|
+
else if (s.status === "rendering" || s.status === "skipped") log(` … ${s.id} ${s.status}`);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
if (job.status === "error") throw new Error(job.error || "narration failed");
|
|
75
|
+
if (!job.manifest) {
|
|
76
|
+
log(`⚠ ${job.notice || "done, but no manifest was written"}`);
|
|
77
|
+
return job;
|
|
78
|
+
}
|
|
79
|
+
const out = join(deckDir, "narration");
|
|
80
|
+
mkdirSync(out, { recursive: true });
|
|
81
|
+
writeFileSync(join(out, "manifest.json"), JSON.stringify(job.manifest, null, 2) + "\n");
|
|
82
|
+
const htmlPath = join(deckDir, "index.html");
|
|
83
|
+
if (job.manifestUrl && existsSync(htmlPath)) {
|
|
84
|
+
const html = readFileSync(htmlPath, "utf8");
|
|
85
|
+
const next = setManifestUrl(html, job.manifestUrl);
|
|
86
|
+
if (next !== html) {
|
|
87
|
+
writeFileSync(htmlPath, next);
|
|
88
|
+
log(` index.html: data-manifest → ${job.manifestUrl}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
log(`✓ ${job.manifest.clips.length} clips, ${job.manifest.spokenDuration}s spoken, ${job.manifest.totalDuration}s with holds; ${job.renderedChars} characters rendered`);
|
|
92
|
+
log(` manifest: ${job.manifestUrl}\n saved: ${join(out, "manifest.json")}`);
|
|
93
|
+
for (const c of job.manifest.clips) log(` ${c.id}\t${c.audio}`);
|
|
94
|
+
return job;
|
|
95
|
+
}
|
package/src/ops.mjs
CHANGED
|
@@ -97,6 +97,179 @@ export async function pollImageJob(opts, jobId, { intervalMs = 3000, timeoutMs =
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// ---- video (convex/videogen.ts) ------------------------------------------
|
|
101
|
+
|
|
102
|
+
// Generate a short clip via fal (Kling v3: text→video, or image→video when an image
|
|
103
|
+
// URL is given as the first frame). Always a background job: returns
|
|
104
|
+
// { jobId, status, seconds, aspect?, creditsRemaining? } without waiting, so the MCP
|
|
105
|
+
// tool never blocks; the CLI polls with pollVideoJob.
|
|
106
|
+
export async function generateVideo(opts, fields = {}) {
|
|
107
|
+
if (!fields.prompt) throw new Error("prompt is required");
|
|
108
|
+
const args = { prompt: String(fields.prompt) };
|
|
109
|
+
const img = fields.image || fields.imageUrl;
|
|
110
|
+
if (img) args.imageUrl = String(img);
|
|
111
|
+
if (fields.seconds != null && fields.seconds !== "") args.seconds = Number(fields.seconds);
|
|
112
|
+
if (fields.aspect) args.aspect = String(fields.aspect);
|
|
113
|
+
if (fields.slug) args.slug = String(fields.slug);
|
|
114
|
+
if (fields.name) args.name = String(fields.name);
|
|
115
|
+
return (await client(opts)).action(api.videogen.generateVideo, args);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Read one video job's status (and the hosted MP4 once done). Shared by the MCP
|
|
119
|
+
// check_video_job tool and the CLI poller.
|
|
120
|
+
export async function checkVideoJob(opts, jobId) {
|
|
121
|
+
if (!jobId) throw new Error("jobId is required");
|
|
122
|
+
return (await client(opts)).query(api.videogen.checkVideoJob, { jobId: String(jobId) });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Poll a video job until it finishes. Returns the job (its `video` holds the hosted
|
|
126
|
+
// URL); throws on job error or timeout. The server settles every job within 15
|
|
127
|
+
// minutes, so the 16-minute default only trips if the job row itself stalls. A few
|
|
128
|
+
// failed checks in a row (network, Convex hiccup) are tolerated: the job keeps
|
|
129
|
+
// running server-side either way, so giving up early would only lose the result.
|
|
130
|
+
export async function pollVideoJob(opts, jobId, { intervalMs = 5000, timeoutMs = 960_000, maxErrors = 5, onProgress } = {}) {
|
|
131
|
+
const c = await client(opts);
|
|
132
|
+
const start = Date.now();
|
|
133
|
+
const resume = `check it later with: video-job ${jobId}`;
|
|
134
|
+
let errors = 0;
|
|
135
|
+
for (;;) {
|
|
136
|
+
let job;
|
|
137
|
+
try {
|
|
138
|
+
job = await c.query(api.videogen.checkVideoJob, { jobId: String(jobId) });
|
|
139
|
+
errors = 0;
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (++errors >= maxErrors) throw new Error(`lost track of video job ${jobId} (${e?.message || e}); ${resume}`);
|
|
142
|
+
}
|
|
143
|
+
if (job?.status === "done") return job;
|
|
144
|
+
if (job?.status === "error") throw new Error(job.error || "video job failed");
|
|
145
|
+
if (Date.now() - start > timeoutMs) throw new Error(`video job ${jobId} is still running after ${Math.round(timeoutMs / 1000)}s; ${resume}`);
|
|
146
|
+
if (onProgress) onProgress(job, Date.now() - start);
|
|
147
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- narration (convex/narration.ts) -------------------------------------
|
|
152
|
+
// The pipeline runs server-side with the backend's ElevenLabs key; these are the
|
|
153
|
+
// thin client calls shared by the CLI `narrate` command and the MCP tools.
|
|
154
|
+
function narrationArgs(fields = {}) {
|
|
155
|
+
if (!fields.slug) throw new Error("slug is required");
|
|
156
|
+
if (!fields.script) throw new Error("script is required (the deck's script.md)");
|
|
157
|
+
const args = { slug: String(fields.slug), script: String(fields.script) };
|
|
158
|
+
if (fields.voice) args.voice = String(fields.voice);
|
|
159
|
+
if (fields.slides) {
|
|
160
|
+
if (!fields.slides.length) throw new Error("slides is empty; omit it to narrate every slide");
|
|
161
|
+
args.slides = fields.slides.map(String);
|
|
162
|
+
}
|
|
163
|
+
if (fields.sectionIds?.length) args.sectionIds = fields.sectionIds.map(String);
|
|
164
|
+
return args;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Dry run: compile on the server and report which slides would render. Renders nothing.
|
|
168
|
+
export async function previewNarration(opts, fields) {
|
|
169
|
+
return (await client(opts)).query(api.narration.preview, narrationArgs(fields));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Start a narration job; returns { jobId, slides }. Poll with checkNarrationJob.
|
|
173
|
+
export async function narrateDeck(opts, fields) {
|
|
174
|
+
return (await client(opts)).mutation(api.narration.narrateDeck, narrationArgs(fields));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function checkNarrationJob(opts, jobId) {
|
|
178
|
+
if (!jobId) throw new Error("jobId is required");
|
|
179
|
+
return (await client(opts)).query(api.narration.getJob, { jobId: String(jobId) });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function listVoices(opts = {}) {
|
|
183
|
+
return (await client(opts)).query(api.voices.listVoices, {});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---- voice cloning (convex/voices.ts) ---------------------------------------
|
|
187
|
+
|
|
188
|
+
// The text of a backend error: ConvexError data (production redacts the rest), else the message.
|
|
189
|
+
export const errorText = (e) => (typeof e?.data === "string" ? e.data : e?.message || String(e));
|
|
190
|
+
|
|
191
|
+
// Upload one recording and bind it to you as a clone sample; returns { sampleId, size }.
|
|
192
|
+
export async function uploadVoiceSample(opts, { bytes, contentType }, adapter = {}) {
|
|
193
|
+
const c = await client(opts);
|
|
194
|
+
const storageId = await (adapter.uploadBytes || runtimeUploadBytes)(c, bytes, contentType);
|
|
195
|
+
return c.mutation(api.voices.addSample, { storageId });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Start a clone from uploaded sample ids. consent: { method, statement, confirmedBy? }.
|
|
199
|
+
// Returns { voiceId, status: "pending", cost }; poll with pollVoice.
|
|
200
|
+
export async function cloneVoice(opts, { name, sampleIds, consent }) {
|
|
201
|
+
if (!name) throw new Error("name is required");
|
|
202
|
+
if (!sampleIds?.length) throw new Error("at least one sample id is required");
|
|
203
|
+
if (!consent?.statement) throw new Error("consent is required: pass the consent statement the user confirmed");
|
|
204
|
+
return (await client(opts)).mutation(api.voices.cloneVoice, { name: String(name), samples: sampleIds.map(String), consent });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function getVoice(opts, voiceId) {
|
|
208
|
+
return (await client(opts)).query(api.voices.getVoice, { voiceId: String(voiceId) });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A voice by id, name or handle among yours.
|
|
212
|
+
export async function findVoice(opts, ref) {
|
|
213
|
+
const want = String(ref || "").trim().toLowerCase();
|
|
214
|
+
if (!want) throw new Error("which voice? pass its name or id");
|
|
215
|
+
const voices = await listVoices(opts);
|
|
216
|
+
const hit = voices.find((x) => x.id === ref || x.name.toLowerCase() === want || x.handle?.toLowerCase() === want);
|
|
217
|
+
if (!hit) throw new Error(`no voice "${ref}" (yours: ${voices.map((x) => x.name).join(", ") || "none"})`);
|
|
218
|
+
return hit;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function renameVoice(opts, ref, name) {
|
|
222
|
+
const voice = await findVoice(opts, ref);
|
|
223
|
+
return (await client(opts)).mutation(api.voices.renameVoice, { voiceId: voice.id, name: String(name) });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Deletes it at the provider too; decks already narrated with it keep playing.
|
|
227
|
+
export async function deleteVoice(opts, ref) {
|
|
228
|
+
const voice = await findVoice(opts, ref);
|
|
229
|
+
const out = await (await client(opts)).action(api.voices.deleteVoice, { voiceId: voice.id });
|
|
230
|
+
return { ...out, id: voice.id, name: voice.name };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Poll a clone until it is ready or failed; returns the final voice.
|
|
234
|
+
export async function pollVoice(opts, voiceId, { intervalMs = 2000, timeoutMs = 300_000, onProgress } = {}) {
|
|
235
|
+
const c = await client(opts);
|
|
236
|
+
const start = Date.now();
|
|
237
|
+
let misses = 0;
|
|
238
|
+
for (;;) {
|
|
239
|
+
let voice;
|
|
240
|
+
try {
|
|
241
|
+
voice = await c.query(api.voices.getVoice, { voiceId: String(voiceId) });
|
|
242
|
+
misses = 0;
|
|
243
|
+
} catch (e) {
|
|
244
|
+
// A blip should not end the wait: the clone keeps running (and is charged).
|
|
245
|
+
if (++misses >= 3) throw new Error(`lost track of voice ${voiceId} (${errorText(e)}); it may still be cloning, check voice list`);
|
|
246
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (onProgress) onProgress(voice);
|
|
250
|
+
if (voice.status !== "pending") return voice;
|
|
251
|
+
if (Date.now() - start > timeoutMs) {
|
|
252
|
+
throw new Error(`voice ${voiceId} still cloning after ${Math.round(timeoutMs / 1000)}s; check it later with voice list`);
|
|
253
|
+
}
|
|
254
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Poll a narration job until it is done or failed; returns the final job.
|
|
259
|
+
export async function pollNarrationJob(opts, jobId, { intervalMs = 3000, timeoutMs = 900_000, onProgress } = {}) {
|
|
260
|
+
const c = await client(opts);
|
|
261
|
+
const start = Date.now();
|
|
262
|
+
for (;;) {
|
|
263
|
+
const job = await c.query(api.narration.getJob, { jobId: String(jobId) });
|
|
264
|
+
if (onProgress) onProgress(job);
|
|
265
|
+
if (job.status === "done" || job.status === "error") return job;
|
|
266
|
+
if (Date.now() - start > timeoutMs) {
|
|
267
|
+
throw new Error(`narration job ${jobId} still ${job.status} after ${Math.round(timeoutMs / 1000)}s; it keeps running on the server, and running narrate again once it ends reuses every finished slide`);
|
|
268
|
+
}
|
|
269
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
100
273
|
export async function listShares(opts, slug) {
|
|
101
274
|
if (!slug) throw new Error("slug is required");
|
|
102
275
|
const rows = await (await client(opts)).query(api.shares.listForDeck, { deckSlug: slug });
|
|
@@ -155,6 +328,15 @@ export async function removeShare(opts, token) {
|
|
|
155
328
|
return { token, removed: true };
|
|
156
329
|
}
|
|
157
330
|
|
|
331
|
+
// Register a new deck (draft) you own. Refuses any slug that already exists:
|
|
332
|
+
// upsert would otherwise reset your own live deck to a draft and rename it.
|
|
333
|
+
export async function createDeck(opts, { slug, title }) {
|
|
334
|
+
const c = await client(opts);
|
|
335
|
+
if (await c.query(api.decks.get, { slug })) throw new Error(`deck ${slug} already exists; pick another slug`);
|
|
336
|
+
await c.mutation(api.decks.upsert, { slug, title, status: "draft" });
|
|
337
|
+
return { slug, title, status: "draft" };
|
|
338
|
+
}
|
|
339
|
+
|
|
158
340
|
export async function removeDeck(opts, slug) {
|
|
159
341
|
if (!slug) throw new Error("slug is required");
|
|
160
342
|
// Cascade delete (deck + its assets/shares/sessions + stored HTML) runs in
|
package/src/preview-capture.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { lookup } from 'node:dns/promises';
|
|
2
2
|
import { isIP } from 'node:net';
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
|
-
import { readFile, realpath, mkdtemp, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import { readFile, realpath, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { resolve, dirname, extname, sep, join } from 'node:path';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
@@ -107,6 +107,59 @@ export async function routeCaptureRequest(route, entryUrl, origins = []) {
|
|
|
107
107
|
} catch { await route.abort().catch(() => {}); return false; }
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// Bounds a page.evaluate on the Node side: in-page timers cannot fire while the
|
|
111
|
+
// deck holds the main thread. Resolves `fallback` instead of waiting further.
|
|
112
|
+
async function within(promise, ms, fallback) {
|
|
113
|
+
promise.catch(() => {});
|
|
114
|
+
let timer;
|
|
115
|
+
try { return await Promise.race([promise, new Promise(ok => { timer = setTimeout(() => ok(fallback), ms); })]); }
|
|
116
|
+
finally { clearTimeout(timer); }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Records `frames` slots at `fps` from CDP screencast frames, which the
|
|
120
|
+
// compositor pushes without waiting on the page's main thread. `ready()`
|
|
121
|
+
// prepares the page and returns a fresh screenshot plus the time it finished:
|
|
122
|
+
// screencast frames stamped earlier can predate the label overlay (the
|
|
123
|
+
// compositor keeps drawing old commits while the main thread is busy) and are
|
|
124
|
+
// dropped. Each slot holds the newest frame at or before its time; slots still
|
|
125
|
+
// empty after the wall-clock window repeat the last frame. Returns how many
|
|
126
|
+
// screencast frames were used (0 means the output is the poster alone).
|
|
127
|
+
export async function recordFrames(session, { frames, fps, dir, ready, slack = 3000, onPhase = () => {} }) {
|
|
128
|
+
let mark = Infinity, last, used = 0, next = 0, writes = Promise.resolve(), done;
|
|
129
|
+
const finished = new Promise(ok => { done = ok; });
|
|
130
|
+
const fill = data => {
|
|
131
|
+
const path = join(dir, `frame-${String(next).padStart(4,'0')}.png`);
|
|
132
|
+
writes = writes.then(() => writeFile(path, data));
|
|
133
|
+
writes.catch(() => {});
|
|
134
|
+
onPhase(`frame ${Math.min(++next + 1, frames)} of ${frames}`);
|
|
135
|
+
};
|
|
136
|
+
session.on('Page.screencastFrame', ({ data, metadata, sessionId }) => {
|
|
137
|
+
session.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
|
|
138
|
+
const ts = metadata.timestamp ?? Date.now() / 1000;
|
|
139
|
+
if (next >= frames || ts < mark) return;
|
|
140
|
+
while (next < frames && mark + next/fps < ts) fill(last);
|
|
141
|
+
last = Buffer.from(data, 'base64');
|
|
142
|
+
used++;
|
|
143
|
+
if (next >= frames) done();
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
onPhase('screencast start');
|
|
147
|
+
await session.send('Page.startScreencast', { format:'png', everyNthFrame:1 });
|
|
148
|
+
({ image: last, at: mark } = await ready());
|
|
149
|
+
let timer;
|
|
150
|
+
try { await Promise.race([finished, new Promise(ok => { timer = setTimeout(ok, frames/fps*1000 + slack); })]); }
|
|
151
|
+
finally { clearTimeout(timer); }
|
|
152
|
+
onPhase('screencast stop');
|
|
153
|
+
await session.send('Page.stopScreencast');
|
|
154
|
+
while (next < frames) fill(last);
|
|
155
|
+
} finally {
|
|
156
|
+
// The caller removes `dir` after this returns or throws; no write may still be pending.
|
|
157
|
+
await writes.catch(() => {});
|
|
158
|
+
}
|
|
159
|
+
await writes;
|
|
160
|
+
return used;
|
|
161
|
+
}
|
|
162
|
+
|
|
110
163
|
async function encode(ffmpeg, args) {
|
|
111
164
|
try { await run(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-nostdin', ...args], { timeout: 90000, maxBuffer: 1024 * 1024, env: { PATH: dirname(ffmpeg), ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}) } }); }
|
|
112
165
|
catch (e) { throw new Error(`FFmpeg encoding failed (${e.killed ? '90s timeout' : 'check portable encoder installation'}); no global FFmpeg is used`); }
|
|
@@ -116,7 +169,7 @@ export async function capturePreview(config) {
|
|
|
116
169
|
let ffmpeg;
|
|
117
170
|
try { ffmpeg = (await import('@ffmpeg-installer/ffmpeg')).default.path; await run(ffmpeg, ['-version'], { timeout:10000 }); }
|
|
118
171
|
catch { throw new Error('Portable FFmpeg missing. Reinstall the CLI with optional dependencies and install scripts enabled (`npm install -g hyperdecks --include=optional`); pnpm users must approve @ffmpeg-installer platform build scripts.'); }
|
|
119
|
-
let browser, server, temp, deadline, timedOut = false;
|
|
172
|
+
let browser, server, temp, deadline, timedOut = false, phase = 'launch';
|
|
120
173
|
const warnings = [];
|
|
121
174
|
try {
|
|
122
175
|
temp = await mkdtemp(join(tmpdir(), 'hyperdecks-preview-'));
|
|
@@ -143,38 +196,58 @@ export async function capturePreview(config) {
|
|
|
143
196
|
page.setDefaultTimeout(15000);
|
|
144
197
|
page.on('pageerror', () => failed++);
|
|
145
198
|
page.on('requestfailed', request => { if (request.failure()?.errorText !== 'net::ERR_FAILED') failed++; });
|
|
199
|
+
phase = 'page load';
|
|
146
200
|
await page.goto(server.url, { waitUntil:'domcontentloaded', timeout:30000 });
|
|
147
|
-
|
|
201
|
+
phase = 'fonts';
|
|
202
|
+
const fontsReady = await within(page.evaluate(() => document.fonts.ready.then(() => true)), 5000, false);
|
|
148
203
|
if (!fontsReady) warnings.push('Fonts did not settle within 5s; inspect the poster or increase --delay.');
|
|
204
|
+
phase = 'delay';
|
|
149
205
|
await page.waitForTimeout(config.delay);
|
|
150
|
-
|
|
206
|
+
phase = 'layout probe';
|
|
207
|
+
const probed = Date.now();
|
|
208
|
+
const detected = await within(page.evaluate(() => {
|
|
151
209
|
const meta = document.querySelector('meta[name="preview-aspect-ratio"]')?.getAttribute('content');
|
|
152
210
|
if (meta && /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(meta)) { const [w,h] = meta.split(':').map(Number); return w/h; }
|
|
153
211
|
const slide = document.querySelector('.slide, [data-slide], canvas');
|
|
154
212
|
const rect = slide?.getBoundingClientRect();
|
|
155
213
|
return rect && rect.width > 100 && rect.height > 100 ? rect.width/rect.height : null;
|
|
156
|
-
});
|
|
157
|
-
|
|
214
|
+
}), 15000, 'timeout');
|
|
215
|
+
// Software WebGL can hold the main thread for seconds; frames still come from the compositor.
|
|
216
|
+
const busy = (Date.now() - probed) / 1000;
|
|
217
|
+
if (detected === 'timeout') warnings.push(`The deck kept its main thread busy for over 15s; the layout probe gave up${config.aspect ? '' : ' and used 16:9 (pass --aspect-ratio W:H if the deck has another shape)'}.`);
|
|
218
|
+
else if (busy > 1) warnings.push(`The deck kept its main thread busy for ${busy.toFixed(1)}s; script-driven motion may look choppy.`);
|
|
158
219
|
if (!config.aspect && !detected) warnings.push('Using 16:9 aspect ratio; use --aspect-ratio W:H or preview-aspect-ratio metadata for a different deck shape.');
|
|
220
|
+
const aspect = config.aspect ?? (detected >= 0.5 && detected <= 3 ? detected : 16/9);
|
|
159
221
|
const width = config.width % 2 ? config.width + 1 : config.width;
|
|
160
222
|
const height = Math.round(width / aspect / 2) * 2;
|
|
161
223
|
await page.setViewportSize({ width:1280, height:Math.round(1280/aspect) });
|
|
162
|
-
await page.evaluate(label => {
|
|
163
|
-
const badge = document.createElement('div');
|
|
164
|
-
badge.textContent = '▶ ' + label;
|
|
165
|
-
badge.setAttribute('style', 'all:initial;position:fixed;bottom:28px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.85);color:white;font:600 25px sans-serif;padding:14px 24px;border-radius:40px;z-index:2147483647;white-space:nowrap;max-width:90%;overflow:hidden;text-overflow:ellipsis;pointer-events:none');
|
|
166
|
-
document.documentElement.appendChild(badge);
|
|
167
|
-
}, config.label);
|
|
168
224
|
const fps = 10, frames = Math.round(config.duration * fps), duration = frames/fps;
|
|
169
|
-
//
|
|
170
|
-
// starting a deck narration tour
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
225
|
+
// Screencast captures the live WebGL compositor and CSS motion without
|
|
226
|
+
// starting a deck narration tour, and its frames keep coming while deck
|
|
227
|
+
// script saturates the main thread (where per-frame screenshots stall).
|
|
228
|
+
phase = 'screencast start';
|
|
229
|
+
const used = await recordFrames(await context.newCDPSession(page), { frames, fps, dir: temp, onPhase: p => { phase = p; }, ready: async () => {
|
|
230
|
+
phase = 'label overlay';
|
|
231
|
+
const labelled = await within(page.evaluate(label => {
|
|
232
|
+
const badge = document.createElement('div');
|
|
233
|
+
badge.textContent = '▶ ' + label;
|
|
234
|
+
badge.setAttribute('style', 'all:initial;position:fixed;bottom:28px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.85);color:white;font:600 25px sans-serif;padding:14px 24px;border-radius:40px;z-index:2147483647;white-space:nowrap;max-width:90%;overflow:hidden;text-overflow:ellipsis;pointer-events:none');
|
|
235
|
+
document.documentElement.appendChild(badge);
|
|
236
|
+
return true;
|
|
237
|
+
}, config.label), 15000, false);
|
|
238
|
+
if (!labelled) warnings.push('The deck kept its main thread busy for over 15s; the label may be missing from the preview.');
|
|
239
|
+
phase = 'poster frame';
|
|
240
|
+
// A screenshot forces a frame from the latest commit, so it and every
|
|
241
|
+
// later compositor frame show the badge.
|
|
242
|
+
const image = await page.screenshot({ timeout:15000 });
|
|
243
|
+
phase = `frame 1 of ${frames}`;
|
|
244
|
+
return { image, at: Date.now() / 1000 };
|
|
245
|
+
} });
|
|
246
|
+
if (!used) warnings.push('No motion was captured after the poster; the GIF and MP4 show a still image.');
|
|
247
|
+
phase = 'browser close';
|
|
176
248
|
await context.close();
|
|
177
249
|
clearTimeout(deadline);
|
|
250
|
+
phase = 'encode';
|
|
178
251
|
const input = ['-framerate', String(fps), '-i', join(temp, 'frame-%04d.png')];
|
|
179
252
|
const paths = Object.fromEntries(['png','gif','mp4'].map(t => [t, resolve(config.out, `preview.${t}`)]));
|
|
180
253
|
await encode(ffmpeg, ['-i', join(temp,'frame-0000.png'), '-vf', `scale=${width}:${height}`, '-frames:v','1', paths.png]);
|
|
@@ -184,7 +257,7 @@ export async function capturePreview(config) {
|
|
|
184
257
|
if (failed) warnings.push(`${failed} page/resource error(s); inspect the poster and increase --delay if necessary.`);
|
|
185
258
|
const files = await Promise.all(['gif','png','mp4'].map(async type => ({ type, path: paths[type], contentType: type === 'mp4' ? 'video/mp4' : `image/${type}`, bytes: (await stat(paths[type])).size, width, height, ...(type === 'png' ? {} : { duration, fps, silent:true, ...(type === 'gif' ? { loop:0 } : {}) }) })));
|
|
186
259
|
return { files, warnings };
|
|
187
|
-
} catch (e) { throw new Error(`Capture failed: ${timedOut ? '60s capture timeout; the renderer did not respond' : /Timeout/i.test(e.name) ? 'bounded capture timeout; inspect the deck or increase --delay' : e.message}`); }
|
|
260
|
+
} catch (e) { throw new Error(`Capture failed during ${phase}: ${timedOut ? '60s capture timeout; the renderer did not respond' : /Timeout/i.test(e.name) ? 'bounded capture timeout; inspect the deck or increase --delay' : e.message}`); }
|
|
188
261
|
finally {
|
|
189
262
|
clearTimeout(deadline);
|
|
190
263
|
if (browser) await browser.close().catch(() => {});
|