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