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