@bendyline/squisq-cli 1.1.6 → 1.2.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/api.js +93 -1
- package/dist/api.js.map +1 -1
- package/dist/index.js +196 -29
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
package/dist/api.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// src/api.ts
|
|
2
|
+
import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
|
|
3
|
+
import { flattenBlocks } from "@bendyline/squisq/doc";
|
|
2
4
|
import { generateRenderHtml } from "@bendyline/squisq-video";
|
|
3
5
|
import { resolveDimensions } from "@bendyline/squisq-video";
|
|
4
6
|
|
|
@@ -183,6 +185,24 @@ async function renderDocToMp4(doc, container, options) {
|
|
|
183
185
|
if (audioBuffers.length > 0) {
|
|
184
186
|
concatenatedAudio = await concatenateAudioBuffers(audioBuffers, ffmpegPath);
|
|
185
187
|
}
|
|
188
|
+
const schedule = resolveMediaSchedule(doc);
|
|
189
|
+
const mediaSrcs = new Set(schedule.map((c) => c.src));
|
|
190
|
+
for (const block of flattenBlocks(doc.blocks)) {
|
|
191
|
+
for (const layer of block.layers ?? []) {
|
|
192
|
+
if (layer.type === "video") mediaSrcs.add(layer.content.src);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const clipBuffers = /* @__PURE__ */ new Map();
|
|
196
|
+
for (const src of mediaSrcs) {
|
|
197
|
+
if (images.has(src)) continue;
|
|
198
|
+
const data = await container.readFile(src);
|
|
199
|
+
if (data) images.set(src, data);
|
|
200
|
+
}
|
|
201
|
+
for (const clip of schedule) {
|
|
202
|
+
if (clip.kind !== "audio") continue;
|
|
203
|
+
const data = images.get(clip.src) ?? await container.readFile(clip.src);
|
|
204
|
+
if (data) clipBuffers.set(clip.id, data);
|
|
205
|
+
}
|
|
186
206
|
onProgress?.("generating render HTML", 10);
|
|
187
207
|
const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
|
|
188
208
|
const renderHtml = generateRenderHtml(doc, {
|
|
@@ -261,8 +281,21 @@ Page errors:
|
|
|
261
281
|
}
|
|
262
282
|
await browser.close();
|
|
263
283
|
onProgress?.("encoding video", 80);
|
|
284
|
+
const scheduledAudioClips = schedule.filter((c) => c.kind === "audio" && clipBuffers.has(c.id));
|
|
264
285
|
let encodingAudio = concatenatedAudio;
|
|
265
|
-
if (
|
|
286
|
+
if (scheduledAudioClips.length > 0 && ffmpegPath) {
|
|
287
|
+
encodingAudio = await mixScheduledAudio(
|
|
288
|
+
ffmpegPath,
|
|
289
|
+
concatenatedAudio,
|
|
290
|
+
scheduledAudioClips.map((c) => ({
|
|
291
|
+
buffer: clipBuffers.get(c.id),
|
|
292
|
+
delaySec: c.absoluteStart + coverPreRoll,
|
|
293
|
+
trimStart: c.sourceIn,
|
|
294
|
+
trimLen: Math.max(0, c.absoluteEnd - c.absoluteStart)
|
|
295
|
+
})),
|
|
296
|
+
coverPreRoll
|
|
297
|
+
);
|
|
298
|
+
} else if (coverPreRoll > 0 && concatenatedAudio) {
|
|
266
299
|
encodingAudio = await addAudioDelay(ffmpegPath, concatenatedAudio, coverPreRoll);
|
|
267
300
|
}
|
|
268
301
|
const { framesToMp4Native } = await import("./nativeEncoder-MWHOONST.js");
|
|
@@ -377,6 +410,65 @@ async function addAudioDelay(ffmpegPath, audioData, delaySecs) {
|
|
|
377
410
|
await rm(outputPath, { force: true });
|
|
378
411
|
}
|
|
379
412
|
}
|
|
413
|
+
async function mixScheduledAudio(ffmpegPath, narration, clips, coverPreRoll) {
|
|
414
|
+
if (!narration && clips.length === 0) return null;
|
|
415
|
+
const { writeFile, readFile: readFile2, mkdir, rm } = await import("fs/promises");
|
|
416
|
+
const { join: join2 } = await import("path");
|
|
417
|
+
const { tmpdir } = await import("os");
|
|
418
|
+
const { randomBytes } = await import("crypto");
|
|
419
|
+
const { execFile: execFile2 } = await import("child_process");
|
|
420
|
+
const workDir = join2(tmpdir(), `squisq-audio-mix-${randomBytes(8).toString("hex")}`);
|
|
421
|
+
await mkdir(workDir, { recursive: true });
|
|
422
|
+
try {
|
|
423
|
+
const inputs = [];
|
|
424
|
+
const filters = [];
|
|
425
|
+
const labels = [];
|
|
426
|
+
const ms = (s) => Math.max(0, Math.round(s * 1e3));
|
|
427
|
+
if (narration) {
|
|
428
|
+
const p = join2(workDir, "narration.mp3");
|
|
429
|
+
await writeFile(p, narration);
|
|
430
|
+
const i = inputs.push(p) - 1;
|
|
431
|
+
const d = ms(coverPreRoll);
|
|
432
|
+
filters.push(`[${i}:a]adelay=${d}|${d}[a${i}]`);
|
|
433
|
+
labels.push(`[a${i}]`);
|
|
434
|
+
}
|
|
435
|
+
for (const clip of clips) {
|
|
436
|
+
const p = join2(workDir, `clip-${inputs.length}.mp3`);
|
|
437
|
+
await writeFile(p, new Uint8Array(clip.buffer));
|
|
438
|
+
const i = inputs.push(p) - 1;
|
|
439
|
+
const d = ms(clip.delaySec);
|
|
440
|
+
const end = clip.trimStart + clip.trimLen;
|
|
441
|
+
filters.push(
|
|
442
|
+
`[${i}:a]atrim=start=${clip.trimStart}:end=${end},asetpts=PTS-STARTPTS,adelay=${d}|${d}[a${i}]`
|
|
443
|
+
);
|
|
444
|
+
labels.push(`[a${i}]`);
|
|
445
|
+
}
|
|
446
|
+
const graph = `${filters.join(";")};${labels.join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[aout]`;
|
|
447
|
+
const args = ["-y"];
|
|
448
|
+
for (const p of inputs) args.push("-i", p);
|
|
449
|
+
args.push(
|
|
450
|
+
"-filter_complex",
|
|
451
|
+
graph,
|
|
452
|
+
"-map",
|
|
453
|
+
"[aout]",
|
|
454
|
+
"-c:a",
|
|
455
|
+
"libmp3lame",
|
|
456
|
+
"-b:a",
|
|
457
|
+
"192k",
|
|
458
|
+
join2(workDir, "mixed.mp3")
|
|
459
|
+
);
|
|
460
|
+
await new Promise((resolve, reject) => {
|
|
461
|
+
execFile2(ffmpegPath, args, { timeout: 18e4 }, (err) => {
|
|
462
|
+
if (err) reject(new Error(`ffmpeg audio mix failed: ${err.message}`));
|
|
463
|
+
else resolve();
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
const data = await readFile2(join2(workDir, "mixed.mp3"));
|
|
467
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
468
|
+
} finally {
|
|
469
|
+
await rm(workDir, { recursive: true, force: true });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
380
472
|
async function extractThumbnails(options) {
|
|
381
473
|
const { videoPath, outputDir, slug, sizes, force } = options;
|
|
382
474
|
const { existsSync } = await import("fs");
|
package/dist/api.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/api.ts","../src/util/detectFfmpeg.ts","../src/util/readInput.ts"],"sourcesContent":["/**\n * Programmatic Video API\n *\n * Provides a library-style entry point for rendering Squisq documents to MP4\n * from Node.js callers (e.g., Qualla's pipeline). This avoids the need to shell\n * out to the `squisq video` CLI and gives callers full control over the Doc,\n * MemoryContentContainer, and encoding options.\n *\n * Orchestrates the full pipeline: Doc → render HTML → Playwright frame capture → FFmpeg encode.\n *\n * Usage:\n * import { renderDocToMp4 } from '@bendyline/squisq-cli/api';\n *\n * await renderDocToMp4(doc, container, {\n * outputPath: '/tmp/output.mp4',\n * fps: 30,\n * quality: 'normal',\n * orientation: 'landscape',\n * });\n */\n\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport type { MemoryContentContainer } from '@bendyline/squisq/storage';\nimport type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';\nimport { generateRenderHtml } from '@bendyline/squisq-video';\nimport { resolveDimensions } from '@bendyline/squisq-video';\nimport { detectFfmpeg } from './util/detectFfmpeg.js';\n\n// Re-export utility types and functions callers may need\nexport type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';\nexport { MemoryContentContainer } from '@bendyline/squisq/storage';\nexport { readInput } from './util/readInput.js';\nexport type { ReadInputResult } from './util/readInput.js';\n\n/** Options for renderDocToMp4. */\nexport interface RenderDocToMp4Options {\n /** Output file path for the MP4. */\n outputPath: string;\n\n /** Frames per second (default: 30). */\n fps?: number;\n\n /** Encoding quality preset (default: 'normal'). */\n quality?: VideoQuality;\n\n /** Video orientation (default: 'landscape'). */\n orientation?: VideoOrientation;\n\n /** Override video width in pixels. */\n width?: number;\n\n /** Override video height in pixels. */\n height?: number;\n\n /** Caption style to bake into the video (default: none). */\n captionStyle?: 'standard' | 'social';\n\n /** Seconds of cover-slide pre-roll before the story starts (default: 0). */\n coverPreRoll?: number;\n\n /**\n * Progress callback. Called with a phase name and 0-100 percentage.\n */\n onProgress?: (phase: string, percent: number) => void;\n}\n\n/** Result returned by renderDocToMp4. */\nexport interface RenderDocToMp4Result {\n /** Duration of the rendered video in seconds (including pre-roll). */\n duration: number;\n\n /** Number of frames captured. */\n frameCount: number;\n\n /** Output file path. */\n outputPath: string;\n}\n\n/**\n * Render a Doc + media container to an MP4 video file.\n *\n * The container should contain audio and image files referenced by the Doc's\n * audio.segments[].src and block image paths. Files are embedded as base64\n * data URIs in a self-contained render HTML page.\n *\n * Requires:\n * - Playwright (chromium) — for headless frame capture\n * - FFmpeg — for video encoding (must be on PATH)\n *\n * @param doc - The Doc structure to render\n * @param container - MemoryContentContainer with audio/image files\n * @param options - Rendering and encoding options\n * @returns Result with duration and frame count\n */\nexport async function renderDocToMp4(\n doc: Doc,\n container: MemoryContentContainer,\n options: RenderDocToMp4Options,\n): Promise<RenderDocToMp4Result> {\n const {\n outputPath,\n fps = 30,\n quality = 'normal',\n orientation = 'landscape',\n captionStyle,\n coverPreRoll = 0,\n onProgress,\n } = options;\n\n const dimensions = resolveDimensions({\n orientation,\n width: options.width,\n height: options.height,\n });\n\n // Detect ffmpeg early — needed for audio concat and video encoding\n const ffmpegPath = await detectFfmpeg();\n if (!ffmpegPath) {\n throw new Error(\n 'ffmpeg is required but not found in PATH.\\n' +\n 'Install it with:\\n' +\n ' macOS: brew install ffmpeg\\n' +\n ' Ubuntu: sudo apt install ffmpeg\\n' +\n ' Windows: winget install ffmpeg',\n );\n }\n\n onProgress?.('collecting media', 0);\n\n // ── Collect images from container ───────────────────────────────\n const { collectImagePaths } = await import('@bendyline/squisq-formats/html');\n const imagePaths = collectImagePaths(doc);\n const images = new Map<string, ArrayBuffer>();\n for (const imgPath of imagePaths) {\n const data = await container.readFile(imgPath);\n if (data) {\n images.set(imgPath, data);\n }\n }\n\n // ── Collect audio segments ──────────────────────────────────────\n const audio = new Map<string, ArrayBuffer>();\n const audioBuffers: ArrayBuffer[] = [];\n if (doc.audio?.segments?.length) {\n for (const seg of doc.audio.segments) {\n const data = await container.readFile(seg.src);\n if (data) {\n audio.set(seg.src, data);\n audio.set(seg.name, data);\n audioBuffers.push(data);\n }\n }\n }\n\n // Concatenate audio for the MP4's audio track\n let concatenatedAudio: Uint8Array | null = null;\n if (audioBuffers.length > 0) {\n concatenatedAudio = await concatenateAudioBuffers(audioBuffers, ffmpegPath);\n }\n\n onProgress?.('generating render HTML', 10);\n\n // ── Generate self-contained render HTML ─────────────────────────\n const { PLAYER_BUNDLE } = await import('@bendyline/squisq-react/standalone-source');\n const renderHtml = generateRenderHtml(doc, {\n playerScript: PLAYER_BUNDLE,\n images,\n audio: audio.size > 0 ? audio : undefined,\n width: dimensions.width,\n height: dimensions.height,\n captionStyle,\n });\n\n onProgress?.('launching browser', 15);\n\n // ── Playwright frame capture ────────────────────────────────────\n const { chromium } = await import('playwright-core');\n const browser = await chromium.launch({ headless: true });\n const page = await browser.newPage({\n viewport: { width: dimensions.width, height: dimensions.height },\n });\n\n const pageErrors: string[] = [];\n page.on('pageerror', (err) => pageErrors.push(err.message));\n\n await page.setContent(renderHtml, { waitUntil: 'load' });\n await page.waitForTimeout(500);\n\n try {\n await page.waitForFunction(\n () => typeof (window as unknown as Record<string, unknown>).getDuration === 'function',\n { timeout: 15000 },\n );\n } catch {\n await browser.close();\n const errorDetail = pageErrors.length\n ? `\\nPage errors:\\n ${pageErrors.join('\\n ')}`\n : '\\nNo page errors captured — the player may have failed to mount.';\n throw new Error(`Render API did not initialize within 15 seconds.${errorDetail}`);\n }\n\n const docDuration: number = await page.evaluate(() => {\n return (window as unknown as { getDuration: () => number }).getDuration();\n });\n\n if (docDuration <= 0) {\n await browser.close();\n throw new Error('Document has zero duration — nothing to render');\n }\n\n const storyFrameCount = Math.ceil(docDuration * fps);\n const preRollFrameCount = Math.ceil(coverPreRoll * fps);\n const totalFrames = preRollFrameCount + storyFrameCount;\n const frames: Uint8Array[] = [];\n\n onProgress?.('capturing frames', 20);\n\n // Cover slide pre-roll (if requested)\n if (preRollFrameCount > 0) {\n const hasCover: boolean = await page.evaluate(() => {\n const w = window as unknown as { hasCoverBlock?: () => boolean };\n return typeof w.hasCoverBlock === 'function' ? w.hasCoverBlock() : false;\n });\n\n if (hasCover) {\n await page.evaluate(() => {\n (window as unknown as { showCover: () => void }).showCover();\n });\n await page.waitForTimeout(100);\n const coverFrame = new Uint8Array(await page.screenshot({ type: 'png' }));\n for (let i = 0; i < preRollFrameCount; i++) {\n frames.push(coverFrame);\n }\n await page.evaluate(() => {\n (window as unknown as { hideCover: () => void }).hideCover();\n });\n }\n }\n\n // Story frames via seekTo\n const frameInterval = 1 / fps;\n for (let i = 0; i < storyFrameCount; i++) {\n const time = i * frameInterval;\n await page.evaluate((t: number) => {\n return (window as unknown as { seekTo: (t: number) => Promise<void> }).seekTo(t);\n }, time);\n\n const screenshot = await page.screenshot({ type: 'png' });\n frames.push(new Uint8Array(screenshot));\n\n // Report progress: frames phase is 20% to 80%\n if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {\n const pct = 20 + Math.round((frames.length / totalFrames) * 60);\n onProgress?.('capturing frames', pct);\n }\n }\n\n await browser.close();\n\n onProgress?.('encoding video', 80);\n\n // If there's a pre-roll, delay the audio track to match\n let encodingAudio = concatenatedAudio;\n if (coverPreRoll > 0 && concatenatedAudio) {\n // Use FFmpeg to add silence padding at the start (adelay filter)\n encodingAudio = await addAudioDelay(ffmpegPath, concatenatedAudio, coverPreRoll);\n }\n\n const { framesToMp4Native } = await import('./util/nativeEncoder.js');\n await framesToMp4Native(ffmpegPath, frames, encodingAudio, outputPath, {\n fps,\n quality,\n orientation,\n width: dimensions.width,\n height: dimensions.height,\n onProgress: (percent, phase) => {\n onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2));\n },\n });\n\n onProgress?.('done', 100);\n\n const totalDuration = docDuration + coverPreRoll;\n return {\n duration: totalDuration,\n frameCount: frames.length,\n outputPath,\n };\n}\n\n// ── Audio helpers ─────────────────────────────────────────────────\n\n/**\n * Concatenate multiple audio buffers into one.\n * Uses native ffmpeg concat when available, falls back to byte concatenation.\n */\nasync function concatenateAudioBuffers(\n buffers: ArrayBuffer[],\n ffmpegPath?: string,\n): Promise<Uint8Array> {\n if (buffers.length === 0) return new Uint8Array(0);\n if (buffers.length === 1) return new Uint8Array(buffers[0]);\n\n if (ffmpegPath) {\n return concatenateAudioNative(ffmpegPath, buffers);\n }\n\n // Fallback: naive byte concatenation (works for MP3)\n const totalLength = buffers.reduce((sum, b) => sum + b.byteLength, 0);\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const buf of buffers) {\n result.set(new Uint8Array(buf), offset);\n offset += buf.byteLength;\n }\n return result;\n}\n\nasync function concatenateAudioNative(\n ffmpegPath: string,\n buffers: ArrayBuffer[],\n): Promise<Uint8Array> {\n const { writeFile, readFile, mkdir, rm } = await import('node:fs/promises');\n const { join } = await import('node:path');\n const { tmpdir } = await import('node:os');\n const { randomBytes } = await import('node:crypto');\n const { execFile } = await import('node:child_process');\n\n const tmpId = randomBytes(8).toString('hex');\n const workDir = join(tmpdir(), `squisq-audio-concat-${tmpId}`);\n await mkdir(workDir, { recursive: true });\n\n try {\n const segmentPaths: string[] = [];\n for (let i = 0; i < buffers.length; i++) {\n const segPath = join(workDir, `seg-${i}.mp3`);\n await writeFile(segPath, new Uint8Array(buffers[i]));\n segmentPaths.push(segPath);\n }\n\n const listPath = join(workDir, 'concat-list.txt');\n const listContent = segmentPaths.map((p) => `file '${p.replace(/\\\\/g, '/')}'`).join('\\n');\n await writeFile(listPath, listContent);\n\n const outputPath = join(workDir, 'combined.mp3');\n await new Promise<void>((resolve, reject) => {\n execFile(\n ffmpegPath,\n ['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', outputPath],\n { timeout: 120_000 },\n (err) => {\n if (err) reject(new Error(`ffmpeg audio concat failed: ${err.message}`));\n else resolve();\n },\n );\n });\n\n const data = await readFile(outputPath);\n return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n } finally {\n await rm(workDir, { recursive: true, force: true });\n }\n}\n\n/**\n * Add silence at the start of an audio track by re-encoding with adelay filter.\n */\nasync function addAudioDelay(\n ffmpegPath: string,\n audioData: Uint8Array,\n delaySecs: number,\n): Promise<Uint8Array> {\n const { writeFile, readFile, rm } = await import('node:fs/promises');\n const { join } = await import('node:path');\n const { tmpdir } = await import('node:os');\n const { randomBytes } = await import('node:crypto');\n const { execFile } = await import('node:child_process');\n\n const tmpId = randomBytes(8).toString('hex');\n const inputPath = join(tmpdir(), `squisq-audio-delay-in-${tmpId}.mp3`);\n const outputPath = join(tmpdir(), `squisq-audio-delay-out-${tmpId}.mp3`);\n\n try {\n await writeFile(inputPath, audioData);\n const delayMs = Math.round(delaySecs * 1000);\n\n await new Promise<void>((resolve, reject) => {\n execFile(\n ffmpegPath,\n [\n '-y',\n '-i',\n inputPath,\n '-af',\n `adelay=${delayMs}|${delayMs}`,\n '-c:a',\n 'libmp3lame',\n '-b:a',\n '128k',\n outputPath,\n ],\n { timeout: 60_000 },\n (err) => {\n if (err) reject(new Error(`ffmpeg audio delay failed: ${err.message}`));\n else resolve();\n },\n );\n });\n\n const data = await readFile(outputPath);\n return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n } finally {\n await rm(inputPath, { force: true });\n await rm(outputPath, { force: true });\n }\n}\n\n// ── Thumbnail extraction ──────────────────────────────────────────\n\n/** A thumbnail size specification. */\nexport interface ThumbnailSpec {\n /** Label for the thumbnail (used in filename: `{slug}-{width}x{height}.jpg`). */\n name: string;\n /** Output width in pixels. */\n width: number;\n /** Output height in pixels. */\n height: number;\n /** FFmpeg video filter string (e.g., 'scale=1280:720'). */\n filter: string;\n}\n\n/** Options for extractThumbnails. */\nexport interface ExtractThumbnailsOptions {\n /** Path to the source MP4 video. */\n videoPath: string;\n /** Directory to write thumbnails into. */\n outputDir: string;\n /** Base slug for filenames (produces `{slug}-{width}x{height}.jpg`). */\n slug: string;\n /** Thumbnail sizes to generate. */\n sizes: ThumbnailSpec[];\n /** Overwrite existing thumbnails (default: false). */\n force?: boolean;\n}\n\n/**\n * Extract thumbnail images from the first frame of an MP4 video.\n * Produces JPEG files at each specified size using FFmpeg video filters.\n */\nexport async function extractThumbnails(options: ExtractThumbnailsOptions): Promise<void> {\n const { videoPath, outputDir, slug, sizes, force } = options;\n const { existsSync } = await import('node:fs');\n const { join } = await import('node:path');\n const { execFile } = await import('node:child_process');\n\n const ffmpegPath = await detectFfmpeg();\n if (!ffmpegPath) {\n throw new Error(\n 'ffmpeg is required for thumbnail extraction but not found in PATH.\\n' +\n 'Install it with:\\n' +\n ' macOS: brew install ffmpeg\\n' +\n ' Ubuntu: sudo apt install ffmpeg\\n' +\n ' Windows: winget install ffmpeg',\n );\n }\n\n for (const thumb of sizes) {\n const outputPath = join(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);\n if (!force && existsSync(outputPath)) continue;\n\n await new Promise<void>((resolve, reject) => {\n execFile(\n ffmpegPath,\n ['-y', '-i', videoPath, '-vf', thumb.filter, '-frames:v', '1', '-q:v', '2', outputPath],\n { timeout: 30_000 },\n (err) => {\n if (err) reject(new Error(`Thumbnail extraction failed (${thumb.name}): ${err.message}`));\n else resolve();\n },\n );\n });\n }\n}\n","/**\n * detectFfmpeg\n *\n * Checks whether a native ffmpeg binary is available on the system PATH.\n * Returns the absolute path to the binary if found, or null if not.\n *\n * Uses `which` on macOS/Linux and `where` on Windows.\n */\n\nimport { execFile } from 'node:child_process';\n\n/**\n * Detect whether native ffmpeg is installed and available on PATH.\n *\n * @returns Absolute path to ffmpeg binary, or null if not found\n */\nexport async function detectFfmpeg(): Promise<string | null> {\n const command = process.platform === 'win32' ? 'where' : 'which';\n\n return new Promise((resolve) => {\n execFile(command, ['ffmpeg'], { timeout: 5000 }, (err, stdout) => {\n if (err || !stdout.trim()) {\n resolve(null);\n return;\n }\n // `which` and `where` can return multiple lines; take the first\n const path = stdout.trim().split('\\n')[0].trim();\n resolve(path);\n });\n });\n}\n","/**\n * readInput\n *\n * Unified input reader for the CLI. Accepts a path to a .md file, .json file\n * (Doc schema), .zip/.dbk container, or a folder and returns a MemoryContentContainer\n * (virtual file system) plus either the parsed MarkdownDocument or a pre-built Doc.\n *\n * When a .json file or a container/folder containing doc.json is provided, the\n * Doc is returned directly and markdownDoc will be null. Callers should check\n * result.doc first; when present, skip markdownToDoc() and use it directly.\n */\n\nimport { readFile, readdir, stat } from 'node:fs/promises';\nimport { join, extname } from 'node:path';\nimport { parseMarkdown } from '@bendyline/squisq/markdown';\nimport type { MarkdownDocument } from '@bendyline/squisq/markdown';\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport { MemoryContentContainer } from '@bendyline/squisq/storage';\nimport { zipToContainer } from '@bendyline/squisq-formats/container';\n\nexport interface ReadInputResult {\n container: MemoryContentContainer;\n /** Parsed markdown document. Null when input is a Doc JSON file. */\n markdownDoc: MarkdownDocument | null;\n /** Pre-built Doc from JSON input. Present when input is .json or contains doc.json. */\n doc?: Doc;\n}\n\n/** MIME type lookup by extension (common content types) */\nconst MIME_TYPES: Record<string, string> = {\n '.md': 'text/markdown',\n '.txt': 'text/plain',\n '.json': 'application/json',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.png': 'image/png',\n '.gif': 'image/gif',\n '.webp': 'image/webp',\n '.svg': 'image/svg+xml',\n '.mp3': 'audio/mpeg',\n '.wav': 'audio/wav',\n '.ogg': 'audio/ogg',\n '.mp4': 'video/mp4',\n '.webm': 'video/webm',\n};\n\nfunction mimeFromExt(filePath: string): string {\n return MIME_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';\n}\n\n/**\n * Recursively walk a directory and return all file paths (relative to root).\n */\nasync function walkDir(root: string, prefix = ''): Promise<string[]> {\n const entries = await readdir(root, { withFileTypes: true });\n const paths: string[] = [];\n\n for (const entry of entries) {\n const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;\n if (entry.isDirectory()) {\n paths.push(...(await walkDir(join(root, entry.name), relPath)));\n } else if (entry.isFile()) {\n paths.push(relPath);\n }\n }\n\n return paths;\n}\n\n/**\n * Read input from a file path (markdown, JSON Doc, ZIP/DBK container, or folder)\n * and return a populated ContentContainer + parsed MarkdownDocument or Doc.\n *\n * When the input is a .json file (or a container/folder containing doc.json),\n * the result will have `doc` populated and `markdownDoc` set to null.\n * Otherwise, `markdownDoc` is populated and `doc` is undefined.\n */\nexport async function readInput(inputPath: string): Promise<ReadInputResult> {\n const info = await stat(inputPath);\n\n if (info.isDirectory()) {\n return readFolder(inputPath);\n }\n\n const ext = extname(inputPath).toLowerCase();\n if (ext === '.zip' || ext === '.dbk') {\n return readContainer(inputPath);\n }\n\n if (ext === '.json') {\n return readDocJsonFile(inputPath);\n }\n\n // Default: treat as a markdown file\n return readMarkdownFile(inputPath);\n}\n\nasync function readMarkdownFile(filePath: string): Promise<ReadInputResult> {\n const content = await readFile(filePath, 'utf-8');\n const container = new MemoryContentContainer();\n await container.writeDocument(content);\n const markdownDoc = parseMarkdown(content);\n return { container, markdownDoc };\n}\n\n/**\n * Read a standalone Doc JSON file. The container is empty (no media bundled);\n * callers should populate it or set basePath for media resolution.\n */\nasync function readDocJsonFile(filePath: string): Promise<ReadInputResult> {\n const content = await readFile(filePath, 'utf-8');\n const doc = JSON.parse(content) as Doc;\n const container = new MemoryContentContainer();\n return { container, markdownDoc: null, doc };\n}\n\n/**\n * Known filenames for Doc JSON inside containers and folders.\n * Checked in priority order before falling back to markdown discovery.\n */\nconst DOC_JSON_NAMES = ['doc.json', 'story.json'];\n\nasync function readContainer(filePath: string): Promise<ReadInputResult> {\n const data = await readFile(filePath);\n const container = await zipToContainer(\n data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer,\n );\n\n // Check for Doc JSON inside the container first\n for (const name of DOC_JSON_NAMES) {\n const jsonData = await container.readFile(name);\n if (jsonData) {\n const decoder = new TextDecoder();\n const doc = JSON.parse(decoder.decode(jsonData)) as Doc;\n return { container, markdownDoc: null, doc };\n }\n }\n\n const markdown = await container.readDocument();\n if (!markdown) {\n throw new Error(`No markdown document or doc.json found in container: ${filePath}`);\n }\n\n const markdownDoc = parseMarkdown(markdown);\n return { container, markdownDoc };\n}\n\nasync function readFolder(dirPath: string): Promise<ReadInputResult> {\n const container = new MemoryContentContainer();\n const files = await walkDir(dirPath);\n\n for (const relPath of files) {\n const absPath = join(dirPath, relPath);\n const data = await readFile(absPath);\n await container.writeFile(\n relPath,\n new Uint8Array(data.buffer, data.byteOffset, data.byteLength),\n mimeFromExt(relPath),\n );\n }\n\n // Check for Doc JSON inside the folder first\n for (const name of DOC_JSON_NAMES) {\n const jsonData = await container.readFile(name);\n if (jsonData) {\n const decoder = new TextDecoder();\n const doc = JSON.parse(decoder.decode(jsonData)) as Doc;\n return { container, markdownDoc: null, doc };\n }\n }\n\n const markdown = await container.readDocument();\n if (!markdown) {\n throw new Error(`No markdown document or doc.json found in folder: ${dirPath}`);\n }\n\n const markdownDoc = parseMarkdown(markdown);\n return { container, markdownDoc };\n}\n"],"mappings":"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","names":["MemoryContentContainer","readFile","join","execFile"]}
|
|
1
|
+
{"version":3,"sources":["../src/api.ts","../src/util/detectFfmpeg.ts","../src/util/readInput.ts"],"sourcesContent":["/**\n * Programmatic Video API\n *\n * Provides a library-style entry point for rendering Squisq documents to MP4\n * from Node.js callers (e.g., Qualla's pipeline). This avoids the need to shell\n * out to the `squisq video` CLI and gives callers full control over the Doc,\n * MemoryContentContainer, and encoding options.\n *\n * Orchestrates the full pipeline: Doc → render HTML → Playwright frame capture → FFmpeg encode.\n *\n * Usage:\n * import { renderDocToMp4 } from '@bendyline/squisq-cli/api';\n *\n * await renderDocToMp4(doc, container, {\n * outputPath: '/tmp/output.mp4',\n * fps: 30,\n * quality: 'normal',\n * orientation: 'landscape',\n * });\n */\n\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport { resolveMediaSchedule } from '@bendyline/squisq/schemas';\nimport { flattenBlocks } from '@bendyline/squisq/doc';\nimport type { MemoryContentContainer } from '@bendyline/squisq/storage';\nimport type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';\nimport { generateRenderHtml } from '@bendyline/squisq-video';\nimport { resolveDimensions } from '@bendyline/squisq-video';\nimport { detectFfmpeg } from './util/detectFfmpeg.js';\n\n// Re-export utility types and functions callers may need\nexport type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';\nexport { MemoryContentContainer } from '@bendyline/squisq/storage';\nexport { readInput } from './util/readInput.js';\nexport type { ReadInputResult } from './util/readInput.js';\n\n/** Options for renderDocToMp4. */\nexport interface RenderDocToMp4Options {\n /** Output file path for the MP4. */\n outputPath: string;\n\n /** Frames per second (default: 30). */\n fps?: number;\n\n /** Encoding quality preset (default: 'normal'). */\n quality?: VideoQuality;\n\n /** Video orientation (default: 'landscape'). */\n orientation?: VideoOrientation;\n\n /** Override video width in pixels. */\n width?: number;\n\n /** Override video height in pixels. */\n height?: number;\n\n /** Caption style to bake into the video (default: none). */\n captionStyle?: 'standard' | 'social';\n\n /** Seconds of cover-slide pre-roll before the story starts (default: 0). */\n coverPreRoll?: number;\n\n /**\n * Progress callback. Called with a phase name and 0-100 percentage.\n */\n onProgress?: (phase: string, percent: number) => void;\n}\n\n/** Result returned by renderDocToMp4. */\nexport interface RenderDocToMp4Result {\n /** Duration of the rendered video in seconds (including pre-roll). */\n duration: number;\n\n /** Number of frames captured. */\n frameCount: number;\n\n /** Output file path. */\n outputPath: string;\n}\n\n/**\n * Render a Doc + media container to an MP4 video file.\n *\n * The container should contain audio and image files referenced by the Doc's\n * audio.segments[].src and block image paths. Files are embedded as base64\n * data URIs in a self-contained render HTML page.\n *\n * Requires:\n * - Playwright (chromium) — for headless frame capture\n * - FFmpeg — for video encoding (must be on PATH)\n *\n * @param doc - The Doc structure to render\n * @param container - MemoryContentContainer with audio/image files\n * @param options - Rendering and encoding options\n * @returns Result with duration and frame count\n */\nexport async function renderDocToMp4(\n doc: Doc,\n container: MemoryContentContainer,\n options: RenderDocToMp4Options,\n): Promise<RenderDocToMp4Result> {\n const {\n outputPath,\n fps = 30,\n quality = 'normal',\n orientation = 'landscape',\n captionStyle,\n coverPreRoll = 0,\n onProgress,\n } = options;\n\n const dimensions = resolveDimensions({\n orientation,\n width: options.width,\n height: options.height,\n });\n\n // Detect ffmpeg early — needed for audio concat and video encoding\n const ffmpegPath = await detectFfmpeg();\n if (!ffmpegPath) {\n throw new Error(\n 'ffmpeg is required but not found in PATH.\\n' +\n 'Install it with:\\n' +\n ' macOS: brew install ffmpeg\\n' +\n ' Ubuntu: sudo apt install ffmpeg\\n' +\n ' Windows: winget install ffmpeg',\n );\n }\n\n onProgress?.('collecting media', 0);\n\n // ── Collect images from container ───────────────────────────────\n const { collectImagePaths } = await import('@bendyline/squisq-formats/html');\n const imagePaths = collectImagePaths(doc);\n const images = new Map<string, ArrayBuffer>();\n for (const imgPath of imagePaths) {\n const data = await container.readFile(imgPath);\n if (data) {\n images.set(imgPath, data);\n }\n }\n\n // ── Collect audio segments ──────────────────────────────────────\n const audio = new Map<string, ArrayBuffer>();\n const audioBuffers: ArrayBuffer[] = [];\n if (doc.audio?.segments?.length) {\n for (const seg of doc.audio.segments) {\n const data = await container.readFile(seg.src);\n if (data) {\n audio.set(seg.src, data);\n audio.set(seg.name, data);\n audioBuffers.push(data);\n }\n }\n }\n\n // Concatenate audio for the MP4's audio track\n let concatenatedAudio: Uint8Array | null = null;\n if (audioBuffers.length > 0) {\n concatenatedAudio = await concatenateAudioBuffers(audioBuffers, ffmpegPath);\n }\n\n // ── Collect timed-media clips + block video sources ─────────────\n // Every scheduled clip (block.media + doc.documentMedia) and every\n // template-produced VideoLayer needs its bytes embedded so the headless\n // page can load them with no network. They resolve through the inline\n // media provider's `images` map (which infers mp4/mp3 MIME by extension).\n const schedule = resolveMediaSchedule(doc);\n const mediaSrcs = new Set<string>(schedule.map((c) => c.src));\n for (const block of flattenBlocks(doc.blocks)) {\n for (const layer of block.layers ?? []) {\n if (layer.type === 'video') mediaSrcs.add(layer.content.src);\n }\n }\n // Buffers for the scheduled audio clips, keyed by clip id, for the mux.\n const clipBuffers = new Map<string, ArrayBuffer>();\n for (const src of mediaSrcs) {\n if (images.has(src)) continue;\n const data = await container.readFile(src);\n if (data) images.set(src, data);\n }\n for (const clip of schedule) {\n if (clip.kind !== 'audio') continue;\n const data = images.get(clip.src) ?? (await container.readFile(clip.src));\n if (data) clipBuffers.set(clip.id, data);\n }\n\n onProgress?.('generating render HTML', 10);\n\n // ── Generate self-contained render HTML ─────────────────────────\n const { PLAYER_BUNDLE } = await import('@bendyline/squisq-react/standalone-source');\n const renderHtml = generateRenderHtml(doc, {\n playerScript: PLAYER_BUNDLE,\n images,\n audio: audio.size > 0 ? audio : undefined,\n width: dimensions.width,\n height: dimensions.height,\n captionStyle,\n });\n\n onProgress?.('launching browser', 15);\n\n // ── Playwright frame capture ────────────────────────────────────\n const { chromium } = await import('playwright-core');\n const browser = await chromium.launch({ headless: true });\n const page = await browser.newPage({\n viewport: { width: dimensions.width, height: dimensions.height },\n });\n\n const pageErrors: string[] = [];\n page.on('pageerror', (err) => pageErrors.push(err.message));\n\n await page.setContent(renderHtml, { waitUntil: 'load' });\n await page.waitForTimeout(500);\n\n try {\n await page.waitForFunction(\n () => typeof (window as unknown as Record<string, unknown>).getDuration === 'function',\n { timeout: 15000 },\n );\n } catch {\n await browser.close();\n const errorDetail = pageErrors.length\n ? `\\nPage errors:\\n ${pageErrors.join('\\n ')}`\n : '\\nNo page errors captured — the player may have failed to mount.';\n throw new Error(`Render API did not initialize within 15 seconds.${errorDetail}`);\n }\n\n const docDuration: number = await page.evaluate(() => {\n return (window as unknown as { getDuration: () => number }).getDuration();\n });\n\n if (docDuration <= 0) {\n await browser.close();\n throw new Error('Document has zero duration — nothing to render');\n }\n\n const storyFrameCount = Math.ceil(docDuration * fps);\n const preRollFrameCount = Math.ceil(coverPreRoll * fps);\n const totalFrames = preRollFrameCount + storyFrameCount;\n const frames: Uint8Array[] = [];\n\n onProgress?.('capturing frames', 20);\n\n // Cover slide pre-roll (if requested)\n if (preRollFrameCount > 0) {\n const hasCover: boolean = await page.evaluate(() => {\n const w = window as unknown as { hasCoverBlock?: () => boolean };\n return typeof w.hasCoverBlock === 'function' ? w.hasCoverBlock() : false;\n });\n\n if (hasCover) {\n await page.evaluate(() => {\n (window as unknown as { showCover: () => void }).showCover();\n });\n await page.waitForTimeout(100);\n const coverFrame = new Uint8Array(await page.screenshot({ type: 'png' }));\n for (let i = 0; i < preRollFrameCount; i++) {\n frames.push(coverFrame);\n }\n await page.evaluate(() => {\n (window as unknown as { hideCover: () => void }).hideCover();\n });\n }\n }\n\n // Story frames via seekTo\n const frameInterval = 1 / fps;\n for (let i = 0; i < storyFrameCount; i++) {\n const time = i * frameInterval;\n await page.evaluate((t: number) => {\n return (window as unknown as { seekTo: (t: number) => Promise<void> }).seekTo(t);\n }, time);\n\n const screenshot = await page.screenshot({ type: 'png' });\n frames.push(new Uint8Array(screenshot));\n\n // Report progress: frames phase is 20% to 80%\n if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {\n const pct = 20 + Math.round((frames.length / totalFrames) * 60);\n onProgress?.('capturing frames', pct);\n }\n }\n\n await browser.close();\n\n onProgress?.('encoding video', 80);\n\n // Build the final audio track. With timed media clips, mix the narration\n // (delayed by the cover pre-roll) and each scheduled audio clip at its\n // absolute time. Without clips, keep the legacy narration-only path so\n // existing exports are unchanged.\n const scheduledAudioClips = schedule.filter((c) => c.kind === 'audio' && clipBuffers.has(c.id));\n let encodingAudio = concatenatedAudio;\n if (scheduledAudioClips.length > 0 && ffmpegPath) {\n encodingAudio = await mixScheduledAudio(\n ffmpegPath,\n concatenatedAudio,\n scheduledAudioClips.map((c) => ({\n buffer: clipBuffers.get(c.id)!,\n delaySec: c.absoluteStart + coverPreRoll,\n trimStart: c.sourceIn,\n trimLen: Math.max(0, c.absoluteEnd - c.absoluteStart),\n })),\n coverPreRoll,\n );\n } else if (coverPreRoll > 0 && concatenatedAudio) {\n // Use FFmpeg to add silence padding at the start (adelay filter)\n encodingAudio = await addAudioDelay(ffmpegPath, concatenatedAudio, coverPreRoll);\n }\n\n const { framesToMp4Native } = await import('./util/nativeEncoder.js');\n await framesToMp4Native(ffmpegPath, frames, encodingAudio, outputPath, {\n fps,\n quality,\n orientation,\n width: dimensions.width,\n height: dimensions.height,\n onProgress: (percent, phase) => {\n onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2));\n },\n });\n\n onProgress?.('done', 100);\n\n const totalDuration = docDuration + coverPreRoll;\n return {\n duration: totalDuration,\n frameCount: frames.length,\n outputPath,\n };\n}\n\n// ── Audio helpers ─────────────────────────────────────────────────\n\n/**\n * Concatenate multiple audio buffers into one.\n * Uses native ffmpeg concat when available, falls back to byte concatenation.\n */\nasync function concatenateAudioBuffers(\n buffers: ArrayBuffer[],\n ffmpegPath?: string,\n): Promise<Uint8Array> {\n if (buffers.length === 0) return new Uint8Array(0);\n if (buffers.length === 1) return new Uint8Array(buffers[0]);\n\n if (ffmpegPath) {\n return concatenateAudioNative(ffmpegPath, buffers);\n }\n\n // Fallback: naive byte concatenation (works for MP3)\n const totalLength = buffers.reduce((sum, b) => sum + b.byteLength, 0);\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const buf of buffers) {\n result.set(new Uint8Array(buf), offset);\n offset += buf.byteLength;\n }\n return result;\n}\n\nasync function concatenateAudioNative(\n ffmpegPath: string,\n buffers: ArrayBuffer[],\n): Promise<Uint8Array> {\n const { writeFile, readFile, mkdir, rm } = await import('node:fs/promises');\n const { join } = await import('node:path');\n const { tmpdir } = await import('node:os');\n const { randomBytes } = await import('node:crypto');\n const { execFile } = await import('node:child_process');\n\n const tmpId = randomBytes(8).toString('hex');\n const workDir = join(tmpdir(), `squisq-audio-concat-${tmpId}`);\n await mkdir(workDir, { recursive: true });\n\n try {\n const segmentPaths: string[] = [];\n for (let i = 0; i < buffers.length; i++) {\n const segPath = join(workDir, `seg-${i}.mp3`);\n await writeFile(segPath, new Uint8Array(buffers[i]));\n segmentPaths.push(segPath);\n }\n\n const listPath = join(workDir, 'concat-list.txt');\n const listContent = segmentPaths.map((p) => `file '${p.replace(/\\\\/g, '/')}'`).join('\\n');\n await writeFile(listPath, listContent);\n\n const outputPath = join(workDir, 'combined.mp3');\n await new Promise<void>((resolve, reject) => {\n execFile(\n ffmpegPath,\n ['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', outputPath],\n { timeout: 120_000 },\n (err) => {\n if (err) reject(new Error(`ffmpeg audio concat failed: ${err.message}`));\n else resolve();\n },\n );\n });\n\n const data = await readFile(outputPath);\n return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n } finally {\n await rm(workDir, { recursive: true, force: true });\n }\n}\n\n/**\n * Add silence at the start of an audio track by re-encoding with adelay filter.\n */\nasync function addAudioDelay(\n ffmpegPath: string,\n audioData: Uint8Array,\n delaySecs: number,\n): Promise<Uint8Array> {\n const { writeFile, readFile, rm } = await import('node:fs/promises');\n const { join } = await import('node:path');\n const { tmpdir } = await import('node:os');\n const { randomBytes } = await import('node:crypto');\n const { execFile } = await import('node:child_process');\n\n const tmpId = randomBytes(8).toString('hex');\n const inputPath = join(tmpdir(), `squisq-audio-delay-in-${tmpId}.mp3`);\n const outputPath = join(tmpdir(), `squisq-audio-delay-out-${tmpId}.mp3`);\n\n try {\n await writeFile(inputPath, audioData);\n const delayMs = Math.round(delaySecs * 1000);\n\n await new Promise<void>((resolve, reject) => {\n execFile(\n ffmpegPath,\n [\n '-y',\n '-i',\n inputPath,\n '-af',\n `adelay=${delayMs}|${delayMs}`,\n '-c:a',\n 'libmp3lame',\n '-b:a',\n '128k',\n outputPath,\n ],\n { timeout: 60_000 },\n (err) => {\n if (err) reject(new Error(`ffmpeg audio delay failed: ${err.message}`));\n else resolve();\n },\n );\n });\n\n const data = await readFile(outputPath);\n return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n } finally {\n await rm(inputPath, { force: true });\n await rm(outputPath, { force: true });\n }\n}\n\n/** One scheduled audio clip to fold into the mix. */\ninterface AudioMixClip {\n buffer: ArrayBuffer;\n /** Seconds to delay the clip on the final timeline. */\n delaySec: number;\n /** Source in-point to trim from (seconds). */\n trimStart: number;\n /** Played length (seconds). */\n trimLen: number;\n}\n\n/**\n * Mix the sequential narration track (delayed by the cover pre-roll) with any\n * number of scheduled audio clips, each trimmed to its source window and\n * delayed to its absolute position. Returns a single MP3.\n *\n * Each input is `adelay`'d (per-channel) and `atrim`'d; `amix` sums them with\n * `normalize=0` so individual levels are preserved. Frame capture stays silent\n * — this reconstructs the audio offline from the same schedule the renderer\n * uses, keeping audio and video aligned.\n */\nasync function mixScheduledAudio(\n ffmpegPath: string,\n narration: Uint8Array | null,\n clips: AudioMixClip[],\n coverPreRoll: number,\n): Promise<Uint8Array | null> {\n if (!narration && clips.length === 0) return null;\n\n const { writeFile, readFile, mkdir, rm } = await import('node:fs/promises');\n const { join } = await import('node:path');\n const { tmpdir } = await import('node:os');\n const { randomBytes } = await import('node:crypto');\n const { execFile } = await import('node:child_process');\n\n const workDir = join(tmpdir(), `squisq-audio-mix-${randomBytes(8).toString('hex')}`);\n await mkdir(workDir, { recursive: true });\n\n try {\n const inputs: string[] = [];\n const filters: string[] = [];\n const labels: string[] = [];\n const ms = (s: number) => Math.max(0, Math.round(s * 1000));\n\n if (narration) {\n const p = join(workDir, 'narration.mp3');\n await writeFile(p, narration);\n const i = inputs.push(p) - 1;\n const d = ms(coverPreRoll);\n filters.push(`[${i}:a]adelay=${d}|${d}[a${i}]`);\n labels.push(`[a${i}]`);\n }\n\n for (const clip of clips) {\n const p = join(workDir, `clip-${inputs.length}.mp3`);\n await writeFile(p, new Uint8Array(clip.buffer));\n const i = inputs.push(p) - 1;\n const d = ms(clip.delaySec);\n const end = clip.trimStart + clip.trimLen;\n filters.push(\n `[${i}:a]atrim=start=${clip.trimStart}:end=${end},asetpts=PTS-STARTPTS,adelay=${d}|${d}[a${i}]`,\n );\n labels.push(`[a${i}]`);\n }\n\n const graph = `${filters.join(';')};${labels.join('')}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[aout]`;\n const args = ['-y'];\n for (const p of inputs) args.push('-i', p);\n args.push(\n '-filter_complex',\n graph,\n '-map',\n '[aout]',\n '-c:a',\n 'libmp3lame',\n '-b:a',\n '192k',\n join(workDir, 'mixed.mp3'),\n );\n\n await new Promise<void>((resolve, reject) => {\n execFile(ffmpegPath, args, { timeout: 180_000 }, (err) => {\n if (err) reject(new Error(`ffmpeg audio mix failed: ${err.message}`));\n else resolve();\n });\n });\n\n const data = await readFile(join(workDir, 'mixed.mp3'));\n return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n } finally {\n await rm(workDir, { recursive: true, force: true });\n }\n}\n\n// ── Thumbnail extraction ──────────────────────────────────────────\n\n/** A thumbnail size specification. */\nexport interface ThumbnailSpec {\n /** Label for the thumbnail (used in filename: `{slug}-{width}x{height}.jpg`). */\n name: string;\n /** Output width in pixels. */\n width: number;\n /** Output height in pixels. */\n height: number;\n /** FFmpeg video filter string (e.g., 'scale=1280:720'). */\n filter: string;\n}\n\n/** Options for extractThumbnails. */\nexport interface ExtractThumbnailsOptions {\n /** Path to the source MP4 video. */\n videoPath: string;\n /** Directory to write thumbnails into. */\n outputDir: string;\n /** Base slug for filenames (produces `{slug}-{width}x{height}.jpg`). */\n slug: string;\n /** Thumbnail sizes to generate. */\n sizes: ThumbnailSpec[];\n /** Overwrite existing thumbnails (default: false). */\n force?: boolean;\n}\n\n/**\n * Extract thumbnail images from the first frame of an MP4 video.\n * Produces JPEG files at each specified size using FFmpeg video filters.\n */\nexport async function extractThumbnails(options: ExtractThumbnailsOptions): Promise<void> {\n const { videoPath, outputDir, slug, sizes, force } = options;\n const { existsSync } = await import('node:fs');\n const { join } = await import('node:path');\n const { execFile } = await import('node:child_process');\n\n const ffmpegPath = await detectFfmpeg();\n if (!ffmpegPath) {\n throw new Error(\n 'ffmpeg is required for thumbnail extraction but not found in PATH.\\n' +\n 'Install it with:\\n' +\n ' macOS: brew install ffmpeg\\n' +\n ' Ubuntu: sudo apt install ffmpeg\\n' +\n ' Windows: winget install ffmpeg',\n );\n }\n\n for (const thumb of sizes) {\n const outputPath = join(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);\n if (!force && existsSync(outputPath)) continue;\n\n await new Promise<void>((resolve, reject) => {\n execFile(\n ffmpegPath,\n ['-y', '-i', videoPath, '-vf', thumb.filter, '-frames:v', '1', '-q:v', '2', outputPath],\n { timeout: 30_000 },\n (err) => {\n if (err) reject(new Error(`Thumbnail extraction failed (${thumb.name}): ${err.message}`));\n else resolve();\n },\n );\n });\n }\n}\n","/**\n * detectFfmpeg\n *\n * Checks whether a native ffmpeg binary is available on the system PATH.\n * Returns the absolute path to the binary if found, or null if not.\n *\n * Uses `which` on macOS/Linux and `where` on Windows.\n */\n\nimport { execFile } from 'node:child_process';\n\n/**\n * Detect whether native ffmpeg is installed and available on PATH.\n *\n * @returns Absolute path to ffmpeg binary, or null if not found\n */\nexport async function detectFfmpeg(): Promise<string | null> {\n const command = process.platform === 'win32' ? 'where' : 'which';\n\n return new Promise((resolve) => {\n execFile(command, ['ffmpeg'], { timeout: 5000 }, (err, stdout) => {\n if (err || !stdout.trim()) {\n resolve(null);\n return;\n }\n // `which` and `where` can return multiple lines; take the first\n const path = stdout.trim().split('\\n')[0].trim();\n resolve(path);\n });\n });\n}\n","/**\n * readInput\n *\n * Unified input reader for the CLI. Accepts a path to a .md file, .json file\n * (Doc schema), .zip/.dbk container, or a folder and returns a MemoryContentContainer\n * (virtual file system) plus either the parsed MarkdownDocument or a pre-built Doc.\n *\n * When a .json file or a container/folder containing doc.json is provided, the\n * Doc is returned directly and markdownDoc will be null. Callers should check\n * result.doc first; when present, skip markdownToDoc() and use it directly.\n */\n\nimport { readFile, readdir, stat } from 'node:fs/promises';\nimport { join, extname } from 'node:path';\nimport { parseMarkdown } from '@bendyline/squisq/markdown';\nimport type { MarkdownDocument } from '@bendyline/squisq/markdown';\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport { MemoryContentContainer } from '@bendyline/squisq/storage';\nimport { zipToContainer } from '@bendyline/squisq-formats/container';\n\nexport interface ReadInputResult {\n container: MemoryContentContainer;\n /** Parsed markdown document. Null when input is a Doc JSON file. */\n markdownDoc: MarkdownDocument | null;\n /** Pre-built Doc from JSON input. Present when input is .json or contains doc.json. */\n doc?: Doc;\n}\n\n/** MIME type lookup by extension (common content types) */\nconst MIME_TYPES: Record<string, string> = {\n '.md': 'text/markdown',\n '.txt': 'text/plain',\n '.json': 'application/json',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.png': 'image/png',\n '.gif': 'image/gif',\n '.webp': 'image/webp',\n '.svg': 'image/svg+xml',\n '.mp3': 'audio/mpeg',\n '.wav': 'audio/wav',\n '.ogg': 'audio/ogg',\n '.mp4': 'video/mp4',\n '.webm': 'video/webm',\n};\n\nfunction mimeFromExt(filePath: string): string {\n return MIME_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';\n}\n\n/**\n * Recursively walk a directory and return all file paths (relative to root).\n */\nasync function walkDir(root: string, prefix = ''): Promise<string[]> {\n const entries = await readdir(root, { withFileTypes: true });\n const paths: string[] = [];\n\n for (const entry of entries) {\n const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;\n if (entry.isDirectory()) {\n paths.push(...(await walkDir(join(root, entry.name), relPath)));\n } else if (entry.isFile()) {\n paths.push(relPath);\n }\n }\n\n return paths;\n}\n\n/**\n * Read input from a file path (markdown, JSON Doc, ZIP/DBK container, or folder)\n * and return a populated ContentContainer + parsed MarkdownDocument or Doc.\n *\n * When the input is a .json file (or a container/folder containing doc.json),\n * the result will have `doc` populated and `markdownDoc` set to null.\n * Otherwise, `markdownDoc` is populated and `doc` is undefined.\n */\nexport async function readInput(inputPath: string): Promise<ReadInputResult> {\n const info = await stat(inputPath);\n\n if (info.isDirectory()) {\n return readFolder(inputPath);\n }\n\n const ext = extname(inputPath).toLowerCase();\n if (ext === '.zip' || ext === '.dbk') {\n return readContainer(inputPath);\n }\n\n if (ext === '.json') {\n return readDocJsonFile(inputPath);\n }\n\n // Default: treat as a markdown file\n return readMarkdownFile(inputPath);\n}\n\nasync function readMarkdownFile(filePath: string): Promise<ReadInputResult> {\n const content = await readFile(filePath, 'utf-8');\n const container = new MemoryContentContainer();\n await container.writeDocument(content);\n const markdownDoc = parseMarkdown(content);\n return { container, markdownDoc };\n}\n\n/**\n * Read a standalone Doc JSON file. The container is empty (no media bundled);\n * callers should populate it or set basePath for media resolution.\n */\nasync function readDocJsonFile(filePath: string): Promise<ReadInputResult> {\n const content = await readFile(filePath, 'utf-8');\n const doc = JSON.parse(content) as Doc;\n const container = new MemoryContentContainer();\n return { container, markdownDoc: null, doc };\n}\n\n/**\n * Known filenames for Doc JSON inside containers and folders.\n * Checked in priority order before falling back to markdown discovery.\n */\nconst DOC_JSON_NAMES = ['doc.json', 'story.json'];\n\nasync function readContainer(filePath: string): Promise<ReadInputResult> {\n const data = await readFile(filePath);\n const container = await zipToContainer(\n data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer,\n );\n\n // Check for Doc JSON inside the container first\n for (const name of DOC_JSON_NAMES) {\n const jsonData = await container.readFile(name);\n if (jsonData) {\n const decoder = new TextDecoder();\n const doc = JSON.parse(decoder.decode(jsonData)) as Doc;\n return { container, markdownDoc: null, doc };\n }\n }\n\n const markdown = await container.readDocument();\n if (!markdown) {\n throw new Error(`No markdown document or doc.json found in container: ${filePath}`);\n }\n\n const markdownDoc = parseMarkdown(markdown);\n return { container, markdownDoc };\n}\n\nasync function readFolder(dirPath: string): Promise<ReadInputResult> {\n const container = new MemoryContentContainer();\n const files = await walkDir(dirPath);\n\n for (const relPath of files) {\n const absPath = join(dirPath, relPath);\n const data = await readFile(absPath);\n await container.writeFile(\n relPath,\n new Uint8Array(data.buffer, data.byteOffset, data.byteLength),\n mimeFromExt(relPath),\n );\n }\n\n // Check for Doc JSON inside the folder first\n for (const name of DOC_JSON_NAMES) {\n const jsonData = await container.readFile(name);\n if (jsonData) {\n const decoder = new TextDecoder();\n const doc = JSON.parse(decoder.decode(jsonData)) as Doc;\n return { container, markdownDoc: null, doc };\n }\n }\n\n const markdown = await container.readDocument();\n if (!markdown) {\n throw new Error(`No markdown document or doc.json found in folder: ${dirPath}`);\n }\n\n const markdownDoc = parseMarkdown(markdown);\n return { container, markdownDoc };\n}\n"],"mappings":";AAsBA,SAAS,4BAA4B;AACrC,SAAS,qBAAqB;AAG9B,SAAS,0BAA0B;AACnC,SAAS,yBAAyB;;;AClBlC,SAAS,gBAAgB;AAOzB,eAAsB,eAAuC;AAC3D,QAAM,UAAU,QAAQ,aAAa,UAAU,UAAU;AAEzD,SAAO,IAAI,QAAQ,CAAC,YAAY;AAC9B,aAAS,SAAS,CAAC,QAAQ,GAAG,EAAE,SAAS,IAAK,GAAG,CAAC,KAAK,WAAW;AAChE,UAAI,OAAO,CAAC,OAAO,KAAK,GAAG;AACzB,gBAAQ,IAAI;AACZ;AAAA,MACF;AAEA,YAAM,OAAO,OAAO,KAAK,EAAE,MAAM,IAAI,EAAE,CAAC,EAAE,KAAK;AAC/C,cAAQ,IAAI;AAAA,IACd,CAAC;AAAA,EACH,CAAC;AACH;;;ADEA,SAAS,0BAAAA,+BAA8B;;;AEpBvC,SAAS,UAAU,SAAS,YAAY;AACxC,SAAS,MAAM,eAAe;AAC9B,SAAS,qBAAqB;AAG9B,SAAS,8BAA8B;AACvC,SAAS,sBAAsB;AAW/B,IAAM,aAAqC;AAAA,EACzC,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,SAAS;AACX;AAEA,SAAS,YAAY,UAA0B;AAC7C,SAAO,WAAW,QAAQ,QAAQ,EAAE,YAAY,CAAC,KAAK;AACxD;AAKA,eAAe,QAAQ,MAAc,SAAS,IAAuB;AACnE,QAAM,UAAU,MAAM,QAAQ,MAAM,EAAE,eAAe,KAAK,CAAC;AAC3D,QAAM,QAAkB,CAAC;AAEzB,aAAW,SAAS,SAAS;AAC3B,UAAM,UAAU,SAAS,GAAG,MAAM,IAAI,MAAM,IAAI,KAAK,MAAM;AAC3D,QAAI,MAAM,YAAY,GAAG;AACvB,YAAM,KAAK,GAAI,MAAM,QAAQ,KAAK,MAAM,MAAM,IAAI,GAAG,OAAO,CAAE;AAAA,IAChE,WAAW,MAAM,OAAO,GAAG;AACzB,YAAM,KAAK,OAAO;AAAA,IACpB;AAAA,EACF;AAEA,SAAO;AACT;AAUA,eAAsB,UAAU,WAA6C;AAC3E,QAAM,OAAO,MAAM,KAAK,SAAS;AAEjC,MAAI,KAAK,YAAY,GAAG;AACtB,WAAO,WAAW,SAAS;AAAA,EAC7B;AAEA,QAAM,MAAM,QAAQ,SAAS,EAAE,YAAY;AAC3C,MAAI,QAAQ,UAAU,QAAQ,QAAQ;AACpC,WAAO,cAAc,SAAS;AAAA,EAChC;AAEA,MAAI,QAAQ,SAAS;AACnB,WAAO,gBAAgB,SAAS;AAAA,EAClC;AAGA,SAAO,iBAAiB,SAAS;AACnC;AAEA,eAAe,iBAAiB,UAA4C;AAC1E,QAAM,UAAU,MAAM,SAAS,UAAU,OAAO;AAChD,QAAM,YAAY,IAAI,uBAAuB;AAC7C,QAAM,UAAU,cAAc,OAAO;AACrC,QAAM,cAAc,cAAc,OAAO;AACzC,SAAO,EAAE,WAAW,YAAY;AAClC;AAMA,eAAe,gBAAgB,UAA4C;AACzE,QAAM,UAAU,MAAM,SAAS,UAAU,OAAO;AAChD,QAAM,MAAM,KAAK,MAAM,OAAO;AAC9B,QAAM,YAAY,IAAI,uBAAuB;AAC7C,SAAO,EAAE,WAAW,aAAa,MAAM,IAAI;AAC7C;AAMA,IAAM,iBAAiB,CAAC,YAAY,YAAY;AAEhD,eAAe,cAAc,UAA4C;AACvE,QAAM,OAAO,MAAM,SAAS,QAAQ;AACpC,QAAM,YAAY,MAAM;AAAA,IACtB,KAAK,OAAO,MAAM,KAAK,YAAY,KAAK,aAAa,KAAK,UAAU;AAAA,EACtE;AAGA,aAAW,QAAQ,gBAAgB;AACjC,UAAM,WAAW,MAAM,UAAU,SAAS,IAAI;AAC9C,QAAI,UAAU;AACZ,YAAM,UAAU,IAAI,YAAY;AAChC,YAAM,MAAM,KAAK,MAAM,QAAQ,OAAO,QAAQ,CAAC;AAC/C,aAAO,EAAE,WAAW,aAAa,MAAM,IAAI;AAAA,IAC7C;AAAA,EACF;AAEA,QAAM,WAAW,MAAM,UAAU,aAAa;AAC9C,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,MAAM,wDAAwD,QAAQ,EAAE;AAAA,EACpF;AAEA,QAAM,cAAc,cAAc,QAAQ;AAC1C,SAAO,EAAE,WAAW,YAAY;AAClC;AAEA,eAAe,WAAW,SAA2C;AACnE,QAAM,YAAY,IAAI,uBAAuB;AAC7C,QAAM,QAAQ,MAAM,QAAQ,OAAO;AAEnC,aAAW,WAAW,OAAO;AAC3B,UAAM,UAAU,KAAK,SAAS,OAAO;AACrC,UAAM,OAAO,MAAM,SAAS,OAAO;AACnC,UAAM,UAAU;AAAA,MACd;AAAA,MACA,IAAI,WAAW,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU;AAAA,MAC5D,YAAY,OAAO;AAAA,IACrB;AAAA,EACF;AAGA,aAAW,QAAQ,gBAAgB;AACjC,UAAM,WAAW,MAAM,UAAU,SAAS,IAAI;AAC9C,QAAI,UAAU;AACZ,YAAM,UAAU,IAAI,YAAY;AAChC,YAAM,MAAM,KAAK,MAAM,QAAQ,OAAO,QAAQ,CAAC;AAC/C,aAAO,EAAE,WAAW,aAAa,MAAM,IAAI;AAAA,IAC7C;AAAA,EACF;AAEA,QAAM,WAAW,MAAM,UAAU,aAAa;AAC9C,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,MAAM,qDAAqD,OAAO,EAAE;AAAA,EAChF;AAEA,QAAM,cAAc,cAAc,QAAQ;AAC1C,SAAO,EAAE,WAAW,YAAY;AAClC;;;AFlFA,eAAsB,eACpB,KACA,WACA,SAC+B;AAC/B,QAAM;AAAA,IACJ;AAAA,IACA,MAAM;AAAA,IACN,UAAU;AAAA,IACV,cAAc;AAAA,IACd;AAAA,IACA,eAAe;AAAA,IACf;AAAA,EACF,IAAI;AAEJ,QAAM,aAAa,kBAAkB;AAAA,IACnC;AAAA,IACA,OAAO,QAAQ;AAAA,IACf,QAAQ,QAAQ;AAAA,EAClB,CAAC;AAGD,QAAM,aAAa,MAAM,aAAa;AACtC,MAAI,CAAC,YAAY;AACf,UAAM,IAAI;AAAA,MACR;AAAA,IAKF;AAAA,EACF;AAEA,eAAa,oBAAoB,CAAC;AAGlC,QAAM,EAAE,kBAAkB,IAAI,MAAM,OAAO,gCAAgC;AAC3E,QAAM,aAAa,kBAAkB,GAAG;AACxC,QAAM,SAAS,oBAAI,IAAyB;AAC5C,aAAW,WAAW,YAAY;AAChC,UAAM,OAAO,MAAM,UAAU,SAAS,OAAO;AAC7C,QAAI,MAAM;AACR,aAAO,IAAI,SAAS,IAAI;AAAA,IAC1B;AAAA,EACF;AAGA,QAAM,QAAQ,oBAAI,IAAyB;AAC3C,QAAM,eAA8B,CAAC;AACrC,MAAI,IAAI,OAAO,UAAU,QAAQ;AAC/B,eAAW,OAAO,IAAI,MAAM,UAAU;AACpC,YAAM,OAAO,MAAM,UAAU,SAAS,IAAI,GAAG;AAC7C,UAAI,MAAM;AACR,cAAM,IAAI,IAAI,KAAK,IAAI;AACvB,cAAM,IAAI,IAAI,MAAM,IAAI;AACxB,qBAAa,KAAK,IAAI;AAAA,MACxB;AAAA,IACF;AAAA,EACF;AAGA,MAAI,oBAAuC;AAC3C,MAAI,aAAa,SAAS,GAAG;AAC3B,wBAAoB,MAAM,wBAAwB,cAAc,UAAU;AAAA,EAC5E;AAOA,QAAM,WAAW,qBAAqB,GAAG;AACzC,QAAM,YAAY,IAAI,IAAY,SAAS,IAAI,CAAC,MAAM,EAAE,GAAG,CAAC;AAC5D,aAAW,SAAS,cAAc,IAAI,MAAM,GAAG;AAC7C,eAAW,SAAS,MAAM,UAAU,CAAC,GAAG;AACtC,UAAI,MAAM,SAAS,QAAS,WAAU,IAAI,MAAM,QAAQ,GAAG;AAAA,IAC7D;AAAA,EACF;AAEA,QAAM,cAAc,oBAAI,IAAyB;AACjD,aAAW,OAAO,WAAW;AAC3B,QAAI,OAAO,IAAI,GAAG,EAAG;AACrB,UAAM,OAAO,MAAM,UAAU,SAAS,GAAG;AACzC,QAAI,KAAM,QAAO,IAAI,KAAK,IAAI;AAAA,EAChC;AACA,aAAW,QAAQ,UAAU;AAC3B,QAAI,KAAK,SAAS,QAAS;AAC3B,UAAM,OAAO,OAAO,IAAI,KAAK,GAAG,KAAM,MAAM,UAAU,SAAS,KAAK,GAAG;AACvE,QAAI,KAAM,aAAY,IAAI,KAAK,IAAI,IAAI;AAAA,EACzC;AAEA,eAAa,0BAA0B,EAAE;AAGzC,QAAM,EAAE,cAAc,IAAI,MAAM,OAAO,2CAA2C;AAClF,QAAM,aAAa,mBAAmB,KAAK;AAAA,IACzC,cAAc;AAAA,IACd;AAAA,IACA,OAAO,MAAM,OAAO,IAAI,QAAQ;AAAA,IAChC,OAAO,WAAW;AAAA,IAClB,QAAQ,WAAW;AAAA,IACnB;AAAA,EACF,CAAC;AAED,eAAa,qBAAqB,EAAE;AAGpC,QAAM,EAAE,SAAS,IAAI,MAAM,OAAO,iBAAiB;AACnD,QAAM,UAAU,MAAM,SAAS,OAAO,EAAE,UAAU,KAAK,CAAC;AACxD,QAAM,OAAO,MAAM,QAAQ,QAAQ;AAAA,IACjC,UAAU,EAAE,OAAO,WAAW,OAAO,QAAQ,WAAW,OAAO;AAAA,EACjE,CAAC;AAED,QAAM,aAAuB,CAAC;AAC9B,OAAK,GAAG,aAAa,CAAC,QAAQ,WAAW,KAAK,IAAI,OAAO,CAAC;AAE1D,QAAM,KAAK,WAAW,YAAY,EAAE,WAAW,OAAO,CAAC;AACvD,QAAM,KAAK,eAAe,GAAG;AAE7B,MAAI;AACF,UAAM,KAAK;AAAA,MACT,MAAM,OAAQ,OAA8C,gBAAgB;AAAA,MAC5E,EAAE,SAAS,KAAM;AAAA,IACnB;AAAA,EACF,QAAQ;AACN,UAAM,QAAQ,MAAM;AACpB,UAAM,cAAc,WAAW,SAC3B;AAAA;AAAA,IAAqB,WAAW,KAAK,MAAM,CAAC,KAC5C;AACJ,UAAM,IAAI,MAAM,mDAAmD,WAAW,EAAE;AAAA,EAClF;AAEA,QAAM,cAAsB,MAAM,KAAK,SAAS,MAAM;AACpD,WAAQ,OAAoD,YAAY;AAAA,EAC1E,CAAC;AAED,MAAI,eAAe,GAAG;AACpB,UAAM,QAAQ,MAAM;AACpB,UAAM,IAAI,MAAM,qDAAgD;AAAA,EAClE;AAEA,QAAM,kBAAkB,KAAK,KAAK,cAAc,GAAG;AACnD,QAAM,oBAAoB,KAAK,KAAK,eAAe,GAAG;AACtD,QAAM,cAAc,oBAAoB;AACxC,QAAM,SAAuB,CAAC;AAE9B,eAAa,oBAAoB,EAAE;AAGnC,MAAI,oBAAoB,GAAG;AACzB,UAAM,WAAoB,MAAM,KAAK,SAAS,MAAM;AAClD,YAAM,IAAI;AACV,aAAO,OAAO,EAAE,kBAAkB,aAAa,EAAE,cAAc,IAAI;AAAA,IACrE,CAAC;AAED,QAAI,UAAU;AACZ,YAAM,KAAK,SAAS,MAAM;AACxB,QAAC,OAAgD,UAAU;AAAA,MAC7D,CAAC;AACD,YAAM,KAAK,eAAe,GAAG;AAC7B,YAAM,aAAa,IAAI,WAAW,MAAM,KAAK,WAAW,EAAE,MAAM,MAAM,CAAC,CAAC;AACxE,eAAS,IAAI,GAAG,IAAI,mBAAmB,KAAK;AAC1C,eAAO,KAAK,UAAU;AAAA,MACxB;AACA,YAAM,KAAK,SAAS,MAAM;AACxB,QAAC,OAAgD,UAAU;AAAA,MAC7D,CAAC;AAAA,IACH;AAAA,EACF;AAGA,QAAM,gBAAgB,IAAI;AAC1B,WAAS,IAAI,GAAG,IAAI,iBAAiB,KAAK;AACxC,UAAM,OAAO,IAAI;AACjB,UAAM,KAAK,SAAS,CAAC,MAAc;AACjC,aAAQ,OAA+D,OAAO,CAAC;AAAA,IACjF,GAAG,IAAI;AAEP,UAAM,aAAa,MAAM,KAAK,WAAW,EAAE,MAAM,MAAM,CAAC;AACxD,WAAO,KAAK,IAAI,WAAW,UAAU,CAAC;AAGtC,QAAI,IAAI,KAAK,IAAI,GAAG,KAAK,MAAM,MAAM,CAAC,CAAC,MAAM,KAAK,MAAM,kBAAkB,GAAG;AAC3E,YAAM,MAAM,KAAK,KAAK,MAAO,OAAO,SAAS,cAAe,EAAE;AAC9D,mBAAa,oBAAoB,GAAG;AAAA,IACtC;AAAA,EACF;AAEA,QAAM,QAAQ,MAAM;AAEpB,eAAa,kBAAkB,EAAE;AAMjC,QAAM,sBAAsB,SAAS,OAAO,CAAC,MAAM,EAAE,SAAS,WAAW,YAAY,IAAI,EAAE,EAAE,CAAC;AAC9F,MAAI,gBAAgB;AACpB,MAAI,oBAAoB,SAAS,KAAK,YAAY;AAChD,oBAAgB,MAAM;AAAA,MACpB;AAAA,MACA;AAAA,MACA,oBAAoB,IAAI,CAAC,OAAO;AAAA,QAC9B,QAAQ,YAAY,IAAI,EAAE,EAAE;AAAA,QAC5B,UAAU,EAAE,gBAAgB;AAAA,QAC5B,WAAW,EAAE;AAAA,QACb,SAAS,KAAK,IAAI,GAAG,EAAE,cAAc,EAAE,aAAa;AAAA,MACtD,EAAE;AAAA,MACF;AAAA,IACF;AAAA,EACF,WAAW,eAAe,KAAK,mBAAmB;AAEhD,oBAAgB,MAAM,cAAc,YAAY,mBAAmB,YAAY;AAAA,EACjF;AAEA,QAAM,EAAE,kBAAkB,IAAI,MAAM,OAAO,6BAAyB;AACpE,QAAM,kBAAkB,YAAY,QAAQ,eAAe,YAAY;AAAA,IACrE;AAAA,IACA;AAAA,IACA;AAAA,IACA,OAAO,WAAW;AAAA,IAClB,QAAQ,WAAW;AAAA,IACnB,YAAY,CAAC,SAAS,UAAU;AAC9B,mBAAa,aAAa,KAAK,IAAI,KAAK,KAAK,MAAM,UAAU,GAAG,CAAC;AAAA,IACnE;AAAA,EACF,CAAC;AAED,eAAa,QAAQ,GAAG;AAExB,QAAM,gBAAgB,cAAc;AACpC,SAAO;AAAA,IACL,UAAU;AAAA,IACV,YAAY,OAAO;AAAA,IACnB;AAAA,EACF;AACF;AAQA,eAAe,wBACb,SACA,YACqB;AACrB,MAAI,QAAQ,WAAW,EAAG,QAAO,IAAI,WAAW,CAAC;AACjD,MAAI,QAAQ,WAAW,EAAG,QAAO,IAAI,WAAW,QAAQ,CAAC,CAAC;AAE1D,MAAI,YAAY;AACd,WAAO,uBAAuB,YAAY,OAAO;AAAA,EACnD;AAGA,QAAM,cAAc,QAAQ,OAAO,CAAC,KAAK,MAAM,MAAM,EAAE,YAAY,CAAC;AACpE,QAAM,SAAS,IAAI,WAAW,WAAW;AACzC,MAAI,SAAS;AACb,aAAW,OAAO,SAAS;AACzB,WAAO,IAAI,IAAI,WAAW,GAAG,GAAG,MAAM;AACtC,cAAU,IAAI;AAAA,EAChB;AACA,SAAO;AACT;AAEA,eAAe,uBACb,YACA,SACqB;AACrB,QAAM,EAAE,WAAW,UAAAC,WAAU,OAAO,GAAG,IAAI,MAAM,OAAO,aAAkB;AAC1E,QAAM,EAAE,MAAAC,MAAK,IAAI,MAAM,OAAO,MAAW;AACzC,QAAM,EAAE,OAAO,IAAI,MAAM,OAAO,IAAS;AACzC,QAAM,EAAE,YAAY,IAAI,MAAM,OAAO,QAAa;AAClD,QAAM,EAAE,UAAAC,UAAS,IAAI,MAAM,OAAO,eAAoB;AAEtD,QAAM,QAAQ,YAAY,CAAC,EAAE,SAAS,KAAK;AAC3C,QAAM,UAAUD,MAAK,OAAO,GAAG,uBAAuB,KAAK,EAAE;AAC7D,QAAM,MAAM,SAAS,EAAE,WAAW,KAAK,CAAC;AAExC,MAAI;AACF,UAAM,eAAyB,CAAC;AAChC,aAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,YAAM,UAAUA,MAAK,SAAS,OAAO,CAAC,MAAM;AAC5C,YAAM,UAAU,SAAS,IAAI,WAAW,QAAQ,CAAC,CAAC,CAAC;AACnD,mBAAa,KAAK,OAAO;AAAA,IAC3B;AAEA,UAAM,WAAWA,MAAK,SAAS,iBAAiB;AAChD,UAAM,cAAc,aAAa,IAAI,CAAC,MAAM,SAAS,EAAE,QAAQ,OAAO,GAAG,CAAC,GAAG,EAAE,KAAK,IAAI;AACxF,UAAM,UAAU,UAAU,WAAW;AAErC,UAAM,aAAaA,MAAK,SAAS,cAAc;AAC/C,UAAM,IAAI,QAAc,CAAC,SAAS,WAAW;AAC3C,MAAAC;AAAA,QACE;AAAA,QACA,CAAC,MAAM,MAAM,UAAU,SAAS,KAAK,MAAM,UAAU,MAAM,QAAQ,UAAU;AAAA,QAC7E,EAAE,SAAS,KAAQ;AAAA,QACnB,CAAC,QAAQ;AACP,cAAI,IAAK,QAAO,IAAI,MAAM,+BAA+B,IAAI,OAAO,EAAE,CAAC;AAAA,cAClE,SAAQ;AAAA,QACf;AAAA,MACF;AAAA,IACF,CAAC;AAED,UAAM,OAAO,MAAMF,UAAS,UAAU;AACtC,WAAO,IAAI,WAAW,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU;AAAA,EACrE,UAAE;AACA,UAAM,GAAG,SAAS,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC;AAAA,EACpD;AACF;AAKA,eAAe,cACb,YACA,WACA,WACqB;AACrB,QAAM,EAAE,WAAW,UAAAA,WAAU,GAAG,IAAI,MAAM,OAAO,aAAkB;AACnE,QAAM,EAAE,MAAAC,MAAK,IAAI,MAAM,OAAO,MAAW;AACzC,QAAM,EAAE,OAAO,IAAI,MAAM,OAAO,IAAS;AACzC,QAAM,EAAE,YAAY,IAAI,MAAM,OAAO,QAAa;AAClD,QAAM,EAAE,UAAAC,UAAS,IAAI,MAAM,OAAO,eAAoB;AAEtD,QAAM,QAAQ,YAAY,CAAC,EAAE,SAAS,KAAK;AAC3C,QAAM,YAAYD,MAAK,OAAO,GAAG,yBAAyB,KAAK,MAAM;AACrE,QAAM,aAAaA,MAAK,OAAO,GAAG,0BAA0B,KAAK,MAAM;AAEvE,MAAI;AACF,UAAM,UAAU,WAAW,SAAS;AACpC,UAAM,UAAU,KAAK,MAAM,YAAY,GAAI;AAE3C,UAAM,IAAI,QAAc,CAAC,SAAS,WAAW;AAC3C,MAAAC;AAAA,QACE;AAAA,QACA;AAAA,UACE;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA,UAAU,OAAO,IAAI,OAAO;AAAA,UAC5B;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,QACF;AAAA,QACA,EAAE,SAAS,IAAO;AAAA,QAClB,CAAC,QAAQ;AACP,cAAI,IAAK,QAAO,IAAI,MAAM,8BAA8B,IAAI,OAAO,EAAE,CAAC;AAAA,cACjE,SAAQ;AAAA,QACf;AAAA,MACF;AAAA,IACF,CAAC;AAED,UAAM,OAAO,MAAMF,UAAS,UAAU;AACtC,WAAO,IAAI,WAAW,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU;AAAA,EACrE,UAAE;AACA,UAAM,GAAG,WAAW,EAAE,OAAO,KAAK,CAAC;AACnC,UAAM,GAAG,YAAY,EAAE,OAAO,KAAK,CAAC;AAAA,EACtC;AACF;AAuBA,eAAe,kBACb,YACA,WACA,OACA,cAC4B;AAC5B,MAAI,CAAC,aAAa,MAAM,WAAW,EAAG,QAAO;AAE7C,QAAM,EAAE,WAAW,UAAAA,WAAU,OAAO,GAAG,IAAI,MAAM,OAAO,aAAkB;AAC1E,QAAM,EAAE,MAAAC,MAAK,IAAI,MAAM,OAAO,MAAW;AACzC,QAAM,EAAE,OAAO,IAAI,MAAM,OAAO,IAAS;AACzC,QAAM,EAAE,YAAY,IAAI,MAAM,OAAO,QAAa;AAClD,QAAM,EAAE,UAAAC,UAAS,IAAI,MAAM,OAAO,eAAoB;AAEtD,QAAM,UAAUD,MAAK,OAAO,GAAG,oBAAoB,YAAY,CAAC,EAAE,SAAS,KAAK,CAAC,EAAE;AACnF,QAAM,MAAM,SAAS,EAAE,WAAW,KAAK,CAAC;AAExC,MAAI;AACF,UAAM,SAAmB,CAAC;AAC1B,UAAM,UAAoB,CAAC;AAC3B,UAAM,SAAmB,CAAC;AAC1B,UAAM,KAAK,CAAC,MAAc,KAAK,IAAI,GAAG,KAAK,MAAM,IAAI,GAAI,CAAC;AAE1D,QAAI,WAAW;AACb,YAAM,IAAIA,MAAK,SAAS,eAAe;AACvC,YAAM,UAAU,GAAG,SAAS;AAC5B,YAAM,IAAI,OAAO,KAAK,CAAC,IAAI;AAC3B,YAAM,IAAI,GAAG,YAAY;AACzB,cAAQ,KAAK,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG;AAC9C,aAAO,KAAK,KAAK,CAAC,GAAG;AAAA,IACvB;AAEA,eAAW,QAAQ,OAAO;AACxB,YAAM,IAAIA,MAAK,SAAS,QAAQ,OAAO,MAAM,MAAM;AACnD,YAAM,UAAU,GAAG,IAAI,WAAW,KAAK,MAAM,CAAC;AAC9C,YAAM,IAAI,OAAO,KAAK,CAAC,IAAI;AAC3B,YAAM,IAAI,GAAG,KAAK,QAAQ;AAC1B,YAAM,MAAM,KAAK,YAAY,KAAK;AAClC,cAAQ;AAAA,QACN,IAAI,CAAC,kBAAkB,KAAK,SAAS,QAAQ,GAAG,gCAAgC,CAAC,IAAI,CAAC,KAAK,CAAC;AAAA,MAC9F;AACA,aAAO,KAAK,KAAK,CAAC,GAAG;AAAA,IACvB;AAEA,UAAM,QAAQ,GAAG,QAAQ,KAAK,GAAG,CAAC,IAAI,OAAO,KAAK,EAAE,CAAC,eAAe,OAAO,MAAM;AACjF,UAAM,OAAO,CAAC,IAAI;AAClB,eAAW,KAAK,OAAQ,MAAK,KAAK,MAAM,CAAC;AACzC,SAAK;AAAA,MACH;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACAA,MAAK,SAAS,WAAW;AAAA,IAC3B;AAEA,UAAM,IAAI,QAAc,CAAC,SAAS,WAAW;AAC3C,MAAAC,UAAS,YAAY,MAAM,EAAE,SAAS,KAAQ,GAAG,CAAC,QAAQ;AACxD,YAAI,IAAK,QAAO,IAAI,MAAM,4BAA4B,IAAI,OAAO,EAAE,CAAC;AAAA,YAC/D,SAAQ;AAAA,MACf,CAAC;AAAA,IACH,CAAC;AAED,UAAM,OAAO,MAAMF,UAASC,MAAK,SAAS,WAAW,CAAC;AACtD,WAAO,IAAI,WAAW,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU;AAAA,EACrE,UAAE;AACA,UAAM,GAAG,SAAS,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC;AAAA,EACpD;AACF;AAkCA,eAAsB,kBAAkB,SAAkD;AACxF,QAAM,EAAE,WAAW,WAAW,MAAM,OAAO,MAAM,IAAI;AACrD,QAAM,EAAE,WAAW,IAAI,MAAM,OAAO,IAAS;AAC7C,QAAM,EAAE,MAAAA,MAAK,IAAI,MAAM,OAAO,MAAW;AACzC,QAAM,EAAE,UAAAC,UAAS,IAAI,MAAM,OAAO,eAAoB;AAEtD,QAAM,aAAa,MAAM,aAAa;AACtC,MAAI,CAAC,YAAY;AACf,UAAM,IAAI;AAAA,MACR;AAAA,IAKF;AAAA,EACF;AAEA,aAAW,SAAS,OAAO;AACzB,UAAM,aAAaD,MAAK,WAAW,GAAG,IAAI,IAAI,MAAM,KAAK,IAAI,MAAM,MAAM,MAAM;AAC/E,QAAI,CAAC,SAAS,WAAW,UAAU,EAAG;AAEtC,UAAM,IAAI,QAAc,CAAC,SAAS,WAAW;AAC3C,MAAAC;AAAA,QACE;AAAA,QACA,CAAC,MAAM,MAAM,WAAW,OAAO,MAAM,QAAQ,aAAa,KAAK,QAAQ,KAAK,UAAU;AAAA,QACtF,EAAE,SAAS,IAAO;AAAA,QAClB,CAAC,QAAQ;AACP,cAAI,IAAK,QAAO,IAAI,MAAM,gCAAgC,MAAM,IAAI,MAAM,IAAI,OAAO,EAAE,CAAC;AAAA,cACnF,SAAQ;AAAA,QACf;AAAA,MACF;AAAA,IACF,CAAC;AAAA,EACH;AACF;","names":["MemoryContentContainer","readFile","join","execFile"]}
|
package/dist/index.js
CHANGED
|
@@ -145,6 +145,9 @@ function registerConvertCommand(program2) {
|
|
|
145
145
|
).option("-t, --theme <id>", "Squisq theme ID to apply (e.g., documentary, cinematic, bold)").option(
|
|
146
146
|
"--transform <style>",
|
|
147
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"
|
|
148
151
|
).action(async (inputPath, opts) => {
|
|
149
152
|
try {
|
|
150
153
|
await runConvert(inputPath, opts);
|
|
@@ -163,13 +166,6 @@ async function runConvert(inputPath, opts) {
|
|
|
163
166
|
const inputExt = extname2(inputBasename);
|
|
164
167
|
const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
|
|
165
168
|
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
169
|
if (opts.transform) {
|
|
174
170
|
const { getTransformStyleIds } = await import("@bendyline/squisq/transform");
|
|
175
171
|
const styles = getTransformStyleIds();
|
|
@@ -187,13 +183,27 @@ async function runConvert(inputPath, opts) {
|
|
|
187
183
|
"Convert command requires a markdown document. JSON Doc input is not supported for convert \u2014 use the video command instead."
|
|
188
184
|
);
|
|
189
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
|
+
}
|
|
190
199
|
let exportMarkdownDoc = result.markdownDoc;
|
|
191
200
|
if (opts.transform) {
|
|
192
201
|
exportMarkdownDoc = await applyTransformToMarkdown(
|
|
193
202
|
result.markdownDoc,
|
|
194
203
|
container,
|
|
195
204
|
opts.transform,
|
|
196
|
-
opts.theme
|
|
205
|
+
opts.theme,
|
|
206
|
+
opts.autoTemplates
|
|
197
207
|
);
|
|
198
208
|
console.error(` Applied transform: ${opts.transform}`);
|
|
199
209
|
}
|
|
@@ -224,7 +234,7 @@ async function runConvert(inputPath, opts) {
|
|
|
224
234
|
const { markdownToDoc } = await import("@bendyline/squisq/doc");
|
|
225
235
|
const { docToHtml } = await import("@bendyline/squisq-formats/html");
|
|
226
236
|
const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
|
|
227
|
-
const doc = markdownToDoc(exportMarkdownDoc);
|
|
237
|
+
const doc = markdownToDoc(exportMarkdownDoc, { autoTemplates: opts.autoTemplates });
|
|
228
238
|
const images = await collectImagesForHtml(doc, container);
|
|
229
239
|
const html = docToHtml(doc, {
|
|
230
240
|
playerScript: PLAYER_BUNDLE,
|
|
@@ -240,7 +250,7 @@ async function runConvert(inputPath, opts) {
|
|
|
240
250
|
const { markdownToDoc } = await import("@bendyline/squisq/doc");
|
|
241
251
|
const { docToHtmlZip } = await import("@bendyline/squisq-formats/html");
|
|
242
252
|
const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
|
|
243
|
-
const doc = markdownToDoc(exportMarkdownDoc);
|
|
253
|
+
const doc = markdownToDoc(exportMarkdownDoc, { autoTemplates: opts.autoTemplates });
|
|
244
254
|
const images = await collectImagesForHtml(doc, container);
|
|
245
255
|
const blob = await docToHtmlZip(doc, {
|
|
246
256
|
playerScript: PLAYER_BUNDLE,
|
|
@@ -288,10 +298,10 @@ async function runConvert(inputPath, opts) {
|
|
|
288
298
|
}
|
|
289
299
|
console.error("Done.");
|
|
290
300
|
}
|
|
291
|
-
async function applyTransformToMarkdown(markdownDoc, container, transformStyle, themeId) {
|
|
301
|
+
async function applyTransformToMarkdown(markdownDoc, container, transformStyle, themeId, autoTemplates) {
|
|
292
302
|
const { markdownToDoc, docToMarkdown } = await import("@bendyline/squisq/doc");
|
|
293
303
|
const { applyTransform, extractDocImages } = await import("@bendyline/squisq/transform");
|
|
294
|
-
const doc = markdownToDoc(markdownDoc);
|
|
304
|
+
const doc = markdownToDoc(markdownDoc, { autoTemplates });
|
|
295
305
|
const images = extractDocImages(doc.blocks);
|
|
296
306
|
const result = applyTransform(doc, transformStyle, {
|
|
297
307
|
themeId,
|
|
@@ -348,6 +358,8 @@ import { mkdir as mkdir2 } from "fs/promises";
|
|
|
348
358
|
import { dirname as dirname2, basename as basename2, extname as extname3, resolve as resolve2 } from "path";
|
|
349
359
|
|
|
350
360
|
// src/api.ts
|
|
361
|
+
import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
|
|
362
|
+
import { flattenBlocks } from "@bendyline/squisq/doc";
|
|
351
363
|
import { generateRenderHtml } from "@bendyline/squisq-video";
|
|
352
364
|
import { resolveDimensions } from "@bendyline/squisq-video";
|
|
353
365
|
|
|
@@ -355,14 +367,14 @@ import { resolveDimensions } from "@bendyline/squisq-video";
|
|
|
355
367
|
import { execFile } from "child_process";
|
|
356
368
|
async function detectFfmpeg() {
|
|
357
369
|
const command = process.platform === "win32" ? "where" : "which";
|
|
358
|
-
return new Promise((
|
|
370
|
+
return new Promise((resolve4) => {
|
|
359
371
|
execFile(command, ["ffmpeg"], { timeout: 5e3 }, (err, stdout) => {
|
|
360
372
|
if (err || !stdout.trim()) {
|
|
361
|
-
|
|
373
|
+
resolve4(null);
|
|
362
374
|
return;
|
|
363
375
|
}
|
|
364
376
|
const path = stdout.trim().split("\n")[0].trim();
|
|
365
|
-
|
|
377
|
+
resolve4(path);
|
|
366
378
|
});
|
|
367
379
|
});
|
|
368
380
|
}
|
|
@@ -416,6 +428,24 @@ async function renderDocToMp4(doc, container, options) {
|
|
|
416
428
|
if (audioBuffers.length > 0) {
|
|
417
429
|
concatenatedAudio = await concatenateAudioBuffers(audioBuffers, ffmpegPath);
|
|
418
430
|
}
|
|
431
|
+
const schedule = resolveMediaSchedule(doc);
|
|
432
|
+
const mediaSrcs = new Set(schedule.map((c) => c.src));
|
|
433
|
+
for (const block of flattenBlocks(doc.blocks)) {
|
|
434
|
+
for (const layer of block.layers ?? []) {
|
|
435
|
+
if (layer.type === "video") mediaSrcs.add(layer.content.src);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const clipBuffers = /* @__PURE__ */ new Map();
|
|
439
|
+
for (const src of mediaSrcs) {
|
|
440
|
+
if (images.has(src)) continue;
|
|
441
|
+
const data = await container.readFile(src);
|
|
442
|
+
if (data) images.set(src, data);
|
|
443
|
+
}
|
|
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
|
+
}
|
|
419
449
|
onProgress?.("generating render HTML", 10);
|
|
420
450
|
const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
|
|
421
451
|
const renderHtml = generateRenderHtml(doc, {
|
|
@@ -494,8 +524,21 @@ Page errors:
|
|
|
494
524
|
}
|
|
495
525
|
await browser.close();
|
|
496
526
|
onProgress?.("encoding video", 80);
|
|
527
|
+
const scheduledAudioClips = schedule.filter((c) => c.kind === "audio" && clipBuffers.has(c.id));
|
|
497
528
|
let encodingAudio = concatenatedAudio;
|
|
498
|
-
if (
|
|
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) {
|
|
499
542
|
encodingAudio = await addAudioDelay(ffmpegPath, concatenatedAudio, coverPreRoll);
|
|
500
543
|
}
|
|
501
544
|
const { framesToMp4Native } = await import("./nativeEncoder-EXDP2O5B.js");
|
|
@@ -534,32 +577,32 @@ async function concatenateAudioBuffers(buffers, ffmpegPath) {
|
|
|
534
577
|
}
|
|
535
578
|
async function concatenateAudioNative(ffmpegPath, buffers) {
|
|
536
579
|
const { writeFile: writeFile2, readFile: readFile2, mkdir: mkdir3, rm } = await import("fs/promises");
|
|
537
|
-
const { join:
|
|
580
|
+
const { join: join4 } = await import("path");
|
|
538
581
|
const { tmpdir } = await import("os");
|
|
539
582
|
const { randomBytes } = await import("crypto");
|
|
540
583
|
const { execFile: execFile2 } = await import("child_process");
|
|
541
584
|
const tmpId = randomBytes(8).toString("hex");
|
|
542
|
-
const workDir =
|
|
585
|
+
const workDir = join4(tmpdir(), `squisq-audio-concat-${tmpId}`);
|
|
543
586
|
await mkdir3(workDir, { recursive: true });
|
|
544
587
|
try {
|
|
545
588
|
const segmentPaths = [];
|
|
546
589
|
for (let i = 0; i < buffers.length; i++) {
|
|
547
|
-
const segPath =
|
|
590
|
+
const segPath = join4(workDir, `seg-${i}.mp3`);
|
|
548
591
|
await writeFile2(segPath, new Uint8Array(buffers[i]));
|
|
549
592
|
segmentPaths.push(segPath);
|
|
550
593
|
}
|
|
551
|
-
const listPath =
|
|
594
|
+
const listPath = join4(workDir, "concat-list.txt");
|
|
552
595
|
const listContent = segmentPaths.map((p) => `file '${p.replace(/\\/g, "/")}'`).join("\n");
|
|
553
596
|
await writeFile2(listPath, listContent);
|
|
554
|
-
const outputPath =
|
|
555
|
-
await new Promise((
|
|
597
|
+
const outputPath = join4(workDir, "combined.mp3");
|
|
598
|
+
await new Promise((resolve4, reject) => {
|
|
556
599
|
execFile2(
|
|
557
600
|
ffmpegPath,
|
|
558
601
|
["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", outputPath],
|
|
559
602
|
{ timeout: 12e4 },
|
|
560
603
|
(err) => {
|
|
561
604
|
if (err) reject(new Error(`ffmpeg audio concat failed: ${err.message}`));
|
|
562
|
-
else
|
|
605
|
+
else resolve4();
|
|
563
606
|
}
|
|
564
607
|
);
|
|
565
608
|
});
|
|
@@ -571,17 +614,17 @@ async function concatenateAudioNative(ffmpegPath, buffers) {
|
|
|
571
614
|
}
|
|
572
615
|
async function addAudioDelay(ffmpegPath, audioData, delaySecs) {
|
|
573
616
|
const { writeFile: writeFile2, readFile: readFile2, rm } = await import("fs/promises");
|
|
574
|
-
const { join:
|
|
617
|
+
const { join: join4 } = await import("path");
|
|
575
618
|
const { tmpdir } = await import("os");
|
|
576
619
|
const { randomBytes } = await import("crypto");
|
|
577
620
|
const { execFile: execFile2 } = await import("child_process");
|
|
578
621
|
const tmpId = randomBytes(8).toString("hex");
|
|
579
|
-
const inputPath =
|
|
580
|
-
const outputPath =
|
|
622
|
+
const inputPath = join4(tmpdir(), `squisq-audio-delay-in-${tmpId}.mp3`);
|
|
623
|
+
const outputPath = join4(tmpdir(), `squisq-audio-delay-out-${tmpId}.mp3`);
|
|
581
624
|
try {
|
|
582
625
|
await writeFile2(inputPath, audioData);
|
|
583
626
|
const delayMs = Math.round(delaySecs * 1e3);
|
|
584
|
-
await new Promise((
|
|
627
|
+
await new Promise((resolve4, reject) => {
|
|
585
628
|
execFile2(
|
|
586
629
|
ffmpegPath,
|
|
587
630
|
[
|
|
@@ -599,7 +642,7 @@ async function addAudioDelay(ffmpegPath, audioData, delaySecs) {
|
|
|
599
642
|
{ timeout: 6e4 },
|
|
600
643
|
(err) => {
|
|
601
644
|
if (err) reject(new Error(`ffmpeg audio delay failed: ${err.message}`));
|
|
602
|
-
else
|
|
645
|
+
else resolve4();
|
|
603
646
|
}
|
|
604
647
|
);
|
|
605
648
|
});
|
|
@@ -610,6 +653,65 @@ async function addAudioDelay(ffmpegPath, audioData, delaySecs) {
|
|
|
610
653
|
await rm(outputPath, { force: true });
|
|
611
654
|
}
|
|
612
655
|
}
|
|
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}]`);
|
|
677
|
+
}
|
|
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}]`);
|
|
688
|
+
}
|
|
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")
|
|
702
|
+
);
|
|
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
|
+
});
|
|
708
|
+
});
|
|
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 });
|
|
713
|
+
}
|
|
714
|
+
}
|
|
613
715
|
|
|
614
716
|
// src/commands/video.ts
|
|
615
717
|
var VALID_QUALITIES = ["draft", "normal", "high"];
|
|
@@ -628,6 +730,9 @@ function registerVideoCommand(program2) {
|
|
|
628
730
|
"--captions <style>",
|
|
629
731
|
`Caption style: ${VALID_CAPTIONS.join(", ")} (default: off)`,
|
|
630
732
|
"off"
|
|
733
|
+
).option(
|
|
734
|
+
"--no-auto-templates",
|
|
735
|
+
"Disable content-aware template auto-picking for unannotated headings"
|
|
631
736
|
).option("--width <pixels>", "Override video width").option("--height <pixels>", "Override video height").action(async (inputPath, outputArg, opts) => {
|
|
632
737
|
try {
|
|
633
738
|
if (outputArg && !opts.output) {
|
|
@@ -676,7 +781,7 @@ async function runVideo(inputPath, opts) {
|
|
|
676
781
|
doc = result.doc;
|
|
677
782
|
} else if (result.markdownDoc) {
|
|
678
783
|
const { markdownToDoc } = await import("@bendyline/squisq/doc");
|
|
679
|
-
doc = markdownToDoc(result.markdownDoc);
|
|
784
|
+
doc = markdownToDoc(result.markdownDoc, { autoTemplates: opts.autoTemplates });
|
|
680
785
|
} else {
|
|
681
786
|
throw new Error("No document found in input");
|
|
682
787
|
}
|
|
@@ -714,6 +819,67 @@ function clearProgress() {
|
|
|
714
819
|
process.stderr.write("\r" + " ".repeat(80) + "\r");
|
|
715
820
|
}
|
|
716
821
|
|
|
822
|
+
// src/commands/validate.ts
|
|
823
|
+
import { existsSync, statSync } from "fs";
|
|
824
|
+
import { dirname as dirname3, extname as extname4, join as join3, resolve as resolve3 } from "path";
|
|
825
|
+
import { validateMarkdownDoc } from "@bendyline/squisq/doc";
|
|
826
|
+
function registerValidateCommand(program2) {
|
|
827
|
+
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) => {
|
|
828
|
+
try {
|
|
829
|
+
const exitCode = await runValidate(inputPath, opts);
|
|
830
|
+
process.exitCode = exitCode;
|
|
831
|
+
} catch (err) {
|
|
832
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
833
|
+
console.error(`Error: ${message}`);
|
|
834
|
+
process.exitCode = 2;
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
async function runValidate(inputPath, opts) {
|
|
839
|
+
const resolvedInput = resolve3(inputPath);
|
|
840
|
+
const { container, markdownDoc, doc } = await readInput(resolvedInput);
|
|
841
|
+
if (!markdownDoc) {
|
|
842
|
+
const diagnostics = doc?.diagnostics ?? [];
|
|
843
|
+
return report(diagnostics, opts, "doc.json input \u2014 markdown-level checks skipped");
|
|
844
|
+
}
|
|
845
|
+
const entries = await container.listFiles();
|
|
846
|
+
const containerPaths = new Set(entries.map((e) => e.path));
|
|
847
|
+
const isBareMarkdown = statSync(resolvedInput).isFile() && ![".zip", ".dbk"].includes(extname4(resolvedInput).toLowerCase());
|
|
848
|
+
const baseDir = dirname3(resolvedInput);
|
|
849
|
+
const hasAsset = (path) => {
|
|
850
|
+
if (containerPaths.has(path) || containerPaths.has(path.replace(/^\.\//, ""))) return true;
|
|
851
|
+
if (isBareMarkdown) return existsSync(join3(baseDir, path));
|
|
852
|
+
return false;
|
|
853
|
+
};
|
|
854
|
+
const result = validateMarkdownDoc(markdownDoc, { assets: hasAsset });
|
|
855
|
+
return report(result.diagnostics, opts);
|
|
856
|
+
}
|
|
857
|
+
function report(diagnostics, opts, note) {
|
|
858
|
+
const errorCount = diagnostics.filter((d) => d.severity === "error").length;
|
|
859
|
+
const warningCount = diagnostics.length - errorCount;
|
|
860
|
+
if (opts.json) {
|
|
861
|
+
process.stdout.write(`${JSON.stringify({ errorCount, warningCount, diagnostics }, null, 2)}
|
|
862
|
+
`);
|
|
863
|
+
} else {
|
|
864
|
+
if (note) console.error(note);
|
|
865
|
+
for (const d of diagnostics) {
|
|
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
|
+
);
|
|
870
|
+
}
|
|
871
|
+
if (diagnostics.length === 0) {
|
|
872
|
+
console.error("\u2713 No problems found");
|
|
873
|
+
} else {
|
|
874
|
+
console.error(`
|
|
875
|
+
${errorCount} error(s), ${warningCount} warning(s)`);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (errorCount > 0) return 1;
|
|
879
|
+
if (opts.strict && warningCount > 0) return 1;
|
|
880
|
+
return 0;
|
|
881
|
+
}
|
|
882
|
+
|
|
717
883
|
// src/index.ts
|
|
718
884
|
console.error(
|
|
719
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"
|
|
@@ -722,5 +888,6 @@ var program = new Command();
|
|
|
722
888
|
program.name("squisq").description("Squisq CLI \u2014 convert and process markdown-based documents").version("1.0.0");
|
|
723
889
|
registerConvertCommand(program);
|
|
724
890
|
registerVideoCommand(program);
|
|
891
|
+
registerValidateCommand(program);
|
|
725
892
|
program.parse();
|
|
726
893
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/commands/convert.ts","../src/util/readInput.ts","../src/commands/video.ts","../src/api.ts","../src/util/detectFfmpeg.ts"],"sourcesContent":["/**\n * squisq CLI\n *\n * Command-line tool for converting and processing Squisq documents.\n * Designed for easy addition of future subcommands (e.g., import).\n *\n * Usage:\n * squisq convert <input> [options]\n * squisq --help\n */\n\nimport { Command } from 'commander';\nimport { registerConvertCommand } from './commands/convert.js';\nimport { registerVideoCommand } from './commands/video.js';\n\n// Colored banner: cyan brackets, bold white text, dim version\nconsole.error(\n '\\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',\n);\n\nconst program = new Command();\n\nprogram\n .name('squisq')\n .description('Squisq CLI — convert and process markdown-based documents')\n .version('1.0.0');\n\nregisterConvertCommand(program);\nregisterVideoCommand(program);\n\nprogram.parse();\n","/**\n * convert command\n *\n * Reads a markdown file, ZIP/DBK container, or folder and exports to\n * all supported formats: DOCX, PPTX, PDF, HTML, and container ZIP (.dbk).\n *\n * Supports optional --theme and --transform flags to apply a squisq theme\n * and/or transform style before exporting.\n *\n * Usage:\n * squisq convert <input> [--output-dir <dir>] [--formats <list>] [--theme <id>] [--transform <style>]\n */\n\nimport { writeFile, mkdir } from 'node:fs/promises';\nimport { dirname, basename, extname, join, resolve } from 'node:path';\nimport type { Command } from 'commander';\nimport type { MarkdownDocument } from '@bendyline/squisq/markdown';\nimport type { ContentContainer } from '@bendyline/squisq/storage';\nimport { readInput } from '../util/readInput.js';\n\nconst ALL_FORMATS = ['docx', 'pptx', 'pdf', 'html', 'htmlzip', 'epub', 'dbk'] as const;\ntype Format = (typeof ALL_FORMATS)[number];\n\nfunction parseFormats(value: string): Format[] {\n const requested = value.split(',').map((s) => s.trim().toLowerCase());\n const valid: Format[] = [];\n for (const r of requested) {\n if (ALL_FORMATS.includes(r as Format)) {\n valid.push(r as Format);\n } else {\n console.warn(`Unknown format \"${r}\" — skipping. Valid: ${ALL_FORMATS.join(', ')}`);\n }\n }\n if (valid.length === 0) {\n throw new Error(`No valid formats specified. Valid: ${ALL_FORMATS.join(', ')}`);\n }\n return valid;\n}\n\ninterface ConvertOpts {\n outputDir?: string;\n formats?: string;\n theme?: string;\n transform?: string;\n}\n\nexport function registerConvertCommand(program: Command): void {\n program\n .command('convert')\n .description('Convert a markdown document to DOCX, PPTX, PDF, HTML, and DBK container formats')\n .argument('<input>', 'Path to .md file, .zip/.dbk container, or folder')\n .option('-o, --output-dir <dir>', 'Output directory (default: same as input)')\n .option(\n '-f, --formats <list>',\n `Comma-separated formats to produce (default: all). Valid: ${ALL_FORMATS.join(', ')}`,\n )\n .option('-t, --theme <id>', 'Squisq theme ID to apply (e.g., documentary, cinematic, bold)')\n .option(\n '--transform <style>',\n 'Transform style to apply before export (e.g., documentary, magazine, minimal)',\n )\n .action(async (inputPath: string, opts: ConvertOpts) => {\n try {\n await runConvert(inputPath, opts);\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n console.error(`Error: ${message}`);\n process.exitCode = 1;\n }\n });\n}\n\nasync function runConvert(inputPath: string, opts: ConvertOpts): Promise<void> {\n const resolvedInput = resolve(inputPath);\n\n // Determine which formats to produce\n const formats: Format[] = opts.formats ? parseFormats(opts.formats) : [...ALL_FORMATS];\n\n // Determine output directory and base filename\n const outputDir = opts.outputDir ? resolve(opts.outputDir) : dirname(resolvedInput);\n const inputBasename = basename(resolvedInput);\n const inputExt = extname(inputBasename);\n const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;\n\n // Ensure output directory exists\n await mkdir(outputDir, { recursive: true });\n\n // Validate theme and transform IDs\n if (opts.theme) {\n const { getAvailableThemes } = await import('@bendyline/squisq/schemas');\n const themes = getAvailableThemes();\n if (!themes.includes(opts.theme)) {\n throw new Error(`Unknown theme \"${opts.theme}\". Available: ${themes.join(', ')}`);\n }\n }\n\n if (opts.transform) {\n const { getTransformStyleIds } = await import('@bendyline/squisq/transform');\n const styles = getTransformStyleIds();\n if (!styles.includes(opts.transform)) {\n throw new Error(\n `Unknown transform style \"${opts.transform}\". Available: ${styles.join(', ')}`,\n );\n }\n }\n\n console.error(`Reading: ${resolvedInput}`);\n const result = await readInput(resolvedInput);\n const { container } = result;\n\n if (!result.markdownDoc) {\n throw new Error(\n 'Convert command requires a markdown document. JSON Doc input is not supported for convert — use the video command instead.',\n );\n }\n\n // Apply transform if requested\n let exportMarkdownDoc = result.markdownDoc;\n if (opts.transform) {\n exportMarkdownDoc = await applyTransformToMarkdown(\n result.markdownDoc,\n container,\n opts.transform,\n opts.theme,\n );\n console.error(` Applied transform: ${opts.transform}`);\n }\n\n const themeId = opts.theme;\n\n for (const format of formats) {\n const outPath = join(outputDir, `${baseName}.${format}`);\n\n switch (format) {\n case 'docx': {\n const { markdownDocToDocx } = await import('@bendyline/squisq-formats/docx');\n const buf = await markdownDocToDocx(exportMarkdownDoc, { themeId });\n await writeFile(outPath, Buffer.from(buf));\n break;\n }\n\n case 'pptx': {\n const { markdownDocToPptx } = await import('@bendyline/squisq-formats/pptx');\n // Collect images from container for PPTX embedding\n const images = await collectContainerImages(container);\n const buf = await markdownDocToPptx(exportMarkdownDoc, { themeId, images });\n await writeFile(outPath, Buffer.from(buf));\n break;\n }\n\n case 'pdf': {\n const { markdownDocToPdf } = await import('@bendyline/squisq-formats/pdf');\n const buf = await markdownDocToPdf(exportMarkdownDoc, { themeId });\n await writeFile(outPath, Buffer.from(buf));\n break;\n }\n\n case 'html': {\n const { markdownToDoc } = await import('@bendyline/squisq/doc');\n const { docToHtml } = await import('@bendyline/squisq-formats/html');\n const { PLAYER_BUNDLE } = await import('@bendyline/squisq-react/standalone-source');\n\n const doc = markdownToDoc(exportMarkdownDoc);\n const images = await collectImagesForHtml(doc, container);\n\n const html = docToHtml(doc, {\n playerScript: PLAYER_BUNDLE,\n images,\n title: baseName,\n mode: 'static',\n themeId,\n });\n await writeFile(outPath, html, 'utf-8');\n break;\n }\n\n case 'htmlzip': {\n const { markdownToDoc } = await import('@bendyline/squisq/doc');\n const { docToHtmlZip } = await import('@bendyline/squisq-formats/html');\n const { PLAYER_BUNDLE } = await import('@bendyline/squisq-react/standalone-source');\n\n const doc = markdownToDoc(exportMarkdownDoc);\n const images = await collectImagesForHtml(doc, container);\n\n const blob = await docToHtmlZip(doc, {\n playerScript: PLAYER_BUNDLE,\n images,\n title: baseName,\n mode: 'static',\n themeId,\n });\n const buf = Buffer.from(await blob.arrayBuffer());\n await writeFile(outPath.replace(/\\.htmlzip$/, '.html.zip'), buf);\n break;\n }\n\n case 'epub': {\n const { markdownDocToEpub } = await import('@bendyline/squisq-formats/epub');\n const images = await collectContainerImages(container);\n\n // Collect audio narration from container if Doc has audio segments\n const epubAudio = new Map<string, ArrayBuffer>();\n const audioSegments = result.doc?.audio?.segments;\n if (audioSegments?.length) {\n for (const seg of audioSegments) {\n const data = await container.readFile(seg.src);\n if (data) epubAudio.set(seg.src, data);\n }\n }\n\n const hasNarration = epubAudio.size > 0;\n const buf = await markdownDocToEpub(exportMarkdownDoc, {\n title: baseName,\n themeId,\n images,\n audio: hasNarration ? epubAudio : undefined,\n audioSegments: hasNarration ? audioSegments : undefined,\n totalDuration: hasNarration ? result.doc?.duration : undefined,\n });\n await writeFile(outPath, Buffer.from(buf));\n break;\n }\n\n case 'dbk': {\n const { containerToZip } = await import('@bendyline/squisq-formats/container');\n const blob = await containerToZip(container);\n const buf = await blob.arrayBuffer();\n await writeFile(outPath, Buffer.from(buf));\n break;\n }\n }\n\n console.error(` ✓ ${outPath}`);\n }\n\n console.error('Done.');\n}\n\n// ============================================\n// Transform Pipeline\n// ============================================\n\n/**\n * Apply a transform style to a MarkdownDocument.\n * Pipeline: MarkdownDocument → Doc → applyTransform → docToMarkdown → MarkdownDocument\n */\nasync function applyTransformToMarkdown(\n markdownDoc: MarkdownDocument,\n container: ContentContainer,\n transformStyle: string,\n themeId?: string,\n): Promise<MarkdownDocument> {\n const { markdownToDoc, docToMarkdown } = await import('@bendyline/squisq/doc');\n const { applyTransform, extractDocImages } = await import('@bendyline/squisq/transform');\n\n const doc = markdownToDoc(markdownDoc);\n\n // Extract image metadata from the doc for transform interleaving\n const images = extractDocImages(doc.blocks);\n\n const result = applyTransform(doc, transformStyle, {\n themeId,\n images,\n });\n\n return docToMarkdown(result.doc);\n}\n\n/**\n * Collect images for HTML export, keying each by both its container path and\n * its filename. The standalone player resolves by exact key first then by\n * filename, so both keyings handle path mismatches between how the Doc\n * references images and how they're stored in the container.\n *\n * Only images referenced by the Doc are included — unreferenced container\n * images would bloat the HTML output unnecessarily.\n */\nasync function collectImagesForHtml(\n doc: import('@bendyline/squisq/schemas').Doc,\n container: ContentContainer,\n): Promise<Map<string, ArrayBuffer>> {\n const { collectImagePaths, extractFilename } = await import('@bendyline/squisq-formats/html');\n\n const images = new Map<string, ArrayBuffer>();\n const docPaths = collectImagePaths(doc);\n\n // Try exact paths first\n const needByFilename = new Set<string>();\n for (const imgPath of docPaths) {\n const data = await container.readFile(imgPath);\n if (data) {\n images.set(imgPath, data);\n const filename = extractFilename(imgPath);\n if (filename !== imgPath) images.set(filename, data);\n } else {\n needByFilename.add(extractFilename(imgPath));\n }\n }\n\n // For any doc paths that didn't resolve, scan the container for a file\n // with the same basename (handles \"images/hero.jpg\" vs \"hero.jpg\" mismatches).\n if (needByFilename.size > 0) {\n const files = await container.listFiles();\n for (const file of files) {\n const filename = extractFilename(file.path);\n if (needByFilename.has(filename)) {\n const data = await container.readFile(file.path);\n if (data) {\n images.set(filename, data);\n images.set(file.path, data);\n }\n }\n }\n }\n\n return images;\n}\n\n/**\n * Collect all image files from a container as a Map<path, ArrayBuffer>.\n */\nasync function collectContainerImages(\n container: ContentContainer,\n): Promise<Map<string, ArrayBuffer>> {\n const images = new Map<string, ArrayBuffer>();\n const files = await container.listFiles();\n for (const file of files) {\n if (/\\.(jpg|jpeg|png|gif|webp|svg|bmp|avif)$/i.test(file.path)) {\n const data = await container.readFile(file.path);\n if (data) {\n images.set(file.path, data);\n }\n }\n }\n return images;\n}\n","/**\n * readInput\n *\n * Unified input reader for the CLI. Accepts a path to a .md file, .json file\n * (Doc schema), .zip/.dbk container, or a folder and returns a MemoryContentContainer\n * (virtual file system) plus either the parsed MarkdownDocument or a pre-built Doc.\n *\n * When a .json file or a container/folder containing doc.json is provided, the\n * Doc is returned directly and markdownDoc will be null. Callers should check\n * result.doc first; when present, skip markdownToDoc() and use it directly.\n */\n\nimport { readFile, readdir, stat } from 'node:fs/promises';\nimport { join, extname } from 'node:path';\nimport { parseMarkdown } from '@bendyline/squisq/markdown';\nimport type { MarkdownDocument } from '@bendyline/squisq/markdown';\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport { MemoryContentContainer } from '@bendyline/squisq/storage';\nimport { zipToContainer } from '@bendyline/squisq-formats/container';\n\nexport interface ReadInputResult {\n container: MemoryContentContainer;\n /** Parsed markdown document. Null when input is a Doc JSON file. */\n markdownDoc: MarkdownDocument | null;\n /** Pre-built Doc from JSON input. Present when input is .json or contains doc.json. */\n doc?: Doc;\n}\n\n/** MIME type lookup by extension (common content types) */\nconst MIME_TYPES: Record<string, string> = {\n '.md': 'text/markdown',\n '.txt': 'text/plain',\n '.json': 'application/json',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.png': 'image/png',\n '.gif': 'image/gif',\n '.webp': 'image/webp',\n '.svg': 'image/svg+xml',\n '.mp3': 'audio/mpeg',\n '.wav': 'audio/wav',\n '.ogg': 'audio/ogg',\n '.mp4': 'video/mp4',\n '.webm': 'video/webm',\n};\n\nfunction mimeFromExt(filePath: string): string {\n return MIME_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';\n}\n\n/**\n * Recursively walk a directory and return all file paths (relative to root).\n */\nasync function walkDir(root: string, prefix = ''): Promise<string[]> {\n const entries = await readdir(root, { withFileTypes: true });\n const paths: string[] = [];\n\n for (const entry of entries) {\n const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;\n if (entry.isDirectory()) {\n paths.push(...(await walkDir(join(root, entry.name), relPath)));\n } else if (entry.isFile()) {\n paths.push(relPath);\n }\n }\n\n return paths;\n}\n\n/**\n * Read input from a file path (markdown, JSON Doc, ZIP/DBK container, or folder)\n * and return a populated ContentContainer + parsed MarkdownDocument or Doc.\n *\n * When the input is a .json file (or a container/folder containing doc.json),\n * the result will have `doc` populated and `markdownDoc` set to null.\n * Otherwise, `markdownDoc` is populated and `doc` is undefined.\n */\nexport async function readInput(inputPath: string): Promise<ReadInputResult> {\n const info = await stat(inputPath);\n\n if (info.isDirectory()) {\n return readFolder(inputPath);\n }\n\n const ext = extname(inputPath).toLowerCase();\n if (ext === '.zip' || ext === '.dbk') {\n return readContainer(inputPath);\n }\n\n if (ext === '.json') {\n return readDocJsonFile(inputPath);\n }\n\n // Default: treat as a markdown file\n return readMarkdownFile(inputPath);\n}\n\nasync function readMarkdownFile(filePath: string): Promise<ReadInputResult> {\n const content = await readFile(filePath, 'utf-8');\n const container = new MemoryContentContainer();\n await container.writeDocument(content);\n const markdownDoc = parseMarkdown(content);\n return { container, markdownDoc };\n}\n\n/**\n * Read a standalone Doc JSON file. The container is empty (no media bundled);\n * callers should populate it or set basePath for media resolution.\n */\nasync function readDocJsonFile(filePath: string): Promise<ReadInputResult> {\n const content = await readFile(filePath, 'utf-8');\n const doc = JSON.parse(content) as Doc;\n const container = new MemoryContentContainer();\n return { container, markdownDoc: null, doc };\n}\n\n/**\n * Known filenames for Doc JSON inside containers and folders.\n * Checked in priority order before falling back to markdown discovery.\n */\nconst DOC_JSON_NAMES = ['doc.json', 'story.json'];\n\nasync function readContainer(filePath: string): Promise<ReadInputResult> {\n const data = await readFile(filePath);\n const container = await zipToContainer(\n data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer,\n );\n\n // Check for Doc JSON inside the container first\n for (const name of DOC_JSON_NAMES) {\n const jsonData = await container.readFile(name);\n if (jsonData) {\n const decoder = new TextDecoder();\n const doc = JSON.parse(decoder.decode(jsonData)) as Doc;\n return { container, markdownDoc: null, doc };\n }\n }\n\n const markdown = await container.readDocument();\n if (!markdown) {\n throw new Error(`No markdown document or doc.json found in container: ${filePath}`);\n }\n\n const markdownDoc = parseMarkdown(markdown);\n return { container, markdownDoc };\n}\n\nasync function readFolder(dirPath: string): Promise<ReadInputResult> {\n const container = new MemoryContentContainer();\n const files = await walkDir(dirPath);\n\n for (const relPath of files) {\n const absPath = join(dirPath, relPath);\n const data = await readFile(absPath);\n await container.writeFile(\n relPath,\n new Uint8Array(data.buffer, data.byteOffset, data.byteLength),\n mimeFromExt(relPath),\n );\n }\n\n // Check for Doc JSON inside the folder first\n for (const name of DOC_JSON_NAMES) {\n const jsonData = await container.readFile(name);\n if (jsonData) {\n const decoder = new TextDecoder();\n const doc = JSON.parse(decoder.decode(jsonData)) as Doc;\n return { container, markdownDoc: null, doc };\n }\n }\n\n const markdown = await container.readDocument();\n if (!markdown) {\n throw new Error(`No markdown document or doc.json found in folder: ${dirPath}`);\n }\n\n const markdownDoc = parseMarkdown(markdown);\n return { container, markdownDoc };\n}\n","/**\n * video command\n *\n * Renders a squisq document to MP4 video by delegating to the\n * programmatic renderDocToMp4 API.\n *\n * Usage:\n * squisq video <input> [-o output.mp4] [--fps 30] [--quality normal] [--orientation landscape]\n */\n\nimport { mkdir } from 'node:fs/promises';\nimport { dirname, basename, extname, resolve } from 'node:path';\nimport type { Command } from 'commander';\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport { readInput } from '../util/readInput.js';\nimport { renderDocToMp4 } from '../api.js';\n\nimport type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';\n\ntype CaptionOption = 'off' | 'standard' | 'social';\n\ninterface VideoCommandOptions {\n output?: string;\n fps?: string;\n quality?: VideoQuality;\n orientation?: VideoOrientation;\n captions?: CaptionOption;\n width?: string;\n height?: string;\n}\n\nconst VALID_QUALITIES = ['draft', 'normal', 'high'] as const;\nconst VALID_ORIENTATIONS = ['landscape', 'portrait'] as const;\nconst VALID_CAPTIONS = ['off', 'standard', 'social'] as const;\n\nexport function registerVideoCommand(program: Command): void {\n program\n .command('video')\n .description('Render a squisq document to MP4 video')\n .argument('<input>', 'Path to .md file, .zip/.dbk container, or folder')\n .argument('[output]', 'Output MP4 path (default: <input>.mp4)')\n .option('-o, --output <path>', 'Output MP4 path (default: <input>.mp4)')\n .option('--fps <number>', 'Frames per second (default: 30)', '30')\n .option(\n '--quality <level>',\n `Encoding quality: ${VALID_QUALITIES.join(', ')} (default: normal)`,\n 'normal',\n )\n .option(\n '--orientation <orient>',\n `Video orientation: ${VALID_ORIENTATIONS.join(', ')} (default: landscape)`,\n 'landscape',\n )\n .option(\n '--captions <style>',\n `Caption style: ${VALID_CAPTIONS.join(', ')} (default: off)`,\n 'off',\n )\n .option('--width <pixels>', 'Override video width')\n .option('--height <pixels>', 'Override video height')\n .action(async (inputPath: string, outputArg: string | undefined, opts: VideoCommandOptions) => {\n try {\n // Positional output arg takes precedence, then -o flag\n if (outputArg && !opts.output) {\n opts.output = outputArg;\n }\n await runVideo(inputPath, opts);\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n console.error(`Error: ${message}`);\n process.exitCode = 1;\n }\n });\n}\n\nasync function runVideo(inputPath: string, opts: VideoCommandOptions): Promise<void> {\n const resolvedInput = resolve(inputPath);\n\n // Validate options\n const fps = parseInt(opts.fps ?? '30', 10);\n if (isNaN(fps) || fps < 1 || fps > 120) {\n throw new Error('FPS must be a number between 1 and 120');\n }\n\n const quality = opts.quality ?? 'normal';\n if (!VALID_QUALITIES.includes(quality as (typeof VALID_QUALITIES)[number])) {\n throw new Error(`Invalid quality \"${quality}\". Valid: ${VALID_QUALITIES.join(', ')}`);\n }\n\n const orientation = opts.orientation ?? 'landscape';\n if (!VALID_ORIENTATIONS.includes(orientation as (typeof VALID_ORIENTATIONS)[number])) {\n throw new Error(\n `Invalid orientation \"${orientation}\". Valid: ${VALID_ORIENTATIONS.join(', ')}`,\n );\n }\n\n const captions = opts.captions ?? 'off';\n if (!VALID_CAPTIONS.includes(captions as (typeof VALID_CAPTIONS)[number])) {\n throw new Error(`Invalid captions \"${captions}\". Valid: ${VALID_CAPTIONS.join(', ')}`);\n }\n const captionStyle = captions === 'off' ? undefined : (captions as 'standard' | 'social');\n\n // Determine output path\n const inputBasename = basename(resolvedInput);\n const inputExt = extname(inputBasename);\n const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;\n const outputPath = opts.output\n ? resolve(opts.output)\n : resolve(dirname(resolvedInput), `${baseName}.mp4`);\n\n // Ensure output directory exists\n await mkdir(dirname(outputPath), { recursive: true });\n\n // ── Step 1: Read input ──────────────────────────────────────────\n console.error(`Reading: ${resolvedInput}`);\n const result = await readInput(resolvedInput);\n const { container } = result;\n\n // ── Step 2: Get or parse Doc ────────────────────────────────────\n let doc: Doc;\n if (result.doc) {\n console.error('Using pre-built Doc JSON');\n doc = result.doc;\n } else if (result.markdownDoc) {\n const { markdownToDoc } = await import('@bendyline/squisq/doc');\n doc = markdownToDoc(result.markdownDoc);\n } else {\n throw new Error('No document found in input');\n }\n\n console.error(\n `Rendering: ${fps} fps, quality: ${quality}, orientation: ${orientation}, captions: ${captions}`,\n );\n\n // ── Step 3: Render via programmatic API ─────────────────────────\n const result2 = await renderDocToMp4(doc, container, {\n outputPath,\n fps,\n quality: quality as VideoQuality,\n orientation: orientation as VideoOrientation,\n width: opts.width ? parseInt(opts.width, 10) : undefined,\n height: opts.height ? parseInt(opts.height, 10) : undefined,\n captionStyle,\n coverPreRoll: 2,\n onProgress: (phase, percent) => {\n writeProgress(phase, percent, 100);\n },\n });\n\n clearProgress();\n console.error(\n ` ✓ ${outputPath} (${result2.duration.toFixed(1)}s, ${result2.frameCount} frames)`,\n );\n console.error('Done.');\n}\n\n// ── Progress helpers ──────────────────────────────────────────────\n\nconst BAR_WIDTH = 30;\n\nfunction writeProgress(label: string, current: number, total: number, detail?: string): void {\n const pct = Math.min(100, Math.round((current / total) * 100));\n const filled = Math.round((pct / 100) * BAR_WIDTH);\n const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);\n const suffix = detail ? ` (${detail})` : '';\n process.stderr.write(`\\r ${label}: ${bar} ${pct}%${suffix} `);\n}\n\nfunction clearProgress(): void {\n process.stderr.write('\\r' + ' '.repeat(80) + '\\r');\n}\n","/**\n * Programmatic Video API\n *\n * Provides a library-style entry point for rendering Squisq documents to MP4\n * from Node.js callers (e.g., Qualla's pipeline). This avoids the need to shell\n * out to the `squisq video` CLI and gives callers full control over the Doc,\n * MemoryContentContainer, and encoding options.\n *\n * Orchestrates the full pipeline: Doc → render HTML → Playwright frame capture → FFmpeg encode.\n *\n * Usage:\n * import { renderDocToMp4 } from '@bendyline/squisq-cli/api';\n *\n * await renderDocToMp4(doc, container, {\n * outputPath: '/tmp/output.mp4',\n * fps: 30,\n * quality: 'normal',\n * orientation: 'landscape',\n * });\n */\n\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport type { MemoryContentContainer } from '@bendyline/squisq/storage';\nimport type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';\nimport { generateRenderHtml } from '@bendyline/squisq-video';\nimport { resolveDimensions } from '@bendyline/squisq-video';\nimport { detectFfmpeg } from './util/detectFfmpeg.js';\n\n// Re-export utility types and functions callers may need\nexport type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';\nexport { MemoryContentContainer } from '@bendyline/squisq/storage';\nexport { readInput } from './util/readInput.js';\nexport type { ReadInputResult } from './util/readInput.js';\n\n/** Options for renderDocToMp4. */\nexport interface RenderDocToMp4Options {\n /** Output file path for the MP4. */\n outputPath: string;\n\n /** Frames per second (default: 30). */\n fps?: number;\n\n /** Encoding quality preset (default: 'normal'). */\n quality?: VideoQuality;\n\n /** Video orientation (default: 'landscape'). */\n orientation?: VideoOrientation;\n\n /** Override video width in pixels. */\n width?: number;\n\n /** Override video height in pixels. */\n height?: number;\n\n /** Caption style to bake into the video (default: none). */\n captionStyle?: 'standard' | 'social';\n\n /** Seconds of cover-slide pre-roll before the story starts (default: 0). */\n coverPreRoll?: number;\n\n /**\n * Progress callback. Called with a phase name and 0-100 percentage.\n */\n onProgress?: (phase: string, percent: number) => void;\n}\n\n/** Result returned by renderDocToMp4. */\nexport interface RenderDocToMp4Result {\n /** Duration of the rendered video in seconds (including pre-roll). */\n duration: number;\n\n /** Number of frames captured. */\n frameCount: number;\n\n /** Output file path. */\n outputPath: string;\n}\n\n/**\n * Render a Doc + media container to an MP4 video file.\n *\n * The container should contain audio and image files referenced by the Doc's\n * audio.segments[].src and block image paths. Files are embedded as base64\n * data URIs in a self-contained render HTML page.\n *\n * Requires:\n * - Playwright (chromium) — for headless frame capture\n * - FFmpeg — for video encoding (must be on PATH)\n *\n * @param doc - The Doc structure to render\n * @param container - MemoryContentContainer with audio/image files\n * @param options - Rendering and encoding options\n * @returns Result with duration and frame count\n */\nexport async function renderDocToMp4(\n doc: Doc,\n container: MemoryContentContainer,\n options: RenderDocToMp4Options,\n): Promise<RenderDocToMp4Result> {\n const {\n outputPath,\n fps = 30,\n quality = 'normal',\n orientation = 'landscape',\n captionStyle,\n coverPreRoll = 0,\n onProgress,\n } = options;\n\n const dimensions = resolveDimensions({\n orientation,\n width: options.width,\n height: options.height,\n });\n\n // Detect ffmpeg early — needed for audio concat and video encoding\n const ffmpegPath = await detectFfmpeg();\n if (!ffmpegPath) {\n throw new Error(\n 'ffmpeg is required but not found in PATH.\\n' +\n 'Install it with:\\n' +\n ' macOS: brew install ffmpeg\\n' +\n ' Ubuntu: sudo apt install ffmpeg\\n' +\n ' Windows: winget install ffmpeg',\n );\n }\n\n onProgress?.('collecting media', 0);\n\n // ── Collect images from container ───────────────────────────────\n const { collectImagePaths } = await import('@bendyline/squisq-formats/html');\n const imagePaths = collectImagePaths(doc);\n const images = new Map<string, ArrayBuffer>();\n for (const imgPath of imagePaths) {\n const data = await container.readFile(imgPath);\n if (data) {\n images.set(imgPath, data);\n }\n }\n\n // ── Collect audio segments ──────────────────────────────────────\n const audio = new Map<string, ArrayBuffer>();\n const audioBuffers: ArrayBuffer[] = [];\n if (doc.audio?.segments?.length) {\n for (const seg of doc.audio.segments) {\n const data = await container.readFile(seg.src);\n if (data) {\n audio.set(seg.src, data);\n audio.set(seg.name, data);\n audioBuffers.push(data);\n }\n }\n }\n\n // Concatenate audio for the MP4's audio track\n let concatenatedAudio: Uint8Array | null = null;\n if (audioBuffers.length > 0) {\n concatenatedAudio = await concatenateAudioBuffers(audioBuffers, ffmpegPath);\n }\n\n onProgress?.('generating render HTML', 10);\n\n // ── Generate self-contained render HTML ─────────────────────────\n const { PLAYER_BUNDLE } = await import('@bendyline/squisq-react/standalone-source');\n const renderHtml = generateRenderHtml(doc, {\n playerScript: PLAYER_BUNDLE,\n images,\n audio: audio.size > 0 ? audio : undefined,\n width: dimensions.width,\n height: dimensions.height,\n captionStyle,\n });\n\n onProgress?.('launching browser', 15);\n\n // ── Playwright frame capture ────────────────────────────────────\n const { chromium } = await import('playwright-core');\n const browser = await chromium.launch({ headless: true });\n const page = await browser.newPage({\n viewport: { width: dimensions.width, height: dimensions.height },\n });\n\n const pageErrors: string[] = [];\n page.on('pageerror', (err) => pageErrors.push(err.message));\n\n await page.setContent(renderHtml, { waitUntil: 'load' });\n await page.waitForTimeout(500);\n\n try {\n await page.waitForFunction(\n () => typeof (window as unknown as Record<string, unknown>).getDuration === 'function',\n { timeout: 15000 },\n );\n } catch {\n await browser.close();\n const errorDetail = pageErrors.length\n ? `\\nPage errors:\\n ${pageErrors.join('\\n ')}`\n : '\\nNo page errors captured — the player may have failed to mount.';\n throw new Error(`Render API did not initialize within 15 seconds.${errorDetail}`);\n }\n\n const docDuration: number = await page.evaluate(() => {\n return (window as unknown as { getDuration: () => number }).getDuration();\n });\n\n if (docDuration <= 0) {\n await browser.close();\n throw new Error('Document has zero duration — nothing to render');\n }\n\n const storyFrameCount = Math.ceil(docDuration * fps);\n const preRollFrameCount = Math.ceil(coverPreRoll * fps);\n const totalFrames = preRollFrameCount + storyFrameCount;\n const frames: Uint8Array[] = [];\n\n onProgress?.('capturing frames', 20);\n\n // Cover slide pre-roll (if requested)\n if (preRollFrameCount > 0) {\n const hasCover: boolean = await page.evaluate(() => {\n const w = window as unknown as { hasCoverBlock?: () => boolean };\n return typeof w.hasCoverBlock === 'function' ? w.hasCoverBlock() : false;\n });\n\n if (hasCover) {\n await page.evaluate(() => {\n (window as unknown as { showCover: () => void }).showCover();\n });\n await page.waitForTimeout(100);\n const coverFrame = new Uint8Array(await page.screenshot({ type: 'png' }));\n for (let i = 0; i < preRollFrameCount; i++) {\n frames.push(coverFrame);\n }\n await page.evaluate(() => {\n (window as unknown as { hideCover: () => void }).hideCover();\n });\n }\n }\n\n // Story frames via seekTo\n const frameInterval = 1 / fps;\n for (let i = 0; i < storyFrameCount; i++) {\n const time = i * frameInterval;\n await page.evaluate((t: number) => {\n return (window as unknown as { seekTo: (t: number) => Promise<void> }).seekTo(t);\n }, time);\n\n const screenshot = await page.screenshot({ type: 'png' });\n frames.push(new Uint8Array(screenshot));\n\n // Report progress: frames phase is 20% to 80%\n if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {\n const pct = 20 + Math.round((frames.length / totalFrames) * 60);\n onProgress?.('capturing frames', pct);\n }\n }\n\n await browser.close();\n\n onProgress?.('encoding video', 80);\n\n // If there's a pre-roll, delay the audio track to match\n let encodingAudio = concatenatedAudio;\n if (coverPreRoll > 0 && concatenatedAudio) {\n // Use FFmpeg to add silence padding at the start (adelay filter)\n encodingAudio = await addAudioDelay(ffmpegPath, concatenatedAudio, coverPreRoll);\n }\n\n const { framesToMp4Native } = await import('./util/nativeEncoder.js');\n await framesToMp4Native(ffmpegPath, frames, encodingAudio, outputPath, {\n fps,\n quality,\n orientation,\n width: dimensions.width,\n height: dimensions.height,\n onProgress: (percent, phase) => {\n onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2));\n },\n });\n\n onProgress?.('done', 100);\n\n const totalDuration = docDuration + coverPreRoll;\n return {\n duration: totalDuration,\n frameCount: frames.length,\n outputPath,\n };\n}\n\n// ── Audio helpers ─────────────────────────────────────────────────\n\n/**\n * Concatenate multiple audio buffers into one.\n * Uses native ffmpeg concat when available, falls back to byte concatenation.\n */\nasync function concatenateAudioBuffers(\n buffers: ArrayBuffer[],\n ffmpegPath?: string,\n): Promise<Uint8Array> {\n if (buffers.length === 0) return new Uint8Array(0);\n if (buffers.length === 1) return new Uint8Array(buffers[0]);\n\n if (ffmpegPath) {\n return concatenateAudioNative(ffmpegPath, buffers);\n }\n\n // Fallback: naive byte concatenation (works for MP3)\n const totalLength = buffers.reduce((sum, b) => sum + b.byteLength, 0);\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const buf of buffers) {\n result.set(new Uint8Array(buf), offset);\n offset += buf.byteLength;\n }\n return result;\n}\n\nasync function concatenateAudioNative(\n ffmpegPath: string,\n buffers: ArrayBuffer[],\n): Promise<Uint8Array> {\n const { writeFile, readFile, mkdir, rm } = await import('node:fs/promises');\n const { join } = await import('node:path');\n const { tmpdir } = await import('node:os');\n const { randomBytes } = await import('node:crypto');\n const { execFile } = await import('node:child_process');\n\n const tmpId = randomBytes(8).toString('hex');\n const workDir = join(tmpdir(), `squisq-audio-concat-${tmpId}`);\n await mkdir(workDir, { recursive: true });\n\n try {\n const segmentPaths: string[] = [];\n for (let i = 0; i < buffers.length; i++) {\n const segPath = join(workDir, `seg-${i}.mp3`);\n await writeFile(segPath, new Uint8Array(buffers[i]));\n segmentPaths.push(segPath);\n }\n\n const listPath = join(workDir, 'concat-list.txt');\n const listContent = segmentPaths.map((p) => `file '${p.replace(/\\\\/g, '/')}'`).join('\\n');\n await writeFile(listPath, listContent);\n\n const outputPath = join(workDir, 'combined.mp3');\n await new Promise<void>((resolve, reject) => {\n execFile(\n ffmpegPath,\n ['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', outputPath],\n { timeout: 120_000 },\n (err) => {\n if (err) reject(new Error(`ffmpeg audio concat failed: ${err.message}`));\n else resolve();\n },\n );\n });\n\n const data = await readFile(outputPath);\n return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n } finally {\n await rm(workDir, { recursive: true, force: true });\n }\n}\n\n/**\n * Add silence at the start of an audio track by re-encoding with adelay filter.\n */\nasync function addAudioDelay(\n ffmpegPath: string,\n audioData: Uint8Array,\n delaySecs: number,\n): Promise<Uint8Array> {\n const { writeFile, readFile, rm } = await import('node:fs/promises');\n const { join } = await import('node:path');\n const { tmpdir } = await import('node:os');\n const { randomBytes } = await import('node:crypto');\n const { execFile } = await import('node:child_process');\n\n const tmpId = randomBytes(8).toString('hex');\n const inputPath = join(tmpdir(), `squisq-audio-delay-in-${tmpId}.mp3`);\n const outputPath = join(tmpdir(), `squisq-audio-delay-out-${tmpId}.mp3`);\n\n try {\n await writeFile(inputPath, audioData);\n const delayMs = Math.round(delaySecs * 1000);\n\n await new Promise<void>((resolve, reject) => {\n execFile(\n ffmpegPath,\n [\n '-y',\n '-i',\n inputPath,\n '-af',\n `adelay=${delayMs}|${delayMs}`,\n '-c:a',\n 'libmp3lame',\n '-b:a',\n '128k',\n outputPath,\n ],\n { timeout: 60_000 },\n (err) => {\n if (err) reject(new Error(`ffmpeg audio delay failed: ${err.message}`));\n else resolve();\n },\n );\n });\n\n const data = await readFile(outputPath);\n return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n } finally {\n await rm(inputPath, { force: true });\n await rm(outputPath, { force: true });\n }\n}\n\n// ── Thumbnail extraction ──────────────────────────────────────────\n\n/** A thumbnail size specification. */\nexport interface ThumbnailSpec {\n /** Label for the thumbnail (used in filename: `{slug}-{width}x{height}.jpg`). */\n name: string;\n /** Output width in pixels. */\n width: number;\n /** Output height in pixels. */\n height: number;\n /** FFmpeg video filter string (e.g., 'scale=1280:720'). */\n filter: string;\n}\n\n/** Options for extractThumbnails. */\nexport interface ExtractThumbnailsOptions {\n /** Path to the source MP4 video. */\n videoPath: string;\n /** Directory to write thumbnails into. */\n outputDir: string;\n /** Base slug for filenames (produces `{slug}-{width}x{height}.jpg`). */\n slug: string;\n /** Thumbnail sizes to generate. */\n sizes: ThumbnailSpec[];\n /** Overwrite existing thumbnails (default: false). */\n force?: boolean;\n}\n\n/**\n * Extract thumbnail images from the first frame of an MP4 video.\n * Produces JPEG files at each specified size using FFmpeg video filters.\n */\nexport async function extractThumbnails(options: ExtractThumbnailsOptions): Promise<void> {\n const { videoPath, outputDir, slug, sizes, force } = options;\n const { existsSync } = await import('node:fs');\n const { join } = await import('node:path');\n const { execFile } = await import('node:child_process');\n\n const ffmpegPath = await detectFfmpeg();\n if (!ffmpegPath) {\n throw new Error(\n 'ffmpeg is required for thumbnail extraction but not found in PATH.\\n' +\n 'Install it with:\\n' +\n ' macOS: brew install ffmpeg\\n' +\n ' Ubuntu: sudo apt install ffmpeg\\n' +\n ' Windows: winget install ffmpeg',\n );\n }\n\n for (const thumb of sizes) {\n const outputPath = join(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);\n if (!force && existsSync(outputPath)) continue;\n\n await new Promise<void>((resolve, reject) => {\n execFile(\n ffmpegPath,\n ['-y', '-i', videoPath, '-vf', thumb.filter, '-frames:v', '1', '-q:v', '2', outputPath],\n { timeout: 30_000 },\n (err) => {\n if (err) reject(new Error(`Thumbnail extraction failed (${thumb.name}): ${err.message}`));\n else resolve();\n },\n );\n });\n }\n}\n","/**\n * detectFfmpeg\n *\n * Checks whether a native ffmpeg binary is available on the system PATH.\n * Returns the absolute path to the binary if found, or null if not.\n *\n * Uses `which` on macOS/Linux and `where` on Windows.\n */\n\nimport { execFile } from 'node:child_process';\n\n/**\n * Detect whether native ffmpeg is installed and available on PATH.\n *\n * @returns Absolute path to ffmpeg binary, or null if not found\n */\nexport async function detectFfmpeg(): Promise<string | null> {\n const command = process.platform === 'win32' ? 'where' : 'which';\n\n return new Promise((resolve) => {\n execFile(command, ['ffmpeg'], { timeout: 5000 }, (err, stdout) => {\n if (err || !stdout.trim()) {\n resolve(null);\n return;\n }\n // `which` and `where` can return multiple lines; take the first\n const path = stdout.trim().split('\\n')[0].trim();\n resolve(path);\n });\n });\n}\n"],"mappings":";;;AAWA,SAAS,eAAe;;;ACExB,SAAS,WAAW,aAAa;AACjC,SAAS,SAAS,UAAU,WAAAA,UAAS,QAAAC,OAAM,eAAe;;;ACF1D,SAAS,UAAU,SAAS,YAAY;AACxC,SAAS,MAAM,eAAe;AAC9B,SAAS,qBAAqB;AAG9B,SAAS,8BAA8B;AACvC,SAAS,sBAAsB;AAW/B,IAAM,aAAqC;AAAA,EACzC,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,SAAS;AACX;AAEA,SAAS,YAAY,UAA0B;AAC7C,SAAO,WAAW,QAAQ,QAAQ,EAAE,YAAY,CAAC,KAAK;AACxD;AAKA,eAAe,QAAQ,MAAc,SAAS,IAAuB;AACnE,QAAM,UAAU,MAAM,QAAQ,MAAM,EAAE,eAAe,KAAK,CAAC;AAC3D,QAAM,QAAkB,CAAC;AAEzB,aAAW,SAAS,SAAS;AAC3B,UAAM,UAAU,SAAS,GAAG,MAAM,IAAI,MAAM,IAAI,KAAK,MAAM;AAC3D,QAAI,MAAM,YAAY,GAAG;AACvB,YAAM,KAAK,GAAI,MAAM,QAAQ,KAAK,MAAM,MAAM,IAAI,GAAG,OAAO,CAAE;AAAA,IAChE,WAAW,MAAM,OAAO,GAAG;AACzB,YAAM,KAAK,OAAO;AAAA,IACpB;AAAA,EACF;AAEA,SAAO;AACT;AAUA,eAAsB,UAAU,WAA6C;AAC3E,QAAM,OAAO,MAAM,KAAK,SAAS;AAEjC,MAAI,KAAK,YAAY,GAAG;AACtB,WAAO,WAAW,SAAS;AAAA,EAC7B;AAEA,QAAM,MAAM,QAAQ,SAAS,EAAE,YAAY;AAC3C,MAAI,QAAQ,UAAU,QAAQ,QAAQ;AACpC,WAAO,cAAc,SAAS;AAAA,EAChC;AAEA,MAAI,QAAQ,SAAS;AACnB,WAAO,gBAAgB,SAAS;AAAA,EAClC;AAGA,SAAO,iBAAiB,SAAS;AACnC;AAEA,eAAe,iBAAiB,UAA4C;AAC1E,QAAM,UAAU,MAAM,SAAS,UAAU,OAAO;AAChD,QAAM,YAAY,IAAI,uBAAuB;AAC7C,QAAM,UAAU,cAAc,OAAO;AACrC,QAAM,cAAc,cAAc,OAAO;AACzC,SAAO,EAAE,WAAW,YAAY;AAClC;AAMA,eAAe,gBAAgB,UAA4C;AACzE,QAAM,UAAU,MAAM,SAAS,UAAU,OAAO;AAChD,QAAM,MAAM,KAAK,MAAM,OAAO;AAC9B,QAAM,YAAY,IAAI,uBAAuB;AAC7C,SAAO,EAAE,WAAW,aAAa,MAAM,IAAI;AAC7C;AAMA,IAAM,iBAAiB,CAAC,YAAY,YAAY;AAEhD,eAAe,cAAc,UAA4C;AACvE,QAAM,OAAO,MAAM,SAAS,QAAQ;AACpC,QAAM,YAAY,MAAM;AAAA,IACtB,KAAK,OAAO,MAAM,KAAK,YAAY,KAAK,aAAa,KAAK,UAAU;AAAA,EACtE;AAGA,aAAW,QAAQ,gBAAgB;AACjC,UAAM,WAAW,MAAM,UAAU,SAAS,IAAI;AAC9C,QAAI,UAAU;AACZ,YAAM,UAAU,IAAI,YAAY;AAChC,YAAM,MAAM,KAAK,MAAM,QAAQ,OAAO,QAAQ,CAAC;AAC/C,aAAO,EAAE,WAAW,aAAa,MAAM,IAAI;AAAA,IAC7C;AAAA,EACF;AAEA,QAAM,WAAW,MAAM,UAAU,aAAa;AAC9C,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,MAAM,wDAAwD,QAAQ,EAAE;AAAA,EACpF;AAEA,QAAM,cAAc,cAAc,QAAQ;AAC1C,SAAO,EAAE,WAAW,YAAY;AAClC;AAEA,eAAe,WAAW,SAA2C;AACnE,QAAM,YAAY,IAAI,uBAAuB;AAC7C,QAAM,QAAQ,MAAM,QAAQ,OAAO;AAEnC,aAAW,WAAW,OAAO;AAC3B,UAAM,UAAU,KAAK,SAAS,OAAO;AACrC,UAAM,OAAO,MAAM,SAAS,OAAO;AACnC,UAAM,UAAU;AAAA,MACd;AAAA,MACA,IAAI,WAAW,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU;AAAA,MAC5D,YAAY,OAAO;AAAA,IACrB;AAAA,EACF;AAGA,aAAW,QAAQ,gBAAgB;AACjC,UAAM,WAAW,MAAM,UAAU,SAAS,IAAI;AAC9C,QAAI,UAAU;AACZ,YAAM,UAAU,IAAI,YAAY;AAChC,YAAM,MAAM,KAAK,MAAM,QAAQ,OAAO,QAAQ,CAAC;AAC/C,aAAO,EAAE,WAAW,aAAa,MAAM,IAAI;AAAA,IAC7C;AAAA,EACF;AAEA,QAAM,WAAW,MAAM,UAAU,aAAa;AAC9C,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,MAAM,qDAAqD,OAAO,EAAE;AAAA,EAChF;AAEA,QAAM,cAAc,cAAc,QAAQ;AAC1C,SAAO,EAAE,WAAW,YAAY;AAClC;;;AD9JA,IAAM,cAAc,CAAC,QAAQ,QAAQ,OAAO,QAAQ,WAAW,QAAQ,KAAK;AAG5E,SAAS,aAAa,OAAyB;AAC7C,QAAM,YAAY,MAAM,MAAM,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,YAAY,CAAC;AACpE,QAAM,QAAkB,CAAC;AACzB,aAAW,KAAK,WAAW;AACzB,QAAI,YAAY,SAAS,CAAW,GAAG;AACrC,YAAM,KAAK,CAAW;AAAA,IACxB,OAAO;AACL,cAAQ,KAAK,mBAAmB,CAAC,6BAAwB,YAAY,KAAK,IAAI,CAAC,EAAE;AAAA,IACnF;AAAA,EACF;AACA,MAAI,MAAM,WAAW,GAAG;AACtB,UAAM,IAAI,MAAM,sCAAsC,YAAY,KAAK,IAAI,CAAC,EAAE;AAAA,EAChF;AACA,SAAO;AACT;AASO,SAAS,uBAAuBC,UAAwB;AAC7D,EAAAA,SACG,QAAQ,SAAS,EACjB,YAAY,iFAAiF,EAC7F,SAAS,WAAW,kDAAkD,EACtE,OAAO,0BAA0B,2CAA2C,EAC5E;AAAA,IACC;AAAA,IACA,6DAA6D,YAAY,KAAK,IAAI,CAAC;AAAA,EACrF,EACC,OAAO,oBAAoB,+DAA+D,EAC1F;AAAA,IACC;AAAA,IACA;AAAA,EACF,EACC,OAAO,OAAO,WAAmB,SAAsB;AACtD,QAAI;AACF,YAAM,WAAW,WAAW,IAAI;AAAA,IAClC,SAAS,KAAc;AACrB,YAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,cAAQ,MAAM,UAAU,OAAO,EAAE;AACjC,cAAQ,WAAW;AAAA,IACrB;AAAA,EACF,CAAC;AACL;AAEA,eAAe,WAAW,WAAmB,MAAkC;AAC7E,QAAM,gBAAgB,QAAQ,SAAS;AAGvC,QAAM,UAAoB,KAAK,UAAU,aAAa,KAAK,OAAO,IAAI,CAAC,GAAG,WAAW;AAGrF,QAAM,YAAY,KAAK,YAAY,QAAQ,KAAK,SAAS,IAAI,QAAQ,aAAa;AAClF,QAAM,gBAAgB,SAAS,aAAa;AAC5C,QAAM,WAAWC,SAAQ,aAAa;AACtC,QAAM,WAAW,WAAW,cAAc,MAAM,GAAG,CAAC,SAAS,MAAM,IAAI;AAGvE,QAAM,MAAM,WAAW,EAAE,WAAW,KAAK,CAAC;AAG1C,MAAI,KAAK,OAAO;AACd,UAAM,EAAE,mBAAmB,IAAI,MAAM,OAAO,2BAA2B;AACvE,UAAM,SAAS,mBAAmB;AAClC,QAAI,CAAC,OAAO,SAAS,KAAK,KAAK,GAAG;AAChC,YAAM,IAAI,MAAM,kBAAkB,KAAK,KAAK,iBAAiB,OAAO,KAAK,IAAI,CAAC,EAAE;AAAA,IAClF;AAAA,EACF;AAEA,MAAI,KAAK,WAAW;AAClB,UAAM,EAAE,qBAAqB,IAAI,MAAM,OAAO,6BAA6B;AAC3E,UAAM,SAAS,qBAAqB;AACpC,QAAI,CAAC,OAAO,SAAS,KAAK,SAAS,GAAG;AACpC,YAAM,IAAI;AAAA,QACR,4BAA4B,KAAK,SAAS,iBAAiB,OAAO,KAAK,IAAI,CAAC;AAAA,MAC9E;AAAA,IACF;AAAA,EACF;AAEA,UAAQ,MAAM,YAAY,aAAa,EAAE;AACzC,QAAM,SAAS,MAAM,UAAU,aAAa;AAC5C,QAAM,EAAE,UAAU,IAAI;AAEtB,MAAI,CAAC,OAAO,aAAa;AACvB,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAGA,MAAI,oBAAoB,OAAO;AAC/B,MAAI,KAAK,WAAW;AAClB,wBAAoB,MAAM;AAAA,MACxB,OAAO;AAAA,MACP;AAAA,MACA,KAAK;AAAA,MACL,KAAK;AAAA,IACP;AACA,YAAQ,MAAM,wBAAwB,KAAK,SAAS,EAAE;AAAA,EACxD;AAEA,QAAM,UAAU,KAAK;AAErB,aAAW,UAAU,SAAS;AAC5B,UAAM,UAAUC,MAAK,WAAW,GAAG,QAAQ,IAAI,MAAM,EAAE;AAEvD,YAAQ,QAAQ;AAAA,MACd,KAAK,QAAQ;AACX,cAAM,EAAE,kBAAkB,IAAI,MAAM,OAAO,gCAAgC;AAC3E,cAAM,MAAM,MAAM,kBAAkB,mBAAmB,EAAE,QAAQ,CAAC;AAClE,cAAM,UAAU,SAAS,OAAO,KAAK,GAAG,CAAC;AACzC;AAAA,MACF;AAAA,MAEA,KAAK,QAAQ;AACX,cAAM,EAAE,kBAAkB,IAAI,MAAM,OAAO,gCAAgC;AAE3E,cAAM,SAAS,MAAM,uBAAuB,SAAS;AACrD,cAAM,MAAM,MAAM,kBAAkB,mBAAmB,EAAE,SAAS,OAAO,CAAC;AAC1E,cAAM,UAAU,SAAS,OAAO,KAAK,GAAG,CAAC;AACzC;AAAA,MACF;AAAA,MAEA,KAAK,OAAO;AACV,cAAM,EAAE,iBAAiB,IAAI,MAAM,OAAO,+BAA+B;AACzE,cAAM,MAAM,MAAM,iBAAiB,mBAAmB,EAAE,QAAQ,CAAC;AACjE,cAAM,UAAU,SAAS,OAAO,KAAK,GAAG,CAAC;AACzC;AAAA,MACF;AAAA,MAEA,KAAK,QAAQ;AACX,cAAM,EAAE,cAAc,IAAI,MAAM,OAAO,uBAAuB;AAC9D,cAAM,EAAE,UAAU,IAAI,MAAM,OAAO,gCAAgC;AACnE,cAAM,EAAE,cAAc,IAAI,MAAM,OAAO,2CAA2C;AAElF,cAAM,MAAM,cAAc,iBAAiB;AAC3C,cAAM,SAAS,MAAM,qBAAqB,KAAK,SAAS;AAExD,cAAM,OAAO,UAAU,KAAK;AAAA,UAC1B,cAAc;AAAA,UACd;AAAA,UACA,OAAO;AAAA,UACP,MAAM;AAAA,UACN;AAAA,QACF,CAAC;AACD,cAAM,UAAU,SAAS,MAAM,OAAO;AACtC;AAAA,MACF;AAAA,MAEA,KAAK,WAAW;AACd,cAAM,EAAE,cAAc,IAAI,MAAM,OAAO,uBAAuB;AAC9D,cAAM,EAAE,aAAa,IAAI,MAAM,OAAO,gCAAgC;AACtE,cAAM,EAAE,cAAc,IAAI,MAAM,OAAO,2CAA2C;AAElF,cAAM,MAAM,cAAc,iBAAiB;AAC3C,cAAM,SAAS,MAAM,qBAAqB,KAAK,SAAS;AAExD,cAAM,OAAO,MAAM,aAAa,KAAK;AAAA,UACnC,cAAc;AAAA,UACd;AAAA,UACA,OAAO;AAAA,UACP,MAAM;AAAA,UACN;AAAA,QACF,CAAC;AACD,cAAM,MAAM,OAAO,KAAK,MAAM,KAAK,YAAY,CAAC;AAChD,cAAM,UAAU,QAAQ,QAAQ,cAAc,WAAW,GAAG,GAAG;AAC/D;AAAA,MACF;AAAA,MAEA,KAAK,QAAQ;AACX,cAAM,EAAE,kBAAkB,IAAI,MAAM,OAAO,gCAAgC;AAC3E,cAAM,SAAS,MAAM,uBAAuB,SAAS;AAGrD,cAAM,YAAY,oBAAI,IAAyB;AAC/C,cAAM,gBAAgB,OAAO,KAAK,OAAO;AACzC,YAAI,eAAe,QAAQ;AACzB,qBAAW,OAAO,eAAe;AAC/B,kBAAM,OAAO,MAAM,UAAU,SAAS,IAAI,GAAG;AAC7C,gBAAI,KAAM,WAAU,IAAI,IAAI,KAAK,IAAI;AAAA,UACvC;AAAA,QACF;AAEA,cAAM,eAAe,UAAU,OAAO;AACtC,cAAM,MAAM,MAAM,kBAAkB,mBAAmB;AAAA,UACrD,OAAO;AAAA,UACP;AAAA,UACA;AAAA,UACA,OAAO,eAAe,YAAY;AAAA,UAClC,eAAe,eAAe,gBAAgB;AAAA,UAC9C,eAAe,eAAe,OAAO,KAAK,WAAW;AAAA,QACvD,CAAC;AACD,cAAM,UAAU,SAAS,OAAO,KAAK,GAAG,CAAC;AACzC;AAAA,MACF;AAAA,MAEA,KAAK,OAAO;AACV,cAAM,EAAE,eAAe,IAAI,MAAM,OAAO,qCAAqC;AAC7E,cAAM,OAAO,MAAM,eAAe,SAAS;AAC3C,cAAM,MAAM,MAAM,KAAK,YAAY;AACnC,cAAM,UAAU,SAAS,OAAO,KAAK,GAAG,CAAC;AACzC;AAAA,MACF;AAAA,IACF;AAEA,YAAQ,MAAM,YAAO,OAAO,EAAE;AAAA,EAChC;AAEA,UAAQ,MAAM,OAAO;AACvB;AAUA,eAAe,yBACb,aACA,WACA,gBACA,SAC2B;AAC3B,QAAM,EAAE,eAAe,cAAc,IAAI,MAAM,OAAO,uBAAuB;AAC7E,QAAM,EAAE,gBAAgB,iBAAiB,IAAI,MAAM,OAAO,6BAA6B;AAEvF,QAAM,MAAM,cAAc,WAAW;AAGrC,QAAM,SAAS,iBAAiB,IAAI,MAAM;AAE1C,QAAM,SAAS,eAAe,KAAK,gBAAgB;AAAA,IACjD;AAAA,IACA;AAAA,EACF,CAAC;AAED,SAAO,cAAc,OAAO,GAAG;AACjC;AAWA,eAAe,qBACb,KACA,WACmC;AACnC,QAAM,EAAE,mBAAmB,gBAAgB,IAAI,MAAM,OAAO,gCAAgC;AAE5F,QAAM,SAAS,oBAAI,IAAyB;AAC5C,QAAM,WAAW,kBAAkB,GAAG;AAGtC,QAAM,iBAAiB,oBAAI,IAAY;AACvC,aAAW,WAAW,UAAU;AAC9B,UAAM,OAAO,MAAM,UAAU,SAAS,OAAO;AAC7C,QAAI,MAAM;AACR,aAAO,IAAI,SAAS,IAAI;AACxB,YAAM,WAAW,gBAAgB,OAAO;AACxC,UAAI,aAAa,QAAS,QAAO,IAAI,UAAU,IAAI;AAAA,IACrD,OAAO;AACL,qBAAe,IAAI,gBAAgB,OAAO,CAAC;AAAA,IAC7C;AAAA,EACF;AAIA,MAAI,eAAe,OAAO,GAAG;AAC3B,UAAM,QAAQ,MAAM,UAAU,UAAU;AACxC,eAAW,QAAQ,OAAO;AACxB,YAAM,WAAW,gBAAgB,KAAK,IAAI;AAC1C,UAAI,eAAe,IAAI,QAAQ,GAAG;AAChC,cAAM,OAAO,MAAM,UAAU,SAAS,KAAK,IAAI;AAC/C,YAAI,MAAM;AACR,iBAAO,IAAI,UAAU,IAAI;AACzB,iBAAO,IAAI,KAAK,MAAM,IAAI;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAKA,eAAe,uBACb,WACmC;AACnC,QAAM,SAAS,oBAAI,IAAyB;AAC5C,QAAM,QAAQ,MAAM,UAAU,UAAU;AACxC,aAAW,QAAQ,OAAO;AACxB,QAAI,2CAA2C,KAAK,KAAK,IAAI,GAAG;AAC9D,YAAM,OAAO,MAAM,UAAU,SAAS,KAAK,IAAI;AAC/C,UAAI,MAAM;AACR,eAAO,IAAI,KAAK,MAAM,IAAI;AAAA,MAC5B;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;;;AErUA,SAAS,SAAAC,cAAa;AACtB,SAAS,WAAAC,UAAS,YAAAC,WAAU,WAAAC,UAAS,WAAAC,gBAAe;;;ACapD,SAAS,0BAA0B;AACnC,SAAS,yBAAyB;;;AChBlC,SAAS,gBAAgB;AAOzB,eAAsB,eAAuC;AAC3D,QAAM,UAAU,QAAQ,aAAa,UAAU,UAAU;AAEzD,SAAO,IAAI,QAAQ,CAACC,aAAY;AAC9B,aAAS,SAAS,CAAC,QAAQ,GAAG,EAAE,SAAS,IAAK,GAAG,CAAC,KAAK,WAAW;AAChE,UAAI,OAAO,CAAC,OAAO,KAAK,GAAG;AACzB,QAAAA,SAAQ,IAAI;AACZ;AAAA,MACF;AAEA,YAAM,OAAO,OAAO,KAAK,EAAE,MAAM,IAAI,EAAE,CAAC,EAAE,KAAK;AAC/C,MAAAA,SAAQ,IAAI;AAAA,IACd,CAAC;AAAA,EACH,CAAC;AACH;;;ADAA,SAAS,0BAAAC,+BAA8B;AAgEvC,eAAsB,eACpB,KACA,WACA,SAC+B;AAC/B,QAAM;AAAA,IACJ;AAAA,IACA,MAAM;AAAA,IACN,UAAU;AAAA,IACV,cAAc;AAAA,IACd;AAAA,IACA,eAAe;AAAA,IACf;AAAA,EACF,IAAI;AAEJ,QAAM,aAAa,kBAAkB;AAAA,IACnC;AAAA,IACA,OAAO,QAAQ;AAAA,IACf,QAAQ,QAAQ;AAAA,EAClB,CAAC;AAGD,QAAM,aAAa,MAAM,aAAa;AACtC,MAAI,CAAC,YAAY;AACf,UAAM,IAAI;AAAA,MACR;AAAA,IAKF;AAAA,EACF;AAEA,eAAa,oBAAoB,CAAC;AAGlC,QAAM,EAAE,kBAAkB,IAAI,MAAM,OAAO,gCAAgC;AAC3E,QAAM,aAAa,kBAAkB,GAAG;AACxC,QAAM,SAAS,oBAAI,IAAyB;AAC5C,aAAW,WAAW,YAAY;AAChC,UAAM,OAAO,MAAM,UAAU,SAAS,OAAO;AAC7C,QAAI,MAAM;AACR,aAAO,IAAI,SAAS,IAAI;AAAA,IAC1B;AAAA,EACF;AAGA,QAAM,QAAQ,oBAAI,IAAyB;AAC3C,QAAM,eAA8B,CAAC;AACrC,MAAI,IAAI,OAAO,UAAU,QAAQ;AAC/B,eAAW,OAAO,IAAI,MAAM,UAAU;AACpC,YAAM,OAAO,MAAM,UAAU,SAAS,IAAI,GAAG;AAC7C,UAAI,MAAM;AACR,cAAM,IAAI,IAAI,KAAK,IAAI;AACvB,cAAM,IAAI,IAAI,MAAM,IAAI;AACxB,qBAAa,KAAK,IAAI;AAAA,MACxB;AAAA,IACF;AAAA,EACF;AAGA,MAAI,oBAAuC;AAC3C,MAAI,aAAa,SAAS,GAAG;AAC3B,wBAAoB,MAAM,wBAAwB,cAAc,UAAU;AAAA,EAC5E;AAEA,eAAa,0BAA0B,EAAE;AAGzC,QAAM,EAAE,cAAc,IAAI,MAAM,OAAO,2CAA2C;AAClF,QAAM,aAAa,mBAAmB,KAAK;AAAA,IACzC,cAAc;AAAA,IACd;AAAA,IACA,OAAO,MAAM,OAAO,IAAI,QAAQ;AAAA,IAChC,OAAO,WAAW;AAAA,IAClB,QAAQ,WAAW;AAAA,IACnB;AAAA,EACF,CAAC;AAED,eAAa,qBAAqB,EAAE;AAGpC,QAAM,EAAE,SAAS,IAAI,MAAM,OAAO,iBAAiB;AACnD,QAAM,UAAU,MAAM,SAAS,OAAO,EAAE,UAAU,KAAK,CAAC;AACxD,QAAM,OAAO,MAAM,QAAQ,QAAQ;AAAA,IACjC,UAAU,EAAE,OAAO,WAAW,OAAO,QAAQ,WAAW,OAAO;AAAA,EACjE,CAAC;AAED,QAAM,aAAuB,CAAC;AAC9B,OAAK,GAAG,aAAa,CAAC,QAAQ,WAAW,KAAK,IAAI,OAAO,CAAC;AAE1D,QAAM,KAAK,WAAW,YAAY,EAAE,WAAW,OAAO,CAAC;AACvD,QAAM,KAAK,eAAe,GAAG;AAE7B,MAAI;AACF,UAAM,KAAK;AAAA,MACT,MAAM,OAAQ,OAA8C,gBAAgB;AAAA,MAC5E,EAAE,SAAS,KAAM;AAAA,IACnB;AAAA,EACF,QAAQ;AACN,UAAM,QAAQ,MAAM;AACpB,UAAM,cAAc,WAAW,SAC3B;AAAA;AAAA,IAAqB,WAAW,KAAK,MAAM,CAAC,KAC5C;AACJ,UAAM,IAAI,MAAM,mDAAmD,WAAW,EAAE;AAAA,EAClF;AAEA,QAAM,cAAsB,MAAM,KAAK,SAAS,MAAM;AACpD,WAAQ,OAAoD,YAAY;AAAA,EAC1E,CAAC;AAED,MAAI,eAAe,GAAG;AACpB,UAAM,QAAQ,MAAM;AACpB,UAAM,IAAI,MAAM,qDAAgD;AAAA,EAClE;AAEA,QAAM,kBAAkB,KAAK,KAAK,cAAc,GAAG;AACnD,QAAM,oBAAoB,KAAK,KAAK,eAAe,GAAG;AACtD,QAAM,cAAc,oBAAoB;AACxC,QAAM,SAAuB,CAAC;AAE9B,eAAa,oBAAoB,EAAE;AAGnC,MAAI,oBAAoB,GAAG;AACzB,UAAM,WAAoB,MAAM,KAAK,SAAS,MAAM;AAClD,YAAM,IAAI;AACV,aAAO,OAAO,EAAE,kBAAkB,aAAa,EAAE,cAAc,IAAI;AAAA,IACrE,CAAC;AAED,QAAI,UAAU;AACZ,YAAM,KAAK,SAAS,MAAM;AACxB,QAAC,OAAgD,UAAU;AAAA,MAC7D,CAAC;AACD,YAAM,KAAK,eAAe,GAAG;AAC7B,YAAM,aAAa,IAAI,WAAW,MAAM,KAAK,WAAW,EAAE,MAAM,MAAM,CAAC,CAAC;AACxE,eAAS,IAAI,GAAG,IAAI,mBAAmB,KAAK;AAC1C,eAAO,KAAK,UAAU;AAAA,MACxB;AACA,YAAM,KAAK,SAAS,MAAM;AACxB,QAAC,OAAgD,UAAU;AAAA,MAC7D,CAAC;AAAA,IACH;AAAA,EACF;AAGA,QAAM,gBAAgB,IAAI;AAC1B,WAAS,IAAI,GAAG,IAAI,iBAAiB,KAAK;AACxC,UAAM,OAAO,IAAI;AACjB,UAAM,KAAK,SAAS,CAAC,MAAc;AACjC,aAAQ,OAA+D,OAAO,CAAC;AAAA,IACjF,GAAG,IAAI;AAEP,UAAM,aAAa,MAAM,KAAK,WAAW,EAAE,MAAM,MAAM,CAAC;AACxD,WAAO,KAAK,IAAI,WAAW,UAAU,CAAC;AAGtC,QAAI,IAAI,KAAK,IAAI,GAAG,KAAK,MAAM,MAAM,CAAC,CAAC,MAAM,KAAK,MAAM,kBAAkB,GAAG;AAC3E,YAAM,MAAM,KAAK,KAAK,MAAO,OAAO,SAAS,cAAe,EAAE;AAC9D,mBAAa,oBAAoB,GAAG;AAAA,IACtC;AAAA,EACF;AAEA,QAAM,QAAQ,MAAM;AAEpB,eAAa,kBAAkB,EAAE;AAGjC,MAAI,gBAAgB;AACpB,MAAI,eAAe,KAAK,mBAAmB;AAEzC,oBAAgB,MAAM,cAAc,YAAY,mBAAmB,YAAY;AAAA,EACjF;AAEA,QAAM,EAAE,kBAAkB,IAAI,MAAM,OAAO,6BAAyB;AACpE,QAAM,kBAAkB,YAAY,QAAQ,eAAe,YAAY;AAAA,IACrE;AAAA,IACA;AAAA,IACA;AAAA,IACA,OAAO,WAAW;AAAA,IAClB,QAAQ,WAAW;AAAA,IACnB,YAAY,CAAC,SAAS,UAAU;AAC9B,mBAAa,aAAa,KAAK,IAAI,KAAK,KAAK,MAAM,UAAU,GAAG,CAAC;AAAA,IACnE;AAAA,EACF,CAAC;AAED,eAAa,QAAQ,GAAG;AAExB,QAAM,gBAAgB,cAAc;AACpC,SAAO;AAAA,IACL,UAAU;AAAA,IACV,YAAY,OAAO;AAAA,IACnB;AAAA,EACF;AACF;AAQA,eAAe,wBACb,SACA,YACqB;AACrB,MAAI,QAAQ,WAAW,EAAG,QAAO,IAAI,WAAW,CAAC;AACjD,MAAI,QAAQ,WAAW,EAAG,QAAO,IAAI,WAAW,QAAQ,CAAC,CAAC;AAE1D,MAAI,YAAY;AACd,WAAO,uBAAuB,YAAY,OAAO;AAAA,EACnD;AAGA,QAAM,cAAc,QAAQ,OAAO,CAAC,KAAK,MAAM,MAAM,EAAE,YAAY,CAAC;AACpE,QAAM,SAAS,IAAI,WAAW,WAAW;AACzC,MAAI,SAAS;AACb,aAAW,OAAO,SAAS;AACzB,WAAO,IAAI,IAAI,WAAW,GAAG,GAAG,MAAM;AACtC,cAAU,IAAI;AAAA,EAChB;AACA,SAAO;AACT;AAEA,eAAe,uBACb,YACA,SACqB;AACrB,QAAM,EAAE,WAAAC,YAAW,UAAAC,WAAU,OAAAC,QAAO,GAAG,IAAI,MAAM,OAAO,aAAkB;AAC1E,QAAM,EAAE,MAAAC,MAAK,IAAI,MAAM,OAAO,MAAW;AACzC,QAAM,EAAE,OAAO,IAAI,MAAM,OAAO,IAAS;AACzC,QAAM,EAAE,YAAY,IAAI,MAAM,OAAO,QAAa;AAClD,QAAM,EAAE,UAAAC,UAAS,IAAI,MAAM,OAAO,eAAoB;AAEtD,QAAM,QAAQ,YAAY,CAAC,EAAE,SAAS,KAAK;AAC3C,QAAM,UAAUD,MAAK,OAAO,GAAG,uBAAuB,KAAK,EAAE;AAC7D,QAAMD,OAAM,SAAS,EAAE,WAAW,KAAK,CAAC;AAExC,MAAI;AACF,UAAM,eAAyB,CAAC;AAChC,aAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,YAAM,UAAUC,MAAK,SAAS,OAAO,CAAC,MAAM;AAC5C,YAAMH,WAAU,SAAS,IAAI,WAAW,QAAQ,CAAC,CAAC,CAAC;AACnD,mBAAa,KAAK,OAAO;AAAA,IAC3B;AAEA,UAAM,WAAWG,MAAK,SAAS,iBAAiB;AAChD,UAAM,cAAc,aAAa,IAAI,CAAC,MAAM,SAAS,EAAE,QAAQ,OAAO,GAAG,CAAC,GAAG,EAAE,KAAK,IAAI;AACxF,UAAMH,WAAU,UAAU,WAAW;AAErC,UAAM,aAAaG,MAAK,SAAS,cAAc;AAC/C,UAAM,IAAI,QAAc,CAACE,UAAS,WAAW;AAC3C,MAAAD;AAAA,QACE;AAAA,QACA,CAAC,MAAM,MAAM,UAAU,SAAS,KAAK,MAAM,UAAU,MAAM,QAAQ,UAAU;AAAA,QAC7E,EAAE,SAAS,KAAQ;AAAA,QACnB,CAAC,QAAQ;AACP,cAAI,IAAK,QAAO,IAAI,MAAM,+BAA+B,IAAI,OAAO,EAAE,CAAC;AAAA,cAClE,CAAAC,SAAQ;AAAA,QACf;AAAA,MACF;AAAA,IACF,CAAC;AAED,UAAM,OAAO,MAAMJ,UAAS,UAAU;AACtC,WAAO,IAAI,WAAW,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU;AAAA,EACrE,UAAE;AACA,UAAM,GAAG,SAAS,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC;AAAA,EACpD;AACF;AAKA,eAAe,cACb,YACA,WACA,WACqB;AACrB,QAAM,EAAE,WAAAD,YAAW,UAAAC,WAAU,GAAG,IAAI,MAAM,OAAO,aAAkB;AACnE,QAAM,EAAE,MAAAE,MAAK,IAAI,MAAM,OAAO,MAAW;AACzC,QAAM,EAAE,OAAO,IAAI,MAAM,OAAO,IAAS;AACzC,QAAM,EAAE,YAAY,IAAI,MAAM,OAAO,QAAa;AAClD,QAAM,EAAE,UAAAC,UAAS,IAAI,MAAM,OAAO,eAAoB;AAEtD,QAAM,QAAQ,YAAY,CAAC,EAAE,SAAS,KAAK;AAC3C,QAAM,YAAYD,MAAK,OAAO,GAAG,yBAAyB,KAAK,MAAM;AACrE,QAAM,aAAaA,MAAK,OAAO,GAAG,0BAA0B,KAAK,MAAM;AAEvE,MAAI;AACF,UAAMH,WAAU,WAAW,SAAS;AACpC,UAAM,UAAU,KAAK,MAAM,YAAY,GAAI;AAE3C,UAAM,IAAI,QAAc,CAACK,UAAS,WAAW;AAC3C,MAAAD;AAAA,QACE;AAAA,QACA;AAAA,UACE;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA,UAAU,OAAO,IAAI,OAAO;AAAA,UAC5B;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,QACF;AAAA,QACA,EAAE,SAAS,IAAO;AAAA,QAClB,CAAC,QAAQ;AACP,cAAI,IAAK,QAAO,IAAI,MAAM,8BAA8B,IAAI,OAAO,EAAE,CAAC;AAAA,cACjE,CAAAC,SAAQ;AAAA,QACf;AAAA,MACF;AAAA,IACF,CAAC;AAED,UAAM,OAAO,MAAMJ,UAAS,UAAU;AACtC,WAAO,IAAI,WAAW,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU;AAAA,EACrE,UAAE;AACA,UAAM,GAAG,WAAW,EAAE,OAAO,KAAK,CAAC;AACnC,UAAM,GAAG,YAAY,EAAE,OAAO,KAAK,CAAC;AAAA,EACtC;AACF;;;ADhYA,IAAM,kBAAkB,CAAC,SAAS,UAAU,MAAM;AAClD,IAAM,qBAAqB,CAAC,aAAa,UAAU;AACnD,IAAM,iBAAiB,CAAC,OAAO,YAAY,QAAQ;AAE5C,SAAS,qBAAqBK,UAAwB;AAC3D,EAAAA,SACG,QAAQ,OAAO,EACf,YAAY,uCAAuC,EACnD,SAAS,WAAW,kDAAkD,EACtE,SAAS,YAAY,wCAAwC,EAC7D,OAAO,uBAAuB,wCAAwC,EACtE,OAAO,kBAAkB,mCAAmC,IAAI,EAChE;AAAA,IACC;AAAA,IACA,qBAAqB,gBAAgB,KAAK,IAAI,CAAC;AAAA,IAC/C;AAAA,EACF,EACC;AAAA,IACC;AAAA,IACA,sBAAsB,mBAAmB,KAAK,IAAI,CAAC;AAAA,IACnD;AAAA,EACF,EACC;AAAA,IACC;AAAA,IACA,kBAAkB,eAAe,KAAK,IAAI,CAAC;AAAA,IAC3C;AAAA,EACF,EACC,OAAO,oBAAoB,sBAAsB,EACjD,OAAO,qBAAqB,uBAAuB,EACnD,OAAO,OAAO,WAAmB,WAA+B,SAA8B;AAC7F,QAAI;AAEF,UAAI,aAAa,CAAC,KAAK,QAAQ;AAC7B,aAAK,SAAS;AAAA,MAChB;AACA,YAAM,SAAS,WAAW,IAAI;AAAA,IAChC,SAAS,KAAc;AACrB,YAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,cAAQ,MAAM,UAAU,OAAO,EAAE;AACjC,cAAQ,WAAW;AAAA,IACrB;AAAA,EACF,CAAC;AACL;AAEA,eAAe,SAAS,WAAmB,MAA0C;AACnF,QAAM,gBAAgBC,SAAQ,SAAS;AAGvC,QAAM,MAAM,SAAS,KAAK,OAAO,MAAM,EAAE;AACzC,MAAI,MAAM,GAAG,KAAK,MAAM,KAAK,MAAM,KAAK;AACtC,UAAM,IAAI,MAAM,wCAAwC;AAAA,EAC1D;AAEA,QAAM,UAAU,KAAK,WAAW;AAChC,MAAI,CAAC,gBAAgB,SAAS,OAA2C,GAAG;AAC1E,UAAM,IAAI,MAAM,oBAAoB,OAAO,aAAa,gBAAgB,KAAK,IAAI,CAAC,EAAE;AAAA,EACtF;AAEA,QAAM,cAAc,KAAK,eAAe;AACxC,MAAI,CAAC,mBAAmB,SAAS,WAAkD,GAAG;AACpF,UAAM,IAAI;AAAA,MACR,wBAAwB,WAAW,aAAa,mBAAmB,KAAK,IAAI,CAAC;AAAA,IAC/E;AAAA,EACF;AAEA,QAAM,WAAW,KAAK,YAAY;AAClC,MAAI,CAAC,eAAe,SAAS,QAA2C,GAAG;AACzE,UAAM,IAAI,MAAM,qBAAqB,QAAQ,aAAa,eAAe,KAAK,IAAI,CAAC,EAAE;AAAA,EACvF;AACA,QAAM,eAAe,aAAa,QAAQ,SAAa;AAGvD,QAAM,gBAAgBC,UAAS,aAAa;AAC5C,QAAM,WAAWC,SAAQ,aAAa;AACtC,QAAM,WAAW,WAAW,cAAc,MAAM,GAAG,CAAC,SAAS,MAAM,IAAI;AACvE,QAAM,aAAa,KAAK,SACpBF,SAAQ,KAAK,MAAM,IACnBA,SAAQG,SAAQ,aAAa,GAAG,GAAG,QAAQ,MAAM;AAGrD,QAAMC,OAAMD,SAAQ,UAAU,GAAG,EAAE,WAAW,KAAK,CAAC;AAGpD,UAAQ,MAAM,YAAY,aAAa,EAAE;AACzC,QAAM,SAAS,MAAM,UAAU,aAAa;AAC5C,QAAM,EAAE,UAAU,IAAI;AAGtB,MAAI;AACJ,MAAI,OAAO,KAAK;AACd,YAAQ,MAAM,0BAA0B;AACxC,UAAM,OAAO;AAAA,EACf,WAAW,OAAO,aAAa;AAC7B,UAAM,EAAE,cAAc,IAAI,MAAM,OAAO,uBAAuB;AAC9D,UAAM,cAAc,OAAO,WAAW;AAAA,EACxC,OAAO;AACL,UAAM,IAAI,MAAM,4BAA4B;AAAA,EAC9C;AAEA,UAAQ;AAAA,IACN,cAAc,GAAG,kBAAkB,OAAO,kBAAkB,WAAW,eAAe,QAAQ;AAAA,EAChG;AAGA,QAAM,UAAU,MAAM,eAAe,KAAK,WAAW;AAAA,IACnD;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,OAAO,KAAK,QAAQ,SAAS,KAAK,OAAO,EAAE,IAAI;AAAA,IAC/C,QAAQ,KAAK,SAAS,SAAS,KAAK,QAAQ,EAAE,IAAI;AAAA,IAClD;AAAA,IACA,cAAc;AAAA,IACd,YAAY,CAAC,OAAO,YAAY;AAC9B,oBAAc,OAAO,SAAS,GAAG;AAAA,IACnC;AAAA,EACF,CAAC;AAED,gBAAc;AACd,UAAQ;AAAA,IACN,YAAO,UAAU,KAAK,QAAQ,SAAS,QAAQ,CAAC,CAAC,MAAM,QAAQ,UAAU;AAAA,EAC3E;AACA,UAAQ,MAAM,OAAO;AACvB;AAIA,IAAM,YAAY;AAElB,SAAS,cAAc,OAAe,SAAiB,OAAe,QAAuB;AAC3F,QAAM,MAAM,KAAK,IAAI,KAAK,KAAK,MAAO,UAAU,QAAS,GAAG,CAAC;AAC7D,QAAM,SAAS,KAAK,MAAO,MAAM,MAAO,SAAS;AACjD,QAAM,MAAM,SAAI,OAAO,MAAM,IAAI,SAAI,OAAO,YAAY,MAAM;AAC9D,QAAM,SAAS,SAAS,KAAK,MAAM,MAAM;AACzC,UAAQ,OAAO,MAAM,OAAO,KAAK,KAAK,GAAG,IAAI,GAAG,IAAI,MAAM,IAAI;AAChE;AAEA,SAAS,gBAAsB;AAC7B,UAAQ,OAAO,MAAM,OAAO,IAAI,OAAO,EAAE,IAAI,IAAI;AACnD;;;AH1JA,QAAQ;AAAA,EACN;AACF;AAEA,IAAM,UAAU,IAAI,QAAQ;AAE5B,QACG,KAAK,QAAQ,EACb,YAAY,gEAA2D,EACvE,QAAQ,OAAO;AAElB,uBAAuB,OAAO;AAC9B,qBAAqB,OAAO;AAE5B,QAAQ,MAAM;","names":["extname","join","program","extname","join","mkdir","dirname","basename","extname","resolve","resolve","MemoryContentContainer","writeFile","readFile","mkdir","join","execFile","resolve","program","resolve","basename","extname","dirname","mkdir"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/commands/convert.ts","../src/util/readInput.ts","../src/commands/video.ts","../src/api.ts","../src/util/detectFfmpeg.ts","../src/commands/validate.ts"],"sourcesContent":["/**\n * squisq CLI\n *\n * Command-line tool for converting and processing Squisq documents.\n * Designed for easy addition of future subcommands (e.g., import).\n *\n * Usage:\n * squisq convert <input> [options]\n * squisq --help\n */\n\nimport { Command } from 'commander';\nimport { registerConvertCommand } from './commands/convert.js';\nimport { registerVideoCommand } from './commands/video.js';\nimport { registerValidateCommand } from './commands/validate.js';\n\n// Colored banner: cyan brackets, bold white text, dim version\nconsole.error(\n '\\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',\n);\n\nconst program = new Command();\n\nprogram\n .name('squisq')\n .description('Squisq CLI — convert and process markdown-based documents')\n .version('1.0.0');\n\nregisterConvertCommand(program);\nregisterVideoCommand(program);\nregisterValidateCommand(program);\n\nprogram.parse();\n","/**\n * convert command\n *\n * Reads a markdown file, ZIP/DBK container, or folder and exports to\n * all supported formats: DOCX, PPTX, PDF, HTML, and container ZIP (.dbk).\n *\n * Supports optional --theme and --transform flags to apply a squisq theme\n * and/or transform style before exporting.\n *\n * Usage:\n * squisq convert <input> [--output-dir <dir>] [--formats <list>] [--theme <id>] [--transform <style>]\n */\n\nimport { writeFile, mkdir } from 'node:fs/promises';\nimport { dirname, basename, extname, join, resolve } from 'node:path';\nimport type { Command } from 'commander';\nimport type { MarkdownDocument } from '@bendyline/squisq/markdown';\nimport type { ContentContainer } from '@bendyline/squisq/storage';\nimport { readInput } from '../util/readInput.js';\n\nconst ALL_FORMATS = ['docx', 'pptx', 'pdf', 'html', 'htmlzip', 'epub', 'dbk'] as const;\ntype Format = (typeof ALL_FORMATS)[number];\n\nfunction parseFormats(value: string): Format[] {\n const requested = value.split(',').map((s) => s.trim().toLowerCase());\n const valid: Format[] = [];\n for (const r of requested) {\n if (ALL_FORMATS.includes(r as Format)) {\n valid.push(r as Format);\n } else {\n console.warn(`Unknown format \"${r}\" — skipping. Valid: ${ALL_FORMATS.join(', ')}`);\n }\n }\n if (valid.length === 0) {\n throw new Error(`No valid formats specified. Valid: ${ALL_FORMATS.join(', ')}`);\n }\n return valid;\n}\n\ninterface ConvertOpts {\n outputDir?: string;\n formats?: string;\n theme?: string;\n transform?: string;\n autoTemplates?: boolean;\n}\n\nexport function registerConvertCommand(program: Command): void {\n program\n .command('convert')\n .description('Convert a markdown document to DOCX, PPTX, PDF, HTML, and DBK container formats')\n .argument('<input>', 'Path to .md file, .zip/.dbk container, or folder')\n .option('-o, --output-dir <dir>', 'Output directory (default: same as input)')\n .option(\n '-f, --formats <list>',\n `Comma-separated formats to produce (default: all). Valid: ${ALL_FORMATS.join(', ')}`,\n )\n .option('-t, --theme <id>', 'Squisq theme ID to apply (e.g., documentary, cinematic, bold)')\n .option(\n '--transform <style>',\n 'Transform style to apply before export (e.g., documentary, magazine, minimal)',\n )\n .option(\n '--no-auto-templates',\n 'Disable content-aware template auto-picking for unannotated headings',\n )\n .action(async (inputPath: string, opts: ConvertOpts) => {\n try {\n await runConvert(inputPath, opts);\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n console.error(`Error: ${message}`);\n process.exitCode = 1;\n }\n });\n}\n\nasync function runConvert(inputPath: string, opts: ConvertOpts): Promise<void> {\n const resolvedInput = resolve(inputPath);\n\n // Determine which formats to produce\n const formats: Format[] = opts.formats ? parseFormats(opts.formats) : [...ALL_FORMATS];\n\n // Determine output directory and base filename\n const outputDir = opts.outputDir ? resolve(opts.outputDir) : dirname(resolvedInput);\n const inputBasename = basename(resolvedInput);\n const inputExt = extname(inputBasename);\n const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;\n\n // Ensure output directory exists\n await mkdir(outputDir, { recursive: true });\n\n // Validate the transform ID up front. The theme id is validated after the\n // doc is read (below), so a custom theme inlined in the doc's frontmatter\n // is admitted rather than rejected as \"unknown\".\n if (opts.transform) {\n const { getTransformStyleIds } = await import('@bendyline/squisq/transform');\n const styles = getTransformStyleIds();\n if (!styles.includes(opts.transform)) {\n throw new Error(\n `Unknown transform style \"${opts.transform}\". Available: ${styles.join(', ')}`,\n );\n }\n }\n\n console.error(`Reading: ${resolvedInput}`);\n const result = await readInput(resolvedInput);\n const { container } = result;\n\n if (!result.markdownDoc) {\n throw new Error(\n 'Convert command requires a markdown document. JSON Doc input is not supported for convert — use the video command instead.',\n );\n }\n\n // Validate the theme id now that we have the doc: accept built-ins AND any\n // custom themes inlined in the doc's `squisq-custom-themes` frontmatter.\n if (opts.theme) {\n const { getAvailableThemes } = await import('@bendyline/squisq/schemas');\n const { readCustomThemesFromFrontmatter } = await import('@bendyline/squisq/doc');\n const builtins = getAvailableThemes();\n const customIds = (readCustomThemesFromFrontmatter(result.markdownDoc.frontmatter) ?? []).map(\n (t) => t.id,\n );\n if (!builtins.includes(opts.theme) && !customIds.includes(opts.theme)) {\n throw new Error(\n `Unknown theme \"${opts.theme}\". Available: ${[...builtins, ...customIds].join(', ')}`,\n );\n }\n }\n\n // Apply transform if requested\n let exportMarkdownDoc = result.markdownDoc;\n if (opts.transform) {\n exportMarkdownDoc = await applyTransformToMarkdown(\n result.markdownDoc,\n container,\n opts.transform,\n opts.theme,\n opts.autoTemplates,\n );\n console.error(` Applied transform: ${opts.transform}`);\n }\n\n const themeId = opts.theme;\n\n for (const format of formats) {\n const outPath = join(outputDir, `${baseName}.${format}`);\n\n switch (format) {\n case 'docx': {\n const { markdownDocToDocx } = await import('@bendyline/squisq-formats/docx');\n const buf = await markdownDocToDocx(exportMarkdownDoc, { themeId });\n await writeFile(outPath, Buffer.from(buf));\n break;\n }\n\n case 'pptx': {\n const { markdownDocToPptx } = await import('@bendyline/squisq-formats/pptx');\n // Collect images from container for PPTX embedding\n const images = await collectContainerImages(container);\n const buf = await markdownDocToPptx(exportMarkdownDoc, { themeId, images });\n await writeFile(outPath, Buffer.from(buf));\n break;\n }\n\n case 'pdf': {\n const { markdownDocToPdf } = await import('@bendyline/squisq-formats/pdf');\n const buf = await markdownDocToPdf(exportMarkdownDoc, { themeId });\n await writeFile(outPath, Buffer.from(buf));\n break;\n }\n\n case 'html': {\n const { markdownToDoc } = await import('@bendyline/squisq/doc');\n const { docToHtml } = await import('@bendyline/squisq-formats/html');\n const { PLAYER_BUNDLE } = await import('@bendyline/squisq-react/standalone-source');\n\n const doc = markdownToDoc(exportMarkdownDoc, { autoTemplates: opts.autoTemplates });\n const images = await collectImagesForHtml(doc, container);\n\n const html = docToHtml(doc, {\n playerScript: PLAYER_BUNDLE,\n images,\n title: baseName,\n mode: 'static',\n themeId,\n });\n await writeFile(outPath, html, 'utf-8');\n break;\n }\n\n case 'htmlzip': {\n const { markdownToDoc } = await import('@bendyline/squisq/doc');\n const { docToHtmlZip } = await import('@bendyline/squisq-formats/html');\n const { PLAYER_BUNDLE } = await import('@bendyline/squisq-react/standalone-source');\n\n const doc = markdownToDoc(exportMarkdownDoc, { autoTemplates: opts.autoTemplates });\n const images = await collectImagesForHtml(doc, container);\n\n const blob = await docToHtmlZip(doc, {\n playerScript: PLAYER_BUNDLE,\n images,\n title: baseName,\n mode: 'static',\n themeId,\n });\n const buf = Buffer.from(await blob.arrayBuffer());\n await writeFile(outPath.replace(/\\.htmlzip$/, '.html.zip'), buf);\n break;\n }\n\n case 'epub': {\n const { markdownDocToEpub } = await import('@bendyline/squisq-formats/epub');\n const images = await collectContainerImages(container);\n\n // Collect audio narration from container if Doc has audio segments\n const epubAudio = new Map<string, ArrayBuffer>();\n const audioSegments = result.doc?.audio?.segments;\n if (audioSegments?.length) {\n for (const seg of audioSegments) {\n const data = await container.readFile(seg.src);\n if (data) epubAudio.set(seg.src, data);\n }\n }\n\n const hasNarration = epubAudio.size > 0;\n const buf = await markdownDocToEpub(exportMarkdownDoc, {\n title: baseName,\n themeId,\n images,\n audio: hasNarration ? epubAudio : undefined,\n audioSegments: hasNarration ? audioSegments : undefined,\n totalDuration: hasNarration ? result.doc?.duration : undefined,\n });\n await writeFile(outPath, Buffer.from(buf));\n break;\n }\n\n case 'dbk': {\n const { containerToZip } = await import('@bendyline/squisq-formats/container');\n const blob = await containerToZip(container);\n const buf = await blob.arrayBuffer();\n await writeFile(outPath, Buffer.from(buf));\n break;\n }\n }\n\n console.error(` ✓ ${outPath}`);\n }\n\n console.error('Done.');\n}\n\n// ============================================\n// Transform Pipeline\n// ============================================\n\n/**\n * Apply a transform style to a MarkdownDocument.\n * Pipeline: MarkdownDocument → Doc → applyTransform → docToMarkdown → MarkdownDocument\n */\nasync function applyTransformToMarkdown(\n markdownDoc: MarkdownDocument,\n container: ContentContainer,\n transformStyle: string,\n themeId?: string,\n autoTemplates?: boolean,\n): Promise<MarkdownDocument> {\n const { markdownToDoc, docToMarkdown } = await import('@bendyline/squisq/doc');\n const { applyTransform, extractDocImages } = await import('@bendyline/squisq/transform');\n\n const doc = markdownToDoc(markdownDoc, { autoTemplates });\n\n // Extract image metadata from the doc for transform interleaving\n const images = extractDocImages(doc.blocks);\n\n const result = applyTransform(doc, transformStyle, {\n themeId,\n images,\n });\n\n return docToMarkdown(result.doc);\n}\n\n/**\n * Collect images for HTML export, keying each by both its container path and\n * its filename. The standalone player resolves by exact key first then by\n * filename, so both keyings handle path mismatches between how the Doc\n * references images and how they're stored in the container.\n *\n * Only images referenced by the Doc are included — unreferenced container\n * images would bloat the HTML output unnecessarily.\n */\nasync function collectImagesForHtml(\n doc: import('@bendyline/squisq/schemas').Doc,\n container: ContentContainer,\n): Promise<Map<string, ArrayBuffer>> {\n const { collectImagePaths, extractFilename } = await import('@bendyline/squisq-formats/html');\n\n const images = new Map<string, ArrayBuffer>();\n const docPaths = collectImagePaths(doc);\n\n // Try exact paths first\n const needByFilename = new Set<string>();\n for (const imgPath of docPaths) {\n const data = await container.readFile(imgPath);\n if (data) {\n images.set(imgPath, data);\n const filename = extractFilename(imgPath);\n if (filename !== imgPath) images.set(filename, data);\n } else {\n needByFilename.add(extractFilename(imgPath));\n }\n }\n\n // For any doc paths that didn't resolve, scan the container for a file\n // with the same basename (handles \"images/hero.jpg\" vs \"hero.jpg\" mismatches).\n if (needByFilename.size > 0) {\n const files = await container.listFiles();\n for (const file of files) {\n const filename = extractFilename(file.path);\n if (needByFilename.has(filename)) {\n const data = await container.readFile(file.path);\n if (data) {\n images.set(filename, data);\n images.set(file.path, data);\n }\n }\n }\n }\n\n return images;\n}\n\n/**\n * Collect all image files from a container as a Map<path, ArrayBuffer>.\n */\nasync function collectContainerImages(\n container: ContentContainer,\n): Promise<Map<string, ArrayBuffer>> {\n const images = new Map<string, ArrayBuffer>();\n const files = await container.listFiles();\n for (const file of files) {\n if (/\\.(jpg|jpeg|png|gif|webp|svg|bmp|avif)$/i.test(file.path)) {\n const data = await container.readFile(file.path);\n if (data) {\n images.set(file.path, data);\n }\n }\n }\n return images;\n}\n","/**\n * readInput\n *\n * Unified input reader for the CLI. Accepts a path to a .md file, .json file\n * (Doc schema), .zip/.dbk container, or a folder and returns a MemoryContentContainer\n * (virtual file system) plus either the parsed MarkdownDocument or a pre-built Doc.\n *\n * When a .json file or a container/folder containing doc.json is provided, the\n * Doc is returned directly and markdownDoc will be null. Callers should check\n * result.doc first; when present, skip markdownToDoc() and use it directly.\n */\n\nimport { readFile, readdir, stat } from 'node:fs/promises';\nimport { join, extname } from 'node:path';\nimport { parseMarkdown } from '@bendyline/squisq/markdown';\nimport type { MarkdownDocument } from '@bendyline/squisq/markdown';\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport { MemoryContentContainer } from '@bendyline/squisq/storage';\nimport { zipToContainer } from '@bendyline/squisq-formats/container';\n\nexport interface ReadInputResult {\n container: MemoryContentContainer;\n /** Parsed markdown document. Null when input is a Doc JSON file. */\n markdownDoc: MarkdownDocument | null;\n /** Pre-built Doc from JSON input. Present when input is .json or contains doc.json. */\n doc?: Doc;\n}\n\n/** MIME type lookup by extension (common content types) */\nconst MIME_TYPES: Record<string, string> = {\n '.md': 'text/markdown',\n '.txt': 'text/plain',\n '.json': 'application/json',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.png': 'image/png',\n '.gif': 'image/gif',\n '.webp': 'image/webp',\n '.svg': 'image/svg+xml',\n '.mp3': 'audio/mpeg',\n '.wav': 'audio/wav',\n '.ogg': 'audio/ogg',\n '.mp4': 'video/mp4',\n '.webm': 'video/webm',\n};\n\nfunction mimeFromExt(filePath: string): string {\n return MIME_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';\n}\n\n/**\n * Recursively walk a directory and return all file paths (relative to root).\n */\nasync function walkDir(root: string, prefix = ''): Promise<string[]> {\n const entries = await readdir(root, { withFileTypes: true });\n const paths: string[] = [];\n\n for (const entry of entries) {\n const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;\n if (entry.isDirectory()) {\n paths.push(...(await walkDir(join(root, entry.name), relPath)));\n } else if (entry.isFile()) {\n paths.push(relPath);\n }\n }\n\n return paths;\n}\n\n/**\n * Read input from a file path (markdown, JSON Doc, ZIP/DBK container, or folder)\n * and return a populated ContentContainer + parsed MarkdownDocument or Doc.\n *\n * When the input is a .json file (or a container/folder containing doc.json),\n * the result will have `doc` populated and `markdownDoc` set to null.\n * Otherwise, `markdownDoc` is populated and `doc` is undefined.\n */\nexport async function readInput(inputPath: string): Promise<ReadInputResult> {\n const info = await stat(inputPath);\n\n if (info.isDirectory()) {\n return readFolder(inputPath);\n }\n\n const ext = extname(inputPath).toLowerCase();\n if (ext === '.zip' || ext === '.dbk') {\n return readContainer(inputPath);\n }\n\n if (ext === '.json') {\n return readDocJsonFile(inputPath);\n }\n\n // Default: treat as a markdown file\n return readMarkdownFile(inputPath);\n}\n\nasync function readMarkdownFile(filePath: string): Promise<ReadInputResult> {\n const content = await readFile(filePath, 'utf-8');\n const container = new MemoryContentContainer();\n await container.writeDocument(content);\n const markdownDoc = parseMarkdown(content);\n return { container, markdownDoc };\n}\n\n/**\n * Read a standalone Doc JSON file. The container is empty (no media bundled);\n * callers should populate it or set basePath for media resolution.\n */\nasync function readDocJsonFile(filePath: string): Promise<ReadInputResult> {\n const content = await readFile(filePath, 'utf-8');\n const doc = JSON.parse(content) as Doc;\n const container = new MemoryContentContainer();\n return { container, markdownDoc: null, doc };\n}\n\n/**\n * Known filenames for Doc JSON inside containers and folders.\n * Checked in priority order before falling back to markdown discovery.\n */\nconst DOC_JSON_NAMES = ['doc.json', 'story.json'];\n\nasync function readContainer(filePath: string): Promise<ReadInputResult> {\n const data = await readFile(filePath);\n const container = await zipToContainer(\n data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer,\n );\n\n // Check for Doc JSON inside the container first\n for (const name of DOC_JSON_NAMES) {\n const jsonData = await container.readFile(name);\n if (jsonData) {\n const decoder = new TextDecoder();\n const doc = JSON.parse(decoder.decode(jsonData)) as Doc;\n return { container, markdownDoc: null, doc };\n }\n }\n\n const markdown = await container.readDocument();\n if (!markdown) {\n throw new Error(`No markdown document or doc.json found in container: ${filePath}`);\n }\n\n const markdownDoc = parseMarkdown(markdown);\n return { container, markdownDoc };\n}\n\nasync function readFolder(dirPath: string): Promise<ReadInputResult> {\n const container = new MemoryContentContainer();\n const files = await walkDir(dirPath);\n\n for (const relPath of files) {\n const absPath = join(dirPath, relPath);\n const data = await readFile(absPath);\n await container.writeFile(\n relPath,\n new Uint8Array(data.buffer, data.byteOffset, data.byteLength),\n mimeFromExt(relPath),\n );\n }\n\n // Check for Doc JSON inside the folder first\n for (const name of DOC_JSON_NAMES) {\n const jsonData = await container.readFile(name);\n if (jsonData) {\n const decoder = new TextDecoder();\n const doc = JSON.parse(decoder.decode(jsonData)) as Doc;\n return { container, markdownDoc: null, doc };\n }\n }\n\n const markdown = await container.readDocument();\n if (!markdown) {\n throw new Error(`No markdown document or doc.json found in folder: ${dirPath}`);\n }\n\n const markdownDoc = parseMarkdown(markdown);\n return { container, markdownDoc };\n}\n","/**\n * video command\n *\n * Renders a squisq document to MP4 video by delegating to the\n * programmatic renderDocToMp4 API.\n *\n * Usage:\n * squisq video <input> [-o output.mp4] [--fps 30] [--quality normal] [--orientation landscape]\n */\n\nimport { mkdir } from 'node:fs/promises';\nimport { dirname, basename, extname, resolve } from 'node:path';\nimport type { Command } from 'commander';\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport { readInput } from '../util/readInput.js';\nimport { renderDocToMp4 } from '../api.js';\n\nimport type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';\n\ntype CaptionOption = 'off' | 'standard' | 'social';\n\ninterface VideoCommandOptions {\n autoTemplates?: boolean;\n output?: string;\n fps?: string;\n quality?: VideoQuality;\n orientation?: VideoOrientation;\n captions?: CaptionOption;\n width?: string;\n height?: string;\n}\n\nconst VALID_QUALITIES = ['draft', 'normal', 'high'] as const;\nconst VALID_ORIENTATIONS = ['landscape', 'portrait'] as const;\nconst VALID_CAPTIONS = ['off', 'standard', 'social'] as const;\n\nexport function registerVideoCommand(program: Command): void {\n program\n .command('video')\n .description('Render a squisq document to MP4 video')\n .argument('<input>', 'Path to .md file, .zip/.dbk container, or folder')\n .argument('[output]', 'Output MP4 path (default: <input>.mp4)')\n .option('-o, --output <path>', 'Output MP4 path (default: <input>.mp4)')\n .option('--fps <number>', 'Frames per second (default: 30)', '30')\n .option(\n '--quality <level>',\n `Encoding quality: ${VALID_QUALITIES.join(', ')} (default: normal)`,\n 'normal',\n )\n .option(\n '--orientation <orient>',\n `Video orientation: ${VALID_ORIENTATIONS.join(', ')} (default: landscape)`,\n 'landscape',\n )\n .option(\n '--captions <style>',\n `Caption style: ${VALID_CAPTIONS.join(', ')} (default: off)`,\n 'off',\n )\n .option(\n '--no-auto-templates',\n 'Disable content-aware template auto-picking for unannotated headings',\n )\n .option('--width <pixels>', 'Override video width')\n .option('--height <pixels>', 'Override video height')\n .action(async (inputPath: string, outputArg: string | undefined, opts: VideoCommandOptions) => {\n try {\n // Positional output arg takes precedence, then -o flag\n if (outputArg && !opts.output) {\n opts.output = outputArg;\n }\n await runVideo(inputPath, opts);\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n console.error(`Error: ${message}`);\n process.exitCode = 1;\n }\n });\n}\n\nasync function runVideo(inputPath: string, opts: VideoCommandOptions): Promise<void> {\n const resolvedInput = resolve(inputPath);\n\n // Validate options\n const fps = parseInt(opts.fps ?? '30', 10);\n if (isNaN(fps) || fps < 1 || fps > 120) {\n throw new Error('FPS must be a number between 1 and 120');\n }\n\n const quality = opts.quality ?? 'normal';\n if (!VALID_QUALITIES.includes(quality as (typeof VALID_QUALITIES)[number])) {\n throw new Error(`Invalid quality \"${quality}\". Valid: ${VALID_QUALITIES.join(', ')}`);\n }\n\n const orientation = opts.orientation ?? 'landscape';\n if (!VALID_ORIENTATIONS.includes(orientation as (typeof VALID_ORIENTATIONS)[number])) {\n throw new Error(\n `Invalid orientation \"${orientation}\". Valid: ${VALID_ORIENTATIONS.join(', ')}`,\n );\n }\n\n const captions = opts.captions ?? 'off';\n if (!VALID_CAPTIONS.includes(captions as (typeof VALID_CAPTIONS)[number])) {\n throw new Error(`Invalid captions \"${captions}\". Valid: ${VALID_CAPTIONS.join(', ')}`);\n }\n const captionStyle = captions === 'off' ? undefined : (captions as 'standard' | 'social');\n\n // Determine output path\n const inputBasename = basename(resolvedInput);\n const inputExt = extname(inputBasename);\n const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;\n const outputPath = opts.output\n ? resolve(opts.output)\n : resolve(dirname(resolvedInput), `${baseName}.mp4`);\n\n // Ensure output directory exists\n await mkdir(dirname(outputPath), { recursive: true });\n\n // ── Step 1: Read input ──────────────────────────────────────────\n console.error(`Reading: ${resolvedInput}`);\n const result = await readInput(resolvedInput);\n const { container } = result;\n\n // ── Step 2: Get or parse Doc ────────────────────────────────────\n let doc: Doc;\n if (result.doc) {\n console.error('Using pre-built Doc JSON');\n doc = result.doc;\n } else if (result.markdownDoc) {\n const { markdownToDoc } = await import('@bendyline/squisq/doc');\n doc = markdownToDoc(result.markdownDoc, { autoTemplates: opts.autoTemplates });\n } else {\n throw new Error('No document found in input');\n }\n\n console.error(\n `Rendering: ${fps} fps, quality: ${quality}, orientation: ${orientation}, captions: ${captions}`,\n );\n\n // ── Step 3: Render via programmatic API ─────────────────────────\n const result2 = await renderDocToMp4(doc, container, {\n outputPath,\n fps,\n quality: quality as VideoQuality,\n orientation: orientation as VideoOrientation,\n width: opts.width ? parseInt(opts.width, 10) : undefined,\n height: opts.height ? parseInt(opts.height, 10) : undefined,\n captionStyle,\n coverPreRoll: 2,\n onProgress: (phase, percent) => {\n writeProgress(phase, percent, 100);\n },\n });\n\n clearProgress();\n console.error(\n ` ✓ ${outputPath} (${result2.duration.toFixed(1)}s, ${result2.frameCount} frames)`,\n );\n console.error('Done.');\n}\n\n// ── Progress helpers ──────────────────────────────────────────────\n\nconst BAR_WIDTH = 30;\n\nfunction writeProgress(label: string, current: number, total: number, detail?: string): void {\n const pct = Math.min(100, Math.round((current / total) * 100));\n const filled = Math.round((pct / 100) * BAR_WIDTH);\n const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);\n const suffix = detail ? ` (${detail})` : '';\n process.stderr.write(`\\r ${label}: ${bar} ${pct}%${suffix} `);\n}\n\nfunction clearProgress(): void {\n process.stderr.write('\\r' + ' '.repeat(80) + '\\r');\n}\n","/**\n * Programmatic Video API\n *\n * Provides a library-style entry point for rendering Squisq documents to MP4\n * from Node.js callers (e.g., Qualla's pipeline). This avoids the need to shell\n * out to the `squisq video` CLI and gives callers full control over the Doc,\n * MemoryContentContainer, and encoding options.\n *\n * Orchestrates the full pipeline: Doc → render HTML → Playwright frame capture → FFmpeg encode.\n *\n * Usage:\n * import { renderDocToMp4 } from '@bendyline/squisq-cli/api';\n *\n * await renderDocToMp4(doc, container, {\n * outputPath: '/tmp/output.mp4',\n * fps: 30,\n * quality: 'normal',\n * orientation: 'landscape',\n * });\n */\n\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport { resolveMediaSchedule } from '@bendyline/squisq/schemas';\nimport { flattenBlocks } from '@bendyline/squisq/doc';\nimport type { MemoryContentContainer } from '@bendyline/squisq/storage';\nimport type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';\nimport { generateRenderHtml } from '@bendyline/squisq-video';\nimport { resolveDimensions } from '@bendyline/squisq-video';\nimport { detectFfmpeg } from './util/detectFfmpeg.js';\n\n// Re-export utility types and functions callers may need\nexport type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';\nexport { MemoryContentContainer } from '@bendyline/squisq/storage';\nexport { readInput } from './util/readInput.js';\nexport type { ReadInputResult } from './util/readInput.js';\n\n/** Options for renderDocToMp4. */\nexport interface RenderDocToMp4Options {\n /** Output file path for the MP4. */\n outputPath: string;\n\n /** Frames per second (default: 30). */\n fps?: number;\n\n /** Encoding quality preset (default: 'normal'). */\n quality?: VideoQuality;\n\n /** Video orientation (default: 'landscape'). */\n orientation?: VideoOrientation;\n\n /** Override video width in pixels. */\n width?: number;\n\n /** Override video height in pixels. */\n height?: number;\n\n /** Caption style to bake into the video (default: none). */\n captionStyle?: 'standard' | 'social';\n\n /** Seconds of cover-slide pre-roll before the story starts (default: 0). */\n coverPreRoll?: number;\n\n /**\n * Progress callback. Called with a phase name and 0-100 percentage.\n */\n onProgress?: (phase: string, percent: number) => void;\n}\n\n/** Result returned by renderDocToMp4. */\nexport interface RenderDocToMp4Result {\n /** Duration of the rendered video in seconds (including pre-roll). */\n duration: number;\n\n /** Number of frames captured. */\n frameCount: number;\n\n /** Output file path. */\n outputPath: string;\n}\n\n/**\n * Render a Doc + media container to an MP4 video file.\n *\n * The container should contain audio and image files referenced by the Doc's\n * audio.segments[].src and block image paths. Files are embedded as base64\n * data URIs in a self-contained render HTML page.\n *\n * Requires:\n * - Playwright (chromium) — for headless frame capture\n * - FFmpeg — for video encoding (must be on PATH)\n *\n * @param doc - The Doc structure to render\n * @param container - MemoryContentContainer with audio/image files\n * @param options - Rendering and encoding options\n * @returns Result with duration and frame count\n */\nexport async function renderDocToMp4(\n doc: Doc,\n container: MemoryContentContainer,\n options: RenderDocToMp4Options,\n): Promise<RenderDocToMp4Result> {\n const {\n outputPath,\n fps = 30,\n quality = 'normal',\n orientation = 'landscape',\n captionStyle,\n coverPreRoll = 0,\n onProgress,\n } = options;\n\n const dimensions = resolveDimensions({\n orientation,\n width: options.width,\n height: options.height,\n });\n\n // Detect ffmpeg early — needed for audio concat and video encoding\n const ffmpegPath = await detectFfmpeg();\n if (!ffmpegPath) {\n throw new Error(\n 'ffmpeg is required but not found in PATH.\\n' +\n 'Install it with:\\n' +\n ' macOS: brew install ffmpeg\\n' +\n ' Ubuntu: sudo apt install ffmpeg\\n' +\n ' Windows: winget install ffmpeg',\n );\n }\n\n onProgress?.('collecting media', 0);\n\n // ── Collect images from container ───────────────────────────────\n const { collectImagePaths } = await import('@bendyline/squisq-formats/html');\n const imagePaths = collectImagePaths(doc);\n const images = new Map<string, ArrayBuffer>();\n for (const imgPath of imagePaths) {\n const data = await container.readFile(imgPath);\n if (data) {\n images.set(imgPath, data);\n }\n }\n\n // ── Collect audio segments ──────────────────────────────────────\n const audio = new Map<string, ArrayBuffer>();\n const audioBuffers: ArrayBuffer[] = [];\n if (doc.audio?.segments?.length) {\n for (const seg of doc.audio.segments) {\n const data = await container.readFile(seg.src);\n if (data) {\n audio.set(seg.src, data);\n audio.set(seg.name, data);\n audioBuffers.push(data);\n }\n }\n }\n\n // Concatenate audio for the MP4's audio track\n let concatenatedAudio: Uint8Array | null = null;\n if (audioBuffers.length > 0) {\n concatenatedAudio = await concatenateAudioBuffers(audioBuffers, ffmpegPath);\n }\n\n // ── Collect timed-media clips + block video sources ─────────────\n // Every scheduled clip (block.media + doc.documentMedia) and every\n // template-produced VideoLayer needs its bytes embedded so the headless\n // page can load them with no network. They resolve through the inline\n // media provider's `images` map (which infers mp4/mp3 MIME by extension).\n const schedule = resolveMediaSchedule(doc);\n const mediaSrcs = new Set<string>(schedule.map((c) => c.src));\n for (const block of flattenBlocks(doc.blocks)) {\n for (const layer of block.layers ?? []) {\n if (layer.type === 'video') mediaSrcs.add(layer.content.src);\n }\n }\n // Buffers for the scheduled audio clips, keyed by clip id, for the mux.\n const clipBuffers = new Map<string, ArrayBuffer>();\n for (const src of mediaSrcs) {\n if (images.has(src)) continue;\n const data = await container.readFile(src);\n if (data) images.set(src, data);\n }\n for (const clip of schedule) {\n if (clip.kind !== 'audio') continue;\n const data = images.get(clip.src) ?? (await container.readFile(clip.src));\n if (data) clipBuffers.set(clip.id, data);\n }\n\n onProgress?.('generating render HTML', 10);\n\n // ── Generate self-contained render HTML ─────────────────────────\n const { PLAYER_BUNDLE } = await import('@bendyline/squisq-react/standalone-source');\n const renderHtml = generateRenderHtml(doc, {\n playerScript: PLAYER_BUNDLE,\n images,\n audio: audio.size > 0 ? audio : undefined,\n width: dimensions.width,\n height: dimensions.height,\n captionStyle,\n });\n\n onProgress?.('launching browser', 15);\n\n // ── Playwright frame capture ────────────────────────────────────\n const { chromium } = await import('playwright-core');\n const browser = await chromium.launch({ headless: true });\n const page = await browser.newPage({\n viewport: { width: dimensions.width, height: dimensions.height },\n });\n\n const pageErrors: string[] = [];\n page.on('pageerror', (err) => pageErrors.push(err.message));\n\n await page.setContent(renderHtml, { waitUntil: 'load' });\n await page.waitForTimeout(500);\n\n try {\n await page.waitForFunction(\n () => typeof (window as unknown as Record<string, unknown>).getDuration === 'function',\n { timeout: 15000 },\n );\n } catch {\n await browser.close();\n const errorDetail = pageErrors.length\n ? `\\nPage errors:\\n ${pageErrors.join('\\n ')}`\n : '\\nNo page errors captured — the player may have failed to mount.';\n throw new Error(`Render API did not initialize within 15 seconds.${errorDetail}`);\n }\n\n const docDuration: number = await page.evaluate(() => {\n return (window as unknown as { getDuration: () => number }).getDuration();\n });\n\n if (docDuration <= 0) {\n await browser.close();\n throw new Error('Document has zero duration — nothing to render');\n }\n\n const storyFrameCount = Math.ceil(docDuration * fps);\n const preRollFrameCount = Math.ceil(coverPreRoll * fps);\n const totalFrames = preRollFrameCount + storyFrameCount;\n const frames: Uint8Array[] = [];\n\n onProgress?.('capturing frames', 20);\n\n // Cover slide pre-roll (if requested)\n if (preRollFrameCount > 0) {\n const hasCover: boolean = await page.evaluate(() => {\n const w = window as unknown as { hasCoverBlock?: () => boolean };\n return typeof w.hasCoverBlock === 'function' ? w.hasCoverBlock() : false;\n });\n\n if (hasCover) {\n await page.evaluate(() => {\n (window as unknown as { showCover: () => void }).showCover();\n });\n await page.waitForTimeout(100);\n const coverFrame = new Uint8Array(await page.screenshot({ type: 'png' }));\n for (let i = 0; i < preRollFrameCount; i++) {\n frames.push(coverFrame);\n }\n await page.evaluate(() => {\n (window as unknown as { hideCover: () => void }).hideCover();\n });\n }\n }\n\n // Story frames via seekTo\n const frameInterval = 1 / fps;\n for (let i = 0; i < storyFrameCount; i++) {\n const time = i * frameInterval;\n await page.evaluate((t: number) => {\n return (window as unknown as { seekTo: (t: number) => Promise<void> }).seekTo(t);\n }, time);\n\n const screenshot = await page.screenshot({ type: 'png' });\n frames.push(new Uint8Array(screenshot));\n\n // Report progress: frames phase is 20% to 80%\n if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {\n const pct = 20 + Math.round((frames.length / totalFrames) * 60);\n onProgress?.('capturing frames', pct);\n }\n }\n\n await browser.close();\n\n onProgress?.('encoding video', 80);\n\n // Build the final audio track. With timed media clips, mix the narration\n // (delayed by the cover pre-roll) and each scheduled audio clip at its\n // absolute time. Without clips, keep the legacy narration-only path so\n // existing exports are unchanged.\n const scheduledAudioClips = schedule.filter((c) => c.kind === 'audio' && clipBuffers.has(c.id));\n let encodingAudio = concatenatedAudio;\n if (scheduledAudioClips.length > 0 && ffmpegPath) {\n encodingAudio = await mixScheduledAudio(\n ffmpegPath,\n concatenatedAudio,\n scheduledAudioClips.map((c) => ({\n buffer: clipBuffers.get(c.id)!,\n delaySec: c.absoluteStart + coverPreRoll,\n trimStart: c.sourceIn,\n trimLen: Math.max(0, c.absoluteEnd - c.absoluteStart),\n })),\n coverPreRoll,\n );\n } else if (coverPreRoll > 0 && concatenatedAudio) {\n // Use FFmpeg to add silence padding at the start (adelay filter)\n encodingAudio = await addAudioDelay(ffmpegPath, concatenatedAudio, coverPreRoll);\n }\n\n const { framesToMp4Native } = await import('./util/nativeEncoder.js');\n await framesToMp4Native(ffmpegPath, frames, encodingAudio, outputPath, {\n fps,\n quality,\n orientation,\n width: dimensions.width,\n height: dimensions.height,\n onProgress: (percent, phase) => {\n onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2));\n },\n });\n\n onProgress?.('done', 100);\n\n const totalDuration = docDuration + coverPreRoll;\n return {\n duration: totalDuration,\n frameCount: frames.length,\n outputPath,\n };\n}\n\n// ── Audio helpers ─────────────────────────────────────────────────\n\n/**\n * Concatenate multiple audio buffers into one.\n * Uses native ffmpeg concat when available, falls back to byte concatenation.\n */\nasync function concatenateAudioBuffers(\n buffers: ArrayBuffer[],\n ffmpegPath?: string,\n): Promise<Uint8Array> {\n if (buffers.length === 0) return new Uint8Array(0);\n if (buffers.length === 1) return new Uint8Array(buffers[0]);\n\n if (ffmpegPath) {\n return concatenateAudioNative(ffmpegPath, buffers);\n }\n\n // Fallback: naive byte concatenation (works for MP3)\n const totalLength = buffers.reduce((sum, b) => sum + b.byteLength, 0);\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const buf of buffers) {\n result.set(new Uint8Array(buf), offset);\n offset += buf.byteLength;\n }\n return result;\n}\n\nasync function concatenateAudioNative(\n ffmpegPath: string,\n buffers: ArrayBuffer[],\n): Promise<Uint8Array> {\n const { writeFile, readFile, mkdir, rm } = await import('node:fs/promises');\n const { join } = await import('node:path');\n const { tmpdir } = await import('node:os');\n const { randomBytes } = await import('node:crypto');\n const { execFile } = await import('node:child_process');\n\n const tmpId = randomBytes(8).toString('hex');\n const workDir = join(tmpdir(), `squisq-audio-concat-${tmpId}`);\n await mkdir(workDir, { recursive: true });\n\n try {\n const segmentPaths: string[] = [];\n for (let i = 0; i < buffers.length; i++) {\n const segPath = join(workDir, `seg-${i}.mp3`);\n await writeFile(segPath, new Uint8Array(buffers[i]));\n segmentPaths.push(segPath);\n }\n\n const listPath = join(workDir, 'concat-list.txt');\n const listContent = segmentPaths.map((p) => `file '${p.replace(/\\\\/g, '/')}'`).join('\\n');\n await writeFile(listPath, listContent);\n\n const outputPath = join(workDir, 'combined.mp3');\n await new Promise<void>((resolve, reject) => {\n execFile(\n ffmpegPath,\n ['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', outputPath],\n { timeout: 120_000 },\n (err) => {\n if (err) reject(new Error(`ffmpeg audio concat failed: ${err.message}`));\n else resolve();\n },\n );\n });\n\n const data = await readFile(outputPath);\n return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n } finally {\n await rm(workDir, { recursive: true, force: true });\n }\n}\n\n/**\n * Add silence at the start of an audio track by re-encoding with adelay filter.\n */\nasync function addAudioDelay(\n ffmpegPath: string,\n audioData: Uint8Array,\n delaySecs: number,\n): Promise<Uint8Array> {\n const { writeFile, readFile, rm } = await import('node:fs/promises');\n const { join } = await import('node:path');\n const { tmpdir } = await import('node:os');\n const { randomBytes } = await import('node:crypto');\n const { execFile } = await import('node:child_process');\n\n const tmpId = randomBytes(8).toString('hex');\n const inputPath = join(tmpdir(), `squisq-audio-delay-in-${tmpId}.mp3`);\n const outputPath = join(tmpdir(), `squisq-audio-delay-out-${tmpId}.mp3`);\n\n try {\n await writeFile(inputPath, audioData);\n const delayMs = Math.round(delaySecs * 1000);\n\n await new Promise<void>((resolve, reject) => {\n execFile(\n ffmpegPath,\n [\n '-y',\n '-i',\n inputPath,\n '-af',\n `adelay=${delayMs}|${delayMs}`,\n '-c:a',\n 'libmp3lame',\n '-b:a',\n '128k',\n outputPath,\n ],\n { timeout: 60_000 },\n (err) => {\n if (err) reject(new Error(`ffmpeg audio delay failed: ${err.message}`));\n else resolve();\n },\n );\n });\n\n const data = await readFile(outputPath);\n return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n } finally {\n await rm(inputPath, { force: true });\n await rm(outputPath, { force: true });\n }\n}\n\n/** One scheduled audio clip to fold into the mix. */\ninterface AudioMixClip {\n buffer: ArrayBuffer;\n /** Seconds to delay the clip on the final timeline. */\n delaySec: number;\n /** Source in-point to trim from (seconds). */\n trimStart: number;\n /** Played length (seconds). */\n trimLen: number;\n}\n\n/**\n * Mix the sequential narration track (delayed by the cover pre-roll) with any\n * number of scheduled audio clips, each trimmed to its source window and\n * delayed to its absolute position. Returns a single MP3.\n *\n * Each input is `adelay`'d (per-channel) and `atrim`'d; `amix` sums them with\n * `normalize=0` so individual levels are preserved. Frame capture stays silent\n * — this reconstructs the audio offline from the same schedule the renderer\n * uses, keeping audio and video aligned.\n */\nasync function mixScheduledAudio(\n ffmpegPath: string,\n narration: Uint8Array | null,\n clips: AudioMixClip[],\n coverPreRoll: number,\n): Promise<Uint8Array | null> {\n if (!narration && clips.length === 0) return null;\n\n const { writeFile, readFile, mkdir, rm } = await import('node:fs/promises');\n const { join } = await import('node:path');\n const { tmpdir } = await import('node:os');\n const { randomBytes } = await import('node:crypto');\n const { execFile } = await import('node:child_process');\n\n const workDir = join(tmpdir(), `squisq-audio-mix-${randomBytes(8).toString('hex')}`);\n await mkdir(workDir, { recursive: true });\n\n try {\n const inputs: string[] = [];\n const filters: string[] = [];\n const labels: string[] = [];\n const ms = (s: number) => Math.max(0, Math.round(s * 1000));\n\n if (narration) {\n const p = join(workDir, 'narration.mp3');\n await writeFile(p, narration);\n const i = inputs.push(p) - 1;\n const d = ms(coverPreRoll);\n filters.push(`[${i}:a]adelay=${d}|${d}[a${i}]`);\n labels.push(`[a${i}]`);\n }\n\n for (const clip of clips) {\n const p = join(workDir, `clip-${inputs.length}.mp3`);\n await writeFile(p, new Uint8Array(clip.buffer));\n const i = inputs.push(p) - 1;\n const d = ms(clip.delaySec);\n const end = clip.trimStart + clip.trimLen;\n filters.push(\n `[${i}:a]atrim=start=${clip.trimStart}:end=${end},asetpts=PTS-STARTPTS,adelay=${d}|${d}[a${i}]`,\n );\n labels.push(`[a${i}]`);\n }\n\n const graph = `${filters.join(';')};${labels.join('')}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[aout]`;\n const args = ['-y'];\n for (const p of inputs) args.push('-i', p);\n args.push(\n '-filter_complex',\n graph,\n '-map',\n '[aout]',\n '-c:a',\n 'libmp3lame',\n '-b:a',\n '192k',\n join(workDir, 'mixed.mp3'),\n );\n\n await new Promise<void>((resolve, reject) => {\n execFile(ffmpegPath, args, { timeout: 180_000 }, (err) => {\n if (err) reject(new Error(`ffmpeg audio mix failed: ${err.message}`));\n else resolve();\n });\n });\n\n const data = await readFile(join(workDir, 'mixed.mp3'));\n return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n } finally {\n await rm(workDir, { recursive: true, force: true });\n }\n}\n\n// ── Thumbnail extraction ──────────────────────────────────────────\n\n/** A thumbnail size specification. */\nexport interface ThumbnailSpec {\n /** Label for the thumbnail (used in filename: `{slug}-{width}x{height}.jpg`). */\n name: string;\n /** Output width in pixels. */\n width: number;\n /** Output height in pixels. */\n height: number;\n /** FFmpeg video filter string (e.g., 'scale=1280:720'). */\n filter: string;\n}\n\n/** Options for extractThumbnails. */\nexport interface ExtractThumbnailsOptions {\n /** Path to the source MP4 video. */\n videoPath: string;\n /** Directory to write thumbnails into. */\n outputDir: string;\n /** Base slug for filenames (produces `{slug}-{width}x{height}.jpg`). */\n slug: string;\n /** Thumbnail sizes to generate. */\n sizes: ThumbnailSpec[];\n /** Overwrite existing thumbnails (default: false). */\n force?: boolean;\n}\n\n/**\n * Extract thumbnail images from the first frame of an MP4 video.\n * Produces JPEG files at each specified size using FFmpeg video filters.\n */\nexport async function extractThumbnails(options: ExtractThumbnailsOptions): Promise<void> {\n const { videoPath, outputDir, slug, sizes, force } = options;\n const { existsSync } = await import('node:fs');\n const { join } = await import('node:path');\n const { execFile } = await import('node:child_process');\n\n const ffmpegPath = await detectFfmpeg();\n if (!ffmpegPath) {\n throw new Error(\n 'ffmpeg is required for thumbnail extraction but not found in PATH.\\n' +\n 'Install it with:\\n' +\n ' macOS: brew install ffmpeg\\n' +\n ' Ubuntu: sudo apt install ffmpeg\\n' +\n ' Windows: winget install ffmpeg',\n );\n }\n\n for (const thumb of sizes) {\n const outputPath = join(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);\n if (!force && existsSync(outputPath)) continue;\n\n await new Promise<void>((resolve, reject) => {\n execFile(\n ffmpegPath,\n ['-y', '-i', videoPath, '-vf', thumb.filter, '-frames:v', '1', '-q:v', '2', outputPath],\n { timeout: 30_000 },\n (err) => {\n if (err) reject(new Error(`Thumbnail extraction failed (${thumb.name}): ${err.message}`));\n else resolve();\n },\n );\n });\n }\n}\n","/**\n * detectFfmpeg\n *\n * Checks whether a native ffmpeg binary is available on the system PATH.\n * Returns the absolute path to the binary if found, or null if not.\n *\n * Uses `which` on macOS/Linux and `where` on Windows.\n */\n\nimport { execFile } from 'node:child_process';\n\n/**\n * Detect whether native ffmpeg is installed and available on PATH.\n *\n * @returns Absolute path to ffmpeg binary, or null if not found\n */\nexport async function detectFfmpeg(): Promise<string | null> {\n const command = process.platform === 'win32' ? 'where' : 'which';\n\n return new Promise((resolve) => {\n execFile(command, ['ffmpeg'], { timeout: 5000 }, (err, stdout) => {\n if (err || !stdout.trim()) {\n resolve(null);\n return;\n }\n // `which` and `where` can return multiple lines; take the first\n const path = stdout.trim().split('\\n')[0].trim();\n resolve(path);\n });\n });\n}\n","/**\n * validate command\n *\n * Structural validation for squisq markdown documents — fast feedback for\n * authors and agents without opening a browser. Reports unknown template\n * names (with did-you-mean), unparsed `{[…]}` annotations, malformed\n * heading attributes, unresolved `connectsTo` targets, duplicate block ids,\n * unparseable data fences, and image references missing from the bundle.\n *\n * Exit code: 0 when clean (or warnings only), 1 when errors are present\n * (or any finding with --strict), 2 when the input could not be read.\n *\n * Usage:\n * squisq validate <input> [--json] [--strict]\n */\n\nimport { existsSync, statSync } from 'node:fs';\nimport { dirname, extname, join, resolve } from 'node:path';\nimport type { Command } from 'commander';\nimport type { DocDiagnostic } from '@bendyline/squisq/schemas';\nimport { validateMarkdownDoc } from '@bendyline/squisq/doc';\nimport { readInput } from '../util/readInput.js';\n\ninterface ValidateOpts {\n json?: boolean;\n strict?: boolean;\n}\n\nexport function registerValidateCommand(program: Command): void {\n program\n .command('validate')\n .description('Validate a squisq markdown document and report structural problems')\n .argument('<input>', 'Path to .md file, .zip/.dbk container, or folder')\n .option('--json', 'Output diagnostics as JSON (for tooling and agents)')\n .option('--strict', 'Exit non-zero on warnings as well as errors')\n .action(async (inputPath: string, opts: ValidateOpts) => {\n try {\n const exitCode = await runValidate(inputPath, opts);\n process.exitCode = exitCode;\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n console.error(`Error: ${message}`);\n process.exitCode = 2;\n }\n });\n}\n\nasync function runValidate(inputPath: string, opts: ValidateOpts): Promise<number> {\n const resolvedInput = resolve(inputPath);\n const { container, markdownDoc, doc } = await readInput(resolvedInput);\n\n if (!markdownDoc) {\n // Doc-JSON input: nothing markdown-level to validate. Surface any\n // diagnostics already recorded on the doc.\n const diagnostics = doc?.diagnostics ?? [];\n return report(diagnostics, opts, 'doc.json input — markdown-level checks skipped');\n }\n\n // Asset check source: container file list (dbk/zip/folder) or, for a bare\n // .md file, the filesystem relative to the document's folder.\n const entries = await container.listFiles();\n const containerPaths = new Set(entries.map((e) => e.path));\n const isBareMarkdown =\n statSync(resolvedInput).isFile() &&\n !['.zip', '.dbk'].includes(extname(resolvedInput).toLowerCase());\n const baseDir = dirname(resolvedInput);\n const hasAsset = (path: string): boolean => {\n if (containerPaths.has(path) || containerPaths.has(path.replace(/^\\.\\//, ''))) return true;\n if (isBareMarkdown) return existsSync(join(baseDir, path));\n return false;\n };\n\n const result = validateMarkdownDoc(markdownDoc, { assets: hasAsset });\n return report(result.diagnostics, opts);\n}\n\nfunction report(diagnostics: DocDiagnostic[], opts: ValidateOpts, note?: string): number {\n const errorCount = diagnostics.filter((d) => d.severity === 'error').length;\n const warningCount = diagnostics.length - errorCount;\n\n if (opts.json) {\n // Machine-readable output goes to stdout so it can be piped/parsed;\n // everything human-facing follows the CLI convention of stderr.\n process.stdout.write(`${JSON.stringify({ errorCount, warningCount, diagnostics }, null, 2)}\\n`);\n } else {\n if (note) console.error(note);\n for (const d of diagnostics) {\n const location = d.line != null ? `line ${d.line}` : (d.blockId ?? '—');\n console.error(\n `${d.severity === 'error' ? 'ERROR ' : 'warning'} ${location} [${d.code}] ${d.message}`,\n );\n }\n if (diagnostics.length === 0) {\n console.error('✓ No problems found');\n } else {\n console.error(`\\n${errorCount} error(s), ${warningCount} warning(s)`);\n }\n }\n\n if (errorCount > 0) return 1;\n if (opts.strict && warningCount > 0) return 1;\n return 0;\n}\n"],"mappings":";;;AAWA,SAAS,eAAe;;;ACExB,SAAS,WAAW,aAAa;AACjC,SAAS,SAAS,UAAU,WAAAA,UAAS,QAAAC,OAAM,eAAe;;;ACF1D,SAAS,UAAU,SAAS,YAAY;AACxC,SAAS,MAAM,eAAe;AAC9B,SAAS,qBAAqB;AAG9B,SAAS,8BAA8B;AACvC,SAAS,sBAAsB;AAW/B,IAAM,aAAqC;AAAA,EACzC,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,SAAS;AACX;AAEA,SAAS,YAAY,UAA0B;AAC7C,SAAO,WAAW,QAAQ,QAAQ,EAAE,YAAY,CAAC,KAAK;AACxD;AAKA,eAAe,QAAQ,MAAc,SAAS,IAAuB;AACnE,QAAM,UAAU,MAAM,QAAQ,MAAM,EAAE,eAAe,KAAK,CAAC;AAC3D,QAAM,QAAkB,CAAC;AAEzB,aAAW,SAAS,SAAS;AAC3B,UAAM,UAAU,SAAS,GAAG,MAAM,IAAI,MAAM,IAAI,KAAK,MAAM;AAC3D,QAAI,MAAM,YAAY,GAAG;AACvB,YAAM,KAAK,GAAI,MAAM,QAAQ,KAAK,MAAM,MAAM,IAAI,GAAG,OAAO,CAAE;AAAA,IAChE,WAAW,MAAM,OAAO,GAAG;AACzB,YAAM,KAAK,OAAO;AAAA,IACpB;AAAA,EACF;AAEA,SAAO;AACT;AAUA,eAAsB,UAAU,WAA6C;AAC3E,QAAM,OAAO,MAAM,KAAK,SAAS;AAEjC,MAAI,KAAK,YAAY,GAAG;AACtB,WAAO,WAAW,SAAS;AAAA,EAC7B;AAEA,QAAM,MAAM,QAAQ,SAAS,EAAE,YAAY;AAC3C,MAAI,QAAQ,UAAU,QAAQ,QAAQ;AACpC,WAAO,cAAc,SAAS;AAAA,EAChC;AAEA,MAAI,QAAQ,SAAS;AACnB,WAAO,gBAAgB,SAAS;AAAA,EAClC;AAGA,SAAO,iBAAiB,SAAS;AACnC;AAEA,eAAe,iBAAiB,UAA4C;AAC1E,QAAM,UAAU,MAAM,SAAS,UAAU,OAAO;AAChD,QAAM,YAAY,IAAI,uBAAuB;AAC7C,QAAM,UAAU,cAAc,OAAO;AACrC,QAAM,cAAc,cAAc,OAAO;AACzC,SAAO,EAAE,WAAW,YAAY;AAClC;AAMA,eAAe,gBAAgB,UAA4C;AACzE,QAAM,UAAU,MAAM,SAAS,UAAU,OAAO;AAChD,QAAM,MAAM,KAAK,MAAM,OAAO;AAC9B,QAAM,YAAY,IAAI,uBAAuB;AAC7C,SAAO,EAAE,WAAW,aAAa,MAAM,IAAI;AAC7C;AAMA,IAAM,iBAAiB,CAAC,YAAY,YAAY;AAEhD,eAAe,cAAc,UAA4C;AACvE,QAAM,OAAO,MAAM,SAAS,QAAQ;AACpC,QAAM,YAAY,MAAM;AAAA,IACtB,KAAK,OAAO,MAAM,KAAK,YAAY,KAAK,aAAa,KAAK,UAAU;AAAA,EACtE;AAGA,aAAW,QAAQ,gBAAgB;AACjC,UAAM,WAAW,MAAM,UAAU,SAAS,IAAI;AAC9C,QAAI,UAAU;AACZ,YAAM,UAAU,IAAI,YAAY;AAChC,YAAM,MAAM,KAAK,MAAM,QAAQ,OAAO,QAAQ,CAAC;AAC/C,aAAO,EAAE,WAAW,aAAa,MAAM,IAAI;AAAA,IAC7C;AAAA,EACF;AAEA,QAAM,WAAW,MAAM,UAAU,aAAa;AAC9C,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,MAAM,wDAAwD,QAAQ,EAAE;AAAA,EACpF;AAEA,QAAM,cAAc,cAAc,QAAQ;AAC1C,SAAO,EAAE,WAAW,YAAY;AAClC;AAEA,eAAe,WAAW,SAA2C;AACnE,QAAM,YAAY,IAAI,uBAAuB;AAC7C,QAAM,QAAQ,MAAM,QAAQ,OAAO;AAEnC,aAAW,WAAW,OAAO;AAC3B,UAAM,UAAU,KAAK,SAAS,OAAO;AACrC,UAAM,OAAO,MAAM,SAAS,OAAO;AACnC,UAAM,UAAU;AAAA,MACd;AAAA,MACA,IAAI,WAAW,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU;AAAA,MAC5D,YAAY,OAAO;AAAA,IACrB;AAAA,EACF;AAGA,aAAW,QAAQ,gBAAgB;AACjC,UAAM,WAAW,MAAM,UAAU,SAAS,IAAI;AAC9C,QAAI,UAAU;AACZ,YAAM,UAAU,IAAI,YAAY;AAChC,YAAM,MAAM,KAAK,MAAM,QAAQ,OAAO,QAAQ,CAAC;AAC/C,aAAO,EAAE,WAAW,aAAa,MAAM,IAAI;AAAA,IAC7C;AAAA,EACF;AAEA,QAAM,WAAW,MAAM,UAAU,aAAa;AAC9C,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,MAAM,qDAAqD,OAAO,EAAE;AAAA,EAChF;AAEA,QAAM,cAAc,cAAc,QAAQ;AAC1C,SAAO,EAAE,WAAW,YAAY;AAClC;;;AD9JA,IAAM,cAAc,CAAC,QAAQ,QAAQ,OAAO,QAAQ,WAAW,QAAQ,KAAK;AAG5E,SAAS,aAAa,OAAyB;AAC7C,QAAM,YAAY,MAAM,MAAM,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,YAAY,CAAC;AACpE,QAAM,QAAkB,CAAC;AACzB,aAAW,KAAK,WAAW;AACzB,QAAI,YAAY,SAAS,CAAW,GAAG;AACrC,YAAM,KAAK,CAAW;AAAA,IACxB,OAAO;AACL,cAAQ,KAAK,mBAAmB,CAAC,6BAAwB,YAAY,KAAK,IAAI,CAAC,EAAE;AAAA,IACnF;AAAA,EACF;AACA,MAAI,MAAM,WAAW,GAAG;AACtB,UAAM,IAAI,MAAM,sCAAsC,YAAY,KAAK,IAAI,CAAC,EAAE;AAAA,EAChF;AACA,SAAO;AACT;AAUO,SAAS,uBAAuBC,UAAwB;AAC7D,EAAAA,SACG,QAAQ,SAAS,EACjB,YAAY,iFAAiF,EAC7F,SAAS,WAAW,kDAAkD,EACtE,OAAO,0BAA0B,2CAA2C,EAC5E;AAAA,IACC;AAAA,IACA,6DAA6D,YAAY,KAAK,IAAI,CAAC;AAAA,EACrF,EACC,OAAO,oBAAoB,+DAA+D,EAC1F;AAAA,IACC;AAAA,IACA;AAAA,EACF,EACC;AAAA,IACC;AAAA,IACA;AAAA,EACF,EACC,OAAO,OAAO,WAAmB,SAAsB;AACtD,QAAI;AACF,YAAM,WAAW,WAAW,IAAI;AAAA,IAClC,SAAS,KAAc;AACrB,YAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,cAAQ,MAAM,UAAU,OAAO,EAAE;AACjC,cAAQ,WAAW;AAAA,IACrB;AAAA,EACF,CAAC;AACL;AAEA,eAAe,WAAW,WAAmB,MAAkC;AAC7E,QAAM,gBAAgB,QAAQ,SAAS;AAGvC,QAAM,UAAoB,KAAK,UAAU,aAAa,KAAK,OAAO,IAAI,CAAC,GAAG,WAAW;AAGrF,QAAM,YAAY,KAAK,YAAY,QAAQ,KAAK,SAAS,IAAI,QAAQ,aAAa;AAClF,QAAM,gBAAgB,SAAS,aAAa;AAC5C,QAAM,WAAWC,SAAQ,aAAa;AACtC,QAAM,WAAW,WAAW,cAAc,MAAM,GAAG,CAAC,SAAS,MAAM,IAAI;AAGvE,QAAM,MAAM,WAAW,EAAE,WAAW,KAAK,CAAC;AAK1C,MAAI,KAAK,WAAW;AAClB,UAAM,EAAE,qBAAqB,IAAI,MAAM,OAAO,6BAA6B;AAC3E,UAAM,SAAS,qBAAqB;AACpC,QAAI,CAAC,OAAO,SAAS,KAAK,SAAS,GAAG;AACpC,YAAM,IAAI;AAAA,QACR,4BAA4B,KAAK,SAAS,iBAAiB,OAAO,KAAK,IAAI,CAAC;AAAA,MAC9E;AAAA,IACF;AAAA,EACF;AAEA,UAAQ,MAAM,YAAY,aAAa,EAAE;AACzC,QAAM,SAAS,MAAM,UAAU,aAAa;AAC5C,QAAM,EAAE,UAAU,IAAI;AAEtB,MAAI,CAAC,OAAO,aAAa;AACvB,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAIA,MAAI,KAAK,OAAO;AACd,UAAM,EAAE,mBAAmB,IAAI,MAAM,OAAO,2BAA2B;AACvE,UAAM,EAAE,gCAAgC,IAAI,MAAM,OAAO,uBAAuB;AAChF,UAAM,WAAW,mBAAmB;AACpC,UAAM,aAAa,gCAAgC,OAAO,YAAY,WAAW,KAAK,CAAC,GAAG;AAAA,MACxF,CAAC,MAAM,EAAE;AAAA,IACX;AACA,QAAI,CAAC,SAAS,SAAS,KAAK,KAAK,KAAK,CAAC,UAAU,SAAS,KAAK,KAAK,GAAG;AACrE,YAAM,IAAI;AAAA,QACR,kBAAkB,KAAK,KAAK,iBAAiB,CAAC,GAAG,UAAU,GAAG,SAAS,EAAE,KAAK,IAAI,CAAC;AAAA,MACrF;AAAA,IACF;AAAA,EACF;AAGA,MAAI,oBAAoB,OAAO;AAC/B,MAAI,KAAK,WAAW;AAClB,wBAAoB,MAAM;AAAA,MACxB,OAAO;AAAA,MACP;AAAA,MACA,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AAAA,IACP;AACA,YAAQ,MAAM,wBAAwB,KAAK,SAAS,EAAE;AAAA,EACxD;AAEA,QAAM,UAAU,KAAK;AAErB,aAAW,UAAU,SAAS;AAC5B,UAAM,UAAUC,MAAK,WAAW,GAAG,QAAQ,IAAI,MAAM,EAAE;AAEvD,YAAQ,QAAQ;AAAA,MACd,KAAK,QAAQ;AACX,cAAM,EAAE,kBAAkB,IAAI,MAAM,OAAO,gCAAgC;AAC3E,cAAM,MAAM,MAAM,kBAAkB,mBAAmB,EAAE,QAAQ,CAAC;AAClE,cAAM,UAAU,SAAS,OAAO,KAAK,GAAG,CAAC;AACzC;AAAA,MACF;AAAA,MAEA,KAAK,QAAQ;AACX,cAAM,EAAE,kBAAkB,IAAI,MAAM,OAAO,gCAAgC;AAE3E,cAAM,SAAS,MAAM,uBAAuB,SAAS;AACrD,cAAM,MAAM,MAAM,kBAAkB,mBAAmB,EAAE,SAAS,OAAO,CAAC;AAC1E,cAAM,UAAU,SAAS,OAAO,KAAK,GAAG,CAAC;AACzC;AAAA,MACF;AAAA,MAEA,KAAK,OAAO;AACV,cAAM,EAAE,iBAAiB,IAAI,MAAM,OAAO,+BAA+B;AACzE,cAAM,MAAM,MAAM,iBAAiB,mBAAmB,EAAE,QAAQ,CAAC;AACjE,cAAM,UAAU,SAAS,OAAO,KAAK,GAAG,CAAC;AACzC;AAAA,MACF;AAAA,MAEA,KAAK,QAAQ;AACX,cAAM,EAAE,cAAc,IAAI,MAAM,OAAO,uBAAuB;AAC9D,cAAM,EAAE,UAAU,IAAI,MAAM,OAAO,gCAAgC;AACnE,cAAM,EAAE,cAAc,IAAI,MAAM,OAAO,2CAA2C;AAElF,cAAM,MAAM,cAAc,mBAAmB,EAAE,eAAe,KAAK,cAAc,CAAC;AAClF,cAAM,SAAS,MAAM,qBAAqB,KAAK,SAAS;AAExD,cAAM,OAAO,UAAU,KAAK;AAAA,UAC1B,cAAc;AAAA,UACd;AAAA,UACA,OAAO;AAAA,UACP,MAAM;AAAA,UACN;AAAA,QACF,CAAC;AACD,cAAM,UAAU,SAAS,MAAM,OAAO;AACtC;AAAA,MACF;AAAA,MAEA,KAAK,WAAW;AACd,cAAM,EAAE,cAAc,IAAI,MAAM,OAAO,uBAAuB;AAC9D,cAAM,EAAE,aAAa,IAAI,MAAM,OAAO,gCAAgC;AACtE,cAAM,EAAE,cAAc,IAAI,MAAM,OAAO,2CAA2C;AAElF,cAAM,MAAM,cAAc,mBAAmB,EAAE,eAAe,KAAK,cAAc,CAAC;AAClF,cAAM,SAAS,MAAM,qBAAqB,KAAK,SAAS;AAExD,cAAM,OAAO,MAAM,aAAa,KAAK;AAAA,UACnC,cAAc;AAAA,UACd;AAAA,UACA,OAAO;AAAA,UACP,MAAM;AAAA,UACN;AAAA,QACF,CAAC;AACD,cAAM,MAAM,OAAO,KAAK,MAAM,KAAK,YAAY,CAAC;AAChD,cAAM,UAAU,QAAQ,QAAQ,cAAc,WAAW,GAAG,GAAG;AAC/D;AAAA,MACF;AAAA,MAEA,KAAK,QAAQ;AACX,cAAM,EAAE,kBAAkB,IAAI,MAAM,OAAO,gCAAgC;AAC3E,cAAM,SAAS,MAAM,uBAAuB,SAAS;AAGrD,cAAM,YAAY,oBAAI,IAAyB;AAC/C,cAAM,gBAAgB,OAAO,KAAK,OAAO;AACzC,YAAI,eAAe,QAAQ;AACzB,qBAAW,OAAO,eAAe;AAC/B,kBAAM,OAAO,MAAM,UAAU,SAAS,IAAI,GAAG;AAC7C,gBAAI,KAAM,WAAU,IAAI,IAAI,KAAK,IAAI;AAAA,UACvC;AAAA,QACF;AAEA,cAAM,eAAe,UAAU,OAAO;AACtC,cAAM,MAAM,MAAM,kBAAkB,mBAAmB;AAAA,UACrD,OAAO;AAAA,UACP;AAAA,UACA;AAAA,UACA,OAAO,eAAe,YAAY;AAAA,UAClC,eAAe,eAAe,gBAAgB;AAAA,UAC9C,eAAe,eAAe,OAAO,KAAK,WAAW;AAAA,QACvD,CAAC;AACD,cAAM,UAAU,SAAS,OAAO,KAAK,GAAG,CAAC;AACzC;AAAA,MACF;AAAA,MAEA,KAAK,OAAO;AACV,cAAM,EAAE,eAAe,IAAI,MAAM,OAAO,qCAAqC;AAC7E,cAAM,OAAO,MAAM,eAAe,SAAS;AAC3C,cAAM,MAAM,MAAM,KAAK,YAAY;AACnC,cAAM,UAAU,SAAS,OAAO,KAAK,GAAG,CAAC;AACzC;AAAA,MACF;AAAA,IACF;AAEA,YAAQ,MAAM,YAAO,OAAO,EAAE;AAAA,EAChC;AAEA,UAAQ,MAAM,OAAO;AACvB;AAUA,eAAe,yBACb,aACA,WACA,gBACA,SACA,eAC2B;AAC3B,QAAM,EAAE,eAAe,cAAc,IAAI,MAAM,OAAO,uBAAuB;AAC7E,QAAM,EAAE,gBAAgB,iBAAiB,IAAI,MAAM,OAAO,6BAA6B;AAEvF,QAAM,MAAM,cAAc,aAAa,EAAE,cAAc,CAAC;AAGxD,QAAM,SAAS,iBAAiB,IAAI,MAAM;AAE1C,QAAM,SAAS,eAAe,KAAK,gBAAgB;AAAA,IACjD;AAAA,IACA;AAAA,EACF,CAAC;AAED,SAAO,cAAc,OAAO,GAAG;AACjC;AAWA,eAAe,qBACb,KACA,WACmC;AACnC,QAAM,EAAE,mBAAmB,gBAAgB,IAAI,MAAM,OAAO,gCAAgC;AAE5F,QAAM,SAAS,oBAAI,IAAyB;AAC5C,QAAM,WAAW,kBAAkB,GAAG;AAGtC,QAAM,iBAAiB,oBAAI,IAAY;AACvC,aAAW,WAAW,UAAU;AAC9B,UAAM,OAAO,MAAM,UAAU,SAAS,OAAO;AAC7C,QAAI,MAAM;AACR,aAAO,IAAI,SAAS,IAAI;AACxB,YAAM,WAAW,gBAAgB,OAAO;AACxC,UAAI,aAAa,QAAS,QAAO,IAAI,UAAU,IAAI;AAAA,IACrD,OAAO;AACL,qBAAe,IAAI,gBAAgB,OAAO,CAAC;AAAA,IAC7C;AAAA,EACF;AAIA,MAAI,eAAe,OAAO,GAAG;AAC3B,UAAM,QAAQ,MAAM,UAAU,UAAU;AACxC,eAAW,QAAQ,OAAO;AACxB,YAAM,WAAW,gBAAgB,KAAK,IAAI;AAC1C,UAAI,eAAe,IAAI,QAAQ,GAAG;AAChC,cAAM,OAAO,MAAM,UAAU,SAAS,KAAK,IAAI;AAC/C,YAAI,MAAM;AACR,iBAAO,IAAI,UAAU,IAAI;AACzB,iBAAO,IAAI,KAAK,MAAM,IAAI;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAKA,eAAe,uBACb,WACmC;AACnC,QAAM,SAAS,oBAAI,IAAyB;AAC5C,QAAM,QAAQ,MAAM,UAAU,UAAU;AACxC,aAAW,QAAQ,OAAO;AACxB,QAAI,2CAA2C,KAAK,KAAK,IAAI,GAAG;AAC9D,YAAM,OAAO,MAAM,UAAU,SAAS,KAAK,IAAI;AAC/C,UAAI,MAAM;AACR,eAAO,IAAI,KAAK,MAAM,IAAI;AAAA,MAC5B;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;;;AEtVA,SAAS,SAAAC,cAAa;AACtB,SAAS,WAAAC,UAAS,YAAAC,WAAU,WAAAC,UAAS,WAAAC,gBAAe;;;ACWpD,SAAS,4BAA4B;AACrC,SAAS,qBAAqB;AAG9B,SAAS,0BAA0B;AACnC,SAAS,yBAAyB;;;AClBlC,SAAS,gBAAgB;AAOzB,eAAsB,eAAuC;AAC3D,QAAM,UAAU,QAAQ,aAAa,UAAU,UAAU;AAEzD,SAAO,IAAI,QAAQ,CAACC,aAAY;AAC9B,aAAS,SAAS,CAAC,QAAQ,GAAG,EAAE,SAAS,IAAK,GAAG,CAAC,KAAK,WAAW;AAChE,UAAI,OAAO,CAAC,OAAO,KAAK,GAAG;AACzB,QAAAA,SAAQ,IAAI;AACZ;AAAA,MACF;AAEA,YAAM,OAAO,OAAO,KAAK,EAAE,MAAM,IAAI,EAAE,CAAC,EAAE,KAAK;AAC/C,MAAAA,SAAQ,IAAI;AAAA,IACd,CAAC;AAAA,EACH,CAAC;AACH;;;ADEA,SAAS,0BAAAC,+BAA8B;AAgEvC,eAAsB,eACpB,KACA,WACA,SAC+B;AAC/B,QAAM;AAAA,IACJ;AAAA,IACA,MAAM;AAAA,IACN,UAAU;AAAA,IACV,cAAc;AAAA,IACd;AAAA,IACA,eAAe;AAAA,IACf;AAAA,EACF,IAAI;AAEJ,QAAM,aAAa,kBAAkB;AAAA,IACnC;AAAA,IACA,OAAO,QAAQ;AAAA,IACf,QAAQ,QAAQ;AAAA,EAClB,CAAC;AAGD,QAAM,aAAa,MAAM,aAAa;AACtC,MAAI,CAAC,YAAY;AACf,UAAM,IAAI;AAAA,MACR;AAAA,IAKF;AAAA,EACF;AAEA,eAAa,oBAAoB,CAAC;AAGlC,QAAM,EAAE,kBAAkB,IAAI,MAAM,OAAO,gCAAgC;AAC3E,QAAM,aAAa,kBAAkB,GAAG;AACxC,QAAM,SAAS,oBAAI,IAAyB;AAC5C,aAAW,WAAW,YAAY;AAChC,UAAM,OAAO,MAAM,UAAU,SAAS,OAAO;AAC7C,QAAI,MAAM;AACR,aAAO,IAAI,SAAS,IAAI;AAAA,IAC1B;AAAA,EACF;AAGA,QAAM,QAAQ,oBAAI,IAAyB;AAC3C,QAAM,eAA8B,CAAC;AACrC,MAAI,IAAI,OAAO,UAAU,QAAQ;AAC/B,eAAW,OAAO,IAAI,MAAM,UAAU;AACpC,YAAM,OAAO,MAAM,UAAU,SAAS,IAAI,GAAG;AAC7C,UAAI,MAAM;AACR,cAAM,IAAI,IAAI,KAAK,IAAI;AACvB,cAAM,IAAI,IAAI,MAAM,IAAI;AACxB,qBAAa,KAAK,IAAI;AAAA,MACxB;AAAA,IACF;AAAA,EACF;AAGA,MAAI,oBAAuC;AAC3C,MAAI,aAAa,SAAS,GAAG;AAC3B,wBAAoB,MAAM,wBAAwB,cAAc,UAAU;AAAA,EAC5E;AAOA,QAAM,WAAW,qBAAqB,GAAG;AACzC,QAAM,YAAY,IAAI,IAAY,SAAS,IAAI,CAAC,MAAM,EAAE,GAAG,CAAC;AAC5D,aAAW,SAAS,cAAc,IAAI,MAAM,GAAG;AAC7C,eAAW,SAAS,MAAM,UAAU,CAAC,GAAG;AACtC,UAAI,MAAM,SAAS,QAAS,WAAU,IAAI,MAAM,QAAQ,GAAG;AAAA,IAC7D;AAAA,EACF;AAEA,QAAM,cAAc,oBAAI,IAAyB;AACjD,aAAW,OAAO,WAAW;AAC3B,QAAI,OAAO,IAAI,GAAG,EAAG;AACrB,UAAM,OAAO,MAAM,UAAU,SAAS,GAAG;AACzC,QAAI,KAAM,QAAO,IAAI,KAAK,IAAI;AAAA,EAChC;AACA,aAAW,QAAQ,UAAU;AAC3B,QAAI,KAAK,SAAS,QAAS;AAC3B,UAAM,OAAO,OAAO,IAAI,KAAK,GAAG,KAAM,MAAM,UAAU,SAAS,KAAK,GAAG;AACvE,QAAI,KAAM,aAAY,IAAI,KAAK,IAAI,IAAI;AAAA,EACzC;AAEA,eAAa,0BAA0B,EAAE;AAGzC,QAAM,EAAE,cAAc,IAAI,MAAM,OAAO,2CAA2C;AAClF,QAAM,aAAa,mBAAmB,KAAK;AAAA,IACzC,cAAc;AAAA,IACd;AAAA,IACA,OAAO,MAAM,OAAO,IAAI,QAAQ;AAAA,IAChC,OAAO,WAAW;AAAA,IAClB,QAAQ,WAAW;AAAA,IACnB;AAAA,EACF,CAAC;AAED,eAAa,qBAAqB,EAAE;AAGpC,QAAM,EAAE,SAAS,IAAI,MAAM,OAAO,iBAAiB;AACnD,QAAM,UAAU,MAAM,SAAS,OAAO,EAAE,UAAU,KAAK,CAAC;AACxD,QAAM,OAAO,MAAM,QAAQ,QAAQ;AAAA,IACjC,UAAU,EAAE,OAAO,WAAW,OAAO,QAAQ,WAAW,OAAO;AAAA,EACjE,CAAC;AAED,QAAM,aAAuB,CAAC;AAC9B,OAAK,GAAG,aAAa,CAAC,QAAQ,WAAW,KAAK,IAAI,OAAO,CAAC;AAE1D,QAAM,KAAK,WAAW,YAAY,EAAE,WAAW,OAAO,CAAC;AACvD,QAAM,KAAK,eAAe,GAAG;AAE7B,MAAI;AACF,UAAM,KAAK;AAAA,MACT,MAAM,OAAQ,OAA8C,gBAAgB;AAAA,MAC5E,EAAE,SAAS,KAAM;AAAA,IACnB;AAAA,EACF,QAAQ;AACN,UAAM,QAAQ,MAAM;AACpB,UAAM,cAAc,WAAW,SAC3B;AAAA;AAAA,IAAqB,WAAW,KAAK,MAAM,CAAC,KAC5C;AACJ,UAAM,IAAI,MAAM,mDAAmD,WAAW,EAAE;AAAA,EAClF;AAEA,QAAM,cAAsB,MAAM,KAAK,SAAS,MAAM;AACpD,WAAQ,OAAoD,YAAY;AAAA,EAC1E,CAAC;AAED,MAAI,eAAe,GAAG;AACpB,UAAM,QAAQ,MAAM;AACpB,UAAM,IAAI,MAAM,qDAAgD;AAAA,EAClE;AAEA,QAAM,kBAAkB,KAAK,KAAK,cAAc,GAAG;AACnD,QAAM,oBAAoB,KAAK,KAAK,eAAe,GAAG;AACtD,QAAM,cAAc,oBAAoB;AACxC,QAAM,SAAuB,CAAC;AAE9B,eAAa,oBAAoB,EAAE;AAGnC,MAAI,oBAAoB,GAAG;AACzB,UAAM,WAAoB,MAAM,KAAK,SAAS,MAAM;AAClD,YAAM,IAAI;AACV,aAAO,OAAO,EAAE,kBAAkB,aAAa,EAAE,cAAc,IAAI;AAAA,IACrE,CAAC;AAED,QAAI,UAAU;AACZ,YAAM,KAAK,SAAS,MAAM;AACxB,QAAC,OAAgD,UAAU;AAAA,MAC7D,CAAC;AACD,YAAM,KAAK,eAAe,GAAG;AAC7B,YAAM,aAAa,IAAI,WAAW,MAAM,KAAK,WAAW,EAAE,MAAM,MAAM,CAAC,CAAC;AACxE,eAAS,IAAI,GAAG,IAAI,mBAAmB,KAAK;AAC1C,eAAO,KAAK,UAAU;AAAA,MACxB;AACA,YAAM,KAAK,SAAS,MAAM;AACxB,QAAC,OAAgD,UAAU;AAAA,MAC7D,CAAC;AAAA,IACH;AAAA,EACF;AAGA,QAAM,gBAAgB,IAAI;AAC1B,WAAS,IAAI,GAAG,IAAI,iBAAiB,KAAK;AACxC,UAAM,OAAO,IAAI;AACjB,UAAM,KAAK,SAAS,CAAC,MAAc;AACjC,aAAQ,OAA+D,OAAO,CAAC;AAAA,IACjF,GAAG,IAAI;AAEP,UAAM,aAAa,MAAM,KAAK,WAAW,EAAE,MAAM,MAAM,CAAC;AACxD,WAAO,KAAK,IAAI,WAAW,UAAU,CAAC;AAGtC,QAAI,IAAI,KAAK,IAAI,GAAG,KAAK,MAAM,MAAM,CAAC,CAAC,MAAM,KAAK,MAAM,kBAAkB,GAAG;AAC3E,YAAM,MAAM,KAAK,KAAK,MAAO,OAAO,SAAS,cAAe,EAAE;AAC9D,mBAAa,oBAAoB,GAAG;AAAA,IACtC;AAAA,EACF;AAEA,QAAM,QAAQ,MAAM;AAEpB,eAAa,kBAAkB,EAAE;AAMjC,QAAM,sBAAsB,SAAS,OAAO,CAAC,MAAM,EAAE,SAAS,WAAW,YAAY,IAAI,EAAE,EAAE,CAAC;AAC9F,MAAI,gBAAgB;AACpB,MAAI,oBAAoB,SAAS,KAAK,YAAY;AAChD,oBAAgB,MAAM;AAAA,MACpB;AAAA,MACA;AAAA,MACA,oBAAoB,IAAI,CAAC,OAAO;AAAA,QAC9B,QAAQ,YAAY,IAAI,EAAE,EAAE;AAAA,QAC5B,UAAU,EAAE,gBAAgB;AAAA,QAC5B,WAAW,EAAE;AAAA,QACb,SAAS,KAAK,IAAI,GAAG,EAAE,cAAc,EAAE,aAAa;AAAA,MACtD,EAAE;AAAA,MACF;AAAA,IACF;AAAA,EACF,WAAW,eAAe,KAAK,mBAAmB;AAEhD,oBAAgB,MAAM,cAAc,YAAY,mBAAmB,YAAY;AAAA,EACjF;AAEA,QAAM,EAAE,kBAAkB,IAAI,MAAM,OAAO,6BAAyB;AACpE,QAAM,kBAAkB,YAAY,QAAQ,eAAe,YAAY;AAAA,IACrE;AAAA,IACA;AAAA,IACA;AAAA,IACA,OAAO,WAAW;AAAA,IAClB,QAAQ,WAAW;AAAA,IACnB,YAAY,CAAC,SAAS,UAAU;AAC9B,mBAAa,aAAa,KAAK,IAAI,KAAK,KAAK,MAAM,UAAU,GAAG,CAAC;AAAA,IACnE;AAAA,EACF,CAAC;AAED,eAAa,QAAQ,GAAG;AAExB,QAAM,gBAAgB,cAAc;AACpC,SAAO;AAAA,IACL,UAAU;AAAA,IACV,YAAY,OAAO;AAAA,IACnB;AAAA,EACF;AACF;AAQA,eAAe,wBACb,SACA,YACqB;AACrB,MAAI,QAAQ,WAAW,EAAG,QAAO,IAAI,WAAW,CAAC;AACjD,MAAI,QAAQ,WAAW,EAAG,QAAO,IAAI,WAAW,QAAQ,CAAC,CAAC;AAE1D,MAAI,YAAY;AACd,WAAO,uBAAuB,YAAY,OAAO;AAAA,EACnD;AAGA,QAAM,cAAc,QAAQ,OAAO,CAAC,KAAK,MAAM,MAAM,EAAE,YAAY,CAAC;AACpE,QAAM,SAAS,IAAI,WAAW,WAAW;AACzC,MAAI,SAAS;AACb,aAAW,OAAO,SAAS;AACzB,WAAO,IAAI,IAAI,WAAW,GAAG,GAAG,MAAM;AACtC,cAAU,IAAI;AAAA,EAChB;AACA,SAAO;AACT;AAEA,eAAe,uBACb,YACA,SACqB;AACrB,QAAM,EAAE,WAAAC,YAAW,UAAAC,WAAU,OAAAC,QAAO,GAAG,IAAI,MAAM,OAAO,aAAkB;AAC1E,QAAM,EAAE,MAAAC,MAAK,IAAI,MAAM,OAAO,MAAW;AACzC,QAAM,EAAE,OAAO,IAAI,MAAM,OAAO,IAAS;AACzC,QAAM,EAAE,YAAY,IAAI,MAAM,OAAO,QAAa;AAClD,QAAM,EAAE,UAAAC,UAAS,IAAI,MAAM,OAAO,eAAoB;AAEtD,QAAM,QAAQ,YAAY,CAAC,EAAE,SAAS,KAAK;AAC3C,QAAM,UAAUD,MAAK,OAAO,GAAG,uBAAuB,KAAK,EAAE;AAC7D,QAAMD,OAAM,SAAS,EAAE,WAAW,KAAK,CAAC;AAExC,MAAI;AACF,UAAM,eAAyB,CAAC;AAChC,aAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,YAAM,UAAUC,MAAK,SAAS,OAAO,CAAC,MAAM;AAC5C,YAAMH,WAAU,SAAS,IAAI,WAAW,QAAQ,CAAC,CAAC,CAAC;AACnD,mBAAa,KAAK,OAAO;AAAA,IAC3B;AAEA,UAAM,WAAWG,MAAK,SAAS,iBAAiB;AAChD,UAAM,cAAc,aAAa,IAAI,CAAC,MAAM,SAAS,EAAE,QAAQ,OAAO,GAAG,CAAC,GAAG,EAAE,KAAK,IAAI;AACxF,UAAMH,WAAU,UAAU,WAAW;AAErC,UAAM,aAAaG,MAAK,SAAS,cAAc;AAC/C,UAAM,IAAI,QAAc,CAACE,UAAS,WAAW;AAC3C,MAAAD;AAAA,QACE;AAAA,QACA,CAAC,MAAM,MAAM,UAAU,SAAS,KAAK,MAAM,UAAU,MAAM,QAAQ,UAAU;AAAA,QAC7E,EAAE,SAAS,KAAQ;AAAA,QACnB,CAAC,QAAQ;AACP,cAAI,IAAK,QAAO,IAAI,MAAM,+BAA+B,IAAI,OAAO,EAAE,CAAC;AAAA,cAClE,CAAAC,SAAQ;AAAA,QACf;AAAA,MACF;AAAA,IACF,CAAC;AAED,UAAM,OAAO,MAAMJ,UAAS,UAAU;AACtC,WAAO,IAAI,WAAW,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU;AAAA,EACrE,UAAE;AACA,UAAM,GAAG,SAAS,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC;AAAA,EACpD;AACF;AAKA,eAAe,cACb,YACA,WACA,WACqB;AACrB,QAAM,EAAE,WAAAD,YAAW,UAAAC,WAAU,GAAG,IAAI,MAAM,OAAO,aAAkB;AACnE,QAAM,EAAE,MAAAE,MAAK,IAAI,MAAM,OAAO,MAAW;AACzC,QAAM,EAAE,OAAO,IAAI,MAAM,OAAO,IAAS;AACzC,QAAM,EAAE,YAAY,IAAI,MAAM,OAAO,QAAa;AAClD,QAAM,EAAE,UAAAC,UAAS,IAAI,MAAM,OAAO,eAAoB;AAEtD,QAAM,QAAQ,YAAY,CAAC,EAAE,SAAS,KAAK;AAC3C,QAAM,YAAYD,MAAK,OAAO,GAAG,yBAAyB,KAAK,MAAM;AACrE,QAAM,aAAaA,MAAK,OAAO,GAAG,0BAA0B,KAAK,MAAM;AAEvE,MAAI;AACF,UAAMH,WAAU,WAAW,SAAS;AACpC,UAAM,UAAU,KAAK,MAAM,YAAY,GAAI;AAE3C,UAAM,IAAI,QAAc,CAACK,UAAS,WAAW;AAC3C,MAAAD;AAAA,QACE;AAAA,QACA;AAAA,UACE;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA,UAAU,OAAO,IAAI,OAAO;AAAA,UAC5B;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,QACF;AAAA,QACA,EAAE,SAAS,IAAO;AAAA,QAClB,CAAC,QAAQ;AACP,cAAI,IAAK,QAAO,IAAI,MAAM,8BAA8B,IAAI,OAAO,EAAE,CAAC;AAAA,cACjE,CAAAC,SAAQ;AAAA,QACf;AAAA,MACF;AAAA,IACF,CAAC;AAED,UAAM,OAAO,MAAMJ,UAAS,UAAU;AACtC,WAAO,IAAI,WAAW,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU;AAAA,EACrE,UAAE;AACA,UAAM,GAAG,WAAW,EAAE,OAAO,KAAK,CAAC;AACnC,UAAM,GAAG,YAAY,EAAE,OAAO,KAAK,CAAC;AAAA,EACtC;AACF;AAuBA,eAAe,kBACb,YACA,WACA,OACA,cAC4B;AAC5B,MAAI,CAAC,aAAa,MAAM,WAAW,EAAG,QAAO;AAE7C,QAAM,EAAE,WAAAD,YAAW,UAAAC,WAAU,OAAAC,QAAO,GAAG,IAAI,MAAM,OAAO,aAAkB;AAC1E,QAAM,EAAE,MAAAC,MAAK,IAAI,MAAM,OAAO,MAAW;AACzC,QAAM,EAAE,OAAO,IAAI,MAAM,OAAO,IAAS;AACzC,QAAM,EAAE,YAAY,IAAI,MAAM,OAAO,QAAa;AAClD,QAAM,EAAE,UAAAC,UAAS,IAAI,MAAM,OAAO,eAAoB;AAEtD,QAAM,UAAUD,MAAK,OAAO,GAAG,oBAAoB,YAAY,CAAC,EAAE,SAAS,KAAK,CAAC,EAAE;AACnF,QAAMD,OAAM,SAAS,EAAE,WAAW,KAAK,CAAC;AAExC,MAAI;AACF,UAAM,SAAmB,CAAC;AAC1B,UAAM,UAAoB,CAAC;AAC3B,UAAM,SAAmB,CAAC;AAC1B,UAAM,KAAK,CAAC,MAAc,KAAK,IAAI,GAAG,KAAK,MAAM,IAAI,GAAI,CAAC;AAE1D,QAAI,WAAW;AACb,YAAM,IAAIC,MAAK,SAAS,eAAe;AACvC,YAAMH,WAAU,GAAG,SAAS;AAC5B,YAAM,IAAI,OAAO,KAAK,CAAC,IAAI;AAC3B,YAAM,IAAI,GAAG,YAAY;AACzB,cAAQ,KAAK,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG;AAC9C,aAAO,KAAK,KAAK,CAAC,GAAG;AAAA,IACvB;AAEA,eAAW,QAAQ,OAAO;AACxB,YAAM,IAAIG,MAAK,SAAS,QAAQ,OAAO,MAAM,MAAM;AACnD,YAAMH,WAAU,GAAG,IAAI,WAAW,KAAK,MAAM,CAAC;AAC9C,YAAM,IAAI,OAAO,KAAK,CAAC,IAAI;AAC3B,YAAM,IAAI,GAAG,KAAK,QAAQ;AAC1B,YAAM,MAAM,KAAK,YAAY,KAAK;AAClC,cAAQ;AAAA,QACN,IAAI,CAAC,kBAAkB,KAAK,SAAS,QAAQ,GAAG,gCAAgC,CAAC,IAAI,CAAC,KAAK,CAAC;AAAA,MAC9F;AACA,aAAO,KAAK,KAAK,CAAC,GAAG;AAAA,IACvB;AAEA,UAAM,QAAQ,GAAG,QAAQ,KAAK,GAAG,CAAC,IAAI,OAAO,KAAK,EAAE,CAAC,eAAe,OAAO,MAAM;AACjF,UAAM,OAAO,CAAC,IAAI;AAClB,eAAW,KAAK,OAAQ,MAAK,KAAK,MAAM,CAAC;AACzC,SAAK;AAAA,MACH;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACAG,MAAK,SAAS,WAAW;AAAA,IAC3B;AAEA,UAAM,IAAI,QAAc,CAACE,UAAS,WAAW;AAC3C,MAAAD,UAAS,YAAY,MAAM,EAAE,SAAS,KAAQ,GAAG,CAAC,QAAQ;AACxD,YAAI,IAAK,QAAO,IAAI,MAAM,4BAA4B,IAAI,OAAO,EAAE,CAAC;AAAA,YAC/D,CAAAC,SAAQ;AAAA,MACf,CAAC;AAAA,IACH,CAAC;AAED,UAAM,OAAO,MAAMJ,UAASE,MAAK,SAAS,WAAW,CAAC;AACtD,WAAO,IAAI,WAAW,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU;AAAA,EACrE,UAAE;AACA,UAAM,GAAG,SAAS,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC;AAAA,EACpD;AACF;;;ADxgBA,IAAM,kBAAkB,CAAC,SAAS,UAAU,MAAM;AAClD,IAAM,qBAAqB,CAAC,aAAa,UAAU;AACnD,IAAM,iBAAiB,CAAC,OAAO,YAAY,QAAQ;AAE5C,SAAS,qBAAqBG,UAAwB;AAC3D,EAAAA,SACG,QAAQ,OAAO,EACf,YAAY,uCAAuC,EACnD,SAAS,WAAW,kDAAkD,EACtE,SAAS,YAAY,wCAAwC,EAC7D,OAAO,uBAAuB,wCAAwC,EACtE,OAAO,kBAAkB,mCAAmC,IAAI,EAChE;AAAA,IACC;AAAA,IACA,qBAAqB,gBAAgB,KAAK,IAAI,CAAC;AAAA,IAC/C;AAAA,EACF,EACC;AAAA,IACC;AAAA,IACA,sBAAsB,mBAAmB,KAAK,IAAI,CAAC;AAAA,IACnD;AAAA,EACF,EACC;AAAA,IACC;AAAA,IACA,kBAAkB,eAAe,KAAK,IAAI,CAAC;AAAA,IAC3C;AAAA,EACF,EACC;AAAA,IACC;AAAA,IACA;AAAA,EACF,EACC,OAAO,oBAAoB,sBAAsB,EACjD,OAAO,qBAAqB,uBAAuB,EACnD,OAAO,OAAO,WAAmB,WAA+B,SAA8B;AAC7F,QAAI;AAEF,UAAI,aAAa,CAAC,KAAK,QAAQ;AAC7B,aAAK,SAAS;AAAA,MAChB;AACA,YAAM,SAAS,WAAW,IAAI;AAAA,IAChC,SAAS,KAAc;AACrB,YAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,cAAQ,MAAM,UAAU,OAAO,EAAE;AACjC,cAAQ,WAAW;AAAA,IACrB;AAAA,EACF,CAAC;AACL;AAEA,eAAe,SAAS,WAAmB,MAA0C;AACnF,QAAM,gBAAgBC,SAAQ,SAAS;AAGvC,QAAM,MAAM,SAAS,KAAK,OAAO,MAAM,EAAE;AACzC,MAAI,MAAM,GAAG,KAAK,MAAM,KAAK,MAAM,KAAK;AACtC,UAAM,IAAI,MAAM,wCAAwC;AAAA,EAC1D;AAEA,QAAM,UAAU,KAAK,WAAW;AAChC,MAAI,CAAC,gBAAgB,SAAS,OAA2C,GAAG;AAC1E,UAAM,IAAI,MAAM,oBAAoB,OAAO,aAAa,gBAAgB,KAAK,IAAI,CAAC,EAAE;AAAA,EACtF;AAEA,QAAM,cAAc,KAAK,eAAe;AACxC,MAAI,CAAC,mBAAmB,SAAS,WAAkD,GAAG;AACpF,UAAM,IAAI;AAAA,MACR,wBAAwB,WAAW,aAAa,mBAAmB,KAAK,IAAI,CAAC;AAAA,IAC/E;AAAA,EACF;AAEA,QAAM,WAAW,KAAK,YAAY;AAClC,MAAI,CAAC,eAAe,SAAS,QAA2C,GAAG;AACzE,UAAM,IAAI,MAAM,qBAAqB,QAAQ,aAAa,eAAe,KAAK,IAAI,CAAC,EAAE;AAAA,EACvF;AACA,QAAM,eAAe,aAAa,QAAQ,SAAa;AAGvD,QAAM,gBAAgBC,UAAS,aAAa;AAC5C,QAAM,WAAWC,SAAQ,aAAa;AACtC,QAAM,WAAW,WAAW,cAAc,MAAM,GAAG,CAAC,SAAS,MAAM,IAAI;AACvE,QAAM,aAAa,KAAK,SACpBF,SAAQ,KAAK,MAAM,IACnBA,SAAQG,SAAQ,aAAa,GAAG,GAAG,QAAQ,MAAM;AAGrD,QAAMC,OAAMD,SAAQ,UAAU,GAAG,EAAE,WAAW,KAAK,CAAC;AAGpD,UAAQ,MAAM,YAAY,aAAa,EAAE;AACzC,QAAM,SAAS,MAAM,UAAU,aAAa;AAC5C,QAAM,EAAE,UAAU,IAAI;AAGtB,MAAI;AACJ,MAAI,OAAO,KAAK;AACd,YAAQ,MAAM,0BAA0B;AACxC,UAAM,OAAO;AAAA,EACf,WAAW,OAAO,aAAa;AAC7B,UAAM,EAAE,cAAc,IAAI,MAAM,OAAO,uBAAuB;AAC9D,UAAM,cAAc,OAAO,aAAa,EAAE,eAAe,KAAK,cAAc,CAAC;AAAA,EAC/E,OAAO;AACL,UAAM,IAAI,MAAM,4BAA4B;AAAA,EAC9C;AAEA,UAAQ;AAAA,IACN,cAAc,GAAG,kBAAkB,OAAO,kBAAkB,WAAW,eAAe,QAAQ;AAAA,EAChG;AAGA,QAAM,UAAU,MAAM,eAAe,KAAK,WAAW;AAAA,IACnD;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,OAAO,KAAK,QAAQ,SAAS,KAAK,OAAO,EAAE,IAAI;AAAA,IAC/C,QAAQ,KAAK,SAAS,SAAS,KAAK,QAAQ,EAAE,IAAI;AAAA,IAClD;AAAA,IACA,cAAc;AAAA,IACd,YAAY,CAAC,OAAO,YAAY;AAC9B,oBAAc,OAAO,SAAS,GAAG;AAAA,IACnC;AAAA,EACF,CAAC;AAED,gBAAc;AACd,UAAQ;AAAA,IACN,YAAO,UAAU,KAAK,QAAQ,SAAS,QAAQ,CAAC,CAAC,MAAM,QAAQ,UAAU;AAAA,EAC3E;AACA,UAAQ,MAAM,OAAO;AACvB;AAIA,IAAM,YAAY;AAElB,SAAS,cAAc,OAAe,SAAiB,OAAe,QAAuB;AAC3F,QAAM,MAAM,KAAK,IAAI,KAAK,KAAK,MAAO,UAAU,QAAS,GAAG,CAAC;AAC7D,QAAM,SAAS,KAAK,MAAO,MAAM,MAAO,SAAS;AACjD,QAAM,MAAM,SAAI,OAAO,MAAM,IAAI,SAAI,OAAO,YAAY,MAAM;AAC9D,QAAM,SAAS,SAAS,KAAK,MAAM,MAAM;AACzC,UAAQ,OAAO,MAAM,OAAO,KAAK,KAAK,GAAG,IAAI,GAAG,IAAI,MAAM,IAAI;AAChE;AAEA,SAAS,gBAAsB;AAC7B,UAAQ,OAAO,MAAM,OAAO,IAAI,OAAO,EAAE,IAAI,IAAI;AACnD;;;AG/JA,SAAS,YAAY,gBAAgB;AACrC,SAAS,WAAAE,UAAS,WAAAC,UAAS,QAAAC,OAAM,WAAAC,gBAAe;AAGhD,SAAS,2BAA2B;AAQ7B,SAAS,wBAAwBC,UAAwB;AAC9D,EAAAA,SACG,QAAQ,UAAU,EAClB,YAAY,oEAAoE,EAChF,SAAS,WAAW,kDAAkD,EACtE,OAAO,UAAU,qDAAqD,EACtE,OAAO,YAAY,6CAA6C,EAChE,OAAO,OAAO,WAAmB,SAAuB;AACvD,QAAI;AACF,YAAM,WAAW,MAAM,YAAY,WAAW,IAAI;AAClD,cAAQ,WAAW;AAAA,IACrB,SAAS,KAAc;AACrB,YAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,cAAQ,MAAM,UAAU,OAAO,EAAE;AACjC,cAAQ,WAAW;AAAA,IACrB;AAAA,EACF,CAAC;AACL;AAEA,eAAe,YAAY,WAAmB,MAAqC;AACjF,QAAM,gBAAgBC,SAAQ,SAAS;AACvC,QAAM,EAAE,WAAW,aAAa,IAAI,IAAI,MAAM,UAAU,aAAa;AAErE,MAAI,CAAC,aAAa;AAGhB,UAAM,cAAc,KAAK,eAAe,CAAC;AACzC,WAAO,OAAO,aAAa,MAAM,qDAAgD;AAAA,EACnF;AAIA,QAAM,UAAU,MAAM,UAAU,UAAU;AAC1C,QAAM,iBAAiB,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC;AACzD,QAAM,iBACJ,SAAS,aAAa,EAAE,OAAO,KAC/B,CAAC,CAAC,QAAQ,MAAM,EAAE,SAASC,SAAQ,aAAa,EAAE,YAAY,CAAC;AACjE,QAAM,UAAUC,SAAQ,aAAa;AACrC,QAAM,WAAW,CAAC,SAA0B;AAC1C,QAAI,eAAe,IAAI,IAAI,KAAK,eAAe,IAAI,KAAK,QAAQ,SAAS,EAAE,CAAC,EAAG,QAAO;AACtF,QAAI,eAAgB,QAAO,WAAWC,MAAK,SAAS,IAAI,CAAC;AACzD,WAAO;AAAA,EACT;AAEA,QAAM,SAAS,oBAAoB,aAAa,EAAE,QAAQ,SAAS,CAAC;AACpE,SAAO,OAAO,OAAO,aAAa,IAAI;AACxC;AAEA,SAAS,OAAO,aAA8B,MAAoB,MAAuB;AACvF,QAAM,aAAa,YAAY,OAAO,CAAC,MAAM,EAAE,aAAa,OAAO,EAAE;AACrE,QAAM,eAAe,YAAY,SAAS;AAE1C,MAAI,KAAK,MAAM;AAGb,YAAQ,OAAO,MAAM,GAAG,KAAK,UAAU,EAAE,YAAY,cAAc,YAAY,GAAG,MAAM,CAAC,CAAC;AAAA,CAAI;AAAA,EAChG,OAAO;AACL,QAAI,KAAM,SAAQ,MAAM,IAAI;AAC5B,eAAW,KAAK,aAAa;AAC3B,YAAM,WAAW,EAAE,QAAQ,OAAO,QAAQ,EAAE,IAAI,KAAM,EAAE,WAAW;AACnE,cAAQ;AAAA,QACN,GAAG,EAAE,aAAa,UAAU,YAAY,SAAS,KAAK,QAAQ,MAAM,EAAE,IAAI,KAAK,EAAE,OAAO;AAAA,MAC1F;AAAA,IACF;AACA,QAAI,YAAY,WAAW,GAAG;AAC5B,cAAQ,MAAM,0BAAqB;AAAA,IACrC,OAAO;AACL,cAAQ,MAAM;AAAA,EAAK,UAAU,cAAc,YAAY,aAAa;AAAA,IACtE;AAAA,EACF;AAEA,MAAI,aAAa,EAAG,QAAO;AAC3B,MAAI,KAAK,UAAU,eAAe,EAAG,QAAO;AAC5C,SAAO;AACT;;;ANrFA,QAAQ;AAAA,EACN;AACF;AAEA,IAAM,UAAU,IAAI,QAAQ;AAE5B,QACG,KAAK,QAAQ,EACb,YAAY,gEAA2D,EACvE,QAAQ,OAAO;AAElB,uBAAuB,OAAO;AAC9B,qBAAqB,OAAO;AAC5B,wBAAwB,OAAO;AAE/B,QAAQ,MAAM;","names":["extname","join","program","extname","join","mkdir","dirname","basename","extname","resolve","resolve","MemoryContentContainer","writeFile","readFile","mkdir","join","execFile","resolve","program","resolve","basename","extname","dirname","mkdir","dirname","extname","join","resolve","program","resolve","extname","dirname","join"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bendyline/squisq-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Squisq CLI — convert markdown documents to DOCX, PDF, HTML, and more",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bendyline",
|
|
@@ -49,18 +49,19 @@
|
|
|
49
49
|
"test": "mocha"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@bendyline/squisq": "1.
|
|
53
|
-
"@bendyline/squisq-formats": "1.
|
|
54
|
-
"@bendyline/squisq-react": "1.
|
|
55
|
-
"@bendyline/squisq-video": "1.
|
|
52
|
+
"@bendyline/squisq": "1.5.0",
|
|
53
|
+
"@bendyline/squisq-formats": "1.4.0",
|
|
54
|
+
"@bendyline/squisq-react": "1.4.0",
|
|
55
|
+
"@bendyline/squisq-video": "1.2.0",
|
|
56
56
|
"commander": "12.1.0",
|
|
57
|
-
"playwright-core": "1.58.2"
|
|
57
|
+
"playwright-core": "1.58.2",
|
|
58
|
+
"vite": "8.0.14"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@types/chai": "5.2.3",
|
|
61
62
|
"@types/mocha": "10.0.10",
|
|
62
63
|
"chai": "5.3.3",
|
|
63
|
-
"mocha": "11.
|
|
64
|
+
"mocha": "11.3.0",
|
|
64
65
|
"tsup": "8.5.1",
|
|
65
66
|
"tsx": "4.21.0",
|
|
66
67
|
"typescript": "5.9.3"
|