@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/index.js CHANGED
@@ -1,18 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import { createRequire } from "module";
4
5
  import { Command } from "commander";
5
6
 
6
7
  // src/commands/convert.ts
7
8
  import { writeFile, mkdir } from "fs/promises";
8
- import { dirname, basename, extname as extname2, join as join2, resolve } from "path";
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
+ }
9
18
 
10
19
  // src/util/readInput.ts
11
20
  import { readFile, readdir, stat } from "fs/promises";
12
21
  import { join, extname } from "path";
13
- import { parseMarkdown } from "@bendyline/squisq/markdown";
22
+ import { parseMarkdown, stringifyMarkdown } from "@bendyline/squisq/markdown";
23
+ import { markdownToDoc } from "@bendyline/squisq/doc";
14
24
  import { MemoryContentContainer } from "@bendyline/squisq/storage";
15
25
  import { zipToContainer } from "@bendyline/squisq-formats/container";
26
+ import { defaultRegistry } from "@bendyline/squisq-formats";
16
27
  var MIME_TYPES = {
17
28
  ".md": "text/markdown",
18
29
  ".txt": "text/plain",
@@ -29,6 +40,7 @@ var MIME_TYPES = {
29
40
  ".mp4": "video/mp4",
30
41
  ".webm": "video/webm"
31
42
  };
43
+ var IMPORTER_EXTS = [".docx", ".pptx", ".pdf", ".xlsx", ".csv", ".html", ".htm"];
32
44
  function mimeFromExt(filePath) {
33
45
  return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
34
46
  }
@@ -57,41 +69,70 @@ async function readInput(inputPath) {
57
69
  if (ext === ".json") {
58
70
  return readDocJsonFile(inputPath);
59
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
+ }
60
78
  return readMarkdownFile(inputPath);
61
79
  }
80
+ async function readArrayBuffer(filePath) {
81
+ const data = await readFile(filePath);
82
+ return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
83
+ }
62
84
  async function readMarkdownFile(filePath) {
63
85
  const content = await readFile(filePath, "utf-8");
64
86
  const container = new MemoryContentContainer();
65
87
  await container.writeDocument(content);
66
88
  const markdownDoc = parseMarkdown(content);
67
- return { container, markdownDoc };
89
+ return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: "md" };
68
90
  }
69
91
  async function readDocJsonFile(filePath) {
70
92
  const content = await readFile(filePath, "utf-8");
71
93
  const doc = JSON.parse(content);
72
94
  const container = new MemoryContentContainer();
73
- return { container, markdownDoc: null, doc };
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 };
74
112
  }
75
113
  var DOC_JSON_NAMES = ["doc.json", "story.json"];
76
- async function readContainer(filePath) {
77
- const data = await readFile(filePath);
78
- const container = await zipToContainer(
79
- data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
80
- );
114
+ async function resolveContainer(container, sourceFormat, missingMessage) {
81
115
  for (const name of DOC_JSON_NAMES) {
82
116
  const jsonData = await container.readFile(name);
83
117
  if (jsonData) {
84
- const decoder = new TextDecoder();
85
- const doc = JSON.parse(decoder.decode(jsonData));
86
- return { container, markdownDoc: null, doc };
118
+ const doc = JSON.parse(new TextDecoder().decode(jsonData));
119
+ return { doc, container, sourceFormat };
87
120
  }
88
121
  }
89
122
  const markdown = await container.readDocument();
90
123
  if (!markdown) {
91
- throw new Error(`No markdown document or doc.json found in container: ${filePath}`);
124
+ throw new Error(missingMessage);
92
125
  }
93
126
  const markdownDoc = parseMarkdown(markdown);
94
- return { container, markdownDoc };
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
+ );
95
136
  }
96
137
  async function readFolder(dirPath) {
97
138
  const container = new MemoryContentContainer();
@@ -105,282 +146,159 @@ async function readFolder(dirPath) {
105
146
  mimeFromExt(relPath)
106
147
  );
107
148
  }
