@bendyline/squisq-cli 1.2.1 → 2.0.0

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/index.js CHANGED
@@ -1,4 +1,14 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ convert,
4
+ createCliRegistry,
5
+ detectFfmpegDetailed,
6
+ getFfmpegVersion,
7
+ readInput,
8
+ renderDocToGif,
9
+ renderDocToMp4
10
+ } from "./chunk-JEE26SZS.js";
11
+ import "./chunk-VM3ZFP3J.js";
2
12
 
3
13
  // src/index.ts
4
14
  import { createRequire } from "module";
@@ -6,503 +16,8 @@ import { Command } from "commander";
6
16
 
7
17
  // src/commands/convert.ts
8
18
  import { writeFile, mkdir } from "fs/promises";
9
- import { dirname, basename, extname as extname2, join as join3, resolve } from "path";
10
- import { BUILTIN_FORMAT_IDS, ConversionError as ConversionError2 } from "@bendyline/squisq-formats";
11
-
12
- // src/util/domPolyfill.ts
13
- import { DOMParser as XmldomDOMParser } from "@xmldom/xmldom";
14
- var globalScope = globalThis;
15
- if (typeof globalScope.DOMParser === "undefined") {
16
- globalScope.DOMParser = XmldomDOMParser;
17
- }
18
-
19
- // src/util/readInput.ts
20
- import { readFile, readdir, stat } from "fs/promises";
21
- import { join, extname } from "path";
22
- import { parseMarkdown, stringifyMarkdown } from "@bendyline/squisq/markdown";
23
- import { markdownToDoc } from "@bendyline/squisq/doc";
24
- import { MemoryContentContainer } from "@bendyline/squisq/storage";
25
- import { zipToContainer } from "@bendyline/squisq-formats/container";
26
- import { defaultRegistry } from "@bendyline/squisq-formats";
27
- var MIME_TYPES = {
28
- ".md": "text/markdown",
29
- ".txt": "text/plain",
30
- ".json": "application/json",
31
- ".jpg": "image/jpeg",
32
- ".jpeg": "image/jpeg",
33
- ".png": "image/png",
34
- ".gif": "image/gif",
35
- ".webp": "image/webp",
36
- ".svg": "image/svg+xml",
37
- ".mp3": "audio/mpeg",
38
- ".wav": "audio/wav",
39
- ".ogg": "audio/ogg",
40
- ".mp4": "video/mp4",
41
- ".webm": "video/webm"
42
- };
43
- var IMPORTER_EXTS = [".docx", ".pptx", ".pdf", ".xlsx", ".csv", ".html", ".htm"];
44
- function mimeFromExt(filePath) {
45
- return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
46
- }
47
- async function walkDir(root, prefix = "") {
48
- const entries = await readdir(root, { withFileTypes: true });
49
- const paths = [];
50
- for (const entry of entries) {
51
- const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
52
- if (entry.isDirectory()) {
53
- paths.push(...await walkDir(join(root, entry.name), relPath));
54
- } else if (entry.isFile()) {
55
- paths.push(relPath);
56
- }
57
- }
58
- return paths;
59
- }
60
- async function readInput(inputPath) {
61
- const info = await stat(inputPath);
62
- if (info.isDirectory()) {
63
- return readFolder(inputPath);
64
- }
65
- const ext = extname(inputPath).toLowerCase();
66
- if (ext === ".zip" || ext === ".dbk") {
67
- return readContainer(inputPath);
68
- }
69
- if (ext === ".json") {
70
- return readDocJsonFile(inputPath);
71
- }
72
- if (IMPORTER_EXTS.includes(ext)) {
73
- const def = defaultRegistry().byExtension(ext);
74
- if (def && (def.importContainer || def.importDoc)) {
75
- return readViaImporter(inputPath, def);
76
- }
77
- }
78
- return readMarkdownFile(inputPath);
79
- }
80
- async function readArrayBuffer(filePath) {
81
- const data = await readFile(filePath);
82
- return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
83
- }
84
- async function readMarkdownFile(filePath) {
85
- const content = await readFile(filePath, "utf-8");
86
- const container = new MemoryContentContainer();
87
- await container.writeDocument(content);
88
- const markdownDoc = parseMarkdown(content);
89
- return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: "md" };
90
- }
91
- async function readDocJsonFile(filePath) {
92
- const content = await readFile(filePath, "utf-8");
93
- const doc = JSON.parse(content);
94
- const container = new MemoryContentContainer();
95
- return { doc, container, sourceFormat: "json" };
96
- }
97
- async function readViaImporter(filePath, def) {
98
- const buffer = await readArrayBuffer(filePath);
99
- let container;
100
- let markdownDoc;
101
- if (def.importContainer) {
102
- container = await def.importContainer(buffer, {});
103
- const text = await container.readDocument();
104
- markdownDoc = text ? parseMarkdown(text) : { type: "document", children: [] };
105
- } else {
106
- markdownDoc = await def.importDoc(buffer, {});
107
- const mem = new MemoryContentContainer();
108
- await mem.writeDocument(stringifyMarkdown(markdownDoc));
109
- container = mem;
110
- }
111
- return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: def.id };
112
- }
113
- var DOC_JSON_NAMES = ["doc.json", "story.json"];
114
- async function resolveContainer(container, sourceFormat, missingMessage) {
115
- for (const name of DOC_JSON_NAMES) {
116
- const jsonData = await container.readFile(name);
117
- if (jsonData) {
118
- const doc = JSON.parse(new TextDecoder().decode(jsonData));
119
- return { doc, container, sourceFormat };
120
- }
121
- }
122
- const markdown = await container.readDocument();
123
- if (!markdown) {
124
- throw new Error(missingMessage);
125
- }
126
- const markdownDoc = parseMarkdown(markdown);
127
- return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat };
128
- }
129
- async function readContainer(filePath) {
130
- const container = await zipToContainer(await readArrayBuffer(filePath));
131
- return resolveContainer(
132
- container,
133
- "dbk",
134
- `No markdown document or doc.json found in container: ${filePath}`
135
- );
136
- }
137
- async function readFolder(dirPath) {
138
- const container = new MemoryContentContainer();
139
- const files = await walkDir(dirPath);
140
- for (const relPath of files) {
141
- const absPath = join(dirPath, relPath);
142
- const data = await readFile(absPath);
143
- await container.writeFile(
144
- relPath,
145
- new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
146
- mimeFromExt(relPath)
147
- );
148
- }
149
- return resolveContainer(
150
- container,
151
- "folder",
152
- `No markdown document or doc.json found in folder: ${dirPath}`
153
- );
154
- }
155
-
156
- // src/registry.ts
157
- import { randomBytes } from "crypto";
158
- import { readFile as readFile2, rm } from "fs/promises";
159
- import { tmpdir } from "os";
160
- import { join as join2 } from "path";
161
- import { defaultRegistry as defaultRegistry2 } from "@bendyline/squisq-formats";
162
-
163
- // src/api.ts
164
- import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
165
- import { flattenBlocks } from "@bendyline/squisq/doc";
166
- import { generateRenderHtml } from "@bendyline/squisq-video";
167
- import { resolveDimensions } from "@bendyline/squisq-video";
168
- import { convert as formatsConvert } from "@bendyline/squisq-formats";
169
-
170
- // src/util/detectFfmpeg.ts
171
- import { execFile } from "child_process";
172
- function run(command, args) {
173
- return new Promise((resolve4) => {
174
- execFile(command, args, { timeout: 5e3 }, (err, stdout) => {
175
- if (err || !stdout.trim()) {
176
- resolve4(null);
177
- return;
178
- }
179
- resolve4(stdout.trim());
180
- });
181
- });
182
- }
183
- async function getFfmpegVersion(path) {
184
- const out = await run(path, ["-version"]);
185
- return out ? out.split("\n")[0].trim() : null;
186
- }
187
- async function detectFfmpegDetailed() {
188
- const envPath = process.env.SQUISQ_FFMPEG;
189
- if (envPath) {
190
- const version2 = await getFfmpegVersion(envPath);
191
- if (!version2) {
192
- throw new Error(
193
- `SQUISQ_FFMPEG is set to "${envPath}" but running "${envPath} -version" failed. Fix the path or unset SQUISQ_FFMPEG.`
194
- );
195
- }
196
- return { path: envPath, source: "env" };
197
- }
198
- const command = process.platform === "win32" ? "where" : "which";
199
- const found = await run(command, ["ffmpeg"]);
200
- if (found) {
201
- return { path: found.split("\n")[0].trim(), source: "path" };
202
- }
203
- try {
204
- const specifier = "ffmpeg-static";
205
- const mod = await import(specifier);
206
- const candidate = typeof mod === "string" ? mod : typeof mod.default === "string" ? mod.default : null;
207
- if (candidate) {
208
- return { path: candidate, source: "ffmpeg-static" };
209
- }
210
- } catch {
211
- }
212
- return null;
213
- }
214
- async function detectFfmpeg() {
215
- const detection = await detectFfmpegDetailed();
216
- return detection?.path ?? null;
217
- }
218
-
219
- // src/util/audioMix.ts
220
- import { computeAudioTimeline } from "@bendyline/squisq-video";
221
- async function buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll) {
222
- const timeline = computeAudioTimeline(doc, coverPreRoll);
223
- if (timeline.length === 0) return null;
224
- const bytesBySrc = /* @__PURE__ */ new Map();
225
- const readSrc = async (src) => {
226
- if (bytesBySrc.has(src)) return bytesBySrc.get(src);
227
- const data = await container.readFile(src);
228
- bytesBySrc.set(src, data ?? null);
229
- return data ?? null;
230
- };
231
- const usable = [];
232
- for (const clip of timeline) {
233
- const buffer = await readSrc(clip.src);
234
- if (buffer) usable.push({ clip, buffer });
235
- }
236
- if (usable.length === 0) return null;
237
- return mixTimelineClips(ffmpegPath, usable);
238
- }
239
- async function mixTimelineClips(ffmpegPath, usable) {
240
- const { writeFile: writeFile2, readFile: readFile3, mkdir: mkdir3, rm: rm2 } = await import("fs/promises");
241
- const { join: join5 } = await import("path");
242
- const { tmpdir: tmpdir2 } = await import("os");
243
- const { randomBytes: randomBytes2 } = await import("crypto");
244
- const { execFile: execFile2 } = await import("child_process");
245
- const workDir = join5(tmpdir2(), `squisq-audio-mix-${randomBytes2(8).toString("hex")}`);
246
- await mkdir3(workDir, { recursive: true });
247
- const ms = (s) => Math.max(0, Math.round(s * 1e3));
248
- try {
249
- const inputs = [];
250
- const filters = [];
251
- const labels = [];
252
- for (const { clip, buffer } of usable) {
253
- const p = join5(workDir, `clip-${inputs.length}.mp3`);
254
- await writeFile2(p, new Uint8Array(buffer));
255
- const i = inputs.push(p) - 1;
256
- const delayMs = ms(clip.startSec);
257
- const start = Math.max(0, clip.sourceInSec);
258
- const end = start + Math.max(0, clip.durationSec);
259
- filters.push(
260
- `[${i}:a]atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS,adelay=${delayMs}|${delayMs}[a${i}]`
261
- );
262
- labels.push(`[a${i}]`);
263
- }
264
- const graph = `${filters.join(";")};${labels.join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[aout]`;
265
- const outputPath = join5(workDir, "mixed.mp3");
266
- const args = ["-y"];
267
- for (const p of inputs) args.push("-i", p);
268
- args.push(
269
- "-filter_complex",
270
- graph,
271
- "-map",
272
- "[aout]",
273
- "-c:a",
274
- "libmp3lame",
275
- "-b:a",
276
- "192k",
277
- outputPath
278
- );
279
- await new Promise((resolve4, reject) => {
280
- execFile2(ffmpegPath, args, { timeout: 18e4 }, (err) => {
281
- if (err) reject(new Error(`ffmpeg audio mix failed: ${err.message}`));
282
- else resolve4();
283
- });
284
- });
285
- const data = await readFile3(outputPath);
286
- return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
287
- } finally {
288
- await rm2(workDir, { recursive: true, force: true });
289
- }
290
- }
291
-
292
- // src/api.ts
293
- import { MemoryContentContainer as MemoryContentContainer2 } from "@bendyline/squisq/storage";
294
- import { ConversionError } from "@bendyline/squisq-formats";
295
- async function convert(source, to, options = {}) {
296
- return formatsConvert(source, to, {
297
- registry: createCliRegistry(),
298
- resolvePlayerScript: () => import("@bendyline/squisq-react/standalone-source").then((m) => m.PLAYER_BUNDLE),
299
- ...options
300
- });
301
- }
302
- async function renderDocToMp4(doc, container, options) {
303
- const {
304
- outputPath,
305
- fps = 30,
306
- quality = "normal",
307
- orientation = "landscape",
308
- captionStyle,
309
- coverPreRoll = 0,
310
- onProgress
311
- } = options;
312
- const dimensions = resolveDimensions({
313
- orientation,
314
- width: options.width,
315
- height: options.height
316
- });
317
- const ffmpegPath = await detectFfmpeg();
318
- if (!ffmpegPath) {
319
- throw new Error(
320
- "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."
321
- );
322
- }
323
- onProgress?.("collecting media", 0);
324
- const { collectImagePaths } = await import("@bendyline/squisq-formats/html");
325
- const imagePaths = collectImagePaths(doc);
326
- const images = /* @__PURE__ */ new Map();
327
- for (const imgPath of imagePaths) {
328
- const data = await container.readFile(imgPath);
329
- if (data) {
330
- images.set(imgPath, data);
331
- }
332
- }
333
- const audio = /* @__PURE__ */ new Map();
334
- if (doc.audio?.segments?.length) {
335
- for (const seg of doc.audio.segments) {
336
- const data = await container.readFile(seg.src);
337
- if (data) {
338
- audio.set(seg.src, data);
339
- audio.set(seg.name, data);
340
- }
341
- }
342
- }
343
- const mediaSrcs = new Set(resolveMediaSchedule(doc).map((c) => c.src));
344
- for (const block of flattenBlocks(doc.blocks)) {
345
- for (const layer of block.layers ?? []) {
346
- if (layer.type === "video") mediaSrcs.add(layer.content.src);
347
- }
348
- }
349
- for (const src of mediaSrcs) {
350
- if (images.has(src)) continue;
351
- const data = await container.readFile(src);
352
- if (data) images.set(src, data);
353
- }
354
- onProgress?.("generating render HTML", 10);
355
- const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
356
- const renderHtml = generateRenderHtml(doc, {
357
- playerScript: PLAYER_BUNDLE,
358
- images,
359
- audio: audio.size > 0 ? audio : void 0,
360
- width: dimensions.width,
361
- height: dimensions.height,
362
- captionStyle
363
- });
364
- onProgress?.("launching browser", 15);
365
- const { chromium } = await import("playwright-core");
366
- let browser;
367
- try {
368
- browser = await chromium.launch({ headless: true });
369
- } catch (err) {
370
- const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
371
- throw new Error(
372
- `Playwright Chromium is not installed. Run: npx playwright install chromium
373
- (launch failed: ${detail})`
374
- );
375
- }
376
- const page = await browser.newPage({
377
- viewport: { width: dimensions.width, height: dimensions.height }
378
- });
379
- const pageErrors = [];
380
- page.on("pageerror", (err) => pageErrors.push(err.message));
381
- await page.setContent(renderHtml, { waitUntil: "load" });
382
- await page.waitForTimeout(500);
383
- try {
384
- await page.waitForFunction(
385
- () => typeof window.getDuration === "function",
386
- { timeout: 15e3 }
387
- );
388
- } catch {
389
- await browser.close();
390
- const errorDetail = pageErrors.length ? `
391
- Page errors:
392
- ${pageErrors.join("\n ")}` : "\nNo page errors captured \u2014 the player may have failed to mount.";
393
- throw new Error(
394
- `The standalone player failed to boot in headless Chromium. Render API did not initialize within 15 seconds.${errorDetail}`
395
- );
396
- }
397
- const docDuration = await page.evaluate(() => {
398
- return window.getDuration();
399
- });
400
- if (docDuration <= 0) {
401
- await browser.close();
402
- throw new Error("Document has zero duration \u2014 nothing to render");
403
- }
404
- const storyFrameCount = Math.ceil(docDuration * fps);
405
- const preRollFrameCount = Math.ceil(coverPreRoll * fps);
406
- const totalFrames = preRollFrameCount + storyFrameCount;
407
- const frames = [];
408
- onProgress?.("capturing frames", 20);
409
- if (preRollFrameCount > 0) {
410
- const hasCover = await page.evaluate(() => {
411
- const w = window;
412
- return typeof w.hasCoverBlock === "function" ? w.hasCoverBlock() : false;
413
- });
414
- if (hasCover) {
415
- await page.evaluate(() => {
416
- window.showCover();
417
- });
418
- await page.waitForTimeout(100);
419
- const coverFrame = new Uint8Array(await page.screenshot({ type: "png" }));
420
- for (let i = 0; i < preRollFrameCount; i++) {
421
- frames.push(coverFrame);
422
- }
423
- await page.evaluate(() => {
424
- window.hideCover();
425
- });
426
- }
427
- }
428
- const frameInterval = 1 / fps;
429
- for (let i = 0; i < storyFrameCount; i++) {
430
- const time = i * frameInterval;
431
- await page.evaluate((t) => {
432
- return window.seekTo(t);
433
- }, time);
434
- const screenshot = await page.screenshot({ type: "png" });
435
- frames.push(new Uint8Array(screenshot));
436
- if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {
437
- const pct = 20 + Math.round(frames.length / totalFrames * 60);
438
- onProgress?.("capturing frames", pct);
439
- }
440
- }
441
- await browser.close();
442
- onProgress?.("encoding video", 80);
443
- const encodingAudio = await buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll);
444
- const { framesToMp4Native } = await import("./nativeEncoder-DLLAT2RT.js");
445
- await framesToMp4Native(ffmpegPath, frames, encodingAudio, outputPath, {
446
- fps,
447
- quality,
448
- orientation,
449
- width: dimensions.width,
450
- height: dimensions.height,
451
- onProgress: (percent, phase) => {
452
- onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2));
453
- }
454
- });
455
- onProgress?.("done", 100);
456
- const totalDuration = docDuration + coverPreRoll;
457
- return {
458
- duration: totalDuration,
459
- frameCount: frames.length,
460
- outputPath
461
- };
462
- }
463
-
464
- // src/registry.ts
465
- var MP4_DEFAULTS = {
466
- fps: 30,
467
- quality: "normal",
468
- orientation: "landscape",
469
- coverPreRoll: 0
470
- };
471
- function mp4Format() {
472
- return {
473
- id: "mp4",
474
- label: "MP4 Video",
475
- mimeType: "video/mp4",
476
- extensions: [".mp4"],
477
- async exportDoc(input, options) {
478
- const mp4Opts = options.formatOptions?.mp4 ?? {};
479
- const fps = typeof mp4Opts.fps === "number" ? mp4Opts.fps : MP4_DEFAULTS.fps;
480
- const quality = typeof mp4Opts.quality === "string" ? mp4Opts.quality : MP4_DEFAULTS.quality;
481
- const orientation = typeof mp4Opts.orientation === "string" ? mp4Opts.orientation : MP4_DEFAULTS.orientation;
482
- const coverPreRoll = typeof mp4Opts.coverPreRoll === "number" ? mp4Opts.coverPreRoll : MP4_DEFAULTS.coverPreRoll;
483
- const outputPath = join2(tmpdir(), `squisq-mp4-${randomBytes(8).toString("hex")}.mp4`);
484
- try {
485
- await renderDocToMp4(input.doc, input.container, {
486
- outputPath,
487
- fps,
488
- quality,
489
- orientation,
490
- coverPreRoll
491
- });
492
- const data = await readFile2(outputPath);
493
- const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
494
- return { bytes, mimeType: "video/mp4", suggestedFilename: "", warnings: [] };
495
- } finally {
496
- await rm(outputPath, { force: true });
497
- }
498
- }
499
- };
500
- }
501
- function createCliRegistry() {
502
- const registry = defaultRegistry2();
503
- registry.register(mp4Format());
504
- return registry;
505
- }
19
+ import { dirname, basename, extname, join, resolve } from "path";
20
+ import { BUILTIN_FORMAT_IDS, ConversionError } from "@bendyline/squisq-formats";
506
21
 
