@bendyline/squisq-cli 2.0.0 → 2.0.1

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,20 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- GIF_EXPORT_DEFAULTS
4
- } from "./chunk-VM3ZFP3J.js";
3
+ GIF_EXPORT_DEFAULTS,
4
+ runFfmpeg
5
+ } from "./chunk-JDHONKFZ.js";
5
6
 
6
7
  // src/api.ts
7
8
  import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
8
9
  import { flattenBlocks } from "@bendyline/squisq/doc";
9
10
  import { ffmpegGifOutputArgs, generateRenderHtml } from "@bendyline/squisq-video";
10
11
  import { resolveDimensions } from "@bendyline/squisq-video";
11
- import { convert as formatsConvert } from "@bendyline/squisq-formats";
12
+ import {
13
+ convert as formatsConvert,
14
+ prepareConversion as formatsPrepareConversion
15
+ } from "@bendyline/squisq-formats";
12
16
 
13
17
  // src/util/detectFfmpeg.ts
14
18
  import { execFile } from "child_process";
15
- function run(command, args) {
16
- return new Promise((resolve) => {
17
- execFile(command, args, { timeout: 5e3 }, (err, stdout) => {
19
+ function run(command, args, signal) {
20
+ signal?.throwIfAborted();
21
+ return new Promise((resolve, reject) => {
22
+ execFile(command, args, { timeout: 5e3, signal }, (err, stdout) => {
23
+ if (signal?.aborted) {
24
+ reject(signal.reason);
25
+ return;
26
+ }
18
27
  if (err || !stdout.trim()) {
19
28
  resolve(null);
20
29
  return;
@@ -23,14 +32,15 @@ function run(command, args) {
23
32
  });
24
33
  });
25
34
  }
26
- async function getFfmpegVersion(path) {
27
- const out = await run(path, ["-version"]);
35
+ async function getFfmpegVersion(path, signal) {
36
+ const out = await run(path, ["-version"], signal);
28
37
  return out ? out.split("\n")[0].trim() : null;
29
38
  }
30
- async function detectFfmpegDetailed() {
39
+ async function detectFfmpegDetailed(signal) {
40
+ signal?.throwIfAborted();
31
41
  const envPath = process.env.SQUISQ_FFMPEG;
32
42
  if (envPath) {
33
- const version = await getFfmpegVersion(envPath);
43
+ const version = await getFfmpegVersion(envPath, signal);
34
44
  if (!version) {
35
45
  throw new Error(
36
46
  `SQUISQ_FFMPEG is set to "${envPath}" but running "${envPath} -version" failed. Fix the path or unset SQUISQ_FFMPEG.`
@@ -39,11 +49,12 @@ async function detectFfmpegDetailed() {
39
49
  return { path: envPath, source: "env" };
40
50
  }
41
51
  const command = process.platform === "win32" ? "where" : "which";
42
- const found = await run(command, ["ffmpeg"]);
52
+ const found = await run(command, ["ffmpeg"], signal);
43
53
  if (found) {
44
54
  return { path: found.split("\n")[0].trim(), source: "path" };
45
55
  }
46
56
  try {
57
+ signal?.throwIfAborted();
47
58
  const specifier = "ffmpeg-static";
48
59
  const mod = await import(specifier);
49
60
  const candidate = typeof mod === "string" ? mod : typeof mod.default === "string" ? mod.default : null;
@@ -51,46 +62,55 @@ async function detectFfmpegDetailed() {
51
62
  return { path: candidate, source: "ffmpeg-static" };
52
63
  }
53
64
  } catch {
65
+ signal?.throwIfAborted();
54
66
  }
67
+ signal?.throwIfAborted();
55
68
  return null;
56
69
  }
57
70
 
58
71
  // src/util/audioMix.ts
59
72
  import { computeAudioTimeline } from "@bendyline/squisq-video";
60
- async function buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll) {
73
+ async function buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll, signal) {
74
+ signal?.throwIfAborted();
61
75
  const timeline = computeAudioTimeline(doc, coverPreRoll);
62
76
  if (timeline.length === 0) return null;
63
77
  const bytesBySrc = /* @__PURE__ */ new Map();
64
78
  const readSrc = async (src) => {
79
+ signal?.throwIfAborted();
65
80
  if (bytesBySrc.has(src)) return bytesBySrc.get(src);
66
81
  const data = await container.readFile(src);
82
+ signal?.throwIfAborted();
67
83
  bytesBySrc.set(src, data ?? null);
68
84
  return data ?? null;
69
85
  };
70
86
  const usable = [];
71
87
  for (const clip of timeline) {
88
+ signal?.throwIfAborted();
72
89
  const buffer = await readSrc(clip.src);
73
90
  if (buffer) usable.push({ clip, buffer });
74
91
  }
75
92
  if (usable.length === 0) return null;
76
- return mixTimelineClips(ffmpegPath, usable);
93
+ return mixTimelineClips(ffmpegPath, usable, signal);
77
94
  }
78
- async function mixTimelineClips(ffmpegPath, usable) {
95
+ async function mixTimelineClips(ffmpegPath, usable, signal) {
79
96
  const { writeFile, readFile: readFile3, mkdir, rm: rm2 } = await import("fs/promises");
80
97
  const { join: join3 } = await import("path");
81
98
  const { tmpdir: tmpdir2 } = await import("os");
82
99
  const { randomBytes: randomBytes2 } = await import("crypto");
83
- const { execFile: execFile2 } = await import("child_process");
100
+ signal?.throwIfAborted();
84
101
  const workDir = join3(tmpdir2(), `squisq-audio-mix-${randomBytes2(8).toString("hex")}`);
85
102
  await mkdir(workDir, { recursive: true });
86
103
  const ms = (s) => Math.max(0, Math.round(s * 1e3));
87
104
  try {
105
+ signal?.throwIfAborted();
88
106
  const inputs = [];
89
107
  const filters = [];
90
108
  const labels = [];
91
109
  for (const { clip, buffer } of usable) {
110
+ signal?.throwIfAborted();
92
111
  const p = join3(workDir, `clip-${inputs.length}.mp3`);
93
112
  await writeFile(p, new Uint8Array(buffer));
113
+ signal?.throwIfAborted();
94
114
  const i = inputs.push(p) - 1;
95
115
  const delayMs = ms(clip.startSec);
96
116
  const start = Math.max(0, clip.sourceInSec);
@@ -115,19 +135,91 @@ async function mixTimelineClips(ffmpegPath, usable) {
115
135
  "192k",
116
136
  outputPath
117
137
  );
118
- await new Promise((resolve, reject) => {
119
- execFile2(ffmpegPath, args, { timeout: 18e4 }, (err) => {
120
- if (err) reject(new Error(`ffmpeg audio mix failed: ${err.message}`));
121
- else resolve();
122
- });
138
+ await runFfmpeg(ffmpegPath, args, {
139
+ timeoutMs: 18e4,
140
+ failureMessage: "ffmpeg audio mix failed",
141
+ signal
123
142
  });
143
+ signal?.throwIfAborted();
124
144
  const data = await readFile3(outputPath);
145
+ signal?.throwIfAborted();
125
146
  return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
126
147
  } finally {
127
148
  await rm2(workDir, { recursive: true, force: true });
128
149
  }
129
150
  }
130
151
 
152
+ // src/util/capturedFrameBudget.ts
153
+ var MAX_CAPTURED_FRAME_BYTES = 256 * 1024 * 1024;
154
+ var CapturedFrameBudgetError = class extends Error {
155
+ constructor(capturedBytes, attemptedFrameBytes, maximumBytes) {
156
+ super(
157
+ `Captured PNG frames would retain ${capturedBytes + attemptedFrameBytes} bytes, exceeding the ${maximumBytes}-byte rendered-media memory limit.`
158
+ );
159
+ this.capturedBytes = capturedBytes;
160
+ this.attemptedFrameBytes = attemptedFrameBytes;
161
+ this.maximumBytes = maximumBytes;
162
+ this.code = "captured-frame-budget-exceeded";
163
+ this.name = "CapturedFrameBudgetError";
164
+ }
165
+ };
166
+ var CapturedFrameCollector = class {
167
+ constructor(maximumBytes = MAX_CAPTURED_FRAME_BYTES) {
168
+ this.maximumBytes = maximumBytes;
169
+ this.values = [];
170
+ this.seen = /* @__PURE__ */ new WeakSet();
171
+ this.bytes = 0;
172
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1 || maximumBytes > MAX_CAPTURED_FRAME_BYTES) {
173
+ throw new Error(
174
+ `Captured-frame byte limit must be between 1 and ${MAX_CAPTURED_FRAME_BYTES}`
175
+ );
176
+ }
177
+ }
178
+ get frameCount() {
179
+ return this.values.length;
180
+ }
181
+ get retainedBytes() {
182
+ return this.bytes;
183
+ }
184
+ append(frame, repetitions = 1) {
185
+ if (!Number.isSafeInteger(repetitions) || repetitions < 1) {
186
+ throw new Error("Captured-frame repetition count must be a positive integer");
187
+ }
188
+ const additionalBytes = this.seen.has(frame) ? 0 : frame.byteLength;
189
+ if (this.bytes + additionalBytes > this.maximumBytes) {
190
+ const capturedBytes = this.bytes;
191
+ this.clear();
192
+ throw new CapturedFrameBudgetError(capturedBytes, additionalBytes, this.maximumBytes);
193
+ }
194
+ if (additionalBytes > 0) {
195
+ this.seen.add(frame);
196
+ this.bytes += additionalBytes;
197
+ }
198
+ for (let index = 0; index < repetitions; index += 1) this.values.push(frame);
199
+ }
200
+ /** Clear retained frame references before preserving the caller's abort reason. */
201
+ throwIfAborted(signal) {
202
+ if (!signal?.aborted) return;
203
+ this.clear();
204
+ signal.throwIfAborted();
205
+ }
206
+ /** Transfer ownership of the captured frames to the encoder. */
207
+ release() {
208
+ const released = this.values;
209
+ this.values = [];
210
+ this.seen = /* @__PURE__ */ new WeakSet();
211
+ this.bytes = 0;
212
+ return released;
213
+ }
214
+ /** Drop every retained reference so failed/cancelled captures can be collected promptly. */
215
+ clear() {
216
+ this.values.length = 0;
217
+ this.values = [];
218
+ this.seen = /* @__PURE__ */ new WeakSet();
219
+ this.bytes = 0;
220
+ }
221
+ };
222
+
131
223
  // src/util/coverPreRoll.ts
132
224
  function resolveAppliedCoverPreRoll(requestedSeconds, hasCover) {
133
225
  if (!Number.isFinite(requestedSeconds) || requestedSeconds < 0) {
@@ -167,6 +259,7 @@ function mp4Format() {
167
259
  mimeType: "video/mp4",
168
260
  extensions: [".mp4"],
169
261
  async exportDoc(input, options) {
262
+ options.signal?.throwIfAborted();
170
263
  const mp4Opts = options.formatOptions?.mp4 ?? {};
171
264
  const fps = typeof mp4Opts.fps === "number" ? mp4Opts.fps : MP4_DEFAULTS.fps;
172
265
  const quality = typeof mp4Opts.quality === "string" ? mp4Opts.quality : MP4_DEFAULTS.quality;
@@ -175,7 +268,7 @@ function mp4Format() {
175
268
  const animationsEnabled = typeof mp4Opts.animationsEnabled === "boolean" ? mp4Opts.animationsEnabled : MP4_DEFAULTS.animationsEnabled;
176
269
  const outputPath = join(tmpdir(), `squisq-mp4-${randomBytes(8).toString("hex")}.mp4`);
177
270
  try {
178
- const { renderDocToMp4: renderDocToMp42 } = await import("./api-6KN3F6IQ.js");
271
+ const { renderDocToMp4: renderDocToMp42 } = await import("./api-LE75OSRQ.js");
179
272
  await renderDocToMp42(input.doc, input.container, {
180
273
  outputPath,
181
274
  fps,
@@ -185,9 +278,13 @@ function mp4Format() {
185
278
  height: mp4Opts.height,
186
279
  captionStyle: mp4Opts.captionStyle,
187
280
  coverPreRoll,
188
- animationsEnabled
281
+ animationsEnabled,
282
+ signal: options.signal,
283
+ onProgress: mp4Opts.onProgress
189
284
  });
190
- const data = await readFile(outputPath);
285
+ options.signal?.throwIfAborted();
286
+ const data = await readFile(outputPath, { signal: options.signal });
287
+ options.signal?.throwIfAborted();
191
288
  const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
192
289
  return { bytes, mimeType: "video/mp4", suggestedFilename: "", warnings: [] };
193
290
  } finally {
@@ -203,6 +300,7 @@ function gifFormat() {
203
300
  mimeType: "image/gif",
204
301
  extensions: [".gif"],
205
302
  async exportDoc(input, options) {
303
+ options.signal?.throwIfAborted();
206
304
  const gifOpts = options.formatOptions?.gif ?? {};
207
305
  const orientation = typeof gifOpts.orientation === "string" ? gifOpts.orientation : GIF_DEFAULTS.orientation;
208
306
  const portrait = orientation === "portrait";
@@ -210,7 +308,7 @@ function gifFormat() {
210
308
  const defaultHeight = portrait ? GIF_DEFAULTS.width : GIF_DEFAULTS.height;
211
309
  const outputPath = join(tmpdir(), `squisq-gif-${randomBytes(8).toString("hex")}.gif`);
212
310
  try {
213
- const { renderDocToGif: renderDocToGif2 } = await import("./api-6KN3F6IQ.js");
311
+ const { renderDocToGif: renderDocToGif2 } = await import("./api-LE75OSRQ.js");
214
312
  const result = await renderDocToGif2(input.doc, input.container, {
215
313
  outputPath,
216
314
  fps: typeof gifOpts.fps === "number" ? gifOpts.fps : GIF_DEFAULTS.fps,
@@ -223,9 +321,13 @@ function gifFormat() {
223
321
  loop: typeof gifOpts.loop === "number" ? gifOpts.loop : GIF_DEFAULTS.loop,
224
322
  maxColors: typeof gifOpts.maxColors === "number" ? gifOpts.maxColors : GIF_DEFAULTS.maxColors,
225
323
  dither: gifOpts.dither ?? GIF_DEFAULTS.dither,
226
- bayerScale: gifOpts.bayerScale
324
+ bayerScale: gifOpts.bayerScale,
325
+ signal: options.signal,
326
+ onProgress: gifOpts.onProgress
227
327
  });
228
- const data = await readFile(outputPath);
328
+ options.signal?.throwIfAborted();
329
+ const data = await readFile(outputPath, { signal: options.signal });
330
+ options.signal?.throwIfAborted();
229
331
  const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
230
332
  return {
231
333
  bytes,
@@ -277,13 +379,16 @@ var IMPORTER_EXTS = [".docx", ".pptx", ".pdf", ".xlsx", ".csv", ".html", ".htm"]
277
379
  function mimeFromExt(filePath) {
278
380
  return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
279
381
  }
280
- async function walkDir(root, prefix = "") {
382
+ async function walkDir(root, prefix = "", signal) {
383
+ throwIfAborted(signal);
281
384
  const entries = await readdir(root, { withFileTypes: true });
385
+ throwIfAborted(signal);
282
386
  const paths = [];
283
387
  for (const entry of entries) {
388
+ throwIfAborted(signal);
284
389
  const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
285
390
  if (entry.isDirectory()) {
286
- paths.push(...await walkDir(join2(root, entry.name), relPath));
391
+ paths.push(...await walkDir(join2(root, entry.name), relPath, signal));
287
392
  } else if (entry.isFile()) {
288
393
  paths.push(relPath);
289
394
  }
@@ -291,21 +396,26 @@ async function walkDir(root, prefix = "") {
291
396
  return paths;
292
397
  }
293
398
  async function readInput(inputPath, options) {
399
+ throwIfAborted(options?.signal);
294
400
  const result = await readInputRaw(inputPath, options);
401
+ throwIfAborted(options?.signal);
295
402
  const doc = await resolveAudioMapping(result.doc, result.container);
403
+ throwIfAborted(options?.signal);
296
404
  return doc === result.doc ? result : { ...result, doc };
297
405
  }
298
406
  async function readInputRaw(inputPath, options) {
407
+ throwIfAborted(options?.signal);
299
408
  const info = await stat(inputPath);
409
+ throwIfAborted(options?.signal);
300
410
  if (info.isDirectory()) {
301
- return readFolder(inputPath);
411
+ return readFolder(inputPath, options?.signal);
302
412
  }
303
413
  const ext = extname(inputPath).toLowerCase();
304
414
  if (ext === ".zip" || ext === ".dbk") {
305
- return readContainer(inputPath);
415
+ return readContainer(inputPath, options?.signal);
306
416
  }
307
417
  if (ext === ".json") {
308
- return readDocJsonFile(inputPath);
418
+ return readDocJsonFile(inputPath, options?.signal);
309
419
  }
310
420
  if (IMPORTER_EXTS.includes(ext)) {
311
421
  const def = defaultRegistry2().byExtension(ext);
@@ -313,28 +423,54 @@ async function readInputRaw(inputPath, options) {
313
423
  return readViaImporter(inputPath, def, options);
314
424
  }
315
425
  }
316
- return readMarkdownFile(inputPath);
426
+ return readMarkdownFile(inputPath, options?.signal);
317
427
  }
318
- async function readArrayBuffer(filePath) {
319
- const data = await readFile2(filePath);
428
+ async function readArrayBuffer(filePath, signal) {
429
+ const data = await readBinaryFile(filePath, signal);
320
430
  return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
321
431
  }
322
- async function readMarkdownFile(filePath) {
323
- const content = await readFile2(filePath, "utf-8");
432
+ async function readBinaryFile(filePath, signal) {
433
+ throwIfAborted(signal);
434
+ try {
435
+ const data = await readFile2(filePath, { signal });
436
+ throwIfAborted(signal);
437
+ return data;
438
+ } catch (error) {
439
+ throwIfAborted(signal);
440
+ throw error;
441
+ }
442
+ }
443
+ async function readUtf8File(filePath, signal) {
444
+ throwIfAborted(signal);
445
+ try {
446
+ const content = await readFile2(filePath, { encoding: "utf-8", signal });
447
+ throwIfAborted(signal);
448
+ return content;
449
+ } catch (error) {
450
+ throwIfAborted(signal);
451
+ throw error;
452
+ }
453
+ }
454
+ async function readMarkdownFile(filePath, signal) {
455
+ const content = await readUtf8File(filePath, signal);
324
456
  const container = new MemoryContentContainer();
325
457
  await container.writeDocument(content);
458
+ throwIfAborted(signal);
326
459
  const markdownDoc = parseMarkdown(content);
327
460
  return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: "md" };
328
461
  }
329
- async function readDocJsonFile(filePath) {
330
- const content = await readFile2(filePath, "utf-8");
462
+ async function readDocJsonFile(filePath, signal) {
463
+ const content = await readUtf8File(filePath, signal);
331
464
  const doc = JSON.parse(content);
332
465
  const container = new MemoryContentContainer();
333
466
  return { doc, container, sourceFormat: "json" };
334
467
  }
335
468
  async function readViaImporter(filePath, def, options) {
336
- const buffer = await readArrayBuffer(filePath);
469
+ const signal = options?.signal;
470
+ throwIfAborted(signal);
471
+ const buffer = await readArrayBuffer(filePath, signal);
337
472
  const convertOptions = {
473
+ signal,
338
474
  formatOptions: {
339
475
  [def.id]: {
340
476
  inferTheme: options?.inferTheme !== false,
@@ -345,59 +481,85 @@ async function readViaImporter(filePath, def, options) {
345
481
  let container;
346
482
  let markdownDoc;
347
483
  if (def.importContainer) {
484
+ throwIfAborted(signal);
348
485
  container = await def.importContainer(buffer, convertOptions);
486
+ throwIfAborted(signal);
349
487
  const text = await container.readDocument();
488
+ throwIfAborted(signal);
350
489
  markdownDoc = text ? parseMarkdown(text) : { type: "document", children: [] };
351
490
  } else {
491
+ throwIfAborted(signal);
352
492
  markdownDoc = await def.importDoc(buffer, convertOptions);
493
+ throwIfAborted(signal);
353
494
  const mem = new MemoryContentContainer();
354
495
  await mem.writeDocument(stringifyMarkdown(markdownDoc));
496
+ throwIfAborted(signal);
355
497
  container = mem;
356
498
  }
357
499
  return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: def.id };
358
500
  }
359
501
  var DOC_JSON_NAMES = ["doc.json", "story.json"];
360
- async function resolveContainer(container, sourceFormat, missingMessage) {
502
+ async function resolveContainer(container, sourceFormat, missingMessage, signal) {
361
503
  for (const name of DOC_JSON_NAMES) {
504
+ throwIfAborted(signal);
362
505
  const jsonData = await container.readFile(name);
506
+ throwIfAborted(signal);
363
507
  if (jsonData) {
364
508
  const doc = JSON.parse(new TextDecoder().decode(jsonData));
365
509
  return { doc, container, sourceFormat };
366
510
  }
367
511
  }
512
+ throwIfAborted(signal);
368
513
  const markdown = await container.readDocument();
514
+ throwIfAborted(signal);
369
515
  if (!markdown) {
370
516
  throw new Error(missingMessage);
371
517
  }
372
518
  const markdownDoc = parseMarkdown(markdown);
373
519
  return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat };
374
520
  }
375
- async function readContainer(filePath) {
376
- const container = await zipToContainer(await readArrayBuffer(filePath));
521
+ async function readContainer(filePath, signal) {
522
+ throwIfAborted(signal);
523
+ const container = await zipToContainer(await readArrayBuffer(filePath, signal));
524
+ throwIfAborted(signal);
377
525
  return resolveContainer(
378
526
  container,
379
527
  "dbk",
380
- `No markdown document or doc.json found in container: ${filePath}`
528
+ `No markdown document or doc.json found in container: ${filePath}`,
529
+ signal
381
530
  );
382
531
  }
383
- async function readFolder(dirPath) {
532
+ async function readFolder(dirPath, signal) {
533
+ throwIfAborted(signal);
384
534
  const container = new MemoryContentContainer();
385
- const files = await walkDir(dirPath);
535
+ const files = await walkDir(dirPath, "", signal);
536
+ throwIfAborted(signal);
386
537
  for (const relPath of files) {
538
+ throwIfAborted(signal);
387
539
  const absPath = join2(dirPath, relPath);
388
- const data = await readFile2(absPath);
540
+ const data = await readBinaryFile(absPath, signal);
541
+ throwIfAborted(signal);
389
542
  await container.writeFile(
390
543
  relPath,
391
544
  new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
392
545
  mimeFromExt(relPath)
393
546
  );
547
+ throwIfAborted(signal);
394
548
  }
395
549
  return resolveContainer(
396
550
  container,
397
551
  "folder",
398
- `No markdown document or doc.json found in folder: ${dirPath}`
552
+ `No markdown document or doc.json found in folder: ${dirPath}`,
553
+ signal
399
554
  );
400
555
  }
556
+ function throwIfAborted(signal) {
557
+ if (!signal?.aborted) return;
558
+ if (signal.reason !== void 0) throw signal.reason;
559
+ const error = new Error("Input reading was cancelled");
560
+ error.name = "AbortError";
561
+ throw error;
562
+ }
401
563
 
402
564
  // src/api.ts
403
565
  import { ConversionError } from "@bendyline/squisq-formats";
@@ -408,25 +570,40 @@ async function convert(source, to, options = {}) {
408
570
  ...options
409
571
  });
410
572
  }
573
+ async function prepareConversion(source, options = {}) {
574
+ return formatsPrepareConversion(source, {
575
+ registry: createCliRegistry(),
576
+ resolvePlayerScript: () => import("@bendyline/squisq-react/standalone-source").then((m) => m.PLAYER_BUNDLE),
577
+ ...options
578
+ });
579
+ }
411
580
  async function captureDocFrames(doc, container, options) {
412
- const { fps, width, height, captionStyle, coverPreRoll, animationsEnabled, onProgress } = options;
581
+ const { fps, width, height, captionStyle, coverPreRoll, animationsEnabled, onProgress, signal } = options;
582
+ signal?.throwIfAborted();
413
583
  resolveAppliedCoverPreRoll(coverPreRoll, true);
414
- const ffmpegPath = (await detectFfmpegDetailed())?.path ?? null;
584
+ const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
585
+ signal?.throwIfAborted();
415
586
  if (!ffmpegPath) {
416
587
  throw new Error(
417
588
  "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."
418
589
  );
419
590
  }
420
591
  onProgress?.("collecting media", 0);
592
+ signal?.throwIfAborted();
421
593
  const { collectImagePaths } = await import("@bendyline/squisq-formats/html");
594
+ signal?.throwIfAborted();
422
595
  const images = /* @__PURE__ */ new Map();
423
596
  for (const imgPath of collectImagePaths(doc)) {
597
+ signal?.throwIfAborted();
424
598
  const data = await container.readFile(imgPath);
599
+ signal?.throwIfAborted();
425
600
  if (data) images.set(imgPath, data);
426
601
  }
427
602
  const audio = /* @__PURE__ */ new Map();
428
603
  for (const seg of doc.audio?.segments ?? []) {
604
+ signal?.throwIfAborted();
429
605
  const data = await container.readFile(seg.src);
606
+ signal?.throwIfAborted();
430
607
  if (data) {
431
608
  audio.set(seg.src, data);
432
609
  audio.set(seg.name, data);
@@ -439,12 +616,16 @@ async function captureDocFrames(doc, container, options) {
439
616
  }
440
617
  }
441
618
  for (const src of mediaSrcs) {
619
+ signal?.throwIfAborted();
442
620
  if (images.has(src)) continue;
443
621
  const data = await container.readFile(src);
622
+ signal?.throwIfAborted();
444
623
  if (data) images.set(src, data);
445
624
  }
446
625
  onProgress?.("generating render HTML", 10);
626
+ signal?.throwIfAborted();
447
627
  const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
628
+ signal?.throwIfAborted();
448
629
  const renderHtml = generateRenderHtml(doc, {
449
630
  playerScript: PLAYER_BUNDLE,
450
631
  images,
@@ -455,24 +636,39 @@ async function captureDocFrames(doc, container, options) {
455
636
  animationsEnabled
456
637
  });
457
638
  onProgress?.("launching browser", 15);
639
+ signal?.throwIfAborted();
458
640
  const { chromium } = await import("playwright-core");
641
+ signal?.throwIfAborted();
459
642
  let browser;
460
643
  try {
461
644
  browser = await chromium.launch({ headless: true });
462
645
  } catch (err) {
646
+ signal?.throwIfAborted();
463
647
  const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
464
648
  throw new Error(
465
649
  `Playwright Chromium is not installed. Run: npx playwright install chromium
466
650
  (launch failed: ${detail})`
467
651
  );
468
652
  }
469
- const page = await browser.newPage({ viewport: { width, height } });
470
- const pageErrors = [];
471
- page.on("pageerror", (err) => pageErrors.push(err.message));
653
+ const handleAbort = () => {
654
+ void browser.close().catch(() => void 0);
655
+ };
656
+ if (signal?.aborted) {
657
+ await browser.close().catch(() => void 0);
658
+ signal.throwIfAborted();
659
+ }
660
+ signal?.addEventListener("abort", handleAbort, { once: true });
472
661
  let renderAPI = null;
662
+ const capturedFrames = new CapturedFrameCollector();
473
663
  try {
664
+ const page = await browser.newPage({ viewport: { width, height } });
665
+ const pageErrors = [];
666
+ page.on("pageerror", (err) => pageErrors.push(err.message));
667
+ signal?.throwIfAborted();
474
668
  await page.setContent(renderHtml, { waitUntil: "load" });
669
+ signal?.throwIfAborted();
475
670
  await page.waitForTimeout(500);
671
+ signal?.throwIfAborted();
476
672
  try {
477
673
  await page.waitForFunction(
478
674
  () => {
@@ -483,6 +679,7 @@ async function captureDocFrames(doc, container, options) {
483
679
  { timeout: 15e3 }
484
680
  );
485
681
  } catch {
682
+ signal?.throwIfAborted();
486
683
  const errorDetail = pageErrors.length ? `
487
684
  Page errors:
488
685
  ${pageErrors.join("\n ")}` : "\nNo page errors captured \u2014 the player may have failed to mount.";
@@ -497,43 +694,61 @@ Page errors:
497
694
  if (!api) throw new Error("Squisq render API disappeared after initialization.");
498
695
  return api;
499
696
  });
697
+ signal?.throwIfAborted();
500
698
  const docDuration = await renderAPI.evaluate((api) => api.getDuration());
699
+ signal?.throwIfAborted();
501
700
  if (docDuration <= 0) throw new Error("Document has zero duration \u2014 nothing to render");
502
701
  const hasCover = coverPreRoll > 0 ? await renderAPI.evaluate((api) => api.hasCoverBlock()) : false;
503
702
  const appliedCoverPreRoll = resolveAppliedCoverPreRoll(coverPreRoll, hasCover);
504
703
  const storyFrameCount = Math.ceil(docDuration * fps);
505
704
  const preRollFrameCount = Math.ceil(appliedCoverPreRoll * fps);
506
705
  const totalFrames = preRollFrameCount + storyFrameCount;
507
- const frames = [];
508
706
  onProgress?.("capturing frames", 20);
707
+ capturedFrames.throwIfAborted(signal);
509
708
  if (preRollFrameCount > 0) {
709
+ capturedFrames.throwIfAborted(signal);
510
710
  await renderAPI.evaluate((api) => api.showCover());
511
711
  await page.waitForTimeout(100);
512
- const coverFrame = new Uint8Array(await page.screenshot({ type: "png" }));
513
- for (let i = 0; i < preRollFrameCount; i++) frames.push(coverFrame);
712
+ capturedFrames.throwIfAborted(signal);
713
+ const coverFrame = await page.screenshot({ type: "png" });
714
+ capturedFrames.throwIfAborted(signal);
715
+ capturedFrames.append(coverFrame, preRollFrameCount);
514
716
  await renderAPI.evaluate((api) => api.hideCover());
515
717
  }
516
718
  const frameInterval = 1 / fps;
517
719
  for (let i = 0; i < storyFrameCount; i++) {
720
+ capturedFrames.throwIfAborted(signal);
518
721
  const time = i * frameInterval;
519
722
  await renderAPI.evaluate((api, t) => api.seekTo(t), time);
520
- frames.push(new Uint8Array(await page.screenshot({ type: "png" })));
723
+ const frame = await page.screenshot({ type: "png" });
724
+ capturedFrames.throwIfAborted(signal);
725
+ capturedFrames.append(frame);
521
726
  if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {
522
- onProgress?.("capturing frames", 20 + Math.round(frames.length / totalFrames * 60));
727
+ onProgress?.(
728
+ "capturing frames",
729
+ 20 + Math.round(capturedFrames.frameCount / totalFrames * 60)
730
+ );
731
+ capturedFrames.throwIfAborted(signal);
523
732
  }
524
733
  }
525
734
  return {
526
- frames,
735
+ frames: capturedFrames.release(),
527
736
  totalDuration: docDuration + appliedCoverPreRoll,
528
737
  appliedCoverPreRoll,
529
738
  ffmpegPath
530
739
  };
740
+ } catch (err) {
741
+ capturedFrames.clear();
742
+ signal?.throwIfAborted();
743
+ throw err;
531
744
  } finally {
532
- await renderAPI?.dispose();
533
- await browser.close();
745
+ signal?.removeEventListener("abort", handleAbort);
746
+ await renderAPI?.dispose().catch(() => void 0);
747
+ await browser.close().catch(() => void 0);
534
748
  }
535
749
  }
536
750
  async function renderDocToMp4(doc, container, options) {
751
+ options.signal?.throwIfAborted();
537
752
  const fps = options.fps ?? 30;
538
753
  const quality = options.quality ?? "normal";
539
754
  const orientation = options.orientation ?? "landscape";
@@ -551,32 +766,45 @@ async function renderDocToMp4(doc, container, options) {
551
766
  captionStyle: options.captionStyle,
552
767
  coverPreRoll: options.coverPreRoll ?? 0,
553
768
  animationsEnabled: options.animationsEnabled ?? true,
554
- onProgress: options.onProgress
769
+ onProgress: options.onProgress,
770
+ signal: options.signal
555
771
  });
556
- options.onProgress?.("encoding video", 80);
557
- const encodingAudio = await buildMixedAudioTrack(
558
- doc,
559
- container,
560
- capture.ffmpegPath,
561
- capture.appliedCoverPreRoll
562
- );
563
- const { framesToMp4Native: framesToMp4Native2 } = await import("./nativeEncoder-WHUIQZLL.js");
564
- await framesToMp4Native2(capture.ffmpegPath, capture.frames, encodingAudio, options.outputPath, {
565
- fps,
566
- quality,
567
- orientation,
568
- width: dimensions.width,
569
- height: dimensions.height,
570
- onProgress: (percent, phase) => options.onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2))
571
- });
572
- options.onProgress?.("done", 100);
573
- return {
574
- duration: capture.totalDuration,
575
- frameCount: capture.frames.length,
576
- outputPath: options.outputPath
577
- };
772
+ const frameCount = capture.frames.length;
773
+ try {
774
+ options.onProgress?.("encoding video", 80);
775
+ options.signal?.throwIfAborted();
776
+ const encodingAudio = await buildMixedAudioTrack(
777
+ doc,
778
+ container,
779
+ capture.ffmpegPath,
780
+ capture.appliedCoverPreRoll,
781
+ options.signal
782
+ );
783
+ options.signal?.throwIfAborted();
784
+ const { framesToMp4Native: framesToMp4Native2 } = await import("./nativeEncoder-LRPK2YV5.js");
785
+ await framesToMp4Native2(capture.ffmpegPath, capture.frames, encodingAudio, options.outputPath, {
786
+ fps,
787
+ quality,
788
+ orientation,
789
+ width: dimensions.width,
790
+ height: dimensions.height,
791
+ signal: options.signal,
792
+ onProgress: (percent, phase) => options.onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2))
793
+ });
794
+ options.signal?.throwIfAborted();
795
+ options.onProgress?.("done", 100);
796
+ options.signal?.throwIfAborted();
797
+ return {
798
+ duration: capture.totalDuration,
799
+ frameCount,
800
+ outputPath: options.outputPath
801
+ };
802
+ } finally {
803
+ capture.frames.length = 0;
804
+ }
578
805
  }
579
806
  async function renderDocToGif(doc, container, options) {
807
+ options.signal?.throwIfAborted();
580
808
  const fps = options.fps ?? GIF_EXPORT_DEFAULTS.fps;
581
809
  if (!Number.isFinite(fps) || fps <= 0 || fps > 100) {
582
810
  throw new RangeError("GIF FPS must be a finite number between 1 and 100.");
@@ -601,55 +829,79 @@ async function renderDocToGif(doc, container, options) {
601
829
  captionStyle: options.captionStyle,
602
830
  coverPreRoll: options.coverPreRoll ?? 0,
603
831
  animationsEnabled: options.animationsEnabled ?? false,
604
- onProgress: options.onProgress
832
+ onProgress: options.onProgress,
833
+ signal: options.signal
605
834
  });
606
- options.onProgress?.("encoding GIF", 80);
607
- const { framesToGifNative: framesToGifNative2 } = await import("./nativeEncoder-WHUIQZLL.js");
608
- await framesToGifNative2(capture.ffmpegPath, capture.frames, options.outputPath, {
609
- fps,
610
- orientation,
611
- width,
612
- height,
613
- loop: options.loop,
614
- maxColors: options.maxColors,
615
- dither: options.dither,
616
- bayerScale: options.bayerScale,
617
- onProgress: (percent, phase) => options.onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2))
618
- });
619
- options.onProgress?.("done", 100);
620
- const hasAudio = (doc.audio?.segments?.length ?? 0) > 0 || resolveMediaSchedule(doc).some((clip) => clip.kind === "audio");
621
- return {
622
- duration: capture.totalDuration,
623
- frameCount: capture.frames.length,
624
- outputPath: options.outputPath,
625
- warnings: hasAudio ? ["Animated GIF does not support audio; audio tracks were omitted."] : []
626
- };
835
+ const frameCount = capture.frames.length;
836
+ try {
837
+ options.onProgress?.("encoding GIF", 80);
838
+ options.signal?.throwIfAborted();
839
+ const { framesToGifNative: framesToGifNative2 } = await import("./nativeEncoder-LRPK2YV5.js");
840
+ await framesToGifNative2(capture.ffmpegPath, capture.frames, options.outputPath, {
841
+ fps,
842
+ orientation,
843
+ width,
844
+ height,
845
+ loop: options.loop,
846
+ maxColors: options.maxColors,
847
+ dither: options.dither,
848
+ bayerScale: options.bayerScale,
849
+ signal: options.signal,
850
+ onProgress: (percent, phase) => options.onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2))
851
+ });
852
+ options.signal?.throwIfAborted();
853
+ options.onProgress?.("done", 100);
854
+ options.signal?.throwIfAborted();
855
+ const hasAudio = (doc.audio?.segments?.length ?? 0) > 0 || resolveMediaSchedule(doc).some((clip) => clip.kind === "audio");
856
+ return {
857
+ duration: capture.totalDuration,
858
+ frameCount,
859
+ outputPath: options.outputPath,
860
+ warnings: hasAudio ? ["Animated GIF does not support audio; audio tracks were omitted."] : []
861
+ };
862
+ } finally {
863
+ capture.frames.length = 0;
864
+ }
627
865
  }
628
866
  async function extractThumbnails(options) {
629
- const { videoPath, outputDir, slug, sizes, force } = options;
867
+ const { videoPath, outputDir, slug, sizes, force, signal } = options;
630
868
  const { existsSync } = await import("fs");
869
+ const { rm: rm2 } = await import("fs/promises");
631
870
  const { join: join3 } = await import("path");
632
- const { execFile: execFile2 } = await import("child_process");
633
- const ffmpegPath = (await detectFfmpegDetailed())?.path ?? null;
871
+ signal?.throwIfAborted();
872
+ const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
634
873
  if (!ffmpegPath) {
635
874
  throw new Error(
636
875
  "ffmpeg is required for thumbnail extraction but not found in PATH.\nInstall it with:\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg\n Windows: winget install ffmpeg"
637
876
  );
638
877
  }
639
- for (const thumb of sizes) {
640
- const outputPath = join3(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
641
- if (!force && existsSync(outputPath)) continue;
642
- await new Promise((resolve, reject) => {
643
- execFile2(
644
- ffmpegPath,
645
- ["-y", "-i", videoPath, "-vf", thumb.filter, "-frames:v", "1", "-q:v", "2", outputPath],
646
- { timeout: 3e4 },
647
- (err) => {
648
- if (err) reject(new Error(`Thumbnail extraction failed (${thumb.name}): ${err.message}`));
649
- else resolve();
650
- }
651
- );
652
- });
878
+ const generatedPaths = [];
879
+ try {
880
+ for (const thumb of sizes) {
881
+ signal?.throwIfAborted();
882
+ const outputPath = join3(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
883
+ if (!force && existsSync(outputPath)) continue;
884
+ try {
885
+ await runFfmpeg(
886
+ ffmpegPath,
887
+ ["-y", "-i", videoPath, "-vf", thumb.filter, "-frames:v", "1", "-q:v", "2", outputPath],
888
+ {
889
+ timeoutMs: 3e4,
890
+ failureMessage: `Thumbnail extraction failed (${thumb.name})`,
891
+ signal
892
+ }
893
+ );
894
+ signal?.throwIfAborted();
895
+ generatedPaths.push(outputPath);
896
+ } catch (error) {
897
+ await rm2(outputPath, { force: true });
898
+ throw error;
899
+ }
900
+ }
901
+ signal?.throwIfAborted();
902
+ } catch (error) {
903
+ await Promise.allSettled(generatedPaths.map((path) => rm2(path, { force: true })));
904
+ throw error;
653
905
  }
654
906
  }
655
907
 
@@ -657,7 +909,10 @@ export {
657
909
  readInput,
658
910
  getFfmpegVersion,
659
911
  detectFfmpegDetailed,
912
+ MAX_CAPTURED_FRAME_BYTES,
913
+ CapturedFrameBudgetError,
660
914
  convert,
915
+ prepareConversion,
661
916
  renderDocToMp4,
662
917
  renderDocToGif,
663
918
  extractThumbnails,
@@ -665,4 +920,4 @@ export {
665
920
  ConversionError,
666
921
  createCliRegistry
667
922
  };
668
- //# sourceMappingURL=chunk-JEE26SZS.js.map
923
+ //# sourceMappingURL=chunk-FVFK4ZJU.js.map