@bendyline/squisq-cli 2.1.0 → 2.2.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 +21 -0
- package/NOTICE.md +19 -0
- package/README.md +11 -1
- package/dist/{api-LE75OSRQ.js → api-5SX73ZKT.js} +2 -3
- package/dist/api.js +117 -8
- package/dist/{chunk-JDHONKFZ.js → chunk-GGVASRPG.js} +37 -9
- package/dist/{chunk-FVFK4ZJU.js → chunk-GYHQHKO5.js} +119 -10
- package/dist/{chunk-OEN2RADG.js → chunk-Z3BHD4AF.js} +37 -9
- package/dist/index.js +150 -39
- package/dist/{nativeEncoder-LRPK2YV5.js → nativeEncoder-PMVSFLMN.js} +1 -2
- package/dist/{nativeEncoder-ET3HUSOE.js → nativeEncoder-RZ3T4Z6S.js} +1 -2
- package/package.json +17 -14
- package/dist/api-LE75OSRQ.js.map +0 -1
- package/dist/api.js.map +0 -1
- package/dist/chunk-FVFK4ZJU.js.map +0 -1
- package/dist/chunk-JDHONKFZ.js.map +0 -1
- package/dist/chunk-OEN2RADG.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/nativeEncoder-ET3HUSOE.js.map +0 -1
- package/dist/nativeEncoder-LRPK2YV5.js.map +0 -1
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,19 @@
|
|
|
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
|
+
| @bendyline/squisq | 2.1.0 | MIT | https://github.com/bendyline/squisq |
|
|
12
|
+
| @bendyline/squisq-formats | 2.1.0 | MIT | https://github.com/bendyline/squisq |
|
|
13
|
+
| @bendyline/squisq-react | 2.1.0 | MIT | https://github.com/bendyline/squisq |
|
|
14
|
+
| @bendyline/squisq-video | 2.0.2 | MIT | https://github.com/bendyline/squisq |
|
|
15
|
+
| commander | 12.1.0 | MIT | https://github.com/tj/commander.js |
|
|
16
|
+
| playwright-core | 1.58.2 | Apache-2.0 | https://github.com/microsoft/playwright |
|
|
17
|
+
|
|
18
|
+
Copyright and complete license texts for these dependencies are included in
|
|
19
|
+
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
|
|
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.
|
|
@@ -11,14 +11,14 @@ import {
|
|
|
11
11
|
readInput,
|
|
12
12
|
renderDocToGif,
|
|
13
13
|
renderDocToMp4
|
|
14
|
-
} from "./chunk-
|
|
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-
|
|
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-
|
|
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 =
|
|
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 =
|
|
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)
|
|
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)
|
|
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-
|
|
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-
|
|
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(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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 =
|
|
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 =
|
|
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)
|
|
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)
|
|
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-
|
|
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-
|
|
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(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|