507
22
  // src/util/applyTransformPipeline.ts
508
23
  async function assertValidTransformStyle(style) {
@@ -541,7 +56,7 @@ async function applyTransformToDoc(doc, transformStyle, themeId) {
541
56
  }
542
57
 
543
58
  // src/commands/convert.ts
544
- var VALID_FORMATS = [...BUILTIN_FORMAT_IDS, "mp4"];
59
+ var VALID_FORMATS = [...BUILTIN_FORMAT_IDS, "mp4", "gif"];
545
60
  var DEFAULT_FORMATS = [
546
61
  "docx",
547
62
  "pptx",
@@ -572,33 +87,38 @@ function parseFormats(value) {
572
87
  function formatFromOutputPath(outputPath) {
573
88
  const lower = outputPath.toLowerCase();
574
89
  if (lower.endsWith(".html.zip")) return "htmlzip";
575
- const ext = extname2(lower);
90
+ const ext = extname(lower);
576
91
  const def = createCliRegistry().byExtension(ext);
577
92
  if (!def) {
578
93
  throw new Error(
579
- `Cannot infer a format from output extension "${ext || "(none)"}". Use --format to specify one. Valid: ${VALID_FORMATS.join(", ")}`
94
+ `Cannot infer a format from output extension "${ext || "(none)"}". Use --formats to specify one. Valid: ${VALID_FORMATS.join(", ")}`
580
95
  );
581
96
  }
582
97
  return def.id;
583
98
  }
584
99
  function registerConvertCommand(program2) {
585
- program2.command("convert").description("Convert a document to DOCX, PPTX, PDF, HTML, EPUB, MP4, and container formats").argument("<input>", "Path to .md/.docx/.pptx/.pdf/.xlsx/.csv/.html file, .zip/.dbk, or folder").option("-o, --output <file>", "Single output file (format inferred from its extension)").option(
100
+ program2.command("convert").description(
101
+ "Convert a document to DOCX, PPTX, PDF, HTML, EPUB, MP4, animated GIF, and container formats"
102
+ ).argument("<input>", "Path to .md/.docx/.pptx/.pdf/.xlsx/.csv/.html file, .zip/.dbk, or folder").option("-o, --output <file>", "Single output file (format inferred from its extension)").option(
586
103
  "-d, --output-dir <dir>",
587
104
  "Output directory for multi-format export (default: same as input)"
588
105
  ).option(
589
106
  "-f, --formats <list>",
590
107
  `Comma-separated formats to produce (default: a standard set). Valid: ${VALID_FORMATS.join(", ")}`
591
- ).option("--format <id>", "Produce a single format (alias for a one-entry --formats)").option("-t, --theme <id>", "Squisq theme ID to apply (e.g., documentary, cinematic, bold)").option(
108
+ ).option("-t, --theme <id>", "Squisq theme ID to apply (e.g., documentary, cinematic, bold)").option(
592
109
  "--transform <style>",
593
110
  "Transform style to apply before export (e.g., documentary, magazine, minimal)"
594
111
  ).option(
595
112
  "--no-auto-templates",
596
113
  "Disable content-aware template auto-picking for unannotated headings"
597
- ).action(async (inputPath, opts) => {
114
+ ).option(
115
+ "--no-infer-theme",
116
+ "Do not infer a Squisq theme from an office input file (PPTX theme colors/fonts)"
117
+ ).option("--no-infer-layouts", "Do not derive custom layout templates from PPTX slide layouts").option("--animations", "Enable slide animations/transitions in rendered media").option("--no-animations", "Disable slide animations/transitions in rendered media").action(async (inputPath, opts) => {
598
118
  try {
599
119
  await runConvert(inputPath, opts);
600
120
  } catch (err) {
601
- if (err instanceof ConversionError2) {
121
+ if (err instanceof ConversionError) {
602
122
  console.error(`Error: ${err.message}`);
603
123
  if (err.hint) console.error(` ${err.hint}`);
604
124
  } else {
@@ -611,21 +131,13 @@ function registerConvertCommand(program2) {
611
131
  }
612
132
  function resolveFormats(opts) {
613
133
  const hasOutput = Boolean(opts.output);
614
- const hasFormatSelection = Boolean(opts.formats || opts.format);
134
+ const hasFormatSelection = Boolean(opts.formats);
615
135
  if (hasOutput && hasFormatSelection) {
616
- throw new Error(
617
- "--output is a single-file destination and cannot be combined with --formats/--format."
618
- );
136
+ throw new Error("--output is a single-file destination and cannot be combined with --formats.");
619
137
  }
620
138
  if (hasOutput) {
621
139
  return [formatFromOutputPath(opts.output)];
622
140
  }
623
- if (opts.format) {
624
- if (!isValidFormat(opts.format.toLowerCase())) {
625
- throw new Error(`Unknown format "${opts.format}". Valid: ${VALID_FORMATS.join(", ")}`);
626
- }
627
- return [opts.format.toLowerCase()];
628
- }
629
141
  if (opts.formats) {
630
142
  return parseFormats(opts.formats);
631
143
  }
@@ -635,13 +147,16 @@ async function runConvert(inputPath, opts) {
635
147
  const resolvedInput = resolve(inputPath);
636
148
  const formats = resolveFormats(opts);
637
149
  const inputBasename = basename(resolvedInput);
638
- const inputExt = extname2(inputBasename);
150
+ const inputExt = extname(inputBasename);
639
151
  const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
640
152
  if (opts.transform) {
641
153
  await assertValidTransformStyle(opts.transform);
642
154
  }
643
155
  console.error(`Reading: ${resolvedInput}`);
644
- const result = await readInput(resolvedInput);
156
+ const result = await readInput(resolvedInput, {
157
+ inferTheme: opts.inferTheme,
158
+ inferLayouts: opts.inferLayouts
159
+ });
645
160
  if (opts.theme) {
646
161
  await assertValidThemeId(opts.theme, result);
647
162
  }
@@ -655,16 +170,18 @@ async function runConvert(inputPath, opts) {
655
170
  console.error(` Applying transform: ${opts.transform}`);
656
171
  }
657
172
  for (const format of formats) {
173
+ const formatOptions = (format === "mp4" || format === "gif") && opts.animations !== void 0 ? { [format]: { animationsEnabled: opts.animations } } : void 0;
658
174
  const conversion = await convert(source, format, {
659
175
  themeId: opts.theme,
660
176
  transformStyle: opts.transform,
661
177
  autoTemplates: opts.autoTemplates,
662
- title: baseName
178
+ title: baseName,
179
+ formatOptions
663
180
  });
664
181
  for (const warning of conversion.warnings) {
665
182
  console.error(` \u26A0 ${warning}`);
666
183
  }
667
- const outPath = opts.output ? resolve(opts.output) : join3(
184
+ const outPath = opts.output ? resolve(opts.output) : join(
668
185
  opts.outputDir ? resolve(opts.outputDir) : dirname(resolvedInput),
669
186
  conversion.suggestedFilename
670
187
  );
@@ -677,15 +194,16 @@ async function runConvert(inputPath, opts) {
677
194
 
678
195
  // src/commands/video.ts
679
196
  import { mkdir as mkdir2 } from "fs/promises";
680
- import { dirname as dirname2, basename as basename2, extname as extname3, resolve as resolve2 } from "path";
197
+ import { dirname as dirname2, basename as basename2, extname as extname2, resolve as resolve2 } from "path";
681
198
  var VALID_QUALITIES = ["draft", "normal", "high"];
682
199
  var VALID_ORIENTATIONS = ["landscape", "portrait"];
683
200
  var VALID_CAPTIONS = ["off", "standard", "social"];
201
+ var VALID_FORMATS2 = ["mp4", "gif"];
202
+ var VALID_GIF_DITHERS = ["bayer", "sierra2_4a", "none"];
684
203
  function registerVideoCommand(program2) {
685
- program2.command("video").description("Render a squisq document to MP4 video").argument("<input>", "Path to .md file, .zip/.dbk container, or folder").argument("[output]", "Output MP4 path (default: <input>.mp4)").option("-o, --output <path>", "Output MP4 path (default: <input>.mp4)").option("--fps <number>", "Frames per second (default: 30)", "30").option(
204
+ program2.command("video").description("Render a squisq document to MP4 video or animated GIF").argument("<input>", "Path to .md file, .zip/.dbk container, or folder").argument("[output]", "Output .mp4 or .gif path (default: <input>.<format>)").option("-o, --output <path>", "Output .mp4 or .gif path (format inferred from extension)").option("--format <format>", `Output format: ${VALID_FORMATS2.join(", ")} (default: mp4)`).option("--fps <number>", "Frames per second (default: 30 for MP4, 10 for GIF)").option(
686
205
  "--quality <level>",
687
- `Encoding quality: ${VALID_QUALITIES.join(", ")} (default: normal)`,
688
- "normal"
206
+ `MP4 encoding quality: ${VALID_QUALITIES.join(", ")} (default: normal)`
689
207
  ).option(
690
208
  "--orientation <orient>",
691
209
  `Video orientation: ${VALID_ORIENTATIONS.join(", ")} (default: landscape)`,
@@ -704,7 +222,10 @@ function registerVideoCommand(program2) {
704
222
  "--cover-preroll <seconds>",
705
223
  "Seconds of cover-slide pre-roll before the story starts (default: 2)",
706
224
  "2"
707
- ).option("--width <pixels>", "Override video width").option("--height <pixels>", "Override video height").action(async (inputPath, outputArg, opts) => {
225
+ ).option("--width <pixels>", "Override video width").option("--height <pixels>", "Override video height").option("--animations", "Enable slide animations/transitions (GIF default: disabled)").option("--no-animations", "Disable slide animations/transitions").option("--loop <count>", "GIF repeat count: 0 forever, -1 no loop (default: 0)").option("--max-colors <count>", "GIF palette size from 2 to 256 (default: 256)").option(
226
+ "--dither <algorithm>",
227
+ `GIF dithering: ${VALID_GIF_DITHERS.join(", ")} (default: sierra2_4a)`
228
+ ).option("--bayer-scale <number>", "GIF Bayer dither strength from 0 to 5 (default: 3)").action(async (inputPath, outputArg, opts) => {
708
229
  try {
709
230
  if (outputArg && !opts.output) {
710
231
  opts.output = outputArg;
@@ -719,11 +240,30 @@ function registerVideoCommand(program2) {
719
240
  }
720
241
  async function runVideo(inputPath, opts) {
721
242
  const resolvedInput = resolve2(inputPath);
722
- const fps = parseInt(opts.fps ?? "30", 10);
723
- if (isNaN(fps) || fps < 1 || fps > 120) {
724
- throw new Error("FPS must be a number between 1 and 120");
243
+ const requestedFormat = opts.format?.toLowerCase();
244
+ if (requestedFormat !== void 0 && !VALID_FORMATS2.includes(requestedFormat)) {
245
+ throw new Error(`Invalid format "${requestedFormat}". Valid: ${VALID_FORMATS2.join(", ")}`);
246
+ }
247
+ const outputExtension = opts.output ? extname2(opts.output).toLowerCase() : "";
248
+ const extensionFormat = outputExtension === ".gif" ? "gif" : outputExtension === ".mp4" ? "mp4" : void 0;
249
+ if (opts.output && !extensionFormat) {
250
+ throw new Error("Output path must end in .mp4 or .gif");
251
+ }
252
+ if (requestedFormat && extensionFormat && requestedFormat !== extensionFormat) {
253
+ throw new Error(
254
+ `Output extension "${outputExtension}" conflicts with --format ${requestedFormat}.`
255
+ );
256
+ }
257
+ const outputFormat = requestedFormat ?? extensionFormat ?? "mp4";
258
+ const maxFps = outputFormat === "gif" ? 100 : 120;
259
+ const fps = parseInt(opts.fps ?? (outputFormat === "gif" ? "10" : "30"), 10);
260
+ if (isNaN(fps) || fps < 1 || fps > maxFps) {
261
+ throw new Error(`FPS must be a number between 1 and ${maxFps}`);
725
262
  }
726
263
  const quality = opts.quality ?? "normal";
264
+ if (outputFormat === "gif" && opts.quality !== void 0) {
265
+ throw new Error("--quality only applies to MP4 output");
266
+ }
727
267
  if (!VALID_QUALITIES.includes(quality)) {
728
268
  throw new Error(`Invalid quality "${quality}". Valid: ${VALID_QUALITIES.join(", ")}`);
729
269
  }
@@ -742,13 +282,33 @@ async function runVideo(inputPath, opts) {
742
282
  if (isNaN(coverPreRoll) || coverPreRoll < 0) {
743
283
  throw new Error("Cover pre-roll must be a number of seconds >= 0");
744
284
  }
285
+ const animationsEnabled = opts.animations ?? outputFormat === "mp4";
286
+ const loop = opts.loop === void 0 ? 0 : Number(opts.loop);
287
+ const maxColors = opts.maxColors === void 0 ? 256 : Number(opts.maxColors);
288
+ const bayerScale = opts.bayerScale === void 0 ? void 0 : Number(opts.bayerScale);
289
+ const dither = opts.dither ?? "sierra2_4a";
290
+ if (outputFormat === "mp4" && (opts.loop || opts.maxColors || opts.dither || opts.bayerScale)) {
291
+ throw new Error("--loop, --max-colors, --dither, and --bayer-scale only apply to GIF output");
292
+ }
293
+ if (!Number.isSafeInteger(loop) || loop < -1 || loop > 65535) {
294
+ throw new Error("GIF loop must be an integer between -1 and 65535");
295
+ }
296
+ if (!Number.isSafeInteger(maxColors) || maxColors < 2 || maxColors > 256) {
297
+ throw new Error("GIF max colors must be an integer between 2 and 256");
298
+ }
299
+ if (!VALID_GIF_DITHERS.includes(dither)) {
300
+ throw new Error(`Invalid GIF dither "${dither}". Valid: ${VALID_GIF_DITHERS.join(", ")}`);
301
+ }
302
+ if (bayerScale !== void 0 && (!Number.isSafeInteger(bayerScale) || bayerScale < 0 || bayerScale > 5)) {
303
+ throw new Error("GIF Bayer scale must be an integer between 0 and 5");
304
+ }
745
305
  if (opts.transform) {
746
306
  await assertValidTransformStyle(opts.transform);
747
307
  }
748
308
  const inputBasename = basename2(resolvedInput);
749
- const inputExt = extname3(inputBasename);
309
+ const inputExt = extname2(inputBasename);
750
310
  const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
751
- const outputPath = opts.output ? resolve2(opts.output) : resolve2(dirname2(resolvedInput), `${baseName}.mp4`);
311
+ const outputPath = opts.output ? resolve2(opts.output) : resolve2(dirname2(resolvedInput), `${baseName}.${outputFormat}`);
752
312
  await mkdir2(dirname2(outputPath), { recursive: true });
753
313
  console.error(`Reading: ${resolvedInput}`);
754
314
  const result = await readInput(resolvedInput);
@@ -759,8 +319,11 @@ async function runVideo(inputPath, opts) {
759
319
  let doc = result.doc;
760
320
  if (result.markdownDoc) {
761
321
  if (opts.autoTemplates === false) {
762
- const { markdownToDoc: markdownToDoc2 } = await import("@bendyline/squisq/doc");
763
- doc = markdownToDoc2(result.markdownDoc, { autoTemplates: false });
322
+ const { markdownToDoc, resolveAudioMapping } = await import("@bendyline/squisq/doc");
323
+ doc = await resolveAudioMapping(
324
+ markdownToDoc(result.markdownDoc, { autoTemplates: false }),
325
+ container
326
+ );
764
327
  }
765
328
  } else {
766
329
  console.error("Using pre-built Doc JSON");
@@ -772,23 +335,37 @@ async function runVideo(inputPath, opts) {
772
335
  if (opts.theme) {
773
336
  doc = { ...doc, themeId: opts.theme };
774
337
  }
338
+ const motionLabel = animationsEnabled ? "animations on" : "animations off";
339
+ const qualityLabel = outputFormat === "mp4" ? `, quality: ${quality}` : "";
775
340
  console.error(
776
- `Rendering: ${fps} fps, quality: ${quality}, orientation: ${orientation}, captions: ${captions}`
341
+ `Rendering ${outputFormat.toUpperCase()}: ${fps} fps${qualityLabel}, orientation: ${orientation}, captions: ${captions}, ${motionLabel}`
777
342
  );
778
- const result2 = await renderDocToMp4(doc, container, {
343
+ const sharedRenderOptions = {
779
344
  outputPath,
780
345
  fps,
781
- quality,
782
346
  orientation,
783
347
  width: opts.width ? parseInt(opts.width, 10) : void 0,
784
348
  height: opts.height ? parseInt(opts.height, 10) : void 0,
785
349
  captionStyle,
786
350
  coverPreRoll,
787
- onProgress: (phase, percent) => {
788
- writeProgress(phase, percent, 100);
789
- }
351
+ animationsEnabled,
352
+ onProgress: (phase, percent) => writeProgress(phase, percent, 100)
353
+ };
354
+ const result2 = outputFormat === "gif" ? await renderDocToGif(doc, container, {
355
+ ...sharedRenderOptions,
356
+ loop,
357
+ maxColors,
358
+ dither,
359
+ bayerScale
360
+ }) : await renderDocToMp4(doc, container, {
361
+ ...sharedRenderOptions,
362
+ quality
790
363
  });
791
364
  clearProgress();
365
+ const warnings = "warnings" in result2 && Array.isArray(result2.warnings) ? result2.warnings : [];
366
+ for (const warning of warnings) {
367
+ console.error(` \u26A0 ${warning}`);
368
+ }
792
369
  console.error(
793
370
  ` \u2713 ${outputPath} (${result2.duration.toFixed(1)}s, ${result2.frameCount} frames)`
794
371
  );
@@ -808,7 +385,7 @@ function clearProgress() {
808
385
 
809
386
  // src/commands/validate.ts
810
387
  import { existsSync, statSync } from "fs";
811
- import { dirname as dirname3, extname as extname4, join as join4, resolve as resolve3 } from "path";
388
+ import { dirname as dirname3, extname as extname3, join as join2, resolve as resolve3 } from "path";
812
389
  import { validateMarkdownDoc } from "@bendyline/squisq/doc";
813
390
  function registerValidateCommand(program2) {
814
391
  program2.command("validate").description("Validate a squisq markdown document and report structural problems").argument("<input>", "Path to .md file, .zip/.dbk container, or folder").option("--json", "Output diagnostics as JSON (for tooling and agents)").option("--strict", "Exit non-zero on warnings as well as errors").action(async (inputPath, opts) => {
@@ -831,11 +408,11 @@ async function runValidate(inputPath, opts) {
831
408
  }
832
409
  const entries = await container.listFiles();
833
410
  const containerPaths = new Set(entries.map((e) => e.path));
834
- const isBareMarkdown = statSync(resolvedInput).isFile() && ![".zip", ".dbk"].includes(extname4(resolvedInput).toLowerCase());
411
+ const isBareMarkdown = statSync(resolvedInput).isFile() && ![".zip", ".dbk"].includes(extname3(resolvedInput).toLowerCase());
835
412
  const baseDir = dirname3(resolvedInput);
836
413
  const hasAsset = (path) => {
837
414
  if (containerPaths.has(path) || containerPaths.has(path.replace(/^\.\//, ""))) return true;
838
- if (isBareMarkdown) return existsSync(join4(baseDir, path));
415
+ if (isBareMarkdown) return existsSync(join2(baseDir, path));
839
416
  return false;
840
417
  };
841
418
  const result = validateMarkdownDoc(markdownDoc, { assets: hasAsset });