@bendyline/squisq-cli 1.1.5 → 1.1.6

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.
Files changed (41) hide show
  1. package/dist/api.d.ts +45 -14
  2. package/dist/api.js +393 -304
  3. package/dist/api.js.map +1 -1
  4. package/dist/index.d.ts +2 -12
  5. package/dist/index.js +722 -20
  6. package/dist/index.js.map +1 -1
  7. package/package.json +5 -5
  8. package/dist/__tests__/convert.test.d.ts +0 -7
  9. package/dist/__tests__/convert.test.d.ts.map +0 -1
  10. package/dist/__tests__/convert.test.js +0 -127
  11. package/dist/__tests__/convert.test.js.map +0 -1
  12. package/dist/__tests__/html-export.test.d.ts +0 -8
  13. package/dist/__tests__/html-export.test.d.ts.map +0 -1
  14. package/dist/__tests__/html-export.test.js +0 -126
  15. package/dist/__tests__/html-export.test.js.map +0 -1
  16. package/dist/__tests__/readInput.test.d.ts +0 -5
  17. package/dist/__tests__/readInput.test.d.ts.map +0 -1
  18. package/dist/__tests__/readInput.test.js +0 -120
  19. package/dist/__tests__/readInput.test.js.map +0 -1
  20. package/dist/api.d.ts.map +0 -1
  21. package/dist/commands/convert.d.ts +0 -15
  22. package/dist/commands/convert.d.ts.map +0 -1
  23. package/dist/commands/convert.js +0 -264
  24. package/dist/commands/convert.js.map +0 -1
  25. package/dist/commands/video.d.ts +0 -12
  26. package/dist/commands/video.d.ts.map +0 -1
  27. package/dist/commands/video.js +0 -122
  28. package/dist/commands/video.js.map +0 -1
  29. package/dist/index.d.ts.map +0 -1
  30. package/dist/util/detectFfmpeg.d.ts +0 -15
  31. package/dist/util/detectFfmpeg.d.ts.map +0 -1
  32. package/dist/util/detectFfmpeg.js +0 -29
  33. package/dist/util/detectFfmpeg.js.map +0 -1
  34. package/dist/util/nativeEncoder.d.ts +0 -27
  35. package/dist/util/nativeEncoder.d.ts.map +0 -1
  36. package/dist/util/nativeEncoder.js +0 -106
  37. package/dist/util/nativeEncoder.js.map +0 -1
  38. package/dist/util/readInput.d.ts +0 -31
  39. package/dist/util/readInput.d.ts.map +0 -1
  40. package/dist/util/readInput.js +0 -142
  41. package/dist/util/readInput.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,23 +1,725 @@
