@bendyline/squisq-cli 1.2.0 → 1.2.1

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/dist/api.js CHANGED
@@ -3,32 +3,196 @@ import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
3
3
  import { flattenBlocks } from "@bendyline/squisq/doc";
4
4
  import { generateRenderHtml } from "@bendyline/squisq-video";
5
5
  import { resolveDimensions } from "@bendyline/squisq-video";
6
+ import { convert as formatsConvert } from "@bendyline/squisq-formats";
6
7
 
7
8
  // src/util/detectFfmpeg.ts
8
9
  import { execFile } from "child_process";
9
- async function detectFfmpeg() {
10
- const command = process.platform === "win32" ? "where" : "which";
10
+ function run(command, args) {
11
11
  return new Promise((resolve) => {
12
- execFile(command, ["ffmpeg"], { timeout: 5e3 }, (err, stdout) => {
12
+ execFile(command, args, { timeout: 5e3 }, (err, stdout) => {
13
13
  if (err || !stdout.trim()) {
14
14
  resolve(null);
15
15
  return;
16
16
  }
17
- const path = stdout.trim().split("\n")[0].trim();
18
- resolve(path);
17
+ resolve(stdout.trim());
19
18
  });
20
19
  });
21
20
  }
21
+ async function getFfmpegVersion(path) {
22
+ const out = await run(path, ["-version"]);
23
+ return out ? out.split("\n")[0].trim() : null;
24
+ }
25
+ async function detectFfmpegDetailed() {
26
+ const envPath = process.env.SQUISQ_FFMPEG;
27
+ if (envPath) {
28
+ const version = await getFfmpegVersion(envPath);
29
+ if (!version) {
30
+ throw new Error(
31
+ `SQUISQ_FFMPEG is set to "${envPath}" but running "${envPath} -version" failed. Fix the path or unset SQUISQ_FFMPEG.`
32
+ );
33
+ }
34
+ return { path: envPath, source: "env" };
35
+ }
36
+ const command = process.platform === "win32" ? "where" : "which";
37
+ const found = await run(command, ["ffmpeg"]);
38
+ if (found) {
39
+ return { path: found.split("\n")[0].trim(), source: "path" };
40
+ }
41
+ try {
42
+ const specifier = "ffmpeg-static";
43
+ const mod = await import(specifier);
44
+ const candidate = typeof mod === "string" ? mod : typeof mod.default === "string" ? mod.default : null;
45
+ if (candidate) {
46
+ return { path: candidate, source: "ffmpeg-static" };
47
+ }
48
+ } catch {
49
+ }
50
+ return null;
51
+ }
52
+ async function detectFfmpeg() {
53
+ const detection = await detectFfmpegDetailed();
54
+ return detection?.path ?? null;
55
+ }
56
+
57
+ // src/util/audioMix.ts
58
+ import { computeAudioTimeline } from "@bendyline/squisq-video";
59
+ async function buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll) {
60
+ const timeline = computeAudioTimeline(doc, coverPreRoll);
61
+ if (timeline.length === 0) return null;
62
+ const bytesBySrc = /* @__PURE__ */ new Map();
63
+ const readSrc = async (src) => {
64
+ if (bytesBySrc.has(src)) return bytesBySrc.get(src);
65
+ const data = await container.readFile(src);
66
+ bytesBySrc.set(src, data ?? null);
67
+ return data ?? null;
68
+ };
69
+ const usable = [];
70
+ for (const clip of timeline) {
71
+ const buffer = await readSrc(clip.src);
72
+ if (buffer) usable.push({ clip, buffer });
73
+ }
74
+ if (usable.length === 0) return null;
75
+ return mixTimelineClips(ffmpegPath, usable);
76
+ }
77
+ async function mixTimelineClips(ffmpegPath, usable) {
78
+ const { writeFile, readFile: readFile3, mkdir, rm: rm2 } = await import("fs/promises");
79
+ const { join: join3 } = await import("path");
80
+ const { tmpdir: tmpdir2 } = await import("os");
81
+ const { randomBytes: randomBytes2 } = await import("crypto");
82
+ const { execFile: execFile2 } = await import("child_process");
83
+ const workDir = join3(tmpdir2(), `squisq-audio-mix-${randomBytes2(8).toString("hex")}`);
84
+ await mkdir(workDir, { recursive: true });
85
+ const ms = (s) => Math.max(0, Math.round(s * 1e3));
86
+ try {
87
+ const inputs = [];
88
+ const filters = [];
89
+ const labels = [];
90
+ for (const { clip, buffer } of usable) {
91
+ const p = join3(workDir, `clip-${inputs.length}.mp3`);
92
+ await writeFile(p, new Uint8Array(buffer));
93
+ const i = inputs.push(p) - 1;
94
+ const delayMs = ms(clip.startSec);
95
+ const start = Math.max(0, clip.sourceInSec);
96
+ const end = start + Math.max(0, clip.durationSec);
97
+ filters.push(
98
+ `[${i}:a]atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS,adelay=${delayMs}|${delayMs}[a${i}]`
99
+ );
100
+ labels.push(`[a${i}]`);
101
+ }
102
+ const graph = `${filters.join(";")};${labels.join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[aout]`;
103
+ const outputPath = join3(workDir, "mixed.mp3");
104
+ const args = ["-y"];
105
+ for (const p of inputs) args.push("-i", p);
106
+ args.push(
107
+ "-filter_complex",
108
+ graph,
109
+ "-map",
110
+ "[aout]",
111
+ "-c:a",
112
+ "libmp3lame",
113
+ "-b:a",
114
+ "192k",
115
+ outputPath
116
+ );
117
+ await new Promise((resolve, reject) => {
118
+ execFile2(ffmpegPath, args, { timeout: 18e4 }, (err) => {
119
+ if (err) reject(new Error(`ffmpeg audio mix failed: ${err.message}`));
120
+ else resolve();
121
+ });
122
+ });
123
+ const data = await readFile3(outputPath);
124
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
125
+ } finally {
126
+ await rm2(workDir, { recursive: true, force: true });
127
+ }
128
+ }
129
+
130
+ // src/registry.ts
131
+ import { randomBytes } from "crypto";
132
+ import { readFile, rm } from "fs/promises";
133
+ import { tmpdir } from "os";
134
+ import { join } from "path";
135
+ import { defaultRegistry } from "@bendyline/squisq-formats";
136
+ var MP4_DEFAULTS = {
137
+ fps: 30,
138
+ quality: "normal",
139
+ orientation: "landscape",
140
+ coverPreRoll: 0
141
+ };
142
+ function mp4Format() {
143
+ return {
144
+ id: "mp4",
145
+ label: "MP4 Video",
146
+ mimeType: "video/mp4",
147
+ extensions: [".mp4"],
148
+ async exportDoc(input, options) {
149
+ const mp4Opts = options.formatOptions?.mp4 ?? {};
150
+ const fps = typeof mp4Opts.fps === "number" ? mp4Opts.fps : MP4_DEFAULTS.fps;
151
+ const quality = typeof mp4Opts.quality === "string" ? mp4Opts.quality : MP4_DEFAULTS.quality;
152
+ const orientation = typeof mp4Opts.orientation === "string" ? mp4Opts.orientation : MP4_DEFAULTS.orientation;
153
+ const coverPreRoll = typeof mp4Opts.coverPreRoll === "number" ? mp4Opts.coverPreRoll : MP4_DEFAULTS.coverPreRoll;
154
+ const outputPath = join(tmpdir(), `squisq-mp4-${randomBytes(8).toString("hex")}.mp4`);
155
+ try {
156
+ await renderDocToMp4(input.doc, input.container, {
157
+ outputPath,
158
+ fps,
159
+ quality,
160
+ orientation,
161
+ coverPreRoll
162
+ });
163
+ const data = await readFile(outputPath);
164
+ const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
165
+ return { bytes, mimeType: "video/mp4", suggestedFilename: "", warnings: [] };
166
+ } finally {
167
+ await rm(outputPath, { force: true });
168
+ }
169
+ }
170
+ };
171
+ }
172
+ function createCliRegistry() {
173
+ const registry = defaultRegistry();
174
+ registry.register(mp4Format());
175
+ return registry;
176
+ }
22
177
 
23
178
  // src/api.ts
24
179
  import { MemoryContentContainer as MemoryContentContainer2 } from "@bendyline/squisq/storage";
25
180
 
181
+ // src/util/domPolyfill.ts
182
+ import { DOMParser as XmldomDOMParser } from "@xmldom/xmldom";
183
+ var globalScope = globalThis;
184
+ if (typeof globalScope.DOMParser === "undefined") {
185
+ globalScope.DOMParser = XmldomDOMParser;
186
+ }
187
+
26
188
  // src/util/readInput.ts
27
- import { readFile, readdir, stat } from "fs/promises";
28
- import { join, extname } from "path";
29
- import { parseMarkdown } from "@bendyline/squisq/markdown";
189
+ import { readFile as readFile2, readdir, stat } from "fs/promises";
190
+ import { join as join2, extname } from "path";
191
+ import { parseMarkdown, stringifyMarkdown } from "@bendyline/squisq/markdown";
192
+ import { markdownToDoc } from "@bendyline/squisq/doc";
30
193
  import { MemoryContentContainer } from "@bendyline/squisq/storage";
31
194
  import { zipToContainer } from "@bendyline/squisq-formats/container";
195
+ import { defaultRegistry as defaultRegistry2 } from "@bendyline/squisq-formats";
32
196
  var MIME_TYPES = {
33
197
  ".md": "text/markdown",
34
198
  ".txt": "text/plain",
@@ -45,6 +209,7 @@ var MIME_TYPES = {
45
209
  ".mp4": "video/mp4",
46
210
  ".webm": "video/webm"
47
211
  };
212
+ var IMPORTER_EXTS = [".docx", ".pptx", ".pdf", ".xlsx", ".csv", ".html", ".htm"];
48
213
  function mimeFromExt(filePath) {
49
214
  return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
50
215
  }
@@ -54,7 +219,7 @@ async function walkDir(root, prefix = "") {
54
219
  for (const entry of entries) {
55
220
  const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
56
221
  if (entry.isDirectory()) {
57
- paths.push(...await walkDir(join(root, entry.name), relPath));
222
+ paths.push(...await walkDir(join2(root, entry.name), relPath));
58
223
  } else if (entry.isFile()) {
59
224
  paths.push(relPath);
60
225
  }
@@ -73,71 +238,99 @@ async function readInput(inputPath) {
73
238
  if (ext === ".json") {
74
239
  return readDocJsonFile(inputPath);
75
240
  }
241
+ if (IMPORTER_EXTS.includes(ext)) {
242
+ const def = defaultRegistry2().byExtension(ext);
243
+ if (def && (def.importContainer || def.importDoc)) {
244
+ return readViaImporter(inputPath, def);
245
+ }
246
+ }
76
247
  return readMarkdownFile(inputPath);
77
248
  }
249
+ async function readArrayBuffer(filePath) {
250
+ const data = await readFile2(filePath);
251
+ return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
252
+ }
78
253
  async function readMarkdownFile(filePath) {
79
- const content = await readFile(filePath, "utf-8");
254
+ const content = await readFile2(filePath, "utf-8");
80
255
  const container = new MemoryContentContainer();
81
256
  await container.writeDocument(content);
82
257
  const markdownDoc = parseMarkdown(content);
83
- return { container, markdownDoc };
258
+ return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: "md" };
84
259
  }
85
260
  async function readDocJsonFile(filePath) {
86
- const content = await readFile(filePath, "utf-8");
261
+ const content = await readFile2(filePath, "utf-8");
87
262
  const doc = JSON.parse(content);
88
263
  const container = new MemoryContentContainer();
89
- return { container, markdownDoc: null, doc };
264
+ return { doc, container, sourceFormat: "json" };
265
+ }
266
+ async function readViaImporter(filePath, def) {
267
+ const buffer = await readArrayBuffer(filePath);
268
+ let container;
269
+ let markdownDoc;
270
+ if (def.importContainer) {
271
+ container = await def.importContainer(buffer, {});
272
+ const text = await container.readDocument();
273
+ markdownDoc = text ? parseMarkdown(text) : { type: "document", children: [] };
274
+ } else {
275
+ markdownDoc = await def.importDoc(buffer, {});
276
+ const mem = new MemoryContentContainer();
277
+ await mem.writeDocument(stringifyMarkdown(markdownDoc));
278
+ container = mem;
279
+ }
280
+ return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: def.id };
90
281
  }
91
282
  var DOC_JSON_NAMES = ["doc.json", "story.json"];
92
- async function readContainer(filePath) {
93
- const data = await readFile(filePath);
94
- const container = await zipToContainer(
95
- data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
96
- );
283
+ async function resolveContainer(container, sourceFormat, missingMessage) {
97
284
  for (const name of DOC_JSON_NAMES) {
98
285
  const jsonData = await container.readFile(name);
99
286
  if (jsonData) {
100
- const decoder = new TextDecoder();
101
- const doc = JSON.parse(decoder.decode(jsonData));
102
- return { container, markdownDoc: null, doc };
287
+ const doc = JSON.parse(new TextDecoder().decode(jsonData));
288
+ return { doc, container, sourceFormat };
103
289
  }
104
290
  }
105
291
  const markdown = await container.readDocument();
106
292
  if (!markdown) {
107
- throw new Error(`No markdown document or doc.json found in container: ${filePath}`);
293
+ throw new Error(missingMessage);
108
294
  }
109
295
  const markdownDoc = parseMarkdown(markdown);
110
- return { container, markdownDoc };
296
+ return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat };
297
+ }
298
+ async function readContainer(filePath) {
299
+ const container = await zipToContainer(await readArrayBuffer(filePath));
300
+ return resolveContainer(
301
+ container,
302
+ "dbk",
303
+ `No markdown document or doc.json found in container: ${filePath}`
304
+ );
111
305
  }
112
306
  async function readFolder(dirPath) {
113
307
  const container = new MemoryContentContainer();
114
308
  const files = await walkDir(dirPath);
115
309
  for (const relPath of files) {
116
- const absPath = join(dirPath, relPath);
117
- const data = await readFile(absPath);
310
+ const absPath = join2(dirPath, relPath);
311
+ const data = await readFile2(absPath);
118
312
  await container.writeFile(
119
313
  relPath,
120
314
  new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
121
315
  mimeFromExt(relPath)
122
316
  );
123
317
  }
124
- for (const name of DOC_JSON_NAMES) {
125
- const jsonData = await container.readFile(name);
126
- if (jsonData) {
127
- const decoder = new TextDecoder();
128
- const doc = JSON.parse(decoder.decode(jsonData));
129
- return { container, markdownDoc: null, doc };
130
- }
131
- }
132
- const markdown = await container.readDocument();
133
- if (!markdown) {
134
- throw new Error(`No markdown document or doc.json found in folder: ${dirPath}`);
135
- }
136
- const markdownDoc = parseMarkdown(markdown);
137
- return { container, markdownDoc };
318
+ return resolveContainer(
319
+ container,
320
+ "folder",
321
+ `No markdown document or doc.json found in folder: ${dirPath}`
322
+ );
138
323
  }
139
324
 
140
325
  // src/api.ts
326
+ import { ConversionError } from "@bendyline/squisq-formats";
327
+ async function convert(source, to, options = {}) {
328
+ return formatsConvert(source, to, {
329
+ registry: createCliRegistry(),
330
+ resolvePlayerScript: () => import("@bendyline/squisq-react/standalone-source").then((m) => m.PLAYER_BUNDLE),
331
+ ...options
332
+ });
333
+ }
141
334
  async function renderDocToMp4(doc, container, options) {
142
335
  const {
143
336
  outputPath,
@@ -156,7 +349,7 @@ async function renderDocToMp4(doc, container, options) {
156
349
  const ffmpegPath = await detectFfmpeg();
157
350
  if (!ffmpegPath) {
158
351
  throw new Error(
159
- "ffmpeg is required but not found in PATH.\nInstall it with:\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg\n Windows: winget install ffmpeg"
352
+ "ffmpeg is required but not found in PATH.\nInstall it with:\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg\n Windows: winget install ffmpeg\nOr: npm install ffmpeg-static, or set SQUISQ_FFMPEG to an ffmpeg binary."
160
353
  );
161
354
  }
162
355
  onProgress?.("collecting media", 0);
@@ -170,39 +363,26 @@ async function renderDocToMp4(doc, container, options) {
170
363
  }
171
364
  }
172
365
  const audio = /* @__PURE__ */ new Map();
173
- const audioBuffers = [];
174
366
  if (doc.audio?.segments?.length) {
175
367
  for (const seg of doc.audio.segments) {
176
368
  const data = await container.readFile(seg.src);
177
369
  if (data) {
178
370
  audio.set(seg.src, data);
179
371
  audio.set(seg.name, data);
180
- audioBuffers.push(data);
181
372
  }
182
373
  }
183
374
  }
184
- let concatenatedAudio = null;
185
- if (audioBuffers.length > 0) {
186
- concatenatedAudio = await concatenateAudioBuffers(audioBuffers, ffmpegPath);
187
- }
188
- const schedule = resolveMediaSchedule(doc);
189
- const mediaSrcs = new Set(schedule.map((c) => c.src));
375
+ const mediaSrcs = new Set(resolveMediaSchedule(doc).map((c) => c.src));
190
376
  for (const block of flattenBlocks(doc.blocks)) {
191
377
  for (const layer of block.layers ?? []) {
192
378
  if (layer.type === "video") mediaSrcs.add(layer.content.src);
193
379
  }
194
380
  }
195
- const clipBuffers = /* @__PURE__ */ new Map();
196
381
  for (const src of mediaSrcs) {
197
382
  if (images.has(src)) continue;
198
383
  const data = await container.readFile(src);
199
384
  if (data) images.set(src, data);
200
385
  }
201
- for (const clip of schedule) {
202
- if (clip.kind !== "audio") continue;
203
- const data = images.get(clip.src) ?? await container.readFile(clip.src);
204
- if (data) clipBuffers.set(clip.id, data);
205
- }
206
386
  onProgress?.("generating render HTML", 10);
207
387
  const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
208
388
  const renderHtml = generateRenderHtml(doc, {
@@ -215,7 +395,16 @@ async function renderDocToMp4(doc, container, options) {
215
395
  });
216
396
  onProgress?.("launching browser", 15);
217
397
  const { chromium } = await import("playwright-core");
218
- const browser = await chromium.launch({ headless: true });
398
+ let browser;
399
+ try {
400
+ browser = await chromium.launch({ headless: true });
401
+ } catch (err) {
402
+ const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
403
+ throw new Error(
404
+ `Playwright Chromium is not installed. Run: npx playwright install chromium
405
+ (launch failed: ${detail})`
406
+ );
407
+ }
219
408
  const page = await browser.newPage({
220
409
  viewport: { width: dimensions.width, height: dimensions.height }
221
410
  });
@@ -233,7 +422,9 @@ async function renderDocToMp4(doc, container, options) {
233
422
  const errorDetail = pageErrors.length ? `
234
423
  Page errors:
235
424
  ${pageErrors.join("\n ")}` : "\nNo page errors captured \u2014 the player may have failed to mount.";
236
- throw new Error(`Render API did not initialize within 15 seconds.${errorDetail}`);
425
+ throw new Error(
426
+ `The standalone player failed to boot in headless Chromium. Render API did not initialize within 15 seconds.${errorDetail}`
427
+ );
237
428
  }
238
429
  const docDuration = await page.evaluate(() => {
239
430
  return window.getDuration();
@@ -281,24 +472,8 @@ Page errors:
281
472
  }
282
473
  await browser.close();
283
474
  onProgress?.("encoding video", 80);
284
- const scheduledAudioClips = schedule.filter((c) => c.kind === "audio" && clipBuffers.has(c.id));
285
- let encodingAudio = concatenatedAudio;
286
- if (scheduledAudioClips.length > 0 && ffmpegPath) {
287
- encodingAudio = await mixScheduledAudio(
288
- ffmpegPath,
289
- concatenatedAudio,
290
- scheduledAudioClips.map((c) => ({
291
- buffer: clipBuffers.get(c.id),
292
- delaySec: c.absoluteStart + coverPreRoll,
293
- trimStart: c.sourceIn,
294
- trimLen: Math.max(0, c.absoluteEnd - c.absoluteStart)
295
- })),
296
- coverPreRoll
297
- );
298
- } else if (coverPreRoll > 0 && concatenatedAudio) {
299
- encodingAudio = await addAudioDelay(ffmpegPath, concatenatedAudio, coverPreRoll);
300
- }
301
- const { framesToMp4Native } = await import("./nativeEncoder-MWHOONST.js");
475
+ const encodingAudio = await buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll);
476
+ const { framesToMp4Native } = await import("./nativeEncoder-2HGWDEZZ.js");
302
477
  await framesToMp4Native(ffmpegPath, frames, encodingAudio, outputPath, {
303
478
  fps,
304
479
  quality,
@@ -317,162 +492,10 @@ Page errors:
317
492
  outputPath
318
493
  };
319
494
  }
320
- async function concatenateAudioBuffers(buffers, ffmpegPath) {
321
- if (buffers.length === 0) return new Uint8Array(0);
322
- if (buffers.length === 1) return new Uint8Array(buffers[0]);
323
- if (ffmpegPath) {
324
- return concatenateAudioNative(ffmpegPath, buffers);
325
- }
326
- const totalLength = buffers.reduce((sum, b) => sum + b.byteLength, 0);
327
- const result = new Uint8Array(totalLength);
328
- let offset = 0;
329
- for (const buf of buffers) {
330
- result.set(new Uint8Array(buf), offset);
331
- offset += buf.byteLength;
332
- }
333
- return result;
334
- }
335
- async function concatenateAudioNative(ffmpegPath, buffers) {
336
- const { writeFile, readFile: readFile2, mkdir, rm } = await import("fs/promises");
337
- const { join: join2 } = await import("path");
338
- const { tmpdir } = await import("os");
339
- const { randomBytes } = await import("crypto");
340
- const { execFile: execFile2 } = await import("child_process");
341
- const tmpId = randomBytes(8).toString("hex");
342
- const workDir = join2(tmpdir(), `squisq-audio-concat-${tmpId}`);
343
- await mkdir(workDir, { recursive: true });
344
- try {
345
- const segmentPaths = [];
346
- for (let i = 0; i < buffers.length; i++) {
347
- const segPath = join2(workDir, `seg-${i}.mp3`);
348
- await writeFile(segPath, new Uint8Array(buffers[i]));
349
- segmentPaths.push(segPath);
350
- }
351
- const listPath = join2(workDir, "concat-list.txt");
352
- const listContent = segmentPaths.map((p) => `file '${p.replace(/\\/g, "/")}'`).join("\n");
353
- await writeFile(listPath, listContent);
354
- const outputPath = join2(workDir, "combined.mp3");
355
- await new Promise((resolve, reject) => {
356
- execFile2(
357
- ffmpegPath,
358
- ["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", outputPath],
359
- { timeout: 12e4 },
360
- (err) => {
361
- if (err) reject(new Error(`ffmpeg audio concat failed: ${err.message}`));
362
- else resolve();
363
- }
364
- );
365
- });
366
- const data = await readFile2(outputPath);
367
- return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
368
- } finally {
369
- await rm(workDir, { recursive: true, force: true });
370
- }
371
- }
372
- async function addAudioDelay(ffmpegPath, audioData, delaySecs) {
373
- const { writeFile, readFile: readFile2, rm } = await import("fs/promises");
374
- const { join: join2 } = await import("path");
375
- const { tmpdir } = await import("os");
376
- const { randomBytes } = await import("crypto");
377
- const { execFile: execFile2 } = await import("child_process");
378
- const tmpId = randomBytes(8).toString("hex");
379
- const inputPath = join2(tmpdir(), `squisq-audio-delay-in-${tmpId}.mp3`);
380
- const outputPath = join2(tmpdir(), `squisq-audio-delay-out-${tmpId}.mp3`);
381
- try {
382
- await writeFile(inputPath, audioData);
383
- const delayMs = Math.round(delaySecs * 1e3);
384
- await new Promise((resolve, reject) => {
385
- execFile2(
386
- ffmpegPath,
387
- [
388
- "-y",
389
- "-i",
390
- inputPath,
391
- "-af",
392
- `adelay=${delayMs}|${delayMs}`,
393
- "-c:a",
394
- "libmp3lame",
395
- "-b:a",
396
- "128k",
397
- outputPath
398
- ],
399
- { timeout: 6e4 },
400
- (err) => {
401
- if (err) reject(new Error(`ffmpeg audio delay failed: ${err.message}`));
402
- else resolve();
403
- }
404
- );
405
- });
406
- const data = await readFile2(outputPath);
407
- return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
408
- } finally {
409
- await rm(inputPath, { force: true });
410
- await rm(outputPath, { force: true });
411
- }
412
- }
413
- async function mixScheduledAudio(ffmpegPath, narration, clips, coverPreRoll) {
414
- if (!narration && clips.length === 0) return null;
415
- const { writeFile, readFile: readFile2, mkdir, rm } = await import("fs/promises");
416
- const { join: join2 } = await import("path");
417
- const { tmpdir } = await import("os");
418
- const { randomBytes } = await import("crypto");
419
- const { execFile: execFile2 } = await import("child_process");
420
- const workDir = join2(tmpdir(), `squisq-audio-mix-${randomBytes(8).toString("hex")}`);
421
- await mkdir(workDir, { recursive: true });
422
- try {
423
- const inputs = [];
424
- const filters = [];
425
- const labels = [];
426
- const ms = (s) => Math.max(0, Math.round(s * 1e3));
427
- if (narration) {
428
- const p = join2(workDir, "narration.mp3");
429
- await writeFile(p, narration);
430
- const i = inputs.push(p) - 1;
431
- const d = ms(coverPreRoll);
432
- filters.push(`[${i}:a]adelay=${d}|${d}[a${i}]`);
433
- labels.push(`[a${i}]`);
434
- }
435
- for (const clip of clips) {
436
- const p = join2(workDir, `clip-${inputs.length}.mp3`);
437
- await writeFile(p, new Uint8Array(clip.buffer));
438
- const i = inputs.push(p) - 1;
439
- const d = ms(clip.delaySec);
440
- const end = clip.trimStart + clip.trimLen;
441
- filters.push(
442
- `[${i}:a]atrim=start=${clip.trimStart}:end=${end},asetpts=PTS-STARTPTS,adelay=${d}|${d}[a${i}]`
443
- );
444
- labels.push(`[a${i}]`);
445
- }
446
- const graph = `${filters.join(";")};${labels.join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[aout]`;
447
- const args = ["-y"];
448
- for (const p of inputs) args.push("-i", p);
449
- args.push(
450
- "-filter_complex",
451
- graph,
452
- "-map",
453
- "[aout]",
454
- "-c:a",
455
- "libmp3lame",
456
- "-b:a",
457
- "192k",
458
- join2(workDir, "mixed.mp3")
459
- );
460
- await new Promise((resolve, reject) => {
461
- execFile2(ffmpegPath, args, { timeout: 18e4 }, (err) => {
462
- if (err) reject(new Error(`ffmpeg audio mix failed: ${err.message}`));
463
- else resolve();
464
- });
465
- });
466
- const data = await readFile2(join2(workDir, "mixed.mp3"));
467
- return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
468
- } finally {
469
- await rm(workDir, { recursive: true, force: true });
470
- }
471
- }
472
495
  async function extractThumbnails(options) {
473
496
  const { videoPath, outputDir, slug, sizes, force } = options;
474
497
  const { existsSync } = await import("fs");
475
- const { join: join2 } = await import("path");
498
+ const { join: join3 } = await import("path");
476
499
  const { execFile: execFile2 } = await import("child_process");
477
500
  const ffmpegPath = await detectFfmpeg();
478
501
  if (!ffmpegPath) {
@@ -481,7 +504,7 @@ async function extractThumbnails(options) {
481
504
  );
482
505
  }
483
506
  for (const thumb of sizes) {
484
- const outputPath = join2(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
507
+ const outputPath = join3(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
485
508
  if (!force && existsSync(outputPath)) continue;
486
509
  await new Promise((resolve, reject) => {
487
510
  execFile2(
@@ -497,7 +520,10 @@ async function extractThumbnails(options) {
497
520
  }
498
521
  }
499
522
  export {
523
+ ConversionError,
500
524
  MemoryContentContainer2 as MemoryContentContainer,
525
+ convert,
526
+ createCliRegistry,
501
527
  extractThumbnails,
502
528
  readInput,
503
529
  renderDocToMp4