@bendyline/squisq-cli 2.1.0 → 2.3.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bendyline LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE.md ADDED
@@ -0,0 +1,15 @@
1
+ # Third-Party Notices for @bendyline/squisq-cli
2
+
3
+ This notice applies to the `@bendyline/squisq-cli` npm package.
4
+ Squisq-authored code is licensed under the MIT license in `LICENSE`.
5
+ Third-party components remain under their respective license terms.
6
+
7
+ ## Runtime dependencies
8
+
9
+ | Package | Version | License | Repository |
10
+ | --------------- | ------- | ---------- | --------------------------------------- |
11
+ | commander | 12.1.0 | MIT | https://github.com/tj/commander.js |
12
+ | playwright-core | 1.58.2 | Apache-2.0 | https://github.com/microsoft/playwright |
13
+
14
+ Copyright and complete license texts for these dependencies are included in
15
+ their respective npm distributions and source repositories.
package/README.md CHANGED
@@ -40,6 +40,7 @@ squisq convert input.md --theme cinematic --transform magazine --formats pptx
40
40
  | `-o, --output <file>` | **Single** output file; format inferred from its extension (cannot combine with `--formats`) | — |
41
41
  | `-d, --output-dir` | Output directory (multi-format mode) | same as input |
42
42
  | `-f, --formats` | Comma-separated: `docx`, `pptx`, `pdf`, `html`, `htmlzip`, `epub`, `dbk`, `md`, `xlsx`, `csv`, `mp4`, `gif` | default set |
43
+ | `--overwrite` | Replace existing output files (otherwise the run refuses and exits non-zero) | off |
43
44
  | `-t, --theme` | Squisq theme id — built-in or a custom theme inlined in the doc's frontmatter | none |
44
45
  | `--transform` | Transform style applied before export (e.g. `documentary`, `magazine`, `minimal`) | none |
45
46
  | `--no-auto-templates` | Disable content-aware template auto-picking for unannotated headings | (auto on) |
@@ -48,6 +49,8 @@ squisq convert input.md --theme cinematic --transform magazine --formats pptx
48
49
 
49
50
  Notes:
50
51
 
52
+ - **Existing files are never overwritten by default.** A bare `convert report.md` writes several files next to the input; if any already exists the run refuses up front (before converting anything) and exits 1. Pass `--overwrite` to replace them. The flag is `--overwrite`, not `-f`, because `-f` is `--formats`. There is no prompt — this stays scriptable.
53
+ - **An unknown `--formats` id is a hard error.** `-f docx,pfd` fails with `Unknown format: "pfd" (did you mean "pdf"?)` and produces nothing, rather than warning and exiting 0 with the PDF silently missing.
51
54
  - `html` produces a single self-contained file with the standalone player inlined (static mode); `htmlzip` produces a `<name>.html.zip` archive with external assets and optional audio.
52
55
  - `epub` embeds images from the input container and, when the doc has narration segments, generates EPUB 3 Media Overlays.
53
56
  - `xlsx` export is tables-only; `csv` export emits the first table; `mp4` and `gif` require the render toolchain (see Install).
@@ -83,9 +86,16 @@ squisq video doc.json -o out.mp4
83
86
  | `-t, --theme` | Squisq theme id to apply | none |
84
87
  | `--transform` | Transform style to apply before rendering | none |
85
88
  | `--cover-preroll` | Seconds of cover-slide pre-roll before the story starts | 2 |
86
- | `--width` / `--height` | Dimension overrides in pixels | MP4 1080p; GIF 960×540 |
89
+ | `--width` / `--height` | Dimension overrides in pixels — **must be even** | MP4 1080p; GIF 960×540 |
90
+ | `--overwrite` | Replace an existing output file (otherwise refuse and exit non-zero) | off |
87
91
  | `--no-auto-templates` | Disable content-aware template auto-picking for unannotated headings | (auto on) |
88
92
 
93
+ Notes:
94
+
95
+ - **Dimensions must be even.** `--width 851` is rejected immediately — before the document is read or a browser launches — with `Video width must be an even number of pixels (got 851) … Use 850 or 852.` Odd values are rejected rather than rounded, so a render never silently ships at a size you did not ask for. The rule is the same for MP4 and GIF, and matches browser export.
96
+ - **An existing output file is never overwritten by default.** The check runs before rendering, so a colliding path costs you a second rather than a full capture. Pass `--overwrite` to replace.
97
+ - FFmpeg failures report the one relevant line (e.g. `[libx264] width not divisible by 2 (851x480) (exit code 1)`) rather than dumping the whole command line and stderr buffer.
98
+
89
99
  ### `squisq doctor`
