@hypersocial/decks-cli 0.1.12 → 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/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
- export async function runCli(argv = process.argv.slice(2)) {
330
- const { pos, opts } = parse(argv);
331
- if (opts.version) {
332
- console.log(VERSION);
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
- if (opts.help || pos.length === 0) {
336
- help();
337
- return opts.help || pos.length === 0 ? 0 : 1;
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
- const [group, cmd] = pos;
341
- const rest = pos.slice(2);
342
- const handlers = {
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?.message || e);
511
+ console.error("Error:", ops.errorText(e));
390
512
  return 1;
391
513
  }
392
514
  }
@@ -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
@@ -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
- const fontsReady = await page.evaluate(async () => Promise.race([document.fonts.ready.then(() => true), new Promise(ok => setTimeout(() => ok(false), 5000))]));
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
- const detected = await page.evaluate(() => {
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
- const aspect = config.aspect ?? (detected >= 0.5 && detected <= 3 ? detected : 16/9);
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
- // Screenshots capture the live WebGL compositor and CSS motion without
170
- // starting a deck narration tour or waiting forever on looped media.
171
- for (let i = 0; i < frames; i++) {
172
- const start = Date.now();
173
- await page.screenshot({ path: join(temp, `frame-${String(i).padStart(4,'0')}.png`), timeout:10000 });
174
- if (i < frames-1) await page.waitForTimeout(Math.max(0, 1000/fps - (Date.now()-start)));
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(() => {});