@bendyline/squisq-cli 1.0.0 → 1.0.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 +78 -0
- package/dist/__tests__/convert.test.d.ts +7 -0
- package/dist/__tests__/convert.test.d.ts.map +1 -0
- package/dist/__tests__/convert.test.js +127 -0
- package/dist/__tests__/convert.test.js.map +1 -0
- package/dist/__tests__/readInput.test.d.ts +5 -0
- package/dist/__tests__/readInput.test.d.ts.map +1 -0
- package/dist/__tests__/readInput.test.js +83 -0
- package/dist/__tests__/readInput.test.js.map +1 -0
- package/dist/commands/convert.d.ts +15 -0
- package/dist/commands/convert.d.ts.map +1 -0
- package/dist/commands/convert.js +182 -0
- package/dist/commands/convert.js.map +1 -0
- package/dist/commands/video.d.ts +15 -0
- package/dist/commands/video.d.ts.map +1 -0
- package/dist/commands/video.js +253 -0
- package/dist/commands/video.js.map +1 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -425
- package/dist/index.js.map +1 -1
- package/dist/nativeEncoder-EXDP2O5B.js +0 -0
- package/dist/util/detectFfmpeg.d.ts +15 -0
- package/dist/util/detectFfmpeg.d.ts.map +1 -0
- package/dist/util/detectFfmpeg.js +29 -0
- package/dist/util/detectFfmpeg.js.map +1 -0
- package/dist/util/nativeEncoder.d.ts +27 -0
- package/dist/util/nativeEncoder.d.ts.map +1 -0
- package/dist/util/nativeEncoder.js +106 -0
- package/dist/util/nativeEncoder.js.map +1 -0
- package/dist/util/readInput.d.ts +19 -0
- package/dist/util/readInput.d.ts.map +1 -0
- package/dist/util/readInput.js +98 -0
- package/dist/util/readInput.js.map +1 -0
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,428 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
".jpeg": "image/jpeg",
|
|
22
|
-
".png": "image/png",
|
|
23
|
-
".gif": "image/gif",
|
|
24
|
-
".webp": "image/webp",
|
|
25
|
-
".svg": "image/svg+xml",
|
|
26
|
-
".mp3": "audio/mpeg",
|
|
27
|
-
".wav": "audio/wav",
|
|
28
|
-
".ogg": "audio/ogg",
|
|
29
|
-
".mp4": "video/mp4",
|
|
30
|
-
".webm": "video/webm"
|
|
31
|
-
};
|
|
32
|
-
function mimeFromExt(filePath) {
|
|
33
|
-
return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
34
|
-
}
|
|
35
|
-
async function walkDir(root, prefix = "") {
|
|
36
|
-
const entries = await readdir(root, { withFileTypes: true });
|
|
37
|
-
const paths = [];
|
|
38
|
-
for (const entry of entries) {
|
|
39
|
-
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
40
|
-
if (entry.isDirectory()) {
|
|
41
|
-
paths.push(...await walkDir(join(root, entry.name), relPath));
|
|
42
|
-
} else if (entry.isFile()) {
|
|
43
|
-
paths.push(relPath);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return paths;
|
|
47
|
-
}
|
|
48
|
-
async function readInput(inputPath) {
|
|
49
|
-
const info = await stat(inputPath);
|
|
50
|
-
if (info.isDirectory()) {
|
|
51
|
-
return readFolder(inputPath);
|
|
52
|
-
}
|
|
53
|
-
const ext = extname(inputPath).toLowerCase();
|
|
54
|
-
if (ext === ".zip" || ext === ".dbk") {
|
|
55
|
-
return readContainer(inputPath);
|
|
56
|
-
}
|
|
57
|
-
return readMarkdownFile(inputPath);
|
|
58
|
-
}
|
|
59
|
-
async function readMarkdownFile(filePath) {
|
|
60
|
-
const content = await readFile(filePath, "utf-8");
|
|
61
|
-
const container = new MemoryContentContainer();
|
|
62
|
-
await container.writeDocument(content);
|
|
63
|
-
const markdownDoc = parseMarkdown(content);
|
|
64
|
-
return { container, markdownDoc };
|
|
65
|
-
}
|
|
66
|
-
async function readContainer(filePath) {
|
|
67
|
-
const data = await readFile(filePath);
|
|
68
|
-
const container = await zipToContainer(
|
|
69
|
-
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
|
|
70
|
-
);
|
|
71
|
-
const markdown = await container.readDocument();
|
|
72
|
-
if (!markdown) {
|
|
73
|
-
throw new Error(`No markdown document found in container: ${filePath}`);
|
|
74
|
-
}
|
|
75
|
-
const markdownDoc = parseMarkdown(markdown);
|
|
76
|
-
return { container, markdownDoc };
|
|
77
|
-
}
|
|
78
|
-
async function readFolder(dirPath) {
|
|
79
|
-
const container = new MemoryContentContainer();
|
|
80
|
-
const files = await walkDir(dirPath);
|
|
81
|
-
for (const relPath of files) {
|
|
82
|
-
const absPath = join(dirPath, relPath);
|
|
83
|
-
const data = await readFile(absPath);
|
|
84
|
-
await container.writeFile(
|
|
85
|
-
relPath,
|
|
86
|
-
new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
|
87
|
-
mimeFromExt(relPath)
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
const markdown = await container.readDocument();
|
|
91
|
-
if (!markdown) {
|
|
92
|
-
throw new Error(`No markdown document found in folder: ${dirPath}`);
|
|
93
|
-
}
|
|
94
|
-
const markdownDoc = parseMarkdown(markdown);
|
|
95
|
-
return { container, markdownDoc };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// src/commands/convert.ts
|
|
99
|
-
var ALL_FORMATS = ["docx", "pdf", "html", "dbk"];
|
|
100
|
-
function parseFormats(value) {
|
|
101
|
-
const requested = value.split(",").map((s) => s.trim().toLowerCase());
|
|
102
|
-
const valid = [];
|
|
103
|
-
for (const r of requested) {
|
|
104
|
-
if (ALL_FORMATS.includes(r)) {
|
|
105
|
-
valid.push(r);
|
|
106
|
-
} else {
|
|
107
|
-
console.warn(`Unknown format "${r}" \u2014 skipping. Valid: ${ALL_FORMATS.join(", ")}`);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
if (valid.length === 0) {
|
|
111
|
-
throw new Error(`No valid formats specified. Valid: ${ALL_FORMATS.join(", ")}`);
|
|
112
|
-
}
|
|
113
|
-
return valid;
|
|
114
|
-
}
|
|
115
|
-
function registerConvertCommand(program2) {
|
|
116
|
-
program2.command("convert").description("Convert a markdown document to DOCX, 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(
|
|
117
|
-
"-f, --formats <list>",
|
|
118
|
-
`Comma-separated formats to produce (default: all). Valid: ${ALL_FORMATS.join(", ")}`
|
|
119
|
-
).action(async (inputPath, opts) => {
|
|
120
|
-
try {
|
|
121
|
-
await runConvert(inputPath, opts);
|
|
122
|
-
} catch (err) {
|
|
123
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
124
|
-
console.error(`Error: ${message}`);
|
|
125
|
-
process.exitCode = 1;
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
async function runConvert(inputPath, opts) {
|
|
130
|
-
const resolvedInput = resolve(inputPath);
|
|
131
|
-
const formats = opts.formats ? parseFormats(opts.formats) : [...ALL_FORMATS];
|
|
132
|
-
const outputDir = opts.outputDir ? resolve(opts.outputDir) : dirname(resolvedInput);
|
|
133
|
-
const inputBasename = basename(resolvedInput);
|
|
134
|
-
const inputExt = extname2(inputBasename);
|
|
135
|
-
const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
|
|
136
|
-
await mkdir(outputDir, { recursive: true });
|
|
137
|
-
console.error(`Reading: ${resolvedInput}`);
|
|
138
|
-
const { container, markdownDoc } = await readInput(resolvedInput);
|
|
139
|
-
for (const format of formats) {
|
|
140
|
-
const outPath = join2(outputDir, `${baseName}.${format}`);
|
|
141
|
-
switch (format) {
|
|
142
|
-
case "docx": {
|
|
143
|
-
const { markdownDocToDocx } = await import("@bendyline/squisq-formats/docx");
|
|
144
|
-
const buf = await markdownDocToDocx(markdownDoc);
|
|
145
|
-
await writeFile(outPath, Buffer.from(buf));
|
|
146
|
-
break;
|
|
147
|
-
}
|
|
148
|
-
case "pdf": {
|
|
149
|
-
const { markdownDocToPdf } = await import("@bendyline/squisq-formats/pdf");
|
|
150
|
-
const buf = await markdownDocToPdf(markdownDoc);
|
|
151
|
-
await writeFile(outPath, Buffer.from(buf));
|
|
152
|
-
break;
|
|
153
|
-
}
|
|
154
|
-
case "html": {
|
|
155
|
-
const { markdownToDoc } = await import("@bendyline/squisq/doc");
|
|
156
|
-
const { docToHtml, collectImagePaths } = await import("@bendyline/squisq-formats/html");
|
|
157
|
-
const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
|
|
158
|
-
const doc = markdownToDoc(markdownDoc);
|
|
159
|
-
const imagePaths = collectImagePaths(doc);
|
|
160
|
-
const images = /* @__PURE__ */ new Map();
|
|
161
|
-
for (const imgPath of imagePaths) {
|
|
162
|
-
const data = await container.readFile(imgPath);
|
|
163
|
-
if (data) {
|
|
164
|
-
images.set(imgPath, data);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
const html = docToHtml(doc, {
|
|
168
|
-
playerScript: PLAYER_BUNDLE,
|
|
169
|
-
images,
|
|
170
|
-
title: baseName,
|
|
171
|
-
mode: "static"
|
|
172
|
-
});
|
|
173
|
-
await writeFile(outPath, html, "utf-8");
|
|
174
|
-
break;
|
|
175
|
-
}
|
|
176
|
-
case "dbk": {
|
|
177
|
-
const { containerToZip } = await import("@bendyline/squisq-formats/container");
|
|
178
|
-
const blob = await containerToZip(container);
|
|
179
|
-
const buf = await blob.arrayBuffer();
|
|
180
|
-
await writeFile(outPath, Buffer.from(buf));
|
|
181
|
-
break;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
console.error(` \u2713 ${outPath}`);
|
|
185
|
-
}
|
|
186
|
-
console.error("Done.");
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// src/commands/video.ts
|
|
190
|
-
import { mkdir as mkdir2 } from "fs/promises";
|
|
191
|
-
import { dirname as dirname2, basename as basename2, extname as extname3, resolve as resolve2 } from "path";
|
|
192
|
-
|
|
193
|
-
// src/util/detectFfmpeg.ts
|
|
194
|
-
import { execFile } from "child_process";
|
|
195
|
-
async function detectFfmpeg() {
|
|
196
|
-
const command = process.platform === "win32" ? "where" : "which";
|
|
197
|
-
return new Promise((resolve3) => {
|
|
198
|
-
execFile(command, ["ffmpeg"], { timeout: 5e3 }, (err, stdout) => {
|
|
199
|
-
if (err || !stdout.trim()) {
|
|
200
|
-
resolve3(null);
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
const path = stdout.trim().split("\n")[0].trim();
|
|
204
|
-
resolve3(path);
|
|
205
|
-
});
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// src/commands/video.ts
|
|
210
|
-
var VALID_QUALITIES = ["draft", "normal", "high"];
|
|
211
|
-
var VALID_ORIENTATIONS = ["landscape", "portrait"];
|
|
212
|
-
var VALID_CAPTIONS = ["off", "standard", "social"];
|
|
213
|
-
function registerVideoCommand(program2) {
|
|
214
|
-
program2.command("video").description("Render a squisq document to MP4 video").argument("<input>", "Path to .md file, .zip/.dbk container, or folder").argument("[output]", "Output MP4 path (default: <input>.mp4)").option("-o, --output <path>", "Output MP4 path (default: <input>.mp4)").option("--fps <number>", "Frames per second (default: 30)", "30").option(
|
|
215
|
-
"--quality <level>",
|
|
216
|
-
`Encoding quality: ${VALID_QUALITIES.join(", ")} (default: normal)`,
|
|
217
|
-
"normal"
|
|
218
|
-
).option(
|
|
219
|
-
"--orientation <orient>",
|
|
220
|
-
`Video orientation: ${VALID_ORIENTATIONS.join(", ")} (default: landscape)`,
|
|
221
|
-
"landscape"
|
|
222
|
-
).option(
|
|
223
|
-
"--captions <style>",
|
|
224
|
-
`Caption style: ${VALID_CAPTIONS.join(", ")} (default: off)`,
|
|
225
|
-
"off"
|
|
226
|
-
).option("--width <pixels>", "Override video width").option("--height <pixels>", "Override video height").action(async (inputPath, outputArg, opts) => {
|
|
227
|
-
try {
|
|
228
|
-
if (outputArg && !opts.output) {
|
|
229
|
-
opts.output = outputArg;
|
|
230
|
-
}
|
|
231
|
-
await runVideo(inputPath, opts);
|
|
232
|
-
} catch (err) {
|
|
233
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
234
|
-
console.error(`Error: ${message}`);
|
|
235
|
-
process.exitCode = 1;
|
|
236
|
-
}
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
async function runVideo(inputPath, opts) {
|
|
240
|
-
const resolvedInput = resolve2(inputPath);
|
|
241
|
-
const fps = parseInt(opts.fps ?? "30", 10);
|
|
242
|
-
if (isNaN(fps) || fps < 1 || fps > 120) {
|
|
243
|
-
throw new Error("FPS must be a number between 1 and 120");
|
|
244
|
-
}
|
|
245
|
-
const quality = opts.quality ?? "normal";
|
|
246
|
-
if (!VALID_QUALITIES.includes(quality)) {
|
|
247
|
-
throw new Error(`Invalid quality "${quality}". Valid: ${VALID_QUALITIES.join(", ")}`);
|
|
248
|
-
}
|
|
249
|
-
const orientation = opts.orientation ?? "landscape";
|
|
250
|
-
if (!VALID_ORIENTATIONS.includes(orientation)) {
|
|
251
|
-
throw new Error(
|
|
252
|
-
`Invalid orientation "${orientation}". Valid: ${VALID_ORIENTATIONS.join(", ")}`
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
const captions = opts.captions ?? "off";
|
|
256
|
-
if (!VALID_CAPTIONS.includes(captions)) {
|
|
257
|
-
throw new Error(`Invalid captions "${captions}". Valid: ${VALID_CAPTIONS.join(", ")}`);
|
|
258
|
-
}
|
|
259
|
-
const captionStyle = captions === "off" ? void 0 : captions;
|
|
260
|
-
const inputBasename = basename2(resolvedInput);
|
|
261
|
-
const inputExt = extname3(inputBasename);
|
|
262
|
-
const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
|
|
263
|
-
const outputPath = opts.output ? resolve2(opts.output) : resolve2(dirname2(resolvedInput), `${baseName}.mp4`);
|
|
264
|
-
await mkdir2(dirname2(outputPath), { recursive: true });
|
|
265
|
-
console.error(`Reading: ${resolvedInput}`);
|
|
266
|
-
const { container, markdownDoc } = await readInput(resolvedInput);
|
|
267
|
-
const { markdownToDoc } = await import("@bendyline/squisq/doc");
|
|
268
|
-
const doc = markdownToDoc(markdownDoc);
|
|
269
|
-
const { collectImagePaths } = await import("@bendyline/squisq-formats/html");
|
|
270
|
-
const imagePaths = collectImagePaths(doc);
|
|
271
|
-
const images = /* @__PURE__ */ new Map();
|
|
272
|
-
for (const imgPath of imagePaths) {
|
|
273
|
-
const data = await container.readFile(imgPath);
|
|
274
|
-
if (data) {
|
|
275
|
-
images.set(imgPath, data);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
const audio = /* @__PURE__ */ new Map();
|
|
279
|
-
let concatenatedAudio = null;
|
|
280
|
-
if (doc.audio?.segments?.length) {
|
|
281
|
-
for (const seg of doc.audio.segments) {
|
|
282
|
-
const data = await container.readFile(seg.src);
|
|
283
|
-
if (data) {
|
|
284
|
-
audio.set(seg.src, data);
|
|
285
|
-
audio.set(seg.name, data);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
if (audio.size > 0) {
|
|
289
|
-
const firstSeg = doc.audio.segments[0];
|
|
290
|
-
const firstData = await container.readFile(firstSeg.src);
|
|
291
|
-
if (firstData) {
|
|
292
|
-
concatenatedAudio = new Uint8Array(firstData);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
const { generateRenderHtml } = await import("@bendyline/squisq-video");
|
|
297
|
-
const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
|
|
298
|
-
const { resolveDimensions } = await import("@bendyline/squisq-video");
|
|
299
|
-
const dimensions = resolveDimensions({
|
|
300
|
-
orientation,
|
|
301
|
-
width: opts.width ? parseInt(opts.width, 10) : void 0,
|
|
302
|
-
height: opts.height ? parseInt(opts.height, 10) : void 0
|
|
303
|
-
});
|
|
304
|
-
const renderHtml = generateRenderHtml(doc, {
|
|
305
|
-
playerScript: PLAYER_BUNDLE,
|
|
306
|
-
images,
|
|
307
|
-
audio: audio.size > 0 ? audio : void 0,
|
|
308
|
-
width: dimensions.width,
|
|
309
|
-
height: dimensions.height,
|
|
310
|
-
captionStyle
|
|
311
|
-
});
|
|
312
|
-
console.error(
|
|
313
|
-
`Viewport: ${dimensions.width}x${dimensions.height}, ${fps} fps, quality: ${quality}, captions: ${captions}`
|
|
314
|
-
);
|
|
315
|
-
const { chromium } = await import("playwright-core");
|
|
316
|
-
const browser = await chromium.launch({ headless: true });
|
|
317
|
-
const page = await browser.newPage({
|
|
318
|
-
viewport: { width: dimensions.width, height: dimensions.height }
|
|
319
|
-
});
|
|
320
|
-
const pageErrors = [];
|
|
321
|
-
page.on("pageerror", (err) => pageErrors.push(err.message));
|
|
322
|
-
page.on("console", (msg) => {
|
|
323
|
-
if (msg.type() === "error") pageErrors.push(msg.text());
|
|
324
|
-
});
|
|
325
|
-
await page.setContent(renderHtml, { waitUntil: "load" });
|
|
326
|
-
await page.waitForTimeout(500);
|
|
327
|
-
try {
|
|
328
|
-
await page.waitForFunction(
|
|
329
|
-
() => typeof window.getDuration === "function",
|
|
330
|
-
{ timeout: 15e3 }
|
|
331
|
-
);
|
|
332
|
-
} catch {
|
|
333
|
-
await browser.close();
|
|
334
|
-
const errorDetail = pageErrors.length ? `
|
|
335
|
-
Page errors:
|
|
336
|
-
${pageErrors.join("\n ")}` : "\nNo page errors captured \u2014 the player may have failed to mount.";
|
|
337
|
-
throw new Error(`Render API did not initialize within 15 seconds.${errorDetail}`);
|
|
338
|
-
}
|
|
339
|
-
const duration = await page.evaluate(() => {
|
|
340
|
-
return window.getDuration();
|
|
341
|
-
});
|
|
342
|
-
if (duration <= 0) {
|
|
343
|
-
await browser.close();
|
|
344
|
-
throw new Error("Document has zero duration \u2014 nothing to render");
|
|
345
|
-
}
|
|
346
|
-
const totalFrames = Math.ceil(duration * fps);
|
|
347
|
-
console.error(`Duration: ${duration.toFixed(1)}s, ${totalFrames} frames to capture`);
|
|
348
|
-
const frames = [];
|
|
349
|
-
const frameInterval = 1 / fps;
|
|
350
|
-
const hasCover = await page.evaluate(() => {
|
|
351
|
-
const w = window;
|
|
352
|
-
return typeof w.hasCoverBlock === "function" ? w.hasCoverBlock() : false;
|
|
353
|
-
});
|
|
354
|
-
if (hasCover) {
|
|
355
|
-
const coverFrameCount = 2 * fps;
|
|
356
|
-
await page.evaluate(() => {
|
|
357
|
-
window.showCover();
|
|
358
|
-
});
|
|
359
|
-
await page.waitForTimeout(100);
|
|
360
|
-
const coverScreenshot = await page.screenshot({ type: "png" });
|
|
361
|
-
const coverFrame = new Uint8Array(coverScreenshot);
|
|
362
|
-
for (let i = 0; i < coverFrameCount; i++) {
|
|
363
|
-
frames.push(coverFrame);
|
|
364
|
-
}
|
|
365
|
-
await page.evaluate(() => {
|
|
366
|
-
window.hideCover();
|
|
367
|
-
});
|
|
368
|
-
writeProgress("Capturing", coverFrameCount, totalFrames + coverFrameCount);
|
|
369
|
-
}
|
|
370
|
-
const totalWithCover = totalFrames + frames.length;
|
|
371
|
-
for (let i = 0; i < totalFrames; i++) {
|
|
372
|
-
const time = i * frameInterval;
|
|
373
|
-
await page.evaluate((t) => {
|
|
374
|
-
return window.seekTo(t);
|
|
375
|
-
}, time);
|
|
376
|
-
const screenshot = await page.screenshot({ type: "png" });
|
|
377
|
-
frames.push(new Uint8Array(screenshot));
|
|
378
|
-
if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === totalFrames - 1) {
|
|
379
|
-
writeProgress("Capturing", frames.length, totalWithCover);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
clearProgress();
|
|
383
|
-
await browser.close();
|
|
384
|
-
const ffmpegPath = await detectFfmpeg();
|
|
385
|
-
const exportOptions = {
|
|
386
|
-
fps,
|
|
387
|
-
quality,
|
|
388
|
-
orientation,
|
|
389
|
-
width: dimensions.width,
|
|
390
|
-
height: dimensions.height,
|
|
391
|
-
onProgress: (percent, phase) => {
|
|
392
|
-
writeProgress("Encoding", percent, 100, phase);
|
|
393
|
-
}
|
|
394
|
-
};
|
|
395
|
-
if (ffmpegPath) {
|
|
396
|
-
console.error(`Using native ffmpeg: ${ffmpegPath}`);
|
|
397
|
-
const { framesToMp4Native } = await import("./nativeEncoder-EXDP2O5B.js");
|
|
398
|
-
await framesToMp4Native(ffmpegPath, frames, concatenatedAudio, outputPath, exportOptions);
|
|
399
|
-
} else {
|
|
400
|
-
throw new Error(
|
|
401
|
-
"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"
|
|
402
|
-
);
|
|
403
|
-
}
|
|
404
|
-
clearProgress();
|
|
405
|
-
console.error(` \u2713 ${outputPath}`);
|
|
406
|
-
console.error("Done.");
|
|
407
|
-
}
|
|
408
|
-
var BAR_WIDTH = 30;
|
|
409
|
-
function writeProgress(label, current, total, detail) {
|
|
410
|
-
const pct = Math.min(100, Math.round(current / total * 100));
|
|
411
|
-
const filled = Math.round(pct / 100 * BAR_WIDTH);
|
|
412
|
-
const bar = "\u2588".repeat(filled) + "\u2591".repeat(BAR_WIDTH - filled);
|
|
413
|
-
const suffix = detail ? ` (${detail})` : "";
|
|
414
|
-
process.stderr.write(`\r ${label}: ${bar} ${pct}%${suffix} `);
|
|
415
|
-
}
|
|
416
|
-
function clearProgress() {
|
|
417
|
-
process.stderr.write("\r" + " ".repeat(80) + "\r");
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// src/index.ts
|
|
421
|
-
console.error(
|
|
422
|
-
"\x1B[36m{[\x1B[0m \x1B[1msquiggly square\x1B[0m \x1B[2m\u2014\x1B[0m \x1B[1msquisq\x1B[0m \x1B[2m\u2014\x1B[0m \x1B[33mv1.0.0\x1B[0m \x1B[36m]}\x1B[0m"
|
|
423
|
-
);
|
|
424
|
-
var program = new Command();
|
|
425
|
-
program.name("squisq").description("Squisq CLI \u2014 convert and process markdown-based documents").version("1.0.0");
|
|
1
|
+
/**
|
|
2
|
+
* squisq CLI
|
|
3
|
+
*
|
|
4
|
+
* Command-line tool for converting and processing Squisq documents.
|
|
5
|
+
* Designed for easy addition of future subcommands (e.g., import).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* squisq convert <input> [options]
|
|
9
|
+
* squisq --help
|
|
10
|
+
*/
|
|
11
|
+
import { Command } from 'commander';
|
|
12
|
+
import { registerConvertCommand } from './commands/convert.js';
|
|
13
|
+
import { registerVideoCommand } from './commands/video.js';
|
|
14
|
+
// Colored banner: cyan brackets, bold white text, dim version
|
|
15
|
+
console.error('\x1b[36m{[\x1b[0m \x1b[1msquiggly square\x1b[0m \x1b[2m—\x1b[0m \x1b[1msquisq\x1b[0m \x1b[2m—\x1b[0m \x1b[33mv1.0.0\x1b[0m \x1b[36m]}\x1b[0m');
|
|
16
|
+
const program = new Command();
|
|
17
|
+
program
|
|
18
|
+
.name('squisq')
|
|
19
|
+
.description('Squisq CLI — convert and process markdown-based documents')
|
|
20
|
+
.version('1.0.0');
|
|
426
21
|
registerConvertCommand(program);
|
|
427
22
|
registerVideoCommand(program);
|
|
428
23
|
program.parse();
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/commands/convert.ts","../src/util/readInput.ts","../src/commands/video.ts","../src/util/detectFfmpeg.ts"],"sourcesContent":["/**\n * squisq CLI\n *\n * Command-line tool for converting and processing Squisq documents.\n * Designed for easy addition of future subcommands (e.g., import).\n *\n * Usage:\n * squisq convert <input> [options]\n * squisq --help\n */\n\nimport { Command } from 'commander';\nimport { registerConvertCommand } from './commands/convert.js';\nimport { registerVideoCommand } from './commands/video.js';\n\n// Colored banner: cyan brackets, bold white text, dim version\nconsole.error(\n '\\x1b[36m{[\\x1b[0m \\x1b[1msquiggly square\\x1b[0m \\x1b[2m—\\x1b[0m \\x1b[1msquisq\\x1b[0m \\x1b[2m—\\x1b[0m \\x1b[33mv1.0.0\\x1b[0m \\x1b[36m]}\\x1b[0m',\n);\n\nconst program = new Command();\n\nprogram\n .name('squisq')\n .description('Squisq CLI — convert and process markdown-based documents')\n .version('1.0.0');\n\nregisterConvertCommand(program);\nregisterVideoCommand(program);\n\nprogram.parse();\n","/**\n * convert command\n *\n * Reads a markdown file, ZIP/DBK container, or folder and exports to\n * all supported formats: DOCX, PDF, HTML, and container ZIP (.dbk).\n *\n * Usage:\n * squisq convert <input> [--output-dir <dir>] [--formats <list>]\n */\n\nimport { writeFile, mkdir } from 'node:fs/promises';\nimport { dirname, basename, extname, join, resolve } from 'node:path';\nimport type { Command } from 'commander';\nimport { readInput } from '../util/readInput.js';\n\nconst ALL_FORMATS = ['docx', 'pdf', 'html', 'dbk'] as const;\ntype Format = (typeof ALL_FORMATS)[number];\n\nfunction parseFormats(value: string): Format[] {\n const requested = value.split(',').map((s) => s.trim().toLowerCase());\n const valid: Format[] = [];\n for (const r of requested) {\n if (ALL_FORMATS.includes(r as Format)) {\n valid.push(r as Format);\n } else {\n console.warn(`Unknown format \"${r}\" — skipping. Valid: ${ALL_FORMATS.join(', ')}`);\n }\n }\n if (valid.length === 0) {\n throw new Error(`No valid formats specified. Valid: ${ALL_FORMATS.join(', ')}`);\n }\n return valid;\n}\n\nexport function registerConvertCommand(program: Command): void {\n program\n .command('convert')\n .description('Convert a markdown document to DOCX, PDF, HTML, and DBK container formats')\n .argument('<input>', 'Path to .md file, .zip/.dbk container, or folder')\n .option('-o, --output-dir <dir>', 'Output directory (default: same as input)')\n .option(\n '-f, --formats <list>',\n `Comma-separated formats to produce (default: all). Valid: ${ALL_FORMATS.join(', ')}`,\n )\n .action(async (inputPath: string, opts: { outputDir?: string; formats?: string }) => {\n try {\n await runConvert(inputPath, opts);\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n console.error(`Error: ${message}`);\n process.exitCode = 1;\n }\n });\n}\n\nasync function runConvert(\n inputPath: string,\n opts: { outputDir?: string; formats?: string },\n): Promise<void> {\n const resolvedInput = resolve(inputPath);\n\n // Determine which formats to produce\n const formats: Format[] = opts.formats ? parseFormats(opts.formats) : [...ALL_FORMATS];\n\n // Determine output directory and base filename\n const outputDir = opts.outputDir ? resolve(opts.outputDir) : dirname(resolvedInput);\n const inputBasename = basename(resolvedInput);\n const inputExt = extname(inputBasename);\n const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;\n\n // Ensure output directory exists\n await mkdir(outputDir, { recursive: true });\n\n console.error(`Reading: ${resolvedInput}`);\n const { container, markdownDoc } = await readInput(resolvedInput);\n\n for (const format of formats) {\n const outPath = join(outputDir, `${baseName}.${format}`);\n\n switch (format) {\n case 'docx': {\n const { markdownDocToDocx } = await import('@bendyline/squisq-formats/docx');\n const buf = await markdownDocToDocx(markdownDoc);\n await writeFile(outPath, Buffer.from(buf));\n break;\n }\n\n case 'pdf': {\n const { markdownDocToPdf } = await import('@bendyline/squisq-formats/pdf');\n const buf = await markdownDocToPdf(markdownDoc);\n await writeFile(outPath, Buffer.from(buf));\n break;\n }\n\n case 'html': {\n const { markdownToDoc } = await import('@bendyline/squisq/doc');\n const { docToHtml, collectImagePaths } = await import('@bendyline/squisq-formats/html');\n const { PLAYER_BUNDLE } = await import('@bendyline/squisq-react/standalone-source');\n\n const doc = markdownToDoc(markdownDoc);\n\n // Gather images referenced by the doc from the container\n const imagePaths = collectImagePaths(doc);\n const images = new Map<string, ArrayBuffer>();\n for (const imgPath of imagePaths) {\n const data = await container.readFile(imgPath);\n if (data) {\n images.set(imgPath, data);\n }\n }\n\n const html = docToHtml(doc, {\n playerScript: PLAYER_BUNDLE,\n images,\n title: baseName,\n mode: 'static',\n });\n await writeFile(outPath, html, 'utf-8');\n break;\n }\n\n case 'dbk': {\n const { containerToZip } = await import('@bendyline/squisq-formats/container');\n const blob = await containerToZip(container);\n const buf = await blob.arrayBuffer();\n await writeFile(outPath, Buffer.from(buf));\n break;\n }\n }\n\n console.error(` ✓ ${outPath}`);\n }\n\n console.error('Done.');\n}\n","/**\n * readInput\n *\n * Unified input reader for the CLI. Accepts a path to a .md file,\n * .zip/.dbk container, or a folder and returns a MemoryContentContainer\n * (virtual file system) plus the parsed MarkdownDocument.\n */\n\nimport { readFile, readdir, stat } from 'node:fs/promises';\nimport { join, extname } from 'node:path';\nimport { parseMarkdown } from '@bendyline/squisq/markdown';\nimport type { MarkdownDocument } from '@bendyline/squisq/markdown';\nimport { MemoryContentContainer } from '@bendyline/squisq/storage';\nimport { zipToContainer } from '@bendyline/squisq-formats/container';\n\nexport interface ReadInputResult {\n container: MemoryContentContainer;\n markdownDoc: MarkdownDocument;\n}\n\n/** MIME type lookup by extension (common content types) */\nconst MIME_TYPES: Record<string, string> = {\n '.md': 'text/markdown',\n '.txt': 'text/plain',\n '.json': 'application/json',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.png': 'image/png',\n '.gif': 'image/gif',\n '.webp': 'image/webp',\n '.svg': 'image/svg+xml',\n '.mp3': 'audio/mpeg',\n '.wav': 'audio/wav',\n '.ogg': 'audio/ogg',\n '.mp4': 'video/mp4',\n '.webm': 'video/webm',\n};\n\nfunction mimeFromExt(filePath: string): string {\n return MIME_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';\n}\n\n/**\n * Recursively walk a directory and return all file paths (relative to root).\n */\nasync function walkDir(root: string, prefix = ''): Promise<string[]> {\n const entries = await readdir(root, { withFileTypes: true });\n const paths: string[] = [];\n\n for (const entry of entries) {\n const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;\n if (entry.isDirectory()) {\n paths.push(...(await walkDir(join(root, entry.name), relPath)));\n } else if (entry.isFile()) {\n paths.push(relPath);\n }\n }\n\n return paths;\n}\n\n/**\n * Read input from a file path (markdown, ZIP/DBK container, or folder)\n * and return a populated ContentContainer + parsed MarkdownDocument.\n */\nexport async function readInput(inputPath: string): Promise<ReadInputResult> {\n const info = await stat(inputPath);\n\n if (info.isDirectory()) {\n return readFolder(inputPath);\n }\n\n const ext = extname(inputPath).toLowerCase();\n if (ext === '.zip' || ext === '.dbk') {\n return readContainer(inputPath);\n }\n\n // Default: treat as a markdown file\n return readMarkdownFile(inputPath);\n}\n\nasync function readMarkdownFile(filePath: string): Promise<ReadInputResult> {\n const content = await readFile(filePath, 'utf-8');\n const container = new MemoryContentContainer();\n await container.writeDocument(content);\n const markdownDoc = parseMarkdown(content);\n return { container, markdownDoc };\n}\n\nasync function readContainer(filePath: string): Promise<ReadInputResult> {\n const data = await readFile(filePath);\n const container = await zipToContainer(\n data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer,\n );\n\n const markdown = await container.readDocument();\n if (!markdown) {\n throw new Error(`No markdown document found in container: ${filePath}`);\n }\n\n const markdownDoc = parseMarkdown(markdown);\n return { container, markdownDoc };\n}\n\nasync function readFolder(dirPath: string): Promise<ReadInputResult> {\n const container = new MemoryContentContainer();\n const files = await walkDir(dirPath);\n\n for (const relPath of files) {\n const absPath = join(dirPath, relPath);\n const data = await readFile(absPath);\n await container.writeFile(\n relPath,\n new Uint8Array(data.buffer, data.byteOffset, data.byteLength),\n mimeFromExt(relPath),\n );\n }\n\n const markdown = await container.readDocument();\n if (!markdown) {\n throw new Error(`No markdown document found in folder: ${dirPath}`);\n }\n\n const markdownDoc = parseMarkdown(markdown);\n return { container, markdownDoc };\n}\n","/**\n * video command\n *\n * Renders a squisq document to MP4 video by:\n * 1. Parsing the input (markdown, container, or folder)\n * 2. Generating a self-contained render HTML\n * 3. Capturing frames via Playwright headless browser\n * 4. Encoding to MP4 using native ffmpeg (fast) or ffmpeg.wasm (fallback)\n *\n * Usage:\n * squisq video <input> [-o output.mp4] [--fps 30] [--quality normal] [--orientation landscape]\n */\n\nimport { mkdir } from 'node:fs/promises';\nimport { dirname, basename, extname, resolve } from 'node:path';\nimport type { Command } from 'commander';\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport { readInput } from '../util/readInput.js';\nimport { detectFfmpeg } from '../util/detectFfmpeg.js';\n\nimport type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';\n\ntype CaptionOption = 'off' | 'standard' | 'social';\n\ninterface VideoCommandOptions {\n output?: string;\n fps?: string;\n quality?: VideoQuality;\n orientation?: VideoOrientation;\n captions?: CaptionOption;\n width?: string;\n height?: string;\n}\n\nconst VALID_QUALITIES = ['draft', 'normal', 'high'] as const;\nconst VALID_ORIENTATIONS = ['landscape', 'portrait'] as const;\nconst VALID_CAPTIONS = ['off', 'standard', 'social'] as const;\n\nexport function registerVideoCommand(program: Command): void {\n program\n .command('video')\n .description('Render a squisq document to MP4 video')\n .argument('<input>', 'Path to .md file, .zip/.dbk container, or folder')\n .argument('[output]', 'Output MP4 path (default: <input>.mp4)')\n .option('-o, --output <path>', 'Output MP4 path (default: <input>.mp4)')\n .option('--fps <number>', 'Frames per second (default: 30)', '30')\n .option(\n '--quality <level>',\n `Encoding quality: ${VALID_QUALITIES.join(', ')} (default: normal)`,\n 'normal',\n )\n .option(\n '--orientation <orient>',\n `Video orientation: ${VALID_ORIENTATIONS.join(', ')} (default: landscape)`,\n 'landscape',\n )\n .option(\n '--captions <style>',\n `Caption style: ${VALID_CAPTIONS.join(', ')} (default: off)`,\n 'off',\n )\n .option('--width <pixels>', 'Override video width')\n .option('--height <pixels>', 'Override video height')\n .action(async (inputPath: string, outputArg: string | undefined, opts: VideoCommandOptions) => {\n try {\n // Positional output arg takes precedence, then -o flag\n if (outputArg && !opts.output) {\n opts.output = outputArg;\n }\n await runVideo(inputPath, opts);\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n console.error(`Error: ${message}`);\n process.exitCode = 1;\n }\n });\n}\n\nasync function runVideo(inputPath: string, opts: VideoCommandOptions): Promise<void> {\n const resolvedInput = resolve(inputPath);\n\n // Validate options\n const fps = parseInt(opts.fps ?? '30', 10);\n if (isNaN(fps) || fps < 1 || fps > 120) {\n throw new Error('FPS must be a number between 1 and 120');\n }\n\n const quality = opts.quality ?? 'normal';\n if (!VALID_QUALITIES.includes(quality as (typeof VALID_QUALITIES)[number])) {\n throw new Error(`Invalid quality \"${quality}\". Valid: ${VALID_QUALITIES.join(', ')}`);\n }\n\n const orientation = opts.orientation ?? 'landscape';\n if (!VALID_ORIENTATIONS.includes(orientation as (typeof VALID_ORIENTATIONS)[number])) {\n throw new Error(\n `Invalid orientation \"${orientation}\". Valid: ${VALID_ORIENTATIONS.join(', ')}`,\n );\n }\n\n const captions = opts.captions ?? 'off';\n if (!VALID_CAPTIONS.includes(captions as (typeof VALID_CAPTIONS)[number])) {\n throw new Error(`Invalid captions \"${captions}\". Valid: ${VALID_CAPTIONS.join(', ')}`);\n }\n const captionStyle = captions === 'off' ? undefined : (captions as 'standard' | 'social');\n\n // Determine output path\n const inputBasename = basename(resolvedInput);\n const inputExt = extname(inputBasename);\n const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;\n const outputPath = opts.output\n ? resolve(opts.output)\n : resolve(dirname(resolvedInput), `${baseName}.mp4`);\n\n // Ensure output directory exists\n await mkdir(dirname(outputPath), { recursive: true });\n\n // ── Step 1: Read input ──────────────────────────────────────────\n console.error(`Reading: ${resolvedInput}`);\n const { container, markdownDoc } = await readInput(resolvedInput);\n\n // ── Step 2: Parse to Doc ────────────────────────────────────────\n const { markdownToDoc } = await import('@bendyline/squisq/doc');\n const doc: Doc = markdownToDoc(markdownDoc);\n\n // ── Step 3: Collect media from container ────────────────────────\n const { collectImagePaths } = await import('@bendyline/squisq-formats/html');\n const imagePaths = collectImagePaths(doc);\n const images = new Map<string, ArrayBuffer>();\n for (const imgPath of imagePaths) {\n const data = await container.readFile(imgPath);\n if (data) {\n images.set(imgPath, data);\n }\n }\n\n // Collect audio segments\n const audio = new Map<string, ArrayBuffer>();\n let concatenatedAudio: Uint8Array | null = null;\n if (doc.audio?.segments?.length) {\n for (const seg of doc.audio.segments) {\n const data = await container.readFile(seg.src);\n if (data) {\n audio.set(seg.src, data);\n // Also map by name for the audio URL rewriting in the player\n audio.set(seg.name, data);\n }\n }\n // For encoding, concatenate audio segments into one file\n // The player handles segment playback internally, but the encoder\n // needs a single audio track. We'll use the first segment as a\n // representative — full audio concatenation would need ffmpeg.\n // For now, pass audio map to the render HTML so the player handles it.\n if (audio.size > 0) {\n // Use the first segment's raw bytes — the encoder will handle it\n const firstSeg = doc.audio.segments[0];\n const firstData = await container.readFile(firstSeg.src);\n if (firstData) {\n concatenatedAudio = new Uint8Array(firstData);\n }\n }\n }\n\n // ── Step 4: Generate render HTML ────────────────────────────────\n const { generateRenderHtml } = await import('@bendyline/squisq-video');\n const { PLAYER_BUNDLE } = await import('@bendyline/squisq-react/standalone-source');\n const { resolveDimensions } = await import('@bendyline/squisq-video');\n\n const dimensions = resolveDimensions({\n orientation,\n width: opts.width ? parseInt(opts.width, 10) : undefined,\n height: opts.height ? parseInt(opts.height, 10) : undefined,\n });\n\n const renderHtml = generateRenderHtml(doc, {\n playerScript: PLAYER_BUNDLE,\n images,\n audio: audio.size > 0 ? audio : undefined,\n width: dimensions.width,\n height: dimensions.height,\n captionStyle,\n });\n\n console.error(\n `Viewport: ${dimensions.width}x${dimensions.height}, ${fps} fps, quality: ${quality}, captions: ${captions}`,\n );\n\n // ── Step 5: Capture frames via Playwright ───────────────────────\n const { chromium } = await import('playwright-core');\n const browser = await chromium.launch({ headless: true });\n const page = await browser.newPage({\n viewport: { width: dimensions.width, height: dimensions.height },\n });\n\n // Capture page errors so they don't vanish silently\n const pageErrors: string[] = [];\n page.on('pageerror', (err) => pageErrors.push(err.message));\n page.on('console', (msg) => {\n if (msg.type() === 'error') pageErrors.push(msg.text());\n });\n\n // Load the render HTML — use 'load' since everything is inline (no network)\n await page.setContent(renderHtml, { waitUntil: 'load' });\n\n // Give React one frame to mount and run useEffect\n await page.waitForTimeout(500);\n\n // Wait for the render API to be available\n try {\n await page.waitForFunction(\n () => typeof (window as unknown as Record<string, unknown>).getDuration === 'function',\n { timeout: 15000 },\n );\n } catch {\n await browser.close();\n const errorDetail = pageErrors.length\n ? `\\nPage errors:\\n ${pageErrors.join('\\n ')}`\n : '\\nNo page errors captured — the player may have failed to mount.';\n throw new Error(`Render API did not initialize within 15 seconds.${errorDetail}`);\n }\n\n // Get duration from the player\n const duration: number = await page.evaluate(() => {\n return (window as unknown as { getDuration: () => number }).getDuration();\n });\n\n if (duration <= 0) {\n await browser.close();\n throw new Error('Document has zero duration — nothing to render');\n }\n\n const totalFrames = Math.ceil(duration * fps);\n console.error(`Duration: ${duration.toFixed(1)}s, ${totalFrames} frames to capture`);\n\n const frames: Uint8Array[] = [];\n const frameInterval = 1 / fps;\n\n // Optional: render cover frame (time 0)\n const hasCover: boolean = await page.evaluate(() => {\n const w = window as unknown as { hasCoverBlock?: () => boolean };\n return typeof w.hasCoverBlock === 'function' ? w.hasCoverBlock() : false;\n });\n\n if (hasCover) {\n const coverFrameCount = 2 * fps;\n await page.evaluate(() => {\n (window as unknown as { showCover: () => void }).showCover();\n });\n await page.waitForTimeout(100);\n const coverScreenshot = await page.screenshot({ type: 'png' });\n const coverFrame = new Uint8Array(coverScreenshot);\n for (let i = 0; i < coverFrameCount; i++) {\n frames.push(coverFrame);\n }\n await page.evaluate(() => {\n (window as unknown as { hideCover: () => void }).hideCover();\n });\n writeProgress('Capturing', coverFrameCount, totalFrames + coverFrameCount);\n }\n\n // Capture animation frames\n const totalWithCover = totalFrames + frames.length;\n for (let i = 0; i < totalFrames; i++) {\n const time = i * frameInterval;\n\n await page.evaluate((t: number) => {\n return (window as unknown as { seekTo: (t: number) => Promise<void> }).seekTo(t);\n }, time);\n\n const screenshot = await page.screenshot({ type: 'png' });\n frames.push(new Uint8Array(screenshot));\n\n if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === totalFrames - 1) {\n writeProgress('Capturing', frames.length, totalWithCover);\n }\n }\n\n clearProgress();\n await browser.close();\n\n // ── Step 6: Encode to MP4 ───────────────────────────────────────\n const ffmpegPath = await detectFfmpeg();\n\n const exportOptions = {\n fps,\n quality: quality as VideoQuality,\n orientation: orientation as VideoOrientation,\n width: dimensions.width,\n height: dimensions.height,\n onProgress: (percent: number, phase: string) => {\n writeProgress('Encoding', percent, 100, phase);\n },\n };\n\n if (ffmpegPath) {\n console.error(`Using native ffmpeg: ${ffmpegPath}`);\n const { framesToMp4Native } = await import('../util/nativeEncoder.js');\n await framesToMp4Native(ffmpegPath, frames, concatenatedAudio, outputPath, exportOptions);\n } else {\n throw new Error(\n 'ffmpeg is required but not found in PATH.\\n' +\n 'Install it with:\\n' +\n ' macOS: brew install ffmpeg\\n' +\n ' Ubuntu: sudo apt install ffmpeg\\n' +\n ' Windows: winget install ffmpeg',\n );\n }\n\n clearProgress();\n console.error(` ✓ ${outputPath}`);\n console.error('Done.');\n}\n\n// ── Progress helpers ──────────────────────────────────────────────\n\nconst BAR_WIDTH = 30;\n\nfunction writeProgress(label: string, current: number, total: number, detail?: string): void {\n const pct = Math.min(100, Math.round((current / total) * 100));\n const filled = Math.round((pct / 100) * BAR_WIDTH);\n const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);\n const suffix = detail ? ` (${detail})` : '';\n process.stderr.write(`\\r ${label}: ${bar} ${pct}%${suffix} `);\n}\n\nfunction clearProgress(): void {\n process.stderr.write('\\r' + ' '.repeat(80) + '\\r');\n}\n","/**\n * detectFfmpeg\n *\n * Checks whether a native ffmpeg binary is available on the system PATH.\n * Returns the absolute path to the binary if found, or null if not.\n *\n * Uses `which` on macOS/Linux and `where` on Windows.\n */\n\nimport { execFile } from 'node:child_process';\n\n/**\n * Detect whether native ffmpeg is installed and available on PATH.\n *\n * @returns Absolute path to ffmpeg binary, or null if not found\n */\nexport async function detectFfmpeg(): Promise<string | null> {\n const command = process.platform === 'win32' ? 'where' : 'which';\n\n return new Promise((resolve) => {\n execFile(command, ['ffmpeg'], { timeout: 5000 }, (err, stdout) => {\n if (err || !stdout.trim()) {\n resolve(null);\n return;\n }\n // `which` and `where` can return multiple lines; take the first\n const path = stdout.trim().split('\\n')[0].trim();\n resolve(path);\n });\n });\n}\n"],"mappings":"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","names":["extname","join","program","extname","join","mkdir","dirname","basename","extname","resolve","resolve","program","resolve","basename","extname","dirname","mkdir"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAE3D,8DAA8D;AAC9D,OAAO,CAAC,KAAK,CACX,8IAA8I,CAC/I,CAAC;AAEF,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;AAE9B,OAAO;KACJ,IAAI,CAAC,QAAQ,CAAC;KACd,WAAW,CAAC,2DAA2D,CAAC;KACxE,OAAO,CAAC,OAAO,CAAC,CAAC;AAEpB,sBAAsB,CAAC,OAAO,CAAC,CAAC;AAChC,oBAAoB,CAAC,OAAO,CAAC,CAAC;AAE9B,OAAO,CAAC,KAAK,EAAE,CAAC"}
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detectFfmpeg
|
|
3
|
+
*
|
|
4
|
+
* Checks whether a native ffmpeg binary is available on the system PATH.
|
|
5
|
+
* Returns the absolute path to the binary if found, or null if not.
|
|
6
|
+
*
|
|
7
|
+
* Uses `which` on macOS/Linux and `where` on Windows.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Detect whether native ffmpeg is installed and available on PATH.
|
|
11
|
+
*
|
|
12
|
+
* @returns Absolute path to ffmpeg binary, or null if not found
|
|
13
|
+
*/
|
|
14
|
+
export declare function detectFfmpeg(): Promise<string | null>;
|
|
15
|
+
//# sourceMappingURL=detectFfmpeg.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"detectFfmpeg.d.ts","sourceRoot":"","sources":["../../src/util/detectFfmpeg.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH;;;;GAIG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAc3D"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detectFfmpeg
|
|
3
|
+
*
|
|
4
|
+
* Checks whether a native ffmpeg binary is available on the system PATH.
|
|
5
|
+
* Returns the absolute path to the binary if found, or null if not.
|
|
6
|
+
*
|
|
7
|
+
* Uses `which` on macOS/Linux and `where` on Windows.
|
|
8
|
+
*/
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
/**
|
|
11
|
+
* Detect whether native ffmpeg is installed and available on PATH.
|
|
12
|
+
*
|
|
13
|
+
* @returns Absolute path to ffmpeg binary, or null if not found
|
|
14
|
+
*/
|
|
15
|
+
export async function detectFfmpeg() {
|
|
16
|
+
const command = process.platform === 'win32' ? 'where' : 'which';
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
execFile(command, ['ffmpeg'], { timeout: 5000 }, (err, stdout) => {
|
|
19
|
+
if (err || !stdout.trim()) {
|
|
20
|
+
resolve(null);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
// `which` and `where` can return multiple lines; take the first
|
|
24
|
+
const path = stdout.trim().split('\n')[0].trim();
|
|
25
|
+
resolve(path);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=detectFfmpeg.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"detectFfmpeg.js","sourceRoot":"","sources":["../../src/util/detectFfmpeg.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAE9C;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY;IAChC,MAAM,OAAO,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC;IAEjE,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,QAAQ,CAAC,OAAO,EAAE,CAAC,QAAQ,CAAC,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE;YAC/D,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;gBAC1B,OAAO,CAAC,IAAI,CAAC,CAAC;gBACd,OAAO;YACT,CAAC;YACD,gEAAgE;YAChE,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;YACjD,OAAO,CAAC,IAAI,CAAC,CAAC;QAChB,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native FFmpeg Encoder
|
|
3
|
+
*
|
|
4
|
+
* Encodes PNG frames to MP4 using a locally installed ffmpeg binary.
|
|
5
|
+
* Writes frames to a temporary directory, invokes ffmpeg as a child process,
|
|
6
|
+
* and reads the resulting MP4.
|
|
7
|
+
*
|
|
8
|
+
* This is the fast path — used when native ffmpeg is detected on the system.
|
|
9
|
+
* Falls back to the WASM encoder (in @bendyline/squisq-video) when unavailable.
|
|
10
|
+
*/
|
|
11
|
+
import type { VideoExportOptions } from '@bendyline/squisq-video';
|
|
12
|
+
/**
|
|
13
|
+
* Encode frame PNGs to MP4 using native ffmpeg.
|
|
14
|
+
*
|
|
15
|
+
* @param ffmpegPath - Absolute path to the ffmpeg binary
|
|
16
|
+
* @param frames - Array of PNG image bytes (one per frame)
|
|
17
|
+
* @param audio - Optional audio file bytes to mux
|
|
18
|
+
* @param outputPath - Where to write the final MP4
|
|
19
|
+
* @param options - Encoding options (fps, quality, dimensions, progress)
|
|
20
|
+
*/
|
|
21
|
+
export declare function framesToMp4Native(ffmpegPath: string, frames: Uint8Array[], audio: Uint8Array | null, outputPath: string, options?: VideoExportOptions): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Encode frames using native ffmpeg and return the MP4 bytes (instead of writing to disk).
|
|
24
|
+
* Useful when the caller needs the bytes in memory.
|
|
25
|
+
*/
|
|
26
|
+
export declare function framesToMp4NativeBytes(ffmpegPath: string, frames: Uint8Array[], audio: Uint8Array | null, options?: VideoExportOptions): Promise<Uint8Array>;
|
|
27
|
+
//# sourceMappingURL=nativeEncoder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nativeEncoder.d.ts","sourceRoot":"","sources":["../../src/util/nativeEncoder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAQH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAGlE;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CACrC,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,UAAU,EAAE,EACpB,KAAK,EAAE,UAAU,GAAG,IAAI,EACxB,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAuFf;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,UAAU,EAAE,EACpB,KAAK,EAAE,UAAU,GAAG,IAAI,EACxB,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,UAAU,CAAC,CAWrB"}
|