@hypersocial/decks-cli 0.9.5 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +3 -3
- package/src/checkRules.mjs +8 -4
- package/src/commands.mjs +8 -5
- package/src/export-video.mjs +290 -43
- package/src/exportHelp.mjs +1 -1
- package/src/firstDeck.mjs +1 -1
- package/src/loadingBlock.mjs +2 -2
- package/src/presenter.mjs +2 -2
- package/src/preview-capture.mjs +24 -2
- package/src/preview.mjs +2 -2
- package/src/runtime.mjs +33 -5
- package/templates/layouts/_deck.html +3 -3
- package/templates/narrated-deck.html +3 -3
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ decks activity
|
|
|
34
34
|
|
|
35
35
|
## Auth
|
|
36
36
|
|
|
37
|
-
`decks login` opens the production broker at `https://
|
|
37
|
+
`decks login` opens the production broker at `https://hyperdecks.ai` and
|
|
38
38
|
stores a refresh credential in:
|
|
39
39
|
|
|
40
40
|
```text
|
|
@@ -261,7 +261,7 @@ Capture defaults to the initial slide, 4 seconds, 600px width and 10 fps, with a
|
|
|
261
261
|
`--json` prints one line on stdout, identical to `<out>/manifest.json`:
|
|
262
262
|
|
|
263
263
|
```json
|
|
264
|
-
{ "status": "exported | uploaded | failed", "shareUrl": "https://
|
|
264
|
+
{ "status": "exported | uploaded | failed", "shareUrl": "https://hyperdecks.ai/v/<token>",
|
|
265
265
|
"files": [{ "type": "gif | png | mp4", "path": "...", "url": "https://...convex.cloud/api/storage/...", "bytes": 74662, "width": 600, "height": 338 }],
|
|
266
266
|
"email": { "sendable": true, "path": "<out>/email.html", "html": "<a href=\"<shareUrl>\"><img src=\"<gif url>\" ...></a>" },
|
|
267
267
|
"warnings": [] }
|
package/package.json
CHANGED
|
@@ -225,7 +225,7 @@ The deck contract (`check` enforces or warns on each line):
|
|
|
225
225
|
so put slots on different sides across slides, as the scaffold's
|
|
226
226
|
`.slot-left` does); the first slide has `data-intro`.
|
|
227
227
|
- The player script stays
|
|
228
|
-
`<script type="module" src="https://
|
|
228
|
+
`<script type="module" src="https://hyperdecks.ai/player/v1/player.js" data-manifest="…">`.
|
|
229
229
|
`narrate` fills `data-manifest`; do not edit it by hand.
|
|
230
230
|
- A deck with no narration (a product tour, an imported design deck, a silent
|
|
231
231
|
walkthrough) says so: `data-silent` on the player script. `check` then warns
|
|
@@ -653,7 +653,7 @@ overflowing slide; `check <slug> --hosted --layout` measures a hosted draft.
|
|
|
653
653
|
`--forbid` lists phrases that must not reach this audience (another customer,
|
|
654
654
|
an internal codename). It matches the page's text, alt text, titles and
|
|
655
655
|
script.md, not URLs in attributes, so forbidding your own brand still passes
|
|
656
|
-
with the player's `
|
|
656
|
+
with the player's `hyperdecks.ai` script tag. Messages and what to do:
|
|
657
657
|
|
|
658
658
|
| Message starts with | Fix |
|
|
659
659
|
|---|---|
|
|
@@ -842,7 +842,7 @@ Capture defaults to the initial slide, 4 seconds, 600px width and 10 fps, with a
|
|
|
842
842
|
`--json` prints one line on stdout, identical to `<out>/manifest.json`:
|
|
843
843
|
|
|
844
844
|
```json
|
|
845
|
-
{ "status": "exported | uploaded | failed", "shareUrl": "https://
|
|
845
|
+
{ "status": "exported | uploaded | failed", "shareUrl": "https://hyperdecks.ai/v/<token>",
|
|
846
846
|
"files": [{ "type": "gif | png | mp4", "path": "...", "url": "https://...convex.cloud/api/storage/...", "bytes": 74662, "width": 600, "height": 338 }],
|
|
847
847
|
"email": { "sendable": true, "path": "<out>/email.html", "html": "<a href=\"<shareUrl>\"><img src=\"<gif url>\" ...></a>" },
|
|
848
848
|
"warnings": [] }
|
package/src/checkRules.mjs
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
// the deck's index.html and script.md as text, with the manifest and clip fetches
|
|
6
6
|
// injected.
|
|
7
7
|
|
|
8
|
-
export const PLAYER_V1 = "https://
|
|
8
|
+
export const PLAYER_V1 = "https://hyperdecks.ai/player/v1/player.js";
|
|
9
|
+
// Decks released before the app moved to hyperdecks.ai load the same player
|
|
10
|
+
// from the old host, which serves it forever; both pass.
|
|
11
|
+
export const PLAYER_V1_LEGACY = "https://decks.hypersocial.ai/player/v1/player.js";
|
|
12
|
+
export const isPlayerV1 = (src) => src === PLAYER_V1 || src === PLAYER_V1_LEGACY;
|
|
9
13
|
export const DEFAULT_MAX_DURATION = 180;
|
|
10
14
|
export const MOBILE_TITLE_CHARS = 28;
|
|
11
15
|
export const CAPTION_MIN_CHARS = 4;
|
|
@@ -549,7 +553,7 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
|
|
|
549
553
|
const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script), ...cueGestureErrors(script), ...placeholderErrors(html, { hosted })];
|
|
550
554
|
const player = playerScript(html);
|
|
551
555
|
if (!player) errs.push(MESSAGES.noPlayer());
|
|
552
|
-
else if (player.src
|
|
556
|
+
else if (!isPlayerV1(player.src)) errs.push(MESSAGES.player(player.src));
|
|
553
557
|
// The loading state must be in <head> to hold the page from its first paint,
|
|
554
558
|
// with its fail-open so a deck whose player never loads still shows.
|
|
555
559
|
const loading = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>([\s\S]*?)<\/style>/i.exec(/<head\b[\s\S]*?<\/head>/i.exec(uncommented(html))?.[0] ?? "");
|
|
@@ -574,7 +578,7 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
|
|
|
574
578
|
const page = textOf(uncommented(html).replace(/<(script|style)\b[\s\S]*?<\/\1>/g, " ")).toLowerCase();
|
|
575
579
|
// The raw HTML too (alt text, <title>, meta content, inline script), less
|
|
576
580
|
// attribute values that are URLs: the player's own src (PLAYER_V1) names
|
|
577
|
-
// decks.hypersocial.ai, so forbidding the brand would otherwise always fail.
|
|
581
|
+
// hyperdecks.ai (or decks.hypersocial.ai), so forbidding the brand would otherwise always fail.
|
|
578
582
|
const raw = withoutUrlAttrs(uncommented(html)).toLowerCase();
|
|
579
583
|
for (const phrase of phraseList(forbid)) {
|
|
580
584
|
const p = phrase.toLowerCase();
|
|
@@ -740,7 +744,7 @@ export function absoluteRefs(html) {
|
|
|
740
744
|
}
|
|
741
745
|
|
|
742
746
|
// Hosts a released deck can rely on: our storage and app, the player, and fonts.
|
|
743
|
-
const HOSTED_ASSET_HOSTS = [/\.convex\.cloud$/, /\.convex\.site$/, /(^|\.)hypersocial\.ai$/, /^fonts\.(googleapis|gstatic)\.com$/];
|
|
747
|
+
const HOSTED_ASSET_HOSTS = [/\.convex\.cloud$/, /\.convex\.site$/, /(^|\.)hypersocial\.ai$/, /(^|\.)hyperdecks\.ai$/, /^fonts\.(googleapis|gstatic)\.com$/];
|
|
744
748
|
const isHostedAsset = (url) => {
|
|
745
749
|
try {
|
|
746
750
|
const host = new URL(url).hostname;
|
package/src/commands.mjs
CHANGED
|
@@ -415,13 +415,16 @@ commands:
|
|
|
415
415
|
a PDF of the released deck: one 1600x900 page per slide (a script page after each
|
|
416
416
|
with --notes), a footer with the deck's tracked link; kept 30 days
|
|
417
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>]] [--
|
|
418
|
+
[--no-avatar] [--title-card] [--end-card [--link <url>]] [--slides <a-b>] [--upload]
|
|
419
|
+
[--out <file.mp4>] [--json]
|
|
419
420
|
an MP4 of the narrated deck, drawn frame by frame so the avatar is smooth (slower
|
|
420
421
|
than real time: a 4-minute deck takes about 25 minutes with the live head on a
|
|
421
|
-
laptop
|
|
422
|
-
the
|
|
423
|
-
|
|
424
|
-
|
|
422
|
+
laptop; the dashboard's Export → Video splits it across machines, about 10
|
|
423
|
+
minutes): the avatar, voice and captions, none of the player's chrome. A slug
|
|
424
|
+
records the released copy, a deck folder its working copy. --slides 3-5 records
|
|
425
|
+
only those slides (counting from 1). --title-card adds the title and presenter
|
|
426
|
+
(2s), --end-card the deck's "Video viewers" link (3s); --upload keeps it with the
|
|
427
|
+
deck's exports for 30 days
|
|
425
428
|
export list [<slug>] [--json] your exports and their download links
|
|
426
429
|
export status <exportId> [--json] one export: a hosted video's progress, its link when done
|
|
427
430
|
export cancel <exportId> stop a hosted video export (Export → Video, export_deck mp4)
|
package/src/export-video.mjs
CHANGED
|
@@ -14,12 +14,18 @@
|
|
|
14
14
|
// card (2 s) and end card (3 s) are plain pages rendered the same size.
|
|
15
15
|
// ffmpeg (the CLI's portable @ffmpeg-installer build) mixes and encodes:
|
|
16
16
|
// H.264, AAC, +faststart.
|
|
17
|
+
//
|
|
18
|
+
// A range of slides (config.range, `--slides a-b`) records just those slides;
|
|
19
|
+
// the render worker splits a long deck that way across machines: each records
|
|
20
|
+
// a video-only segment (config.segment) and says where each clip started in
|
|
21
|
+
// it, and joinVideo concatenates the segments and lays the whole deck's audio
|
|
22
|
+
// under them once, so the voice has no seam.
|
|
17
23
|
import { copyFile, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
18
24
|
import { tmpdir } from 'node:os';
|
|
19
25
|
import { dirname, extname, join } from 'node:path';
|
|
20
26
|
import { execFile } from 'node:child_process';
|
|
21
27
|
import { promisify } from 'node:util';
|
|
22
|
-
import { allowCaptureDestination, installChromium, isMissingBrowser, noteSoftwareWebgl, recorderArgs, routeCaptureRequest } from './preview-capture.mjs';
|
|
28
|
+
import { allowCaptureDestination, allowCaptureRequest, installChromium, isMissingBrowser, noteSoftwareWebgl, recorderArgs, routeCaptureRequest } from './preview-capture.mjs';
|
|
23
29
|
import { installFrameClock } from './frame-clock.mjs';
|
|
24
30
|
|
|
25
31
|
const run = promisify(execFile);
|
|
@@ -53,6 +59,7 @@ export function videoGeometry(size = '1080p', shape = '16:9') {
|
|
|
53
59
|
// The command's options, checked before anything launches.
|
|
54
60
|
export function videoOptions(opts = {}) {
|
|
55
61
|
for (const k of ['size', 'shape', 'out', 'link', 'slug']) if (k in opts && typeof opts[k] !== 'string') throw new Error(`--${k} requires a value`);
|
|
62
|
+
if ('slides' in opts && typeof opts.slides !== 'string') throw new Error('--slides requires a value (a-b, counting from 1)');
|
|
56
63
|
const geometry = videoGeometry(opts.size ?? '1080p', opts.shape ?? '16:9');
|
|
57
64
|
if (opts.out && extname(opts.out).toLowerCase() !== '.mp4') throw new Error(`--out must name an .mp4 file (got ${opts.out})`);
|
|
58
65
|
if (opts.link !== undefined) {
|
|
@@ -70,9 +77,19 @@ export function videoOptions(opts = {}) {
|
|
|
70
77
|
link: opts.link ?? null,
|
|
71
78
|
out: opts.out ?? null,
|
|
72
79
|
upload: !!opts.upload,
|
|
80
|
+
range: opts.slides ? parseSlides(opts.slides) : null,
|
|
73
81
|
};
|
|
74
82
|
}
|
|
75
83
|
|
|
84
|
+
// --slides 3-5 (or 4): the slides to record, counting from 1, as 0-based
|
|
85
|
+
// indexes { from, to } (inclusive). The deck's own length is checked once it loads.
|
|
86
|
+
export function parseSlides(text) {
|
|
87
|
+
const m = /^\s*(\d+)\s*(?:-\s*(\d+)\s*)?$/.exec(String(text ?? ''));
|
|
88
|
+
const from = m ? Number(m[1]) : NaN, to = m ? Number(m[2] ?? m[1]) : NaN;
|
|
89
|
+
if (!m || from < 1 || to < from) throw new Error(`--slides must be a range like 3-5 or one slide like 4, counting from 1 (got ${text})`);
|
|
90
|
+
return { from: from - 1, to: to - 1 };
|
|
91
|
+
}
|
|
92
|
+
|
|
76
93
|
// The page the recorder opens: the deck in export mode.
|
|
77
94
|
export function exportPageUrl(url, { captions = true, avatar = true } = {}) {
|
|
78
95
|
const u = new URL(url);
|
|
@@ -138,18 +155,83 @@ export function audioMixArgs({ inputs, seconds, out }) {
|
|
|
138
155
|
}
|
|
139
156
|
|
|
140
157
|
// ffmpeg args for the MP4: the numbered frames at `fps` and the mixed audio.
|
|
141
|
-
|
|
158
|
+
// Without `audio`, a video-only segment with the same picture settings, so
|
|
159
|
+
// segments join without a re-encode (joinArgs).
|
|
160
|
+
const PICTURE = (fps) => ['-c:v', 'libx264', '-preset', 'fast', '-crf', '20', '-r', String(fps)];
|
|
161
|
+
export function encodeArgs({ frames, fps = VIDEO_FPS, audio, width, height, out, startNumber = 0 }) {
|
|
162
|
+
return [
|
|
163
|
+
'-framerate', String(fps), ...(startNumber ? ['-start_number', String(startNumber)] : []), '-i', frames,
|
|
164
|
+
...(audio ? ['-i', audio, '-map', '0:v', '-map', '1:a'] : ['-map', '0:v']),
|
|
165
|
+
'-vf', `scale=${width}:${height}:flags=lanczos,format=yuv420p`,
|
|
166
|
+
...PICTURE(fps),
|
|
167
|
+
...(audio ? ['-c:a', 'aac', '-b:a', '192k', '-shortest'] : ['-an']),
|
|
168
|
+
'-movflags', '+faststart', '-y', out,
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ffmpeg args that join the segments (a concat list file) under the whole
|
|
173
|
+
// audio track: the picture copied as is, or re-encoded when `reencode`.
|
|
174
|
+
// Only a deck whose clips are plain http(s) URLs of bounded length is split:
|
|
175
|
+
// the join fetches each clip again from its URL on another machine, and Convex
|
|
176
|
+
// keeps the list on a row (a data: or blob: clip, or a huge one, renders whole).
|
|
177
|
+
export const MAX_SPLIT_AUDIO_URL = 1000;
|
|
178
|
+
// The seam: a segment that is not the last records this much past its last
|
|
179
|
+
// slide, through the next slide's real transition (the glide, the bubble's
|
|
180
|
+
// exit, the parts rising), as a single render draws it; the next segment
|
|
181
|
+
// renders the same time from a fresh start but keeps none of it. Its frames
|
|
182
|
+
// after that match a single render's. Longer than any transition (~0.7 s,
|
|
183
|
+
// transitions.js) plus its staggered parts.
|
|
184
|
+
export const SEAM_SECONDS = 2;
|
|
185
|
+
export const SEAM_FRAMES = SEAM_SECONDS * VIDEO_FPS;
|
|
186
|
+
export const MAX_SPLIT_CLIPS = 200;
|
|
187
|
+
export function splittableAudio(plan) {
|
|
188
|
+
const audio = plan.map((s) => s.audio).filter(Boolean);
|
|
189
|
+
return audio.length <= MAX_SPLIT_CLIPS && audio.every((a) => typeof a === 'string' && a.length <= MAX_SPLIT_AUDIO_URL && /^https?:\/\//i.test(a));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// What ffmpeg says about a file it opens: its duration and frame size, from
|
|
193
|
+
// the stream header it prints (the portable build has no ffprobe).
|
|
194
|
+
export function probeText(text) {
|
|
195
|
+
const d = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(text);
|
|
196
|
+
const v = /Video:.*?\b(\d{2,5})x(\d{2,5})\b/.exec(text);
|
|
197
|
+
return {
|
|
198
|
+
seconds: d ? Number(d[1]) * 3600 + Number(d[2]) * 60 + Number(d[3]) : null,
|
|
199
|
+
width: v ? Number(v[1]) : null,
|
|
200
|
+
height: v ? Number(v[2]) : null,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
async function probe(path, file) {
|
|
204
|
+
// Without an output ffmpeg exits 1 after printing the input's header: that is the answer.
|
|
205
|
+
const text = await new Promise((ok) => execFile(path, ['-hide_banner', '-nostdin', '-i', file], { timeout: 60000, env: { PATH: dirname(path), ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}) } }, (_e, _out, err) => ok(String(err ?? ''))));
|
|
206
|
+
return probeText(text);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function joinArgs({ list, audio, out, fps = VIDEO_FPS, reencode = false }) {
|
|
142
210
|
return [
|
|
143
|
-
'-
|
|
211
|
+
'-f', 'concat', '-safe', '0', '-i', list,
|
|
144
212
|
'-i', audio,
|
|
145
213
|
'-map', '0:v', '-map', '1:a',
|
|
146
|
-
'-vf',
|
|
147
|
-
'-c:v', 'libx264', '-preset', 'fast', '-crf', '20', '-r', String(fps),
|
|
214
|
+
...(reencode ? ['-vf', 'format=yuv420p', ...PICTURE(fps)] : ['-c:v', 'copy']),
|
|
148
215
|
'-c:a', 'aac', '-b:a', '192k',
|
|
149
216
|
'-shortest', '-movflags', '+faststart', '-y', out,
|
|
150
217
|
];
|
|
151
218
|
}
|
|
152
219
|
|
|
220
|
+
// Each segment's clips placed on the joined video's timeline: a segment starts
|
|
221
|
+
// where the frames before it end, so the audio follows the real frame counts.
|
|
222
|
+
export function joinTimeline(segments, fps = VIDEO_FPS) {
|
|
223
|
+
let frames = 0;
|
|
224
|
+
const clips = [];
|
|
225
|
+
for (const s of segments) {
|
|
226
|
+
for (const c of s.clips ?? []) clips.push({ id: c.id, audio: c.audio, offset: Math.max(0, frames / fps + (Number(c.offset) || 0)) });
|
|
227
|
+
frames += s.frames;
|
|
228
|
+
}
|
|
229
|
+
return { clips, frames, seconds: frames / fps };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The concat demuxer's list: one quoted path a line.
|
|
233
|
+
export const concatList = (files) => files.map((f) => `file '${String(f).replace(/'/g, "'\\''")}'`).join('\n') + '\n';
|
|
234
|
+
|
|
153
235
|
// A computed style value inside <style>: entities are not decoded there, so it
|
|
154
236
|
// is not HTML-escaped; anything that could end the rule or the element goes.
|
|
155
237
|
const cssValue = (s) => String(s ?? '').replace(/[<>{};\\]/g, '');
|
|
@@ -206,6 +288,20 @@ export async function clipSeconds(path, file) {
|
|
|
206
288
|
return seconds;
|
|
207
289
|
}
|
|
208
290
|
|
|
291
|
+
// One clip's audio into `path`: only from a public address (or the deck's own
|
|
292
|
+
// origin, `entryUrl`), no redirects, at most MAX_CLIP_BYTES.
|
|
293
|
+
async function downloadClip(url, path, { id, entryUrl }) {
|
|
294
|
+
if (!await allowCaptureDestination(url, entryUrl)) throw new Error(`clip ${id}'s audio is not on a public address: ${url}`);
|
|
295
|
+
let res;
|
|
296
|
+
try { res = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(30000) }); }
|
|
297
|
+
catch (e) { throw new Error(`could not download clip ${id}'s audio (${url}): ${e.cause?.message ?? e.message}`); }
|
|
298
|
+
if (!res.ok) throw new Error(`could not download clip ${id}'s audio (${res.status} from ${url})`);
|
|
299
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
300
|
+
if (bytes.length > MAX_CLIP_BYTES) throw new Error(`clip ${id}'s audio is over ${MAX_CLIP_BYTES / 1024 / 1024} MB`);
|
|
301
|
+
await writeFile(path, bytes);
|
|
302
|
+
}
|
|
303
|
+
const clipPath = (dir, n, url) => join(dir, `clip-${n}${extname(new URL(url).pathname).slice(0, 6) || '.audio'}`);
|
|
304
|
+
|
|
209
305
|
// Bounds a page.evaluate on the Node side (a busy main thread can hold it).
|
|
210
306
|
async function within(promise, ms, fallback) {
|
|
211
307
|
promise.catch(() => {});
|
|
@@ -224,15 +320,40 @@ const readLog = (page, ms = 5000) => within(page.evaluate(() => {
|
|
|
224
320
|
// What the page's network did before a ready timeout, for the error message.
|
|
225
321
|
// `blocked` holds the URLs the capture rules refused; `failed` is the last
|
|
226
322
|
// request that failed for another reason ({ url, error }).
|
|
227
|
-
export function readySaw({
|
|
323
|
+
export function readySaw({ refused = 0, unreachable = [], failed = null } = {}) {
|
|
228
324
|
const cut = (u) => (u.length > 160 ? `${u.slice(0, 157)}…` : u);
|
|
229
325
|
const parts = [];
|
|
230
|
-
|
|
326
|
+
// Passed the recorder's rules but could not be fetched (DNS, timeout, a redirect): these can stop a deck.
|
|
327
|
+
if (unreachable.length) parts.push(`${unreachable.length} request${unreachable.length === 1 ? '' : 's'} could not be fetched, last ${cut(unreachable.at(-1))}`);
|
|
231
328
|
// requestfailed can fire before the route handler records the block.
|
|
232
|
-
if (failed && !
|
|
329
|
+
if (failed && !unreachable.includes(failed.url)) parts.push(`last failed request ${cut(failed.url)} (${failed.error})`);
|
|
330
|
+
// Tracking, view counts, links and navigation are refused on every recording; only a count, not a cause.
|
|
331
|
+
if (refused) parts.push(`${refused} tracking or navigation request${refused === 1 ? '' : 's'} refused, as on every recording`);
|
|
233
332
|
return parts.length ? ` (${parts.join('; ')})` : '';
|
|
234
333
|
}
|
|
235
334
|
|
|
335
|
+
// How slow a slide draws, for the render worker's split (lib/videoSegments.ts
|
|
336
|
+
// slideCosts): 1 for a plain slide, plus 0.3 for each video or embedded page it
|
|
337
|
+
// shows (up to 3). Measured on Fly at 1080p in software: plain slides 0.67–0.75 s
|
|
338
|
+
// a frame, slides with video 0.94–1.36.
|
|
339
|
+
export const MEDIA_WEIGHT = 0.3;
|
|
340
|
+
export function slideWeight(media) {
|
|
341
|
+
const n = Number.isFinite(media) && media > 0 ? Math.min(3, Math.floor(media)) : 0;
|
|
342
|
+
return Math.round((1 + MEDIA_WEIGHT * n) * 100) / 100;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// How long a deck may take to become ready: config.readyTimeoutMs, else
|
|
346
|
+
// HYPERDECKS_READY_TIMEOUT_MS, else 120 s. A small cloud machine draws the deck
|
|
347
|
+
// in software and can need a minute or more (the render worker passes 300 s).
|
|
348
|
+
export const READY_TIMEOUT_MS = 120000;
|
|
349
|
+
export function readyTimeoutMs(config = {}, env = process.env) {
|
|
350
|
+
for (const v of [config.readyTimeoutMs, env.HYPERDECKS_READY_TIMEOUT_MS]) {
|
|
351
|
+
const n = Number(v);
|
|
352
|
+
if (v != null && v !== '' && Number.isFinite(n) && n > 0) return Math.floor(n);
|
|
353
|
+
}
|
|
354
|
+
return READY_TIMEOUT_MS;
|
|
355
|
+
}
|
|
356
|
+
|
|
236
357
|
// Why a child process failed: the last line it printed, or its timeout.
|
|
237
358
|
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
359
|
|
|
@@ -259,8 +380,15 @@ export async function launchRecorder(chromium, options, { log = () => {}, instal
|
|
|
259
380
|
|
|
260
381
|
// Records `config.url` (a page that loads player/v1) and writes the MP4.
|
|
261
382
|
// config: { url, geometry, captions, avatar, titleCard, endCard, title,
|
|
262
|
-
// presenter, link, out, firstPartyOrigins, playerDir
|
|
263
|
-
//
|
|
383
|
+
// presenter, link, out, firstPartyOrigins, playerDir, range, plan, segment }.
|
|
384
|
+
// range { from, to }: only those slides (0-based, inclusive); the title card
|
|
385
|
+
// only when it starts at the first slide, the end card only when it ends at the
|
|
386
|
+
// last. plan({ seconds, ranges }) → { to, segment }: asked once the player's
|
|
387
|
+
// plan is read (the render worker's lead job asks Convex how to split);
|
|
388
|
+
// segment: true then records slides 0..to as a segment. segment: the file is
|
|
389
|
+
// video only, and the result's `segment` { from, to, frames, clips } says where
|
|
390
|
+
// each clip started in it (joinVideo). `log` gets progress lines. Returns
|
|
391
|
+
// { file, seconds, width, height, bytes, slides, clips, warnings, segment? }.
|
|
264
392
|
export async function recordVideo(config, { log = () => {}, chromium: injected, install } = {}) {
|
|
265
393
|
const { geometry, out } = config;
|
|
266
394
|
const ff = await ffmpegPath();
|
|
@@ -279,10 +407,15 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
279
407
|
await noteSoftwareWebgl(browser, log);
|
|
280
408
|
const pageUrl = exportPageUrl(config.url, config);
|
|
281
409
|
const context = await browser.newContext({ viewport: geometry.viewport, deviceScaleFactor: geometry.scale, serviceWorkers: 'block', acceptDownloads: false });
|
|
282
|
-
const blockedUrls = [];
|
|
283
|
-
let failed = null;
|
|
410
|
+
const blockedUrls = [], unreachable = [];
|
|
411
|
+
let refused = 0, failed = null;
|
|
284
412
|
await context.route('**/*', async (route) => {
|
|
285
|
-
if (
|
|
413
|
+
if (await routeCaptureRequest(route, pageUrl, config.firstPartyOrigins)) return;
|
|
414
|
+
const r = route.request();
|
|
415
|
+
blockedUrls.push(r.url());
|
|
416
|
+
// Refused by rule (expected) or allowed but unreachable (worth naming).
|
|
417
|
+
if (allowCaptureRequest(r.url(), r.method(), r.resourceType(), pageUrl, config.firstPartyOrigins)) unreachable.push(r.url());
|
|
418
|
+
else refused++;
|
|
286
419
|
});
|
|
287
420
|
// A development checkout plays its own player/v1 (HYPERDECKS_PLAYER_DIR), not the released one.
|
|
288
421
|
if (config.playerDir) {
|
|
@@ -297,6 +430,8 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
297
430
|
await context.routeWebSocket('**/*', (ws) => ws.close());
|
|
298
431
|
// The page clock goes in before any of the deck's scripts (frame-clock.mjs).
|
|
299
432
|
await context.addInitScript({ content: `(${installFrameClock.toString()})(window);` });
|
|
433
|
+
// A range opens on its first slide (export-mode.js exportRangeStart).
|
|
434
|
+
if (config.range?.from > 0) await context.addInitScript((from) => { window.__hdExportFrom = from; }, config.range.from);
|
|
300
435
|
await context.addInitScript(() => {
|
|
301
436
|
navigator.sendBeacon = () => false;
|
|
302
437
|
window.open = () => null;
|
|
@@ -313,17 +448,39 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
313
448
|
await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
314
449
|
phase = 'player ready';
|
|
315
450
|
const pageSaid = () => (pageErrors.length ? ` (page errors: ${pageErrors.slice(0, 3).join('; ').slice(0, 400)})` : '');
|
|
316
|
-
|
|
317
|
-
|
|
451
|
+
const readyWait = readyTimeoutMs(config);
|
|
452
|
+
await page.waitForSelector('body[data-hd-ready="true"]', { timeout: readyWait }).catch(() => {
|
|
453
|
+
const saw = readySaw({ refused, unreachable, failed });
|
|
454
|
+
// Nothing failed and no page error: the deck was still loading (a slow machine), not broken.
|
|
455
|
+
const hint = unreachable.length || failed || pageErrors.length
|
|
456
|
+
? 'is it on player/v1 (https://hyperdecks.ai/player/v1/player.js)?'
|
|
457
|
+
: 'nothing failed, so it was probably still loading on a slow machine: raise HYPERDECKS_READY_TIMEOUT_MS (ms) to wait longer';
|
|
458
|
+
throw new Error(`the deck was not ready after ${Math.round(readyWait / 1000)}s${pageSaid()}${saw}; ${hint}`);
|
|
318
459
|
});
|
|
319
460
|
let state = await readLog(page, 30000);
|
|
320
461
|
if (state === undefined) throw new Error(`the deck's player did not return its export log within 30s${pageSaid()}`);
|
|
321
|
-
if (!state) throw new Error(`the deck's player has no export mode: it must load https://
|
|
462
|
+
if (!state) throw new Error(`the deck's player has no export mode: it must load https://hyperdecks.ai/player/v1/player.js (an older copy, or its own runtime, cannot be recorded)${pageSaid()}`);
|
|
322
463
|
if (state.manifestError) throw new Error(`the deck's narration manifest could not load (${state.manifestError.url}: ${state.manifestError.message}); nothing was recorded`);
|
|
323
464
|
if (state.silent) throw new Error('the deck has no narration to record: run `hyperdecks narrate` first');
|
|
324
465
|
// An older player plays its narration on wall time, which a frame-by-frame recording cannot follow.
|
|
325
|
-
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://
|
|
466
|
+
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://hyperdecks.ai/player/v1/player.js, or update the deck\'s copy');
|
|
326
467
|
const plan = planTimeline(state.plan ?? []);
|
|
468
|
+
const last = plan.ids.length - 1;
|
|
469
|
+
let range = config.range ?? null, segment = !!config.segment;
|
|
470
|
+
if (config.plan) {
|
|
471
|
+
// Videos and embedded pages per slide, read from the page: the split balances by time to render.
|
|
472
|
+
const media = await within(page.evaluate((ids) => ids.map((id) => document.getElementById(id)?.querySelectorAll('video, iframe').length ?? 0), plan.ids), 10000, null).catch(() => null);
|
|
473
|
+
const weights = Array.isArray(media) && media.length === plan.ids.length ? media.map(slideWeight) : undefined;
|
|
474
|
+
const answer = await config.plan({ seconds: plan.seconds, ranges: state.range === true && splittableAudio(state.plan ?? []), ...(weights ? { weights } : {}) });
|
|
475
|
+
if (answer?.segment) { range = { from: 0, to: answer.to }; segment = true; }
|
|
476
|
+
}
|
|
477
|
+
if (range) {
|
|
478
|
+
const { from, to } = range;
|
|
479
|
+
if (!(Number.isInteger(from) && Number.isInteger(to) && from >= 0 && from <= to && to <= last)) throw new Error(`slides ${from + 1}-${to + 1} are not in this deck (it has ${plan.ids.length})`);
|
|
480
|
+
if ((from > 0 || to < last) && state.range !== true) throw new Error('the deck\'s player is older than this CLI (it cannot record a range of slides); reload https://hyperdecks.ai/player/v1/player.js, or update the deck\'s copy');
|
|
481
|
+
}
|
|
482
|
+
const from = range?.from ?? 0, to = range?.to ?? last;
|
|
483
|
+
const inRange = (id) => { const i = plan.ids.indexOf(id); return i >= from && i <= to; };
|
|
327
484
|
const face = state.presenter;
|
|
328
485
|
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'}`);
|
|
329
486
|
// The cards wear the first slide's colours and its heading's font.
|
|
@@ -343,25 +500,22 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
343
500
|
}), 5000, {});
|
|
344
501
|
// Every clip's audio is fetched before recording: a bad URL fails now, not after minutes of real time.
|
|
345
502
|
phase = 'clip audio';
|
|
503
|
+
// A range needs only its own slides' clips.
|
|
346
504
|
const audioFiles = new Map(), clipLengths = {};
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
catch (e) { throw new Error(`could not download clip ${id}'s audio (${url}): ${e.cause?.message ?? e.message}`); }
|
|
353
|
-
if (!res.ok) throw new Error(`could not download clip ${id}'s audio (${res.status} from ${url})`);
|
|
354
|
-
const bytes = Buffer.from(await res.arrayBuffer());
|
|
355
|
-
if (bytes.length > MAX_CLIP_BYTES) throw new Error(`clip ${id}'s audio is over ${MAX_CLIP_BYTES / 1024 / 1024} MB`);
|
|
356
|
-
const path = join(temp, `clip-${n}${extname(new URL(url).pathname).slice(0, 6) || '.audio'}`);
|
|
357
|
-
await writeFile(path, bytes);
|
|
505
|
+
const wanted = (state.plan ?? []).filter((s) => inRange(s.id));
|
|
506
|
+
for (const [n, url] of [...new Set(wanted.map((s) => s.audio).filter(Boolean))].entries()) {
|
|
507
|
+
const id = wanted.find((s) => s.audio === url).id;
|
|
508
|
+
const path = clipPath(temp, n, url);
|
|
509
|
+
await downloadClip(url, path, { id, entryUrl: pageUrl });
|
|
358
510
|
audioFiles.set(url, path);
|
|
359
511
|
clipLengths[url] = await clipSeconds(ff, path).catch((e) => { throw new Error(`clip ${id}'s audio: ${e.message}`); });
|
|
360
512
|
}
|
|
361
|
-
|
|
513
|
+
const part = from > 0 || to < last ? ` (slides ${from + 1}-${to + 1}${segment ? ', a segment' : ''})` : '';
|
|
514
|
+
log(`recording ${plan.ids.length} slides${part}${plan.total ? `, about ${clock(plan.total)} of video,` : ''} at ${geometry.width}×${geometry.height} (${geometry.shape}), frame by frame`);
|
|
362
515
|
|
|
363
516
|
// The title card's frames come first.
|
|
364
|
-
const
|
|
517
|
+
const titleCard = config.titleCard && from === 0, endCard = config.endCard && to === last;
|
|
518
|
+
const lead = titleCard ? TITLE_CARD_SECONDS : 0;
|
|
365
519
|
const card = async (lines) => {
|
|
366
520
|
const p = await context.newPage();
|
|
367
521
|
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`));
|
|
@@ -378,7 +532,7 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
378
532
|
for (let i = 1; i < n; i++) await copyFile(first, join(temp, frameName(frameCount + i)));
|
|
379
533
|
frameCount += n;
|
|
380
534
|
};
|
|
381
|
-
if (
|
|
535
|
+
if (titleCard) {
|
|
382
536
|
phase = 'title card';
|
|
383
537
|
await repeat(await card([config.title, config.presenter ? `Presented by ${config.presenter}` : null]), TITLE_CARD_SECONDS);
|
|
384
538
|
}
|
|
@@ -400,11 +554,14 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
400
554
|
// Frame 0 is the deck as it starts; frame n shows it n/fps seconds later on the page clock.
|
|
401
555
|
const markMs = await page.evaluate(() => performance.timeOrigin + window.__hdFrameClock.freeze());
|
|
402
556
|
// Each clip ends where its real audio does (the manifest's length can be off), as <audio> would end it.
|
|
403
|
-
|
|
557
|
+
// A range's tour ends after its last slide (export-mode.js exportRangeEnd).
|
|
558
|
+
await page.evaluate(({ lengths, to }) => { window.__hdExportClipLengths = lengths; if (to != null) window.__hdExportTo = to; window.__hdExportGo(); return window.__hdFrameClock.settle(); }, { lengths: clipLengths, to: range ? to : null });
|
|
404
559
|
const firstFrame = frameCount;
|
|
405
560
|
await capture();
|
|
406
561
|
const secondsOf = new Map(plan.ids.map((id, i) => [id, plan.seconds[i]]));
|
|
407
562
|
let shown = 0, progress = 0, movedAt = 0, end = Infinity;
|
|
563
|
+
// A segment's seam frames (SEAM_FRAMES): recorded past its end, dropped from its start.
|
|
564
|
+
const tail = segment && to < last ? SEAM_FRAMES : 0, skip = segment && from > 0 ? SEAM_FRAMES : 0;
|
|
408
565
|
// Stuck: no slide landed and no clip started for longer than the current
|
|
409
566
|
// slide's planned clip and hold (unknown for a data-clip deck) plus a grace, on the page clock.
|
|
410
567
|
for (let n = 1; n < end; n++) {
|
|
@@ -430,9 +587,9 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
430
587
|
log(` slide ${i + 1}/${plan.ids.length} ${s.id} · ${clock(Math.max(0, (s.at - markMs) / 1000))}${plan.total ? ` of ${clock(plan.total)}` : ''}`);
|
|
431
588
|
}
|
|
432
589
|
// The video ends on the frame where the tour ended.
|
|
433
|
-
if (state.done) end = Math.max(n + 1, Math.round(((state.doneAt - markMs) / 1000) * fps));
|
|
590
|
+
if (state.done) end = Math.max(n + 1, Math.round(((state.doneAt - markMs) / 1000) * fps) + tail);
|
|
434
591
|
}
|
|
435
|
-
const on = state.slides?.at(-1)?.id ?? plan.ids[
|
|
592
|
+
const on = state.slides?.at(-1)?.id ?? plan.ids[from];
|
|
436
593
|
const allowed = (secondsOf.get(on) || 0) * 1.5 + STUCK_GRACE_SECONDS;
|
|
437
594
|
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()}`);
|
|
438
595
|
}
|
|
@@ -447,7 +604,7 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
447
604
|
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`);
|
|
448
605
|
const render = { frames, seconds: wall };
|
|
449
606
|
|
|
450
|
-
if (
|
|
607
|
+
if (endCard) {
|
|
451
608
|
phase = 'end card';
|
|
452
609
|
await repeat(await card([config.title, config.link]), END_CARD_SECONDS);
|
|
453
610
|
}
|
|
@@ -459,14 +616,41 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
459
616
|
|
|
460
617
|
phase = 'audio';
|
|
461
618
|
const clips = audioTimeline(state, markMs, lead);
|
|
462
|
-
|
|
619
|
+
// A seam's tail plays the next slide's clip too: that one is the next segment's.
|
|
620
|
+
const own = clips.filter((c) => inRange(c.id));
|
|
621
|
+
const missing = plan.ids.filter((id) => inRange(id) && !clips.some((c) => c.id === id) && (state.plan.find((s) => s.id === id)?.audio));
|
|
463
622
|
if (missing.length) warnings.push(`no audio started for ${missing.join(', ')}`);
|
|
623
|
+
const total = frameCount / VIDEO_FPS;
|
|
624
|
+
const place = async (encoded) => {
|
|
625
|
+
await rename(encoded, out).catch(async (e) => {
|
|
626
|
+
if (e.code !== 'EXDEV') throw e;
|
|
627
|
+
await copyFile(encoded, out);
|
|
628
|
+
});
|
|
629
|
+
return (await stat(out)).size;
|
|
630
|
+
};
|
|
631
|
+
const encodeMs = Math.round(Math.max(300000, total * 20000) * geometry.scale);
|
|
632
|
+
if (segment) {
|
|
633
|
+
// The picture only: the join lays the whole deck's audio under all the segments at once.
|
|
634
|
+
phase = 'encode';
|
|
635
|
+
if (frameCount - skip < 1) throw new Error(`slides ${from + 1}-${to + 1} are shorter than the ${SEAM_SECONDS}s seam`);
|
|
636
|
+
const kept = frameCount - skip;
|
|
637
|
+
log(`encoding ${clock(kept / VIDEO_FPS)} of video…`);
|
|
638
|
+
const encoded = join(temp, 'video.mp4');
|
|
639
|
+
await ffmpeg(ff, encodeArgs({ frames: join(temp, 'frame-%06d.jpg'), audio: null, width: geometry.width, height: geometry.height, out: encoded, startNumber: skip }), encodeMs);
|
|
640
|
+
const bytes = await place(encoded);
|
|
641
|
+
if (pageErrors.length) warnings.push(`${pageErrors.length} page error(s) while recording: ${pageErrors.slice(0, 2).join('; ').slice(0, 300)}`);
|
|
642
|
+
return {
|
|
643
|
+
file: out, seconds: kept / VIDEO_FPS, width: geometry.width, height: geometry.height, bytes, slides: to - from + 1, clips: own.length, render, warnings,
|
|
644
|
+
// frames: in the file. A clip's offset is from the file's first frame:
|
|
645
|
+
// after a dropped seam, a slide's clip may start (just) before it.
|
|
646
|
+
segment: { from, to, frames: kept, tail, clips: own.map((c) => ({ id: c.id, audio: c.audio, offset: c.offset - skip / VIDEO_FPS })) },
|
|
647
|
+
};
|
|
648
|
+
}
|
|
464
649
|
const inputs = clips.map((c) => {
|
|
465
650
|
const path = audioFiles.get(c.audio);
|
|
466
651
|
if (!path) throw new Error(`clip ${c.id} played audio the plan did not list (${c.audio})`);
|
|
467
652
|
return { path, offset: c.offset };
|
|
468
653
|
});
|
|
469
|
-
const total = frameCount / VIDEO_FPS;
|
|
470
654
|
const audio = join(temp, 'audio.wav');
|
|
471
655
|
await ffmpeg(ff, audioMixArgs({ inputs, seconds: total, out: audio }), 300000);
|
|
472
656
|
|
|
@@ -474,18 +658,81 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
474
658
|
log(`encoding ${clock(total)} of video…`);
|
|
475
659
|
// Encoded beside the temp frames, then moved into place: a failed encode never leaves a broken file at `out`.
|
|
476
660
|
const encoded = join(temp, 'video.mp4');
|
|
477
|
-
await ffmpeg(ff, encodeArgs({ frames: join(temp, 'frame-%06d.jpg'), audio, width: geometry.width, height: geometry.height, out: encoded }),
|
|
661
|
+
await ffmpeg(ff, encodeArgs({ frames: join(temp, 'frame-%06d.jpg'), audio, width: geometry.width, height: geometry.height, out: encoded }), encodeMs);
|
|
662
|
+
const bytes = await place(encoded);
|
|
663
|
+
if (pageErrors.length) warnings.push(`${pageErrors.length} page error(s) while recording: ${pageErrors.slice(0, 2).join('; ').slice(0, 300)}`);
|
|
664
|
+
return { file: out, seconds: total, width: geometry.width, height: geometry.height, bytes, slides: to - from + 1, clips: clips.length, render, warnings };
|
|
665
|
+
} catch (e) {
|
|
666
|
+
throw new Error(`Video export failed during ${phase}: ${e.message}`);
|
|
667
|
+
} finally {
|
|
668
|
+
if (browser) await browser.close().catch(() => {});
|
|
669
|
+
if (temp) await rm(temp, { recursive: true, force: true });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Joins segments recorded by recordVideo({ segment: true }), in order, into
|
|
674
|
+
// one MP4: the whole deck's audio mixed once over the joined timeline (each
|
|
675
|
+
// clip where its segment says it started, after the frames before it), the
|
|
676
|
+
// picture copied as is (the segments share their encode settings), or
|
|
677
|
+
// re-encoded once if the copy fails. segments: [{ file, frames, clips }].
|
|
678
|
+
// Clip audio comes only from public addresses, or from `origin` (the deck's
|
|
679
|
+
// page, for a local deck). Returns { file, seconds, bytes, frames, reencoded, warnings }.
|
|
680
|
+
export async function joinVideo({ segments, out, origin = null }, { log = () => {} } = {}) {
|
|
681
|
+
if (!Array.isArray(segments) || !segments.length) throw new Error('joinVideo needs at least one segment');
|
|
682
|
+
const ff = await ffmpegPath();
|
|
683
|
+
const warnings = [];
|
|
684
|
+
let temp, phase = 'clip audio';
|
|
685
|
+
try {
|
|
686
|
+
temp = await mkdtemp(join(tmpdir(), 'hyperdecks-join-'));
|
|
687
|
+
log(`joining ${segments.length} segments…`);
|
|
688
|
+
const timeline = joinTimeline(segments);
|
|
689
|
+
const files = new Map();
|
|
690
|
+
for (const c of timeline.clips) {
|
|
691
|
+
if (files.has(c.audio)) continue;
|
|
692
|
+
const path = clipPath(temp, files.size, c.audio);
|
|
693
|
+
await downloadClip(c.audio, path, { id: c.id, entryUrl: origin ?? 'https://origin.invalid/' });
|
|
694
|
+
files.set(c.audio, path);
|
|
695
|
+
}
|
|
696
|
+
phase = 'audio';
|
|
697
|
+
const audio = join(temp, 'audio.wav');
|
|
698
|
+
await ffmpeg(ff, audioMixArgs({ inputs: timeline.clips.map((c) => ({ path: files.get(c.audio), offset: c.offset })), seconds: timeline.seconds, out: audio }), 300000);
|
|
699
|
+
phase = 'join';
|
|
700
|
+
const list = join(temp, 'segments.txt');
|
|
701
|
+
await writeFile(list, concatList(segments.map((s) => s.file)));
|
|
702
|
+
log(`encoding ${clock(timeline.seconds)} of video…`);
|
|
703
|
+
const encoded = join(temp, 'video.mp4');
|
|
704
|
+
let reencoded = false;
|
|
705
|
+
// A joined file must be as long as its segments' frames say: a short or
|
|
706
|
+
// damaged piece would otherwise cut the picture and (-shortest) the voice.
|
|
707
|
+
const checked = async () => {
|
|
708
|
+
const got = await probe(ff, encoded);
|
|
709
|
+
if (got.seconds == null) throw new Error('ffmpeg could not read the joined file back');
|
|
710
|
+
if (Math.abs(got.seconds - timeline.seconds) > 0.25) throw new Error(`the joined video is ${got.seconds.toFixed(2)}s, not the ${timeline.seconds.toFixed(2)}s its segments hold (a segment is short or damaged)`);
|
|
711
|
+
return got;
|
|
712
|
+
};
|
|
713
|
+
let got;
|
|
714
|
+
try {
|
|
715
|
+
await ffmpeg(ff, joinArgs({ list, audio, out: encoded }), Math.max(300000, timeline.seconds * 2000));
|
|
716
|
+
got = await checked();
|
|
717
|
+
} catch (e) {
|
|
718
|
+
reencoded = true;
|
|
719
|
+
warnings.push(`the segments were re-encoded at the join (copying them failed: ${e.message.slice(0, 200)})`);
|
|
720
|
+
try {
|
|
721
|
+
await ffmpeg(ff, joinArgs({ list, audio, out: encoded, reencode: true }), Math.max(300000, timeline.seconds * 20000));
|
|
722
|
+
got = await checked();
|
|
723
|
+
} catch (again) {
|
|
724
|
+
throw new Error(`${again.message} (copying had failed first: ${e.message.slice(0, 200)})`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
478
727
|
await rename(encoded, out).catch(async (e) => {
|
|
479
728
|
if (e.code !== 'EXDEV') throw e;
|
|
480
729
|
await copyFile(encoded, out);
|
|
481
730
|
});
|
|
482
731
|
const bytes = (await stat(out)).size;
|
|
483
|
-
|
|
484
|
-
return { file: out, seconds: total, width: geometry.width, height: geometry.height, bytes, slides: plan.ids.length, clips: clips.length, render, warnings };
|
|
732
|
+
return { file: out, seconds: timeline.seconds, width: got.width, height: got.height, bytes, frames: timeline.frames, reencoded, warnings };
|
|
485
733
|
} catch (e) {
|
|
486
|
-
throw new Error(`Video
|
|
734
|
+
throw new Error(`Video join failed during ${phase}: ${e.message}`);
|
|
487
735
|
} finally {
|
|
488
|
-
if (browser) await browser.close().catch(() => {});
|
|
489
736
|
if (temp) await rm(temp, { recursive: true, force: true });
|
|
490
737
|
}
|
|
491
738
|
}
|
|
@@ -529,7 +776,7 @@ export async function exportVideo(arg, opts = {}, { log = (l) => process.stderr.
|
|
|
529
776
|
log(`recording ${source.slug}${local ? ' (working copy)' : ' (released copy)'} → ${out}`);
|
|
530
777
|
const result = await recordVideo({
|
|
531
778
|
url: source.url, geometry: o.geometry, captions: o.captions, avatar: o.avatar, titleCard: o.titleCard, endCard: o.endCard,
|
|
532
|
-
title: source.title, presenter: source.presenter, link, out, playerDir: process.env.HYPERDECKS_PLAYER_DIR || null,
|
|
779
|
+
title: source.title, presenter: source.presenter, link, out, playerDir: process.env.HYPERDECKS_PLAYER_DIR || null, range: o.range,
|
|
533
780
|
}, { log });
|
|
534
781
|
let uploaded = null;
|
|
535
782
|
if (o.upload) {
|
package/src/exportHelp.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
export const VIDEO_EXPORT_HELP =
|
|
5
5
|
"kind \"mp4\" records the narrated deck as a video on our render worker (drawn frame by frame, " +
|
|
6
|
-
"
|
|
6
|
+
"split across several machines: a 4-minute deck takes about 12 minutes), with the options size (720p, 1080p, 4k), shape (16:9, 9:16, 1:1), captions, avatar, " +
|
|
7
7
|
"titleCard and endCard (the deck's tracked link). It returns { exportId, status: \"queued\" } at once: poll " +
|
|
8
8
|
"check_export(exportId) every 20-30 seconds until status is ready (url is the MP4, kept 30 days; title and description " +
|
|
9
9
|
"are ready for YouTube) or failed (error says why). The owner is also emailed the link. One video per deck at a time, " +
|
package/src/firstDeck.mjs
CHANGED
|
@@ -78,7 +78,7 @@ export function fillFirstDeck({ html, title, answers, presenter, voice }) {
|
|
|
78
78
|
// A character's live head presents its own deck; a person's voice ships with
|
|
79
79
|
// captions only (data-presenter="none"), never on a character's face.
|
|
80
80
|
if (presenter === "dash") {
|
|
81
|
-
out = swap(out, /(<script type="module" src="https:\/\/decks\.hypersocial\.ai\/player\/v1\/player\.js"[^>]*?)\sdata-presenter="none"/, (_, tag) => `${tag} data-presenter="head" data-avatar="${DASH_AVATAR}"`, "player tag");
|
|
81
|
+
out = swap(out, /(<script type="module" src="https:\/\/(?:decks\.hypersocial\.ai|hyperdecks\.ai)\/player\/v1\/player\.js"[^>]*?)\sdata-presenter="none"/, (_, tag) => `${tag} data-presenter="head" data-avatar="${DASH_AVATAR}"`, "player tag");
|
|
82
82
|
}
|
|
83
83
|
const s = (t) => slots(t, answers, false);
|
|
84
84
|
const script = `---
|
package/src/loadingBlock.mjs
CHANGED
|
@@ -11,8 +11,8 @@ export const LOADING_BLOCK = [
|
|
|
11
11
|
" page's brand colour, with a quiet loader in --hd-ground-ink; then the",
|
|
12
12
|
" slides fade in. If the player never arrives, the slides show after 10 s",
|
|
13
13
|
" rather than never. Put data-layout=\"overlay\" on <html> for the overlay. -->",
|
|
14
|
-
"<link rel=\"modulepreload\" href=\"https://
|
|
15
|
-
"<link rel=\"preload\" as=\"style\" href=\"https://
|
|
14
|
+
"<link rel=\"modulepreload\" href=\"https://hyperdecks.ai/player/v1/player.js\">",
|
|
15
|
+
"<link rel=\"preload\" as=\"style\" href=\"https://hyperdecks.ai/player/v1/player.css\">",
|
|
16
16
|
"<script>if (self !== top || /[?&]thumb\\b/.test(location.search)) document.documentElement.dataset.hdFrame = \"\";</script>",
|
|
17
17
|
"<style data-hd-loading>",
|
|
18
18
|
" body:not([data-hd-ready]) { overflow: hidden; background: var(--hd-frame, var(--hd-ground, var(--hd-accent, #ffda1b))); animation: hd-fail-open 0s 10s forwards; }",
|
package/src/presenter.mjs
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
// D.A.S.H.'s face: the head doc and still that ship with the player. A deck
|
|
10
10
|
// narrated in its character voice gets this face at release, even when the
|
|
11
11
|
// tag was scaffolded captions-only (data-presenter="none").
|
|
12
|
-
export const DASH_AVATAR = "https://
|
|
13
|
-
export const DASH_PRESENTER = Object.freeze({ avatarUrl: DASH_AVATAR, imageUrl: "https://
|
|
12
|
+
export const DASH_AVATAR = "https://hyperdecks.ai/player/v1/dash-avatar.json";
|
|
13
|
+
export const DASH_PRESENTER = Object.freeze({ avatarUrl: DASH_AVATAR, imageUrl: "https://hyperdecks.ai/avatars/dash.png" });
|
|
14
14
|
|
|
15
15
|
const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/i;
|
|
16
16
|
|
package/src/preview-capture.mjs
CHANGED
|
@@ -107,12 +107,17 @@ export async function serveSource({ file, html, port = 0 }) {
|
|
|
107
107
|
const url = `http://127.0.0.1:${server.address().port}${entry}`;
|
|
108
108
|
return { url, close: () => new Promise((ok, fail) => { server.close(e => e ? fail(e) : ok()); server.closeAllConnections(); }) };
|
|
109
109
|
}
|
|
110
|
+
// `type` is Playwright's resourceType, except that a document loading into a
|
|
111
|
+
// child frame (an <iframe>) is 'subdocument' (captureResourceType): the top
|
|
112
|
+
// page stays pinned to the entry URL, while an embedded page gets the rules
|
|
113
|
+
// any other public resource gets, over https (or from the entry's own origin).
|
|
110
114
|
export function allowCaptureRequest(url, method, type, entryUrl, firstPartyOrigins = []) {
|
|
111
115
|
let u, path;
|
|
112
116
|
try { u = new URL(url); path = decodeURIComponent(u.pathname); } catch { return false; }
|
|
113
117
|
if (!['http:', 'https:', 'data:', 'blob:'].includes(u.protocol) || method !== 'GET') return false;
|
|
114
118
|
if (/(^|\/)(v|track|telemetry|analytics|beacon)(\/|$)/i.test(path)) return false;
|
|
115
119
|
if (type === 'document') return url === entryUrl;
|
|
120
|
+
if (type === 'subdocument' && u.protocol !== 'https:' && u.origin !== new URL(entryUrl).origin) return false;
|
|
116
121
|
if (['websocket', 'eventsource', 'ping', 'other'].includes(type)) return false;
|
|
117
122
|
if (firstPartyOrigins.some(v => new URL(v).origin === u.origin) || /\.convex\.(cloud|site)$/.test(u.hostname)) {
|
|
118
123
|
// Stored assets, and the hosted player a player/v1 deck loads (player.js, its
|
|
@@ -158,15 +163,32 @@ export async function allowCaptureDestination(url, entryUrl, resolver = lookup)
|
|
|
158
163
|
} catch { return false; }
|
|
159
164
|
}
|
|
160
165
|
|
|
166
|
+
// Playwright's resourceType, with a document bound for a child frame told
|
|
167
|
+
// apart as 'subdocument'. A request with no frame (a service worker's) or a
|
|
168
|
+
// frame that can't be read stays 'document', the strict rule.
|
|
169
|
+
export function captureResourceType(request) {
|
|
170
|
+
const type = request.resourceType();
|
|
171
|
+
if (type !== 'document') return type;
|
|
172
|
+
try { return request.frame().parentFrame() ? 'subdocument' : type; } catch { return type; }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The request's headers without its cache validators. Chromium revalidates a
|
|
176
|
+
// file it has cached (a second load of an embedded page, say) with
|
|
177
|
+
// If-None-Match / If-Modified-Since; the server's 304 is a 3xx, which the
|
|
178
|
+
// route refuses, so the file would fail. Without them the answer is the file.
|
|
179
|
+
export function unconditionalHeaders(headers) {
|
|
180
|
+
return Object.fromEntries(Object.entries(headers).filter(([k]) => !/^if-(none-match|modified-since|match|unmodified-since|range)$/i.test(k)));
|
|
181
|
+
}
|
|
182
|
+
|
|
161
183
|
// Playwright's continue() follows redirects without routing each hop. Fetch
|
|
162
184
|
// without following redirects and never fulfill a 3xx response into Chromium.
|
|
163
185
|
export async function routeCaptureRequest(route, entryUrl, origins = []) {
|
|
164
186
|
const r = route.request();
|
|
165
187
|
try {
|
|
166
|
-
if (!allowCaptureRequest(r.url(), r.method(), r
|
|
188
|
+
if (!allowCaptureRequest(r.url(), r.method(), captureResourceType(r), entryUrl, origins) || !await allowCaptureDestination(r.url(), entryUrl)) {
|
|
167
189
|
await route.abort(); return false;
|
|
168
190
|
}
|
|
169
|
-
const response = await route.fetch({ maxRedirects:0, timeout:15000 });
|
|
191
|
+
const response = await route.fetch({ headers: unconditionalHeaders(r.headers()), maxRedirects:0, timeout:15000 });
|
|
170
192
|
if (response.status() >= 300 && response.status() < 400) {
|
|
171
193
|
await response.dispose(); await route.abort(); return false;
|
|
172
194
|
}
|
package/src/preview.mjs
CHANGED
|
@@ -76,7 +76,7 @@ export async function createPreview(pos, opts, adapters = {}) {
|
|
|
76
76
|
await save();
|
|
77
77
|
try {
|
|
78
78
|
const capture = adapters.capture || (await import('./preview-capture.mjs')).capturePreview;
|
|
79
|
-
const captured = await capture({ ...config, html, firstPartyOrigins: [targetForCapture(opts), 'https://decks.hypersocial.ai', 'https://dashing-snail-874.convex.cloud'] });
|
|
79
|
+
const captured = await capture({ ...config, html, firstPartyOrigins: [targetForCapture(opts), 'https://hyperdecks.ai', 'https://decks.hypersocial.ai', 'https://dashing-snail-874.convex.cloud'] });
|
|
80
80
|
result.files = captured.files;
|
|
81
81
|
warnings.push(...captured.warnings);
|
|
82
82
|
for (const f of result.files) if (f.bytes > 5 * 1024 * 1024) warnings.push(`${f.type} exceeds 5 MiB; shorten --duration or reduce --width for email`);
|
|
@@ -113,5 +113,5 @@ export async function createPreview(pos, opts, adapters = {}) {
|
|
|
113
113
|
}
|
|
114
114
|
function targetForCapture(opts) {
|
|
115
115
|
// Offline local-file capture must not require deployment configuration/login.
|
|
116
|
-
return opts.file ? 'https://
|
|
116
|
+
return opts.file ? 'https://hyperdecks.ai' : target(opts).brokerBase;
|
|
117
117
|
}
|
package/src/runtime.mjs
CHANGED
|
@@ -8,16 +8,38 @@ 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.6";
|
|
12
12
|
const UPLOAD_TIMEOUT_MS = 125_000;
|
|
13
13
|
|
|
14
|
+
// The app's home. HYPERDECKS_HOST points the prod target's broker and share
|
|
15
|
+
// links elsewhere (e.g. https://decks.hypersocial.ai, the old host, which keeps
|
|
16
|
+
// serving the broker and every share link).
|
|
17
|
+
export const PROD_HOST = "https://hyperdecks.ai";
|
|
18
|
+
|
|
14
19
|
export const PROD = {
|
|
15
20
|
name: "prod",
|
|
16
21
|
convexUrl: "https://dashing-snail-874.convex.cloud",
|
|
17
|
-
brokerBase:
|
|
18
|
-
shareBase:
|
|
22
|
+
brokerBase: PROD_HOST,
|
|
23
|
+
shareBase: PROD_HOST,
|
|
19
24
|
};
|
|
20
25
|
|
|
26
|
+
// Where the browser login page may post the credential from: the broker itself,
|
|
27
|
+
// and both app hosts.
|
|
28
|
+
const LOGIN_PAGE_ORIGINS = new Set(["https://hyperdecks.ai", "https://decks.hypersocial.ai"]);
|
|
29
|
+
|
|
30
|
+
let prodHostOverride = null;
|
|
31
|
+
|
|
32
|
+
// The hosted MCP (the Next server) runs this module too; it pins the prod host
|
|
33
|
+
// to wherever the app currently lives (lib/landingHost.ts appOrigin), so its
|
|
34
|
+
// links follow the deployment's switch rather than this CLI's default.
|
|
35
|
+
export function pinProdHost(origin) {
|
|
36
|
+
prodHostOverride = origin ? normalizeBase(origin) : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function prodHost() {
|
|
40
|
+
return normalizeBase(process.env.HYPERDECKS_HOST?.trim() || prodHostOverride || PROD_HOST);
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
export const api = {
|
|
22
44
|
previews: { get: makeFunctionReference("previews:get") },
|
|
23
45
|
avatars: {
|
|
@@ -216,9 +238,11 @@ export function target(opts = {}) {
|
|
|
216
238
|
const envWantsDev = process.env.HSDECKS_PROD === "0";
|
|
217
239
|
const wantDev = opts.dev || (envWantsDev && !opts.prod);
|
|
218
240
|
if (!wantDev) {
|
|
241
|
+
const host = prodHost();
|
|
219
242
|
return {
|
|
220
243
|
...PROD,
|
|
221
|
-
brokerBase: normalizeBase(opts.broker ||
|
|
244
|
+
brokerBase: normalizeBase(opts.broker || host),
|
|
245
|
+
shareBase: host,
|
|
222
246
|
};
|
|
223
247
|
}
|
|
224
248
|
|
|
@@ -461,7 +485,11 @@ export async function login(opts, { log = (line) => console.log(line), open = op
|
|
|
461
485
|
};
|
|
462
486
|
|
|
463
487
|
server = createServer((req, res) => {
|
|
464
|
-
|
|
488
|
+
// The authorize page may be on either app host (a login that started on
|
|
489
|
+
// one can finish on the other while the app moves to hyperdecks.ai).
|
|
490
|
+
const origin = req.headers.origin;
|
|
491
|
+
res.setHeader("Access-Control-Allow-Origin", LOGIN_PAGE_ORIGINS.has(origin) ? origin : t.brokerBase);
|
|
492
|
+
res.setHeader("Vary", "Origin");
|
|
465
493
|
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
466
494
|
res.setHeader("Access-Control-Allow-Headers", "content-type");
|
|
467
495
|
if (req.method === "OPTIONS") {
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
page's brand colour, with a quiet loader in --hd-ground-ink; then the
|
|
11
11
|
slides fade in. If the player never arrives, the slides show after 10 s
|
|
12
12
|
rather than never. Put data-layout="overlay" on <html> for the overlay. -->
|
|
13
|
-
<link rel="modulepreload" href="https://
|
|
14
|
-
<link rel="preload" as="style" href="https://
|
|
13
|
+
<link rel="modulepreload" href="https://hyperdecks.ai/player/v1/player.js">
|
|
14
|
+
<link rel="preload" as="style" href="https://hyperdecks.ai/player/v1/player.css">
|
|
15
15
|
<script>if (self !== top || /[?&]thumb\b/.test(location.search)) document.documentElement.dataset.hdFrame = "";</script>
|
|
16
16
|
<style data-hd-loading>
|
|
17
17
|
body:not([data-hd-ready]) { overflow: hidden; background: var(--hd-frame, var(--hd-ground, var(--hd-accent, #ffda1b))); animation: hd-fail-open 0s 10s forwards; }
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
<!-- `hyperdecks narrate decks/__SLUG__` fills data-manifest with the manifest
|
|
126
126
|
URL it writes. data-presenter: head (live Minimoji, needs data-avatar),
|
|
127
127
|
static (data-presenter-image) or none (captions only). -->
|
|
128
|
-
<script type="module" src="https://
|
|
128
|
+
<script type="module" src="https://hyperdecks.ai/player/v1/player.js"
|
|
129
129
|
data-manifest="" data-stage="1600x900" data-presenter="none" data-presenter-style="__PRESENTER_STYLE__"></script>
|
|
130
130
|
</body>
|
|
131
131
|
</html>
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
page's brand colour, with a quiet loader in --hd-ground-ink; then the
|
|
11
11
|
slides fade in. If the player never arrives, the slides show after 10 s
|
|
12
12
|
rather than never. Put data-layout="overlay" on <html> for the overlay. -->
|
|
13
|
-
<link rel="modulepreload" href="https://
|
|
14
|
-
<link rel="preload" as="style" href="https://
|
|
13
|
+
<link rel="modulepreload" href="https://hyperdecks.ai/player/v1/player.js">
|
|
14
|
+
<link rel="preload" as="style" href="https://hyperdecks.ai/player/v1/player.css">
|
|
15
15
|
<script>if (self !== top || /[?&]thumb\b/.test(location.search)) document.documentElement.dataset.hdFrame = "";</script>
|
|
16
16
|
<style data-hd-loading>
|
|
17
17
|
body:not([data-hd-ready]) { overflow: hidden; background: var(--hd-frame, var(--hd-ground, var(--hd-accent, #ffda1b))); animation: hd-fail-open 0s 10s forwards; }
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
data-avatar), static (data-presenter-image) or none (captions only).
|
|
159
159
|
data-presenter-style: stage (large head, lower-third captions), bubble,
|
|
160
160
|
glass (frosted panel) or corner (small head, subtitles). -->
|
|
161
|
-
<script type="module" src="https://
|
|
161
|
+
<script type="module" src="https://hyperdecks.ai/player/v1/player.js"
|
|
162
162
|
data-manifest="" data-stage="1600x900" data-presenter="none" data-presenter-style="__PRESENTER_STYLE__"></script>
|
|
163
163
|
<!-- Slide controllers for anything the built-in cues don't cover:
|
|
164
164
|
<script type="module">
|