90
100
 
91
101
  Preflight the video toolchain: reports the resolved ffmpeg path, version, and which source it came from (`SQUISQ_FFMPEG` env / `PATH` / `ffmpeg-static`) with an install hint when missing, attempts a headless Chromium launch, and reports the Node version.
@@ -238,7 +248,7 @@ await extractThumbnails({
238
248
  - `MemoryContentContainer` (re-export from `@bendyline/squisq/storage`)
239
249
  - `VideoQuality`, `VideoOrientation` types (re-exports from `@bendyline/squisq-video`)
240
250
 
241
- See the full [API Reference](../../docs/API.md#bendylinesquisq-cli) for all types and options.
251
+ See the full [API Reference](https://github.com/bendyline/squisq/blob/main/docs/API.md#bendylinesquisq-cli) for all types and options.
242
252
 
243
253
  ## Related Packages
244
254
 
@@ -11,14 +11,14 @@ import {
11
11
  readInput,
12
12
  renderDocToGif,
13
13
  renderDocToMp4
14
- } from "./chunk-FVFK4ZJU.js";
14
+ } from "./chunk-GYHQHKO5.js";
15
15
  import {
16
16
  GIF_EXPORT_DEFAULTS,
17
17
  framesToGifNative,
18
18
  framesToGifNativeBytes,
19
19
  framesToMp4Native,
20
20
  framesToMp4NativeBytes
21
- } from "./chunk-JDHONKFZ.js";
21
+ } from "./chunk-GGVASRPG.js";
22
22
  export {
23
23
  CapturedFrameBudgetError,
24
24
  ConversionError,
@@ -37,4 +37,3 @@ export {
37
37
  renderDocToGif,
38
38
  renderDocToMp4
39
39
  };
40
- //# sourceMappingURL=api-LE75OSRQ.js.map
package/dist/api.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  framesToMp4Native,
6
6
  framesToMp4NativeBytes,
7
7
  runFfmpeg
8
- } from "./chunk-OEN2RADG.js";
8
+ } from "./chunk-Z3BHD4AF.js";
9
9
 
10
10
  // src/api.ts
11
11
  import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
@@ -231,6 +231,45 @@ function resolveAppliedCoverPreRoll(requestedSeconds, hasCover) {
231
231
  return hasCover ? requestedSeconds : 0;
232
232
  }
233
233
 
234
+ // src/util/mediaBudget.ts
235
+ var MAX_RENDER_MEDIA_FILE_BYTES = 64 * 1024 * 1024;
236
+ var MAX_RENDER_MEDIA_TOTAL_BYTES = 256 * 1024 * 1024;
237
+ var MAX_RENDER_MEDIA_FILES = 256;
238
+ function mib(bytes) {
239
+ return `${Math.round(bytes / (1024 * 1024))} MB`;
240
+ }
241
+ function createMediaBudget() {
242
+ let totalBytes = 0;
243
+ let fileCount = 0;
244
+ return {
245
+ get totalBytes() {
246
+ return totalBytes;
247
+ },
248
+ get fileCount() {
249
+ return fileCount;
250
+ },
251
+ admit(path, data) {
252
+ if (data.byteLength > MAX_RENDER_MEDIA_FILE_BYTES) {
253
+ throw new Error(
254
+ `Media file "${path}" is ${mib(data.byteLength)}, which exceeds the ${mib(MAX_RENDER_MEDIA_FILE_BYTES)} per-file limit for video rendering. Re-encode or shorten the asset.`
255
+ );
256
+ }
257
+ if (fileCount + 1 > MAX_RENDER_MEDIA_FILES) {
258
+ throw new Error(
259
+ `Document references more than ${MAX_RENDER_MEDIA_FILES} media files, which exceeds the limit for video rendering (adding "${path}").`
260
+ );
261
+ }
262
+ if (totalBytes + data.byteLength > MAX_RENDER_MEDIA_TOTAL_BYTES) {
263
+ throw new Error(
264
+ `Total embedded media exceeds the ${mib(MAX_RENDER_MEDIA_TOTAL_BYTES)} limit for video rendering (adding "${path}" would reach ${mib(totalBytes + data.byteLength)}). Reduce the document's media.`
265
+ );
266
+ }
267
+ totalBytes += data.byteLength;
268
+ fileCount += 1;
269
+ }
270
+ };
271
+ }
272
+
234
273
  // src/registry.ts
235
274
  import { randomBytes } from "crypto";
236
275
  import { readFile, rm } from "fs/promises";
@@ -462,9 +501,67 @@ async function readMarkdownFile(filePath, signal) {
462
501
  const markdownDoc = parseMarkdown(content);
463
502
  return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: "md" };
464
503
  }
