@bendyline/squisq-cli 2.4.2 → 2.4.4

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
@@ -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 and full standalone player artifacts.
3
+ The inventory below covers the bundled light and full standalone player artifacts and their shared icon styles.
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-VK6WPOSG.js";
14
+ } from "./chunk-6OBPEBL4.js";
15
15
  import {
16
16
  GIF_EXPORT_DEFAULTS,
17
17
  framesToGifNative,
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((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) => {
@@ -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 { 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";
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
- 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" };
527
564
  }
528
- function parseDocJson(content, source) {
529
- let parsed;
530
- try {
531
- parsed = JSON.parse(content);
532
- } catch (error) {
533
- const detail = error instanceof Error ? error.message : String(error);
534
- throw new Error(`${source} is not valid JSON: ${detail}`);
535
- }
536
- const fail = (detail) => {
537
- throw new Error(`${source} is not a valid squisq Doc: ${detail}`);
538
- };
539
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
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
- for (const [index, block] of doc.blocks.entries()) {
547
- if (typeof block !== "object" || block === null || Array.isArray(block)) {
548
- 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`);
549
582
  }
550
583
  }
551
- if (doc.duration !== void 0 && !isFiniteNumber(doc.duration)) {
552
- 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;
553
633
  }
554
- if (doc.audio !== void 0) {
555
- if (typeof doc.audio !== "object" || doc.audio === null || Array.isArray(doc.audio)) {
556
- 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;
557
648
  }
558
- const segments = doc.audio.segments;
559
- if (segments !== void 0) {
560
- if (!Array.isArray(segments)) fail('"audio.segments" must be an array');
561
- for (const [index, segment] of segments.entries()) {
562
- if (typeof segment !== "object" || segment === null) {
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;
577
671
  }
578
- function isFiniteNumber(value) {
579
- return typeof value === "number" && Number.isFinite(value);
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);
580
693
  }
581
- function describe(value) {
582
- if (typeof value === "number") return Number.isNaN(value) ? "NaN" : String(value);
583
- if (typeof value === "string") return JSON.stringify(value);
584
- return value === null ? "null" : typeof value;
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`;
699
+ }
700
+ function assertValidDoc(value, source) {
701
+ const issues = validateDocSchema(value);
702
+ if (issues.length > 0) throw new DocInputValidationError(source, issues);
703
+ }
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);
@@ -689,18 +818,26 @@ function throwIfAborted(signal) {
689
818
  import { ConversionError } from "@bendyline/squisq-formats";
690
819
  var playerBundlePromise;
691
820
  var fullPlayerBundlePromise;
692
- function loadPlayerBundle() {
693
- playerBundlePromise ??= readFile3(
694
- new URL("../dist/squisq-player.global.js", import.meta.url),
821
+ var playerIconBootstrapPromise;
822
+ function loadPlayerIconBootstrap() {
823
+ playerIconBootstrapPromise ??= readFile3(
824
+ new URL("../dist/squisq-player-icons.global.js", import.meta.url),
695
825
  "utf8"
696
826
  );
827
+ return playerIconBootstrapPromise;
828
+ }
829
+ function loadPlayerBundle() {
830
+ playerBundlePromise ??= Promise.all([
831
+ loadPlayerIconBootstrap(),
832
+ readFile3(new URL("../dist/squisq-player.global.js", import.meta.url), "utf8")
833
+ ]).then(([icons, player]) => `${icons}${player}`);
697
834
  return playerBundlePromise;
698
835
  }
699
836
  function loadFullPlayerBundle() {
700
- fullPlayerBundlePromise ??= readFile3(
701
- new URL("../dist/squisq-player.full.global.js", import.meta.url),
702
- "utf8"
703
- );
837
+ fullPlayerBundlePromise ??= Promise.all([
838
+ loadPlayerIconBootstrap(),
839
+ readFile3(new URL("../dist/squisq-player.full.global.js", import.meta.url), "utf8")
840
+ ]).then(([icons, player]) => `${icons}${player}`);
704
841
  return fullPlayerBundlePromise;
705
842
  }
706
843
  async function convert(source, to, options = {}) {
@@ -754,7 +891,7 @@ async function captureDocFrames(doc, container, options) {
754
891
  audio.set(seg.name, data);
755
892
  }
756
893
  }
757
- const mediaSrcs = new Set(resolveMediaSchedule(doc).map((clip) => clip.src));
894
+ const mediaSrcs = new Set(resolveMediaSchedule2(doc).map((clip) => clip.src));
758
895
  for (const block of flattenBlocks2(doc.blocks)) {
759
896
  for (const layer of block.layers ?? []) {
760
897
  if (layer.type === "video") mediaSrcs.add(layer.content.src);
@@ -1005,7 +1142,7 @@ async function renderDocToGif(doc, container, options) {
1005
1142
  options.signal?.throwIfAborted();
1006
1143
  options.onProgress?.("done", 100);
1007
1144
  options.signal?.throwIfAborted();
1008
- const hasAudio = (doc.audio?.segments?.length ?? 0) > 0 || resolveMediaSchedule(doc).some((clip) => clip.kind === "audio");
1145
+ const hasAudio = (doc.audio?.segments?.length ?? 0) > 0 || resolveMediaSchedule2(doc).some((clip) => clip.kind === "audio");
1009
1146
  return {
1010
1147
  duration: capture.totalDuration,
1011
1148
  frameCount,
@@ -1020,8 +1157,30 @@ async function extractThumbnails(options) {
1020
1157
  const { videoPath, outputDir, slug, sizes, force, signal } = options;
1021
1158
  const { existsSync } = await import("fs");
1022
1159
  const { rm: rm2 } = await import("fs/promises");
1023
- const { join: join3 } = await import("path");
1160
+ const { isAbsolute: isAbsolute2, relative: relative2, resolve: resolve2, sep: sep2 } = await import("path");
1024
1161
  signal?.throwIfAborted();
1162
+ if (typeof slug !== "string" || !/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9])?$/.test(slug)) {
1163
+ throw new TypeError(
1164
+ "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."
1165
+ );
1166
+ }
1167
+ for (const [index, thumb] of sizes.entries()) {
1168
+ if (!Number.isSafeInteger(thumb.width) || thumb.width <= 0) {
1169
+ throw new TypeError(`Thumbnail sizes[${index}].width must be a positive integer.`);
1170
+ }
1171
+ if (!Number.isSafeInteger(thumb.height) || thumb.height <= 0) {
1172
+ throw new TypeError(`Thumbnail sizes[${index}].height must be a positive integer.`);
1173
+ }
1174
+ }
1175
+ const resolvedOutputDir = resolve2(outputDir);
1176
+ const outputPaths = sizes.map((thumb) => {
1177
+ const outputPath = resolve2(resolvedOutputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
1178
+ const rel = relative2(resolvedOutputDir, outputPath);
1179
+ if (rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute2(rel)) {
1180
+ throw new TypeError("Thumbnail output path must remain inside outputDir.");
1181
+ }
1182
+ return outputPath;
1183
+ });
1025
1184
  const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
1026
1185
  if (!ffmpegPath) {
1027
1186
  throw new Error(
@@ -1030,9 +1189,9 @@ async function extractThumbnails(options) {
1030
1189
  }
1031
1190
  const generatedPaths = [];
1032
1191
  try {
1033
- for (const thumb of sizes) {
1192
+ for (const [index, thumb] of sizes.entries()) {
1034
1193
  signal?.throwIfAborted();
1035
- const outputPath = join3(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
1194
+ const outputPath = outputPaths[index];
1036
1195
  if (!force && existsSync(outputPath)) continue;
1037
1196
  try {
1038
1197
  await runFfmpeg(