1
- /**
2
- * squisq CLI
3
- *
4
- * Command-line tool for converting and processing Squisq documents.
5
- * Designed for easy addition of future subcommands (e.g., import).
6
- *
7
- * Usage:
8
- * squisq convert <input> [options]
9
- * squisq --help
10
- */
11
- import { Command } from 'commander';
12
- import { registerConvertCommand } from './commands/convert.js';
13
- import { registerVideoCommand } from './commands/video.js';
14
- // Colored banner: cyan brackets, bold white text, dim version
15
- console.error('\x1b[36m{[\x1b[0m \x1b[1msquiggly square\x1b[0m \x1b[2m—\x1b[0m \x1b[1msquisq\x1b[0m \x1b[2m—\x1b[0m \x1b[33mv1.0.0\x1b[0m \x1b[36m]}\x1b[0m');
16
- const program = new Command();
17
- program
18
- .name('squisq')
19
- .description('Squisq CLI — convert and process markdown-based documents')
20
- .version('1.0.0');
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/convert.ts
7
+ import { writeFile, mkdir } from "fs/promises";
8
+ import { dirname, basename, extname as extname2, join as join2, resolve } from "path";
9
+
10
+ // src/util/readInput.ts
11
+ import { readFile, readdir, stat } from "fs/promises";
12
+ import { join, extname } from "path";
13
+ import { parseMarkdown } from "@bendyline/squisq/markdown";
14
+ import { MemoryContentContainer } from "@bendyline/squisq/storage";
15
+ import { zipToContainer } from "@bendyline/squisq-formats/container";
16
+ var MIME_TYPES = {
17
+ ".md": "text/markdown",
18
+ ".txt": "text/plain",
19
+ ".json": "application/json",
20
+ ".jpg": "image/jpeg",
21
+ ".jpeg": "image/jpeg",
22
+ ".png": "image/png",
23
+ ".gif": "image/gif",
24
+ ".webp": "image/webp",
25
+ ".svg": "image/svg+xml",
26
+ ".mp3": "audio/mpeg",
27
+ ".wav": "audio/wav",
28
+ ".ogg": "audio/ogg",
29
+ ".mp4": "video/mp4",
30
+ ".webm": "video/webm"
31
+ };
32
+ function mimeFromExt(filePath) {
33
+ return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
34
+ }
35
+ async function walkDir(root, prefix = "") {
36
+ const entries = await readdir(root, { withFileTypes: true });
37
+ const paths = [];
38
+ for (const entry of entries) {
39
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
40
+ if (entry.isDirectory()) {
41
+ paths.push(...await walkDir(join(root, entry.name), relPath));
42
+ } else if (entry.isFile()) {
43
+ paths.push(relPath);
44
+ }
45
+ }
46
+ return paths;
47
+ }
48
+ async function readInput(inputPath) {
49
+ const info = await stat(inputPath);
50
+ if (info.isDirectory()) {
51
+ return readFolder(inputPath);
52
+ }
53
+ const ext = extname(inputPath).toLowerCase();
54
+ if (ext === ".zip" || ext === ".dbk") {
55
+ return readContainer(inputPath);
56
+ }
57
+ if (ext === ".json") {
58
+ return readDocJsonFile(inputPath);
59
+ }
60
+ return readMarkdownFile(inputPath);
61
+ }
62
+ async function readMarkdownFile(filePath) {
63
+ const content = await readFile(filePath, "utf-8");
64
+ const container = new MemoryContentContainer();
65
+ await container.writeDocument(content);
66
+ const markdownDoc = parseMarkdown(content);
67
+ return { container, markdownDoc };
68
+ }
69
+ async function readDocJsonFile(filePath) {
70
+ const content = await readFile(filePath, "utf-8");
71
+ const doc = JSON.parse(content);
72
+ const container = new MemoryContentContainer();
73
+ return { container, markdownDoc: null, doc };
74
+ }
75
+ 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
+ );
81
+ for (const name of DOC_JSON_NAMES) {
82
+ const jsonData = await container.readFile(name);
83
+ if (jsonData) {
84
+ const decoder = new TextDecoder();
85
+ const doc = JSON.parse(decoder.decode(jsonData));
86
+ return { container, markdownDoc: null, doc };
87
+ }
88
+ }
89
+ const markdown = await container.readDocument();
90
+ if (!markdown) {
91
+ throw new Error(`No markdown document or doc.json found in container: ${filePath}`);
92
+ }
93
+ const markdownDoc = parseMarkdown(markdown);
94
+ return { container, markdownDoc };
95
+ }
96
+ async function readFolder(dirPath) {
97
+ const container = new MemoryContentContainer();
98
+ const files = await walkDir(dirPath);
99
+ for (const relPath of files) {
100
+ const absPath = join(dirPath, relPath);
101
+ const data = await readFile(absPath);
102
+ await container.writeFile(
103
+ relPath,
104
+ new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
105
+ mimeFromExt(relPath)
106
+ );
107
+ }
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
+ ).action(async (inputPath, opts) => {
149
+ try {
150
+ await runConvert(inputPath, opts);
151
+ } catch (err) {
152
+ const message = err instanceof Error ? err.message : String(err);
153
+ console.error(`Error: ${message}`);
154
+ process.exitCode = 1;
155
+ }
156
+ });
157
+ }
158
+ async function runConvert(inputPath, opts) {
159
+ const resolvedInput = resolve(inputPath);
160
+ const formats = opts.formats ? parseFormats(opts.formats) : [...ALL_FORMATS];
161
+ const outputDir = opts.outputDir ? resolve(opts.outputDir) : dirname(resolvedInput);
162
+ const inputBasename = basename(resolvedInput);
163
+ const inputExt = extname2(inputBasename);
164
+ const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
165
+ await mkdir(outputDir, { recursive: true });
166
+ if (opts.theme) {
167
+ const { getAvailableThemes } = await import("@bendyline/squisq/schemas");
168
+ const themes = getAvailableThemes();
169
+ if (!themes.includes(opts.theme)) {
170
+ throw new Error(`Unknown theme "${opts.theme}". Available: ${themes.join(", ")}`);
171
+ }
172
+ }
173
+ if (opts.transform) {
174
+ const { getTransformStyleIds } = await import("@bendyline/squisq/transform");
175
+ const styles = getTransformStyleIds();
176
+ if (!styles.includes(opts.transform)) {
177
+ throw new Error(
178
+ `Unknown transform style "${opts.transform}". Available: ${styles.join(", ")}`
179
+ );
180
+ }
181
+ }
182
+ console.error(`Reading: ${resolvedInput}`);
183
+ const result = await readInput(resolvedInput);
184
+ const { container } = result;
185
+ if (!result.markdownDoc) {
186
+ throw new Error(
187
+ "Convert command requires a markdown document. JSON Doc input is not supported for convert \u2014 use the video command instead."
188
+ );
189
+ }
190
+ let exportMarkdownDoc = result.markdownDoc;
191
+ if (opts.transform) {
192
+ exportMarkdownDoc = await applyTransformToMarkdown(
193
+ result.markdownDoc,
194
+ container,
195
+ opts.transform,
196
+ opts.theme
197
+ );
198
+ console.error(` Applied transform: ${opts.transform}`);
199
+ }
200
+ const themeId = opts.theme;
201
+ for (const format of formats) {
202
+ const outPath = join2(outputDir, `${baseName}.${format}`);
203
+ switch (format) {
204
+ case "docx": {
205
+ const { markdownDocToDocx } = await import("@bendyline/squisq-formats/docx");
206
+ const buf = await markdownDocToDocx(exportMarkdownDoc, { themeId });
207
+ await writeFile(outPath, Buffer.from(buf));
208
+ break;
209
+ }
210
+ case "pptx": {
211
+ const { markdownDocToPptx } = await import("@bendyline/squisq-formats/pptx");
212
+ const images = await collectContainerImages(container);
213
+ const buf = await markdownDocToPptx(exportMarkdownDoc, { themeId, images });
214
+ await writeFile(outPath, Buffer.from(buf));
215
+ break;
216
+ }
217
+ case "pdf": {
218
+ const { markdownDocToPdf } = await import("@bendyline/squisq-formats/pdf");
219
+ const buf = await markdownDocToPdf(exportMarkdownDoc, { themeId });
220
+ await writeFile(outPath, Buffer.from(buf));
221
+ break;
222
+ }
223
+ case "html": {
224
+ const { markdownToDoc } = await import("@bendyline/squisq/doc");
225
+ const { docToHtml } = await import("@bendyline/squisq-formats/html");
226
+ const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
227
+ const doc = markdownToDoc(exportMarkdownDoc);
228
+ const images = await collectImagesForHtml(doc, container);
229
+ const html = docToHtml(doc, {
230
+ playerScript: PLAYER_BUNDLE,
231
+ images,
232
+ title: baseName,
233
+ mode: "static",
234
+ themeId
235
+ });
236
+ await writeFile(outPath, html, "utf-8");
237
+ break;
238
+ }
239
+ case "htmlzip": {
240
+ const { markdownToDoc } = await import("@bendyline/squisq/doc");
241
+ const { docToHtmlZip } = await import("@bendyline/squisq-formats/html");
242
+ const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
243
+ const doc = markdownToDoc(exportMarkdownDoc);
244
+ const images = await collectImagesForHtml(doc, container);
245
+ const blob = await docToHtmlZip(doc, {
246
+ playerScript: PLAYER_BUNDLE,
247
+ images,
248
+ title: baseName,
249
+ mode: "static",
250
+ themeId
251
+ });
252
+ const buf = Buffer.from(await blob.arrayBuffer());
253
+ await writeFile(outPath.replace(/\.htmlzip$/, ".html.zip"), buf);
254
+ break;
255
+ }
256
+ case "epub": {
257
+ const { markdownDocToEpub } = await import("@bendyline/squisq-formats/epub");
258
+ const images = await collectContainerImages(container);
259
+ const epubAudio = /* @__PURE__ */ new Map();
260
+ const audioSegments = result.doc?.audio?.segments;
261
+ if (audioSegments?.length) {
262
+ for (const seg of audioSegments) {
263
+ const data = await container.readFile(seg.src);
264
+ if (data) epubAudio.set(seg.src, data);
265
+ }
266
+ }
267
+ const hasNarration = epubAudio.size > 0;
268
+ const buf = await markdownDocToEpub(exportMarkdownDoc, {
269
+ title: baseName,
270
+ themeId,
271
+ images,
272
+ audio: hasNarration ? epubAudio : void 0,
273
+ audioSegments: hasNarration ? audioSegments : void 0,
274
+ totalDuration: hasNarration ? result.doc?.duration : void 0
275
+ });
276
+ await writeFile(outPath, Buffer.from(buf));
277
+ break;
278
+ }
279
+ case "dbk": {
280
+ const { containerToZip } = await import("@bendyline/squisq-formats/container");
281
+ const blob = await containerToZip(container);
282
+ const buf = await blob.arrayBuffer();
283
+ await writeFile(outPath, Buffer.from(buf));
284
+ break;
285
+ }
286
+ }
287
+ console.error(` \u2713 ${outPath}`);
288
+ }
289
+ console.error("Done.");
290
+ }
291
+ async function applyTransformToMarkdown(markdownDoc, container, transformStyle, themeId) {
292
+ const { markdownToDoc, docToMarkdown } = await import("@bendyline/squisq/doc");
293
+ const { applyTransform, extractDocImages } = await import("@bendyline/squisq/transform");
294
+ const doc = markdownToDoc(markdownDoc);
295
+ const images = extractDocImages(doc.blocks);
296
+ const result = applyTransform(doc, transformStyle, {
297
+ themeId,
298
+ images
299
+ });
300
+ return docToMarkdown(result.doc);
301
+ }
302
+ async function collectImagesForHtml(doc, container) {
303
+ const { collectImagePaths, extractFilename } = await import("@bendyline/squisq-formats/html");
304
+ const images = /* @__PURE__ */ new Map();
305
+ const docPaths = collectImagePaths(doc);
306
+ const needByFilename = /* @__PURE__ */ new Set();
307
+ for (const imgPath of docPaths) {
308
+ const data = await container.readFile(imgPath);
309
+ if (data) {
310
+ images.set(imgPath, data);
311
+ const filename = extractFilename(imgPath);
312
+ if (filename !== imgPath) images.set(filename, data);
313
+ } else {
314
+ needByFilename.add(extractFilename(imgPath));
315
+ }
316
+ }
317
+ if (needByFilename.size > 0) {
318
+ const files = await container.listFiles();
319
+ for (const file of files) {
320
+ const filename = extractFilename(file.path);
321
+ if (needByFilename.has(filename)) {
322
+ const data = await container.readFile(file.path);
323
+ if (data) {
324
+ images.set(filename, data);
325
+ images.set(file.path, data);
326
+ }
327
+ }
328
+ }
329
+ }
330
+ return images;
331
+ }
332
+ async function collectContainerImages(container) {
333
+ const images = /* @__PURE__ */ new Map();
334
+ const files = await container.listFiles();
335
+ for (const file of files) {
336
+ if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|avif)$/i.test(file.path)) {
337
+ const data = await container.readFile(file.path);
338
+ if (data) {
339
+ images.set(file.path, data);
340
+ }
341
+ }
342
+ }
343
+ return images;
344
+ }
345
+
346
+ // src/commands/video.ts
347
+ import { mkdir as mkdir2 } from "fs/promises";
348
+ import { dirname as dirname2, basename as basename2, extname as extname3, resolve as resolve2 } from "path";
349
+
350
+ // src/api.ts
351
+ import { generateRenderHtml } from "@bendyline/squisq-video";
352
+ import { resolveDimensions } from "@bendyline/squisq-video";
353
+
354
+ // src/util/detectFfmpeg.ts
355
+ import { execFile } from "child_process";
356
+ async function detectFfmpeg() {
357
+ const command = process.platform === "win32" ? "where" : "which";
358
+ return new Promise((resolve3) => {
359
+ execFile(command, ["ffmpeg"], { timeout: 5e3 }, (err, stdout) => {
360
+ if (err || !stdout.trim()) {
361
+ resolve3(null);
362
+ return;
363
+ }
364
+ const path = stdout.trim().split("\n")[0].trim();
365
+ resolve3(path);
366
+ });
367
+ });
368
+ }
369
+
370
+ // src/api.ts
371
+ import { MemoryContentContainer as MemoryContentContainer2 } from "@bendyline/squisq/storage";
372
+ async function renderDocToMp4(doc, container, options) {
373
+ const {
374
+ outputPath,
375
+ fps = 30,
376
+ quality = "normal",
377
+ orientation = "landscape",
378
+ captionStyle,
379
+ coverPreRoll = 0,
380
+ onProgress
381
+ } = options;
382
+ const dimensions = resolveDimensions({
383
+ orientation,
384
+ width: options.width,
385
+ height: options.height
386
+ });
387
+ const ffmpegPath = await detectFfmpeg();
388
+ if (!ffmpegPath) {
389
+ throw new Error(
390
+ "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"
391
+ );
392
+ }
393
+ onProgress?.("collecting media", 0);
394
+ const { collectImagePaths } = await import("@bendyline/squisq-formats/html");
395
+ const imagePaths = collectImagePaths(doc);
396
+ const images = /* @__PURE__ */ new Map();
397
+ for (const imgPath of imagePaths) {
398
+ const data = await container.readFile(imgPath);
399
+ if (data) {
400
+ images.set(imgPath, data);
401
+ }
402
+ }
403
+ const audio = /* @__PURE__ */ new Map();
404
+ const audioBuffers = [];
405
+ if (doc.audio?.segments?.length) {
406
+ for (const seg of doc.audio.segments) {
407
+ const data = await container.readFile(seg.src);
408
+ if (data) {
409
+ audio.set(seg.src, data);
410
+ audio.set(seg.name, data);
411
+ audioBuffers.push(data);
412
+ }
413
+ }
414
+ }
415
+ let concatenatedAudio = null;
416
+ if (audioBuffers.length > 0) {
417
+ concatenatedAudio = await concatenateAudioBuffers(audioBuffers, ffmpegPath);
418
+ }
419
+ onProgress?.("generating render HTML", 10);
420
+ const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
421
+ const renderHtml = generateRenderHtml(doc, {
422
+ playerScript: PLAYER_BUNDLE,
423
+ images,
424
+ audio: audio.size > 0 ? audio : void 0,
425
+ width: dimensions.width,
426
+ height: dimensions.height,
427
+ captionStyle
428
+ });
429
+ onProgress?.("launching browser", 15);
430
+ const { chromium } = await import("playwright-core");
431
+ const browser = await chromium.launch({ headless: true });
432
+ const page = await browser.newPage({
433
+ viewport: { width: dimensions.width, height: dimensions.height }
434
+ });
435
+ const pageErrors = [];
436
+ page.on("pageerror", (err) => pageErrors.push(err.message));
437
+ await page.setContent(renderHtml, { waitUntil: "load" });
438
+ await page.waitForTimeout(500);
439
+ try {
440
+ await page.waitForFunction(
441
+ () => typeof window.getDuration === "function",
442
+ { timeout: 15e3 }
443
+ );
444
+ } catch {
445
+ await browser.close();
446
+ const errorDetail = pageErrors.length ? `
447
+ Page errors:
448
+ ${pageErrors.join("\n ")}` : "\nNo page errors captured \u2014 the player may have failed to mount.";
449
+ throw new Error(`Render API did not initialize within 15 seconds.${errorDetail}`);
450
+ }
451
+ const docDuration = await page.evaluate(() => {
452
+ return window.getDuration();
453
+ });
454
+ if (docDuration <= 0) {
455
+ await browser.close();
456
+ throw new Error("Document has zero duration \u2014 nothing to render");
457
+ }
458
+ const storyFrameCount = Math.ceil(docDuration * fps);
459
+ const preRollFrameCount = Math.ceil(coverPreRoll * fps);
460
+ const totalFrames = preRollFrameCount + storyFrameCount;
461
+ const frames = [];
462
+ onProgress?.("capturing frames", 20);
463
+ if (preRollFrameCount > 0) {
464
+ const hasCover = await page.evaluate(() => {
465
+ const w = window;
466
+ return typeof w.hasCoverBlock === "function" ? w.hasCoverBlock() : false;
467
+ });
468
+ if (hasCover) {
469
+ await page.evaluate(() => {
470
+ window.showCover();
471
+ });
472
+ await page.waitForTimeout(100);
473
+ const coverFrame = new Uint8Array(await page.screenshot({ type: "png" }));
474
+ for (let i = 0; i < preRollFrameCount; i++) {
475
+ frames.push(coverFrame);
476
+ }
477
+ await page.evaluate(() => {
478
+ window.hideCover();
479
+ });
480
+ }
481
+ }
482
+ const frameInterval = 1 / fps;
483
+ for (let i = 0; i < storyFrameCount; i++) {
484
+ const time = i * frameInterval;
485
+ await page.evaluate((t) => {
486
+ return window.seekTo(t);
487
+ }, time);
488
+ const screenshot = await page.screenshot({ type: "png" });
489
+ frames.push(new Uint8Array(screenshot));
490
+ if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {
491
+ const pct = 20 + Math.round(frames.length / totalFrames * 60);
492
+ onProgress?.("capturing frames", pct);
493
+ }
494
+ }
495
+ await browser.close();
496
+ onProgress?.("encoding video", 80);
497
+ let encodingAudio = concatenatedAudio;
498
+ if (coverPreRoll > 0 && concatenatedAudio) {
499
+ encodingAudio = await addAudioDelay(ffmpegPath, concatenatedAudio, coverPreRoll);
500
+ }
501
+ const { framesToMp4Native } = await import("./nativeEncoder-EXDP2O5B.js");
502
+ await framesToMp4Native(ffmpegPath, frames, encodingAudio, outputPath, {
503
+ fps,
504
+ quality,
505
+ orientation,
506
+ width: dimensions.width,
507
+ height: dimensions.height,
508
+ onProgress: (percent, phase) => {
509
+ onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2));
510
+ }
511
+ });
512
+ onProgress?.("done", 100);
513
+ const totalDuration = docDuration + coverPreRoll;
514
+ return {
515
+ duration: totalDuration,
516
+ frameCount: frames.length,
517
+ outputPath
518
+ };
519
+ }
520
+ async function concatenateAudioBuffers(buffers, ffmpegPath) {
521
+ if (buffers.length === 0) return new Uint8Array(0);
522
+ if (buffers.length === 1) return new Uint8Array(buffers[0]);
523
+ if (ffmpegPath) {
524
+ return concatenateAudioNative(ffmpegPath, buffers);
525
+ }
526
+ const totalLength = buffers.reduce((sum, b) => sum + b.byteLength, 0);
527
+ const result = new Uint8Array(totalLength);
528
+ let offset = 0;
529
+ for (const buf of buffers) {
530
+ result.set(new Uint8Array(buf), offset);
531
+ offset += buf.byteLength;
532
+ }
533
+ return result;
534
+ }
535
+ async function concatenateAudioNative(ffmpegPath, buffers) {
536
+ const { writeFile: writeFile2, readFile: readFile2, mkdir: mkdir3, rm } = await import("fs/promises");
537
+ const { join: join3 } = await import("path");
538
+ const { tmpdir } = await import("os");
539
+ const { randomBytes } = await import("crypto");
540
+ const { execFile: execFile2 } = await import("child_process");
541
+ const tmpId = randomBytes(8).toString("hex");
542
+ const workDir = join3(tmpdir(), `squisq-audio-concat-${tmpId}`);
543
+ await mkdir3(workDir, { recursive: true });
544
+ try {
545
+ const segmentPaths = [];
546
+ for (let i = 0; i < buffers.length; i++) {
547
+ const segPath = join3(workDir, `seg-${i}.mp3`);
548
+ await writeFile2(segPath, new Uint8Array(buffers[i]));
549
+ segmentPaths.push(segPath);
550
+ }
551
+ const listPath = join3(workDir, "concat-list.txt");
552
+ const listContent = segmentPaths.map((p) => `file '${p.replace(/\\/g, "/")}'`).join("\n");
553
+ await writeFile2(listPath, listContent);
554
+ const outputPath = join3(workDir, "combined.mp3");
555
+ await new Promise((resolve3, reject) => {
556
+ execFile2(
557
+ ffmpegPath,
558
+ ["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", outputPath],
559
+ { timeout: 12e4 },
560
+ (err) => {
561
+ if (err) reject(new Error(`ffmpeg audio concat failed: ${err.message}`));
562
+ else resolve3();
563
+ }
564
+ );
565
+ });
566
+ const data = await readFile2(outputPath);
567
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
568
+ } finally {
569
+ await rm(workDir, { recursive: true, force: true });
570
+ }
571
+ }
572
+ async function addAudioDelay(ffmpegPath, audioData, delaySecs) {
573
+ const { writeFile: writeFile2, readFile: readFile2, rm } = await import("fs/promises");
574
+ const { join: join3 } = await import("path");
575
+ const { tmpdir } = await import("os");
576
+ const { randomBytes } = await import("crypto");
577
+ const { execFile: execFile2 } = await import("child_process");
578
+ const tmpId = randomBytes(8).toString("hex");
579
+ const inputPath = join3(tmpdir(), `squisq-audio-delay-in-${tmpId}.mp3`);
580
+ const outputPath = join3(tmpdir(), `squisq-audio-delay-out-${tmpId}.mp3`);
581
+ try {
582
+ await writeFile2(inputPath, audioData);
583
+ const delayMs = Math.round(delaySecs * 1e3);
584
+ await new Promise((resolve3, reject) => {
585
+ execFile2(
586
+ ffmpegPath,
587
+ [
588
+ "-y",
589
+ "-i",
590
+ inputPath,
591
+ "-af",
592
+ `adelay=${delayMs}|${delayMs}`,
593
+ "-c:a",
594
+ "libmp3lame",
595
+ "-b:a",
596
+ "128k",
597
+ outputPath
598
+ ],
599
+ { timeout: 6e4 },
600
+ (err) => {
601
+ if (err) reject(new Error(`ffmpeg audio delay failed: ${err.message}`));
602
+ else resolve3();
603
+ }
604
+ );
605
+ });
606
+ const data = await readFile2(outputPath);
607
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
608
+ } finally {
609
+ await rm(inputPath, { force: true });
610
+ await rm(outputPath, { force: true });
611
+ }
612
+ }
613
+
614
+ // src/commands/video.ts
615
+ var VALID_QUALITIES = ["draft", "normal", "high"];
616
+ var VALID_ORIENTATIONS = ["landscape", "portrait"];
617
+ var VALID_CAPTIONS = ["off", "standard", "social"];
618
+ function registerVideoCommand(program2) {
619
+ 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(
620
+ "--quality <level>",
621
+ `Encoding quality: ${VALID_QUALITIES.join(", ")} (default: normal)`,
622
+ "normal"
623
+ ).option(
624
+ "--orientation <orient>",
625
+ `Video orientation: ${VALID_ORIENTATIONS.join(", ")} (default: landscape)`,
626
+ "landscape"
627
+ ).option(
628
+ "--captions <style>",
629
+ `Caption style: ${VALID_CAPTIONS.join(", ")} (default: off)`,
630
+ "off"
631
+ ).option("--width <pixels>", "Override video width").option("--height <pixels>", "Override video height").action(async (inputPath, outputArg, opts) => {
632
+ try {
633
+ if (outputArg && !opts.output) {
634
+ opts.output = outputArg;
635
+ }
636
+ await runVideo(inputPath, opts);
637
+ } catch (err) {
638
+ const message = err instanceof Error ? err.message : String(err);
639
+ console.error(`Error: ${message}`);
640
+ process.exitCode = 1;
641
+ }
642
+ });
643
+ }
644
+ async function runVideo(inputPath, opts) {
645
+ const resolvedInput = resolve2(inputPath);
646
+ const fps = parseInt(opts.fps ?? "30", 10);
647
+ if (isNaN(fps) || fps < 1 || fps > 120) {
648
+ throw new Error("FPS must be a number between 1 and 120");
649
+ }
650
+ const quality = opts.quality ?? "normal";
651
+ if (!VALID_QUALITIES.includes(quality)) {
652
+ throw new Error(`Invalid quality "${quality}". Valid: ${VALID_QUALITIES.join(", ")}`);
653
+ }
654
+ const orientation = opts.orientation ?? "landscape";
655
+ if (!VALID_ORIENTATIONS.includes(orientation)) {
656
+ throw new Error(
657
+ `Invalid orientation "${orientation}". Valid: ${VALID_ORIENTATIONS.join(", ")}`
658
+ );
659
+ }
660
+ const captions = opts.captions ?? "off";
661
+ if (!VALID_CAPTIONS.includes(captions)) {
662
+ throw new Error(`Invalid captions "${captions}". Valid: ${VALID_CAPTIONS.join(", ")}`);
663
+ }
664
+ const captionStyle = captions === "off" ? void 0 : captions;
665
+ const inputBasename = basename2(resolvedInput);
666
+ const inputExt = extname3(inputBasename);
667
+ const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
668
+ const outputPath = opts.output ? resolve2(opts.output) : resolve2(dirname2(resolvedInput), `${baseName}.mp4`);
669
+ await mkdir2(dirname2(outputPath), { recursive: true });
670
+ console.error(`Reading: ${resolvedInput}`);
671
+ const result = await readInput(resolvedInput);
672
+ const { container } = result;
673
+ let doc;
674
+ if (result.doc) {
675
+ console.error("Using pre-built Doc JSON");
676
+ doc = result.doc;
677
+ } else if (result.markdownDoc) {
678
+ const { markdownToDoc } = await import("@bendyline/squisq/doc");
679
+ doc = markdownToDoc(result.markdownDoc);
680
+ } else {
681
+ throw new Error("No document found in input");
682
+ }
683
+ console.error(
684
+ `Rendering: ${fps} fps, quality: ${quality}, orientation: ${orientation}, captions: ${captions}`
685
+ );
686
+ const result2 = await renderDocToMp4(doc, container, {
687
+ outputPath,
688
+ fps,
689
+ quality,
690
+ orientation,
691
+ width: opts.width ? parseInt(opts.width, 10) : void 0,
692
+ height: opts.height ? parseInt(opts.height, 10) : void 0,
693
+ captionStyle,
694
+ coverPreRoll: 2,
695
+ onProgress: (phase, percent) => {
696
+ writeProgress(phase, percent, 100);
697
+ }
698
+ });
699
+ clearProgress();
700
+ console.error(
701
+ ` \u2713 ${outputPath} (${result2.duration.toFixed(1)}s, ${result2.frameCount} frames)`
702
+ );
703
+ console.error("Done.");
704
+ }
705
+ var BAR_WIDTH = 30;
706
+ function writeProgress(label, current, total, detail) {
707
+ const pct = Math.min(100, Math.round(current / total * 100));
708
+ const filled = Math.round(pct / 100 * BAR_WIDTH);
709
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(BAR_WIDTH - filled);
710
+ const suffix = detail ? ` (${detail})` : "";
711
+ process.stderr.write(`\r ${label}: ${bar} ${pct}%${suffix} `);
712
+ }
713
+ function clearProgress() {
714
+ process.stderr.write("\r" + " ".repeat(80) + "\r");
715
+ }
716
+
717
+ // src/index.ts
718
+ console.error(
719
+ "\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"
720
+ );
721
+ var program = new Command();
722
+ program.name("squisq").description("Squisq CLI \u2014 convert and process markdown-based documents").version("1.0.0");
21
723
  registerConvertCommand(program);
22
724
  registerVideoCommand(program);
23
725
  program.parse();