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