504
+ function parseDocJson(content, source) {
505
+ let parsed;
506
+ try {
507
+ parsed = JSON.parse(content);
508
+ } catch (error) {
509
+ const detail = error instanceof Error ? error.message : String(error);
510
+ throw new Error(`${source} is not valid JSON: ${detail}`);
511
+ }
512
+ const fail = (detail) => {
513
+ throw new Error(`${source} is not a valid squisq Doc: ${detail}`);
514
+ };
515
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
516
+ fail(`expected a JSON object, got ${Array.isArray(parsed) ? "an array" : typeof parsed}`);
517
+ }
518
+ const doc = parsed;
519
+ if (!Array.isArray(doc.blocks)) {
520
+ fail(`"blocks" must be an array${doc.blocks === void 0 ? " (field is missing)" : ""}`);
521
+ }
522
+ for (const [index, block] of doc.blocks.entries()) {
523
+ if (typeof block !== "object" || block === null || Array.isArray(block)) {
524
+ fail(`"blocks[${index}]" must be an object`);
525
+ }
526
+ }
527
+ if (doc.duration !== void 0 && !isFiniteNumber(doc.duration)) {
528
+ fail(`"duration" must be a finite number, got ${describe(doc.duration)}`);
529
+ }
530
+ if (doc.audio !== void 0) {
531
+ if (typeof doc.audio !== "object" || doc.audio === null || Array.isArray(doc.audio)) {
532
+ fail('"audio" must be an object');
533
+ }
534
+ const segments = doc.audio.segments;
535
+ if (segments !== void 0) {
536
+ if (!Array.isArray(segments)) fail('"audio.segments" must be an array');
537
+ for (const [index, segment] of segments.entries()) {
538
+ if (typeof segment !== "object" || segment === null) {
539
+ fail(`"audio.segments[${index}]" must be an object`);
540
+ }
541
+ if (!isFiniteNumber(segment.duration)) {
542
+ fail(
543
+ `"audio.segments[${index}].duration" must be a finite number, got ${describe(segment.duration)}`
544
+ );
545
+ }
546
+ }
547
+ }
548
+ }
549
+ return {
550
+ ...doc,
551
+ audio: doc.audio ?? { segments: [] }
552
+ };
553
+ }
554
+ function isFiniteNumber(value) {
555
+ return typeof value === "number" && Number.isFinite(value);
556
+ }
557
+ function describe(value) {
558
+ if (typeof value === "number") return Number.isNaN(value) ? "NaN" : String(value);
559
+ if (typeof value === "string") return JSON.stringify(value);
560
+ return value === null ? "null" : typeof value;
561
+ }
465
562
  async function readDocJsonFile(filePath, signal) {
466
563
  const content = await readUtf8File(filePath, signal);
467
- const doc = JSON.parse(content);
564
+ const doc = parseDocJson(content, filePath);
468
565
  const container = new MemoryContentContainer();
469
566
  return { doc, container, sourceFormat: "json" };
470
567
  }
@@ -508,7 +605,7 @@ async function resolveContainer(container, sourceFormat, missingMessage, signal)
508
605
  const jsonData = await container.readFile(name);
509
606
  throwIfAborted(signal);
510
607
  if (jsonData) {
511
- const doc = JSON.parse(new TextDecoder().decode(jsonData));
608
+ const doc = parseDocJson(new TextDecoder().decode(jsonData), name);
512
609
  return { doc, container, sourceFormat };
513
610
  }
514
611
  }
