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