@bendyline/squisq-cli 2.8.9 → 2.8.11

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/dist/api.js CHANGED
@@ -4,15 +4,18 @@ import {
4
4
  framesToGifNativeBytes,
5
5
  framesToMp4Native,
6
6
  framesToMp4NativeBytes,
7
+ lastMeaningfulFfmpegLine,
7
8
  runFfmpeg
8
- } from "./chunk-Z3BHD4AF.js";
9
+ } from "./chunk-RQSP3OPR.js";
9
10
 
10
11
  // src/api.ts
11
- import { mkdir, readFile as readFile3, writeFile } from "fs/promises";
12
- import { dirname as dirname2, resolve as resolvePath } from "path";
12
+ import { randomBytes as randomBytes2 } from "crypto";
13
+ import { mkdir as mkdir2, readFile as readFile4, rm as rm2, writeFile as writeFile2 } from "fs/promises";
14
+ import { tmpdir as tmpdir2 } from "os";
15
+ import { dirname as dirname2, join as join4, resolve as resolvePath } from "path";
13
16
  import { resolveMediaSchedule as resolveMediaSchedule2 } from "@bendyline/squisq/schemas";
14
17
  import { flattenBlocks as flattenBlocks2 } from "@bendyline/squisq/doc";
15
- import { ffmpegGifOutputArgs, generateRenderHtml } from "@bendyline/squisq-video";
18
+ import { ffmpegGifOutputArgs, frameTimeSeconds, generateRenderHtml } from "@bendyline/squisq-video";
16
19
  import { resolveDashboardDimensions, resolveDimensions } from "@bendyline/squisq-video";