108
- for (const name of DOC_JSON_NAMES) {
109
- const jsonData = await container.readFile(name);
110
- if (jsonData) {
111
- const decoder = new TextDecoder();
112
- const doc = JSON.parse(decoder.decode(jsonData));
113
- return { container, markdownDoc: null, doc };
114
- }
115
- }
116
- const markdown = await container.readDocument();
117
- if (!markdown) {
118
- throw new Error(`No markdown document or doc.json found in folder: ${dirPath}`);
119
- }
120
- const markdownDoc = parseMarkdown(markdown);
121
- return { container, markdownDoc };
122
- }
123
-
124
- // src/commands/convert.ts
125
- var ALL_FORMATS = ["docx", "pptx", "pdf", "html", "htmlzip", "epub", "dbk"];
126
- function parseFormats(value) {
127
- const requested = value.split(",").map((s) => s.trim().toLowerCase());
128
- const valid = [];
129
- for (const r of requested) {
130
- if (ALL_FORMATS.includes(r)) {
131
- valid.push(r);
132
- } else {
133
- console.warn(`Unknown format "${r}" \u2014 skipping. Valid: ${ALL_FORMATS.join(", ")}`);
134
- }
135
- }
136
- if (valid.length === 0) {
137
- throw new Error(`No valid formats specified. Valid: ${ALL_FORMATS.join(", ")}`);
138
- }
139
- return valid;
140
- }
141
- function registerConvertCommand(program2) {
142
- program2.command("convert").description("Convert a markdown document to DOCX, PPTX, PDF, HTML, and DBK container formats").argument("<input>", "Path to .md file, .zip/.dbk container, or folder").option("-o, --output-dir <dir>", "Output directory (default: same as input)").option(
143
- "-f, --formats <list>",
144
- `Comma-separated formats to produce (default: all). Valid: ${ALL_FORMATS.join(", ")}`
145
- ).option("-t, --theme <id>", "Squisq theme ID to apply (e.g., documentary, cinematic, bold)").option(
146
- "--transform <style>",
147
- "Transform style to apply before export (e.g., documentary, magazine, minimal)"
148
- ).option(
149
- "--no-auto-templates",
150
- "Disable content-aware template auto-picking for unannotated headings"
151
- ).action(async (inputPath, opts) => {
152
- try {
153
- await runConvert(inputPath, opts);
154
- } catch (err) {
155
- const message = err instanceof Error ? err.message : String(err);
156
- console.error(`Error: ${message}`);
157
- process.exitCode = 1;
158
- }
159
- });
160
- }
161
- async function runConvert(inputPath, opts) {
162
- const resolvedInput = resolve(inputPath);
163
- const formats = opts.formats ? parseFormats(opts.formats) : [...ALL_FORMATS];
164
- const outputDir = opts.outputDir ? resolve(opts.outputDir) : dirname(resolvedInput);
165
- const inputBasename = basename(resolvedInput);
166
- const inputExt = extname2(inputBasename);
167
- const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
168
- await mkdir(outputDir, { recursive: true });
169
- if (opts.transform) {
170
- const { getTransformStyleIds } = await import("@bendyline/squisq/transform");
171
- const styles = getTransformStyleIds();
172
- if (!styles.includes(opts.transform)) {
173
- throw new Error(
174
- `Unknown transform style "${opts.transform}". Available: ${styles.join(", ")}`
175
- );
176
- }
177
- }
178
- console.error(`Reading: ${resolvedInput}`);
179
- const result = await readInput(resolvedInput);
180
- const { container } = result;
181
- if (!result.markdownDoc) {
182
- throw new Error(
183
- "Convert command requires a markdown document. JSON Doc input is not supported for convert \u2014 use the video command instead."
184
- );
185
- }
186
- if (opts.theme) {
187
- const { getAvailableThemes } = await import("@bendyline/squisq/schemas");
188
- const { readCustomThemesFromFrontmatter } = await import("@bendyline/squisq/doc");
189
- const builtins = getAvailableThemes();
190
- const customIds = (readCustomThemesFromFrontmatter(result.markdownDoc.frontmatter) ?? []).map(
191
- (t) => t.id
192
- );
193
- if (!builtins.includes(opts.theme) && !customIds.includes(opts.theme)) {
194
- throw new Error(
195
- `Unknown theme "${opts.theme}". Available: ${[...builtins, ...customIds].join(", ")}`
196
- );
197
- }
198
- }
199
- let exportMarkdownDoc = result.markdownDoc;
200
- if (opts.transform) {
201
- exportMarkdownDoc = await applyTransformToMarkdown(
202
- result.markdownDoc,
203
- container,
204
- opts.transform,
205
- opts.theme,
206
- opts.autoTemplates
207
- );
208
- console.error(` Applied transform: ${opts.transform}`);
209
- }
210
- const themeId = opts.theme;
211
- for (const format of formats) {
212
- const outPath = join2(outputDir, `${baseName}.${format}`);
213
- switch (format) {
214
- case "docx": {
215
- const { markdownDocToDocx } = await import("@bendyline/squisq-formats/docx");
216
- const buf = await markdownDocToDocx(exportMarkdownDoc, { themeId });
217
- await writeFile(outPath, Buffer.from(buf));
218
- break;
219
- }
220
- case "pptx": {
221
- const { markdownDocToPptx } = await import("@bendyline/squisq-formats/pptx");
222
- const images = await collectContainerImages(container);
223
- const buf = await markdownDocToPptx(exportMarkdownDoc, { themeId, images });
224
- await writeFile(outPath, Buffer.from(buf));
225
- break;
226
- }
227
- case "pdf": {
228
- const { markdownDocToPdf } = await import("@bendyline/squisq-formats/pdf");
229
- const buf = await markdownDocToPdf(exportMarkdownDoc, { themeId });
230
- await writeFile(outPath, Buffer.from(buf));
231
- break;
232
- }
233
- case "html": {
234
- const { markdownToDoc } = await import("@bendyline/squisq/doc");
235
- const { docToHtml } = await import("@bendyline/squisq-formats/html");
236
- const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
237
- const doc = markdownToDoc(exportMarkdownDoc, { autoTemplates: opts.autoTemplates });
238
- const images = await collectImagesForHtml(doc, container);
239
- const html = docToHtml(doc, {
240
- playerScript: PLAYER_BUNDLE,
241
- images,
242
- title: baseName,
243
- mode: "static",
244
- themeId
245
- });
246
- await writeFile(outPath, html, "utf-8");
247
- break;
248
- }
249
- case "htmlzip": {
250
- const { markdownToDoc } = await import("@bendyline/squisq/doc");
251
- const { docToHtmlZip } = await import("@bendyline/squisq-formats/html");
252
- const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
253
- const doc = markdownToDoc(exportMarkdownDoc, { autoTemplates: opts.autoTemplates });
254
- const images = await collectImagesForHtml(doc, container);
255
- const blob = await docToHtmlZip(doc, {
256
- playerScript: PLAYER_BUNDLE,
257
- images,
258
- title: baseName,
259
- mode: "static",
260
- themeId
261
- });
262
- const buf = Buffer.from(await blob.arrayBuffer());
263
- await writeFile(outPath.replace(/\.htmlzip$/, ".html.zip"), buf);
264
- break;
265
- }
266
- case "epub": {
267
- const { markdownDocToEpub } = await import("@bendyline/squisq-formats/epub");
268
- const images = await collectContainerImages(container);
269
- const epubAudio = /* @__PURE__ */ new Map();
270
- const audioSegments = result.doc?.audio?.segments;
271
- if (audioSegments?.length) {
272
- for (const seg of audioSegments) {
273
- const data = await container.readFile(seg.src);
274
- if (data) epubAudio.set(seg.src, data);
275
- }
276
- }
277
- const hasNarration = epubAudio.size > 0;
278
- const buf = await markdownDocToEpub(exportMarkdownDoc, {
279
- title: baseName,
280
- themeId,
281
- images,
282
- audio: hasNarration ? epubAudio : void 0,
283
- audioSegments: hasNarration ? audioSegments : void 0,
284
- totalDuration: hasNarration ? result.doc?.duration : void 0
285
- });
286
- await writeFile(outPath, Buffer.from(buf));
287
- break;
288
- }
289
- case "dbk": {
290
- const { containerToZip } = await import("@bendyline/squisq-formats/container");
291
- const blob = await containerToZip(container);
292
- const buf = await blob.arrayBuffer();
293
- await writeFile(outPath, Buffer.from(buf));
294
- break;
295
- }
296
- }
297
- console.error(` \u2713 ${outPath}`);
298
- }
299
- console.error("Done.");
300
- }
301
- async function applyTransformToMarkdown(markdownDoc, container, transformStyle, themeId, autoTemplates) {
302
- const { markdownToDoc, docToMarkdown } = await import("@bendyline/squisq/doc");
303
- const { applyTransform, extractDocImages } = await import("@bendyline/squisq/transform");
304
- const doc = markdownToDoc(markdownDoc, { autoTemplates });
305
- const images = extractDocImages(doc.blocks);
306
- const result = applyTransform(doc, transformStyle, {
307
- themeId,
308
- images
309
- });
310
- return docToMarkdown(result.doc);
311
- }
312
- async function collectImagesForHtml(doc, container) {
313
- const { collectImagePaths, extractFilename } = await import("@bendyline/squisq-formats/html");
314
- const images = /* @__PURE__ */ new Map();
315
- const docPaths = collectImagePaths(doc);
316
- const needByFilename = /* @__PURE__ */ new Set();
317
- for (const imgPath of docPaths) {
318
- const data = await container.readFile(imgPath);
319
- if (data) {
320
- images.set(imgPath, data);
321
- const filename = extractFilename(imgPath);
322
- if (filename !== imgPath) images.set(filename, data);
323
- } else {
324
- needByFilename.add(extractFilename(imgPath));
325
- }
326
- }
327
- if (needByFilename.size > 0) {
328
- const files = await container.listFiles();
329
- for (const file of files) {
330
- const filename = extractFilename(file.path);
331
- if (needByFilename.has(filename)) {
332
- const data = await container.readFile(file.path);
333
- if (data) {
334
- images.set(filename, data);
335
- images.set(file.path, data);
336
- }
337
- }
338
- }
339
- }
340
- return images;
341
- }
342
- async function collectContainerImages(container) {
343
- const images = /* @__PURE__ */ new Map();
344
- const files = await container.listFiles();
345
- for (const file of files) {
346
- if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|avif)$/i.test(file.path)) {
347
- const data = await container.readFile(file.path);
348
- if (data) {
349
- images.set(file.path, data);
350
- }
351
- }
352
- }
353
- return images;
149
+ return resolveContainer(
150
+ container,
151
+ "folder",
152
+ `No markdown document or doc.json found in folder: ${dirPath}`
153
+ );
354
154
  }