@@ -595,12 +692,16 @@ async function captureDocFrames(doc, container, options) {
595
692
  signal?.throwIfAborted();
596
693
  const { collectImagePaths } = await import("@bendyline/squisq-formats/html");
597
694
  signal?.throwIfAborted();
695
+ const budget = createMediaBudget();
598
696
  const images = /* @__PURE__ */ new Map();
599
697
  for (const imgPath of collectImagePaths(doc)) {
600
698
  signal?.throwIfAborted();
601
699
  const data = await container.readFile(imgPath);
602
700
  signal?.throwIfAborted();
603
- if (data) images.set(imgPath, data);
701
+ if (data) {
702
+ budget.admit(imgPath, data);
703
+ images.set(imgPath, data);
704
+ }
604
705
  }
605
706
  const audio = /* @__PURE__ */ new Map();
606
707
  for (const seg of doc.audio?.segments ?? []) {
@@ -608,6 +709,7 @@ async function captureDocFrames(doc, container, options) {
608
709
  const data = await container.readFile(seg.src);
609
710
  signal?.throwIfAborted();
610
711
  if (data) {
712
+ budget.admit(seg.src, data);
611
713
  audio.set(seg.src, data);
612
714
  audio.set(seg.name, data);
613
715
  }
@@ -623,7 +725,10 @@ async function captureDocFrames(doc, container, options) {
623
725
  if (images.has(src)) continue;
624
726
  const data = await container.readFile(src);
625
727
  signal?.throwIfAborted();
626
- if (data) images.set(src, data);
728
+ if (data) {
729
+ budget.admit(src, data);
730
+ images.set(src, data);
731
+ }
627
732
  }
628
733
  onProgress?.("generating render HTML", 10);
629
734
  signal?.throwIfAborted();
@@ -667,6 +772,11 @@ async function captureDocFrames(doc, container, options) {
667
772
  const page = await browser.newPage({ viewport: { width, height } });
668
773
  const pageErrors = [];
669
774
  page.on("pageerror", (err) => pageErrors.push(err.message));
775
+ await page.route("**/*", async (route) => {
776
+ const url = route.request().url();
777
+ if (/^(?:about:|blob:|data:)/i.test(url)) await route.continue();
778
+ else await route.abort("blockedbyclient");
779
+ });
670
780
  signal?.throwIfAborted();
671
781
  await page.setContent(renderHtml, { waitUntil: "load" });
672
782
  signal?.throwIfAborted();
@@ -784,7 +894,7 @@ async function renderDocToMp4(doc, container, options) {
784
894
  options.signal
785
895
  );
786
896
  options.signal?.throwIfAborted();
787
- const { framesToMp4Native: framesToMp4Native2 } = await import("./nativeEncoder-ET3HUSOE.js");
897
+ const { framesToMp4Native: framesToMp4Native2 } = await import("./nativeEncoder-RZ3T4Z6S.js");
788
898
  await framesToMp4Native2(capture.ffmpegPath, capture.frames, encodingAudio, options.outputPath, {
789
899
  fps,
790
900
  quality,
@@ -839,7 +949,7 @@ async function renderDocToGif(doc, container, options) {
839
949
  try {
840
950
  options.onProgress?.("encoding GIF", 80);
841
951
  options.signal?.throwIfAborted();
842
- const { framesToGifNative: framesToGifNative2 } = await import("./nativeEncoder-ET3HUSOE.js");
952
+ const { framesToGifNative: framesToGifNative2 } = await import("./nativeEncoder-RZ3T4Z6S.js");
843
953
  await framesToGifNative2(capture.ffmpegPath, capture.frames, options.outputPath, {
844
954
  fps,
845
955
  orientation,
@@ -925,4 +1035,3 @@ export {
925
1035
  renderDocToGif,
926
1036
  renderDocToMp4
927
1037
  };
928
- //# sourceMappingURL=api.js.map
@@ -15,18 +15,47 @@ import {
15
15
 
16
16
  // src/util/runFfmpeg.ts
17
17
  import { execFile } from "child_process";
18
+ var FFMPEG_NOISE = /^(?:ffmpeg version |\s{2}(?:built with|configuration:|lib[a-z0-9]+\s+\d)|frame=|size=|video:|Press \[q\]|\s*Stream mapping:|\s*Metadata:|\s*Duration:|Input #|Output #|\s{4})/;
19
+ var FFMPEG_ERRORISH = /error|invalid|failed|not divisible|no such file|permission denied|unable to|unrecognized|does not contain|conversion failed/i;
20
+ function lastMeaningfulFfmpegLine(stderr) {
21
+ const lines = stderr.split(/\r?\n/).map((line) => line.replace(/ @ 0x[0-9a-f]+/gi, "").trimEnd()).filter((line) => line.trim().length > 0 && !FFMPEG_NOISE.test(line));
22
+ if (lines.length === 0) return null;
23
+ const errorish = lines.find((line) => FFMPEG_ERRORISH.test(line));
24
+ return (errorish ?? lines[lines.length - 1]).trim();
25
+ }
26
+ function describeFfmpegFailure(err, stderr, timeoutMs) {
27
+ if (err.code === "ENOENT") {
28
+ return "the ffmpeg binary could not be started (ENOENT). Run `squisq doctor` to check your FFmpeg installation.";
29
+ }
30
+ const detail = lastMeaningfulFfmpegLine(stderr);
31
+ if (err.killed) {
32
+ const seconds = Math.round(timeoutMs / 1e3);
33
+ return `it timed out after ${seconds}s${detail ? ` (last output: ${detail})` : ""}.`;
34
+ }
35
+ const exit = typeof err.code === "number" ? ` (exit code ${err.code})` : "";
36
+ return detail ? `${detail}${exit}` : `ffmpeg exited unsuccessfully${exit}.`;
37
+ }
18
38
  async function runFfmpeg(command, args, options) {
19
39
  options.signal?.throwIfAborted();
20
40
  await new Promise((resolve, reject) => {
21
- execFile(command, [...args], { timeout: options.timeoutMs, signal: options.signal }, (err) => {
22
- if (options.signal?.aborted) {
23
- reject(options.signal.reason);
24
- } else if (err) {
25
- reject(new Error(`${options.failureMessage}: ${err.message}`));
26
- } else {
27
- resolve();
41
+ execFile(
42
+ command,
43
+ [...args],
44
+ { timeout: options.timeoutMs, signal: options.signal },
45
+ (err, _stdout, stderr) => {
46
+ if (options.signal?.aborted) {
47
+ reject(options.signal.reason);
48
+ } else if (err) {
49
+ reject(
50
+ new Error(
51
+ `${options.failureMessage}: ${describeFfmpegFailure(err, stderr ?? "", options.timeoutMs)}`
52
+ )
53
+ );
54
+ } else {
55
+ resolve();
56
+ }
28
57
  }
29
- });
58
+ );
30
59
  });
31
60
  options.signal?.throwIfAborted();
32
61
  }
@@ -207,4 +236,3 @@ export {
207
236
  framesToGifNative,
208
237
  framesToGifNativeBytes
209
238
  };
210
- //# sourceMappingURL=chunk-JDHONKFZ.js.map
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  GIF_EXPORT_DEFAULTS,
4
4
  runFfmpeg
5
- } from "./chunk-JDHONKFZ.js";
5
+ } from "./chunk-GGVASRPG.js";
6
6
 
7
7
  // src/api.ts
8
8
  import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
@@ -228,6 +228,45 @@ function resolveAppliedCoverPreRoll(requestedSeconds, hasCover) {
228
228
  return hasCover ? requestedSeconds : 0;
229
229
  }
230
230
 
231
+ // src/util/mediaBudget.ts
232
+ var MAX_RENDER_MEDIA_FILE_BYTES = 64 * 1024 * 1024;
233
+ var MAX_RENDER_MEDIA_TOTAL_BYTES = 256 * 1024 * 1024;
234
+ var MAX_RENDER_MEDIA_FILES = 256;
235
+ function mib(bytes) {
236
+ return `${Math.round(bytes / (1024 * 1024))} MB`;
237
+ }
238
+ function createMediaBudget() {
239
+ let totalBytes = 0;
240
+ let fileCount = 0;
241
+ return {
242
+ get totalBytes() {
243
+ return totalBytes;
244
+ },
245
+ get fileCount() {
246
+ return fileCount;
247
+ },
248
+ admit(path, data) {
249
+ if (data.byteLength > MAX_RENDER_MEDIA_FILE_BYTES) {
250
+ throw new Error(
251
+ `Media file "${path}" is ${mib(data.byteLength)}, which exceeds the ${mib(MAX_RENDER_MEDIA_FILE_BYTES)} per-file limit for video rendering. Re-encode or shorten the asset.`
252
+ );
253
+ }
254
+ if (fileCount + 1 > MAX_RENDER_MEDIA_FILES) {
255
+ throw new Error(
256
+ `Document references more than ${MAX_RENDER_MEDIA_FILES} media files, which exceeds the limit for video rendering (adding "${path}").`
257
+ );
258
+ }
259
+ if (totalBytes + data.byteLength > MAX_RENDER_MEDIA_TOTAL_BYTES) {
260
+ throw new Error(
261
+ `Total embedded media exceeds the ${mib(MAX_RENDER_MEDIA_TOTAL_BYTES)} limit for video rendering (adding "${path}" would reach ${mib(totalBytes + data.byteLength)}). Reduce the document's media.`
262
+ );
263
+ }
264
+ totalBytes += data.byteLength;
265
+ fileCount += 1;
266
+ }
267
+ };
268
+ }
269
+
231
270
  // src/registry.ts
232
271
  import { randomBytes } from "crypto";
233
272
  import { readFile, rm } from "fs/promises";
@@ -268,7 +307,7 @@ function mp4Format() {
268
307
  const animationsEnabled = typeof mp4Opts.animationsEnabled === "boolean" ? mp4Opts.animationsEnabled : MP4_DEFAULTS.animationsEnabled;
269
308
  const outputPath = join(tmpdir(), `squisq-mp4-${randomBytes(8).toString("hex")}.mp4`);
270
309
  try {
271
- const { renderDocToMp4: renderDocToMp42 } = await import("./api-LE75OSRQ.js");
310
+ const { renderDocToMp4: renderDocToMp42 } = await import("./api-5SX73ZKT.js");
272
311
  await renderDocToMp42(input.doc, input.container, {
273
312
  outputPath,
274
313
  fps,
@@ -308,7 +347,7 @@ function gifFormat() {
308
347
  const defaultHeight = portrait ? GIF_DEFAULTS.width : GIF_DEFAULTS.height;
309
348
  const outputPath = join(tmpdir(), `squisq-gif-${randomBytes(8).toString("hex")}.gif`);
310
349
  try {
311
- const { renderDocToGif: renderDocToGif2 } = await import("./api-LE75OSRQ.js");
350
+ const { renderDocToGif: renderDocToGif2 } = await import("./api-5SX73ZKT.js");
312
351
  const result = await renderDocToGif2(input.doc, input.container, {
313
352
  outputPath,
314
353
  fps: typeof gifOpts.fps === "number" ? gifOpts.fps : GIF_DEFAULTS.fps,
@@ -459,9 +498,67 @@ async function readMarkdownFile(filePath, signal) {
459
498
  const markdownDoc = parseMarkdown(content);
460
499
  return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: "md" };
461
500
  }
501
+ function parseDocJson(content, source) {
502
+ let parsed;
503
+ try {
504
+ parsed = JSON.parse(content);
505
+ } catch (error) {
506
+ const detail = error instanceof Error ? error.message : String(error);
507
+ throw new Error(`${source} is not valid JSON: ${detail}`);
508
+ }
509
+ const fail = (detail) => {
510
+ throw new Error(`${source} is not a valid squisq Doc: ${detail}`);
511
+ };
512
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
513
+ fail(`expected a JSON object, got ${Array.isArray(parsed) ? "an array" : typeof parsed}`);
514
+ }
515
+ const doc = parsed;
516
+ if (!Array.isArray(doc.blocks)) {
517
+ fail(`"blocks" must be an array${doc.blocks === void 0 ? " (field is missing)" : ""}`);
518
+ }
519
+ for (const [index, block] of doc.blocks.entries()) {
520
+ if (typeof block !== "object" || block === null || Array.isArray(block)) {
521
+ fail(`"blocks[${index}]" must be an object`);
522
+ }
523
+ }
524
+ if (doc.duration !== void 0 && !isFiniteNumber(doc.duration)) {
525
+ fail(`"duration" must be a finite number, got ${describe(doc.duration)}`);
526
+ }
527
+ if (doc.audio !== void 0) {
528
+ if (typeof doc.audio !== "object" || doc.audio === null || Array.isArray(doc.audio)) {
529
+ fail('"audio" must be an object');
530
+ }
531
+ const segments = doc.audio.segments;
532
+ if (segments !== void 0) {
533
+ if (!Array.isArray(segments)) fail('"audio.segments" must be an array');
534
+ for (const [index, segment] of segments.entries()) {
535
+ if (typeof segment !== "object" || segment === null) {
536
+ fail(`"audio.segments[${index}]" must be an object`);
537
+ }
538
+ if (!isFiniteNumber(segment.duration)) {
539
+ fail(
540
+ `"audio.segments[${index}].duration" must be a finite number, got ${describe(segment.duration)}`
541
+ );
542
+ }
543
+ }
544
+ }
545
+ }
546
+ return {
547
+ ...doc,
548
+ audio: doc.audio ?? { segments: [] }
549
+ };
550
+ }
551
+ function isFiniteNumber(value) {
552
+ return typeof value === "number" && Number.isFinite(value);
553
+ }
554
+ function describe(value) {
555
+ if (typeof value === "number") return Number.isNaN(value) ? "NaN" : String(value);
556
+ if (typeof value === "string") return JSON.stringify(value);
557
+ return value === null ? "null" : typeof value;
558
+ }
462
559
  async function readDocJsonFile(filePath, signal) {
463
560
  const content = await readUtf8File(filePath, signal);
464
- const doc = JSON.parse(content);
561
+ const doc = parseDocJson(content, filePath);
465
562
  const container = new MemoryContentContainer();
466
563
  return { doc, container, sourceFormat: "json" };
467
564
  }
@@ -505,7 +602,7 @@ async function resolveContainer(container, sourceFormat, missingMessage, signal)
505
602
  const jsonData = await container.readFile(name);
506
603
  throwIfAborted(signal);
507
604
  if (jsonData) {
508
- const doc = JSON.parse(new TextDecoder().decode(jsonData));
605
+ const doc = parseDocJson(new TextDecoder().decode(jsonData), name);
509
606
  return { doc, container, sourceFormat };
510
607
  }
511
608
  }
@@ -592,12 +689,16 @@ async function captureDocFrames(doc, container, options) {
592
689
  signal?.throwIfAborted();
593
690
  const { collectImagePaths } = await import("@bendyline/squisq-formats/html");
594
691
  signal?.throwIfAborted();
692
+ const budget = createMediaBudget();
595
693
  const images = /* @__PURE__ */ new Map();
596
694
  for (const imgPath of collectImagePaths(doc)) {
597
695
  signal?.throwIfAborted();
598
696
  const data = await container.readFile(imgPath);
599
697
  signal?.throwIfAborted();
600
- if (data) images.set(imgPath, data);
698
+ if (data) {
699
+ budget.admit(imgPath, data);
700
+ images.set(imgPath, data);
701
+ }
601
702
  }
602
703
  const audio = /* @__PURE__ */ new Map();
603
704
  for (const seg of doc.audio?.segments ?? []) {
@@ -605,6 +706,7 @@ async function captureDocFrames(doc, container, options) {
605
706
  const data = await container.readFile(seg.src);
606
707
  signal?.throwIfAborted();
607
708
  if (data) {
709
+ budget.admit(seg.src, data);
608
710
  audio.set(seg.src, data);
609
711
  audio.set(seg.name, data);
610
712
  }
@@ -620,7 +722,10 @@ async function captureDocFrames(doc, container, options) {
620
722
  if (images.has(src)) continue;
621
723
  const data = await container.readFile(src);
622
724
  signal?.throwIfAborted();
623
- if (data) images.set(src, data);
725
+ if (data) {
726
+ budget.admit(src, data);
727
+ images.set(src, data);
728
+ }
624
729
  }
625
730
  onProgress?.("generating render HTML", 10);
626
731
  signal?.throwIfAborted();
@@ -664,6 +769,11 @@ async function captureDocFrames(doc, container, options) {
664
769
  const page = await browser.newPage({ viewport: { width, height } });
665
770
  const pageErrors = [];
666
771
  page.on("pageerror", (err) => pageErrors.push(err.message));
772
+ await page.route("**/*", async (route) => {
773
+ const url = route.request().url();
774
+ if (/^(?:about:|blob:|data:)/i.test(url)) await route.continue();
775
+ else await route.abort("blockedbyclient");
776
+ });
667
777
  signal?.throwIfAborted();
668
778
  await page.setContent(renderHtml, { waitUntil: "load" });
669
779
  signal?.throwIfAborted();
@@ -781,7 +891,7 @@ async function renderDocToMp4(doc, container, options) {
781
891
  options.signal
782
892
  );
783
893
  options.signal?.throwIfAborted();
784
- const { framesToMp4Native: framesToMp4Native2 } = await import("./nativeEncoder-LRPK2YV5.js");
894
+ const { framesToMp4Native: framesToMp4Native2 } = await import("./nativeEncoder-PMVSFLMN.js");
785
895
  await framesToMp4Native2(capture.ffmpegPath, capture.frames, encodingAudio, options.outputPath, {
786
896
  fps,
787
897
  quality,
@@ -836,7 +946,7 @@ async function renderDocToGif(doc, container, options) {
836
946
  try {
837
947
  options.onProgress?.("encoding GIF", 80);
838
948
  options.signal?.throwIfAborted();
839
- const { framesToGifNative: framesToGifNative2 } = await import("./nativeEncoder-LRPK2YV5.js");
949
+ const { framesToGifNative: framesToGifNative2 } = await import("./nativeEncoder-PMVSFLMN.js");
840
950
  await framesToGifNative2(capture.ffmpegPath, capture.frames, options.outputPath, {
841
951
  fps,
842
952
  orientation,
@@ -920,4 +1030,3 @@ export {
920
1030
  ConversionError,
921
1031
  createCliRegistry
922
1032
  };
923
- //# sourceMappingURL=chunk-FVFK4ZJU.js.map
@@ -13,18 +13,47 @@ import {
13
13
 
14
14
  // src/util/runFfmpeg.ts
15
15
  import { execFile } from "child_process";
16
+ var FFMPEG_NOISE = /^(?:ffmpeg version |\s{2}(?:built with|configuration:|lib[a-z0-9]+\s+\d)|frame=|size=|video:|Press \[q\]|\s*Stream mapping:|\s*Metadata:|\s*Duration:|Input #|Output #|\s{4})/;
17
+ var FFMPEG_ERRORISH = /error|invalid|failed|not divisible|no such file|permission denied|unable to|unrecognized|does not contain|conversion failed/i;
18
+ function lastMeaningfulFfmpegLine(stderr) {
19
+ const lines = stderr.split(/\r?\n/).map((line) => line.replace(/ @ 0x[0-9a-f]+/gi, "").trimEnd()).filter((line) => line.trim().length > 0 && !FFMPEG_NOISE.test(line));
20
+ if (lines.length === 0) return null;
21
+ const errorish = lines.find((line) => FFMPEG_ERRORISH.test(line));
22
+ return (errorish ?? lines[lines.length - 1]).trim();
23
+ }
24
+ function describeFfmpegFailure(err, stderr, timeoutMs) {
25
+ if (err.code === "ENOENT") {
26
+ return "the ffmpeg binary could not be started (ENOENT). Run `squisq doctor` to check your FFmpeg installation.";
27
+ }
28
+ const detail = lastMeaningfulFfmpegLine(stderr);
29
+ if (err.killed) {
30
+ const seconds = Math.round(timeoutMs / 1e3);
31
+ return `it timed out after ${seconds}s${detail ? ` (last output: ${detail})` : ""}.`;
32
+ }
33
+ const exit = typeof err.code === "number" ? ` (exit code ${err.code})` : "";
34
+ return detail ? `${detail}${exit}` : `ffmpeg exited unsuccessfully${exit}.`;
35
+ }
16
36
  async function runFfmpeg(command, args, options) {
17
37
  options.signal?.throwIfAborted();
18
38
  await new Promise((resolve, reject) => {
19
- execFile(command, [...args], { timeout: options.timeoutMs, signal: options.signal }, (err) => {
20
- if (options.signal?.aborted) {
21
- reject(options.signal.reason);
22
- } else if (err) {
23
- reject(new Error(`${options.failureMessage}: ${err.message}`));
24
- } else {
25
- resolve();
39
+ execFile(
40
+ command,
41
+ [...args],
42
+ { timeout: options.timeoutMs, signal: options.signal },
43
+ (err, _stdout, stderr) => {
44
+ if (options.signal?.aborted) {
45
+ reject(options.signal.reason);
46
+ } else if (err) {
47
+ reject(
48
+ new Error(
49
+ `${options.failureMessage}: ${describeFfmpegFailure(err, stderr ?? "", options.timeoutMs)}`
50
+ )
51
+ );
52
+ } else {
53
+ resolve();
54
+ }
26
55
  }
27
- });
56
+ );
28
57
  });
29
58
  options.signal?.throwIfAborted();
30
59
  }
@@ -205,4 +234,3 @@ export {
205
234
  framesToGifNative,
206
235
  framesToGifNativeBytes
207
236
  };
208
- //# sourceMappingURL=chunk-OEN2RADG.js.map