@bendyline/squisq-cli 1.2.2 → 2.0.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.
@@ -0,0 +1,923 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ GIF_EXPORT_DEFAULTS,
4
+ runFfmpeg
5
+ } from "./chunk-JDHONKFZ.js";
6
+
7
+ // src/api.ts
8
+ import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
9
+ import { flattenBlocks } from "@bendyline/squisq/doc";
10
+ import { ffmpegGifOutputArgs, generateRenderHtml } from "@bendyline/squisq-video";
11
+ import { resolveDimensions } from "@bendyline/squisq-video";
12
+ import {
13
+ convert as formatsConvert,
14
+ prepareConversion as formatsPrepareConversion
15
+ } from "@bendyline/squisq-formats";
16
+
17
+ // src/util/detectFfmpeg.ts
18
+ import { execFile } from "child_process";
19
+ function run(command, args, signal) {
20
+ signal?.throwIfAborted();
21
+ return new Promise((resolve, reject) => {
22
+ execFile(command, args, { timeout: 5e3, signal }, (err, stdout) => {
23
+ if (signal?.aborted) {
24
+ reject(signal.reason);
25
+ return;
26
+ }
27
+ if (err || !stdout.trim()) {
28
+ resolve(null);
29
+ return;
30
+ }
31
+ resolve(stdout.trim());
32
+ });
33
+ });
34
+ }
35
+ async function getFfmpegVersion(path, signal) {
36
+ const out = await run(path, ["-version"], signal);
37
+ return out ? out.split("\n")[0].trim() : null;
38
+ }
39
+ async function detectFfmpegDetailed(signal) {
40
+ signal?.throwIfAborted();
41
+ const envPath = process.env.SQUISQ_FFMPEG;
42
+ if (envPath) {
43
+ const version = await getFfmpegVersion(envPath, signal);
44
+ if (!version) {
45
+ throw new Error(
46
+ `SQUISQ_FFMPEG is set to "${envPath}" but running "${envPath} -version" failed. Fix the path or unset SQUISQ_FFMPEG.`
47
+ );
48
+ }
49
+ return { path: envPath, source: "env" };
50
+ }
51
+ const command = process.platform === "win32" ? "where" : "which";
52
+ const found = await run(command, ["ffmpeg"], signal);
53
+ if (found) {
54
+ return { path: found.split("\n")[0].trim(), source: "path" };
55
+ }
56
+ try {
57
+ signal?.throwIfAborted();
58
+ const specifier = "ffmpeg-static";
59
+ const mod = await import(specifier);
60
+ const candidate = typeof mod === "string" ? mod : typeof mod.default === "string" ? mod.default : null;
61
+ if (candidate) {
62
+ return { path: candidate, source: "ffmpeg-static" };
63
+ }
64
+ } catch {
65
+ signal?.throwIfAborted();
66
+ }
67
+ signal?.throwIfAborted();
68
+ return null;
69
+ }
70
+
71
+ // src/util/audioMix.ts
72
+ import { computeAudioTimeline } from "@bendyline/squisq-video";
73
+ async function buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll, signal) {
74
+ signal?.throwIfAborted();
75
+ const timeline = computeAudioTimeline(doc, coverPreRoll);
76
+ if (timeline.length === 0) return null;
77
+ const bytesBySrc = /* @__PURE__ */ new Map();
78
+ const readSrc = async (src) => {
79
+ signal?.throwIfAborted();
80
+ if (bytesBySrc.has(src)) return bytesBySrc.get(src);
81
+ const data = await container.readFile(src);
82
+ signal?.throwIfAborted();
83
+ bytesBySrc.set(src, data ?? null);
84
+ return data ?? null;
85
+ };
86
+ const usable = [];
87
+ for (const clip of timeline) {
88
+ signal?.throwIfAborted();
89
+ const buffer = await readSrc(clip.src);
90
+ if (buffer) usable.push({ clip, buffer });
91
+ }
92
+ if (usable.length === 0) return null;
93
+ return mixTimelineClips(ffmpegPath, usable, signal);
94
+ }
95
+ async function mixTimelineClips(ffmpegPath, usable, signal) {
96
+ const { writeFile, readFile: readFile3, mkdir, rm: rm2 } = await import("fs/promises");
97
+ const { join: join3 } = await import("path");
98
+ const { tmpdir: tmpdir2 } = await import("os");
99
+ const { randomBytes: randomBytes2 } = await import("crypto");
100
+ signal?.throwIfAborted();
101
+ const workDir = join3(tmpdir2(), `squisq-audio-mix-${randomBytes2(8).toString("hex")}`);
102
+ await mkdir(workDir, { recursive: true });
103
+ const ms = (s) => Math.max(0, Math.round(s * 1e3));
104
+ try {
105
+ signal?.throwIfAborted();
106
+ const inputs = [];
107
+ const filters = [];
108
+ const labels = [];
109
+ for (const { clip, buffer } of usable) {
110
+ signal?.throwIfAborted();
111
+ const p = join3(workDir, `clip-${inputs.length}.mp3`);
112
+ await writeFile(p, new Uint8Array(buffer));
113
+ signal?.throwIfAborted();
114
+ const i = inputs.push(p) - 1;
115
+ const delayMs = ms(clip.startSec);
116
+ const start = Math.max(0, clip.sourceInSec);
117
+ const end = start + Math.max(0, clip.durationSec);
118
+ filters.push(
119
+ `[${i}:a]atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS,adelay=${delayMs}|${delayMs}[a${i}]`
120
+ );
121
+ labels.push(`[a${i}]`);
122
+ }
123
+ const graph = `${filters.join(";")};${labels.join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[aout]`;
124
+ const outputPath = join3(workDir, "mixed.mp3");
125
+ const args = ["-y"];
126
+ for (const p of inputs) args.push("-i", p);
127
+ args.push(
128
+ "-filter_complex",
129
+ graph,
130
+ "-map",
131
+ "[aout]",
132
+ "-c:a",
133
+ "libmp3lame",
134
+ "-b:a",
135
+ "192k",
136
+ outputPath
137
+ );
138
+ await runFfmpeg(ffmpegPath, args, {
139
+ timeoutMs: 18e4,
140
+ failureMessage: "ffmpeg audio mix failed",
141
+ signal
142
+ });
143
+ signal?.throwIfAborted();
144
+ const data = await readFile3(outputPath);
145
+ signal?.throwIfAborted();
146
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
147
+ } finally {
148
+ await rm2(workDir, { recursive: true, force: true });
149
+ }
150
+ }
151
+
152
+ // src/util/capturedFrameBudget.ts
153
+ var MAX_CAPTURED_FRAME_BYTES = 256 * 1024 * 1024;
154
+ var CapturedFrameBudgetError = class extends Error {
155
+ constructor(capturedBytes, attemptedFrameBytes, maximumBytes) {
156
+ super(
157
+ `Captured PNG frames would retain ${capturedBytes + attemptedFrameBytes} bytes, exceeding the ${maximumBytes}-byte rendered-media memory limit.`
158
+ );
159
+ this.capturedBytes = capturedBytes;
160
+ this.attemptedFrameBytes = attemptedFrameBytes;
161
+ this.maximumBytes = maximumBytes;
162
+ this.code = "captured-frame-budget-exceeded";
163
+ this.name = "CapturedFrameBudgetError";
164
+ }
165
+ };
166
+ var CapturedFrameCollector = class {
167
+ constructor(maximumBytes = MAX_CAPTURED_FRAME_BYTES) {
168
+ this.maximumBytes = maximumBytes;
169
+ this.values = [];
170
+ this.seen = /* @__PURE__ */ new WeakSet();
171
+ this.bytes = 0;
172
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1 || maximumBytes > MAX_CAPTURED_FRAME_BYTES) {
173
+ throw new Error(
174
+ `Captured-frame byte limit must be between 1 and ${MAX_CAPTURED_FRAME_BYTES}`
175
+ );
176
+ }
177
+ }
178
+ get frameCount() {
179
+ return this.values.length;
180
+ }
181
+ get retainedBytes() {
182
+ return this.bytes;
183
+ }
184
+ append(frame, repetitions = 1) {
185
+ if (!Number.isSafeInteger(repetitions) || repetitions < 1) {
186
+ throw new Error("Captured-frame repetition count must be a positive integer");
187
+ }
188
+ const additionalBytes = this.seen.has(frame) ? 0 : frame.byteLength;
189
+ if (this.bytes + additionalBytes > this.maximumBytes) {
190
+ const capturedBytes = this.bytes;
191
+ this.clear();
192
+ throw new CapturedFrameBudgetError(capturedBytes, additionalBytes, this.maximumBytes);
193
+ }
194
+ if (additionalBytes > 0) {
195
+ this.seen.add(frame);
196
+ this.bytes += additionalBytes;
197
+ }
198
+ for (let index = 0; index < repetitions; index += 1) this.values.push(frame);
199
+ }
200
+ /** Clear retained frame references before preserving the caller's abort reason. */
201
+ throwIfAborted(signal) {
202
+ if (!signal?.aborted) return;
203
+ this.clear();
204
+ signal.throwIfAborted();
205
+ }
206
+ /** Transfer ownership of the captured frames to the encoder. */
207
+ release() {
208
+ const released = this.values;
209
+ this.values = [];
210
+ this.seen = /* @__PURE__ */ new WeakSet();
211
+ this.bytes = 0;
212
+ return released;
213
+ }
214
+ /** Drop every retained reference so failed/cancelled captures can be collected promptly. */
215
+ clear() {
216
+ this.values.length = 0;
217
+ this.values = [];
218
+ this.seen = /* @__PURE__ */ new WeakSet();
219
+ this.bytes = 0;
220
+ }
221
+ };
222
+
223
+ // src/util/coverPreRoll.ts
224
+ function resolveAppliedCoverPreRoll(requestedSeconds, hasCover) {
225
+ if (!Number.isFinite(requestedSeconds) || requestedSeconds < 0) {
226
+ throw new Error("Cover pre-roll must be a finite number of seconds greater than or equal to 0");
227
+ }
228
+ return hasCover ? requestedSeconds : 0;
229
+ }
230
+
231
+ // src/registry.ts
232
+ import { randomBytes } from "crypto";
233
+ import { readFile, rm } from "fs/promises";
234
+ import { tmpdir } from "os";
235
+ import { join } from "path";
236
+ import { defaultRegistry } from "@bendyline/squisq-formats";
237
+ var MP4_DEFAULTS = {
238
+ fps: 30,
239
+ quality: "normal",
240
+ orientation: "landscape",
241
+ coverPreRoll: 0,
242
+ animationsEnabled: true
243
+ };
244
+ var GIF_DEFAULTS = {
245
+ fps: 10,
246
+ orientation: "landscape",
247
+ width: 960,
248
+ height: 540,
249
+ coverPreRoll: 0,
250
+ animationsEnabled: false,
251
+ loop: 0,
252
+ maxColors: 256,
253
+ dither: "sierra2_4a"
254
+ };
255
+ function mp4Format() {
256
+ return {
257
+ id: "mp4",
258
+ label: "MP4 Video",
259
+ mimeType: "video/mp4",
260
+ extensions: [".mp4"],
261
+ async exportDoc(input, options) {
262
+ options.signal?.throwIfAborted();
263
+ const mp4Opts = options.formatOptions?.mp4 ?? {};
264
+ const fps = typeof mp4Opts.fps === "number" ? mp4Opts.fps : MP4_DEFAULTS.fps;
265
+ const quality = typeof mp4Opts.quality === "string" ? mp4Opts.quality : MP4_DEFAULTS.quality;
266
+ const orientation = typeof mp4Opts.orientation === "string" ? mp4Opts.orientation : MP4_DEFAULTS.orientation;
267
+ const coverPreRoll = typeof mp4Opts.coverPreRoll === "number" ? mp4Opts.coverPreRoll : MP4_DEFAULTS.coverPreRoll;
268
+ const animationsEnabled = typeof mp4Opts.animationsEnabled === "boolean" ? mp4Opts.animationsEnabled : MP4_DEFAULTS.animationsEnabled;
269
+ const outputPath = join(tmpdir(), `squisq-mp4-${randomBytes(8).toString("hex")}.mp4`);
270
+ try {
271
+ const { renderDocToMp4: renderDocToMp42 } = await import("./api-LE75OSRQ.js");
272
+ await renderDocToMp42(input.doc, input.container, {
273
+ outputPath,
274
+ fps,
275
+ quality,
276
+ orientation,
277
+ width: mp4Opts.width,
278
+ height: mp4Opts.height,
279
+ captionStyle: mp4Opts.captionStyle,
280
+ coverPreRoll,
281
+ animationsEnabled,
282
+ signal: options.signal,
283
+ onProgress: mp4Opts.onProgress
284
+ });
285
+ options.signal?.throwIfAborted();
286
+ const data = await readFile(outputPath, { signal: options.signal });
287
+ options.signal?.throwIfAborted();
288
+ const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
289
+ return { bytes, mimeType: "video/mp4", suggestedFilename: "", warnings: [] };
290
+ } finally {
291
+ await rm(outputPath, { force: true });
292
+ }
293
+ }
294
+ };
295
+ }
296
+ function gifFormat() {
297
+ return {
298
+ id: "gif",
299
+ label: "Animated GIF",
300
+ mimeType: "image/gif",
301
+ extensions: [".gif"],
302
+ async exportDoc(input, options) {
303
+ options.signal?.throwIfAborted();
304
+ const gifOpts = options.formatOptions?.gif ?? {};
305
+ const orientation = typeof gifOpts.orientation === "string" ? gifOpts.orientation : GIF_DEFAULTS.orientation;
306
+ const portrait = orientation === "portrait";
307
+ const defaultWidth = portrait ? GIF_DEFAULTS.height : GIF_DEFAULTS.width;
308
+ const defaultHeight = portrait ? GIF_DEFAULTS.width : GIF_DEFAULTS.height;
309
+ const outputPath = join(tmpdir(), `squisq-gif-${randomBytes(8).toString("hex")}.gif`);
310
+ try {
311
+ const { renderDocToGif: renderDocToGif2 } = await import("./api-LE75OSRQ.js");
312
+ const result = await renderDocToGif2(input.doc, input.container, {
313
+ outputPath,
314
+ fps: typeof gifOpts.fps === "number" ? gifOpts.fps : GIF_DEFAULTS.fps,
315
+ orientation,
316
+ width: typeof gifOpts.width === "number" ? gifOpts.width : defaultWidth,
317
+ height: typeof gifOpts.height === "number" ? gifOpts.height : defaultHeight,
318
+ captionStyle: gifOpts.captionStyle,
319
+ coverPreRoll: typeof gifOpts.coverPreRoll === "number" ? gifOpts.coverPreRoll : GIF_DEFAULTS.coverPreRoll,
320
+ animationsEnabled: typeof gifOpts.animationsEnabled === "boolean" ? gifOpts.animationsEnabled : GIF_DEFAULTS.animationsEnabled,
321
+ loop: typeof gifOpts.loop === "number" ? gifOpts.loop : GIF_DEFAULTS.loop,
322
+ maxColors: typeof gifOpts.maxColors === "number" ? gifOpts.maxColors : GIF_DEFAULTS.maxColors,
323
+ dither: gifOpts.dither ?? GIF_DEFAULTS.dither,
324
+ bayerScale: gifOpts.bayerScale,
325
+ signal: options.signal,
326
+ onProgress: gifOpts.onProgress
327
+ });
328
+ options.signal?.throwIfAborted();
329
+ const data = await readFile(outputPath, { signal: options.signal });
330
+ options.signal?.throwIfAborted();
331
+ const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
332
+ return {
333
+ bytes,
334
+ mimeType: "image/gif",
335
+ suggestedFilename: "",
336
+ warnings: result.warnings
337
+ };
338
+ } finally {
339
+ await rm(outputPath, { force: true });
340
+ }
341
+ }
342
+ };
343
+ }
344
+ function createCliRegistry() {
345
+ const registry = defaultRegistry();
346
+ registry.register(mp4Format());
347
+ registry.register(gifFormat());
348
+ return registry;
349
+ }
350
+
351
+ // src/api.ts
352
+ import { MemoryContentContainer as MemoryContentContainer2 } from "@bendyline/squisq/storage";
353
+
354
+ // src/util/readInput.ts
355
+ import { readFile as readFile2, readdir, stat } from "fs/promises";
356
+ import { join as join2, extname } from "path";
357
+ import { parseMarkdown, stringifyMarkdown } from "@bendyline/squisq/markdown";
358
+ import { markdownToDoc, resolveAudioMapping } from "@bendyline/squisq/doc";
359
+ import { MemoryContentContainer } from "@bendyline/squisq/storage";
360
+ import { zipToContainer } from "@bendyline/squisq-formats/container";
361
+ import { defaultRegistry as defaultRegistry2 } from "@bendyline/squisq-formats";
362
+ var MIME_TYPES = {
363
+ ".md": "text/markdown",
364
+ ".txt": "text/plain",
365
+ ".json": "application/json",
366
+ ".jpg": "image/jpeg",
367
+ ".jpeg": "image/jpeg",
368
+ ".png": "image/png",
369
+ ".gif": "image/gif",
370
+ ".webp": "image/webp",
371
+ ".svg": "image/svg+xml",
372
+ ".mp3": "audio/mpeg",
373
+ ".wav": "audio/wav",
374
+ ".ogg": "audio/ogg",
375
+ ".mp4": "video/mp4",
376
+ ".webm": "video/webm"
377
+ };
378
+ var IMPORTER_EXTS = [".docx", ".pptx", ".pdf", ".xlsx", ".csv", ".html", ".htm"];
379
+ function mimeFromExt(filePath) {
380
+ return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
381
+ }
382
+ async function walkDir(root, prefix = "", signal) {
383
+ throwIfAborted(signal);
384
+ const entries = await readdir(root, { withFileTypes: true });
385
+ throwIfAborted(signal);
386
+ const paths = [];
387
+ for (const entry of entries) {
388
+ throwIfAborted(signal);
389
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
390
+ if (entry.isDirectory()) {
391
+ paths.push(...await walkDir(join2(root, entry.name), relPath, signal));
392
+ } else if (entry.isFile()) {
393
+ paths.push(relPath);
394
+ }
395
+ }
396
+ return paths;
397
+ }
398
+ async function readInput(inputPath, options) {
399
+ throwIfAborted(options?.signal);
400
+ const result = await readInputRaw(inputPath, options);
401
+ throwIfAborted(options?.signal);
402
+ const doc = await resolveAudioMapping(result.doc, result.container);
403
+ throwIfAborted(options?.signal);
404
+ return doc === result.doc ? result : { ...result, doc };
405
+ }
406
+ async function readInputRaw(inputPath, options) {
407
+ throwIfAborted(options?.signal);
408
+ const info = await stat(inputPath);
409
+ throwIfAborted(options?.signal);
410
+ if (info.isDirectory()) {
411
+ return readFolder(inputPath, options?.signal);
412
+ }
413
+ const ext = extname(inputPath).toLowerCase();
414
+ if (ext === ".zip" || ext === ".dbk") {
415
+ return readContainer(inputPath, options?.signal);
416
+ }
417
+ if (ext === ".json") {
418
+ return readDocJsonFile(inputPath, options?.signal);
419
+ }
420
+ if (IMPORTER_EXTS.includes(ext)) {
421
+ const def = defaultRegistry2().byExtension(ext);
422
+ if (def && (def.importContainer || def.importDoc)) {
423
+ return readViaImporter(inputPath, def, options);
424
+ }
425
+ }
426
+ return readMarkdownFile(inputPath, options?.signal);
427
+ }
428
+ async function readArrayBuffer(filePath, signal) {
429
+ const data = await readBinaryFile(filePath, signal);
430
+ return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
431
+ }
432
+ async function readBinaryFile(filePath, signal) {
433
+ throwIfAborted(signal);
434
+ try {
435
+ const data = await readFile2(filePath, { signal });
436
+ throwIfAborted(signal);
437
+ return data;
438
+ } catch (error) {
439
+ throwIfAborted(signal);
440
+ throw error;
441
+ }
442
+ }
443
+ async function readUtf8File(filePath, signal) {
444
+ throwIfAborted(signal);
445
+ try {
446
+ const content = await readFile2(filePath, { encoding: "utf-8", signal });
447
+ throwIfAborted(signal);
448
+ return content;
449
+ } catch (error) {
450
+ throwIfAborted(signal);
451
+ throw error;
452
+ }
453
+ }
454
+ async function readMarkdownFile(filePath, signal) {
455
+ const content = await readUtf8File(filePath, signal);
456
+ const container = new MemoryContentContainer();
457
+ await container.writeDocument(content);
458
+ throwIfAborted(signal);
459
+ const markdownDoc = parseMarkdown(content);
460
+ return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: "md" };
461
+ }
462
+ async function readDocJsonFile(filePath, signal) {
463
+ const content = await readUtf8File(filePath, signal);
464
+ const doc = JSON.parse(content);
465
+ const container = new MemoryContentContainer();
466
+ return { doc, container, sourceFormat: "json" };
467
+ }
468
+ async function readViaImporter(filePath, def, options) {
469
+ const signal = options?.signal;
470
+ throwIfAborted(signal);
471
+ const buffer = await readArrayBuffer(filePath, signal);
472
+ const convertOptions = {
473
+ signal,
474
+ formatOptions: {
475
+ [def.id]: {
476
+ inferTheme: options?.inferTheme !== false,
477
+ inferLayouts: options?.inferLayouts !== false
478
+ }
479
+ }
480
+ };
481
+ let container;
482
+ let markdownDoc;
483
+ if (def.importContainer) {
484
+ throwIfAborted(signal);
485
+ container = await def.importContainer(buffer, convertOptions);
486
+ throwIfAborted(signal);
487
+ const text = await container.readDocument();
488
+ throwIfAborted(signal);
489
+ markdownDoc = text ? parseMarkdown(text) : { type: "document", children: [] };
490
+ } else {
491
+ throwIfAborted(signal);
492
+ markdownDoc = await def.importDoc(buffer, convertOptions);
493
+ throwIfAborted(signal);
494
+ const mem = new MemoryContentContainer();
495
+ await mem.writeDocument(stringifyMarkdown(markdownDoc));
496
+ throwIfAborted(signal);
497
+ container = mem;
498
+ }
499
+ return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: def.id };
500
+ }
501
+ var DOC_JSON_NAMES = ["doc.json", "story.json"];
502
+ async function resolveContainer(container, sourceFormat, missingMessage, signal) {
503
+ for (const name of DOC_JSON_NAMES) {
504
+ throwIfAborted(signal);
505
+ const jsonData = await container.readFile(name);
506
+ throwIfAborted(signal);
507
+ if (jsonData) {
508
+ const doc = JSON.parse(new TextDecoder().decode(jsonData));
509
+ return { doc, container, sourceFormat };
510
+ }
511
+ }
512
+ throwIfAborted(signal);
513
+ const markdown = await container.readDocument();
514
+ throwIfAborted(signal);
515
+ if (!markdown) {
516
+ throw new Error(missingMessage);
517
+ }
518
+ const markdownDoc = parseMarkdown(markdown);
519
+ return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat };
520
+ }
521
+ async function readContainer(filePath, signal) {
522
+ throwIfAborted(signal);
523
+ const container = await zipToContainer(await readArrayBuffer(filePath, signal));
524
+ throwIfAborted(signal);
525
+ return resolveContainer(
526
+ container,
527
+ "dbk",
528
+ `No markdown document or doc.json found in container: ${filePath}`,
529
+ signal
530
+ );
531
+ }
532
+ async function readFolder(dirPath, signal) {
533
+ throwIfAborted(signal);
534
+ const container = new MemoryContentContainer();
535
+ const files = await walkDir(dirPath, "", signal);
536
+ throwIfAborted(signal);
537
+ for (const relPath of files) {
538
+ throwIfAborted(signal);
539
+ const absPath = join2(dirPath, relPath);
540
+ const data = await readBinaryFile(absPath, signal);
541
+ throwIfAborted(signal);
542
+ await container.writeFile(
543
+ relPath,
544
+ new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
545
+ mimeFromExt(relPath)
546
+ );
547
+ throwIfAborted(signal);
548
+ }
549
+ return resolveContainer(
550
+ container,
551
+ "folder",
552
+ `No markdown document or doc.json found in folder: ${dirPath}`,
553
+ signal
554
+ );
555
+ }
556
+ function throwIfAborted(signal) {
557
+ if (!signal?.aborted) return;
558
+ if (signal.reason !== void 0) throw signal.reason;
559
+ const error = new Error("Input reading was cancelled");
560
+ error.name = "AbortError";
561
+ throw error;
562
+ }
563
+
564
+ // src/api.ts
565
+ import { ConversionError } from "@bendyline/squisq-formats";
566
+ async function convert(source, to, options = {}) {
567
+ return formatsConvert(source, to, {
568
+ registry: createCliRegistry(),
569
+ resolvePlayerScript: () => import("@bendyline/squisq-react/standalone-source").then((m) => m.PLAYER_BUNDLE),
570
+ ...options
571
+ });
572
+ }
573
+ async function prepareConversion(source, options = {}) {
574
+ return formatsPrepareConversion(source, {
575
+ registry: createCliRegistry(),
576
+ resolvePlayerScript: () => import("@bendyline/squisq-react/standalone-source").then((m) => m.PLAYER_BUNDLE),
577
+ ...options
578
+ });
579
+ }
580
+ async function captureDocFrames(doc, container, options) {
581
+ const { fps, width, height, captionStyle, coverPreRoll, animationsEnabled, onProgress, signal } = options;
582
+ signal?.throwIfAborted();
583
+ resolveAppliedCoverPreRoll(coverPreRoll, true);
584
+ const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
585
+ signal?.throwIfAborted();
586
+ if (!ffmpegPath) {
587
+ throw new Error(
588
+ "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."
589
+ );
590
+ }
591
+ onProgress?.("collecting media", 0);
592
+ signal?.throwIfAborted();
593
+ const { collectImagePaths } = await import("@bendyline/squisq-formats/html");
594
+ signal?.throwIfAborted();
595
+ const images = /* @__PURE__ */ new Map();
596
+ for (const imgPath of collectImagePaths(doc)) {
597
+ signal?.throwIfAborted();
598
+ const data = await container.readFile(imgPath);
599
+ signal?.throwIfAborted();
600
+ if (data) images.set(imgPath, data);
601
+ }
602
+ const audio = /* @__PURE__ */ new Map();
603
+ for (const seg of doc.audio?.segments ?? []) {
604
+ signal?.throwIfAborted();
605
+ const data = await container.readFile(seg.src);
606
+ signal?.throwIfAborted();
607
+ if (data) {
608
+ audio.set(seg.src, data);
609
+ audio.set(seg.name, data);
610
+ }
611
+ }
612
+ const mediaSrcs = new Set(resolveMediaSchedule(doc).map((clip) => clip.src));
613
+ for (const block of flattenBlocks(doc.blocks)) {
614
+ for (const layer of block.layers ?? []) {
615
+ if (layer.type === "video") mediaSrcs.add(layer.content.src);
616
+ }
617
+ }
618
+ for (const src of mediaSrcs) {
619
+ signal?.throwIfAborted();
620
+ if (images.has(src)) continue;
621
+ const data = await container.readFile(src);
622
+ signal?.throwIfAborted();
623
+ if (data) images.set(src, data);
624
+ }
625
+ onProgress?.("generating render HTML", 10);
626
+ signal?.throwIfAborted();
627
+ const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
628
+ signal?.throwIfAborted();
629
+ const renderHtml = generateRenderHtml(doc, {
630
+ playerScript: PLAYER_BUNDLE,
631
+ images,
632
+ audio: audio.size > 0 ? audio : void 0,
633
+ width,
634
+ height,
635
+ captionStyle,
636
+ animationsEnabled
637
+ });
638
+ onProgress?.("launching browser", 15);
639
+ signal?.throwIfAborted();
640
+ const { chromium } = await import("playwright-core");
641
+ signal?.throwIfAborted();
642
+ let browser;
643
+ try {
644
+ browser = await chromium.launch({ headless: true });
645
+ } catch (err) {
646
+ signal?.throwIfAborted();
647
+ const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
648
+ throw new Error(
649
+ `Playwright Chromium is not installed. Run: npx playwright install chromium
650
+ (launch failed: ${detail})`
651
+ );
652
+ }
653
+ const handleAbort = () => {
654
+ void browser.close().catch(() => void 0);
655
+ };
656
+ if (signal?.aborted) {
657
+ await browser.close().catch(() => void 0);
658
+ signal.throwIfAborted();
659
+ }
660
+ signal?.addEventListener("abort", handleAbort, { once: true });
661
+ let renderAPI = null;
662
+ const capturedFrames = new CapturedFrameCollector();
663
+ try {
664
+ const page = await browser.newPage({ viewport: { width, height } });
665
+ const pageErrors = [];
666
+ page.on("pageerror", (err) => pageErrors.push(err.message));
667
+ signal?.throwIfAborted();
668
+ await page.setContent(renderHtml, { waitUntil: "load" });
669
+ signal?.throwIfAborted();
670
+ await page.waitForTimeout(500);
671
+ signal?.throwIfAborted();
672
+ try {
673
+ await page.waitForFunction(
674
+ () => {
675
+ const root = document.getElementById("squisq-root");
676
+ const player = window.SquisqPlayer;
677
+ return root ? player?.getHandle(root)?.getRenderAPI() != null : false;
678
+ },
679
+ { timeout: 15e3 }
680
+ );
681
+ } catch {
682
+ signal?.throwIfAborted();
683
+ const errorDetail = pageErrors.length ? `
684
+ Page errors:
685
+ ${pageErrors.join("\n ")}` : "\nNo page errors captured \u2014 the player may have failed to mount.";
686
+ throw new Error(
687
+ `The standalone player failed to boot in headless Chromium. Render API did not initialize within 15 seconds.${errorDetail}`
688
+ );
689
+ }
690
+ renderAPI = await page.evaluateHandle(() => {
691
+ const root = document.getElementById("squisq-root");
692
+ const player = window.SquisqPlayer;
693
+ const api = root ? player?.getHandle(root)?.getRenderAPI() : null;
694
+ if (!api) throw new Error("Squisq render API disappeared after initialization.");
695
+ return api;
696
+ });
697
+ signal?.throwIfAborted();
698
+ const docDuration = await renderAPI.evaluate((api) => api.getDuration());
699
+ signal?.throwIfAborted();
700
+ if (docDuration <= 0) throw new Error("Document has zero duration \u2014 nothing to render");
701
+ const hasCover = coverPreRoll > 0 ? await renderAPI.evaluate((api) => api.hasCoverBlock()) : false;
702
+ const appliedCoverPreRoll = resolveAppliedCoverPreRoll(coverPreRoll, hasCover);
703
+ const storyFrameCount = Math.ceil(docDuration * fps);
704
+ const preRollFrameCount = Math.ceil(appliedCoverPreRoll * fps);
705
+ const totalFrames = preRollFrameCount + storyFrameCount;
706
+ onProgress?.("capturing frames", 20);
707
+ capturedFrames.throwIfAborted(signal);
708
+ if (preRollFrameCount > 0) {
709
+ capturedFrames.throwIfAborted(signal);
710
+ await renderAPI.evaluate((api) => api.showCover());
711
+ await page.waitForTimeout(100);
712
+ capturedFrames.throwIfAborted(signal);
713
+ const coverFrame = await page.screenshot({ type: "png" });
714
+ capturedFrames.throwIfAborted(signal);
715
+ capturedFrames.append(coverFrame, preRollFrameCount);
716
+ await renderAPI.evaluate((api) => api.hideCover());
717
+ }
718
+ const frameInterval = 1 / fps;
719
+ for (let i = 0; i < storyFrameCount; i++) {
720
+ capturedFrames.throwIfAborted(signal);
721
+ const time = i * frameInterval;
722
+ await renderAPI.evaluate((api, t) => api.seekTo(t), time);
723
+ const frame = await page.screenshot({ type: "png" });
724
+ capturedFrames.throwIfAborted(signal);
725
+ capturedFrames.append(frame);
726
+ if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {
727
+ onProgress?.(
728
+ "capturing frames",
729
+ 20 + Math.round(capturedFrames.frameCount / totalFrames * 60)
730
+ );
731
+ capturedFrames.throwIfAborted(signal);
732
+ }
733
+ }
734
+ return {
735
+ frames: capturedFrames.release(),
736
+ totalDuration: docDuration + appliedCoverPreRoll,
737
+ appliedCoverPreRoll,
738
+ ffmpegPath
739
+ };
740
+ } catch (err) {
741
+ capturedFrames.clear();
742
+ signal?.throwIfAborted();
743
+ throw err;
744
+ } finally {
745
+ signal?.removeEventListener("abort", handleAbort);
746
+ await renderAPI?.dispose().catch(() => void 0);
747
+ await browser.close().catch(() => void 0);
748
+ }
749
+ }
750
+ async function renderDocToMp4(doc, container, options) {
751
+ options.signal?.throwIfAborted();
752
+ const fps = options.fps ?? 30;
753
+ const quality = options.quality ?? "normal";
754
+ const orientation = options.orientation ?? "landscape";
755
+ const dimensions = resolveDimensions({
756
+ orientation,
757
+ width: options.width,
758
+ height: options.height,
759
+ fps,
760
+ quality
761
+ });
762
+ const capture = await captureDocFrames(doc, container, {
763
+ fps,
764
+ width: dimensions.width,
765
+ height: dimensions.height,
766
+ captionStyle: options.captionStyle,
767
+ coverPreRoll: options.coverPreRoll ?? 0,
768
+ animationsEnabled: options.animationsEnabled ?? true,
769
+ onProgress: options.onProgress,
770
+ signal: options.signal
771
+ });
772
+ const frameCount = capture.frames.length;
773
+ try {
774
+ options.onProgress?.("encoding video", 80);
775
+ options.signal?.throwIfAborted();
776
+ const encodingAudio = await buildMixedAudioTrack(
777
+ doc,
778
+ container,
779
+ capture.ffmpegPath,
780
+ capture.appliedCoverPreRoll,
781
+ options.signal
782
+ );
783
+ options.signal?.throwIfAborted();
784
+ const { framesToMp4Native: framesToMp4Native2 } = await import("./nativeEncoder-LRPK2YV5.js");
785
+ await framesToMp4Native2(capture.ffmpegPath, capture.frames, encodingAudio, options.outputPath, {
786
+ fps,
787
+ quality,
788
+ orientation,
789
+ width: dimensions.width,
790
+ height: dimensions.height,
791
+ signal: options.signal,
792
+ onProgress: (percent, phase) => options.onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2))
793
+ });
794
+ options.signal?.throwIfAborted();
795
+ options.onProgress?.("done", 100);
796
+ options.signal?.throwIfAborted();
797
+ return {
798
+ duration: capture.totalDuration,
799
+ frameCount,
800
+ outputPath: options.outputPath
801
+ };
802
+ } finally {
803
+ capture.frames.length = 0;
804
+ }
805
+ }
806
+ async function renderDocToGif(doc, container, options) {
807
+ options.signal?.throwIfAborted();
808
+ const fps = options.fps ?? GIF_EXPORT_DEFAULTS.fps;
809
+ if (!Number.isFinite(fps) || fps <= 0 || fps > 100) {
810
+ throw new RangeError("GIF FPS must be a finite number between 1 and 100.");
811
+ }
812
+ const orientation = options.orientation ?? "landscape";
813
+ const portrait = orientation === "portrait";
814
+ const width = options.width ?? (portrait ? GIF_EXPORT_DEFAULTS.height : GIF_EXPORT_DEFAULTS.width);
815
+ const height = options.height ?? (portrait ? GIF_EXPORT_DEFAULTS.width : GIF_EXPORT_DEFAULTS.height);
816
+ resolveDimensions({ orientation, width, height, fps });
817
+ ffmpegGifOutputArgs({
818
+ width,
819
+ height,
820
+ loop: options.loop ?? GIF_EXPORT_DEFAULTS.loop,
821
+ maxColors: options.maxColors ?? GIF_EXPORT_DEFAULTS.maxColors,
822
+ dither: options.dither ?? GIF_EXPORT_DEFAULTS.dither,
823
+ bayerScale: options.bayerScale
824
+ });
825
+ const capture = await captureDocFrames(doc, container, {
826
+ fps,
827
+ width,
828
+ height,
829
+ captionStyle: options.captionStyle,
830
+ coverPreRoll: options.coverPreRoll ?? 0,
831
+ animationsEnabled: options.animationsEnabled ?? false,
832
+ onProgress: options.onProgress,
833
+ signal: options.signal
834
+ });
835
+ const frameCount = capture.frames.length;
836
+ try {
837
+ options.onProgress?.("encoding GIF", 80);
838
+ options.signal?.throwIfAborted();
839
+ const { framesToGifNative: framesToGifNative2 } = await import("./nativeEncoder-LRPK2YV5.js");
840
+ await framesToGifNative2(capture.ffmpegPath, capture.frames, options.outputPath, {
841
+ fps,
842
+ orientation,
843
+ width,
844
+ height,
845
+ loop: options.loop,
846
+ maxColors: options.maxColors,
847
+ dither: options.dither,
848
+ bayerScale: options.bayerScale,
849
+ signal: options.signal,
850
+ onProgress: (percent, phase) => options.onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2))
851
+ });
852
+ options.signal?.throwIfAborted();
853
+ options.onProgress?.("done", 100);
854
+ options.signal?.throwIfAborted();
855
+ const hasAudio = (doc.audio?.segments?.length ?? 0) > 0 || resolveMediaSchedule(doc).some((clip) => clip.kind === "audio");
856
+ return {
857
+ duration: capture.totalDuration,
858
+ frameCount,
859
+ outputPath: options.outputPath,
860
+ warnings: hasAudio ? ["Animated GIF does not support audio; audio tracks were omitted."] : []
861
+ };
862
+ } finally {
863
+ capture.frames.length = 0;
864
+ }
865
+ }
866
+ async function extractThumbnails(options) {
867
+ const { videoPath, outputDir, slug, sizes, force, signal } = options;
868
+ const { existsSync } = await import("fs");
869
+ const { rm: rm2 } = await import("fs/promises");
870
+ const { join: join3 } = await import("path");
871
+ signal?.throwIfAborted();
872
+ const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
873
+ if (!ffmpegPath) {
874
+ throw new Error(
875
+ "ffmpeg is required for thumbnail extraction but not found in PATH.\nInstall it with:\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg\n Windows: winget install ffmpeg"
876
+ );
877
+ }
878
+ const generatedPaths = [];
879
+ try {
880
+ for (const thumb of sizes) {
881
+ signal?.throwIfAborted();
882
+ const outputPath = join3(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
883
+ if (!force && existsSync(outputPath)) continue;
884
+ try {
885
+ await runFfmpeg(
886
+ ffmpegPath,
887
+ ["-y", "-i", videoPath, "-vf", thumb.filter, "-frames:v", "1", "-q:v", "2", outputPath],
888
+ {
889
+ timeoutMs: 3e4,
890
+ failureMessage: `Thumbnail extraction failed (${thumb.name})`,
891
+ signal
892
+ }
893
+ );
894
+ signal?.throwIfAborted();
895
+ generatedPaths.push(outputPath);
896
+ } catch (error) {
897
+ await rm2(outputPath, { force: true });
898
+ throw error;
899
+ }
900
+ }
901
+ signal?.throwIfAborted();
902
+ } catch (error) {
903
+ await Promise.allSettled(generatedPaths.map((path) => rm2(path, { force: true })));
904
+ throw error;
905
+ }
906
+ }
907
+
908
+ export {
909
+ readInput,
910
+ getFfmpegVersion,
911
+ detectFfmpegDetailed,
912
+ MAX_CAPTURED_FRAME_BYTES,
913
+ CapturedFrameBudgetError,
914
+ convert,
915
+ prepareConversion,
916
+ renderDocToMp4,
917
+ renderDocToGif,
918
+ extractThumbnails,
919
+ MemoryContentContainer2 as MemoryContentContainer,
920
+ ConversionError,
921
+ createCliRegistry
922
+ };
923
+ //# sourceMappingURL=chunk-FVFK4ZJU.js.map