355
155
 
356
- // src/commands/video.ts
357
- import { mkdir as mkdir2 } from "fs/promises";
358
- import { dirname as dirname2, basename as basename2, extname as extname3, resolve as resolve2 } from "path";
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";
359
162
 
360
163
  // src/api.ts
361
164
  import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
362
165
  import { flattenBlocks } from "@bendyline/squisq/doc";
363
166
  import { generateRenderHtml } from "@bendyline/squisq-video";
364
167
  import { resolveDimensions } from "@bendyline/squisq-video";
168
+ import { convert as formatsConvert } from "@bendyline/squisq-formats";
365
169
 
366
170
  // src/util/detectFfmpeg.ts
367
171
  import { execFile } from "child_process";
368
- async function detectFfmpeg() {
369
- const command = process.platform === "win32" ? "where" : "which";
172
+ function run(command, args) {
370
173
  return new Promise((resolve4) => {
371
- execFile(command, ["ffmpeg"], { timeout: 5e3 }, (err, stdout) => {
174
+ execFile(command, args, { timeout: 5e3 }, (err, stdout) => {
372
175
  if (err || !stdout.trim()) {
373
176
  resolve4(null);
374
177
  return;
375
178
  }
376
- const path = stdout.trim().split("\n")[0].trim();
377
- resolve4(path);
179
+ resolve4(stdout.trim());
378
180
  });
379
181
  });
380
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
+ }
381
291
 
382
292
  // src/api.ts
383
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
+ }
384
302
  async function renderDocToMp4(doc, container, options) {
385
303
  const {
386
304
  outputPath,
@@ -399,7 +317,7 @@ async function renderDocToMp4(doc, container, options) {
399
317
  const ffmpegPath = await detectFfmpeg();
400
318
  if (!ffmpegPath) {
401
319
  throw new Error(
402
- "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"
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."
403
321
  );
404
322
  }
405
323
  onProgress?.("collecting media", 0);
@@ -413,39 +331,26 @@ async function renderDocToMp4(doc, container, options) {
413
331
  }
414
332
  }
415
333
  const audio = /* @__PURE__ */ new Map();
416
- const audioBuffers = [];
417
334
  if (doc.audio?.segments?.length) {
418
335
  for (const seg of doc.audio.segments) {
419
336
  const data = await container.readFile(seg.src);
420
337
  if (data) {
421
338
  audio.set(seg.src, data);
422
339
  audio.set(seg.name, data);
423
- audioBuffers.push(data);
424
340
  }
425
341
  }
426
342
  }
427
- let concatenatedAudio = null;
428
- if (audioBuffers.length > 0) {
429
- concatenatedAudio = await concatenateAudioBuffers(audioBuffers, ffmpegPath);
430
- }
431
- const schedule = resolveMediaSchedule(doc);
432
- const mediaSrcs = new Set(schedule.map((c) => c.src));
343
+ const mediaSrcs = new Set(resolveMediaSchedule(doc).map((c) => c.src));
433
344
  for (const block of flattenBlocks(doc.blocks)) {
434
345
  for (const layer of block.layers ?? []) {
435
346
  if (layer.type === "video") mediaSrcs.add(layer.content.src);
436
347
  }
437
348
  }
438
- const clipBuffers = /* @__PURE__ */ new Map();
439
349
  for (const src of mediaSrcs) {
440
350
  if (images.has(src)) continue;
441
351
  const data = await container.readFile(src);
442
352
  if (data) images.set(src, data);
443
353
  }
444
- for (const clip of schedule) {
445
- if (clip.kind !== "audio") continue;
446
- const data = images.get(clip.src) ?? await container.readFile(clip.src);
447
- if (data) clipBuffers.set(clip.id, data);
448
- }
449
354
  onProgress?.("generating render HTML", 10);
450
355
  const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
451
356
  const renderHtml = generateRenderHtml(doc, {
@@ -458,7 +363,16 @@ async function renderDocToMp4(doc, container, options) {
458
363
  });
459
364
  onProgress?.("launching browser", 15);
460
365
  const { chromium } = await import("playwright-core");
461
- const browser = await chromium.launch({ headless: true });
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
+ }
462
376
  const page = await browser.newPage({
463
377
  viewport: { width: dimensions.width, height: dimensions.height }
464
378
  });
@@ -476,7 +390,9 @@ async function renderDocToMp4(doc, container, options) {
476
390
  const errorDetail = pageErrors.length ? `
477
391
  Page errors:
478
392
  ${pageErrors.join("\n ")}` : "\nNo page errors captured \u2014 the player may have failed to mount.";
479
- throw new Error(`Render API did not initialize within 15 seconds.${errorDetail}`);
393
+ throw new Error(
394
+ `The standalone player failed to boot in headless Chromium. Render API did not initialize within 15 seconds.${errorDetail}`
395
+ );
480
396
  }
481
397
  const docDuration = await page.evaluate(() => {
482
398
  return window.getDuration();
@@ -524,24 +440,8 @@ Page errors:
524
440
  }
525
441
  await browser.close();
526
442
  onProgress?.("encoding video", 80);
527
- const scheduledAudioClips = schedule.filter((c) => c.kind === "audio" && clipBuffers.has(c.id));
528
- let encodingAudio = concatenatedAudio;
529
- if (scheduledAudioClips.length > 0 && ffmpegPath) {
530
- encodingAudio = await mixScheduledAudio(
531
- ffmpegPath,
532
- concatenatedAudio,
533
- scheduledAudioClips.map((c) => ({
534
- buffer: clipBuffers.get(c.id),
535
- delaySec: c.absoluteStart + coverPreRoll,
536
- trimStart: c.sourceIn,
537
- trimLen: Math.max(0, c.absoluteEnd - c.absoluteStart)
538
- })),
539
- coverPreRoll
540
- );
541
- } else if (coverPreRoll > 0 && concatenatedAudio) {
542
- encodingAudio = await addAudioDelay(ffmpegPath, concatenatedAudio, coverPreRoll);
543
- }
544
- const { framesToMp4Native } = await import("./nativeEncoder-EXDP2O5B.js");
443
+ const encodingAudio = await buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll);
444
+ const { framesToMp4Native } = await import("./nativeEncoder-DLLAT2RT.js");
545
445
  await framesToMp4Native(ffmpegPath, frames, encodingAudio, outputPath, {
546
446
  fps,
547
447
  quality,
@@ -560,160 +460,224 @@ Page errors:
560
460
  outputPath
561
461
  };
562
462
  }
563
- async function concatenateAudioBuffers(buffers, ffmpegPath) {
564
- if (buffers.length === 0) return new Uint8Array(0);
565
- if (buffers.length === 1) return new Uint8Array(buffers[0]);
566
- if (ffmpegPath) {
567
- return concatenateAudioNative(ffmpegPath, buffers);
568
- }
569
- const totalLength = buffers.reduce((sum, b) => sum + b.byteLength, 0);
570
- const result = new Uint8Array(totalLength);
571
- let offset = 0;
572
- for (const buf of buffers) {
573
- result.set(new Uint8Array(buf), offset);
574
- offset += buf.byteLength;
575
- }
576
- return result;
577
- }
578
- async function concatenateAudioNative(ffmpegPath, buffers) {
579
- const { writeFile: writeFile2, readFile: readFile2, mkdir: mkdir3, rm } = await import("fs/promises");
580
- const { join: join4 } = await import("path");
581
- const { tmpdir } = await import("os");
582
- const { randomBytes } = await import("crypto");
583
- const { execFile: execFile2 } = await import("child_process");
584
- const tmpId = randomBytes(8).toString("hex");
585
- const workDir = join4(tmpdir(), `squisq-audio-concat-${tmpId}`);
586
- await mkdir3(workDir, { recursive: true });
587
- try {
588
- const segmentPaths = [];
589
- for (let i = 0; i < buffers.length; i++) {
590
- const segPath = join4(workDir, `seg-${i}.mp3`);
591
- await writeFile2(segPath, new Uint8Array(buffers[i]));
592
- segmentPaths.push(segPath);
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
+ }
593
498
  }
594
- const listPath = join4(workDir, "concat-list.txt");
595
- const listContent = segmentPaths.map((p) => `file '${p.replace(/\\/g, "/")}'`).join("\n");
596
- await writeFile2(listPath, listContent);
597
- const outputPath = join4(workDir, "combined.mp3");
598
- await new Promise((resolve4, reject) => {
599
- execFile2(
600
- ffmpegPath,
601
- ["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", outputPath],
602
- { timeout: 12e4 },
603
- (err) => {
604
- if (err) reject(new Error(`ffmpeg audio concat failed: ${err.message}`));
605
- else resolve4();
606
- }
607
- );
608
- });
609
- const data = await readFile2(outputPath);
610
- return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
611
- } finally {
612
- await rm(workDir, { recursive: true, force: true });
499
+ };
500
+ }
501
+ function createCliRegistry() {
502
+ const registry = defaultRegistry2();
503
+ registry.register(mp4Format());
504
+ return registry;
505
+ }
506
+
507
+ // src/util/applyTransformPipeline.ts
508
+ async function assertValidTransformStyle(style) {
509
+ const { getTransformStyleIds } = await import("@bendyline/squisq/transform");
510
+ const styles = getTransformStyleIds();
511
+ if (!styles.includes(style)) {
512
+ throw new Error(`Unknown transform style "${style}". Available: ${styles.join(", ")}`);
613
513
  }
614
514
  }
615
- async function addAudioDelay(ffmpegPath, audioData, delaySecs) {
616
- const { writeFile: writeFile2, readFile: readFile2, rm } = await import("fs/promises");
617
- const { join: join4 } = await import("path");
618
- const { tmpdir } = await import("os");
619
- const { randomBytes } = await import("crypto");
620
- const { execFile: execFile2 } = await import("child_process");
621
- const tmpId = randomBytes(8).toString("hex");
622
- const inputPath = join4(tmpdir(), `squisq-audio-delay-in-${tmpId}.mp3`);
623
- const outputPath = join4(tmpdir(), `squisq-audio-delay-out-${tmpId}.mp3`);
624
- try {
625
- await writeFile2(inputPath, audioData);
626
- const delayMs = Math.round(delaySecs * 1e3);
627
- await new Promise((resolve4, reject) => {
628
- execFile2(
629
- ffmpegPath,
630
- [
631
- "-y",
632
- "-i",
633
- inputPath,
634
- "-af",
635
- `adelay=${delayMs}|${delayMs}`,
636
- "-c:a",
637
- "libmp3lame",
638
- "-b:a",
639
- "128k",
640
- outputPath
641
- ],
642
- { timeout: 6e4 },
643
- (err) => {
644
- if (err) reject(new Error(`ffmpeg audio delay failed: ${err.message}`));
645
- else resolve4();
646
- }
647
- );
648
- });
649
- const data = await readFile2(outputPath);
650
- return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
651
- } finally {
652
- await rm(inputPath, { force: true });
653
- await rm(outputPath, { force: true });
515
+ async function assertValidThemeId(themeId, source) {
516
+ const { getAvailableThemes } = await import("@bendyline/squisq/schemas");
517
+ const builtins = getAvailableThemes();
518
+ let customIds = [];
519
+ if (source.markdownDoc) {
520
+ const { readCustomThemesFromFrontmatter } = await import("@bendyline/squisq/doc");
521
+ customIds = (readCustomThemesFromFrontmatter(source.markdownDoc.frontmatter) ?? []).map(
522
+ (t) => t.id
523
+ );
524
+ } else if (source.doc?.customThemes) {
525
+ customIds = source.doc.customThemes.map((t) => t.id);
526
+ }
527
+ if (!builtins.includes(themeId) && !customIds.includes(themeId)) {
528
+ throw new Error(
529
+ `Unknown theme "${themeId}". Available: ${[...builtins, ...customIds].join(", ")}`
530
+ );
654
531
  }
655
532
  }
656
- async function mixScheduledAudio(ffmpegPath, narration, clips, coverPreRoll) {
657
- if (!narration && clips.length === 0) return null;
658
- const { writeFile: writeFile2, readFile: readFile2, mkdir: mkdir3, rm } = await import("fs/promises");
659
- const { join: join4 } = await import("path");
660
- const { tmpdir } = await import("os");
661
- const { randomBytes } = await import("crypto");
662
- const { execFile: execFile2 } = await import("child_process");
663
- const workDir = join4(tmpdir(), `squisq-audio-mix-${randomBytes(8).toString("hex")}`);
664
- await mkdir3(workDir, { recursive: true });
665
- try {
666
- const inputs = [];
667
- const filters = [];
668
- const labels = [];
669
- const ms = (s) => Math.max(0, Math.round(s * 1e3));
670
- if (narration) {
671
- const p = join4(workDir, "narration.mp3");
672
- await writeFile2(p, narration);
673
- const i = inputs.push(p) - 1;
674
- const d = ms(coverPreRoll);
675
- filters.push(`[${i}:a]adelay=${d}|${d}[a${i}]`);
676
- labels.push(`[a${i}]`);
533
+ async function applyTransformToDoc(doc, transformStyle, themeId) {
534
+ const { applyTransform, extractDocImages } = await import("@bendyline/squisq/transform");
535
+ const images = extractDocImages(doc.blocks);
536
+ const result = applyTransform(doc, transformStyle, {
537
+ themeId,
538
+ images
539
+ });
540
+ return result.doc;
541
+ }
542
+
543
+ // src/commands/convert.ts
544
+ var VALID_FORMATS = [...BUILTIN_FORMAT_IDS, "mp4"];
545
+ var DEFAULT_FORMATS = [
546
+ "docx",
547
+ "pptx",
548
+ "pdf",
549
+ "html",
550
+ "htmlzip",
551
+ "epub",
552
+ "dbk"
553
+ ];
554
+ function isValidFormat(id) {
555
+ return VALID_FORMATS.includes(id);
556
+ }
557
+ function parseFormats(value) {
558
+ const requested = value.split(",").map((s) => s.trim().toLowerCase());
559
+ const valid = [];
560
+ for (const r of requested) {
561
+ if (isValidFormat(r)) {
562
+ valid.push(r);
563
+ } else {
564
+ console.warn(`Unknown format "${r}" \u2014 skipping. Valid: ${VALID_FORMATS.join(", ")}`);
677
565
  }
678
- for (const clip of clips) {
679
- const p = join4(workDir, `clip-${inputs.length}.mp3`);
680
- await writeFile2(p, new Uint8Array(clip.buffer));
681
- const i = inputs.push(p) - 1;
682
- const d = ms(clip.delaySec);
683
- const end = clip.trimStart + clip.trimLen;
684
- filters.push(
685
- `[${i}:a]atrim=start=${clip.trimStart}:end=${end},asetpts=PTS-STARTPTS,adelay=${d}|${d}[a${i}]`
686
- );
687
- labels.push(`[a${i}]`);
566
+ }
567
+ if (valid.length === 0) {
568
+ throw new Error(`No valid formats specified. Valid: ${VALID_FORMATS.join(", ")}`);
569
+ }
570
+ return valid;
571
+ }
572
+ function formatFromOutputPath(outputPath) {
573
+ const lower = outputPath.toLowerCase();
574
+ if (lower.endsWith(".html.zip")) return "htmlzip";
575
+ const ext = extname2(lower);
576
+ const def = createCliRegistry().byExtension(ext);
577
+ if (!def) {
578
+ throw new Error(
579
+ `Cannot infer a format from output extension "${ext || "(none)"}". Use --format to specify one. Valid: ${VALID_FORMATS.join(", ")}`
580
+ );
581
+ }
582
+ return def.id;
583
+ }
584
+ 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(
586
+ "-d, --output-dir <dir>",
587
+ "Output directory for multi-format export (default: same as input)"
588
+ ).option(
589
+ "-f, --formats <list>",
590
+ `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(
592
+ "--transform <style>",
593
+ "Transform style to apply before export (e.g., documentary, magazine, minimal)"
594
+ ).option(
595
+ "--no-auto-templates",
596
+ "Disable content-aware template auto-picking for unannotated headings"
597
+ ).action(async (inputPath, opts) => {
598
+ try {
599
+ await runConvert(inputPath, opts);
600
+ } catch (err) {
601
+ if (err instanceof ConversionError2) {
602
+ console.error(`Error: ${err.message}`);
603
+ if (err.hint) console.error(` ${err.hint}`);
604
+ } else {
605
+ const message = err instanceof Error ? err.message : String(err);
606
+ console.error(`Error: ${message}`);
607
+ }
608
+ process.exitCode = 1;
688
609
  }
689
- const graph = `${filters.join(";")};${labels.join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[aout]`;
690
- const args = ["-y"];
691
- for (const p of inputs) args.push("-i", p);
692
- args.push(
693
- "-filter_complex",
694
- graph,
695
- "-map",
696
- "[aout]",
697
- "-c:a",
698
- "libmp3lame",
699
- "-b:a",
700
- "192k",
701
- join4(workDir, "mixed.mp3")
610
+ });
611
+ }
612
+ function resolveFormats(opts) {
613
+ const hasOutput = Boolean(opts.output);
614
+ const hasFormatSelection = Boolean(opts.formats || opts.format);
615
+ if (hasOutput && hasFormatSelection) {
616
+ throw new Error(
617
+ "--output is a single-file destination and cannot be combined with --formats/--format."
702
618
  );
703
- await new Promise((resolve4, reject) => {
704
- execFile2(ffmpegPath, args, { timeout: 18e4 }, (err) => {
705
- if (err) reject(new Error(`ffmpeg audio mix failed: ${err.message}`));
706
- else resolve4();
707
- });
619
+ }
620
+ if (hasOutput) {
621
+ return [formatFromOutputPath(opts.output)];
622
+ }
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
+ if (opts.formats) {
630
+ return parseFormats(opts.formats);
631
+ }
632
+ return [...DEFAULT_FORMATS];
633
+ }
634
+ async function runConvert(inputPath, opts) {
635
+ const resolvedInput = resolve(inputPath);
636
+ const formats = resolveFormats(opts);
637
+ const inputBasename = basename(resolvedInput);
638
+ const inputExt = extname2(inputBasename);
639
+ const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
640
+ if (opts.transform) {
641
+ await assertValidTransformStyle(opts.transform);
642
+ }
643
+ console.error(`Reading: ${resolvedInput}`);
644
+ const result = await readInput(resolvedInput);
645
+ if (opts.theme) {
646
+ await assertValidThemeId(opts.theme, result);
647
+ }
648
+ const source = result.markdownDoc ? {
649
+ kind: "markdown",
650
+ markdown: result.markdownDoc,
651
+ container: result.container,
652
+ baseName
653
+ } : { kind: "doc", doc: result.doc, container: result.container, baseName };
654
+ if (opts.transform) {
655
+ console.error(` Applying transform: ${opts.transform}`);
656
+ }
657
+ for (const format of formats) {
658
+ const conversion = await convert(source, format, {
659
+ themeId: opts.theme,
660
+ transformStyle: opts.transform,
661
+ autoTemplates: opts.autoTemplates,
662
+ title: baseName
708
663
  });
709
- const data = await readFile2(join4(workDir, "mixed.mp3"));
710
- return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
711
- } finally {
712
- await rm(workDir, { recursive: true, force: true });
664
+ for (const warning of conversion.warnings) {
665
+ console.error(` \u26A0 ${warning}`);
666
+ }
667
+ const outPath = opts.output ? resolve(opts.output) : join3(
668
+ opts.outputDir ? resolve(opts.outputDir) : dirname(resolvedInput),
669
+ conversion.suggestedFilename
670
+ );
671
+ await mkdir(dirname(outPath), { recursive: true });
672
+ await writeFile(outPath, Buffer.from(conversion.bytes));
673
+ console.error(` \u2713 ${outPath}`);
713
674
  }
675
+ console.error("Done.");
714
676
  }
715
677
 
716
678
  // src/commands/video.ts
679
+ import { mkdir as mkdir2 } from "fs/promises";
680
+ import { dirname as dirname2, basename as basename2, extname as extname3, resolve as resolve2 } from "path";
717
681
  var VALID_QUALITIES = ["draft", "normal", "high"];
718
682
  var VALID_ORIENTATIONS = ["landscape", "portrait"];
719
683
  var VALID_CAPTIONS = ["off", "standard", "social"];
@@ -733,6 +697,13 @@ function registerVideoCommand(program2) {
733
697
  ).option(
734
698
  "--no-auto-templates",
735
699
  "Disable content-aware template auto-picking for unannotated headings"
700
+ ).option("-t, --theme <id>", "Squisq theme ID to apply (e.g., documentary, cinematic, bold)").option(
701
+ "--transform <style>",
702
+ "Transform style to apply before rendering (e.g., documentary, magazine, minimal)"
703
+ ).option(
704
+ "--cover-preroll <seconds>",
705
+ "Seconds of cover-slide pre-roll before the story starts (default: 2)",
706
+ "2"
736
707
  ).option("--width <pixels>", "Override video width").option("--height <pixels>", "Override video height").action(async (inputPath, outputArg, opts) => {
737
708
  try {
738
709
  if (outputArg && !opts.output) {
@@ -767,6 +738,13 @@ async function runVideo(inputPath, opts) {
767
738
  throw new Error(`Invalid captions "${captions}". Valid: ${VALID_CAPTIONS.join(", ")}`);
768
739
  }
769
740
  const captionStyle = captions === "off" ? void 0 : captions;
741
+ const coverPreRoll = parseFloat(opts.coverPreroll ?? "2");
742
+ if (isNaN(coverPreRoll) || coverPreRoll < 0) {
743
+ throw new Error("Cover pre-roll must be a number of seconds >= 0");
744
+ }
745
+ if (opts.transform) {
746
+ await assertValidTransformStyle(opts.transform);
747
+ }
770
748
  const inputBasename = basename2(resolvedInput);
771
749
  const inputExt = extname3(inputBasename);
772
750
  const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
@@ -775,15 +753,24 @@ async function runVideo(inputPath, opts) {
775
753
  console.error(`Reading: ${resolvedInput}`);
776
754
  const result = await readInput(resolvedInput);
777
755
  const { container } = result;
778
- let doc;
779
- if (result.doc) {
780
- console.error("Using pre-built Doc JSON");
781
- doc = result.doc;
782
- } else if (result.markdownDoc) {
783
- const { markdownToDoc } = await import("@bendyline/squisq/doc");
784
- doc = markdownToDoc(result.markdownDoc, { autoTemplates: opts.autoTemplates });
756
+ if (opts.theme) {
757
+ await assertValidThemeId(opts.theme, result);
758
+ }
759
+ let doc = result.doc;
760
+ if (result.markdownDoc) {
761
+ if (opts.autoTemplates === false) {
762
+ const { markdownToDoc: markdownToDoc2 } = await import("@bendyline/squisq/doc");
763
+ doc = markdownToDoc2(result.markdownDoc, { autoTemplates: false });
764
+ }
785
765
  } else {
786
- throw new Error("No document found in input");
766
+ console.error("Using pre-built Doc JSON");
767
+ }
768
+ if (opts.transform) {
769
+ doc = await applyTransformToDoc(doc, opts.transform, opts.theme);
770
+ console.error(` Applied transform: ${opts.transform}`);
771
+ }
772
+ if (opts.theme) {
773
+ doc = { ...doc, themeId: opts.theme };
787
774
  }
788
775
  console.error(
789
776
  `Rendering: ${fps} fps, quality: ${quality}, orientation: ${orientation}, captions: ${captions}`
@@ -796,7 +783,7 @@ async function runVideo(inputPath, opts) {
796
783
  width: opts.width ? parseInt(opts.width, 10) : void 0,
797
784
  height: opts.height ? parseInt(opts.height, 10) : void 0,
798
785
  captionStyle,
799
- coverPreRoll: 2,
786
+ coverPreRoll,
800
787
  onProgress: (phase, percent) => {
801
788
  writeProgress(phase, percent, 100);
802
789
  }
@@ -821,7 +808,7 @@ function clearProgress() {
821
808
 
822
809
  // src/commands/validate.ts
823
810
  import { existsSync, statSync } from "fs";
824
- import { dirname as dirname3, extname as extname4, join as join3, resolve as resolve3 } from "path";
811
+ import { dirname as dirname3, extname as extname4, join as join4, resolve as resolve3 } from "path";
825
812
  import { validateMarkdownDoc } from "@bendyline/squisq/doc";
826
813
  function registerValidateCommand(program2) {
827
814
  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) => {
@@ -839,7 +826,7 @@ async function runValidate(inputPath, opts) {
839
826
  const resolvedInput = resolve3(inputPath);
840
827
  const { container, markdownDoc, doc } = await readInput(resolvedInput);
841
828
  if (!markdownDoc) {
842
- const diagnostics = doc?.diagnostics ?? [];
829
+ const diagnostics = doc.diagnostics ?? [];
843
830
  return report(diagnostics, opts, "doc.json input \u2014 markdown-level checks skipped");
844
831
  }
845
832
  const entries = await container.listFiles();
@@ -848,31 +835,42 @@ async function runValidate(inputPath, opts) {
848
835
  const baseDir = dirname3(resolvedInput);
849
836
  const hasAsset = (path) => {
850
837
  if (containerPaths.has(path) || containerPaths.has(path.replace(/^\.\//, ""))) return true;
851
- if (isBareMarkdown) return existsSync(join3(baseDir, path));
838
+ if (isBareMarkdown) return existsSync(join4(baseDir, path));
852
839
  return false;
853
840
  };
854
841
  const result = validateMarkdownDoc(markdownDoc, { assets: hasAsset });
855
842
  return report(result.diagnostics, opts);
856
843
  }
844
+ function severityLabel(severity) {
845
+ switch (severity) {
846
+ case "error":
847
+ return "ERROR ";
848
+ case "warning":
849
+ return "warning";
850
+ case "info":
851
+ return "\u2139 info ";
852
+ }
853
+ }
857
854
  function report(diagnostics, opts, note) {
858
855
  const errorCount = diagnostics.filter((d) => d.severity === "error").length;
859
- const warningCount = diagnostics.length - errorCount;
856
+ const warningCount = diagnostics.filter((d) => d.severity === "warning").length;
857
+ const infoCount = diagnostics.filter((d) => d.severity === "info").length;
860
858
  if (opts.json) {
861
- process.stdout.write(`${JSON.stringify({ errorCount, warningCount, diagnostics }, null, 2)}
862
- `);
859
+ process.stdout.write(
860
+ `${JSON.stringify({ errorCount, warningCount, infoCount, diagnostics }, null, 2)}
861
+ `
862
+ );
863
863
  } else {
864
864
  if (note) console.error(note);
865
865
  for (const d of diagnostics) {
866
866
  const location = d.line != null ? `line ${d.line}` : d.blockId ?? "\u2014";
867
- console.error(
868
- `${d.severity === "error" ? "ERROR " : "warning"} ${location} [${d.code}] ${d.message}`
869
- );
867
+ console.error(`${severityLabel(d.severity)} ${location} [${d.code}] ${d.message}`);
870
868
  }
871
869
  if (diagnostics.length === 0) {
872
870
  console.error("\u2713 No problems found");
873
871
  } else {
874
872
  console.error(`
875
- ${errorCount} error(s), ${warningCount} warning(s)`);
873
+ ${errorCount} error(s), ${warningCount} warning(s), ${infoCount} info`);
876
874
  }
877
875
  }
878
876
  if (errorCount > 0) return 1;
@@ -880,14 +878,62 @@ ${errorCount} error(s), ${warningCount} warning(s)`);
880
878
  return 0;
881
879
  }
882
880
 
881
+ // src/commands/doctor.ts
882
+ function registerDoctorCommand(program2) {
883
+ program2.command("doctor").description("Check that ffmpeg and Playwright Chromium are available for video rendering").action(async () => {
884
+ const allPresent = await runDoctor();
885
+ process.exitCode = allPresent ? 0 : 1;
886
+ });
887
+ }
888
+ async function runDoctor() {
889
+ let allPresent = true;
890
+ console.error(`Node: ${process.version}`);
891
+ try {
892
+ const detection = await detectFfmpegDetailed();
893
+ if (detection) {
894
+ const version2 = await getFfmpegVersion(detection.path);
895
+ console.error(
896
+ `ffmpeg: ${detection.path} (source: ${detection.source}) \u2014 ${version2 ?? "version unknown"}`
897
+ );
898
+ } else {
899
+ allPresent = false;
900
+ console.error("ffmpeg: not found.");
901
+ console.error(" Install it with:");
902
+ console.error(" macOS: brew install ffmpeg");
903
+ console.error(" Ubuntu: sudo apt install ffmpeg");
904
+ console.error(" Windows: winget install ffmpeg");
905
+ console.error(" Or: npm install ffmpeg-static, or set SQUISQ_FFMPEG.");
906
+ }
907
+ } catch (err) {
908
+ allPresent = false;
909
+ console.error(`ffmpeg: ${err instanceof Error ? err.message : String(err)}`);
910
+ }
911
+ try {
912
+ const { chromium } = await import("playwright-core");
913
+ const browser = await chromium.launch({ headless: true });
914
+ await browser.close();
915
+ console.error(`Chromium: available (${chromium.executablePath()})`);
916
+ } catch (err) {
917
+ allPresent = false;
918
+ const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
919
+ console.error(`Chromium: not available \u2014 ${detail}`);
920
+ console.error(" Run: npx playwright install chromium");
921
+ }
922
+ console.error(allPresent ? "\n\u2713 All checks passed" : "\n\u2717 Some checks failed");
923
+ return allPresent;
924
+ }
925
+
883
926
  // src/index.ts
927
+ var require2 = createRequire(import.meta.url);
928
+ var { version } = require2("../package.json");
884
929
  console.error(
885
- "\x1B[36m{[\x1B[0m \x1B[1msquiggly square\x1B[0m \x1B[2m\u2014\x1B[0m \x1B[1msquisq\x1B[0m \x1B[2m\u2014\x1B[0m \x1B[33mv1.0.0\x1B[0m \x1B[36m]}\x1B[0m"
930
+ `\x1B[36m{[\x1B[0m \x1B[1msquiggly square\x1B[0m \x1B[2m\u2014\x1B[0m \x1B[1msquisq\x1B[0m \x1B[2m\u2014\x1B[0m \x1B[33mv${version}\x1B[0m \x1B[36m]}\x1B[0m`
886
931
  );
887
932
  var program = new Command();
888
- program.name("squisq").description("Squisq CLI \u2014 convert and process markdown-based documents").version("1.0.0");
933
+ program.name("squisq").description("Squisq CLI \u2014 convert and process markdown-based documents").version(version);
889
934
  registerConvertCommand(program);
890
935
  registerVideoCommand(program);
891
936
  registerValidateCommand(program);
937
+ registerDoctorCommand(program);
892
938
  program.parse();
893
939
  //# sourceMappingURL=index.js.map