@hypersocial/decks-cli 0.9.5 → 0.9.7

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.
@@ -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/files.mjs CHANGED
@@ -17,6 +17,7 @@ const FILES = {
17
17
  "templates/layouts/number.html": () => new URL("../templates/layouts/number.html", import.meta.url),
18
18
  "templates/layouts/product.html": () => new URL("../templates/layouts/product.html", import.meta.url),
19
19
  "templates/layouts/phones.html": () => new URL("../templates/layouts/phones.html", import.meta.url),
20
+ "templates/layouts/split.html": () => new URL("../templates/layouts/split.html", import.meta.url),
20
21
  "templates/layouts/quote.html": () => new URL("../templates/layouts/quote.html", import.meta.url),
21
22
  "templates/layouts/team.html": () => new URL("../templates/layouts/team.html", import.meta.url),
22
23
  "templates/layouts/timeline.html": () => new URL("../templates/layouts/timeline.html", import.meta.url),
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/guide.mjs CHANGED
@@ -8,7 +8,7 @@ import { packageText } from "./files.mjs";
8
8
  import { LAYOUTS, readLayout } from "./layouts.mjs";
9
9
  import { fontPairingsMarkdown } from "./brand.mjs";
10
10
 
11
- export const GUIDE_TOPICS = ["workflow", "format", "script", "scripts", "design", "layouts", "presenter", "transitions", "check"];
11
+ export const GUIDE_TOPICS = ["workflow", "format", "script", "scripts", "design", "look", "layouts", "presenter", "transitions", "check"];
12
12
  const CLI_NOTE = "_Written for the CLI. On the hosted MCP there is no folder: use the tool names in `deck_guide` (workflow), hosted URLs for every file, and write_deck_source for edits._\n\n";
13
13
 
14
14
  // One <!-- topic: name --> section of SKILL.md: its heading and body, up to the
package/src/layouts.mjs CHANGED
@@ -5,8 +5,8 @@ import { packageText } from "./files.mjs";
5
5
 
6
6
  export const NARRATED_FILES = { "index.html": "narrated-deck.html", "script.md": "script.md", "evidence.md": "evidence.md" };
7
7
  // The library, in a sensible deck order; `--layouts` takes any order and repeats.
8
- export const LAYOUTS = ["hero", "number", "product", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
9
- const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", "phone-row": "phones", comparison: "compare", "three-up": "features" };
8
+ export const LAYOUTS = ["hero", "number", "product", "split", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
9
+ const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", "phone-row": "phones", comparison: "compare", "three-up": "features", "image-copy": "split", "image-text": "split" };
10
10
 
11
11
  const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12
12