@hypersocial/decks-cli 0.9.2 → 0.9.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -144,6 +144,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
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
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" }` renders it on our render worker (the dashboard's Export → Video); poll `check_export`, or `hyperdecks export status <exportId>` |
147
148
  | Import only | `hyperdecks import <file> --slug <slug>` | `import_deck_from_url` |
148
149
  | One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
149
150
  | Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
@@ -103,6 +103,14 @@ makes the hello deck; continue from step 5 below.
103
103
  days; `notes: true` adds the slide's script after each slide.
104
104
  `list_exports` shows what was exported. Release first: it prints the live
105
105
  deck, not the draft.
106
+ A video (MP4): `export_deck { slug, kind: "mp4", size?, shape?, titleCard?,
107
+ endCard? }` queues it on our render worker and returns an `exportId` at once.
108
+ It records in real time (a 4-minute deck takes about 5), so tell the user it
109
+ is on its way, then poll `check_export { exportId }` every 20-30 s: it shows
110
+ the slide it is on, and once `ready` a `url` plus a `title` and `description`
111
+ for YouTube. The owner is emailed the link too. On a server with no render
112
+ worker it answers with the `hyperdecks export <slug> --video` command
113
+ instead; give that to the user to run.
106
114
 
107
115
  ## Rules that matter
108
116
 
package/src/commands.mjs CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  VERSION,
11
11
  } from "./runtime.mjs";
12
12
  import * as ops from "./ops.mjs";
13
+ import { exportVideo } from "./export-video.mjs";
13
14
  import { presenterLine } from "./presenter.mjs";
14
15
 
15
16
  export const playbackLabel = (p) => p.narrationFinished ? "narration finished"
@@ -413,7 +414,17 @@ commands:
413
414
  export <slug|deck-dir> --pdf [--notes] [--out <file.pdf>] [--share <token>] [--json]