17
20
  import {
18
21
  convert as formatsConvert,
@@ -135,13 +138,13 @@ function round3(n) {
135
138
  return Math.round(n * 1e3) / 1e3;
136
139
  }
137
140
  async function mixTimelineClips(ffmpegPath, usable, signal) {
138
- const { writeFile: writeFile2, readFile: readFile4, mkdir: mkdir2, rm: rm2 } = await import("fs/promises");
139
- const { join: join3 } = await import("path");
140
- const { tmpdir: tmpdir2 } = await import("os");
141
- const { randomBytes: randomBytes2 } = await import("crypto");
141
+ const { writeFile: writeFile3, readFile: readFile5, mkdir: mkdir3, rm: rm3 } = await import("fs/promises");
142
+ const { join: join5 } = await import("path");
143
+ const { tmpdir: tmpdir3 } = await import("os");
144
+ const { randomBytes: randomBytes3 } = await import("crypto");
142
145
  signal?.throwIfAborted();
143
- const workDir = join3(tmpdir2(), `squisq-audio-mix-${randomBytes2(8).toString("hex")}`);
144
- await mkdir2(workDir, { recursive: true });
146
+ const workDir = join5(tmpdir3(), `squisq-audio-mix-${randomBytes3(8).toString("hex")}`);
147
+ await mkdir3(workDir, { recursive: true });
145
148
  try {
146
149
  signal?.throwIfAborted();
147
150
  const inputs = [];
@@ -149,15 +152,15 @@ async function mixTimelineClips(ffmpegPath, usable, signal) {
149
152
  const labels = [];
150
153
  for (const { clip, buffer } of usable) {
151
154
  signal?.throwIfAborted();
152
- const p = join3(workDir, `clip-${inputs.length}.mp3`);
153
- await writeFile2(p, new Uint8Array(buffer));
155
+ const p = join5(workDir, `clip-${inputs.length}.mp3`);
156
+ await writeFile3(p, new Uint8Array(buffer));
154
157
  signal?.throwIfAborted();
155
158
  const i = inputs.push(p) - 1;
156
159
  filters.push(`[${i}:a]${clipFilterChain(clip)}[a${i}]`);
157
160
  labels.push(`[a${i}]`);
158
161
  }
159
162
  const graph = `${filters.join(";")};${labels.join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[aout]`;
160
- const outputPath = join3(workDir, "mixed.mp3");
163
+ const outputPath = join5(workDir, "mixed.mp3");
161
164
  const args = ["-y"];
162
165
  for (const p of inputs) args.push("-i", p);
163
166
  args.push(
@@ -177,11 +180,11 @@ async function mixTimelineClips(ffmpegPath, usable, signal) {
177
180
  signal
178
181
  });
179
182
  signal?.throwIfAborted();
180
- const data = await readFile4(outputPath);
183
+ const data = await readFile5(outputPath);
181
184
  signal?.throwIfAborted();
182
185
  return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
183
186
  } finally {
184
- await rm2(workDir, { recursive: true, force: true });
187
+ await rm3(workDir, { recursive: true, force: true });
185
188
  }
186
189
  }
187
190
 
@@ -324,11 +327,188 @@ function isRecord(value) {
324
327
  return typeof value === "object" && value !== null;
325
328
  }
326
329
 
330
+ // src/util/frameSink.ts
331
+ import { spawn } from "child_process";
332
+ import { createHash } from "crypto";
333
+ import { once } from "events";
334
+ import { access, mkdir, readFile, writeFile } from "fs/promises";
335
+ import { join } from "path";
336
+ import { isDeepStrictEqual } from "util";
337
+ import {
338
+ audioBitrateArg,
339
+ ffmpegAudioMuxArgs,
340
+ ffmpegVideoQualityArgs
341
+ } from "@bendyline/squisq-video";
342
+ function ffmpegPipeArgs(options) {
343
+ const { outputPath, fps, width, height, quality, captureFormat, audioPath } = options;
344
+ const args = [
345
+ "-y",
346
+ "-f",
347
+ "image2pipe",
348
+ "-framerate",
349
+ String(fps),
350
+ "-c:v",
351
+ captureFormat === "jpeg" ? "mjpeg" : "png",
352
+ "-i",
353
+ "pipe:0"
354
+ ];
355
+ if (audioPath) args.push("-i", audioPath);
356
+ const range = captureFormat === "jpeg" ? ":in_range=pc:out_range=tv" : "";
357
+ args.push(
358
+ "-c:v",
359
+ "libx264",
360
+ ...ffmpegVideoQualityArgs(quality),
361
+ "-pix_fmt",
362
+ "yuv420p",
363
+ "-color_range",
364
+ "tv",
365
+ "-vf",
366
+ `scale=${width}:${height}:force_original_aspect_ratio=decrease${range},pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`
367
+ );
368
+ if (audioPath) args.push(...ffmpegAudioMuxArgs(audioBitrateArg(quality)));
369
+ args.push("-movflags", "+faststart", outputPath);
370
+ return args;
371
+ }
372
+ var STDERR_TAIL_LINES = 200;
373
+ var IDLE_POLL_MS = 5e3;
374
+ function createFfmpegPipeSink(options) {
375
+ const { ffmpegPath, signal, idleTimeoutMs = 12e4 } = options;
376
+ const child = spawn(ffmpegPath, ffmpegPipeArgs(options), {
377
+ stdio: ["pipe", "ignore", "pipe"]
378
+ });
379
+ const stderrTail = [];
380
+ let lastActivity = Date.now();
381
+ let failure = null;
382
+ let frames = 0;
383
+ let exitCode;
384
+ child.stderr.setEncoding("utf8");
385
+ child.stderr.on("data", (chunk) => {
386
+ lastActivity = Date.now();
387
+ for (const line of chunk.split(/\r?\n|\r/)) {
388
+ if (!line.trim()) continue;
389
+ stderrTail.push(line);
390
+ if (stderrTail.length > STDERR_TAIL_LINES) stderrTail.shift();
391
+ }
392
+ });
393
+ child.on("error", (err) => {
394
+ failure ??= err;
395
+ });
396
+ child.stdin.on("error", (err) => {
397
+ failure ??= err;
398
+ });
399
+ const exited = new Promise((resolve2) => {
400
+ child.on("exit", (code) => {
401
+ exitCode = code;
402
+ resolve2();
403
+ });
404
+ child.on("error", () => resolve2());
405
+ });
406
+ const idleTimer = setInterval(() => {
407
+ if (Date.now() - lastActivity > idleTimeoutMs) {
408
+ failure ??= new Error(`ffmpeg produced no output for ${Math.round(idleTimeoutMs / 1e3)}s`);
409
+ child.kill("SIGKILL");
410
+ }
411
+ }, IDLE_POLL_MS);
412
+ idleTimer.unref();
413
+ const onAbort = () => {
414
+ child.kill("SIGKILL");
415
+ };
416
+ signal?.addEventListener("abort", onAbort, { once: true });
417
+ const cleanup = () => {
418
+ clearInterval(idleTimer);
419
+ signal?.removeEventListener("abort", onAbort);
420
+ };
421
+ const describeFailure = () => {
422
+ const detail = lastMeaningfulFfmpegLine(stderrTail.join("\n"));
423
+ if (failure && failure.code === "ENOENT") {
424
+ return "the ffmpeg binary could not be started (ENOENT). Run `squisq doctor` to check your FFmpeg installation.";
425
+ }
426
+ const head = failure ? failure.message : exitCode === void 0 ? "ffmpeg exited unexpectedly" : `ffmpeg exited unsuccessfully (exit code ${exitCode})`;
427
+ return detail ? `${head} (last output: ${detail})` : head;
428
+ };
429
+ const throwIfDead = () => {
430
+ signal?.throwIfAborted();
431
+ if (failure || exitCode !== void 0) {
432
+ throw new Error(`ffmpeg failed: ${describeFailure()}`);
433
+ }
434
+ };
435
+ return {
436
+ get frameCount() {
437
+ return frames;
438
+ },
439
+ async write(frame, repeat = 1) {
440
+ if (!Number.isSafeInteger(repeat) || repeat < 1) {
441
+ throw new Error("Frame repetition count must be a positive integer");
442
+ }
443
+ throwIfDead();
444
+ for (let i = 0; i < repeat; i += 1) {
445
+ lastActivity = Date.now();
446
+ if (!child.stdin.write(frame)) {
447
+ await Promise.race([once(child.stdin, "drain").catch(() => void 0), exited]);
448
+ }
449
+ throwIfDead();
450
+ frames += 1;
451
+ }
452
+ },
453
+ async finish() {
454
+ throwIfDead();
455
+ child.stdin.end();
456
+ await exited;
457
+ cleanup();
458
+ signal?.throwIfAborted();
459
+ if (failure || exitCode !== 0) throw new Error(`ffmpeg failed: ${describeFailure()}`);
460
+ },
461
+ async abort() {
462
+ cleanup();
463
+ if (exitCode === void 0) child.kill("SIGKILL");
464
+ await exited;
465
+ }
466
+ };
467
+ }
468
+ function docRenderHash(doc) {
469
+ return createHash("sha256").update(JSON.stringify(doc)).digest("hex");
470
+ }
471
+ var MANIFEST_NAME = "manifest.json";
472
+ async function openDirectoryFrameStore(dir, manifest, resume) {
473
+ await mkdir(dir, { recursive: true });
474
+ const manifestPath = join(dir, MANIFEST_NAME);
475
+ let existing = null;
476
+ try {
477
+ existing = JSON.parse(await readFile(manifestPath, "utf8"));
478
+ } catch {
479
+ existing = null;
480
+ }
481
+ const reusable = resume && existing !== null && isDeepStrictEqual(existing, manifest);
482
+ if (!reusable) await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
483
+ const extension = manifest.captureFormat === "jpeg" ? "jpg" : "png";
484
+ const path = (index) => join(dir, `frame-${String(index).padStart(6, "0")}.${extension}`);
485
+ return {
486
+ dir,
487
+ reusable,
488
+ path,
489
+ async has(index) {
490
+ try {
491
+ await access(path(index));
492
+ return true;
493
+ } catch {
494
+ return false;
495
+ }
496
+ },
497
+ async read(index) {
498
+ const bytes = await readFile(path(index));
499
+ return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
500
+ },
501
+ async write(index, frame) {
502
+ await writeFile(path(index), frame);
503
+ }
504
+ };
505
+ }
506
+
327
507
  // src/registry.ts
328
508
  import { randomBytes } from "crypto";
329
- import { readFile, rm } from "fs/promises";
509
+ import { readFile as readFile2, rm } from "fs/promises";
330
510
  import { tmpdir } from "os";
331
- import { join } from "path";
511
+ import { join as join2 } from "path";
332
512
  import { defaultRegistry } from "@bendyline/squisq-formats";
333
513
  var MP4_DEFAULTS = {
334
514
  fps: 30,
@@ -363,7 +543,7 @@ function mp4Format() {
363
543
  const orientation = typeof mp4Opts.orientation === "string" ? mp4Opts.orientation : MP4_DEFAULTS.orientation;
364
544
  const coverPreRoll = typeof mp4Opts.coverPreRoll === "number" ? mp4Opts.coverPreRoll : MP4_DEFAULTS.coverPreRoll;
365
545
  const animationsEnabled = typeof mp4Opts.animationsEnabled === "boolean" ? mp4Opts.animationsEnabled : MP4_DEFAULTS.animationsEnabled;
366
- const outputPath = join(tmpdir(), `squisq-mp4-${randomBytes(8).toString("hex")}.mp4`);
546
+ const outputPath = join2(tmpdir(), `squisq-mp4-${randomBytes(8).toString("hex")}.mp4`);
367
547
  try {
368
548
  const { renderDocToMp4: renderDocToMp42 } = await import("./api.js");
369
549
  await renderDocToMp42(input.doc, input.container, {
@@ -380,7 +560,7 @@ function mp4Format() {
380
560
  onProgress: mp4Opts.onProgress
381
561
  });
382
562
  options.signal?.throwIfAborted();
383
- const data = await readFile(outputPath, { signal: options.signal });
563
+ const data = await readFile2(outputPath, { signal: options.signal });
384
564
  options.signal?.throwIfAborted();
385
565
  const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
386
566
  return { bytes, mimeType: "video/mp4", suggestedFilename: "", warnings: [] };
@@ -404,7 +584,7 @@ function gifFormat() {
404
584
  const portrait = orientation === "portrait";
405
585
  const defaultWidth = portrait ? GIF_DEFAULTS.height : GIF_DEFAULTS.width;
406
586
  const defaultHeight = portrait ? GIF_DEFAULTS.width : GIF_DEFAULTS.height;
407
- const outputPath = join(tmpdir(), `squisq-gif-${randomBytes(8).toString("hex")}.gif`);
587
+ const outputPath = join2(tmpdir(), `squisq-gif-${randomBytes(8).toString("hex")}.gif`);
408
588
  try {
409
589
  const { renderDocToGif: renderDocToGif2 } = await import("./api.js");
410
590
  const result = await renderDocToGif2(input.doc, input.container, {
@@ -424,7 +604,7 @@ function gifFormat() {
424
604
  onProgress: gifOpts.onProgress
425
605
  });
426
606
  options.signal?.throwIfAborted();
427
- const data = await readFile(outputPath, { signal: options.signal });
607
+ const data = await readFile2(outputPath, { signal: options.signal });
428
608
  options.signal?.throwIfAborted();
429
609
  const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
430
610
  return {
@@ -478,13 +658,13 @@ function createCliRegistry() {
478
658
  import { MemoryContentContainer as MemoryContentContainer2 } from "@bendyline/squisq/storage";
479
659
 
480
660
  // src/util/readInput.ts
481
- import { readFile as readFile2, readdir, realpath, stat } from "fs/promises";
661
+ import { readFile as readFile3, readdir, realpath, stat } from "fs/promises";
482
662
  import {
483
663
  basename,
484
664
  dirname,
485
665
  extname,
486
666
  isAbsolute,
487
- join as join2,
667
+ join as join3,
488
668
  posix,
489
669
  relative,
490
670
  resolve,
@@ -550,7 +730,7 @@ async function walkDir(root, prefix = "", signal) {
550
730
  throwIfAborted(signal);
551
731
  const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
552
732
  if (entry.isDirectory()) {
553
- paths.push(...await walkDir(join2(root, entry.name), relPath, signal));
733
+ paths.push(...await walkDir(join3(root, entry.name), relPath, signal));
554
734
  } else if (entry.isFile()) {
555
735
  paths.push(relPath);
556
736
  }
@@ -601,7 +781,7 @@ async function readArrayBuffer(filePath, signal) {
601
781
  async function readBinaryFile(filePath, signal) {
602
782
  throwIfAborted(signal);
603
783
  try {
604
- const data = await readFile2(filePath, { signal });
784
+ const data = await readFile3(filePath, { signal });
605
785
  throwIfAborted(signal);
606
786
  return data;
607
787
  } catch (error) {
@@ -612,7 +792,7 @@ async function readBinaryFile(filePath, signal) {
612
792
  async function readUtf8File(filePath, signal) {
613
793
  throwIfAborted(signal);
614
794
  try {
615
- const content = await readFile2(filePath, { encoding: "utf-8", signal });
795
+ const content = await readFile3(filePath, { encoding: "utf-8", signal });
616
796
  throwIfAborted(signal);
617
797
  return content;
618
798
  } catch (error) {
@@ -867,7 +1047,7 @@ async function readFolder(dirPath, signal) {
867
1047
  throwIfAborted(signal);
868
1048
  for (const relPath of files) {
869
1049
  throwIfAborted(signal);
870
- const absPath = join2(dirPath, relPath);
1050
+ const absPath = join3(dirPath, relPath);
871
1051
  const data = await readBinaryFile(absPath, signal);
872
1052
  throwIfAborted(signal);
873
1053
  await container.writeFile(
@@ -901,11 +1081,13 @@ import {
901
1081
  validateDashboardImageDimensions
902
1082
  } from "@bendyline/squisq-video";
903
1083
  import { ConversionError } from "@bendyline/squisq-formats";
1084
+ var RENDER_BOOT_TIMEOUT_MS = 6e4;
1085
+ var JPEG_CAPTURE_QUALITY = 92;
904
1086
  var playerBundlePromise;
905
1087
  var fullPlayerBundlePromise;
906
1088
  var playerIconBootstrapPromise;
907
1089
  function loadPlayerIconBootstrap() {
908
- playerIconBootstrapPromise ??= readFile3(
1090
+ playerIconBootstrapPromise ??= readFile4(
909
1091
  new URL("../dist/squisq-player-icons.global.js", import.meta.url),
910
1092
  "utf8"
911
1093
  );
@@ -914,14 +1096,14 @@ function loadPlayerIconBootstrap() {
914
1096
  function loadPlayerBundle() {
915
1097
  playerBundlePromise ??= Promise.all([
916
1098
  loadPlayerIconBootstrap(),
917
- readFile3(new URL("../dist/squisq-player.global.js", import.meta.url), "utf8")
1099
+ readFile4(new URL("../dist/squisq-player.global.js", import.meta.url), "utf8")
918
1100
  ]).then(([icons, player]) => `${icons}${player}`);
919
1101
  return playerBundlePromise;
920
1102
  }
921
1103
  function loadFullPlayerBundle() {
922
1104
  fullPlayerBundlePromise ??= Promise.all([
923
1105
  loadPlayerIconBootstrap(),
924
- readFile3(new URL("../dist/squisq-player.full.global.js", import.meta.url), "utf8")
1106
+ readFile4(new URL("../dist/squisq-player.full.global.js", import.meta.url), "utf8")
925
1107
  ]).then(([icons, player]) => `${icons}${player}`);
926
1108
  return fullPlayerBundlePromise;
927
1109
  }
@@ -946,6 +1128,7 @@ async function withRenderPage(doc, container, options, fn) {
946
1128
  height,
947
1129
  includeAudio,
948
1130
  captionStyle,
1131
+ motion,
949
1132
  animationsEnabled,
950
1133
  displayMode,
951
1134
  dashboard,
@@ -1006,6 +1189,7 @@ async function withRenderPage(doc, container, options, fn) {
1006
1189
  width,
1007
1190
  height,
1008
1191
  captionStyle,
1192
+ motion,
1009
1193
  animationsEnabled,
1010
1194
  displayMode,
1011
1195
  dashboard
@@ -1016,7 +1200,14 @@ async function withRenderPage(doc, container, options, fn) {
1016
1200
  signal?.throwIfAborted();
1017
1201
  let browser;
1018
1202
  try {
1019
- browser = await chromium.launch({ headless: true });
1203
+ browser = await chromium.launch({
1204
+ headless: true,
1205
+ // Keyframe animations must be sampled by the main thread: the
1206
+ // compositor rasterises a transforming layer differently depending on
1207
+ // whether it caught the animation running, which made mid-motion stills
1208
+ // differ between otherwise identical renders.
1209
+ args: ["--disable-threaded-animation"]
1210
+ });
1020
1211
  } catch (err) {
1021
1212
  signal?.throwIfAborted();
1022
1213
  const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
@@ -1055,7 +1246,7 @@ async function withRenderPage(doc, container, options, fn) {
1055
1246
  const player = window.SquisqPlayer;
1056
1247
  return root ? player?.getHandle(root)?.getRenderAPI() != null : false;
1057
1248
  },
1058
- { timeout: 15e3 }
1249
+ { timeout: RENDER_BOOT_TIMEOUT_MS }
1059
1250
  );
1060
1251
  } catch {
1061
1252
  signal?.throwIfAborted();
@@ -1063,9 +1254,16 @@ async function withRenderPage(doc, container, options, fn) {
1063
1254
  Page errors:
1064
1255
  ${pageErrors.join("\n ")}` : "\nNo page errors captured \u2014 the player may have failed to mount.";
1065
1256
  throw new Error(
1066
- `The standalone player failed to boot in headless Chromium. Render API did not initialize within 15 seconds.${errorDetail}`
1257
+ `The standalone player failed to boot in headless Chromium. Render API did not initialize within ${RENDER_BOOT_TIMEOUT_MS / 1e3} seconds.${errorDetail}`
1067
1258
  );
1068
1259
  }
1260
+ signal?.throwIfAborted();
1261
+ await page.evaluate(
1262
+ () => Promise.race([
1263
+ document.fonts?.ready ?? Promise.resolve(void 0),
1264
+ new Promise((resolve2) => setTimeout(resolve2, 1e4))
1265
+ ]).then(() => void 0)
1266
+ );
1069
1267
  renderAPI = await page.evaluateHandle(() => {
1070
1268
  const root = document.getElementById("squisq-root");
1071
1269
  const player = window.SquisqPlayer;
@@ -1074,6 +1272,13 @@ Page errors:
1074
1272
  return api;
1075
1273
  });
1076
1274
  signal?.throwIfAborted();
1275
+ const mounted = await renderAPI.evaluate((api) => api.getViewport ? api.getViewport() : null);
1276
+ signal?.throwIfAborted();
1277
+ if (mounted && (mounted.width !== width || mounted.height !== height)) {
1278
+ throw new Error(
1279
+ `The player composed a ${mounted.width}x${mounted.height} viewport for a ${width}x${height} render; the output would be letterboxed. Rebuild the CLI player bundle (npm run build:cli) so the render page can pin its viewport.`
1280
+ );
1281
+ }
1077
1282
  return await fn({ page, renderAPI });
1078
1283
  } finally {
1079
1284
  signal?.removeEventListener("abort", handleAbort);
@@ -1081,10 +1286,7 @@ Page errors:
1081
1286
  await browser.close().catch(() => void 0);
1082
1287
  }
1083
1288
  }
1084
- async function captureDocFrames(doc, container, options) {
1085
- const { fps, width, height, captionStyle, coverPreRoll, animationsEnabled, onProgress, signal } = options;
1086
- signal?.throwIfAborted();
1087
- resolveAppliedCoverPreRoll(coverPreRoll, true);
1289
+ async function requireFfmpeg(signal) {
1088
1290
  const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
1089
1291
  signal?.throwIfAborted();
1090
1292
  if (!ffmpegPath) {
@@ -1092,53 +1294,106 @@ async function captureDocFrames(doc, container, options) {
1092
1294
  "ffmpeg is required but not found in PATH.\nInstall it with:\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg\n Windows: winget install ffmpeg\nOr: npm install ffmpeg-static, or set SQUISQ_FFMPEG to an ffmpeg binary."
1093
1295
  );
1094
1296
  }
1297
+ return ffmpegPath;
1298
+ }
1299
+ async function iterateDocFrames(session, options, visit) {
1300
+ const { page, renderAPI } = session;
1301
+ const { fps, coverPreRoll, captureFormat, signal, onProgress } = options;
1302
+ const screenshot = () => captureFormat === "jpeg" ? page.screenshot({ type: "jpeg", quality: JPEG_CAPTURE_QUALITY }) : page.screenshot({ type: "png" });
1303
+ const docDuration = await renderAPI.evaluate((api) => api.getDuration());
1304
+ signal?.throwIfAborted();
1305
+ if (docDuration <= 0) throw new Error("Document has zero duration \u2014 nothing to render");
1306
+ const hasCover = coverPreRoll > 0 ? await renderAPI.evaluate((api) => api.hasCoverBlock()) : false;
1307
+ const appliedCoverPreRoll = resolveAppliedCoverPreRoll(coverPreRoll, hasCover);
1308
+ const storyFrameCount = Math.ceil(docDuration * fps);
1309
+ const preRollFrameCount = Math.ceil(appliedCoverPreRoll * fps);
1310
+ const totalFrames = preRollFrameCount + storyFrameCount;
1311
+ onProgress?.("capturing frames", 20);
1312
+ if (preRollFrameCount > 0) {
1313
+ signal?.throwIfAborted();
1314
+ await visit(
1315
+ { index: 0, time: 0, isCover: true, repeat: preRollFrameCount, totalFrames },
1316
+ async () => {
1317
+ await renderAPI.evaluate((api) => api.showCover());
1318
+ await page.waitForTimeout(100);
1319
+ signal?.throwIfAborted();
1320
+ const frame = await screenshot();
1321
+ await renderAPI.evaluate((api) => api.hideCover());
1322
+ return frame;
1323
+ }
1324
+ );
1325
+ }
1326
+ const progressEvery = Math.max(1, Math.floor(fps / 2));
1327
+ for (let i = 0; i < storyFrameCount; i++) {
1328
+ signal?.throwIfAborted();
1329
+ const time = frameTimeSeconds(i, fps);
1330
+ await visit(
1331
+ { index: preRollFrameCount + i, time, isCover: false, repeat: 1, totalFrames },
1332
+ async () => {
1333
+ await renderAPI.evaluate((api, t) => api.seekTo(t), time);
1334
+ signal?.throwIfAborted();
1335
+ return screenshot();
1336
+ }
1337
+ );
1338
+ if (i % progressEvery === 0 || i === storyFrameCount - 1) {
1339
+ onProgress?.(
1340
+ "capturing frames",
1341
+ 20 + Math.round((preRollFrameCount + i + 1) / totalFrames * 60)
1342
+ );
1343
+ }
1344
+ }
1345
+ return {
1346
+ totalDuration: docDuration + appliedCoverPreRoll,
1347
+ appliedCoverPreRoll,
1348
+ totalFrames,
1349
+ preRollFrameCount
1350
+ };
1351
+ }
1352
+ async function captureDocFrames(doc, container, options) {
1353
+ const {
1354
+ fps,
1355
+ width,
1356
+ height,
1357
+ captionStyle,
1358
+ motion,
1359
+ coverPreRoll,
1360
+ animationsEnabled,
1361
+ onProgress,
1362
+ signal
1363
+ } = options;
1364
+ signal?.throwIfAborted();
1365
+ resolveAppliedCoverPreRoll(coverPreRoll, true);
1366
+ const ffmpegPath = await requireFfmpeg(signal);
1095
1367
  const capturedFrames = new CapturedFrameCollector();
1096
1368
  try {
1097
1369
  return await withRenderPage(
1098
1370
  doc,
1099
1371
  container,
1100
- { signal, width, height, includeAudio: true, captionStyle, animationsEnabled, onProgress },
1101
- async ({ page, renderAPI }) => {
1102
- const docDuration = await renderAPI.evaluate((api) => api.getDuration());
1103
- signal?.throwIfAborted();
1104
- if (docDuration <= 0) throw new Error("Document has zero duration \u2014 nothing to render");
1105
- const hasCover = coverPreRoll > 0 ? await renderAPI.evaluate((api) => api.hasCoverBlock()) : false;
1106
- const appliedCoverPreRoll = resolveAppliedCoverPreRoll(coverPreRoll, hasCover);
1107
- const storyFrameCount = Math.ceil(docDuration * fps);
1108
- const preRollFrameCount = Math.ceil(appliedCoverPreRoll * fps);
1109
- const totalFrames = preRollFrameCount + storyFrameCount;
1110
- onProgress?.("capturing frames", 20);
1111
- capturedFrames.throwIfAborted(signal);
1112
- if (preRollFrameCount > 0) {
1113
- capturedFrames.throwIfAborted(signal);
1114
- await renderAPI.evaluate((api) => api.showCover());
1115
- await page.waitForTimeout(100);
1116
- capturedFrames.throwIfAborted(signal);
1117
- const coverFrame = await page.screenshot({ type: "png" });
1118
- capturedFrames.throwIfAborted(signal);
1119
- capturedFrames.append(coverFrame, preRollFrameCount);
1120
- await renderAPI.evaluate((api) => api.hideCover());
1121
- }
1122
- const frameInterval = 1 / fps;
1123
- for (let i = 0; i < storyFrameCount; i++) {
1124
- capturedFrames.throwIfAborted(signal);
1125
- const time = i * frameInterval;
1126
- await renderAPI.evaluate((api, t) => api.seekTo(t), time);
1127
- const frame = await page.screenshot({ type: "png" });
1128
- capturedFrames.throwIfAborted(signal);
1129
- capturedFrames.append(frame);
1130
- if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {
1131
- onProgress?.(
1132
- "capturing frames",
1133
- 20 + Math.round(capturedFrames.frameCount / totalFrames * 60)
1134
- );
1372
+ {
1373
+ signal,
1374
+ width,
1375
+ height,
1376
+ includeAudio: true,
1377
+ captionStyle,
1378
+ motion,
1379
+ animationsEnabled,
1380
+ onProgress
1381
+ },
1382
+ async (session) => {
1383
+ const loop = await iterateDocFrames(
1384
+ session,
1385
+ { fps, coverPreRoll, captureFormat: "png", signal, onProgress },
1386
+ async (frame, capture) => {
1135
1387
  capturedFrames.throwIfAborted(signal);
1388
+ const bytes = await capture();
1389
+ capturedFrames.throwIfAborted(signal);
1390
+ capturedFrames.append(bytes, frame.repeat);
1136
1391
  }
1137
- }
1392
+ );
1138
1393
  return {
1139
1394
  frames: capturedFrames.release(),
1140
- totalDuration: docDuration + appliedCoverPreRoll,
1141
- appliedCoverPreRoll,
1395
+ totalDuration: loop.totalDuration,
1396
+ appliedCoverPreRoll: loop.appliedCoverPreRoll,
1142
1397
  ffmpegPath
1143
1398
  };
1144
1399
  }
@@ -1149,6 +1404,130 @@ async function captureDocFrames(doc, container, options) {
1149
1404
  throw err;
1150
1405
  }
1151
1406
  }
1407
+ async function renderDocToMp4Piped(doc, container, options, render) {
1408
+ const { signal, onProgress } = options;
1409
+ const { fps, quality, width, height, captureFormat } = render;
1410
+ const coverPreRoll = options.coverPreRoll ?? 0;
1411
+ resolveAppliedCoverPreRoll(coverPreRoll, true);
1412
+ if (options.resume && !options.framesDir) {
1413
+ throw new Error("resume requires framesDir so there is a spool to resume from");
1414
+ }
1415
+ const captionStyle = options.captionStyle === "off" ? void 0 : options.captionStyle;
1416
+ const motion = options.motion ?? null;
1417
+ const animationsEnabled = options.animationsEnabled ?? true;
1418
+ const ffmpegPath = await requireFfmpeg(signal);
1419
+ const outputPath = resolvePath(options.outputPath);
1420
+ await mkdir2(dirname2(outputPath), { recursive: true });
1421
+ const manifest = {
1422
+ generatedBy: "squisq-cli",
1423
+ docHash: docRenderHash(doc),
1424
+ fps,
1425
+ width,
1426
+ height,
1427
+ captionStyle: captionStyle ?? null,
1428
+ motion: motion === null ? null : JSON.stringify(motion),
1429
+ animationsEnabled,
1430
+ coverPreRoll,
1431
+ captureFormat
1432
+ };
1433
+ const store = options.framesDir ? await openDirectoryFrameStore(
1434
+ resolvePath(options.framesDir),
1435
+ manifest,
1436
+ options.resume === true
1437
+ ) : null;
1438
+ const audioPath = join4(tmpdir2(), `squisq-audio-${randomBytes2(8).toString("hex")}.mp3`);
1439
+ let audioWritten = false;
1440
+ let sink = null;
1441
+ let reusedFrameCount = 0;
1442
+ try {
1443
+ const { loop, activeSink } = await withRenderPage(
1444
+ doc,
1445
+ container,
1446
+ {
1447
+ signal,
1448
+ width,
1449
+ height,
1450
+ includeAudio: true,
1451
+ captionStyle,
1452
+ motion,
1453
+ animationsEnabled,
1454
+ onProgress
1455
+ },
1456
+ async (session) => {
1457
+ const hasCover = coverPreRoll > 0 ? await session.renderAPI.evaluate((api) => api.hasCoverBlock()) : false;
1458
+ const appliedCoverPreRoll = resolveAppliedCoverPreRoll(coverPreRoll, hasCover);
1459
+ onProgress?.("mixing audio", 18);
1460
+ const encodingAudio = await buildMixedAudioTrack(
1461
+ doc,
1462
+ container,
1463
+ ffmpegPath,
1464
+ appliedCoverPreRoll,
1465
+ signal
1466
+ );
1467
+ signal?.throwIfAborted();
1468
+ if (encodingAudio) {
1469
+ await writeFile2(audioPath, encodingAudio);
1470
+ audioWritten = true;
1471
+ }
1472
+ const pipe = createFfmpegPipeSink({
1473
+ ffmpegPath,
1474
+ outputPath,
1475
+ fps,
1476
+ width,
1477
+ height,
1478
+ quality,
1479
+ captureFormat,
1480
+ audioPath: audioWritten ? audioPath : null,
1481
+ signal
1482
+ });
1483
+ sink = pipe;
1484
+ const result = await iterateDocFrames(
1485
+ session,
1486
+ { fps, coverPreRoll, captureFormat, signal, onProgress },
1487
+ async (frame, capture) => {
1488
+ const started = Date.now();
1489
+ let bytes;
1490
+ let reused = false;
1491
+ if (store?.reusable && await store.has(frame.index)) {
1492
+ bytes = await store.read(frame.index);
1493
+ reused = true;
1494
+ reusedFrameCount += frame.repeat;
1495
+ } else {
1496
+ bytes = await capture();
1497
+ if (store) await store.write(frame.index, bytes);
1498
+ }
1499
+ await pipe.write(bytes, frame.repeat);
1500
+ options.onFrame?.({
1501
+ index: frame.index,
1502
+ time: frame.time,
1503
+ isCover: frame.isCover,
1504
+ totalFrames: frame.totalFrames,
1505
+ reused,
1506
+ captureMs: reused ? 0 : Date.now() - started
1507
+ });
1508
+ }
1509
+ );
1510
+ return { loop: result, activeSink: pipe };
1511
+ }
1512
+ );
1513
+ onProgress?.("encoding video", 85);
1514
+ await activeSink.finish();
1515
+ signal?.throwIfAborted();
1516
+ onProgress?.("done", 100);
1517
+ return {
1518
+ duration: loop.totalDuration,
1519
+ frameCount: activeSink.frameCount,
1520
+ outputPath,
1521
+ reusedFrameCount,
1522
+ ...store ? { framesDir: store.dir } : {}
1523
+ };
1524
+ } catch (err) {
1525
+ await sink?.abort();
1526
+ throw err;
1527
+ } finally {
1528
+ if (audioWritten) await rm2(audioPath, { force: true }).catch(() => void 0);
1529
+ }
1530
+ }
1152
1531
  async function renderDocCoverToPng(doc, container, options = {}) {
1153
1532
  const width = options.width ?? 1920;
1154
1533
  const height = options.height ?? 1080;
@@ -1181,8 +1560,8 @@ async function renderDocCoverToPng(doc, container, options = {}) {
1181
1560
  let writtenPath;
1182
1561
  if (options.outputPath) {
1183
1562
  const absolute = resolvePath(options.outputPath);
1184
- await mkdir(dirname2(absolute), { recursive: true });
1185
- await writeFile(absolute, bytes);
1563
+ await mkdir2(dirname2(absolute), { recursive: true });
1564
+ await writeFile2(absolute, bytes);
1186
1565
  writtenPath = absolute;
1187
1566
  }
1188
1567
  options.onProgress?.("done", 100);
@@ -1230,8 +1609,8 @@ async function renderDocToDashboardPng(doc, container, options = {}) {
1230
1609
  let writtenPath;
1231
1610
  if (outputPath) {
1232
1611
  const absolute = resolvePath(outputPath);
1233
- await mkdir(dirname2(absolute), { recursive: true });
1234
- await writeFile(absolute, bytes);
1612
+ await mkdir2(dirname2(absolute), { recursive: true });
1613
+ await writeFile2(absolute, bytes);
1235
1614
  writtenPath = absolute;
1236
1615
  }
1237
1616
  onProgress?.("done", 100);
@@ -1254,11 +1633,26 @@ async function renderDocToMp4(doc, container, options) {
1254
1633
  fps,
1255
1634
  quality
1256
1635
  });
1636
+ const frameTransport = options.frameTransport ?? "pipe";
1637
+ const captureFormat = options.captureFormat ?? (quality === "high" ? "png" : "jpeg");
1638
+ if (frameTransport === "pipe") {
1639
+ return renderDocToMp4Piped(doc, container, options, {
1640
+ fps,
1641
+ quality,
1642
+ width: dimensions.width,
1643
+ height: dimensions.height,
1644
+ captureFormat
1645
+ });
1646
+ }
1647
+ if (options.framesDir || options.resume || options.onFrame) {
1648
+ throw new Error("framesDir, resume, and onFrame require the pipe frame transport");
1649
+ }
1257
1650
  const capture = await captureDocFrames(doc, container, {
1258
1651
  fps,
1259
1652
  width: dimensions.width,
1260
1653
  height: dimensions.height,
1261
1654
  captionStyle: options.captionStyle === "off" ? void 0 : options.captionStyle,
1655
+ motion: options.motion ?? null,
1262
1656
  coverPreRoll: options.coverPreRoll ?? 0,
1263
1657
  animationsEnabled: options.animationsEnabled ?? true,
1264
1658
  onProgress: options.onProgress,
@@ -1276,7 +1670,7 @@ async function renderDocToMp4(doc, container, options) {
1276
1670
  options.signal
1277
1671
  );
1278
1672
  options.signal?.throwIfAborted();
1279
- const { framesToMp4Native: framesToMp4Native2 } = await import("./nativeEncoder-RZ3T4Z6S.js");
1673
+ const { framesToMp4Native: framesToMp4Native2 } = await import("./nativeEncoder-T43J5UMP.js");
1280
1674
  await framesToMp4Native2(capture.ffmpegPath, capture.frames, encodingAudio, options.outputPath, {
1281
1675
  fps,
1282
1676
  quality,
@@ -1322,6 +1716,7 @@ async function renderDocToGif(doc, container, options) {
1322
1716
  width,
1323
1717
  height,
1324
1718
  captionStyle: options.captionStyle === "off" ? void 0 : options.captionStyle ?? "standard",
1719
+ motion: options.motion ?? null,
1325
1720
  coverPreRoll: options.coverPreRoll ?? 0,
1326
1721
  animationsEnabled: options.animationsEnabled ?? false,
1327
1722
  onProgress: options.onProgress,
@@ -1331,7 +1726,7 @@ async function renderDocToGif(doc, container, options) {
1331
1726
  try {
1332
1727
  options.onProgress?.("encoding GIF", 80);
1333
1728
  options.signal?.throwIfAborted();
1334
- const { framesToGifNative: framesToGifNative2 } = await import("./nativeEncoder-RZ3T4Z6S.js");
1729
+ const { framesToGifNative: framesToGifNative2 } = await import("./nativeEncoder-T43J5UMP.js");
1335
1730
  await framesToGifNative2(capture.ffmpegPath, capture.frames, options.outputPath, {
1336
1731
  fps,
1337
1732
  orientation,
@@ -1361,7 +1756,7 @@ async function renderDocToGif(doc, container, options) {
1361
1756
  async function extractThumbnails(options) {
1362
1757
  const { videoPath, outputDir, slug, sizes, force, signal } = options;
1363
1758
  const { existsSync } = await import("fs");
1364
- const { rm: rm2 } = await import("fs/promises");
1759
+ const { rm: rm3 } = await import("fs/promises");
1365
1760
  const { isAbsolute: isAbsolute2, relative: relative2, resolve: resolve2, sep: sep2 } = await import("path");
1366
1761
  signal?.throwIfAborted();
1367
1762
  if (typeof slug !== "string" || !/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9])?$/.test(slug)) {
@@ -1411,13 +1806,13 @@ async function extractThumbnails(options) {
1411
1806
  signal?.throwIfAborted();
1412
1807
  generatedPaths.push(outputPath);
1413
1808
  } catch (error) {
1414
- await rm2(outputPath, { force: true });
1809
+ await rm3(outputPath, { force: true });
1415
1810
  throw error;
1416
1811
  }
1417
1812
  }
1418
1813
  signal?.throwIfAborted();
1419
1814
  } catch (error) {
1420
- await Promise.allSettled(generatedPaths.map((path) => rm2(path, { force: true })));
1815
+ await Promise.allSettled(generatedPaths.map((path) => rm3(path, { force: true })));
1421
1816
  throw error;
1422
1817
  }
1423
1818
  }
@@ -1432,6 +1827,7 @@ export {
1432
1827
  convert,
1433
1828
  createCliRegistry,
1434
1829
  extractThumbnails,
1830
+ ffmpegPipeArgs,
1435
1831
  framesToGifNative,
1436
1832
  framesToGifNativeBytes,
1437
1833
  framesToMp4Native,