@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 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
- // readInput returns { container, markdownDoc, doc? } doc is only set for
186
- // JSON input, so build the Doc from markdown when needed:
187
- const doc = input.doc ?? markdownToDoc(input.markdownDoc!);
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, input.container, {
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: MemoryContentContainer, markdownDoc: MarkdownDocument | null, doc?: Doc }`
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
 
@@ -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 artifact.
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
@@ -11,7 +11,7 @@ import {
11
11
  readInput,
12
12
  renderDocToGif,
13
13
  renderDocToMp4
14
- } from "./chunk-WR2BZJ77.js";
14
+ } from "./chunk-X3GFPKRJ.js";
15
15
  import {
16
16
  GIF_EXPORT_DEFAULTS,
17
17
  framesToGifNative,
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((resolve, reject) => {
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
- resolve(null);
32
+ resolve2(null);
33
33
  return;
34
34
  }
35
- resolve(stdout.trim());
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 { join as join2, extname } from "path";
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
- return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: "md" };
561
+ const doc = markdownToDoc(markdownDoc);
562
+ const container = await buildBareMarkdownContainer(filePath, content, doc, signal);
563
+ return { doc, container, markdownDoc, sourceFormat: "md" };
506
564
  }
507
- function parseDocJson(content, source) {
508
- let parsed;
509
- try {
510
- parsed = JSON.parse(content);
511
- } catch (error) {
512
- const detail = error instanceof Error ? error.message : String(error);
513
- throw new Error(`${source} is not valid JSON: ${detail}`);
514
- }
515
- const fail = (detail) => {
516
- throw new Error(`${source} is not a valid squisq Doc: ${detail}`);
517
- };
518
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
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
- for (const [index, block] of doc.blocks.entries()) {
526
- if (typeof block !== "object" || block === null || Array.isArray(block)) {
527
- fail(`"blocks[${index}]" must be an object`);
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
- if (doc.duration !== void 0 && !isFiniteNumber(doc.duration)) {
531
- fail(`"duration" must be a finite number, got ${describe(doc.duration)}`);
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
- if (doc.audio !== void 0) {
534
- if (typeof doc.audio !== "object" || doc.audio === null || Array.isArray(doc.audio)) {
535
- fail('"audio" must be an object');
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 segments = doc.audio.segments;
538
- if (segments !== void 0) {
539
- if (!Array.isArray(segments)) fail('"audio.segments" must be an array');
540
- for (const [index, segment] of segments.entries()) {
541
- if (typeof segment !== "object" || segment === null) {
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 isFiniteNumber(value) {
558
- return typeof value === "number" && Number.isFinite(value);
700
+ function assertValidDoc(value, source) {
701
+ const issues = validateDocSchema(value);
702
+ if (issues.length > 0) throw new DocInputValidationError(source, issues);
559
703
  }
560
- function describe(value) {
561
- if (typeof value === "number") return Number.isNaN(value) ? "NaN" : String(value);
562
- if (typeof value === "string") return JSON.stringify(value);
563
- return value === null ? "null" : typeof value;
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(resolveMediaSchedule(doc).map((clip) => clip.src));
729
- for (const block of flattenBlocks(doc.blocks)) {
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 || resolveMediaSchedule(doc).some((clip) => clip.kind === "audio");
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 { join: join3 } = await import("path");
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 = join3(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
1186
+ const outputPath = outputPaths[index];
1007
1187
  if (!force && existsSync(outputPath)) continue;
1008
1188
  try {
1009
1189
  await runFfmpeg(