414
415
  a PDF of the released deck: one 1600x900 page per slide (a script page after each
415
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, drawn frame by frame so the avatar is smooth (slower
420
+ than real time: a 4-minute deck takes about 25 minutes with the live head on a
421
+ laptop): the avatar, voice and captions, none of the player's chrome. A slug records
422
+ the released copy, a deck folder its working copy. --title-card adds the title
423
+ and presenter (2s), --end-card the deck's "Video viewers" link (3s); --upload
424
+ keeps it with the deck's exports for 30 days
416
425
  export list [<slug>] [--json] your exports and their download links
426
+ export status <exportId> [--json] one export: a hosted video's progress, its link when done
427
+ export cancel <exportId> stop a hosted video export (Export → Video, export_deck mp4)
417
428
  export rm <exportId> delete an export now
418
429
  poster <slug> [--force] [--json]
419
430
  regenerate the share-link poster (release makes one) and print its URL
@@ -679,12 +690,18 @@ export function exportTarget(arg, opts = {}, cwd = process.cwd()) {
679
690
  return { slug: opts.slug || basename(dir), script: existsSync(scriptPath) ? readFileSync(scriptPath, "utf8") : undefined };
680
691
  }
681
692
 
693
+ export const EXPORT_USAGE = "usage: export <slug|deck-dir> --pdf [--notes] [--out <file.pdf>] [--share <token>]\n" +
694
+ " 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>]";
695
+
682
696
  async function exportCmd(pos, opts) {
683
697
  const [arg] = pos;
684
- const usage = "usage: export <slug|deck-dir> --pdf [--notes] [--out <file.pdf>] [--share <token>]";
698
+ const usage = EXPORT_USAGE;
685
699
  if (!arg) throw new Error(usage);
686
- if (opts.mp4 || opts.gif) throw new Error("only --pdf exports are available so far (video comes later; the email GIF is `hyperdecks preview create`)");
687
- if (!opts.pdf) throw new Error(`${usage}\n(--pdf is the only format so far)`);
700
+ if (opts.gif) throw new Error("the email GIF is `hyperdecks preview create`");
701
+ const video = opts.video || opts.mp4;
702
+ if (video && opts.pdf) throw new Error("pick one of --pdf and --video");
703
+ if (video) return void (await exportVideo(arg, opts));
704
+ if (!opts.pdf) throw new Error(`${usage}\n(pick --pdf or --video)`);
688
705
  const { slug, script } = exportTarget(arg, opts);
689
706
  if (slug !== arg) process.stderr.write(`deck folder ${arg} → slug ${slug} (pass --slug to change it)\n`);
690
707
  process.stderr.write(`rendering ${slug} as a PDF${opts.notes ? " with notes" : ""}…\n`);
@@ -698,6 +715,18 @@ async function exportCmd(pos, opts) {
698
715
  console.log(` download (30 days): ${out.url}`);
699
716
  }
700
717
 
718
+ // One export, a line: "pitch mp4 rendering: slide 3/12" / "… ready, 4:31, 1920×1080, 48.2 MB".
719
+ export function exportStatusLine(r) {
720
+ const p = r.progress;
721
+ const what = r.status === "rendering" && p
722
+ ? (p.slides ? `slide ${p.slide}/${p.slides}` : "starting") + (p.phase && !["recording", "starting"].includes(p.phase) ? ` (${p.phase})` : "")
723
+ : r.status === "queued" ? `${r.ahead ?? 0} ahead in line`
724
+ : r.status === "failed" ? r.error ?? "failed"
725
+ : r.status === "ready" ? [r.seconds ? `${Math.floor(r.seconds / 60)}:${String(Math.round(r.seconds % 60)).padStart(2, "0")}` : null, r.width ? `${r.width}×${r.height}` : null, r.pages ? `${r.pages} pages` : null, r.bytes ? `${(r.bytes / 1e6).toFixed(1)} MB` : null].filter(Boolean).join(", ")
726
+ : "";
727
+ return `${r.exportId} ${r.slug} ${r.kind} ${r.status}${what ? `: ${what}` : ""}`;
728
+ }
729
+
701
730
  async function exportList(pos, opts) {
702
731
  const rows = await ops.listExports(opts, { slug: pos[0] });
703
732
  if (opts.json) return void console.log(JSON.stringify(rows, null, 2));
@@ -979,6 +1008,18 @@ function commandTable(pos, rest, opts) {
979
1008
  poster: () => poster(pos.slice(1), opts),
980
1009
  export: () => exportCmd(pos.slice(1), opts),
981
1010
  "export list": () => exportList(pos.slice(2), opts),
1011
+ "export status": async () => {
1012
+ if (!pos[2]) throw new Error("usage: export status <exportId>");
1013
+ const r = await ops.checkExport(opts, pos[2]);
1014
+ if (opts.json) return void console.log(JSON.stringify(r, null, 2));
1015
+ console.log(exportStatusLine(r));
1016
+ if (r.url) console.log(` ${r.url}`);
1017
+ },
1018
+ "export cancel": async () => {
1019
+ if (!pos[2]) throw new Error("usage: export cancel <exportId>");
1020
+ const out = await ops.cancelExport(opts, pos[2]);
1021
+ console.log(`✓ cancelled ${out.exportId}`);
1022
+ },
982
1023
  "export rm": async () => {
983
1024
  if (!pos[2]) throw new Error("usage: export rm <exportId>");
984
1025
  const out = await ops.removeExport(opts, { exportId: pos[2] });
@@ -0,0 +1,551 @@
1
+ // hyperdecks export <slug|deck-dir> --video: a narrated deck as an MP4.
2
+ //
3
+ // Frame by frame: Chromium opens the deck in the player's export mode
4
+ // (?export=1: no chrome, autoplay, no tracking; packages/player/export-mode.js)
5
+ // on a page clock the recorder controls (frame-clock.mjs). Once the deck is
6
+ // ready the clock is frozen; then, per video frame, it is advanced 1/30 s and
7
+ // a screenshot taken, so every frame is drawn, however slowly the live head
8
+ // draws in headless Chromium (a real-time screencast got about 5 of 30 a
9
+ // second). It runs slower than real time. The page plays no sound (its
10
+ // narration runs on the same clock), so the audio track is built from the
11
+ // clips themselves, each placed at the page-clock moment the player started it
12
+ // (window.__hdExport), so the voice lands under the frames that show its
13
+ // captions, cues and lip-sync. An optional title
14
+ // card (2 s) and end card (3 s) are plain pages rendered the same size.
15
+ // ffmpeg (the CLI's portable @ffmpeg-installer build) mixes and encodes:
16
+ // H.264, AAC, +faststart.
17
+ import { copyFile, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
18
+ import { tmpdir } from 'node:os';
19
+ import { dirname, extname, join } from 'node:path';
20
+ import { execFile } from 'node:child_process';
21
+ import { promisify } from 'node:util';
22
+ import { allowCaptureDestination, installChromium, isMissingBrowser, routeCaptureRequest } from './preview-capture.mjs';
23
+ import { installFrameClock } from './frame-clock.mjs';
24
+
25
+ const run = promisify(execFile);
26
+
27
+ export const VIDEO_FPS = 30;
28
+ export const VIDEO_SIZES = Object.freeze({ '720p': 720, '1080p': 1080, '4k': 2160 });
29
+ export const VIDEO_SHAPES = Object.freeze(['16:9', '9:16', '1:1']);
30
+ export const TITLE_CARD_SECONDS = 2;
31
+ export const END_CARD_SECONDS = 3;
32
+ // Page-clock seconds a slide may run past its planned clip and hold before the
33
+ // export is called stuck: slow clip loads and transitions add a little.
34
+ const STUCK_GRACE_SECONDS = 60;
35
+ // Real seconds one frame (a clock step and a screenshot) may take.
36
+ const FRAME_TIMEOUT_MS = 60000;
37
+ const MAX_CLIP_BYTES = 50 * 1024 * 1024;
38
+
39
+ // Output pixels, and the viewport that draws them: 4K is the 1080p layout at
40
+ // device scale 2, so a slide is laid out the same and drawn sharper. 9:16 and
41
+ // 1:1 are viewports of that shape; the player's overlay layout letterboxes the
42
+ // 16:9 stage on the deck's page colour.
43
+ export function videoGeometry(size = '1080p', shape = '16:9') {
44
+ const short = VIDEO_SIZES[size];
45
+ if (!short) throw new Error(`--size must be one of ${Object.keys(VIDEO_SIZES).join(', ')} (got ${size})`);
46
+ if (!VIDEO_SHAPES.includes(shape)) throw new Error(`--shape must be one of ${VIDEO_SHAPES.join(', ')} (got ${shape})`);
47
+ const long = Math.round((short * 16) / 9);
48
+ const [width, height] = shape === '16:9' ? [long, short] : shape === '9:16' ? [short, long] : [short, short];
49
+ const scale = size === '4k' ? 2 : 1;
50
+ return { size, shape, width, height, scale, viewport: { width: width / scale, height: height / scale } };
51
+ }
52
+
53
+ // The command's options, checked before anything launches.
54
+ export function videoOptions(opts = {}) {
55
+ for (const k of ['size', 'shape', 'out', 'link', 'slug']) if (k in opts && typeof opts[k] !== 'string') throw new Error(`--${k} requires a value`);
56
+ const geometry = videoGeometry(opts.size ?? '1080p', opts.shape ?? '16:9');
57
+ if (opts.out && extname(opts.out).toLowerCase() !== '.mp4') throw new Error(`--out must name an .mp4 file (got ${opts.out})`);
58
+ if (opts.link !== undefined) {
59
+ let u;
60
+ try { u = new URL(opts.link); } catch { throw new Error(`--link must be a URL (got ${opts.link})`); }
61
+ if (!['http:', 'https:'].includes(u.protocol)) throw new Error('--link must be an http(s) URL');
62
+ }
63
+ if (opts.notes || opts.share) throw new Error('--notes and --share are PDF options; a video has neither');
64
+ return {
65
+ geometry,
66
+ captions: !opts['no-captions'],
67
+ avatar: !opts['no-avatar'],
68
+ titleCard: !!opts['title-card'],
69
+ endCard: !!opts['end-card'],
70
+ link: opts.link ?? null,
71
+ out: opts.out ?? null,
72
+ upload: !!opts.upload,
73
+ };
74
+ }
75
+
76
+ // The page the recorder opens: the deck in export mode.
77
+ export function exportPageUrl(url, { captions = true, avatar = true } = {}) {
78
+ const u = new URL(url);
79
+ u.searchParams.set('export', '1');
80
+ if (!captions) u.searchParams.set('captions', '0');
81
+ if (!avatar) u.searchParams.set('avatar', '0');
82
+ return u.href;
83
+ }
84
+
85
+ // The player's own timeline (navigation.js deckTimeline): each slide runs its
86
+ // clip then its hold, back to back. `plan` is what the player logged at ready:
87
+ // [{ id, duration, hold }] in slide order.
88
+ export function planTimeline(plan) {
89
+ const seconds = plan.map((s) => Math.max(0, (Number(s.duration) || 0) + (Number(s.hold) || 0)));
90
+ const starts = seconds.map((_, i) => seconds.slice(0, i).reduce((a, b) => a + b, 0));
91
+ return { ids: plan.map((s) => s.id), seconds, starts, total: seconds.reduce((a, b) => a + b, 0) };
92
+ }
93
+
94
+ // Each clip's audio and where it goes in the video, in seconds: when the
95
+ // player started it, measured from the first recorded frame (`markMs`), after
96
+ // a title card of `lead` seconds. A clip the player never started (the tour
97
+ // stopped early) is left out; the caller warns.
98
+ export function audioTimeline(log, markMs, lead = 0) {
99
+ const audioOf = new Map((log.plan ?? []).map((s) => [s.id, s.audio]));
100
+ return (log.clips ?? [])
101
+ .map((c) => ({ id: c.id, audio: c.audio ?? audioOf.get(c.id) ?? null, offset: lead + Math.max(0, (c.at - markMs) / 1000) }))
102
+ .filter((c) => c.audio);
103
+ }
104
+
105
+ // How far each clip started from where the manifest's timeline puts it, for a
106
+ // warning: a slow clip load pushes everything after it later (the audio still
107
+ // follows the player, so the video stays in sync).
108
+ export function timelineDrift(log, markMs) {
109
+ const plan = planTimeline(log.plan ?? []);
110
+ const startOf = new Map(plan.ids.map((id, i) => [id, plan.starts[i]]));
111
+ return (log.clips ?? []).filter((c) => startOf.has(c.id)).map((c) => ({ id: c.id, drift: (c.at - markMs) / 1000 - startOf.get(c.id) }));
112
+ }
113
+
114
+ // ffmpeg args for the audio track, `seconds` long: silence up to the first
115
+ // clip, then each clip padded with silence (or cut) to where the next one
116
+ // starts, joined end to end. Clips never overlap (the player plays one at a
117
+ // time), so nothing is mixed or normalised, and it needs no clip durations.
118
+ // Only filters FFmpeg 4.1 has (the CLI's bundled build on Linux, Windows and
119
+ // Intel Macs): aevalsrc, aresample, aformat, apad, atrim, asetpts, concat.
120
+ export function audioMixArgs({ inputs, seconds, out }) {
121
+ const fmt = 'aresample=48000,aformat=sample_fmts=s16:channel_layouts=stereo';
122
+ const clips = [...inputs].filter((i) => i.offset < seconds).sort((a, b) => a.offset - b.offset);
123
+ const args = [];
124
+ for (const c of clips) args.push('-i', c.path);
125
+ const parts = [], labels = [];
126
+ const lead = clips.length ? clips[0].offset : seconds;
127
+ if (lead >= 0.001) {
128
+ parts.push(`aevalsrc=0|0:c=stereo:s=48000:d=${lead.toFixed(3)},${fmt}[lead]`);
129
+ labels.push('[lead]');
130
+ }
131
+ clips.forEach((c, n) => {
132
+ const end = n + 1 < clips.length ? clips[n + 1].offset : seconds;
133
+ parts.push(`[${n}:a]${fmt},apad,atrim=0:${Math.max(0.001, end - c.offset).toFixed(3)},asetpts=N/SR/TB[c${n}]`);
134
+ labels.push(`[c${n}]`);
135
+ });
136
+ parts.push(`${labels.join('')}concat=n=${labels.length}:v=0:a=1[mix]`);
137
+ return [...args, '-filter_complex', parts.join(';'), '-map', '[mix]', '-c:a', 'pcm_s16le', '-y', out];
138
+ }
139
+
140
+ // ffmpeg args for the MP4: the numbered frames at `fps` and the mixed audio.
141
+ export function encodeArgs({ frames, fps = VIDEO_FPS, audio, width, height, out }) {
142
+ return [
143
+ '-framerate', String(fps), '-i', frames,
144
+ '-i', audio,
145
+ '-map', '0:v', '-map', '1:a',
146
+ '-vf', `scale=${width}:${height}:flags=lanczos,format=yuv420p`,
147
+ '-c:v', 'libx264', '-preset', 'fast', '-crf', '20', '-r', String(fps),
148
+ '-c:a', 'aac', '-b:a', '192k',
149
+ '-shortest', '-movflags', '+faststart', '-y', out,
150
+ ];
151
+ }
152
+
153
+ // A computed style value inside <style>: entities are not decoded there, so it
154
+ // is not HTML-escaped; anything that could end the rule or the element goes.
155
+ const cssValue = (s) => String(s ?? '').replace(/[<>{};\\]/g, '');
156
+ const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
157
+
158
+ // A title or end card: the first slide's colour, ink and heading font, centred
159
+ // text. `fonts` carries the deck's web fonts: its font stylesheet links and the
160
+ // @font-face rules it could read, resolved against `base` (the deck's page).
161
+ export function cardHtml({ lines, background = '#0b0b0f', color = '#ffffff', font = 'system-ui, sans-serif', width, height, fonts = { links: [], faces: '' }, base = null }) {
162
+ const [first, ...rest] = lines.filter(Boolean);
163
+ const unit = Math.min(width, height) / 1080;
164
+ const links = (fonts.links ?? []).filter((h) => /^https:\/\//.test(h)).map((h) => `<link rel="stylesheet" href="${escapeHtml(h)}">`).join('');
165
+ const faces = String(fonts.faces ?? '').replace(/<\/style/gi, '');
166
+ return `<!doctype html><html><head><meta charset="utf-8">${base ? `<base href="${escapeHtml(base)}">` : ''}${links}<style>${faces}
167
+ html,body{margin:0;height:100%;background:${cssValue(background)};color:${cssValue(color)};font-family:${cssValue(font)}}
168
+ 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}
169
+ h1{font-size:${Math.round(84 * unit)}px;line-height:1.1;margin:0;font-weight:700;max-width:100%;overflow-wrap:anywhere}
170
+ p{font-size:${Math.round(40 * unit)}px;margin:0;opacity:.8;max-width:100%;overflow-wrap:anywhere}
171
+ </style></head><body><h1>${escapeHtml(first ?? '')}</h1>${rest.map((l) => `<p>${escapeHtml(l)}</p>`).join('')}</body></html>`;
172
+ }
173
+
174
+ const pad = (n) => String(n).padStart(6, '0');
175
+ const frameName = (n) => `frame-${pad(n)}.jpg`;
176
+ const clock = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
177
+
178
+ async function ffmpegPath() {
179
+ try {
180
+ const path = (await import('@ffmpeg-installer/ffmpeg')).default.path;
181
+ await run(path, ['-version'], { timeout: 10000 });
182
+ return path;
183
+ } catch {
184
+ 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.');
185
+ }
186
+ }
187
+
188
+ async function ffmpeg(path, args, timeoutMs) {
189
+ try {
190
+ 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 } : {}) } });
191
+ } catch (e) {
192
+ 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(' ')}`);
193
+ }
194
+ }
195
+
196
+ // A clip's real length in seconds, from its decoded samples (an MP3's header
197
+ // can be off): the page's narration ends each clip there, as <audio> would.
198
+ const LENGTH_RATE = 8000;
199
+ export async function clipSeconds(path, file) {
200
+ let stdout;
201
+ try {
202
+ ({ stdout } = await run(path, ['-hide_banner', '-loglevel', 'error', '-nostdin', '-i', file, '-vn', '-ac', '1', '-ar', String(LENGTH_RATE), '-f', 's16le', '-'], { encoding: 'buffer', timeout: 60000, maxBuffer: 256 * 1024 * 1024, env: { PATH: dirname(path), ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}) } }));
203
+ } catch (e) { throw new Error(`could not read ${file}: ${String(e.stderr || e.message).trim().split('\n').at(-1)}`); }
204
+ const seconds = stdout.length / 2 / LENGTH_RATE;
205
+ if (!(seconds > 0)) throw new Error(`could not read ${file}: no audio in it`);
206
+ return seconds;
207
+ }
208
+
209
+ // Bounds a page.evaluate on the Node side (a busy main thread can hold it).
210
+ async function within(promise, ms, fallback) {
211
+ promise.catch(() => {});
212
+ let timer;
213
+ try { return await Promise.race([promise, new Promise((ok) => { timer = setTimeout(() => ok(fallback), ms); })]); }
214
+ finally { clearTimeout(timer); }
215
+ }
216
+
217
+ // The player's export log, read without trusting the page to answer quickly.
218
+ // undefined when it did not answer within `ms`.
219
+ const readLog = (page, ms = 5000) => within(page.evaluate(() => {
220
+ const l = window.__hdExport;
221
+ return l ? JSON.parse(JSON.stringify(l)) : null;
222
+ }), ms, undefined);
223
+
224
+ // What the page's network did before a ready timeout, for the error message.
225
+ // `blocked` holds the URLs the capture rules refused; `failed` is the last
226
+ // request that failed for another reason ({ url, error }).
227
+ export function readySaw({ blocked = [], failed = null } = {}) {
228
+ const cut = (u) => (u.length > 160 ? `${u.slice(0, 157)}…` : u);
229
+ const parts = [];
230
+ if (blocked.length) parts.push(`${blocked.length} request${blocked.length === 1 ? '' : 's'} blocked, last ${cut(blocked.at(-1))}`);
231
+ // requestfailed can fire before the route handler records the block.
232
+ if (failed && !blocked.includes(failed.url)) parts.push(`last failed request ${cut(failed.url)} (${failed.error})`);
233
+ return parts.length ? ` (${parts.join('; ')})` : '';
234
+ }
235
+
236
+ // Why a child process failed: the last line it printed, or its timeout.
237
+ const reasonOf = (e) => (e?.killed ? 'timed out' : String(e?.stderr || '').trim().split('\n').filter(Boolean).at(-1) || String(e?.message ?? e).split('\n')[0]);
238
+
239
+ // Launches the recorder's Chromium. On a first run the browser is not on disk
240
+ // yet: install it (as `preview setup` does) with one line of notice and retry
241
+ // once, instead of stopping at "run preview setup".
242
+ export async function launchRecorder(chromium, options, { log = () => {}, install = installChromium } = {}) {
243
+ const first = (e) => String(e?.message ?? e).split('\n')[0];
244
+ const failed = (e) => new Error(`Could not launch isolated Chromium (${first(e)}). Run \`hyperdecks preview setup\` to install it.`);
245
+ try { return await chromium.launch(options); }
246
+ catch (e) {
247
+ if (!isMissingBrowser(e)) throw failed(e);
248
+ log('first run: installing the Chromium build video export records with (one time, about a minute)…');
249
+ try { await install(); }
250
+ catch (err) { throw new Error(`Could not install isolated Chromium (${reasonOf(err)}). Run \`hyperdecks preview setup\` to retry.`); }
251
+ try { return await chromium.launch(options); }
252
+ catch (err) {
253
+ // Installed, yet still not where this Playwright looks: another install will not help.
254
+ if (isMissingBrowser(err)) throw new Error(`Installed Chromium, but this CLI's Playwright still cannot find it (${first(err)}). Check PLAYWRIGHT_BROWSERS_PATH.`);
255
+ throw failed(err);
256
+ }
257
+ }
258
+ }
259
+
260
+ // Records `config.url` (a page that loads player/v1) and writes the MP4.
261
+ // config: { url, geometry, captions, avatar, titleCard, endCard, title,
262
+ // presenter, link, out, firstPartyOrigins, playerDir }. `log` gets progress
263
+ // lines. Returns { file, seconds, width, height, bytes, slides, clips, warnings }.
264
+ export async function recordVideo(config, { log = () => {}, chromium: injected, install } = {}) {
265
+ const { geometry, out } = config;
266
+ const ff = await ffmpegPath();
267
+ const chromium = injected ?? (await import('playwright')).chromium;
268
+ const warnings = [];
269
+ let browser, temp, phase = 'launch';
270
+ try {
271
+ temp = await mkdtemp(join(tmpdir(), 'hyperdecks-video-'));
272
+ browser = await launchRecorder(chromium, {
273
+ // config.sandbox: false only for CI runners whose AppArmor blocks Chromium's sandbox (the smoke suite).
274
+ headless: true, timeout: 30000, chromiumSandbox: config.sandbox ?? process.platform === 'linux',
275
+ args: ['--autoplay-policy=no-user-gesture-required', '--mute-audio', '--enable-unsafe-swiftshader'],
276
+ env: { ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}), PATH: process.env.PATH, TMPDIR: temp, HOME: temp },
277
+ }, { log, ...(install ? { install } : {}) });
278
+ const pageUrl = exportPageUrl(config.url, config);
279
+ const context = await browser.newContext({ viewport: geometry.viewport, deviceScaleFactor: geometry.scale, serviceWorkers: 'block', acceptDownloads: false });
280
+ const blockedUrls = [];
281
+ let failed = null;
282
+ await context.route('**/*', async (route) => {
283
+ if (!await routeCaptureRequest(route, pageUrl, config.firstPartyOrigins)) blockedUrls.push(route.request().url());
284
+ });
285
+ // A development checkout plays its own player/v1 (HYPERDECKS_PLAYER_DIR), not the released one.
286
+ if (config.playerDir) {
287
+ await context.route(/\/player\/v1\/[^?#]+/, async (route) => {
288
+ const name = new URL(route.request().url()).pathname.replace(/^.*\/player\/v1\//, '');
289
+ if (name.split('/').some((s) => s === '..' || s.startsWith('.'))) return route.abort();
290
+ const type = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png' }[extname(name)] ?? 'application/octet-stream';
291
+ try { await route.fulfill({ body: await readFile(join(config.playerDir, name)), contentType: type }); }
292
+ catch { await route.abort(); }
293
+ });
294
+ }
295
+ await context.routeWebSocket('**/*', (ws) => ws.close());
296
+ // The page clock goes in before any of the deck's scripts (frame-clock.mjs).
297
+ await context.addInitScript({ content: `(${installFrameClock.toString()})(window);` });
298
+ await context.addInitScript(() => {
299
+ navigator.sendBeacon = () => false;
300
+ window.open = () => null;
301
+ // The player's export mode waits for this before it plays (export-mode.js).
302
+ window.__hdExportWait = new Promise((go) => { window.__hdExportGo = go; });
303
+ });
304
+ const page = await context.newPage();
305
+ page.setDefaultTimeout(30000);
306
+ const pageErrors = [];
307
+ page.on('pageerror', (e) => pageErrors.push(String(e?.message ?? e)));
308
+ // A refused request also fails in the page; only the others count here.
309
+ page.on('requestfailed', (r) => { if (!blockedUrls.includes(r.url())) failed = { url: r.url(), error: r.failure()?.errorText ?? 'failed' }; });
310
+ phase = 'page load';
311
+ await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
312
+ phase = 'player ready';
313
+ const pageSaid = () => (pageErrors.length ? ` (page errors: ${pageErrors.slice(0, 3).join('; ').slice(0, 400)})` : '');
314
+ await page.waitForSelector('body[data-hd-ready="true"]', { timeout: 60000 }).catch((e) => {
315
+ throw new Error(`the deck never became ready (${e.message.split('\n')[0]})${pageSaid()}${readySaw({ blocked: blockedUrls, failed })}; is it on player/v1 (https://decks.hypersocial.ai/player/v1/player.js)?`);
316
+ });
317
+ let state = await readLog(page, 30000);
318
+ if (state === undefined) throw new Error(`the deck's player did not return its export log within 30s${pageSaid()}`);
319
+ 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()}`);
320
+ if (state.manifestError) throw new Error(`the deck's narration manifest could not load (${state.manifestError.url}: ${state.manifestError.message}); nothing was recorded`);
321
+ if (state.silent) throw new Error('the deck has no narration to record: run `hyperdecks narrate` first');
322
+ // An older player plays its narration on wall time, which a frame-by-frame recording cannot follow.
323
+ if (!(state.version >= 2 && state.clock === 'page')) throw new Error('the deck\'s player is older than this CLI (its narration cannot follow a frame-by-frame recording); reload https://decks.hypersocial.ai/player/v1/player.js, or update the deck\'s copy');
324
+ const plan = planTimeline(state.plan ?? []);
325
+ const face = state.presenter;
326
+ if (config.avatar !== false && face?.wanted === 'head' && face.shown !== 'head') warnings.push(`the deck's live head did not load${face.why ? ` (${face.why})` : ''}; the video shows ${face.shown === 'static' ? 'its still image' : 'captions only'}`);
327
+ // The cards wear the first slide's colours and its heading's font.
328
+ const look = await within(page.evaluate(() => {
329
+ const first = document.querySelector('section.slide');
330
+ const cs = (n) => n && getComputedStyle(n);
331
+ const solid = (c) => c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent';
332
+ const slide = cs(first), body = cs(document.body), heading = cs(first?.querySelector('h1, h2, h3'));
333
+ const background = [slide?.backgroundColor, body?.backgroundColor].find(solid);
334
+ // Font stylesheets on another origin can't be read: link them; read the rest's @font-face rules.
335
+ 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));
336
+ const faces = [];
337
+ for (const sheet of document.styleSheets) {
338
+ try { for (const rule of sheet.cssRules) if (rule instanceof CSSFontFaceRule) faces.push(rule.cssText); } catch { /* cross-origin */ }
339
+ }
340
+ 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') } };
341
+ }), 5000, {});
342
+ // Every clip's audio is fetched before recording: a bad URL fails now, not after minutes of real time.
343
+ phase = 'clip audio';
344
+ const audioFiles = new Map(), clipLengths = {};
345
+ for (const [n, url] of [...new Set((state.plan ?? []).map((s) => s.audio).filter(Boolean))].entries()) {
346
+ const id = state.plan.find((s) => s.audio === url).id;
347
+ if (!await allowCaptureDestination(url, pageUrl)) throw new Error(`clip ${id}'s audio is not on a public address: ${url}`);
348
+ let res;
349
+ try { res = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(30000) }); }
350
+ catch (e) { throw new Error(`could not download clip ${id}'s audio (${url}): ${e.cause?.message ?? e.message}`); }
351
+ if (!res.ok) throw new Error(`could not download clip ${id}'s audio (${res.status} from ${url})`);
352
+ const bytes = Buffer.from(await res.arrayBuffer());
353
+ if (bytes.length > MAX_CLIP_BYTES) throw new Error(`clip ${id}'s audio is over ${MAX_CLIP_BYTES / 1024 / 1024} MB`);
354
+ const path = join(temp, `clip-${n}${extname(new URL(url).pathname).slice(0, 6) || '.audio'}`);
355
+ await writeFile(path, bytes);
356
+ audioFiles.set(url, path);
357
+ clipLengths[url] = await clipSeconds(ff, path).catch((e) => { throw new Error(`clip ${id}'s audio: ${e.message}`); });
358
+ }
359
+ log(`recording ${plan.ids.length} slides${plan.total ? `, about ${clock(plan.total)} of video,` : ''} at ${geometry.width}×${geometry.height} (${geometry.shape}), frame by frame`);
360
+
361
+ // The title card's frames come first.
362
+ const lead = config.titleCard ? TITLE_CARD_SECONDS : 0;
363
+ const card = async (lines) => {
364
+ const p = await context.newPage();
365
+ 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`));
366
+ await within(p.evaluate(() => document.fonts.ready.then(() => true)), 5000, false);
367
+ const shot = await p.screenshot({ type: 'jpeg', quality: 92 });
368
+ await p.close();
369
+ return shot;
370
+ };
371
+ let frameCount = 0;
372
+ const repeat = async (image, seconds) => {
373
+ const first = join(temp, frameName(frameCount));
374
+ await writeFile(first, image);
375
+ const n = Math.round(seconds * VIDEO_FPS);
376
+ for (let i = 1; i < n; i++) await copyFile(first, join(temp, frameName(frameCount + i)));
377
+ frameCount += n;
378
+ };
379
+ if (config.titleCard) {
380
+ phase = 'title card';
381
+ await repeat(await card([config.title, config.presenter ? `Presented by ${config.presenter}` : null]), TITLE_CARD_SECONDS);
382
+ }
383
+
384
+ phase = 'recording';
385
+ const session = await context.newCDPSession(page);
386
+ const fps = VIDEO_FPS;
387
+ const startedAt = Date.now();
388
+ let written = Promise.resolve();
389
+ const capture = async () => {
390
+ const { data } = await within(session.send('Page.captureScreenshot', { format: 'jpeg', quality: 90, optimizeForSpeed: true }), FRAME_TIMEOUT_MS, { data: null });
391
+ if (!data) throw new Error(`a frame took over ${FRAME_TIMEOUT_MS / 1000}s to capture${pageSaid()}`);
392
+ const path = join(temp, frameName(frameCount++));
393
+ // One write in flight while the next frame is drawn; a failed one (a full disk) throws at the next await.
394
+ await written;
395
+ written = writeFile(path, Buffer.from(data, 'base64')).catch((e) => { throw new Error(`could not write a frame: ${e.message}`); });
396
+ written.catch(() => {});
397
+ };
398
+ // Frame 0 is the deck as it starts; frame n shows it n/fps seconds later on the page clock.
399
+ const markMs = await page.evaluate(() => performance.timeOrigin + window.__hdFrameClock.freeze());
400
+ // Each clip ends where its real audio does (the manifest's length can be off), as <audio> would end it.
401
+ await page.evaluate((lengths) => { window.__hdExportClipLengths = lengths; window.__hdExportGo(); return window.__hdFrameClock.settle(); }, clipLengths);
402
+ const firstFrame = frameCount;
403
+ await capture();
404
+ const secondsOf = new Map(plan.ids.map((id, i) => [id, plan.seconds[i]]));
405
+ let shown = 0, progress = 0, movedAt = 0, end = Infinity;
406
+ // Stuck: no slide landed and no clip started for longer than the current
407
+ // slide's planned clip and hold (unknown for a data-clip deck) plus a grace, on the page clock.
408
+ for (let n = 1; n < end; n++) {
409
+ const step = await within(page.evaluate(async (ms) => {
410
+ const t = window.__hdFrameClock.step(ms);
411
+ await window.__hdFrameClock.settle();
412
+ const l = window.__hdExport;
413
+ return { t, moved: l.slides.length + l.clips.length, errors: l.errors.length, done: l.done };
414
+ }, 1000 / fps), FRAME_TIMEOUT_MS, null);
415
+ if (!step) throw new Error(`the page stopped answering (a frame took over ${FRAME_TIMEOUT_MS / 1000}s to draw)${pageSaid()}`);
416
+ await capture();
417
+ const at = n / fps;
418
+ if (step.moved !== progress || step.errors || (step.done && end === Infinity)) {
419
+ const next = await readLog(page, FRAME_TIMEOUT_MS);
420
+ if (!next) throw new Error(`the page did not return its export log within ${FRAME_TIMEOUT_MS / 1000}s${pageSaid()}`);
421
+ state = next;
422
+ const failedClip = state.errors?.[0];
423
+ if (failedClip) throw new Error(`narration failed on ${failedClip.id} (${failedClip.message}); the tour cannot go on`);
424
+ if (step.moved !== progress) { progress = step.moved; movedAt = at; }
425
+ for (; shown < (state.slides?.length ?? 0); shown++) {
426
+ const s = state.slides[shown];
427
+ const i = plan.ids.indexOf(s.id);
428
+ log(` slide ${i + 1}/${plan.ids.length} ${s.id} · ${clock(Math.max(0, (s.at - markMs) / 1000))}${plan.total ? ` of ${clock(plan.total)}` : ''}`);
429
+ }
430
+ // The video ends on the frame where the tour ended.
431
+ if (state.done) end = Math.max(n + 1, Math.round(((state.doneAt - markMs) / 1000) * fps));
432
+ }
433
+ const on = state.slides?.at(-1)?.id ?? plan.ids[0];
434
+ const allowed = (secondsOf.get(on) || 0) * 1.5 + STUCK_GRACE_SECONDS;
435
+ if (end === Infinity && at - movedAt > allowed) throw new Error(`the tour stopped on slide ${on}: nothing moved for ${Math.round(allowed)}s of the deck${pageSaid()}`);
436
+ }
437
+ await written;
438
+ const frames = frameCount - firstFrame;
439
+ const seconds = frames / fps;
440
+ const wall = (Date.now() - startedAt) / 1000;
441
+ log(`rendered ${frames} frames (${clock(seconds)}) in ${clock(wall)}: ${(frames / Math.max(0.001, wall)).toFixed(1)} frames a second, ${(wall / Math.max(0.001, seconds)).toFixed(1)}× the video's length`);
442
+ // Drift means something only when every clip's planned length is known (a data-clip deck's are not).
443
+ const drift = (state.plan ?? []).every((s) => !s.audio || s.duration > 0) ? timelineDrift(state, markMs) : [];
444
+ const worst = drift.reduce((a, d) => (Math.abs(d.drift) > Math.abs(a.drift) ? d : a), { drift: 0 });
445
+ 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`);
446
+ const render = { frames, seconds: wall };
447
+
448
+ if (config.endCard) {
449
+ phase = 'end card';
450
+ await repeat(await card([config.title, config.link]), END_CARD_SECONDS);
451
+ }
452
+ phase = 'browser close';
453
+ await context.close();
454
+ await browser.close();
455
+ browser = null;
456
+ if (blockedUrls.length) warnings.push(`${blockedUrls.length} request(s) blocked (tracking, navigation or unsafe destinations); nothing was recorded as a view.`);
457
+
458
+ phase = 'audio';
459
+ const clips = audioTimeline(state, markMs, lead);
460
+ const missing = plan.ids.filter((id) => !clips.some((c) => c.id === id) && (state.plan.find((s) => s.id === id)?.audio));
461
+ if (missing.length) warnings.push(`no audio started for ${missing.join(', ')}`);
462
+ const inputs = clips.map((c) => {
463
+ const path = audioFiles.get(c.audio);
464
+ if (!path) throw new Error(`clip ${c.id} played audio the plan did not list (${c.audio})`);
465
+ return { path, offset: c.offset };
466
+ });
467
+ const total = frameCount / VIDEO_FPS;
468
+ const audio = join(temp, 'audio.wav');
469
+ await ffmpeg(ff, audioMixArgs({ inputs, seconds: total, out: audio }), 300000);
470
+
471
+ phase = 'encode';
472
+ log(`encoding ${clock(total)} of video…`);
473
+ // Encoded beside the temp frames, then moved into place: a failed encode never leaves a broken file at `out`.
474
+ const encoded = join(temp, 'video.mp4');
475
+ 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));
476
+ await rename(encoded, out).catch(async (e) => {
477
+ if (e.code !== 'EXDEV') throw e;
478
+ await copyFile(encoded, out);
479
+ });
480
+ const bytes = (await stat(out)).size;
481
+ if (pageErrors.length) warnings.push(`${pageErrors.length} page error(s) while recording: ${pageErrors.slice(0, 2).join('; ').slice(0, 300)}`);
482
+ return { file: out, seconds: total, width: geometry.width, height: geometry.height, bytes, slides: plan.ids.length, clips: clips.length, render, warnings };
483
+ } catch (e) {
484
+ throw new Error(`Video export failed during ${phase}: ${e.message}`);
485
+ } finally {
486
+ if (browser) await browser.close().catch(() => {});
487
+ if (temp) await rm(temp, { recursive: true, force: true });
488
+ }
489
+ }
490
+
491
+ // ---- the command ------------------------------------------------------------
492
+
493
+ const titleOf = (html) => (/<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1] ?? '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").trim();
494
+ const mb = (n) => `${(n / 1e6).toFixed(1)} MB`;
495
+
496
+ // hyperdecks export <slug|deck-dir> --video: a deck folder records its working
497
+ // copy as `hyperdecks play` serves it (the owner's face added in memory, the
498
+ // manifest index.html points at); a slug records the released copy. Progress
499
+ // goes to stderr, the result to stdout.
500
+ export async function exportVideo(arg, opts = {}, { log = (l) => process.stderr.write(`${l}\n`), print = (l) => console.log(l), cwd = process.cwd(), deps = {} } = {}) {
501
+ const o = videoOptions(opts);
502
+ const { existsSync, readFileSync } = await import('node:fs');
503
+ const { resolve, join: joinPath, basename } = await import('node:path');
504
+ const ops = deps.ops ?? await import('./ops.mjs');
505
+ const local = [resolve(cwd, arg), joinPath(cwd, 'decks', arg)].find((d) => existsSync(joinPath(d, 'index.html')));
506
+ // --upload files the MP4 under a deck you own: checked before minutes of recording.
507
+ if (o.upload) await ops.ownedDeck(opts, local ? (opts.slug || basename(local)) : arg);
508
+ let source;
509
+ if (local) {
510
+ const { startPlay } = deps.play ?? await import('./play.mjs');
511
+ const html = readFileSync(joinPath(local, 'index.html'), 'utf8');
512
+ const session = await startPlay([local], { ...opts, 'no-open': true }, { log: (l) => { if (!l.startsWith('▶')) log(l); }, cwd });
513
+ source = { slug: session.slug, title: titleOf(html) || session.slug, url: session.url, close: session.close, presenter: session.who?.face ?? null };
514
+ } else {
515
+ const deck = await ops.releasedDeck(opts, arg);
516
+ const { serveSource } = await import('./preview-capture.mjs');
517
+ const server = await serveSource({ html: deck.html });
518
+ source = { slug: arg, title: deck.title, url: server.url, close: server.close, presenter: null };
519
+ }
520
+ try {
521
+ let link = o.link;
522
+ if (o.endCard && !link) {
523
+ try { link = await ops.videoShareLink(opts, source.slug); }
524
+ catch (e) { throw new Error(`the end card needs the deck's link: ${ops.errorText?.(e) ?? e.message}. Pass --link <url> to name one`); }
525
+ }
526
+ const out = resolve(cwd, o.out ?? `${source.slug}.mp4`);
527
+ log(`recording ${source.slug}${local ? ' (working copy)' : ' (released copy)'} → ${out}`);
528
+ const result = await recordVideo({
529
+ url: source.url, geometry: o.geometry, captions: o.captions, avatar: o.avatar, titleCard: o.titleCard, endCard: o.endCard,
530
+ title: source.title, presenter: source.presenter, link, out, playerDir: process.env.HYPERDECKS_PLAYER_DIR || null,
531
+ }, { log });
532
+ let uploaded = null;
533
+ if (o.upload) {
534
+ log(`uploading ${mb(result.bytes)}…`);
535
+ try {
536
+ uploaded = await ops.uploadVideoExport(opts, { slug: source.slug, bytes: await readFile(out), seconds: result.seconds, width: result.width, height: result.height });
537
+ } catch (e) {
538
+ 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)`);
539
+ }
540
+ }
541
+ const summary = { ...result, slug: source.slug, link: link ?? null, ...(uploaded ? { exportId: uploaded.exportId, url: uploaded.url, expiresAt: uploaded.expiresAt } : {}) };
542
+ if (opts.json) { print(JSON.stringify(summary, null, 2)); return summary; }
543
+ print(`✓ ${clock(result.seconds)} at ${result.width}×${result.height}, ${mb(result.bytes)} → ${out}`);
544
+ if (link && o.endCard) print(` end card link: ${link}`);
545
+ for (const w of result.warnings) print(` ⚠ ${w}`);
546
+ if (uploaded) print(` on the deck page, and download (30 days): ${uploaded.url}`);
547
+ return summary;
548
+ } finally {
549
+ await source.close();
550
+ }
551
+ }
@@ -0,0 +1,26 @@
1
+ // What both MCP servers (app/api/mcp/route.ts, mcp/server.mjs) say about video
2
+ // exports, so the two never drift.
3
+
4
+ export const VIDEO_EXPORT_HELP =
5
+ "kind \"mp4\" records the narrated deck as a video on our render worker (drawn frame by frame, " +
6
+ "so it takes several times the deck's length: a 4-minute deck about half an hour), with the options size (720p, 1080p, 4k), shape (16:9, 9:16, 1:1), captions, avatar, " +
7
+ "titleCard and endCard (the deck's tracked link). It returns { exportId, status: \"queued\" } at once: poll " +
8
+ "check_export(exportId) every 20-30 seconds until status is ready (url is the MP4, kept 30 days; title and description " +
9
+ "are ready for YouTube) or failed (error says why). The owner is also emailed the link. One video per deck at a time, " +
10
+ "10 a day. Same as the dashboard's Export → Video; `hyperdecks export <slug> --video` records one locally instead.";
11
+
12
+ export const CHECK_EXPORT_HELP =
13
+ "Check an export started by export_deck: status (queued | rendering | ready | failed | cancelled); for a video, " +
14
+ "progress { slide, slides, phase } and ahead (jobs before it in line); once ready, url (the file, 30 days), " +
15
+ "filename, bytes, seconds, width, height, and title and description for a YouTube upload (the description " +
16
+ "carries the deck's tracked link); error when it failed. Poll every 20-30 seconds.";
17
+
18
+ // The video options as zod fields (each server passes its own zod).
19
+ export const videoExportInputs = (z) => ({
20
+ size: z.enum(["720p", "1080p", "4k"]).optional().describe("mp4: frame size, default 1080p"),
21
+ shape: z.enum(["16:9", "9:16", "1:1"]).optional().describe("mp4: default 16:9"),
22
+ captions: z.boolean().optional().describe("mp4: burn in captions (default true)"),
23
+ avatar: z.boolean().optional().describe("mp4: show the presenter (default true)"),
24
+ titleCard: z.boolean().optional().describe("mp4: a 2 s title card first"),
25
+ endCard: z.boolean().optional().describe("mp4: a 3 s end card with the deck's tracked link"),
26
+ });
@@ -0,0 +1,268 @@
1
+ // The page clock video export records on (export-video.mjs).
2
+ //
3
+ // Recording in real time loses frames: in headless Chromium the live head
4
+ // (WebGL) draws a few frames a second, so a screencast gets a few. Instead the
5
+ // recorder installs this clock before any of the page's scripts run, freezes
6
+ // it once the deck is ready, and then, per video frame, advances it by exactly
7
+ // 1/fps and takes a screenshot. While frozen:
8
+ // - performance.now() and Date.now() read the page clock;
9
+ // - setTimeout/setInterval fire when the clock passes their time, in order;
10
+ // - requestAnimationFrame callbacks run once per step, with the clock's time;
11
+ // - Web Animations (CSS animations and transitions, element.animate()) are
12
+ // paused and seeked to the clock, and finished when it passes their end
13
+ // (so `animation.finished` and transitionend still come).
14
+ // So a frame that takes the page 300 ms to draw is still one frame 33 ms after
15
+ // the last, and nothing is skipped or repeated. Before freeze() everything runs
16
+ // on real time, so loading (fonts, the head, clip downloads) is unaffected.
17
+ //
18
+ // Chromium's own virtual time (Emulation.setVirtualTimePolicy) was tried
19
+ // first: it moves timers and performance.now(), but not requestAnimationFrame
20
+ // (its callbacks keep their real-time stamps and run many times per step) or
21
+ // CSS animations, so the head and transitions would still run on wall time.
22
+ //
23
+ // installFrameClock is serialised into the page (addInitScript): it must not
24
+ // close over anything outside itself.
25
+ export function installFrameClock(win) {
26
+ if (win.__hdFrameClock) return win.__hdFrameClock;
27
+ const perf = win.performance;
28
+ const native = {
29
+ now: perf.now.bind(perf),
30
+ dateNow: win.Date.now.bind(win.Date),
31
+ setTimeout: win.setTimeout.bind(win),
32
+ clearTimeout: win.clearTimeout.bind(win),
33
+ raf: win.requestAnimationFrame?.bind(win),
34
+ caf: win.cancelAnimationFrame?.bind(win),
35
+ };
36
+ let frozen = false, t = 0, seq = 0;
37
+ const timers = new Map(); // id → { due, fn, args, every, native }
38
+ const frames = new Map(); // id → { cb, native }
39
+ const tracked = new WeakMap(); // Animation → { origin: clock ms at its time 0, last: its time when last seen, ours }
40
+ let clock_takeVideos = () => {};
41
+ const now = () => (frozen ? t : native.now());
42
+ // A callback that throws must not stop the step: report it as the page would (a pageerror).
43
+ const report = (e) => native.setTimeout(() => { throw e; }, 0);
44
+ const call = (fn, args) => {
45
+ try { fn(...args); } catch (e) { report(e); }
46
+ };
47
+ const fire = (id) => {
48
+ const x = timers.get(id);
49
+ if (!x) return;
50
+ if (x.every == null) timers.delete(id);
51
+ else {
52
+ x.due += x.every;
53
+ if (!frozen) x.native = native.setTimeout(() => fire(id), x.every);
54
+ }
55
+ call(x.fn, x.args);
56
+ };
57
+ const schedule = (fn, ms, args, repeat) => {
58
+ // A string body is not run (the player uses none); a deck that does is told, as a page error.
59
+ if (typeof fn !== 'function') { report(new Error('the video recorder does not run a setTimeout/setInterval given a string')); return 0; }
60
+ const delay = Math.max(0, Number(ms) || 0);
61
+ const id = ++seq;
62
+ const x = { due: now() + delay, fn, args, every: repeat ? Math.max(1, delay) : null, native: null };
63
+ if (!frozen) x.native = native.setTimeout(() => fire(id), delay);
64
+ timers.set(id, x);
65
+ return id;
66
+ };
67
+ const unschedule = (id) => {
68
+ const x = timers.get(id);
69
+ if (!x) return;
70
+ if (x.native != null) native.clearTimeout(x.native);
71
+ timers.delete(id);
72
+ };
73
+ perf.now = now;
74
+ const dateOffset = native.dateNow() - native.now();
75
+ win.Date.now = () => (frozen ? Math.round(dateOffset + t) : native.dateNow());
76
+ win.setTimeout = (fn, ms, ...args) => schedule(fn, ms, args, false);
77
+ win.setInterval = (fn, ms, ...args) => schedule(fn, ms, args, true);
78
+ win.clearTimeout = unschedule;
79
+ win.clearInterval = unschedule;
80
+ win.requestAnimationFrame = (cb) => {
81
+ const id = ++seq;
82
+ const x = { cb, native: null };
83
+ if (!frozen && native.raf) x.native = native.raf((ts) => { frames.delete(id); call(cb, [ts]); });
84
+ frames.set(id, x);
85
+ return id;
86
+ };
87
+ win.cancelAnimationFrame = (id) => {
88
+ const x = frames.get(id);
89
+ if (!x) return;
90
+ if (x.native != null) native.caf?.(x.native);
91
+ frames.delete(id);
92
+ };
93
+
94
+ // Every running animation, paused and placed at the clock's time. One
95
+ // running when the clock froze keeps its place. One seen running later
96
+ // starts from where the clock last saw it (0 for a new or replayed one, its
97
+ // paused time for one the page paused and resumed), whatever real time
98
+ // passed before it was seen. One the page paused itself is left alone.
99
+ const animate = (atFreeze = false) => {
100
+ const doc = win.document;
101
+ if (typeof doc?.getAnimations !== 'function') return;
102
+ // getAnimations() also brings styles up to date, so a transition a class change just started is in it.
103
+ for (const a of doc.getAnimations()) {
104
+ const rate = a.playbackRate || 1;
105
+ let s = tracked.get(a);
106
+ if (a.playState === 'running') {
107
+ const from = atFreeze ? Number(a.currentTime) || 0 : s?.last ?? 0;
108
+ s = { origin: t - from / rate, last: from, ours: true };
109
+ tracked.set(a, s);
110
+ if (nativeAnim) nativeAnim.pause.call(a); else a.pause();
111
+ } else if (!s?.ours || a.playState !== 'paused') {
112
+ tracked.set(a, { ...s, ours: false, last: a.playState === 'finished' ? 0 : Number(a.currentTime) || 0 });
113
+ continue;
114
+ }
115
+ const at = (t - s.origin) * rate;
116
+ const end = a.effect?.getComputedTiming?.().endTime;
117
+ if (a.playbackRate > 0 && Number.isFinite(end) && at >= end) { a.finish(); s.ours = false; s.last = 0; }
118
+ else { a.currentTime = Math.max(0, at); s.last = Math.max(0, at); }
119
+ }
120
+ };
121
+
122
+ // The page pausing or cancelling an animation the clock has taken gives it
123
+ // back to the page (the clock pauses with the native method, not these).
124
+ const Anim = win.Animation?.prototype;
125
+ const nativeAnim = Anim && { pause: Anim.pause, cancel: Anim.cancel };
126
+ if (Anim) {
127
+ Anim.pause = function pause() { const s = tracked.get(this); if (s) s.ours = false; return nativeAnim.pause.call(this); };
128
+ Anim.cancel = function cancel() { const s = tracked.get(this); if (s) { s.ours = false; s.last = 0; } return nativeAnim.cancel.call(this); };
129
+ }
130
+
131
+ // Slide <video>s: while frozen, play() and pause() only say whether the page
132
+ // wants one playing, and settle() seeks each playing one to the clock (loop
133
+ // wraps; a clip that reaches its end stops and sends `ended`).
134
+ const Media = win.HTMLMediaElement?.prototype;
135
+ const isVideo = (m) => !!win.HTMLVideoElement && m instanceof win.HTMLVideoElement;
136
+ const videos = new Map(); // HTMLVideoElement → { playing, origin, base }
137
+ let nativeMedia = null;
138
+ if (Media) {
139
+ nativeMedia = { play: Media.play, pause: Media.pause, paused: Object.getOwnPropertyDescriptor(Media, 'paused')?.get };
140
+ const nativelyPlaying = (v) => nativeMedia.paused && !nativeMedia.paused.call(v);
141
+ const send = (v, type) => v.dispatchEvent(new Event(type));
142
+ const take = (v) => {
143
+ const s = { playing: true, origin: t, base: v.currentTime || 0 };
144
+ videos.set(v, s);
145
+ if (nativelyPlaying(v)) nativeMedia.pause.call(v);
146
+ return s;
147
+ };
148
+ Media.play = function play() {
149
+ if (!frozen || !isVideo(this)) return nativeMedia.play.call(this);
150
+ const s = videos.get(this);
151
+ if (!s?.playing) {
152
+ if (s?.ended) this.currentTime = 0;
153
+ // As a real play() does, start loading a video that has not (preload="none").
154
+ if (this.readyState === 0 && this.networkState !== 2) { this.preload = 'auto'; this.load(); }
155
+ take(this);
156
+ send(this, 'play');
157
+ send(this, 'playing');
158
+ }
159
+ return Promise.resolve();
160
+ };
161
+ Media.pause = function pause() {
162
+ if (!frozen || !isVideo(this)) return nativeMedia.pause.call(this);
163
+ const s = videos.get(this);
164
+ if (nativelyPlaying(this)) nativeMedia.pause.call(this);
165
+ else if (s?.playing) send(this, 'pause');
166
+ if (s) s.playing = false;
167
+ };
168
+ if (nativeMedia.paused) {
169
+ Object.defineProperty(Media, 'paused', {
170
+ configurable: true,
171
+ get() { const s = frozen && videos.get(this); return s ? !s.playing : nativeMedia.paused.call(this); },
172
+ });
173
+ }
174
+ clock_takeVideos = () => {
175
+ for (const v of win.document?.querySelectorAll?.('video') ?? []) {
176
+ if (nativelyPlaying(v)) { const s = videos.get(v); if (!s?.playing) take(v); else nativeMedia.pause.call(v); }
177
+ }
178
+ };
179
+ }
180
+ // Seeks every playing video to the clock; resolves when they show it. A
181
+ // video whose seek does not finish within 5 s (a host that stopped answering,
182
+ // a file that cannot seek) is left where it is from then on and reported, so
183
+ // one bad clip costs one wait, not one per frame.
184
+ const seekVideos = () => {
185
+ const waits = [];
186
+ for (const [v, s] of videos) {
187
+ if (!s.playing || s.stuck || !v.isConnected || v.readyState < 1 || v.error) continue;
188
+ let at = s.base + ((t - s.origin) / 1000) * (v.playbackRate || 1);
189
+ const d = v.duration;
190
+ let ended = false;
191
+ if (Number.isFinite(d) && d > 0) {
192
+ if (v.loop) at %= d;
193
+ else if (at >= d) { at = d; ended = true; }
194
+ }
195
+ // A hair past the frame's time, so rounding never lands it on the frame before.
196
+ if (!ended) at = Number.isFinite(d) ? Math.min(at + 0.001, d) : at + 0.001;
197
+ if (Math.abs(v.currentTime - at) > 0.0005) {
198
+ waits.push(new Promise((ok) => {
199
+ const done = () => { native.clearTimeout(timer); v.removeEventListener('seeked', done); v.removeEventListener('error', done); ok(); };
200
+ const timer = native.setTimeout(() => {
201
+ done();
202
+ s.stuck = true;
203
+ report(new Error(`a slide video stopped seeking (${String(v.currentSrc || v.src).slice(0, 120)}); it holds its frame for the rest of the video`));
204
+ }, 5000);
205
+ v.addEventListener('seeked', done);
206
+ v.addEventListener('error', done);
207
+ }));
208
+ v.currentTime = at;
209
+ }
210
+ if (ended) {
211
+ s.playing = false;
212
+ s.ended = true;
213
+ v.dispatchEvent(new Event('pause'));
214
+ v.dispatchEvent(new Event('ended'));
215
+ }
216
+ }
217
+ return Promise.all(waits);
218
+ };
219
+
220
+ const clock = {
221
+ get frozen() { return frozen; },
222
+ // From now on the page's time moves only with step().
223
+ freeze() {
224
+ if (frozen) return t;
225
+ t = native.now();
226
+ frozen = true;
227
+ for (const x of timers.values()) if (x.native != null) { native.clearTimeout(x.native); x.native = null; }
228
+ for (const x of frames.values()) if (x.native != null) { native.caf?.(x.native); x.native = null; }
229
+ clock_takeVideos();
230
+ animate(true);
231
+ return t;
232
+ },
233
+ // Advances the clock `ms`: due timers fire in time order (each at its own
234
+ // time), then this frame's requestAnimationFrame callbacks run. Returns the
235
+ // new time. settle() then finishes the frame.
236
+ step(ms) {
237
+ if (!frozen) throw new Error('the frame clock is not frozen');
238
+ const target = t + Math.max(0, Number(ms) || 0);
239
+ // A timer that keeps re-arming itself at 0 ms must not hang the step: it is reported, as a page error.
240
+ for (let guard = 0; ; guard++) {
241
+ let next = null, nextId = 0;
242
+ for (const [id, x] of timers) if (x.due <= target && (!next || x.due < next.due || (x.due === next.due && id < nextId))) { next = x; nextId = id; }
243
+ if (!next) break;
244
+ if (guard === 100000) { report(new Error('a timer re-armed itself 100000 times in one video frame; the rest wait for the next frame')); break; }
245
+ t = Math.max(t, next.due);
246
+ fire(nextId);
247
+ }
248
+ t = target;
249
+ const due = [...frames.values()];
250
+ frames.clear();
251
+ for (const x of due) call(x.cb, [t]);
252
+ return t;
253
+ },
254
+ // Lets what the step set off finish (promise continuations: a clip's
255
+ // `playing`, a class it adds), then places every animation and video at the
256
+ // clock's time and waits for the videos to show it. Call after each step,
257
+ // before the screenshot.
258
+ async settle() {
259
+ await new Promise((ok) => native.setTimeout(ok, 0));
260
+ clock_takeVideos();
261
+ animate();
262
+ await seekVideos();
263
+ },
264
+ now,
265
+ };
266
+ win.__hdFrameClock = clock;
267
+ return clock;
268
+ }
package/src/ops.mjs CHANGED
@@ -206,10 +206,13 @@ export async function generatePoster(opts, fields = {}) {
206
206
  // page after each with notes=true, a footer with the deck's tracked link) and
207
207
  // keep it 30 days. Returns { exportId, url, filename, bytes, pages, slides,
208
208
  // notes, link, expiresAt }. Waits for the file (about a second per slide).
209
+ // kind "mp4" queues a video on the render worker (convex/videoExports.ts) and
210
+ // returns { exportId, status: "queued" } at once: follow it with checkExport.
209
211
  export async function exportDeck(opts, fields = {}) {
210
212
  if (!fields.slug) throw new Error("slug is required");
211
213
  const kind = fields.kind ?? "pdf";
212
- if (kind !== "pdf") throw new Error(`kind "${kind}" is not supported yet: only "pdf" (video export comes from \`hyperdecks export --mp4\` later)`);
214
+ if (kind === "mp4") return requestVideoExport(opts, fields);
215
+ if (kind !== "pdf") throw new Error(`kind "${kind}" is not supported: "pdf" or "mp4"`);
213
216
  const args = { slug: String(fields.slug), kind: "pdf" };
214
217
  if (fields.notes) args.notes = true;
215
218
  if (fields.script) args.script = String(fields.script);
@@ -217,6 +220,101 @@ export async function exportDeck(opts, fields = {}) {
217
220
  return (await client(opts)).action(api.exports.create, args);
218
221
  }
219
222
 
223
+ const VIDEO_OPTIONS = ["size", "shape", "captions", "avatar", "titleCard", "endCard"];
224
+ export async function requestVideoExport(opts, fields = {}) {
225
+ const slug = String(fields.slug);
226
+ for (const k of ["notes", "script", "shareToken"]) if (fields[k]) throw new Error(`${k} is a PDF option; a video has none`);
227
+ const options = {};
228
+ for (const k of VIDEO_OPTIONS) if (fields[k] !== undefined) options[k] = fields[k];
229
+ const c = await client(opts);
230
+ try {
231
+ const out = await c.mutation(api.videoExports.requestVideo, { slug, options });
232
+ return { ...out, next: `poll check_export { exportId: "${out.exportId}" } (or \`hyperdecks export status ${out.exportId}\`) every 20-30 s; a deck renders in about its own length` };
233
+ } catch (e) {
234
+ // A backend from before the render worker has no requestVideo.
235
+ if (/Could not find public function/i.test(errorText(e))) throw new Error(videoFromCli(slug));
236
+ throw e;
237
+ }
238
+ }
239
+
240
+ // One export by id (check_export / `export status`): status, a rendering
241
+ // video's progress { slide, slides, phase }, its place in line, and the
242
+ // download URL once ready.
243
+ export async function checkExport(opts, exportId) {
244
+ if (!exportId) throw new Error("exportId is required");
245
+ return (await client(opts)).query(api.exports.get, { exportId: String(exportId) });
246
+ }
247
+
248
+ export async function cancelExport(opts, exportId) {
249
+ if (!exportId) throw new Error("exportId is required");
250
+ return (await client(opts)).mutation(api.videoExports.cancel, { exportId: String(exportId) });
251
+ }
252
+
253
+ // What a video request answers where no render worker is set up (the same words as convex/exports.ts videoFromCli).
254
+ export const videoFromCli = (slug) =>
255
+ `video exports from the dashboard and chat are not set up on this server yet; record it with the CLI: \`hyperdecks export ${slug} --video --out ${slug}.mp4\` ` +
256
+ "records the narrated deck frame by frame, slower than real time (add --upload to keep the MP4 with the deck's exports, --shape 9:16 or 1:1, --size 720p or 4k).";
257
+
258
+ // The released copy of a deck you own, for `export --video <slug>`: { slug, title, html }.
259
+ // Owner only, as export_deck is: a video is the owner's to make.
260
+ export async function ownedDeck(opts, slug) {
261
+ const c = await client(opts);
262
+ const [deck, me] = await Promise.all([c.query(api.decks.get, { slug }), c.query(api.decks.whoami, {})]);
263
+ if (!me) throw new Error("not logged in: run `hyperdecks login` first");
264
+ if (!deck) throw new Error(`deck not found: ${slug} (register it first, or pass --slug)`);
265
+ if (!me.isAdmin && deck.ownerId !== me.subject) throw new Error(`forbidden: ${slug} is not your deck`);
266
+ return deck;
267
+ }
268
+ export async function releasedDeck(opts, slug) {
269
+ const deck = await ownedDeck(opts, slug);
270
+ if (deck.status === "archived") throw new Error(`${slug} is archived; restore it before exporting`);
271
+ let html = deck.html ?? null;
272
+ if (!html && deck.htmlUrl) {
273
+ const res = await fetch(deck.htmlUrl, { signal: AbortSignal.timeout(30000) });
274
+ if (!res.ok) throw new Error(`could not read ${slug}'s released HTML (${res.status})`);
275
+ html = await res.text();
276
+ }
277
+ if (!html) throw new Error(`${slug} has no released HTML yet; release it first`);
278
+ return { slug, title: deck.title ?? slug, html };
279
+ }
280
+
281
+ // The link a video's end card shows: the deck's own "Video viewers" link, made
282
+ // on first use, so a viewer who types it in counts as a view of that link and
283
+ // never as a recipient's (their views and view limit).
284
+ export const VIDEO_RECIPIENT = "Video viewers";
285
+ export async function videoShareLink(opts, slug) {
286
+ 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));
287
+ const token = live?.token ?? (await createShare(opts, { slug, recipient: VIDEO_RECIPIENT })).token;
288
+ return `${target(opts).shareBase}/v/${token}`;
289
+ }
290
+
291
+ // `export --video --upload`: file the MP4 with the deck's exports (30 days).
292
+ // Returns { exportId, url, expiresAt }.
293
+ export async function uploadVideoExport(opts, { slug, bytes, seconds, width, height }, { fetchImpl = fetch, timeoutMs = 30 * 60_000 } = {}) {
294
+ const c = await client(opts);
295
+ const { exportId, uploadUrl } = await c.mutation(api.exports.startVideoUpload, { slug, bytes: bytes.length, seconds, width, height });
296
+ try {
297
+ let res;
298
+ try {
299
+ res = await fetchImpl(uploadUrl, { method: "POST", headers: { "Content-Type": "video/mp4" }, body: bytes, signal: AbortSignal.timeout(timeoutMs) });
300
+ } catch (e) {
301
+ throw new Error(`upload failed: ${e?.name === "TimeoutError" ? `no answer in ${Math.round(timeoutMs / 60000)} min` : e?.message || String(e)}`);
302
+ }
303
+ if (!res.ok) throw new Error(`upload failed: ${res.status} ${await res.text()}`);
304
+ const body = await res.text();
305
+ let storageId;
306
+ try { ({ storageId } = JSON.parse(body)); } catch { /* checked below */ }
307
+ if (!storageId) throw new Error(`upload failed: the storage answered without a file id (${body.slice(0, 200)})`);
308
+ const done = await c.mutation(api.exports.finishVideoUpload, { exportId, storageId });
309
+ if (!done.ok) throw new Error(`upload refused: ${done.error}`);
310
+ return { exportId, url: done.url, expiresAt: done.expiresAt };
311
+ } catch (e) {
312
+ // The row says so at once, rather than "uploading…" until the sweep.
313
+ await c.mutation(api.exports.failVideoUpload, { exportId, error: errorText(e) }).catch((err) => console.error(`[export] could not mark export ${exportId} failed: ${errorText(err)}`));
314
+ throw e;
315
+ }
316
+ }
317
+
220
318
  // Your exports (newest first, 30 days), optionally for one deck.
