@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 CHANGED
@@ -34,7 +34,7 @@ decks activity
34
34
 
35
35
  ## Auth
36
36
 
37
- `decks login` opens the production broker at `https://decks.hypersocial.ai` and
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://decks.hypersocial.ai/v/<token>",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -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://decks.hypersocial.ai/player/v1/player.js" data-manifest="…">`.
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 `decks.hypersocial.ai` script tag. Messages and what to do:
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://decks.hypersocial.ai/v/<token>",
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": [] }
@@ -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://decks.hypersocial.ai/player/v1/player.js";
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 !== PLAYER_V1) errs.push(MESSAGES.player(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>]] [--upload] [--out <file.mp4>] [--json]
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): the avatar, voice and captions, none of the player's chrome. A slug records
422
- the released copy, a deck folder its working copy. --title-card adds the title
423
- and presenter (2s), --end-card the deck's "Video viewers" link (3s); --upload
424
- keeps it with the deck's exports for 30 days
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)
@@ -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
- export function encodeArgs({ frames, fps = VIDEO_FPS, audio, width, height, out }) {
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
- '-framerate', String(fps), '-i', frames,
211
+ '-f', 'concat', '-safe', '0', '-i', list,
144
212
  '-i', audio,
145
213
  '-map', '0:v', '-map', '1:a',
146
- '-vf', `scale=${width}:${height}:flags=lanczos,format=yuv420p`,
147
- '-c:v', 'libx264', '-preset', 'fast', '-crf', '20', '-r', String(fps),
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({ blocked = [], failed = null } = {}) {
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
- if (blocked.length) parts.push(`${blocked.length} request${blocked.length === 1 ? '' : 's'} blocked, last ${cut(blocked.at(-1))}`);
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 && !blocked.includes(failed.url)) parts.push(`last failed request ${cut(failed.url)} (${failed.error})`);
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 }. `log` gets progress
263
- // lines. Returns { file, seconds, width, height, bytes, slides, clips, warnings }.
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 (!await routeCaptureRequest(route, pageUrl, config.firstPartyOrigins)) blockedUrls.push(route.request().url());
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
- await page.waitForSelector('body[data-hd-ready="true"]', { timeout: 60000 }).catch((e) => {
317
- throw new Error(`the deck never became ready (${e.message.split('\n')[0]})${pageSaid()}${readySaw({ blocked: blockedUrls, failed })}; is it on player/v1 (https://decks.hypersocial.ai/player/v1/player.js)?`);
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://decks.hypersocial.ai/player/v1/player.js (an older copy, or its own runtime, cannot be recorded)${pageSaid()}`);
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://decks.hypersocial.ai/player/v1/player.js, or update the deck\'s copy');
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
- for (const [n, url] of [...new Set((state.plan ?? []).map((s) => s.audio).filter(Boolean))].entries()) {
348
- const id = state.plan.find((s) => s.audio === url).id;
349
- if (!await allowCaptureDestination(url, pageUrl)) throw new Error(`clip ${id}'s audio is not on a public address: ${url}`);
350
- let res;
351
- try { res = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(30000) }); }
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
- log(`recording ${plan.ids.length} slides${plan.total ? `, about ${clock(plan.total)} of video,` : ''} at ${geometry.width}×${geometry.height} (${geometry.shape}), frame by frame`);
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 lead = config.titleCard ? TITLE_CARD_SECONDS : 0;
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 (config.titleCard) {
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
- await page.evaluate((lengths) => { window.__hdExportClipLengths = lengths; window.__hdExportGo(); return window.__hdFrameClock.settle(); }, clipLengths);
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[0];
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 (config.endCard) {
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
- const missing = plan.ids.filter((id) => !clips.some((c) => c.id === id) && (state.plan.find((s) => s.id === id)?.audio));
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 }), Math.round(Math.max(300000, total * 20000) * geometry.scale));
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
- if (pageErrors.length) warnings.push(`${pageErrors.length} page error(s) while recording: ${pageErrors.slice(0, 2).join('; ').slice(0, 300)}`);
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 export failed during ${phase}: ${e.message}`);
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) {
@@ -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
- "so it takes several times the deck's length: a 4-minute deck about half an hour), with the options size (720p, 1080p, 4k), shape (16:9, 9:16, 1:1), captions, avatar, " +
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 = `---
@@ -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://decks.hypersocial.ai/player/v1/player.js\">",
15
- "<link rel=\"preload\" as=\"style\" href=\"https://decks.hypersocial.ai/player/v1/player.css\">",
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://decks.hypersocial.ai/player/v1/dash-avatar.json";
13
- export const DASH_PRESENTER = Object.freeze({ avatarUrl: DASH_AVATAR, imageUrl: "https://decks.hypersocial.ai/avatars/dash.png" });
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
 
@@ -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.resourceType(), entryUrl, origins) || !await allowCaptureDestination(r.url(), entryUrl)) {
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://decks.hypersocial.ai' : target(opts).brokerBase;
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.5";
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: "https://decks.hypersocial.ai",
18
- shareBase: "https://decks.hypersocial.ai",
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 || PROD.brokerBase),
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
- res.setHeader("Access-Control-Allow-Origin", t.brokerBase);
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://decks.hypersocial.ai/player/v1/player.js">
14
- <link rel="preload" as="style" href="https://decks.hypersocial.ai/player/v1/player.css">
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://decks.hypersocial.ai/player/v1/player.js"
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://decks.hypersocial.ai/player/v1/player.js">
14
- <link rel="preload" as="style" href="https://decks.hypersocial.ai/player/v1/player.css">
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://decks.hypersocial.ai/player/v1/player.js"
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">