@bendyline/squisq-cli 2.4.2 → 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 +5 -6
- package/dist/{api-KYHSJM5T.js → api-PBBQQMRP.js} +1 -1
- package/dist/api.js +215 -64
- package/dist/{chunk-VK6WPOSG.js → chunk-X3GFPKRJ.js} +218 -66
- package/dist/index.js +18 -14
- package/dist/squisq-player.full.global.js +400 -400
- package/dist/squisq-player.global.js +52 -52
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -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/dist/api.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
|
|
10
10
|
// src/api.ts
|
|
11
11
|
import { readFile as readFile3 } from "fs/promises";
|
|
12
|
-
import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
|
|
12
|
+
import { resolveMediaSchedule as resolveMediaSchedule2 } from "@bendyline/squisq/schemas";
|
|
13
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";
|
|
@@ -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) => {
|
|
@@ -418,13 +418,45 @@ function createCliRegistry() {
|
|
|
418
418
|
import { MemoryContentContainer as MemoryContentContainer2 } from "@bendyline/squisq/storage";
|
|
419
419
|
|
|
420
420
|
// src/util/readInput.ts
|
|
421
|
-
import { readFile as readFile2, readdir, stat } from "fs/promises";
|
|
422
|
-
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";
|
|
423
434
|
import { parseMarkdown, stringifyMarkdown } from "@bendyline/squisq/markdown";
|
|
424
435
|
import { markdownToDoc, resolveAudioMapping } from "@bendyline/squisq/doc";
|
|
436
|
+
import { resolveMediaSchedule, validateDocSchema } from "@bendyline/squisq/schemas";
|
|
425
437
|
import { MemoryContentContainer } from "@bendyline/squisq/storage";
|
|
426
438
|
import { zipToContainer } from "@bendyline/squisq-formats/container";
|
|
427
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
|
+
}
|
|
428
460
|
var MIME_TYPES = {
|
|
429
461
|
".md": "text/markdown",
|
|
430
462
|
".txt": "text/plain",
|
|
@@ -439,7 +471,11 @@ var MIME_TYPES = {
|
|
|
439
471
|
".wav": "audio/wav",
|
|
440
472
|
".ogg": "audio/ogg",
|
|
441
473
|
".mp4": "video/mp4",
|
|
442
|
-
".webm": "video/webm"
|
|
474
|
+
".webm": "video/webm",
|
|
475
|
+
".woff": "font/woff",
|
|
476
|
+
".woff2": "font/woff2",
|
|
477
|
+
".ttf": "font/ttf",
|
|
478
|
+
".otf": "font/otf"
|
|
443
479
|
};
|
|
444
480
|
var IMPORTER_EXTS = [".docx", ".pptx", ".pdf", ".xlsx", ".csv", ".html", ".htm"];
|
|
445
481
|
function mimeFromExt(filePath) {
|
|
@@ -465,8 +501,10 @@ async function readInput(inputPath, options) {
|
|
|
465
501
|
throwIfAborted(options?.signal);
|
|
466
502
|
const result = await readInputRaw(inputPath, options);
|
|
467
503
|
throwIfAborted(options?.signal);
|
|
504
|
+
assertValidDoc(result.doc, inputPath);
|
|
468
505
|
const doc = await resolveAudioMapping(result.doc, result.container);
|
|
469
506
|
throwIfAborted(options?.signal);
|
|
507
|
+
assertValidDoc(doc, inputPath);
|
|
470
508
|
return doc === result.doc ? result : { ...result, doc };
|
|
471
509
|
}
|
|
472
510
|
async function readInputRaw(inputPath, options) {
|
|
@@ -519,69 +557,160 @@ async function readUtf8File(filePath, signal) {
|
|
|
519
557
|
}
|
|
520
558
|
async function readMarkdownFile(filePath, signal) {
|
|
521
559
|
const content = await readUtf8File(filePath, signal);
|
|
522
|
-
const container = new MemoryContentContainer();
|
|
523
|
-
await container.writeDocument(content);
|
|
524
|
-
throwIfAborted(signal);
|
|
525
560
|
const markdownDoc = parseMarkdown(content);
|
|
526
|
-
|
|
561
|
+
const doc = markdownToDoc(markdownDoc);
|
|
562
|
+
const container = await buildBareMarkdownContainer(filePath, content, doc, signal);
|
|
563
|
+
return { doc, container, markdownDoc, sourceFormat: "md" };
|
|
527
564
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
fail(`expected a JSON object, got ${Array.isArray(parsed) ? "an array" : typeof parsed}`);
|
|
541
|
-
}
|
|
542
|
-
const doc = parsed;
|
|
543
|
-
if (!Array.isArray(doc.blocks)) {
|
|
544
|
-
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
|
+
}
|
|
545
577
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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`);
|
|
549
582
|
}
|
|
550
583
|
}
|
|
551
|
-
|
|
552
|
-
|
|
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;
|
|
553
633
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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;
|
|
557
648
|
}
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
fail(`"audio.segments[${index}]" must be an object`);
|
|
564
|
-
}
|
|
565
|
-
if (!isFiniteNumber(segment.duration)) {
|
|
566
|
-
fail(
|
|
567
|
-
`"audio.segments[${index}].duration" must be a finite number, got ${describe(segment.duration)}`
|
|
568
|
-
);
|
|
569
|
-
}
|
|
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);
|
|
570
654
|
}
|
|
571
655
|
}
|
|
572
|
-
}
|
|
573
|
-
return {
|
|
574
|
-
...doc,
|
|
575
|
-
audio: doc.audio ?? { segments: [] }
|
|
576
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");
|
|
577
696
|
}
|
|
578
|
-
function
|
|
579
|
-
return
|
|
697
|
+
function formatMiB(bytes) {
|
|
698
|
+
return `${Math.round(bytes / (1024 * 1024))} MB`;
|
|
699
|
+
}
|
|
700
|
+
function assertValidDoc(value, source) {
|
|
701
|
+
const issues = validateDocSchema(value);
|
|
702
|
+
if (issues.length > 0) throw new DocInputValidationError(source, issues);
|
|
580
703
|
}
|
|
581
|
-
function
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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;
|
|
585
714
|
}
|
|
586
715
|
async function readDocJsonFile(filePath, signal) {
|
|
587
716
|
const content = await readUtf8File(filePath, signal);
|
|
@@ -754,7 +883,7 @@ async function captureDocFrames(doc, container, options) {
|
|
|
754
883
|
audio.set(seg.name, data);
|
|
755
884
|
}
|
|
756
885
|
}
|
|
757
|
-
const mediaSrcs = new Set(
|
|
886
|
+
const mediaSrcs = new Set(resolveMediaSchedule2(doc).map((clip) => clip.src));
|
|
758
887
|
for (const block of flattenBlocks2(doc.blocks)) {
|
|
759
888
|
for (const layer of block.layers ?? []) {
|
|
760
889
|
if (layer.type === "video") mediaSrcs.add(layer.content.src);
|
|
@@ -1005,7 +1134,7 @@ async function renderDocToGif(doc, container, options) {
|
|
|
1005
1134
|
options.signal?.throwIfAborted();
|
|
1006
1135
|
options.onProgress?.("done", 100);
|
|
1007
1136
|
options.signal?.throwIfAborted();
|
|
1008
|
-
const hasAudio = (doc.audio?.segments?.length ?? 0) > 0 ||
|
|
1137
|
+
const hasAudio = (doc.audio?.segments?.length ?? 0) > 0 || resolveMediaSchedule2(doc).some((clip) => clip.kind === "audio");
|
|
1009
1138
|
return {
|
|
1010
1139
|
duration: capture.totalDuration,
|
|
1011
1140
|
frameCount,
|
|
@@ -1020,8 +1149,30 @@ async function extractThumbnails(options) {
|
|
|
1020
1149
|
const { videoPath, outputDir, slug, sizes, force, signal } = options;
|
|
1021
1150
|
const { existsSync } = await import("fs");
|
|
1022
1151
|
const { rm: rm2 } = await import("fs/promises");
|
|
1023
|
-
const {
|
|
1152
|
+
const { isAbsolute: isAbsolute2, relative: relative2, resolve: resolve2, sep: sep2 } = await import("path");
|
|
1024
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
|
+
});
|
|
1025
1176
|
const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
|
|
1026
1177
|
if (!ffmpegPath) {
|
|
1027
1178
|
throw new Error(
|
|
@@ -1030,9 +1181,9 @@ async function extractThumbnails(options) {
|
|
|
1030
1181
|
}
|
|
1031
1182
|
const generatedPaths = [];
|
|
1032
1183
|
try {
|
|
1033
|
-
for (const thumb of sizes) {
|
|
1184
|
+
for (const [index, thumb] of sizes.entries()) {
|
|
1034
1185
|
signal?.throwIfAborted();
|
|
1035
|
-
const outputPath =
|
|
1186
|
+
const outputPath = outputPaths[index];
|
|
1036
1187
|
if (!force && existsSync(outputPath)) continue;
|
|
1037
1188
|
try {
|
|
1038
1189
|
await runFfmpeg(
|