221
319
  export async function listExports(opts, fields = {}) {
222
320
  return (await client(opts)).query(api.exports.list, fields.slug ? { slug: String(fields.slug) } : {});
@@ -12,12 +12,35 @@ const run = promisify(execFile);
12
12
  const require = createRequire(import.meta.url);
13
13
  const MIME = { '.html':'text/html', '.htm':'text/html', '.js':'text/javascript', '.mjs':'text/javascript', '.css':'text/css', '.json':'application/json', '.png':'image/png', '.jpg':'image/jpeg', '.jpeg':'image/jpeg', '.gif':'image/gif', '.webp':'image/webp', '.svg':'image/svg+xml', '.woff':'font/woff', '.woff2':'font/woff2', '.ttf':'font/ttf', '.otf':'font/otf', '.mp4':'video/mp4', '.webm':'video/webm', '.mp3':'audio/mpeg', '.wav':'audio/wav', '.wasm':'application/wasm', '.glb':'model/gltf-binary', '.gltf':'model/gltf+json', '.bin':'application/octet-stream' };
14
14
  const SETUP = 'Run `hyperdecks preview setup` (or `decks preview setup`) to install the matching isolated Chromium. On Linux install system libraries with `npx playwright install-deps chromium` using the Playwright version in your installed CLI.';
15
+ // Installs the Chromium this CLI's Playwright launches, into Playwright's shared
16
+ // cache. PLAYWRIGHT_SKIP_BROWSER_GC keeps the installer from pruning builds that
17
+ // other Playwright installs (a repo's own test runner) still use.
18
+ export async function installChromium({ exec = run } = {}) {
19
+ await exec(process.execPath, [join(dirname(require.resolve('playwright/package.json')), 'cli.js'), 'install', 'chromium'], {
20
+ timeout: 240000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_GC: '1' },
21
+ });
22
+ }
23
+ // Playwright's launch error when the browser build is not on disk.
24
+ export const isMissingBrowser = (e) => /Executable doesn't exist/.test(String(e?.message ?? e));
15
25
  export async function setupCapture(pos, opts) {
16
26
  if (pos.length || Object.keys(opts).some(k => !['json', 'dev', 'convex', 'prod'].includes(k))) throw new Error('usage: preview setup');
17
- await run(process.execPath, [join(dirname(require.resolve('playwright/package.json')), 'cli.js'), 'install', 'chromium'], { timeout: 240000, maxBuffer: 4 * 1024 * 1024 });
27
+ await installChromium();
18
28
  console.log(opts.json ? JSON.stringify({ installed: 'chromium' }) : 'Chromium installed. Preview capture is ready.');
19
29
  }
