@bendyline/squisq-cli 2.4.1 → 2.4.3
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/README.md +6 -7
- package/THIRD_PARTY_LICENSES.txt +1 -1
- package/dist/{api-HBPJQ7MA.js → api-PBBQQMRP.js} +1 -1
- package/dist/api.js +247 -67
- package/dist/{chunk-WR2BZJ77.js → chunk-X3GFPKRJ.js} +250 -69
- package/dist/index.js +18 -14
- package/dist/squisq-player.full.global.js +4169 -0
- package/dist/squisq-player.global.js +52 -52
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ Notes:
|
|
|
58
58
|
|
|
59
59
|
### `squisq video <input> [output]`
|
|
60
60
|
|
|
61
|
-
Render a document to MP4 or animated GIF. Playwright captures deterministic frames from a headless player page; native ffmpeg encodes H.264 + AAC for MP4 or a generated global palette for GIF. GIF has no audio track.
|
|
61
|
+
Render a document to MP4 or animated GIF. Playwright captures deterministic frames from a headless player page; native ffmpeg encodes H.264 + AAC for MP4 or a generated global palette for GIF. Documents with Mermaid fences automatically use the full standalone player so diagrams are captured; other documents keep the smaller light player. GIF has no audio track.
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
64
|
squisq video input.md output.mp4
|
|
@@ -177,17 +177,16 @@ const result = await convert({ kind: 'markdown', markdown: '# Hello' }, 'docx');
|
|
|
177
177
|
|
|
178
178
|
```ts
|
|
179
179
|
import { renderDocToMp4, readInput } from '@bendyline/squisq-cli/api';
|
|
180
|
-
import { markdownToDoc } from '@bendyline/squisq/doc';
|
|
181
180
|
|
|
182
181
|
// Load a document from disk (.md, .zip/.dbk, folder, or Doc .json)
|
|
183
182
|
const input = await readInput('./my-article.md');
|
|
184
183
|
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
const doc
|
|
184
|
+
// `doc` is always normalized and ready to render. `markdownDoc` is present
|
|
185
|
+
// only when the source was markdown-shaped; `sourceFormat` identifies the input.
|
|
186
|
+
const { doc, container, markdownDoc, sourceFormat } = input;
|
|
188
187
|
|
|
189
188
|
// Render to MP4
|
|
190
|
-
const result = await renderDocToMp4(doc,
|
|
189
|
+
const result = await renderDocToMp4(doc, container, {
|
|
191
190
|
outputPath: './output.mp4',
|
|
192
191
|
fps: 30, // default 30
|
|
193
192
|
quality: 'high', // 'draft' | 'normal' | 'high' (default 'normal')
|
|
@@ -269,7 +268,7 @@ await extractThumbnails({
|
|
|
269
268
|
|
|
270
269
|
### Other exports
|
|
271
270
|
|
|
272
|
-
- `readInput(inputPath)` → `{ container:
|
|
271
|
+
- `readInput(inputPath)` → `{ doc: Doc, container: ContentContainer, markdownDoc?: MarkdownDocument, sourceFormat: FormatId }`
|
|
273
272
|
- `MemoryContentContainer` (re-export from `@bendyline/squisq/storage`)
|
|
274
273
|
- `VideoQuality`, `VideoOrientation` types (re-exports from `@bendyline/squisq-video`)
|
|
275
274
|
|
package/THIRD_PARTY_LICENSES.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
THIRD-PARTY LICENSES FOR @bendyline/squisq-cli
|
|
2
2
|
|
|
3
|
-
The inventory below covers the bundled light standalone player
|
|
3
|
+
The inventory below covers the bundled light and full standalone player artifacts.
|
|
4
4
|
|
|
5
5
|
Generated from the actual esbuild input graph. Package-local license, copying,
|
|
6
6
|
and notice files are reproduced verbatim. When an npm tarball omits its
|
package/dist/api.js
CHANGED
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
|
|
10
10
|
// src/api.ts
|
|
11
11
|
import { readFile as readFile3 } from "fs/promises";
|
|
12
|
-
import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
|
|
13
|
-
import { flattenBlocks } from "@bendyline/squisq/doc";
|
|
12
|
+
import { resolveMediaSchedule as resolveMediaSchedule2 } from "@bendyline/squisq/schemas";
|
|
13
|
+
import { flattenBlocks as flattenBlocks2 } from "@bendyline/squisq/doc";
|
|
14
14
|
import { ffmpegGifOutputArgs, generateRenderHtml } from "@bendyline/squisq-video";
|
|
15
15
|
import { resolveDimensions } from "@bendyline/squisq-video";
|
|
16
16
|
import {
|
|
@@ -22,17 +22,17 @@ import {
|
|
|
22
22
|
import { execFile } from "child_process";
|
|
23
23
|
function run(command, args, signal) {
|
|
24
24
|
signal?.throwIfAborted();
|
|
25
|
-
return new Promise((
|
|
25
|
+
return new Promise((resolve2, reject) => {
|
|
26
26
|
execFile(command, args, { timeout: 5e3, signal }, (err, stdout) => {
|
|
27
27
|
if (signal?.aborted) {
|
|
28
28
|
reject(signal.reason);
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
31
|
if (err || !stdout.trim()) {
|
|
32
|
-
|
|
32
|
+
resolve2(null);
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
resolve2(stdout.trim());
|
|
36
36
|
});
|
|
37
37
|
});
|
|
38
38
|
}
|
|
@@ -76,7 +76,7 @@ async function detectFfmpegDetailed(signal) {
|
|
|
76
76
|
import { computeAudioTimeline } from "@bendyline/squisq-video";
|
|
77
77
|
async function buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll, signal) {
|
|
78
78
|
signal?.throwIfAborted();
|
|
79
|
-
const timeline = computeAudioTimeline(doc, coverPreRoll);
|
|
79
|
+
const timeline = computeAudioTimeline(doc, coverPreRoll, { includeVideoAudio: false });
|
|
80
80
|
if (timeline.length === 0) return null;
|
|
81
81
|
const bytesBySrc = /* @__PURE__ */ new Map();
|
|
82
82
|
const readSrc = async (src) => {
|
|
@@ -271,6 +271,27 @@ function createMediaBudget() {
|
|
|
271
271
|
};
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
// src/util/playerBundle.ts
|
|
275
|
+
import { flattenBlocks } from "@bendyline/squisq/doc";
|
|
276
|
+
function selectStandalonePlayerVariant(doc) {
|
|
277
|
+
for (const block of flattenBlocks(doc.blocks)) {
|
|
278
|
+
if (block.layers?.some((layer) => layer.type === "mermaid")) return "full";
|
|
279
|
+
if (containsMermaidFence(block.contents)) return "full";
|
|
280
|
+
}
|
|
281
|
+
return "light";
|
|
282
|
+
}
|
|
283
|
+
function containsMermaidFence(value) {
|
|
284
|
+
if (Array.isArray(value)) return value.some(containsMermaidFence);
|
|
285
|
+
if (!isRecord(value)) return false;
|
|
286
|
+
if (value.type === "code" && typeof value.lang === "string" && value.lang.trim().toLowerCase() === "mermaid") {
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
return containsMermaidFence(value.children);
|
|
290
|
+
}
|
|
291
|
+
function isRecord(value) {
|
|
292
|
+
return typeof value === "object" && value !== null;
|
|
293
|
+
}
|
|
294
|
+
|
|
274
295
|
// src/registry.ts
|
|
275
296
|
import { randomBytes } from "crypto";
|
|
276
297
|
import { readFile, rm } from "fs/promises";
|
|
@@ -397,13 +418,45 @@ function createCliRegistry() {
|
|
|
397
418
|
import { MemoryContentContainer as MemoryContentContainer2 } from "@bendyline/squisq/storage";
|
|
398
419
|
|
|
399
420
|
// src/util/readInput.ts
|
|
400
|
-
import { readFile as readFile2, readdir, stat } from "fs/promises";
|
|
401
|
-
import {
|
|
421
|
+
import { readFile as readFile2, readdir, realpath, stat } from "fs/promises";
|
|
422
|
+
import {
|
|
423
|
+
basename,
|
|
424
|
+
dirname,
|
|
425
|
+
extname,
|
|
426
|
+
isAbsolute,
|
|
427
|
+
join as join2,
|
|
428
|
+
posix,
|
|
429
|
+
relative,
|
|
430
|
+
resolve,
|
|
431
|
+
sep,
|
|
432
|
+
win32
|
|
433
|
+
} from "path";
|
|
402
434
|
import { parseMarkdown, stringifyMarkdown } from "@bendyline/squisq/markdown";
|
|
403
435
|
import { markdownToDoc, resolveAudioMapping } from "@bendyline/squisq/doc";
|
|
436
|
+
import { resolveMediaSchedule, validateDocSchema } from "@bendyline/squisq/schemas";
|
|
404
437
|
import { MemoryContentContainer } from "@bendyline/squisq/storage";
|
|
405
438
|
import { zipToContainer } from "@bendyline/squisq-formats/container";
|
|
406
439
|
import { defaultRegistry as defaultRegistry2 } from "@bendyline/squisq-formats";
|
|
440
|
+
var DocInputValidationError = class extends Error {
|
|
441
|
+
constructor(source, issues) {
|
|
442
|
+
const detail = issues.map(formatSchemaIssueForError).join("; ");
|
|
443
|
+
super(`${source} is not a valid squisq Doc: ${detail}`);
|
|
444
|
+
this.name = "DocInputValidationError";
|
|
445
|
+
this.issues = issues;
|
|
446
|
+
this.diagnostics = issues.map((issue) => ({
|
|
447
|
+
severity: "error",
|
|
448
|
+
code: "invalid-doc-schema",
|
|
449
|
+
message: `${issue.path} ${issue.message}`
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
function formatSchemaIssueForError(issue) {
|
|
454
|
+
if (issue.path === "$") {
|
|
455
|
+
const got = /\(got ([^)]+)\)/.exec(issue.message)?.[1] ?? "an invalid value";
|
|
456
|
+
return `expected a JSON object, got ${got}`;
|
|
457
|
+
}
|
|
458
|
+
return `"${issue.path}" ${issue.message}`;
|
|
459
|
+
}
|
|
407
460
|
var MIME_TYPES = {
|
|
408
461
|
".md": "text/markdown",
|
|
409
462
|
".txt": "text/plain",
|
|
@@ -418,7 +471,11 @@ var MIME_TYPES = {
|
|
|
418
471
|
".wav": "audio/wav",
|
|
419
472
|
".ogg": "audio/ogg",
|
|
420
473
|
".mp4": "video/mp4",
|
|
421
|
-
".webm": "video/webm"
|
|
474
|
+
".webm": "video/webm",
|
|
475
|
+
".woff": "font/woff",
|
|
476
|
+
".woff2": "font/woff2",
|
|
477
|
+
".ttf": "font/ttf",
|
|
478
|
+
".otf": "font/otf"
|
|
422
479
|
};
|
|
423
480
|
var IMPORTER_EXTS = [".docx", ".pptx", ".pdf", ".xlsx", ".csv", ".html", ".htm"];
|
|
424
481
|
function mimeFromExt(filePath) {
|
|
@@ -444,8 +501,10 @@ async function readInput(inputPath, options) {
|
|
|
444
501
|
throwIfAborted(options?.signal);
|
|
445
502
|
const result = await readInputRaw(inputPath, options);
|
|
446
503
|
throwIfAborted(options?.signal);
|
|
504
|
+
assertValidDoc(result.doc, inputPath);
|
|
447
505
|
const doc = await resolveAudioMapping(result.doc, result.container);
|
|
448
506
|
throwIfAborted(options?.signal);
|
|
507
|
+
assertValidDoc(doc, inputPath);
|
|
449
508
|
return doc === result.doc ? result : { ...result, doc };
|
|
450
509
|
}
|
|
451
510
|
async function readInputRaw(inputPath, options) {
|
|
@@ -498,69 +557,160 @@ async function readUtf8File(filePath, signal) {
|
|
|
498
557
|
}
|
|
499
558
|
async function readMarkdownFile(filePath, signal) {
|
|
500
559
|
const content = await readUtf8File(filePath, signal);
|
|
501
|
-
const container = new MemoryContentContainer();
|
|
502
|
-
await container.writeDocument(content);
|
|
503
|
-
throwIfAborted(signal);
|
|
504
560
|
const markdownDoc = parseMarkdown(content);
|
|
505
|
-
|
|
561
|
+
const doc = markdownToDoc(markdownDoc);
|
|
562
|
+
const container = await buildBareMarkdownContainer(filePath, content, doc, signal);
|
|
563
|
+
return { doc, container, markdownDoc, sourceFormat: "md" };
|
|
506
564
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
fail(`expected a JSON object, got ${Array.isArray(parsed) ? "an array" : typeof parsed}`);
|
|
520
|
-
}
|
|
521
|
-
const doc = parsed;
|
|
522
|
-
if (!Array.isArray(doc.blocks)) {
|
|
523
|
-
fail(`"blocks" must be an array${doc.blocks === void 0 ? " (field is missing)" : ""}`);
|
|
565
|
+
var NARRATION_EXTENSIONS = /* @__PURE__ */ new Set([".aac", ".flac", ".m4a", ".mp3", ".ogg", ".wav"]);
|
|
566
|
+
async function buildBareMarkdownContainer(filePath, content, doc, signal) {
|
|
567
|
+
const container = new MemoryContentContainer();
|
|
568
|
+
await container.writeDocument(content, basename(filePath));
|
|
569
|
+
throwIfAborted(signal);
|
|
570
|
+
const refs = collectAuthoredAssetRefs(content, doc);
|
|
571
|
+
const root = dirname(resolve(filePath));
|
|
572
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
573
|
+
throwIfAborted(signal);
|
|
574
|
+
if (entry.isFile() && NARRATION_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
|
575
|
+
refs.add(entry.name);
|
|
576
|
+
}
|
|
524
577
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
578
|
+
refs.add("timing.json");
|
|
579
|
+
for (const ref of [...refs]) {
|
|
580
|
+
if (NARRATION_EXTENSIONS.has(extname(stripUrlSuffix(ref)).toLowerCase())) {
|
|
581
|
+
refs.add(`${stripUrlSuffix(ref)}.timing.json`);
|
|
528
582
|
}
|
|
529
583
|
}
|
|
530
|
-
|
|
531
|
-
|
|
584
|
+
const rootReal = await realpath(root);
|
|
585
|
+
let fileCount = 0;
|
|
586
|
+
let totalBytes = 0;
|
|
587
|
+
for (const authoredRef of refs) {
|
|
588
|
+
throwIfAborted(signal);
|
|
589
|
+
const safe = normalizeAssetReference(authoredRef);
|
|
590
|
+
if (!safe) continue;
|
|
591
|
+
const absolute = resolve(root, ...safe.split("/"));
|
|
592
|
+
if (!isContainedPath(root, absolute)) continue;
|
|
593
|
+
let assetReal;
|
|
594
|
+
let info;
|
|
595
|
+
try {
|
|
596
|
+
assetReal = await realpath(absolute);
|
|
597
|
+
if (!isContainedPath(rootReal, assetReal)) continue;
|
|
598
|
+
info = await stat(assetReal);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
if (isMissingFileError(error)) continue;
|
|
601
|
+
throw error;
|
|
602
|
+
}
|
|
603
|
+
if (!info.isFile()) continue;
|
|
604
|
+
if (info.size > MAX_RENDER_MEDIA_FILE_BYTES) {
|
|
605
|
+
throw new Error(
|
|
606
|
+
`Sibling asset "${safe}" exceeds the ${formatMiB(MAX_RENDER_MEDIA_FILE_BYTES)} per-file input limit.`
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
if (fileCount + 1 > MAX_RENDER_MEDIA_FILES) {
|
|
610
|
+
throw new Error(
|
|
611
|
+
`Bare markdown input references more than ${MAX_RENDER_MEDIA_FILES} sibling assets.`
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
if (totalBytes + info.size > MAX_RENDER_MEDIA_TOTAL_BYTES) {
|
|
615
|
+
throw new Error(
|
|
616
|
+
`Sibling assets exceed the ${formatMiB(MAX_RENDER_MEDIA_TOTAL_BYTES)} total input limit.`
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
const data = await readBinaryFile(assetReal, signal);
|
|
620
|
+
if (data.byteLength > MAX_RENDER_MEDIA_FILE_BYTES) {
|
|
621
|
+
throw new Error(
|
|
622
|
+
`Sibling asset "${safe}" exceeds the ${formatMiB(MAX_RENDER_MEDIA_FILE_BYTES)} per-file input limit.`
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
if (totalBytes + data.byteLength > MAX_RENDER_MEDIA_TOTAL_BYTES) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
`Sibling assets exceed the ${formatMiB(MAX_RENDER_MEDIA_TOTAL_BYTES)} total input limit.`
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
await container.writeFile(safe, data, mimeFromExt(safe));
|
|
631
|
+
fileCount += 1;
|
|
632
|
+
totalBytes += data.byteLength;
|
|
532
633
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
634
|
+
return container;
|
|
635
|
+
}
|
|
636
|
+
function collectAuthoredAssetRefs(content, doc) {
|
|
637
|
+
const refs = /* @__PURE__ */ new Set();
|
|
638
|
+
const add = (value) => {
|
|
639
|
+
if (typeof value === "string" && value.trim()) refs.add(value.trim());
|
|
640
|
+
};
|
|
641
|
+
const scanObject = (value, seen = /* @__PURE__ */ new Set()) => {
|
|
642
|
+
if (!value || typeof value !== "object") return;
|
|
643
|
+
if (seen.has(value)) return;
|
|
644
|
+
seen.add(value);
|
|
645
|
+
if (Array.isArray(value)) {
|
|
646
|
+
for (const item of value) scanObject(item, seen);
|
|
647
|
+
return;
|
|
536
648
|
}
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
fail(`"audio.segments[${index}]" must be an object`);
|
|
543
|
-
}
|
|
544
|
-
if (!isFiniteNumber(segment.duration)) {
|
|
545
|
-
fail(
|
|
546
|
-
`"audio.segments[${index}].duration" must be a finite number, got ${describe(segment.duration)}`
|
|
547
|
-
);
|
|
548
|
-
}
|
|
649
|
+
for (const [key, item] of Object.entries(value)) {
|
|
650
|
+
if (typeof item === "string" && ["src", "url", "heroSrc", "posterSrc", "staticSrc", "videoSrc", "imageSrc"].includes(key)) {
|
|
651
|
+
add(item);
|
|
652
|
+
} else {
|
|
653
|
+
scanObject(item, seen);
|
|
549
654
|
}
|
|
550
655
|
}
|
|
551
|
-
}
|
|
552
|
-
return {
|
|
553
|
-
...doc,
|
|
554
|
-
audio: doc.audio ?? { segments: [] }
|
|
555
656
|
};
|
|
657
|
+
scanObject(doc);
|
|
658
|
+
for (const segment of doc.audio.segments) add(segment.src);
|
|
659
|
+
for (const clip of resolveMediaSchedule(doc)) add(clip.src);
|
|
660
|
+
const patterns = [
|
|
661
|
+
/\b(?:src|href)\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/gi,
|
|
662
|
+
/\b(?:src|audio|video|image|font)\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s}\]]+))/gi,
|
|
663
|
+
/\burl\(\s*(?:"([^"]+)"|'([^']+)'|([^\s)]+))\s*\)/gi,
|
|
664
|
+
/!?\[[^\]]*\]\(\s*<?([^\s)>]+)>?/g
|
|
665
|
+
];
|
|
666
|
+
for (const pattern of patterns) {
|
|
667
|
+
let match;
|
|
668
|
+
while (match = pattern.exec(content)) add(match.slice(1).find(Boolean));
|
|
669
|
+
}
|
|
670
|
+
return refs;
|
|
671
|
+
}
|
|
672
|
+
function normalizeAssetReference(authoredRef) {
|
|
673
|
+
let value = stripUrlSuffix(authoredRef.trim().replace(/^<|>$/g, ""));
|
|
674
|
+
try {
|
|
675
|
+
value = decodeURIComponent(value);
|
|
676
|
+
} catch {
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
if (!value || value.includes("\0") || /^[a-z][a-z\d+.-]*:/i.test(value) || posix.isAbsolute(value) || win32.isAbsolute(value) || isAbsolute(value)) {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
const parts = value.replace(/\\/g, "/").split("/");
|
|
683
|
+
if (parts.some((part) => part === "..")) return null;
|
|
684
|
+
const normalized = parts.filter((part) => part && part !== ".").join("/");
|
|
685
|
+
return normalized || null;
|
|
686
|
+
}
|
|
687
|
+
function stripUrlSuffix(value) {
|
|
688
|
+
return value.split(/[?#]/, 1)[0] ?? value;
|
|
689
|
+
}
|
|
690
|
+
function isContainedPath(root, candidate) {
|
|
691
|
+
const rel = relative(root, candidate);
|
|
692
|
+
return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
|
|
693
|
+
}
|
|
694
|
+
function isMissingFileError(error) {
|
|
695
|
+
return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
696
|
+
}
|
|
697
|
+
function formatMiB(bytes) {
|
|
698
|
+
return `${Math.round(bytes / (1024 * 1024))} MB`;
|
|
556
699
|
}
|
|
557
|
-
function
|
|
558
|
-
|
|
700
|
+
function assertValidDoc(value, source) {
|
|
701
|
+
const issues = validateDocSchema(value);
|
|
702
|
+
if (issues.length > 0) throw new DocInputValidationError(source, issues);
|
|
559
703
|
}
|
|
560
|
-
function
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
704
|
+
function parseDocJson(content, source) {
|
|
705
|
+
let parsed;
|
|
706
|
+
try {
|
|
707
|
+
parsed = JSON.parse(content);
|
|
708
|
+
} catch (error) {
|
|
709
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
710
|
+
throw new Error(`${source} is not valid JSON: ${detail}`);
|
|
711
|
+
}
|
|
712
|
+
assertValidDoc(parsed, source);
|
|
713
|
+
return parsed;
|
|
564
714
|
}
|
|
565
715
|
async function readDocJsonFile(filePath, signal) {
|
|
566
716
|
const content = await readUtf8File(filePath, signal);
|
|
@@ -667,6 +817,7 @@ function throwIfAborted(signal) {
|
|
|
667
817
|
// src/api.ts
|
|
668
818
|
import { ConversionError } from "@bendyline/squisq-formats";
|
|
669
819
|
var playerBundlePromise;
|
|
820
|
+
var fullPlayerBundlePromise;
|
|
670
821
|
function loadPlayerBundle() {
|
|
671
822
|
playerBundlePromise ??= readFile3(
|
|
672
823
|
new URL("../dist/squisq-player.global.js", import.meta.url),
|
|
@@ -674,6 +825,13 @@ function loadPlayerBundle() {
|
|
|
674
825
|
);
|
|
675
826
|
return playerBundlePromise;
|
|
676
827
|
}
|
|
828
|
+
function loadFullPlayerBundle() {
|
|
829
|
+
fullPlayerBundlePromise ??= readFile3(
|
|
830
|
+
new URL("../dist/squisq-player.full.global.js", import.meta.url),
|
|
831
|
+
"utf8"
|
|
832
|
+
);
|
|
833
|
+
return fullPlayerBundlePromise;
|
|
834
|
+
}
|
|
677
835
|
async function convert(source, to, options = {}) {
|
|
678
836
|
return formatsConvert(source, to, {
|
|
679
837
|
registry: createCliRegistry(),
|
|
@@ -725,8 +883,8 @@ async function captureDocFrames(doc, container, options) {
|
|
|
725
883
|
audio.set(seg.name, data);
|
|
726
884
|
}
|
|
727
885
|
}
|
|
728
|
-
const mediaSrcs = new Set(
|
|
729
|
-
for (const block of
|
|
886
|
+
const mediaSrcs = new Set(resolveMediaSchedule2(doc).map((clip) => clip.src));
|
|
887
|
+
for (const block of flattenBlocks2(doc.blocks)) {
|
|
730
888
|
for (const layer of block.layers ?? []) {
|
|
731
889
|
if (layer.type === "video") mediaSrcs.add(layer.content.src);
|
|
732
890
|
}
|
|
@@ -743,7 +901,7 @@ async function captureDocFrames(doc, container, options) {
|
|
|
743
901
|
}
|
|
744
902
|
onProgress?.("generating render HTML", 10);
|
|
745
903
|
signal?.throwIfAborted();
|
|
746
|
-
const playerBundle = await loadPlayerBundle();
|
|
904
|
+
const playerBundle = await (selectStandalonePlayerVariant(doc) === "full" ? loadFullPlayerBundle() : loadPlayerBundle());
|
|
747
905
|
signal?.throwIfAborted();
|
|
748
906
|
const renderHtml = generateRenderHtml(doc, {
|
|
749
907
|
playerScript: playerBundle,
|
|
@@ -976,7 +1134,7 @@ async function renderDocToGif(doc, container, options) {
|
|
|
976
1134
|
options.signal?.throwIfAborted();
|
|
977
1135
|
options.onProgress?.("done", 100);
|
|
978
1136
|
options.signal?.throwIfAborted();
|
|
979
|
-
const hasAudio = (doc.audio?.segments?.length ?? 0) > 0 ||
|
|
1137
|
+
const hasAudio = (doc.audio?.segments?.length ?? 0) > 0 || resolveMediaSchedule2(doc).some((clip) => clip.kind === "audio");
|
|
980
1138
|
return {
|
|
981
1139
|
duration: capture.totalDuration,
|
|
982
1140
|
frameCount,
|
|
@@ -991,8 +1149,30 @@ async function extractThumbnails(options) {
|
|
|
991
1149
|
const { videoPath, outputDir, slug, sizes, force, signal } = options;
|
|
992
1150
|
const { existsSync } = await import("fs");
|
|
993
1151
|
const { rm: rm2 } = await import("fs/promises");
|
|
994
|
-
const {
|
|
1152
|
+
const { isAbsolute: isAbsolute2, relative: relative2, resolve: resolve2, sep: sep2 } = await import("path");
|
|
995
1153
|
signal?.throwIfAborted();
|
|
1154
|
+
if (typeof slug !== "string" || !/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9])?$/.test(slug)) {
|
|
1155
|
+
throw new TypeError(
|
|
1156
|
+
"Thumbnail slug must be 1\u2013128 filename-safe characters (letters, numbers, dot, dash, or underscore) and must start and end with a letter or number."
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
for (const [index, thumb] of sizes.entries()) {
|
|
1160
|
+
if (!Number.isSafeInteger(thumb.width) || thumb.width <= 0) {
|
|
1161
|
+
throw new TypeError(`Thumbnail sizes[${index}].width must be a positive integer.`);
|
|
1162
|
+
}
|
|
1163
|
+
if (!Number.isSafeInteger(thumb.height) || thumb.height <= 0) {
|
|
1164
|
+
throw new TypeError(`Thumbnail sizes[${index}].height must be a positive integer.`);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
const resolvedOutputDir = resolve2(outputDir);
|
|
1168
|
+
const outputPaths = sizes.map((thumb) => {
|
|
1169
|
+
const outputPath = resolve2(resolvedOutputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
|
|
1170
|
+
const rel = relative2(resolvedOutputDir, outputPath);
|
|
1171
|
+
if (rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute2(rel)) {
|
|
1172
|
+
throw new TypeError("Thumbnail output path must remain inside outputDir.");
|
|
1173
|
+
}
|
|
1174
|
+
return outputPath;
|
|
1175
|
+
});
|
|
996
1176
|
const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
|
|
997
1177
|
if (!ffmpegPath) {
|
|
998
1178
|
throw new Error(
|
|
@@ -1001,9 +1181,9 @@ async function extractThumbnails(options) {
|
|
|
1001
1181
|
}
|
|
1002
1182
|
const generatedPaths = [];
|
|
1003
1183
|
try {
|
|
1004
|
-
for (const thumb of sizes) {
|
|
1184
|
+
for (const [index, thumb] of sizes.entries()) {
|
|
1005
1185
|
signal?.throwIfAborted();
|
|
1006
|
-
const outputPath =
|
|
1186
|
+
const outputPath = outputPaths[index];
|
|
1007
1187
|
if (!force && existsSync(outputPath)) continue;
|
|
1008
1188
|
try {
|
|
1009
1189
|
await runFfmpeg(
|