@hypersocial/decks-cli 0.9.4 → 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 +293 -44
- 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 +61 -3
- 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, 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();
|
|
@@ -272,15 +400,22 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
272
400
|
browser = await launchRecorder(chromium, {
|
|
273
401
|
// config.sandbox: false only for CI runners whose AppArmor blocks Chromium's sandbox (the smoke suite).
|
|
274
402
|
headless: true, timeout: 30000, chromiumSandbox: config.sandbox ?? process.platform === 'linux',
|
|
275
|
-
|
|
403
|
+
// On a Mac the head is drawn on the graphics card (recorderArgs); elsewhere in software.
|
|
404
|
+
args: ['--autoplay-policy=no-user-gesture-required', '--mute-audio', ...recorderArgs()],
|
|
276
405
|
env: { ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}), PATH: process.env.PATH, TMPDIR: temp, HOME: temp },
|
|
277
406
|
}, { log, ...(install ? { install } : {}) });
|
|
407
|
+
await noteSoftwareWebgl(browser, log);
|
|
278
408
|
const pageUrl = exportPageUrl(config.url, config);
|
|
279
409
|
const context = await browser.newContext({ viewport: geometry.viewport, deviceScaleFactor: geometry.scale, serviceWorkers: 'block', acceptDownloads: false });
|
|
280
|
-
const blockedUrls = [];
|
|
281
|
-
let failed = null;
|
|
410
|
+
const blockedUrls = [], unreachable = [];
|
|
411
|
+
let refused = 0, failed = null;
|
|
282
412
|
await context.route('**/*', async (route) => {
|
|
283
|
-
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++;
|
|
284
419
|
});
|
|
285
420
|
// A development checkout plays its own player/v1 (HYPERDECKS_PLAYER_DIR), not the released one.
|
|
286
421
|
if (config.playerDir) {
|
|
@@ -295,6 +430,8 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
295
430
|
await context.routeWebSocket('**/*', (ws) => ws.close());
|
|
296
431
|
// The page clock goes in before any of the deck's scripts (frame-clock.mjs).
|
|
297
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);
|
|
298
435
|
await context.addInitScript(() => {
|
|
299
436
|
navigator.sendBeacon = () => false;
|
|
300
437
|
window.open = () => null;
|
|
@@ -311,17 +448,39 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
311
448
|
await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
312
449
|
phase = 'player ready';
|
|
313
450
|
const pageSaid = () => (pageErrors.length ? ` (page errors: ${pageErrors.slice(0, 3).join('; ').slice(0, 400)})` : '');
|
|
314
|
-
|
|
315
|
-
|
|
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}`);
|
|
316
459
|
});
|
|
317
460
|
let state = await readLog(page, 30000);
|
|
318
461
|
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://
|
|
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()}`);
|
|
320
463
|
if (state.manifestError) throw new Error(`the deck's narration manifest could not load (${state.manifestError.url}: ${state.manifestError.message}); nothing was recorded`);
|
|
321
464
|
if (state.silent) throw new Error('the deck has no narration to record: run `hyperdecks narrate` first');
|
|
322
465
|
// 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://
|
|
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');
|
|
324
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; };
|
|
325
484
|
const face = state.presenter;
|
|
326
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'}`);
|
|
327
486
|
// The cards wear the first slide's colours and its heading's font.
|
|
@@ -341,25 +500,22 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
341
500
|
}), 5000, {});
|
|
342
501
|
// Every clip's audio is fetched before recording: a bad URL fails now, not after minutes of real time.
|
|
343
502
|
phase = 'clip audio';
|
|
503
|
+
// A range needs only its own slides' clips.
|
|
344
504
|
const audioFiles = new Map(), clipLengths = {};
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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);
|
|
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 });
|
|
356
510
|
audioFiles.set(url, path);
|
|
357
511
|
clipLengths[url] = await clipSeconds(ff, path).catch((e) => { throw new Error(`clip ${id}'s audio: ${e.message}`); });
|
|
358
512
|
}
|
|
359
|
-
|
|
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`);
|
|
360
515
|
|
|
361
516
|
// The title card's frames come first.
|
|
362
|
-
const
|
|
517
|
+
const titleCard = config.titleCard && from === 0, endCard = config.endCard && to === last;
|
|
518
|
+
const lead = titleCard ? TITLE_CARD_SECONDS : 0;
|
|
363
519
|
const card = async (lines) => {
|
|
364
520
|
const p = await context.newPage();
|
|
365
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`));
|
|
@@ -376,7 +532,7 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
376
532
|
for (let i = 1; i < n; i++) await copyFile(first, join(temp, frameName(frameCount + i)));
|
|
377
533
|
frameCount += n;
|
|
378
534
|
};
|
|
379
|
-
if (
|
|
535
|
+
if (titleCard) {
|
|
380
536
|
phase = 'title card';
|
|
381
537
|
await repeat(await card([config.title, config.presenter ? `Presented by ${config.presenter}` : null]), TITLE_CARD_SECONDS);
|
|
382
538
|
}
|
|
@@ -398,11 +554,14 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
398
554
|
// Frame 0 is the deck as it starts; frame n shows it n/fps seconds later on the page clock.
|
|
399
555
|
const markMs = await page.evaluate(() => performance.timeOrigin + window.__hdFrameClock.freeze());
|
|
400
556
|
// Each clip ends where its real audio does (the manifest's length can be off), as <audio> would end it.
|
|
401
|
-
|
|
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 });
|
|
402
559
|
const firstFrame = frameCount;
|
|
403
560
|
await capture();
|
|
404
561
|
const secondsOf = new Map(plan.ids.map((id, i) => [id, plan.seconds[i]]));
|
|
405
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;
|
|
406
565
|
// Stuck: no slide landed and no clip started for longer than the current
|
|
407
566
|
// slide's planned clip and hold (unknown for a data-clip deck) plus a grace, on the page clock.
|
|
408
567
|
for (let n = 1; n < end; n++) {
|
|
@@ -428,9 +587,9 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
428
587
|
log(` slide ${i + 1}/${plan.ids.length} ${s.id} · ${clock(Math.max(0, (s.at - markMs) / 1000))}${plan.total ? ` of ${clock(plan.total)}` : ''}`);
|
|
429
588
|
}
|
|
430
589
|
// 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));
|
|
590
|
+
if (state.done) end = Math.max(n + 1, Math.round(((state.doneAt - markMs) / 1000) * fps) + tail);
|
|
432
591
|
}
|
|
433
|
-
const on = state.slides?.at(-1)?.id ?? plan.ids[
|
|
592
|
+
const on = state.slides?.at(-1)?.id ?? plan.ids[from];
|
|
434
593
|
const allowed = (secondsOf.get(on) || 0) * 1.5 + STUCK_GRACE_SECONDS;
|
|
435
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()}`);
|
|
436
595
|
}
|
|
@@ -445,7 +604,7 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
445
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`);
|
|
446
605
|
const render = { frames, seconds: wall };
|
|
447
606
|
|
|
448
|
-
if (
|
|
607
|
+
if (endCard) {
|
|
449
608
|
phase = 'end card';
|
|
450
609
|
await repeat(await card([config.title, config.link]), END_CARD_SECONDS);
|
|
451
610
|
}
|
|
@@ -457,14 +616,41 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
457
616
|
|
|
458
617
|
phase = 'audio';
|
|
459
618
|
const clips = audioTimeline(state, markMs, lead);
|
|
460
|
-
|
|
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));
|
|
461
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
|
+
}
|
|
462
649
|
const inputs = clips.map((c) => {
|
|
463
650
|
const path = audioFiles.get(c.audio);
|
|
464
651
|
if (!path) throw new Error(`clip ${c.id} played audio the plan did not list (${c.audio})`);
|
|
465
652
|
return { path, offset: c.offset };
|
|
466
653
|
});
|
|
467
|
-
const total = frameCount / VIDEO_FPS;
|
|
468
654
|
const audio = join(temp, 'audio.wav');
|
|
469
655
|
await ffmpeg(ff, audioMixArgs({ inputs, seconds: total, out: audio }), 300000);
|
|
470
656
|
|
|
@@ -472,18 +658,81 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
472
658
|
log(`encoding ${clock(total)} of video…`);
|
|
473
659
|
// Encoded beside the temp frames, then moved into place: a failed encode never leaves a broken file at `out`.
|
|
474
660
|
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 }),
|
|
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
|
+
}
|
|
476
727
|
await rename(encoded, out).catch(async (e) => {
|
|
477
728
|
if (e.code !== 'EXDEV') throw e;
|
|
478
729
|
await copyFile(encoded, out);
|
|
479
730
|
});
|
|
480
731
|
const bytes = (await stat(out)).size;
|
|
481
|
-
|
|
482
|
-
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 };
|
|
483
733
|
} catch (e) {
|
|
484
|
-
throw new Error(`Video
|
|
734
|
+
throw new Error(`Video join failed during ${phase}: ${e.message}`);
|
|
485
735
|
} finally {
|
|
486
|
-
if (browser) await browser.close().catch(() => {});
|
|
487
736
|
if (temp) await rm(temp, { recursive: true, force: true });
|
|
488
737
|
}
|
|
489
738
|
}
|
|
@@ -527,7 +776,7 @@ export async function exportVideo(arg, opts = {}, { log = (l) => process.stderr.
|
|
|
527
776
|
log(`recording ${source.slug}${local ? ' (working copy)' : ' (released copy)'} → ${out}`);
|
|
528
777
|
const result = await recordVideo({
|
|
529
778
|
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,
|
|
779
|
+
title: source.title, presenter: source.presenter, link, out, playerDir: process.env.HYPERDECKS_PLAYER_DIR || null, range: o.range,
|
|
531
780
|
}, { log });
|
|
532
781
|
let uploaded = null;
|
|
533
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
|
@@ -22,6 +22,42 @@ export async function installChromium({ exec = run } = {}) {
|
|
|
22
22
|
}
|
|
23
23
|
// Playwright's launch error when the browser build is not on disk.
|
|
24
24
|
export const isMissingBrowser = (e) => /Executable doesn't exist/.test(String(e?.message ?? e));
|
|
25
|
+
// Chromium's WebGL flags for capture and video export. On a Mac, ANGLE's Metal
|
|
26
|
+
// backend draws WebGL (the live head) on the graphics card: the investor deck's
|
|
27
|
+
// 1080p export rendered in 7 min instead of 48 (docs/spikes/mac-video-export-gpu.md).
|
|
28
|
+
// Elsewhere (Linux, the render worker's GPU-less machines) WebGL stays in
|
|
29
|
+
// SwiftShader, on the CPU. --enable-unsafe-swiftshader is kept on every
|
|
30
|
+
// platform: it is what Chromium falls back to when it cannot use the GPU.
|
|
31
|
+
export function recorderArgs(platform = process.platform) {
|
|
32
|
+
return platform === 'darwin' ? ['--use-angle=metal', '--enable-unsafe-swiftshader'] : ['--enable-unsafe-swiftshader'];
|
|
33
|
+
}
|
|
34
|
+
// The WebGL renderer (UNMASKED_RENDERER_WEBGL) a launched browser draws with,
|
|
35
|
+
// or null when it cannot tell (no WebGL, or no answer within `ms`).
|
|
36
|
+
export async function webglRenderer(browser, ms = 10000) {
|
|
37
|
+
let page, timer;
|
|
38
|
+
try {
|
|
39
|
+
const read = (async () => {
|
|
40
|
+
page = await browser.newPage();
|
|
41
|
+
return page.evaluate(() => {
|
|
42
|
+
const gl = document.createElement('canvas').getContext('webgl');
|
|
43
|
+
const info = gl?.getExtension('WEBGL_debug_renderer_info');
|
|
44
|
+
return gl ? String(gl.getParameter(info ? info.UNMASKED_RENDERER_WEBGL : gl.RENDERER)) : null;
|
|
45
|
+
});
|
|
46
|
+
})();
|
|
47
|
+
read.catch(() => {});
|
|
48
|
+
return await Promise.race([read, new Promise((ok) => { timer = setTimeout(() => ok(null), ms); })]);
|
|
49
|
+
} catch { return null; }
|
|
50
|
+
finally { clearTimeout(timer); await page?.close().catch(() => {}); }
|
|
51
|
+
}
|
|
52
|
+
export const isSoftwareRenderer = (renderer) => /swiftshader/i.test(renderer ?? '');
|
|
53
|
+
// On a Mac, one line when Chromium could not use the graphics card and fell
|
|
54
|
+
// back to drawing WebGL in software. Returns the renderer it read.
|
|
55
|
+
export async function noteSoftwareWebgl(browser, log, platform = process.platform) {
|
|
56
|
+
if (platform !== 'darwin') return null;
|
|
57
|
+
const renderer = await webglRenderer(browser);
|
|
58
|
+
if (isSoftwareRenderer(renderer)) log('Chromium could not use this Mac\'s graphics card; the head is drawn in software, so the export runs several times slower');
|
|
59
|
+
return renderer;
|
|
60
|
+
}
|
|
25
61
|
export async function setupCapture(pos, opts) {
|
|
26
62
|
if (pos.length || Object.keys(opts).some(k => !['json', 'dev', 'convex', 'prod'].includes(k))) throw new Error('usage: preview setup');
|
|
27
63
|
await installChromium();
|
|
@@ -71,12 +107,17 @@ export async function serveSource({ file, html, port = 0 }) {
|
|
|
71
107
|
const url = `http://127.0.0.1:${server.address().port}${entry}`;
|
|
72
108
|
return { url, close: () => new Promise((ok, fail) => { server.close(e => e ? fail(e) : ok()); server.closeAllConnections(); }) };
|
|
73
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).
|
|
74
114
|
export function allowCaptureRequest(url, method, type, entryUrl, firstPartyOrigins = []) {
|
|
75
115
|
let u, path;
|
|
76
116
|
try { u = new URL(url); path = decodeURIComponent(u.pathname); } catch { return false; }
|
|
77
117
|
if (!['http:', 'https:', 'data:', 'blob:'].includes(u.protocol) || method !== 'GET') return false;
|
|
78
118
|
if (/(^|\/)(v|track|telemetry|analytics|beacon)(\/|$)/i.test(path)) return false;
|
|
79
119
|
if (type === 'document') return url === entryUrl;
|
|
120
|
+
if (type === 'subdocument' && u.protocol !== 'https:' && u.origin !== new URL(entryUrl).origin) return false;
|
|
80
121
|
if (['websocket', 'eventsource', 'ping', 'other'].includes(type)) return false;
|
|
81
122
|
if (firstPartyOrigins.some(v => new URL(v).origin === u.origin) || /\.convex\.(cloud|site)$/.test(u.hostname)) {
|
|
82
123
|
// Stored assets, and the hosted player a player/v1 deck loads (player.js, its
|
|
@@ -122,15 +163,32 @@ export async function allowCaptureDestination(url, entryUrl, resolver = lookup)
|
|
|
122
163
|
} catch { return false; }
|
|
123
164
|
}
|
|
124
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
|
+
|
|
125
183
|
// Playwright's continue() follows redirects without routing each hop. Fetch
|
|
126
184
|
// without following redirects and never fulfill a 3xx response into Chromium.
|
|
127
185
|
export async function routeCaptureRequest(route, entryUrl, origins = []) {
|
|
128
186
|
const r = route.request();
|
|
129
187
|
try {
|
|
130
|
-
if (!allowCaptureRequest(r.url(), r.method(), r
|
|
188
|
+
if (!allowCaptureRequest(r.url(), r.method(), captureResourceType(r), entryUrl, origins) || !await allowCaptureDestination(r.url(), entryUrl)) {
|
|
131
189
|
await route.abort(); return false;
|
|
132
190
|
}
|
|
133
|
-
const response = await route.fetch({ maxRedirects:0, timeout:15000 });
|
|
191
|
+
const response = await route.fetch({ headers: unconditionalHeaders(r.headers()), maxRedirects:0, timeout:15000 });
|
|
134
192
|
if (response.status() >= 300 && response.status() < 400) {
|
|
135
193
|
await response.dispose(); await route.abort(); return false;
|
|
136
194
|
}
|
|
@@ -207,7 +265,7 @@ export async function capturePreview(config) {
|
|
|
207
265
|
try {
|
|
208
266
|
temp = await mkdtemp(join(tmpdir(), 'hyperdecks-preview-'));
|
|
209
267
|
try {
|
|
210
|
-
browser = await chromium.launch({ headless: true, timeout:30000, chromiumSandbox: process.platform === 'linux', args: ['--mute-audio',
|
|
268
|
+
browser = await chromium.launch({ headless: true, timeout:30000, chromiumSandbox: process.platform === 'linux', args: ['--mute-audio', ...recorderArgs()], env: { ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}), PATH: process.env.PATH, TMPDIR: temp, HOME: temp } });
|
|
211
269
|
} catch { throw new Error(`Could not launch isolated Chromium. ${SETUP}`); }
|
|
212
270
|
deadline = setTimeout(() => { timedOut = true; browser.close().catch(() => {}); }, 60000);
|
|
213
271
|
server = await serveSource(config);
|
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">
|