20
30
 
31
+ // One `bytes=a-b`, `bytes=a-` or `bytes=-n` range of a `size`-byte file:
32
+ // { start, end } (inclusive), undefined when there is no (usable) Range header
33
+ // (send it whole), null when it asks past the end (416).
34
+ export function byteRange(header, size) {
35
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(header ?? '').trim());
36
+ if (!m || (m[1] === '' && m[2] === '')) return undefined;
37
+ let start, end;
38
+ if (m[1] === '') { start = Math.max(0, size - Number(m[2])); end = size - 1; }
39
+ else { start = Number(m[1]); end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1); }
40
+ if (start >= size || start > end) return null;
41
+ return { start, end };
42
+ }
43
+
21
44
  // Capability URL + realpath containment; captured code gets no general file server.
22
45
  // `html` alone serves that page; with `file` it replaces the page and the file's
23
46
  // folder still serves the rest (play's in-memory copy with the face on it).
@@ -34,8 +57,14 @@ export async function serveSource({ file, html, port = 0 }) {
34
57
  if (!root || rel.split('/').some(s => s.startsWith('.') || s.includes('\\')) || !MIME[extname(rel).toLowerCase()]) { res.writeHead(404).end(); return; }
35
58
  const full = await realpath(resolve(root, rel));
36
59
  if (!full.startsWith(root + sep) || !(await stat(full)).isFile()) { res.writeHead(404).end(); return; }
37
- res.writeHead(200, { 'Content-Type': MIME[extname(full).toLowerCase()] + (MIME[extname(full).toLowerCase()].startsWith('text/') ? '; charset=utf-8' : ''), 'Cache-Control':'no-store' });
38
- res.end(await readFile(full));
60
+ const type = MIME[extname(full).toLowerCase()] + (MIME[extname(full).toLowerCase()].startsWith('text/') ? '; charset=utf-8' : '');
61
+ const body = await readFile(full);
62
+ // A byte range, as any host answers one: a <video> can only seek with it (a frame-by-frame export seeks every frame).
63
+ const range = byteRange(req.headers.range, body.length);
64
+ if (range === null) { res.writeHead(416, { 'Content-Range': `bytes */${body.length}` }).end(); return; }
65
+ if (range) { res.writeHead(206, { 'Content-Type': type, 'Cache-Control':'no-store', 'Accept-Ranges':'bytes', 'Content-Range': `bytes ${range.start}-${range.end}/${body.length}` }).end(body.subarray(range.start, range.end + 1)); return; }
66
+ res.writeHead(200, { 'Content-Type': type, 'Cache-Control':'no-store', 'Accept-Ranges':'bytes' });
67
+ res.end(body);
39
68
  } catch { res.writeHead(404).end(); }
