@hypersocial/decks-cli 0.9.1 → 0.9.3
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/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +9 -2
- package/skills/hyperdecks/chat.md +10 -0
- package/src/commands.mjs +75 -5
- package/src/export-video.mjs +502 -0
- package/src/firstDeck.mjs +2 -1
- package/src/narrator.mjs +1 -1
- package/src/ops.mjs +106 -5
- package/src/play.mjs +2 -1
- package/src/presenter.mjs +6 -0
- package/src/release.mjs +2 -2
- package/src/runtime.mjs +10 -2
package/package.json
CHANGED
|
@@ -143,6 +143,8 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
|
|
|
143
143
|
| Watch a draft with its real face | `hyperdecks play decks/<slug>` | the `previewUrl` from `get_deck_source` |
|
|
144
144
|
| Ship | `hyperdecks release decks/<slug> --to "<name>" --email <e>` | `release_deck` |
|
|
145
145
|
| Share-link poster | `hyperdecks poster <slug>` | `generate_deck_poster` |
|
|
146
|
+
| PDF for a data room or attachment | `hyperdecks export <slug> --pdf [--notes] --out deck.pdf`, `hyperdecks export list` | `export_deck`, `list_exports` |
|
|
147
|
+
| Video (MP4) for YouTube, LinkedIn or an attachment | `hyperdecks export <slug\|deck-dir> --video [--size 1080p\|720p\|4k] [--shape 16:9\|9:16\|1:1] [--title-card] [--end-card] [--no-captions] [--no-avatar] [--upload] --out deck.mp4`: records the narrated player in real time (a 4-minute deck takes about 5), avatar, voice and captions, no player chrome; a slug records the released copy, a deck folder its working copy; `--upload` lists it with the deck's exports | `export_deck { kind: "mp4" }` answers with this command (CLI only until the render worker ships) |
|
|
146
148
|
| Import only | `hyperdecks import <file> --slug <slug>` | `import_deck_from_url` |
|
|
147
149
|
| One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
|
|
148
150
|
| Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
|
|
@@ -611,8 +613,13 @@ still.
|
|
|
611
613
|
`voice clone` (it asks them for consent; never clone anyone else's voice).
|
|
612
614
|
- Names speech-to-text spells another way fail narrate's transcript check
|
|
613
615
|
(step 4). Map what it heard to what you wrote with a `normalize:` entry in
|
|
614
|
-
the front matter, `heard=written`, comma-separated: a
|
|
615
|
-
"Minimoji" but is transcribed as "Moji" needs
|
|
616
|
+
the front matter, `heard=written`, several on one line, comma-separated: a
|
|
617
|
+
clip that says "Minimoji" but is transcribed as "Moji" needs
|
|
618
|
+
`normalize: moji=minimoji`, and with a second entry
|
|
619
|
+
`normalize: moji=minimoji, dash=D.A.S.H.` (one line, not a YAML list).
|
|
620
|
+
- Write a narrator's name as it is spoken when the two differ, or leave it to
|
|
621
|
+
the narrator's spoken name: `narrator list` shows it, and narrate reads
|
|
622
|
+
"D.A.S.H." as "Dash" (captions keep what you wrote).
|
|
616
623
|
|
|
617
624
|
### 4. Narrate and read the guard
|
|
618
625
|
|
|
@@ -96,6 +96,16 @@ makes the hello deck; continue from step 5 below.
|
|
|
96
96
|
`set_recipient_archived` to tidy the activity feed; `credits` for the
|
|
97
97
|
balance. `remove_deck` deletes a deck and breaks every link to it: confirm
|
|
98
98
|
with the user first.
|
|
99
|
+
11. When someone asks for "the PDF" (a data room, an attachment):
|
|
100
|
+
`export_deck { slug, kind: "pdf", notes? }` renders the released deck, one
|
|
101
|
+
1600×900 page per slide with a footer carrying its tracked link (pass
|
|
102
|
+
`shareToken` to choose which), and returns a download `url` that lasts 30
|
|
103
|
+
days; `notes: true` adds the slide's script after each slide.
|
|
104
|
+
`list_exports` shows what was exported. Release first: it prints the live
|
|
105
|
+
deck, not the draft.
|
|
106
|
+
A video (MP4) is recorded by the CLI for now: `export_deck { kind: "mp4" }`
|
|
107
|
+
answers with the `hyperdecks export <slug> --video` command; give it to the
|
|
108
|
+
user to run (it records in real time, so a 4-minute deck takes about 5).
|
|
99
109
|
|
|
100
110
|
## Rules that matter
|
|
101
111
|
|
package/src/commands.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
2
3
|
import {
|
|
3
4
|
installHyperdecksSkill,
|
|
4
5
|
login,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
VERSION,
|
|
10
11
|
} from "./runtime.mjs";
|
|
11
12
|
import * as ops from "./ops.mjs";
|
|
13
|
+
import { exportVideo } from "./export-video.mjs";
|
|
12
14
|
import { presenterLine } from "./presenter.mjs";
|
|
13
15
|
|
|
14
16
|
export const playbackLabel = (p) => p.narrationFinished ? "narration finished"
|
|
@@ -409,6 +411,18 @@ commands:
|
|
|
409
411
|
review <slug> [--script <script.md>] [--json]
|
|
410
412
|
GPT-6 Luna reviews every slide (desktop + phone screenshots) and lists what to fix
|
|
411
413
|
review-job <jobId> [--json]
|
|
414
|
+
export <slug|deck-dir> --pdf [--notes] [--out <file.pdf>] [--share <token>] [--json]
|
|
415
|
+
a PDF of the released deck: one 1600x900 page per slide (a script page after each
|
|
416
|
+
with --notes), a footer with the deck's tracked link; kept 30 days
|
|
417
|
+
export <slug|deck-dir> --video [--size 1080p|720p|4k] [--shape 16:9|9:16|1:1] [--no-captions]
|
|
418
|
+
[--no-avatar] [--title-card] [--end-card [--link <url>]] [--upload] [--out <file.mp4>] [--json]
|
|
419
|
+
an MP4 of the narrated deck, recorded in real time (a 4-minute deck takes about
|
|
420
|
+
5): the avatar, voice and captions, none of the player's chrome. A slug records
|
|
421
|
+
the released copy, a deck folder its working copy. --title-card adds the title
|
|
422
|
+
and presenter (2s), --end-card the deck's "Video viewers" link (3s); --upload
|
|
423
|
+
keeps it with the deck's exports for 30 days
|
|
424
|
+
export list [<slug>] [--json] your exports and their download links
|
|
425
|
+
export rm <exportId> delete an export now
|
|
412
426
|
poster <slug> [--force] [--json]
|
|
413
427
|
regenerate the share-link poster (release makes one) and print its URL
|
|
414
428
|
credits [--limit <n>] [--json] show your credit balance and recent history
|
|
@@ -440,7 +454,7 @@ commands:
|
|
|
440
454
|
narrator default <name|id|dash> make it the default
|
|
441
455
|
narrator remove <name|id> remove it; decks already shared keep working
|
|
442
456
|
narrator voice <name|id> <voice> change its voice (one of yours, or dash)
|
|
443
|
-
narrator rename <name|id> <new name> rename it
|
|
457
|
+
narrator rename <name|id> [<new name>] [--say <spoken name>] rename it, or say how it is spoken ("" clears)
|
|
444
458
|
narrator face <name|id> <avatar> change its face (one of your avatars)
|
|
445
459
|
deck narrator <slug> <name|id|none|default> the deck's own narrator (none: captions only)
|
|
446
460
|
|
|
@@ -663,6 +677,53 @@ async function reviewJob(pos, opts) {
|
|
|
663
677
|
printReview(job);
|
|
664
678
|
}
|
|
665
679
|
|
|
680
|
+
// hyperdecks export <slug|deck-dir> --pdf: a local deck folder exports its
|
|
681
|
+
// released copy (the slug is --slug or the folder's name) with its own script.md
|
|
682
|
+
// (for its [reveal] cues and --notes); nothing renders locally.
|
|
683
|
+
export function exportTarget(arg, opts = {}, cwd = process.cwd()) {
|
|
684
|
+
const dir = [resolve(cwd, arg), join(cwd, "decks", arg)].find((d) => existsSync(join(d, "index.html")));
|
|
685
|
+
if (!dir) return { slug: arg, script: undefined };
|
|
686
|
+
const scriptPath = join(dir, "script.md");
|
|
687
|
+
return { slug: opts.slug || basename(dir), script: existsSync(scriptPath) ? readFileSync(scriptPath, "utf8") : undefined };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export const EXPORT_USAGE = "usage: export <slug|deck-dir> --pdf [--notes] [--out <file.pdf>] [--share <token>]\n" +
|
|
691
|
+
" export <slug|deck-dir> --video [--size 1080p|720p|4k] [--shape 16:9|9:16|1:1] [--no-captions] [--no-avatar] [--title-card] [--end-card [--link <url>]] [--upload] [--out <file.mp4>]";
|
|
692
|
+
|
|
693
|
+
async function exportCmd(pos, opts) {
|
|
694
|
+
const [arg] = pos;
|
|
695
|
+
const usage = EXPORT_USAGE;
|
|
696
|
+
if (!arg) throw new Error(usage);
|
|
697
|
+
if (opts.gif) throw new Error("the email GIF is `hyperdecks preview create`");
|
|
698
|
+
const video = opts.video || opts.mp4;
|
|
699
|
+
if (video && opts.pdf) throw new Error("pick one of --pdf and --video");
|
|
700
|
+
if (video) return void (await exportVideo(arg, opts));
|
|
701
|
+
if (!opts.pdf) throw new Error(`${usage}\n(pick --pdf or --video)`);
|
|
702
|
+
const { slug, script } = exportTarget(arg, opts);
|
|
703
|
+
if (slug !== arg) process.stderr.write(`deck folder ${arg} → slug ${slug} (pass --slug to change it)\n`);
|
|
704
|
+
process.stderr.write(`rendering ${slug} as a PDF${opts.notes ? " with notes" : ""}…\n`);
|
|
705
|
+
const out = await ops.exportDeck(opts, { slug, kind: "pdf", notes: !!opts.notes, script, shareToken: opts.share });
|
|
706
|
+
const file = opts.out === true ? out.filename : opts.out || out.filename;
|
|
707
|
+
await saveTo(file, out.url);
|
|
708
|
+
if (opts.json) return void console.log(JSON.stringify({ ...out, file }, null, 2));
|
|
709
|
+
console.log(`✓ ${out.pages} page(s), ${(out.bytes / 1e6).toFixed(1)} MB → ${file}`);
|
|
710
|
+
console.log(` footer link: ${out.link}`);
|
|
711
|
+
for (const w of out.warnings ?? []) console.log(` ⚠ ${w}`);
|
|
712
|
+
console.log(` download (30 days): ${out.url}`);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function exportList(pos, opts) {
|
|
716
|
+
const rows = await ops.listExports(opts, { slug: pos[0] });
|
|
717
|
+
if (opts.json) return void console.log(JSON.stringify(rows, null, 2));
|
|
718
|
+
if (!rows.length) return void console.log("no exports in the last 30 days");
|
|
719
|
+
for (const r of rows) {
|
|
720
|
+
const when = new Date(r.createdAt).toISOString().slice(0, 16).replace("T", " ");
|
|
721
|
+
const what = r.status === "ready" ? `${r.pages} pages, ${((r.bytes ?? 0) / 1e6).toFixed(1)} MB` : r.status === "failed" ? `failed: ${r.error}` : r.status;
|
|
722
|
+
console.log(`${r.exportId} ${when} ${r.slug} ${r.kind}${r.notes ? "+notes" : ""} ${what}`);
|
|
723
|
+
if (r.url) console.log(` ${r.url}`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
666
727
|
async function poster(pos, opts) {
|
|
667
728
|
const [slug] = pos;
|
|
668
729
|
if (!slug) throw new Error("usage: poster <slug> [--force] [--json]");
|
|
@@ -851,9 +912,11 @@ function commandTable(pos, rest, opts) {
|
|
|
851
912
|
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} speaks in ${out.voice} now`);
|
|
852
913
|
},
|
|
853
914
|
"narrator rename": async () => {
|
|
854
|
-
if (
|
|
855
|
-
const
|
|
856
|
-
|
|
915
|
+
if (Object.hasOwn(opts, "say") && (opts.say === undefined || String(opts.say).startsWith("--"))) throw new Error('--say needs a value (--say "" clears it)');
|
|
916
|
+
const say = Object.hasOwn(opts, "say") ? String(opts.say) : undefined;
|
|
917
|
+
if (rest.length < 1 || (rest.length < 2 && say === undefined)) throw new Error("usage: narrator rename <name|id> [<new name>] [--say <spoken name>]");
|
|
918
|
+
const out = await ops.renameNarrator(opts, rest[0], rest.slice(1).join(" "), say);
|
|
919
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.was} is ${out.name} now${out.spokenName ? `, said "${out.spokenName}"` : ""}`);
|
|
857
920
|
},
|
|
858
921
|
"narrator face": async () => {
|
|
859
922
|
if (rest.length < 2) throw new Error("usage: narrator face <name|id> <avatar>");
|
|
@@ -928,6 +991,13 @@ function commandTable(pos, rest, opts) {
|
|
|
928
991
|
review: () => review(pos.slice(1), opts),
|
|
929
992
|
"review-job": () => reviewJob(pos.slice(1), opts),
|
|
930
993
|
poster: () => poster(pos.slice(1), opts),
|
|
994
|
+
export: () => exportCmd(pos.slice(1), opts),
|
|
995
|
+
"export list": () => exportList(pos.slice(2), opts),
|
|
996
|
+
"export rm": async () => {
|
|
997
|
+
if (!pos[2]) throw new Error("usage: export rm <exportId>");
|
|
998
|
+
const out = await ops.removeExport(opts, { exportId: pos[2] });
|
|
999
|
+
console.log(opts.json ? JSON.stringify(out) : `✓ removed ${pos[2]}`);
|
|
1000
|
+
},
|
|
931
1001
|
credits: () => credits(pos.slice(1), opts),
|
|
932
1002
|
};
|
|
933
1003
|
}
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// hyperdecks export <slug|deck-dir> --video: a narrated deck as an MP4.
|
|
2
|
+
//
|
|
3
|
+
// Real time, as the email GIF preview records (preview-capture.mjs): Chromium
|
|
4
|
+
// opens the deck in the player's export mode (?export=1: no chrome, autoplay,
|
|
5
|
+
// no tracking; packages/player/export-mode.js) and the compositor's screencast
|
|
6
|
+
// is written to disk at 30 fps until the tour ends. The screencast has no
|
|
7
|
+
// sound, so the audio track is built from the clips themselves, each placed at
|
|
8
|
+
// the moment the player really started it (window.__hdExport), so the voice
|
|
9
|
+
// lands under the frames that show its captions and cues. An optional title
|
|
10
|
+
// card (2 s) and end card (3 s) are plain pages rendered the same size.
|
|
11
|
+
// ffmpeg (the CLI's portable @ffmpeg-installer build) mixes and encodes:
|
|
12
|
+
// H.264, AAC, +faststart.
|
|
13
|
+
import { copyFile, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { dirname, extname, join } from 'node:path';
|
|
16
|
+
import { execFile } from 'node:child_process';
|
|
17
|
+
import { promisify } from 'node:util';
|
|
18
|
+
import { allowCaptureDestination, routeCaptureRequest } from './preview-capture.mjs';
|
|
19
|
+
|
|
20
|
+
const run = promisify(execFile);
|
|
21
|
+
|
|
22
|
+
export const VIDEO_FPS = 30;
|
|
23
|
+
export const VIDEO_SIZES = Object.freeze({ '720p': 720, '1080p': 1080, '4k': 2160 });
|
|
24
|
+
export const VIDEO_SHAPES = Object.freeze(['16:9', '9:16', '1:1']);
|
|
25
|
+
export const TITLE_CARD_SECONDS = 2;
|
|
26
|
+
export const END_CARD_SECONDS = 3;
|
|
27
|
+
// What the player's clock may run past the manifest's total before the export
|
|
28
|
+
// is called stuck: slow clip loads and transitions add a little per slide.
|
|
29
|
+
const STUCK_GRACE_SECONDS = 60;
|
|
30
|
+
const MAX_CLIP_BYTES = 50 * 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
// Output pixels, and the viewport that draws them: 4K is the 1080p layout at
|
|
33
|
+
// device scale 2, so a slide is laid out the same and drawn sharper. 9:16 and
|
|
34
|
+
// 1:1 are viewports of that shape; the player's overlay layout letterboxes the
|
|
35
|
+
// 16:9 stage on the deck's page colour.
|
|
36
|
+
export function videoGeometry(size = '1080p', shape = '16:9') {
|
|
37
|
+
const short = VIDEO_SIZES[size];
|
|
38
|
+
if (!short) throw new Error(`--size must be one of ${Object.keys(VIDEO_SIZES).join(', ')} (got ${size})`);
|
|
39
|
+
if (!VIDEO_SHAPES.includes(shape)) throw new Error(`--shape must be one of ${VIDEO_SHAPES.join(', ')} (got ${shape})`);
|
|
40
|
+
const long = Math.round((short * 16) / 9);
|
|
41
|
+
const [width, height] = shape === '16:9' ? [long, short] : shape === '9:16' ? [short, long] : [short, short];
|
|
42
|
+
const scale = size === '4k' ? 2 : 1;
|
|
43
|
+
return { size, shape, width, height, scale, viewport: { width: width / scale, height: height / scale } };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The command's options, checked before anything launches.
|
|
47
|
+
export function videoOptions(opts = {}) {
|
|
48
|
+
for (const k of ['size', 'shape', 'out', 'link', 'slug']) if (k in opts && typeof opts[k] !== 'string') throw new Error(`--${k} requires a value`);
|
|
49
|
+
const geometry = videoGeometry(opts.size ?? '1080p', opts.shape ?? '16:9');
|
|
50
|
+
if (opts.out && extname(opts.out).toLowerCase() !== '.mp4') throw new Error(`--out must name an .mp4 file (got ${opts.out})`);
|
|
51
|
+
if (opts.link !== undefined) {
|
|
52
|
+
let u;
|
|
53
|
+
try { u = new URL(opts.link); } catch { throw new Error(`--link must be a URL (got ${opts.link})`); }
|
|
54
|
+
if (!['http:', 'https:'].includes(u.protocol)) throw new Error('--link must be an http(s) URL');
|
|
55
|
+
}
|
|
56
|
+
if (opts.notes || opts.share) throw new Error('--notes and --share are PDF options; a video has neither');
|
|
57
|
+
return {
|
|
58
|
+
geometry,
|
|
59
|
+
captions: !opts['no-captions'],
|
|
60
|
+
avatar: !opts['no-avatar'],
|
|
61
|
+
titleCard: !!opts['title-card'],
|
|
62
|
+
endCard: !!opts['end-card'],
|
|
63
|
+
link: opts.link ?? null,
|
|
64
|
+
out: opts.out ?? null,
|
|
65
|
+
upload: !!opts.upload,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The page the recorder opens: the deck in export mode.
|
|
70
|
+
export function exportPageUrl(url, { captions = true, avatar = true } = {}) {
|
|
71
|
+
const u = new URL(url);
|
|
72
|
+
u.searchParams.set('export', '1');
|
|
73
|
+
if (!captions) u.searchParams.set('captions', '0');
|
|
74
|
+
if (!avatar) u.searchParams.set('avatar', '0');
|
|
75
|
+
return u.href;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The player's own timeline (navigation.js deckTimeline): each slide runs its
|
|
79
|
+
// clip then its hold, back to back. `plan` is what the player logged at ready:
|
|
80
|
+
// [{ id, duration, hold }] in slide order.
|
|
81
|
+
export function planTimeline(plan) {
|
|
82
|
+
const seconds = plan.map((s) => Math.max(0, (Number(s.duration) || 0) + (Number(s.hold) || 0)));
|
|
83
|
+
const starts = seconds.map((_, i) => seconds.slice(0, i).reduce((a, b) => a + b, 0));
|
|
84
|
+
return { ids: plan.map((s) => s.id), seconds, starts, total: seconds.reduce((a, b) => a + b, 0) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Each clip's audio and where it goes in the video, in seconds: when the
|
|
88
|
+
// player started it, measured from the first recorded frame (`markMs`), after
|
|
89
|
+
// a title card of `lead` seconds. A clip the player never started (the tour
|
|
90
|
+
// stopped early) is left out; the caller warns.
|
|
91
|
+
export function audioTimeline(log, markMs, lead = 0) {
|
|
92
|
+
const audioOf = new Map((log.plan ?? []).map((s) => [s.id, s.audio]));
|
|
93
|
+
return (log.clips ?? [])
|
|
94
|
+
.map((c) => ({ id: c.id, audio: c.audio ?? audioOf.get(c.id) ?? null, offset: lead + Math.max(0, (c.at - markMs) / 1000) }))
|
|
95
|
+
.filter((c) => c.audio);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// How far each clip started from where the manifest's timeline puts it, for a
|
|
99
|
+
// warning: a slow clip load pushes everything after it later (the audio still
|
|
100
|
+
// follows the player, so the video stays in sync).
|
|
101
|
+
export function timelineDrift(log, markMs) {
|
|
102
|
+
const plan = planTimeline(log.plan ?? []);
|
|
103
|
+
const startOf = new Map(plan.ids.map((id, i) => [id, plan.starts[i]]));
|
|
104
|
+
return (log.clips ?? []).filter((c) => startOf.has(c.id)).map((c) => ({ id: c.id, drift: (c.at - markMs) / 1000 - startOf.get(c.id) }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ffmpeg args for the audio track, `seconds` long: silence up to the first
|
|
108
|
+
// clip, then each clip padded with silence (or cut) to where the next one
|
|
109
|
+
// starts, joined end to end. Clips never overlap (the player plays one at a
|
|
110
|
+
// time), so nothing is mixed or normalised, and it needs no clip durations.
|
|
111
|
+
// Only filters FFmpeg 4.1 has (the CLI's bundled build on Linux, Windows and
|
|
112
|
+
// Intel Macs): aevalsrc, aresample, aformat, apad, atrim, asetpts, concat.
|
|
113
|
+
export function audioMixArgs({ inputs, seconds, out }) {
|
|
114
|
+
const fmt = 'aresample=48000,aformat=sample_fmts=s16:channel_layouts=stereo';
|
|
115
|
+
const clips = [...inputs].filter((i) => i.offset < seconds).sort((a, b) => a.offset - b.offset);
|
|
116
|
+
const args = [];
|
|
117
|
+
for (const c of clips) args.push('-i', c.path);
|
|
118
|
+
const parts = [], labels = [];
|
|
119
|
+
const lead = clips.length ? clips[0].offset : seconds;
|
|
120
|
+
if (lead >= 0.001) {
|
|
121
|
+
parts.push(`aevalsrc=0|0:c=stereo:s=48000:d=${lead.toFixed(3)},${fmt}[lead]`);
|
|
122
|
+
labels.push('[lead]');
|
|
123
|
+
}
|
|
124
|
+
clips.forEach((c, n) => {
|
|
125
|
+
const end = n + 1 < clips.length ? clips[n + 1].offset : seconds;
|
|
126
|
+
parts.push(`[${n}:a]${fmt},apad,atrim=0:${Math.max(0.001, end - c.offset).toFixed(3)},asetpts=N/SR/TB[c${n}]`);
|
|
127
|
+
labels.push(`[c${n}]`);
|
|
128
|
+
});
|
|
129
|
+
parts.push(`${labels.join('')}concat=n=${labels.length}:v=0:a=1[mix]`);
|
|
130
|
+
return [...args, '-filter_complex', parts.join(';'), '-map', '[mix]', '-c:a', 'pcm_s16le', '-y', out];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ffmpeg args for the MP4: the numbered frames at `fps` and the mixed audio.
|
|
134
|
+
export function encodeArgs({ frames, fps = VIDEO_FPS, audio, width, height, out }) {
|
|
135
|
+
return [
|
|
136
|
+
'-framerate', String(fps), '-i', frames,
|
|
137
|
+
'-i', audio,
|
|
138
|
+
'-map', '0:v', '-map', '1:a',
|
|
139
|
+
'-vf', `scale=${width}:${height}:flags=lanczos,format=yuv420p`,
|
|
140
|
+
'-c:v', 'libx264', '-preset', 'fast', '-crf', '20', '-r', String(fps),
|
|
141
|
+
'-c:a', 'aac', '-b:a', '192k',
|
|
142
|
+
'-shortest', '-movflags', '+faststart', '-y', out,
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A computed style value inside <style>: entities are not decoded there, so it
|
|
147
|
+
// is not HTML-escaped; anything that could end the rule or the element goes.
|
|
148
|
+
const cssValue = (s) => String(s ?? '').replace(/[<>{};\\]/g, '');
|
|
149
|
+
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
150
|
+
|
|
151
|
+
// A title or end card: the first slide's colour, ink and heading font, centred
|
|
152
|
+
// text. `fonts` carries the deck's web fonts: its font stylesheet links and the
|
|
153
|
+
// @font-face rules it could read, resolved against `base` (the deck's page).
|
|
154
|
+
export function cardHtml({ lines, background = '#0b0b0f', color = '#ffffff', font = 'system-ui, sans-serif', width, height, fonts = { links: [], faces: '' }, base = null }) {
|
|
155
|
+
const [first, ...rest] = lines.filter(Boolean);
|
|
156
|
+
const unit = Math.min(width, height) / 1080;
|
|
157
|
+
const links = (fonts.links ?? []).filter((h) => /^https:\/\//.test(h)).map((h) => `<link rel="stylesheet" href="${escapeHtml(h)}">`).join('');
|
|
158
|
+
const faces = String(fonts.faces ?? '').replace(/<\/style/gi, '');
|
|
159
|
+
return `<!doctype html><html><head><meta charset="utf-8">${base ? `<base href="${escapeHtml(base)}">` : ''}${links}<style>${faces}
|
|
160
|
+
html,body{margin:0;height:100%;background:${cssValue(background)};color:${cssValue(color)};font-family:${cssValue(font)}}
|
|
161
|
+
body{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:${Math.round(28 * unit)}px;text-align:center;padding:0 8%;box-sizing:border-box}
|
|
162
|
+
h1{font-size:${Math.round(84 * unit)}px;line-height:1.1;margin:0;font-weight:700;max-width:100%;overflow-wrap:anywhere}
|
|
163
|
+
p{font-size:${Math.round(40 * unit)}px;margin:0;opacity:.8;max-width:100%;overflow-wrap:anywhere}
|
|
164
|
+
</style></head><body><h1>${escapeHtml(first ?? '')}</h1>${rest.map((l) => `<p>${escapeHtml(l)}</p>`).join('')}</body></html>`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const pad = (n) => String(n).padStart(6, '0');
|
|
168
|
+
const frameName = (n) => `frame-${pad(n)}.jpg`;
|
|
169
|
+
const clock = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
|
|
170
|
+
|
|
171
|
+
async function ffmpegPath() {
|
|
172
|
+
try {
|
|
173
|
+
const path = (await import('@ffmpeg-installer/ffmpeg')).default.path;
|
|
174
|
+
await run(path, ['-version'], { timeout: 10000 });
|
|
175
|
+
return path;
|
|
176
|
+
} catch {
|
|
177
|
+
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.');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function ffmpeg(path, args, timeoutMs) {
|
|
182
|
+
try {
|
|
183
|
+
await run(path, ['-hide_banner', '-loglevel', 'error', '-nostdin', ...args], { timeout: Math.round(timeoutMs), maxBuffer: 4 * 1024 * 1024, env: { PATH: dirname(path), ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}) } });
|
|
184
|
+
} catch (e) {
|
|
185
|
+
throw new Error(`FFmpeg failed${e.killed ? ` (timed out after ${Math.round(timeoutMs / 1000)}s)` : ''}: ${String(e.stderr || e.message).trim().split('\n').slice(-3).join(' ')}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Bounds a page.evaluate on the Node side (a busy main thread can hold it).
|
|
190
|
+
async function within(promise, ms, fallback) {
|
|
191
|
+
promise.catch(() => {});
|
|
192
|
+
let timer;
|
|
193
|
+
try { return await Promise.race([promise, new Promise((ok) => { timer = setTimeout(() => ok(fallback), ms); })]); }
|
|
194
|
+
finally { clearTimeout(timer); }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Screencast frames into numbered slots from `first`, one per 1/fps from
|
|
198
|
+
// `mark` (epoch seconds): each slot holds the newest frame up to half a slot
|
|
199
|
+
// past its time. `stop(seconds)` fills the slots up to that many seconds after `mark`
|
|
200
|
+
// with the last frame and returns how many it wrote.
|
|
201
|
+
function startFrameSlots(session, { dir, fps, first }) {
|
|
202
|
+
let mark = Infinity, last = null, next = 0, writes = Promise.resolve(), received = 0, stopped = false, failed = null;
|
|
203
|
+
// The first failed write (a full disk, say) is kept and thrown by check()/stop(), never left unhandled.
|
|
204
|
+
const write = (data) => {
|
|
205
|
+
const path = join(dir, frameName(first + next++));
|
|
206
|
+
writes = writes.then(() => (failed ? null : writeFile(path, data))).catch((e) => { failed ??= e; });
|
|
207
|
+
};
|
|
208
|
+
session.on('Page.screencastFrame', ({ data, metadata, sessionId }) => {
|
|
209
|
+
session.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
|
|
210
|
+
const ts = metadata.timestamp ?? Date.now() / 1000;
|
|
211
|
+
if (stopped || ts < mark) return;
|
|
212
|
+
// A slot takes the frame nearest its time: one stamped up to half a slot after it beats the one before.
|
|
213
|
+
while (last && mark + (next + 0.5) / fps < ts) write(last);
|
|
214
|
+
last = Buffer.from(data, 'base64');
|
|
215
|
+
received++;
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
get received() { return received; },
|
|
219
|
+
check() { if (failed) throw new Error(`could not write a frame: ${failed.message}`); },
|
|
220
|
+
get latest() { return mark + next / fps; },
|
|
221
|
+
begin(poster, at) { last = poster; mark = at; },
|
|
222
|
+
async stop(seconds) {
|
|
223
|
+
stopped = true;
|
|
224
|
+
const total = Math.max(1, Math.round(seconds * fps));
|
|
225
|
+
while (next < total) write(last);
|
|
226
|
+
await writes;
|
|
227
|
+
if (failed) throw new Error(`could not write a frame: ${failed.message}`);
|
|
228
|
+
return total;
|
|
229
|
+
},
|
|
230
|
+
settled: () => writes,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The player's export log, read without trusting the page to answer quickly.
|
|
235
|
+
const readLog = (page) => within(page.evaluate(() => {
|
|
236
|
+
const l = window.__hdExport;
|
|
237
|
+
return l ? JSON.parse(JSON.stringify(l)) : null;
|
|
238
|
+
}), 5000, undefined);
|
|
239
|
+
|
|
240
|
+
// Records `config.url` (a page that loads player/v1) and writes the MP4.
|
|
241
|
+
// config: { url, geometry, captions, avatar, titleCard, endCard, title,
|
|
242
|
+
// presenter, link, out, firstPartyOrigins, playerDir }. `log` gets progress
|
|
243
|
+
// lines. Returns { file, seconds, width, height, bytes, slides, clips, warnings }.
|
|
244
|
+
export async function recordVideo(config, { log = () => {}, chromium: injected } = {}) {
|
|
245
|
+
const { geometry, out } = config;
|
|
246
|
+
const ff = await ffmpegPath();
|
|
247
|
+
const chromium = injected ?? (await import('playwright')).chromium;
|
|
248
|
+
const warnings = [];
|
|
249
|
+
let browser, temp, phase = 'launch';
|
|
250
|
+
try {
|
|
251
|
+
temp = await mkdtemp(join(tmpdir(), 'hyperdecks-video-'));
|
|
252
|
+
try {
|
|
253
|
+
browser = await chromium.launch({
|
|
254
|
+
// config.sandbox: false only for CI runners whose AppArmor blocks Chromium's sandbox (the smoke suite).
|
|
255
|
+
headless: true, timeout: 30000, chromiumSandbox: config.sandbox ?? process.platform === 'linux',
|
|
256
|
+
args: ['--autoplay-policy=no-user-gesture-required', '--mute-audio', '--enable-unsafe-swiftshader'],
|
|
257
|
+
env: { ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}), PATH: process.env.PATH, TMPDIR: temp, HOME: temp },
|
|
258
|
+
});
|
|
259
|
+
} catch (e) {
|
|
260
|
+
throw new Error(`Could not launch isolated Chromium (${String(e?.message ?? e).split('\n')[0]}). Run \`hyperdecks preview setup\` to install it.`);
|
|
261
|
+
}
|
|
262
|
+
const pageUrl = exportPageUrl(config.url, config);
|
|
263
|
+
const context = await browser.newContext({ viewport: geometry.viewport, deviceScaleFactor: geometry.scale, serviceWorkers: 'block', acceptDownloads: false });
|
|
264
|
+
let blocked = 0;
|
|
265
|
+
await context.route('**/*', async (route) => { if (!await routeCaptureRequest(route, pageUrl, config.firstPartyOrigins)) blocked++; });
|
|
266
|
+
// A development checkout plays its own player/v1 (HYPERDECKS_PLAYER_DIR), not the released one.
|
|
267
|
+
if (config.playerDir) {
|
|
268
|
+
await context.route(/\/player\/v1\/[^?#]+/, async (route) => {
|
|
269
|
+
const name = new URL(route.request().url()).pathname.replace(/^.*\/player\/v1\//, '');
|
|
270
|
+
if (name.split('/').some((s) => s === '..' || s.startsWith('.'))) return route.abort();
|
|
271
|
+
const type = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png' }[extname(name)] ?? 'application/octet-stream';
|
|
272
|
+
try { await route.fulfill({ body: await readFile(join(config.playerDir, name)), contentType: type }); }
|
|
273
|
+
catch { await route.abort(); }
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
await context.routeWebSocket('**/*', (ws) => ws.close());
|
|
277
|
+
await context.addInitScript(() => {
|
|
278
|
+
navigator.sendBeacon = () => false;
|
|
279
|
+
window.open = () => null;
|
|
280
|
+
// The player's export mode waits for this before it plays (export-mode.js).
|
|
281
|
+
window.__hdExportWait = new Promise((go) => { window.__hdExportGo = go; });
|
|
282
|
+
});
|
|
283
|
+
const page = await context.newPage();
|
|
284
|
+
page.setDefaultTimeout(30000);
|
|
285
|
+
const pageErrors = [];
|
|
286
|
+
page.on('pageerror', (e) => pageErrors.push(String(e?.message ?? e)));
|
|
287
|
+
phase = 'page load';
|
|
288
|
+
await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
289
|
+
phase = 'player ready';
|
|
290
|
+
const pageSaid = () => (pageErrors.length ? ` (page errors: ${pageErrors.slice(0, 3).join('; ').slice(0, 400)})` : '');
|
|
291
|
+
await page.waitForSelector('body[data-hd-ready="true"]', { timeout: 60000 }).catch((e) => {
|
|
292
|
+
throw new Error(`the deck never became ready (${e.message.split('\n')[0]})${pageSaid()}; is it on player/v1 (https://decks.hypersocial.ai/player/v1/player.js)?`);
|
|
293
|
+
});
|
|
294
|
+
let state = await readLog(page);
|
|
295
|
+
if (!state) throw new Error(`the deck's player has no export mode: it must load https://decks.hypersocial.ai/player/v1/player.js (an older copy, or its own runtime, cannot be recorded)${pageSaid()}`);
|
|
296
|
+
if (state.manifestError) throw new Error(`the deck's narration manifest could not load (${state.manifestError.url}: ${state.manifestError.message}); nothing was recorded`);
|
|
297
|
+
if (state.silent) throw new Error('the deck has no narration to record: run `hyperdecks narrate` first');
|
|
298
|
+
const plan = planTimeline(state.plan ?? []);
|
|
299
|
+
// The cards wear the first slide's colours and its heading's font.
|
|
300
|
+
const look = await within(page.evaluate(() => {
|
|
301
|
+
const first = document.querySelector('section.slide');
|
|
302
|
+
const cs = (n) => n && getComputedStyle(n);
|
|
303
|
+
const solid = (c) => c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent';
|
|
304
|
+
const slide = cs(first), body = cs(document.body), heading = cs(first?.querySelector('h1, h2, h3'));
|
|
305
|
+
const background = [slide?.backgroundColor, body?.backgroundColor].find(solid);
|
|
306
|
+
// Font stylesheets on another origin can't be read: link them; read the rest's @font-face rules.
|
|
307
|
+
const links = [...document.querySelectorAll('link[rel~="stylesheet"]')].map((l) => l.href).filter((h) => /^https:\/\/fonts\.(googleapis|bunny)\.net\//.test(h) || /^https:\/\/use\.typekit\.net\//.test(h));
|
|
308
|
+
const faces = [];
|
|
309
|
+
for (const sheet of document.styleSheets) {
|
|
310
|
+
try { for (const rule of sheet.cssRules) if (rule instanceof CSSFontFaceRule) faces.push(rule.cssText); } catch { /* cross-origin */ }
|
|
311
|
+
}
|
|
312
|
+
return { background: background ?? '#0b0b0f', color: heading?.color ?? slide?.color ?? '#ffffff', font: heading?.fontFamily ?? slide?.fontFamily ?? 'system-ui, sans-serif', fonts: { links, faces: faces.join('\n') } };
|
|
313
|
+
}), 5000, {});
|
|
314
|
+
// Every clip's audio is fetched before recording: a bad URL fails now, not after minutes of real time.
|
|
315
|
+
phase = 'clip audio';
|
|
316
|
+
const audioFiles = new Map();
|
|
317
|
+
for (const [n, url] of [...new Set((state.plan ?? []).map((s) => s.audio).filter(Boolean))].entries()) {
|
|
318
|
+
const id = state.plan.find((s) => s.audio === url).id;
|
|
319
|
+
if (!await allowCaptureDestination(url, pageUrl)) throw new Error(`clip ${id}'s audio is not on a public address: ${url}`);
|
|
320
|
+
let res;
|
|
321
|
+
try { res = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(30000) }); }
|
|
322
|
+
catch (e) { throw new Error(`could not download clip ${id}'s audio (${url}): ${e.cause?.message ?? e.message}`); }
|
|
323
|
+
if (!res.ok) throw new Error(`could not download clip ${id}'s audio (${res.status} from ${url})`);
|
|
324
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
325
|
+
if (bytes.length > MAX_CLIP_BYTES) throw new Error(`clip ${id}'s audio is over ${MAX_CLIP_BYTES / 1024 / 1024} MB`);
|
|
326
|
+
const path = join(temp, `clip-${n}${extname(new URL(url).pathname).slice(0, 6) || '.audio'}`);
|
|
327
|
+
await writeFile(path, bytes);
|
|
328
|
+
audioFiles.set(url, path);
|
|
329
|
+
}
|
|
330
|
+
log(`recording ${plan.ids.length} slides${plan.total ? `, about ${clock(plan.total)} in real time,` : ''} at ${geometry.width}×${geometry.height} (${geometry.shape})`);
|
|
331
|
+
|
|
332
|
+
// The title card's frames come first.
|
|
333
|
+
const lead = config.titleCard ? TITLE_CARD_SECONDS : 0;
|
|
334
|
+
const card = async (lines) => {
|
|
335
|
+
const p = await context.newPage();
|
|
336
|
+
await p.setContent(cardHtml({ lines, ...look, base: pageUrl, width: geometry.viewport.width, height: geometry.viewport.height }), { waitUntil: 'load', timeout: 15000 }).catch((e) => warnings.push(`a card's fonts did not finish loading (${e.message.split('\n')[0]}); it may show a fallback font`));
|
|
337
|
+
await within(p.evaluate(() => document.fonts.ready.then(() => true)), 5000, false);
|
|
338
|
+
const shot = await p.screenshot({ type: 'jpeg', quality: 92 });
|
|
339
|
+
await p.close();
|
|
340
|
+
return shot;
|
|
341
|
+
};
|
|
342
|
+
let frameCount = 0;
|
|
343
|
+
const repeat = async (image, seconds) => {
|
|
344
|
+
const first = join(temp, frameName(frameCount));
|
|
345
|
+
await writeFile(first, image);
|
|
346
|
+
const n = Math.round(seconds * VIDEO_FPS);
|
|
347
|
+
for (let i = 1; i < n; i++) await copyFile(first, join(temp, frameName(frameCount + i)));
|
|
348
|
+
frameCount += n;
|
|
349
|
+
};
|
|
350
|
+
if (config.titleCard) {
|
|
351
|
+
phase = 'title card';
|
|
352
|
+
await repeat(await card([config.title, config.presenter ? `Presented by ${config.presenter}` : null]), TITLE_CARD_SECONDS);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
phase = 'recording';
|
|
356
|
+
const session = await context.newCDPSession(page);
|
|
357
|
+
const slots = startFrameSlots(session, { dir: temp, fps: VIDEO_FPS, first: frameCount });
|
|
358
|
+
await session.send('Page.startScreencast', { format: 'jpeg', quality: 90, maxWidth: geometry.width, maxHeight: geometry.height, everyNthFrame: 1 });
|
|
359
|
+
// A screenshot forces a fresh frame: it, and every compositor frame after it, show the deck as it starts.
|
|
360
|
+
const poster = await page.screenshot({ type: 'jpeg', quality: 90 });
|
|
361
|
+
const markMs = Date.now();
|
|
362
|
+
slots.begin(poster, markMs / 1000);
|
|
363
|
+
await page.evaluate(() => window.__hdExportGo());
|
|
364
|
+
// Stuck: no slide landed and no clip started for longer than the current
|
|
365
|
+
// slide's planned clip and hold (unknown for a data-clip deck) plus a grace.
|
|
366
|
+
const secondsOf = new Map(plan.ids.map((id, i) => [id, plan.seconds[i]]));
|
|
367
|
+
let shown = 0, progress = 0, movedAt = Date.now();
|
|
368
|
+
for (;;) {
|
|
369
|
+
await new Promise((ok) => setTimeout(ok, 500));
|
|
370
|
+
slots.check();
|
|
371
|
+
const next = await readLog(page);
|
|
372
|
+
if (next) state = next;
|
|
373
|
+
const failedClip = state.errors?.[0];
|
|
374
|
+
if (failedClip) throw new Error(`narration failed on ${failedClip.id} (${failedClip.message}); the tour cannot go on`);
|
|
375
|
+
const moved = (state.slides?.length ?? 0) + (state.clips?.length ?? 0);
|
|
376
|
+
if (moved !== progress) { progress = moved; movedAt = Date.now(); }
|
|
377
|
+
for (; shown < (state.slides?.length ?? 0); shown++) {
|
|
378
|
+
const s = state.slides[shown];
|
|
379
|
+
const i = plan.ids.indexOf(s.id);
|
|
380
|
+
log(` slide ${i + 1}/${plan.ids.length} ${s.id} · ${clock(Math.max(0, (s.at - markMs) / 1000))}${plan.total ? ` of ${clock(plan.total)}` : ''}`);
|
|
381
|
+
}
|
|
382
|
+
if (state.done) break;
|
|
383
|
+
const at = state.slides?.at(-1)?.id ?? plan.ids[0];
|
|
384
|
+
const allowed = ((secondsOf.get(at) || 0) * 1.5 + STUCK_GRACE_SECONDS) * 1000;
|
|
385
|
+
if (Date.now() - movedAt > allowed) throw new Error(`the tour stopped on slide ${at}: nothing moved for ${Math.round(allowed / 1000)}s${pageSaid()}`);
|
|
386
|
+
}
|
|
387
|
+
const seconds = Math.max(0.5, (state.doneAt - markMs) / 1000);
|
|
388
|
+
// Let the compositor deliver the frames up to the end before stopping.
|
|
389
|
+
const waitUntil = Date.now() + 1500;
|
|
390
|
+
while (slots.latest < markMs / 1000 + seconds && Date.now() < waitUntil) await new Promise((ok) => setTimeout(ok, 50));
|
|
391
|
+
await session.send('Page.stopScreencast').catch(() => {});
|
|
392
|
+
frameCount += await slots.stop(seconds);
|
|
393
|
+
if (!slots.received) throw new Error('the screencast delivered no frames after the first; the capture broke (nothing would move in the video)');
|
|
394
|
+
// Drift means something only when every clip's planned length is known (a data-clip deck's are not).
|
|
395
|
+
const drift = (state.plan ?? []).every((s) => !s.audio || s.duration > 0) ? timelineDrift(state, markMs) : [];
|
|
396
|
+
const worst = drift.reduce((a, d) => (Math.abs(d.drift) > Math.abs(a.drift) ? d : a), { drift: 0 });
|
|
397
|
+
if (Math.abs(worst.drift) > 2) warnings.push(`the player ran up to ${worst.drift.toFixed(1)}s off the manifest's timeline (by ${worst.id}: clip loads, transitions); each clip's audio is placed where it really started, so the voice stays in sync`);
|
|
398
|
+
|
|
399
|
+
if (config.endCard) {
|
|
400
|
+
phase = 'end card';
|
|
401
|
+
await repeat(await card([config.title, config.link]), END_CARD_SECONDS);
|
|
402
|
+
}
|
|
403
|
+
phase = 'browser close';
|
|
404
|
+
await context.close();
|
|
405
|
+
await browser.close();
|
|
406
|
+
browser = null;
|
|
407
|
+
if (blocked) warnings.push(`${blocked} request(s) blocked (tracking, navigation or unsafe destinations); nothing was recorded as a view.`);
|
|
408
|
+
|
|
409
|
+
phase = 'audio';
|
|
410
|
+
const clips = audioTimeline(state, markMs, lead);
|
|
411
|
+
const missing = plan.ids.filter((id) => !clips.some((c) => c.id === id) && (state.plan.find((s) => s.id === id)?.audio));
|
|
412
|
+
if (missing.length) warnings.push(`no audio started for ${missing.join(', ')}`);
|
|
413
|
+
const inputs = clips.map((c) => {
|
|
414
|
+
const path = audioFiles.get(c.audio);
|
|
415
|
+
if (!path) throw new Error(`clip ${c.id} played audio the plan did not list (${c.audio})`);
|
|
416
|
+
return { path, offset: c.offset };
|
|
417
|
+
});
|
|
418
|
+
const total = frameCount / VIDEO_FPS;
|
|
419
|
+
const audio = join(temp, 'audio.wav');
|
|
420
|
+
await ffmpeg(ff, audioMixArgs({ inputs, seconds: total, out: audio }), 300000);
|
|
421
|
+
|
|
422
|
+
phase = 'encode';
|
|
423
|
+
log(`encoding ${clock(total)} of video…`);
|
|
424
|
+
// Encoded beside the temp frames, then moved into place: a failed encode never leaves a broken file at `out`.
|
|
425
|
+
const encoded = join(temp, 'video.mp4');
|
|
426
|
+
await ffmpeg(ff, encodeArgs({ frames: join(temp, 'frame-%06d.jpg'), audio, width: geometry.width, height: geometry.height, out: encoded }), Math.round(Math.max(300000, total * 20000) * geometry.scale));
|
|
427
|
+
await rename(encoded, out).catch(async (e) => {
|
|
428
|
+
if (e.code !== 'EXDEV') throw e;
|
|
429
|
+
await copyFile(encoded, out);
|
|
430
|
+
});
|
|
431
|
+
const bytes = (await stat(out)).size;
|
|
432
|
+
if (pageErrors.length) warnings.push(`${pageErrors.length} page error(s) while recording: ${pageErrors.slice(0, 2).join('; ').slice(0, 300)}`);
|
|
433
|
+
return { file: out, seconds: total, width: geometry.width, height: geometry.height, bytes, slides: plan.ids.length, clips: clips.length, warnings };
|
|
434
|
+
} catch (e) {
|
|
435
|
+
throw new Error(`Video export failed during ${phase}: ${e.message}`);
|
|
436
|
+
} finally {
|
|
437
|
+
if (browser) await browser.close().catch(() => {});
|
|
438
|
+
if (temp) await rm(temp, { recursive: true, force: true });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ---- the command ------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
const titleOf = (html) => (/<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1] ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").trim();
|
|
445
|
+
const mb = (n) => `${(n / 1e6).toFixed(1)} MB`;
|
|
446
|
+
|
|
447
|
+
// hyperdecks export <slug|deck-dir> --video: a deck folder records its working
|
|
448
|
+
// copy as `hyperdecks play` serves it (the owner's face added in memory, the
|
|
449
|
+
// manifest index.html points at); a slug records the released copy. Progress
|
|
450
|
+
// goes to stderr, the result to stdout.
|
|
451
|
+
export async function exportVideo(arg, opts = {}, { log = (l) => process.stderr.write(`${l}\n`), print = (l) => console.log(l), cwd = process.cwd(), deps = {} } = {}) {
|
|
452
|
+
const o = videoOptions(opts);
|
|
453
|
+
const { existsSync, readFileSync } = await import('node:fs');
|
|
454
|
+
const { resolve, join: joinPath, basename } = await import('node:path');
|
|
455
|
+
const ops = deps.ops ?? await import('./ops.mjs');
|
|
456
|
+
const local = [resolve(cwd, arg), joinPath(cwd, 'decks', arg)].find((d) => existsSync(joinPath(d, 'index.html')));
|
|
457
|
+
// --upload files the MP4 under a deck you own: checked before minutes of recording.
|
|
458
|
+
if (o.upload) await ops.ownedDeck(opts, local ? (opts.slug || basename(local)) : arg);
|
|
459
|
+
let source;
|
|
460
|
+
if (local) {
|
|
461
|
+
const { startPlay } = deps.play ?? await import('./play.mjs');
|
|
462
|
+
const html = readFileSync(joinPath(local, 'index.html'), 'utf8');
|
|
463
|
+
const session = await startPlay([local], { ...opts, 'no-open': true }, { log: (l) => { if (!l.startsWith('▶')) log(l); }, cwd });
|
|
464
|
+
source = { slug: session.slug, title: titleOf(html) || session.slug, url: session.url, close: session.close, presenter: session.who?.face ?? null };
|
|
465
|
+
} else {
|
|
466
|
+
const deck = await ops.releasedDeck(opts, arg);
|
|
467
|
+
const { serveSource } = await import('./preview-capture.mjs');
|
|
468
|
+
const server = await serveSource({ html: deck.html });
|
|
469
|
+
source = { slug: arg, title: deck.title, url: server.url, close: server.close, presenter: null };
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
let link = o.link;
|
|
473
|
+
if (o.endCard && !link) {
|
|
474
|
+
try { link = await ops.videoShareLink(opts, source.slug); }
|
|
475
|
+
catch (e) { throw new Error(`the end card needs the deck's link: ${ops.errorText?.(e) ?? e.message}. Pass --link <url> to name one`); }
|
|
476
|
+
}
|
|
477
|
+
const out = resolve(cwd, o.out ?? `${source.slug}.mp4`);
|
|
478
|
+
log(`recording ${source.slug}${local ? ' (working copy)' : ' (released copy)'} → ${out}`);
|
|
479
|
+
const result = await recordVideo({
|
|
480
|
+
url: source.url, geometry: o.geometry, captions: o.captions, avatar: o.avatar, titleCard: o.titleCard, endCard: o.endCard,
|
|
481
|
+
title: source.title, presenter: source.presenter, link, out, playerDir: process.env.HYPERDECKS_PLAYER_DIR || null,
|
|
482
|
+
}, { log });
|
|
483
|
+
let uploaded = null;
|
|
484
|
+
if (o.upload) {
|
|
485
|
+
log(`uploading ${mb(result.bytes)}…`);
|
|
486
|
+
try {
|
|
487
|
+
uploaded = await ops.uploadVideoExport(opts, { slug: source.slug, bytes: await readFile(out), seconds: result.seconds, width: result.width, height: result.height });
|
|
488
|
+
} catch (e) {
|
|
489
|
+
throw new Error(`${ops.errorText?.(e) ?? e.message}. The MP4 is saved at ${out}: share the file itself, or fix that and re-run with --upload (it records again)`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const summary = { ...result, slug: source.slug, link: link ?? null, ...(uploaded ? { exportId: uploaded.exportId, url: uploaded.url, expiresAt: uploaded.expiresAt } : {}) };
|
|
493
|
+
if (opts.json) { print(JSON.stringify(summary, null, 2)); return summary; }
|
|
494
|
+
print(`✓ ${clock(result.seconds)} at ${result.width}×${result.height}, ${mb(result.bytes)} → ${out}`);
|
|
495
|
+
if (link && o.endCard) print(` end card link: ${link}`);
|
|
496
|
+
for (const w of result.warnings) print(` ⚠ ${w}`);
|
|
497
|
+
if (uploaded) print(` on the deck page, and download (30 days): ${uploaded.url}`);
|
|
498
|
+
return summary;
|
|
499
|
+
} finally {
|
|
500
|
+
await source.close();
|
|
501
|
+
}
|
|
502
|
+
}
|
package/src/firstDeck.mjs
CHANGED
|
@@ -6,7 +6,8 @@ import { packageText } from "./files.mjs";
|
|
|
6
6
|
import { slugify } from "./importer.mjs";
|
|
7
7
|
|
|
8
8
|
export const FIRST_DECK_LAYOUTS = "hero,features,closer";
|
|
9
|
-
export
|
|
9
|
+
export { DASH_AVATAR } from "./presenter.mjs";
|
|
10
|
+
import { DASH_AVATAR } from "./presenter.mjs";
|
|
10
11
|
export const ANSWER_MAX = { name: 40, about: 80, ask: 80 };
|
|
11
12
|
|
|
12
13
|
// One answer as it goes on a slide and into the script: one line, no stage-tag
|
package/src/narrator.mjs
CHANGED
|
@@ -11,7 +11,7 @@ const interactiveOf = (opts) => !opts.json && !!process.stdin.isTTY && !!process
|
|
|
11
11
|
export async function narratorList(opts, { log = console.log } = {}) {
|
|
12
12
|
const out = await ops.listNarrators(opts);
|
|
13
13
|
if (opts.json) return out;
|
|
14
|
-
for (const n of out.narrators) log(`${n.isDefault ? "*" : " "} ${n.name}\t${n.face ?? "(no face)"} · ${n.voice ? `${n.voice}${n.voiceReady ? "" : " (not ready)"}` : "(voice removed)"}\t${n.id}`);
|
|
14
|
+
for (const n of out.narrators) log(`${n.isDefault ? "*" : " "} ${n.name}${n.spokenName && n.spokenName !== n.name ? ` (said "${n.spokenName}")` : ""}\t${n.face ?? "(no face)"} · ${n.voice ? `${n.voice}${n.voiceReady ? "" : " (not ready)"}` : "(voice removed)"}\t${n.id}`);
|
|
15
15
|
if (out.note) log(`\n${out.note}`);
|
|
16
16
|
log("\n* the default; decks use it unless they set their own (deck narrator <slug> <id>)");
|
|
17
17
|
return out;
|
package/src/ops.mjs
CHANGED
|
@@ -200,6 +200,99 @@ export async function generatePoster(opts, fields = {}) {
|
|
|
200
200
|
return (await client(opts)).action(api.posters.generatePoster, args);
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
// ---- deck exports (convex/exports.ts) -------------------------------------
|
|
204
|
+
|
|
205
|
+
// Render a PDF of a released deck you own (one 1600x900 page per slide, a notes
|
|
206
|
+
// page after each with notes=true, a footer with the deck's tracked link) and
|
|
207
|
+
// keep it 30 days. Returns { exportId, url, filename, bytes, pages, slides,
|
|
208
|
+
// notes, link, expiresAt }. Waits for the file (about a second per slide).
|
|
209
|
+
export async function exportDeck(opts, fields = {}) {
|
|
210
|
+
if (!fields.slug) throw new Error("slug is required");
|
|
211
|
+
const kind = fields.kind ?? "pdf";
|
|
212
|
+
if (kind === "mp4") throw new Error(videoFromCli(String(fields.slug)));
|
|
213
|
+
if (kind !== "pdf") throw new Error(`kind "${kind}" is not supported: "pdf", or "mp4" from the CLI`);
|
|
214
|
+
const args = { slug: String(fields.slug), kind: "pdf" };
|
|
215
|
+
if (fields.notes) args.notes = true;
|
|
216
|
+
if (fields.script) args.script = String(fields.script);
|
|
217
|
+
if (fields.shareToken) args.shareToken = String(fields.shareToken);
|
|
218
|
+
return (await client(opts)).action(api.exports.create, args);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// What export_deck { kind: "mp4" } answers (the same words as convex/exports.ts videoFromCli).
|
|
222
|
+
export const videoFromCli = (slug) =>
|
|
223
|
+
`video exports run from the CLI until the render worker ships: \`hyperdecks export ${slug} --video --out ${slug}.mp4\` ` +
|
|
224
|
+
"records the narrated deck in real time (add --upload to keep the MP4 with the deck's exports, --shape 9:16 or 1:1, --size 720p or 4k).";
|
|
225
|
+
|
|
226
|
+
// The released copy of a deck you own, for `export --video <slug>`: { slug, title, html }.
|
|
227
|
+
// Owner only, as export_deck is: a video is the owner's to make.
|
|
228
|
+
export async function ownedDeck(opts, slug) {
|
|
229
|
+
const c = await client(opts);
|
|
230
|
+
const [deck, me] = await Promise.all([c.query(api.decks.get, { slug }), c.query(api.decks.whoami, {})]);
|
|
231
|
+
if (!me) throw new Error("not logged in: run `hyperdecks login` first");
|
|
232
|
+
if (!deck) throw new Error(`deck not found: ${slug} (register it first, or pass --slug)`);
|
|
233
|
+
if (!me.isAdmin && deck.ownerId !== me.subject) throw new Error(`forbidden: ${slug} is not your deck`);
|
|
234
|
+
return deck;
|
|
235
|
+
}
|
|
236
|
+
export async function releasedDeck(opts, slug) {
|
|
237
|
+
const deck = await ownedDeck(opts, slug);
|
|
238
|
+
if (deck.status === "archived") throw new Error(`${slug} is archived; restore it before exporting`);
|
|
239
|
+
let html = deck.html ?? null;
|
|
240
|
+
if (!html && deck.htmlUrl) {
|
|
241
|
+
const res = await fetch(deck.htmlUrl, { signal: AbortSignal.timeout(30000) });
|
|
242
|
+
if (!res.ok) throw new Error(`could not read ${slug}'s released HTML (${res.status})`);
|
|
243
|
+
html = await res.text();
|
|
244
|
+
}
|
|
245
|
+
if (!html) throw new Error(`${slug} has no released HTML yet; release it first`);
|
|
246
|
+
return { slug, title: deck.title ?? slug, html };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The link a video's end card shows: the deck's own "Video viewers" link, made
|
|
250
|
+
// on first use, so a viewer who types it in counts as a view of that link and
|
|
251
|
+
// never as a recipient's (their views and view limit).
|
|
252
|
+
export const VIDEO_RECIPIENT = "Video viewers";
|
|
253
|
+
export async function videoShareLink(opts, slug) {
|
|
254
|
+
const live = (await listShares(opts, slug)).find((s) => s.recipient === VIDEO_RECIPIENT && s.enabled && !s.qa && !s.archived && !s.requireEmail && (s.expiresAt == null || s.expiresAt > Date.now()) && (s.viewLimit == null || s.opens < s.viewLimit));
|
|
255
|
+
const token = live?.token ?? (await createShare(opts, { slug, recipient: VIDEO_RECIPIENT })).token;
|
|
256
|
+
return `${target(opts).shareBase}/v/${token}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// `export --video --upload`: file the MP4 with the deck's exports (30 days).
|
|
260
|
+
// Returns { exportId, url, expiresAt }.
|
|
261
|
+
export async function uploadVideoExport(opts, { slug, bytes, seconds, width, height }, { fetchImpl = fetch, timeoutMs = 30 * 60_000 } = {}) {
|
|
262
|
+
const c = await client(opts);
|
|
263
|
+
const { exportId, uploadUrl } = await c.mutation(api.exports.startVideoUpload, { slug, bytes: bytes.length, seconds, width, height });
|
|
264
|
+
try {
|
|
265
|
+
let res;
|
|
266
|
+
try {
|
|
267
|
+
res = await fetchImpl(uploadUrl, { method: "POST", headers: { "Content-Type": "video/mp4" }, body: bytes, signal: AbortSignal.timeout(timeoutMs) });
|
|
268
|
+
} catch (e) {
|
|
269
|
+
throw new Error(`upload failed: ${e?.name === "TimeoutError" ? `no answer in ${Math.round(timeoutMs / 60000)} min` : e?.message || String(e)}`);
|
|
270
|
+
}
|
|
271
|
+
if (!res.ok) throw new Error(`upload failed: ${res.status} ${await res.text()}`);
|
|
272
|
+
const body = await res.text();
|
|
273
|
+
let storageId;
|
|
274
|
+
try { ({ storageId } = JSON.parse(body)); } catch { /* checked below */ }
|
|
275
|
+
if (!storageId) throw new Error(`upload failed: the storage answered without a file id (${body.slice(0, 200)})`);
|
|
276
|
+
const done = await c.mutation(api.exports.finishVideoUpload, { exportId, storageId });
|
|
277
|
+
if (!done.ok) throw new Error(`upload refused: ${done.error}`);
|
|
278
|
+
return { exportId, url: done.url, expiresAt: done.expiresAt };
|
|
279
|
+
} catch (e) {
|
|
280
|
+
// The row says so at once, rather than "uploading…" until the sweep.
|
|
281
|
+
await c.mutation(api.exports.failVideoUpload, { exportId, error: errorText(e) }).catch((err) => console.error(`[export] could not mark export ${exportId} failed: ${errorText(err)}`));
|
|
282
|
+
throw e;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Your exports (newest first, 30 days), optionally for one deck.
|
|
287
|
+
export async function listExports(opts, fields = {}) {
|
|
288
|
+
return (await client(opts)).query(api.exports.list, fields.slug ? { slug: String(fields.slug) } : {});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function removeExport(opts, fields = {}) {
|
|
292
|
+
if (!fields.exportId) throw new Error("exportId is required");
|
|
293
|
+
return (await client(opts)).mutation(api.exports.remove, { exportId: String(fields.exportId) });
|
|
294
|
+
}
|
|
295
|
+
|
|
203
296
|
// ---- deck review (convex/review.ts) --------------------------------------
|
|
204
297
|
|
|
205
298
|
// Queue a GPT-6 Luna review of every slide (desktop + phone screenshots of the
|
|
@@ -1017,7 +1110,7 @@ export async function listNarrators(opts = {}) {
|
|
|
1017
1110
|
return {
|
|
1018
1111
|
default: out.defaultId,
|
|
1019
1112
|
...(out.unpickedVoice ? { note: `no default narrator yet: decks narrate in your voice "${out.unpickedVoice}"` } : {}),
|
|
1020
|
-
narrators: out.narrators.map((n) => ({ id: n.id, name: n.name, isDefault: n.isDefault, builtin: n.builtin, face: n.face?.name ?? null, voice: n.voice?.name ?? null, voiceReady: !!n.voice?.ready })),
|
|
1113
|
+
narrators: out.narrators.map((n) => ({ id: n.id, name: n.name, spokenName: n.spokenName ?? null, isDefault: n.isDefault, builtin: n.builtin, face: n.face?.name ?? null, voice: n.voice?.name ?? null, voiceReady: !!n.voice?.ready })),
|
|
1021
1114
|
voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, narrators: x.narrators })),
|
|
1022
1115
|
};
|
|
1023
1116
|
}
|
|
@@ -1068,12 +1161,20 @@ export async function setNarratorVoice(opts, ref, voice) {
|
|
|
1068
1161
|
return { narrator: n.id, name: n.name, voice: String(voice) };
|
|
1069
1162
|
}
|
|
1070
1163
|
|
|
1071
|
-
|
|
1072
|
-
|
|
1164
|
+
// A new name, and/or how it is said (`spokenName`, "" clears it): narrate gives
|
|
1165
|
+
// the voice the spoken form, so "J.P." is not read letter by letter.
|
|
1166
|
+
export async function renameNarrator(opts, ref, name, spokenName) {
|
|
1167
|
+
const next = String(name ?? "").trim();
|
|
1168
|
+
if (!next && spokenName === undefined) throw new Error("usage: narrator rename <name|id> [<new name>] [--say <spoken name>]");
|
|
1073
1169
|
const n = await findNarrator(opts, ref);
|
|
1074
1170
|
if (n.builtin) throw new Error("D.A.S.H. is built in; its name stays");
|
|
1075
|
-
await (await client(opts)).mutation(api.narrators.rename, {
|
|
1076
|
-
|
|
1171
|
+
await (await client(opts)).mutation(api.narrators.rename, {
|
|
1172
|
+
narrator: n.id, ...(next ? { name: next } : {}), ...(spokenName !== undefined ? { spokenName: String(spokenName) } : {}),
|
|
1173
|
+
});
|
|
1174
|
+
return {
|
|
1175
|
+
narrator: n.id, was: n.name, name: next || n.name,
|
|
1176
|
+
spokenName: spokenName === undefined ? n.spokenName : String(spokenName).trim() || null,
|
|
1177
|
+
};
|
|
1077
1178
|
}
|
|
1078
1179
|
|
|
1079
1180
|
// face: one of your avatars (name or id from `avatar`); D.A.S.H. keeps its own.
|
package/src/play.mjs
CHANGED
|
@@ -55,7 +55,8 @@ async function lookUpPresenter(opts, { slug, path, html, log }) {
|
|
|
55
55
|
|
|
56
56
|
// Says what the served page does, not what the lookup answered.
|
|
57
57
|
function sayWhoPresents({ html, page, presenter, who, log }) {
|
|
58
|
-
|
|
58
|
+
// A tag that already has this face (an init deck's D.A.S.H.) is not a conflict.
|
|
59
|
+
if (presenter && page === html && !html.includes(`data-avatar="${presenter.avatarUrl}"`)) log("⚠ the player tag already names a presenter, so it plays as written");
|
|
59
60
|
else if (who?.face && !presenter && playsCaptionsOnly(html)) log(`⚠ captions only: the page names no presenter, and a face for voice "${who.voice}" is not added here`);
|
|
60
61
|
else if (who) log(presenterLine(who));
|
|
61
62
|
}
|
package/src/presenter.mjs
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
// preview, so all rewrite the tag the same way; only a release stores it. A tag that already names a presenter (D.A.S.H.
|
|
7
7
|
// on a character-voice deck, or one set by hand) is left alone.
|
|
8
8
|
|
|
9
|
+
// D.A.S.H.'s face: the head doc and still that ship with the player. A deck
|
|
10
|
+
// narrated in its character voice gets this face at release, even when the
|
|
11
|
+
// tag was scaffolded captions-only (data-presenter="none").
|
|
12
|
+
export const DASH_AVATAR = "https://decks.hypersocial.ai/player/v1/dash-avatar.json";
|
|
13
|
+
export const DASH_PRESENTER = Object.freeze({ avatarUrl: DASH_AVATAR, imageUrl: "https://decks.hypersocial.ai/avatars/dash.png" });
|
|
14
|
+
|
|
9
15
|
const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/i;
|
|
10
16
|
|
|
11
17
|
const attr = (s) => String(s).replace(/&/g, "&").replace(/"/g, """);
|
package/src/release.mjs
CHANGED
|
@@ -110,7 +110,7 @@ export async function release(pos, opts = {}, io = {}) {
|
|
|
110
110
|
throw new Error(`narration changed the deck (${after.split("\n").length} file(s)):\n${after}\n` +
|
|
111
111
|
`run \`hyperdecks check ${pos[0]}\`, look at it, commit (index.html and narration/), then run release again`);
|
|
112
112
|
}
|
|
113
|
-
//
|
|
113
|
+
// The released page gets the narrator's face: your avatar, or D.A.S.H.'s for its voice.
|
|
114
114
|
let presenter = null;
|
|
115
115
|
try {
|
|
116
116
|
presenter = await ops.presenterFor(opts, slug, opts.voice);
|
|
@@ -118,7 +118,7 @@ export async function release(pos, opts = {}, io = {}) {
|
|
|
118
118
|
log(`⚠ could not look up your avatar (${ops.errorText(e)}); releasing captions only`);
|
|
119
119
|
}
|
|
120
120
|
const released = presenter ? releaseBundle(deckDir, presenter) : bundle;
|
|
121
|
-
if (presenter) log(
|
|
121
|
+
if (presenter) log(`✓ presented by ${job.presenter?.face ?? "your avatar"}`);
|
|
122
122
|
// A terminal user sees this before it ships, so the CLI warns rather than refuses.
|
|
123
123
|
else if (job.presenter && !job.presenter.face) log(`⚠ releasing captions only: ${job.presenter.reason ?? "no face"}`);
|
|
124
124
|
return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary: job.presenter ?? null });
|
package/src/runtime.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { ConvexHttpClient } from "convex/browser";
|
|
9
9
|
import { makeFunctionReference } from "convex/server";
|
|
10
10
|
|
|
11
|
-
export const VERSION = "0.9.
|
|
11
|
+
export const VERSION = "0.9.3";
|
|
12
12
|
const UPLOAD_TIMEOUT_MS = 125_000;
|
|
13
13
|
|
|
14
14
|
export const PROD = {
|
|
@@ -128,6 +128,14 @@ export const api = {
|
|
|
128
128
|
posters: {
|
|
129
129
|
generatePoster: makeFunctionReference("posters:generatePoster"),
|
|
130
130
|
},
|
|
131
|
+
exports: {
|
|
132
|
+
create: makeFunctionReference("exports:create"),
|
|
133
|
+
list: makeFunctionReference("exports:list"),
|
|
134
|
+
remove: makeFunctionReference("exports:remove"),
|
|
135
|
+
startVideoUpload: makeFunctionReference("exports:startVideoUpload"),
|
|
136
|
+
finishVideoUpload: makeFunctionReference("exports:finishVideoUpload"),
|
|
137
|
+
failVideoUpload: makeFunctionReference("exports:failVideoUpload"),
|
|
138
|
+
},
|
|
131
139
|
review: {
|
|
132
140
|
reviewDeck: makeFunctionReference("review:reviewDeck"),
|
|
133
141
|
checkReviewJob: makeFunctionReference("review:checkReviewJob"),
|
|
@@ -156,7 +164,7 @@ export const api = {
|
|
|
156
164
|
},
|
|
157
165
|
};
|
|
158
166
|
|
|
159
|
-
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "no-logo"]);
|
|
167
|
+
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card"]);
|
|
160
168
|
// release's recipients, in order: each --to starts one, and an --email right
|
|
161
169
|
// after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
|
|
162
170
|
// opts.email stays the last --email for the other commands (share new).
|