40
69
  });
41
70
  await new Promise((ok, fail) => { server.once('error', fail); server.listen(port, '127.0.0.1', ok); });
@@ -50,7 +79,9 @@ export function allowCaptureRequest(url, method, type, entryUrl, firstPartyOrigi
50
79
  if (type === 'document') return url === entryUrl;
51
80
  if (['websocket', 'eventsource', 'ping', 'other'].includes(type)) return false;
52
81
  if (firstPartyOrigins.some(v => new URL(v).origin === u.origin) || /\.convex\.(cloud|site)$/.test(u.hostname)) {
53
- return /^\/api\/storage\//.test(path);
82
+ // Stored assets, and the hosted player a player/v1 deck loads (player.js, its
83
+ // modules, css, emote bursts). Views (/v/) and telemetry stay refused above.
84
+ return /^\/(api\/storage|player)\//.test(path);
54
85
  }
55
86
  // Prevent page code from reaching local services or cloud metadata. Only the
56
87
  // capability file server is available on loopback; redirects are checked too.
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.2";
11
+ export const VERSION = "0.9.4";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  export const PROD = {
@@ -132,6 +132,14 @@ export const api = {
132
132
  create: makeFunctionReference("exports:create"),
133
133
  list: makeFunctionReference("exports:list"),
134
134
  remove: makeFunctionReference("exports:remove"),
135
+ startVideoUpload: makeFunctionReference("exports:startVideoUpload"),
136
+ finishVideoUpload: makeFunctionReference("exports:finishVideoUpload"),
137
+ failVideoUpload: makeFunctionReference("exports:failVideoUpload"),
138
+ get: makeFunctionReference("exports:get"),
139
+ },
140
+ videoExports: {
141
+ requestVideo: makeFunctionReference("videoExports:requestVideo"),
142
+ cancel: makeFunctionReference("videoExports:cancel"),
135
143
  },
136
144
  review: {
137
145
  reviewDeck: makeFunctionReference("review:reviewDeck"),
@@ -161,7 +169,7 @@ export const api = {
161
169
  },
162
170
  };
163
171
 
164
- 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"]);
172
+ 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"]);
165
173
  // release's recipients, in order: each --to starts one, and an --email right
166
174
  // after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
167
175
  // opts.email stays the last --email for the other commands (share new).