@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/api.js
CHANGED
|
@@ -3,32 +3,196 @@ import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
|
|
|
3
3
|
import { flattenBlocks } from "@bendyline/squisq/doc";
|
|
4
4
|
import { generateRenderHtml } from "@bendyline/squisq-video";
|
|
5
5
|
import { resolveDimensions } from "@bendyline/squisq-video";
|
|
6
|
+
import { convert as formatsConvert } from "@bendyline/squisq-formats";
|
|
6
7
|
|
|
7
8
|
// src/util/detectFfmpeg.ts
|
|
8
9
|
import { execFile } from "child_process";
|
|
9
|
-
|
|
10
|
-
const command = process.platform === "win32" ? "where" : "which";
|
|
10
|
+
function run(command, args) {
|
|
11
11
|
return new Promise((resolve) => {
|
|
12
|
-
execFile(command,
|
|
12
|
+
execFile(command, args, { timeout: 5e3 }, (err, stdout) => {
|
|
13
13
|
if (err || !stdout.trim()) {
|
|
14
14
|
resolve(null);
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
resolve(path);
|
|
17
|
+
resolve(stdout.trim());
|
|
19
18
|
});
|
|
20
19
|
});
|
|
21
20
|
}
|
|
21
|
+
async function getFfmpegVersion(path) {
|
|
22
|
+
const out = await run(path, ["-version"]);
|
|
23
|
+
return out ? out.split("\n")[0].trim() : null;
|
|
24
|
+
}
|
|
25
|
+
async function detectFfmpegDetailed() {
|
|
26
|
+
const envPath = process.env.SQUISQ_FFMPEG;
|
|
27
|
+
if (envPath) {
|
|
28
|
+
const version = await getFfmpegVersion(envPath);
|
|
29
|
+
if (!version) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`SQUISQ_FFMPEG is set to "${envPath}" but running "${envPath} -version" failed. Fix the path or unset SQUISQ_FFMPEG.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return { path: envPath, source: "env" };
|
|
35
|
+
}
|
|
36
|
+
const command = process.platform === "win32" ? "where" : "which";
|
|
37
|
+
const found = await run(command, ["ffmpeg"]);
|
|
38
|
+
if (found) {
|
|
39
|
+
return { path: found.split("\n")[0].trim(), source: "path" };
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const specifier = "ffmpeg-static";
|
|
43
|
+
const mod = await import(specifier);
|
|
44
|
+
const candidate = typeof mod === "string" ? mod : typeof mod.default === "string" ? mod.default : null;
|
|
45
|
+
if (candidate) {
|
|
46
|
+
return { path: candidate, source: "ffmpeg-static" };
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
async function detectFfmpeg() {
|
|
53
|
+
const detection = await detectFfmpegDetailed();
|
|
54
|
+
return detection?.path ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/util/audioMix.ts
|
|
58
|
+
import { computeAudioTimeline } from "@bendyline/squisq-video";
|
|
59
|
+
async function buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll) {
|
|
60
|
+
const timeline = computeAudioTimeline(doc, coverPreRoll);
|
|
61
|
+
if (timeline.length === 0) return null;
|
|
62
|
+
const bytesBySrc = /* @__PURE__ */ new Map();
|
|
63
|
+
const readSrc = async (src) => {
|
|
64
|
+
if (bytesBySrc.has(src)) return bytesBySrc.get(src);
|
|
65
|
+
const data = await container.readFile(src);
|
|
66
|
+
bytesBySrc.set(src, data ?? null);
|
|
67
|
+
return data ?? null;
|
|
68
|
+
};
|
|
69
|
+
const usable = [];
|
|
70
|
+
for (const clip of timeline) {
|
|
71
|
+
const buffer = await readSrc(clip.src);
|
|
72
|
+
if (buffer) usable.push({ clip, buffer });
|
|
73
|
+
}
|
|
74
|
+
if (usable.length === 0) return null;
|
|
75
|
+
return mixTimelineClips(ffmpegPath, usable);
|
|
76
|
+
}
|
|
77
|
+
async function mixTimelineClips(ffmpegPath, usable) {
|
|
78
|
+
const { writeFile, readFile: readFile3, mkdir, rm: rm2 } = await import("fs/promises");
|
|
79
|
+
const { join: join3 } = await import("path");
|
|
80
|
+
const { tmpdir: tmpdir2 } = await import("os");
|
|
81
|
+
const { randomBytes: randomBytes2 } = await import("crypto");
|
|
82
|
+
const { execFile: execFile2 } = await import("child_process");
|
|
83
|
+
const workDir = join3(tmpdir2(), `squisq-audio-mix-${randomBytes2(8).toString("hex")}`);
|
|
84
|
+
await mkdir(workDir, { recursive: true });
|
|
85
|
+
const ms = (s) => Math.max(0, Math.round(s * 1e3));
|
|
86
|
+
try {
|
|
87
|
+
const inputs = [];
|
|
88
|
+
const filters = [];
|
|
89
|
+
const labels = [];
|
|
90
|
+
for (const { clip, buffer } of usable) {
|
|
91
|
+
const p = join3(workDir, `clip-${inputs.length}.mp3`);
|
|
92
|
+
await writeFile(p, new Uint8Array(buffer));
|
|
93
|
+
const i = inputs.push(p) - 1;
|
|
94
|
+
const delayMs = ms(clip.startSec);
|
|
95
|
+
const start = Math.max(0, clip.sourceInSec);
|
|
96
|
+
const end = start + Math.max(0, clip.durationSec);
|
|
97
|
+
filters.push(
|
|
98
|
+
`[${i}:a]atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS,adelay=${delayMs}|${delayMs}[a${i}]`
|
|
99
|
+
);
|
|
100
|
+
labels.push(`[a${i}]`);
|
|
101
|
+
}
|
|
102
|
+
const graph = `${filters.join(";")};${labels.join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[aout]`;
|
|
103
|
+
const outputPath = join3(workDir, "mixed.mp3");
|
|
104
|
+
const args = ["-y"];
|
|
105
|
+
for (const p of inputs) args.push("-i", p);
|
|
106
|
+
args.push(
|
|
107
|
+
"-filter_complex",
|
|
108
|
+
graph,
|
|
109
|
+
"-map",
|
|
110
|
+
"[aout]",
|
|
111
|
+
"-c:a",
|
|
112
|
+
"libmp3lame",
|
|
113
|
+
"-b:a",
|
|
114
|
+
"192k",
|
|
115
|
+
outputPath
|
|
116
|
+
);
|
|
117
|
+
await new Promise((resolve, reject) => {
|
|
118
|
+
execFile2(ffmpegPath, args, { timeout: 18e4 }, (err) => {
|
|
119
|
+
if (err) reject(new Error(`ffmpeg audio mix failed: ${err.message}`));
|
|
120
|
+
else resolve();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
const data = await readFile3(outputPath);
|
|
124
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
125
|
+
} finally {
|
|
126
|
+
await rm2(workDir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/registry.ts
|
|
131
|
+
import { randomBytes } from "crypto";
|
|
132
|
+
import { readFile, rm } from "fs/promises";
|
|
133
|
+
import { tmpdir } from "os";
|
|
134
|
+
import { join } from "path";
|
|
135
|
+
import { defaultRegistry } from "@bendyline/squisq-formats";
|
|
136
|
+
var MP4_DEFAULTS = {
|
|
137
|
+
fps: 30,
|
|
138
|
+
quality: "normal",
|
|
139
|
+
orientation: "landscape",
|
|
140
|
+
coverPreRoll: 0
|
|
141
|
+
};
|
|
142
|
+
function mp4Format() {
|
|
143
|
+
return {
|
|
144
|
+
id: "mp4",
|
|
145
|
+
label: "MP4 Video",
|
|
146
|
+
mimeType: "video/mp4",
|
|
147
|
+
extensions: [".mp4"],
|
|
148
|
+
async exportDoc(input, options) {
|
|
149
|
+
const mp4Opts = options.formatOptions?.mp4 ?? {};
|
|
150
|
+
const fps = typeof mp4Opts.fps === "number" ? mp4Opts.fps : MP4_DEFAULTS.fps;
|
|
151
|
+
const quality = typeof mp4Opts.quality === "string" ? mp4Opts.quality : MP4_DEFAULTS.quality;
|
|
152
|
+
const orientation = typeof mp4Opts.orientation === "string" ? mp4Opts.orientation : MP4_DEFAULTS.orientation;
|
|
153
|
+
const coverPreRoll = typeof mp4Opts.coverPreRoll === "number" ? mp4Opts.coverPreRoll : MP4_DEFAULTS.coverPreRoll;
|
|
154
|
+
const outputPath = join(tmpdir(), `squisq-mp4-${randomBytes(8).toString("hex")}.mp4`);
|
|
155
|
+
try {
|
|
156
|
+
await renderDocToMp4(input.doc, input.container, {
|
|
157
|
+
outputPath,
|
|
158
|
+
fps,
|
|
159
|
+
quality,
|
|
160
|
+
orientation,
|
|
161
|
+
coverPreRoll
|
|
162
|
+
});
|
|
163
|
+
const data = await readFile(outputPath);
|
|
164
|
+
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
165
|
+
return { bytes, mimeType: "video/mp4", suggestedFilename: "", warnings: [] };
|
|
166
|
+
} finally {
|
|
167
|
+
await rm(outputPath, { force: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function createCliRegistry() {
|
|
173
|
+
const registry = defaultRegistry();
|
|
174
|
+
registry.register(mp4Format());
|
|
175
|
+
return registry;
|
|
176
|
+
}
|
|
22
177
|
|
|
23
178
|
// src/api.ts
|
|
24
179
|
import { MemoryContentContainer as MemoryContentContainer2 } from "@bendyline/squisq/storage";
|
|
25
180
|
|
|
181
|
+
// src/util/domPolyfill.ts
|
|
182
|
+
import { DOMParser as XmldomDOMParser } from "@xmldom/xmldom";
|
|
183
|
+
var globalScope = globalThis;
|
|
184
|
+
if (typeof globalScope.DOMParser === "undefined") {
|
|
185
|
+
globalScope.DOMParser = XmldomDOMParser;
|
|
186
|
+
}
|
|
187
|
+
|
|
26
188
|
// src/util/readInput.ts
|
|
27
|
-
import { readFile, readdir, stat } from "fs/promises";
|
|
28
|
-
import { join, extname } from "path";
|
|
29
|
-
import { parseMarkdown } from "@bendyline/squisq/markdown";
|
|
189
|
+
import { readFile as readFile2, readdir, stat } from "fs/promises";
|
|
190
|
+
import { join as join2, extname } from "path";
|
|
191
|
+
import { parseMarkdown, stringifyMarkdown } from "@bendyline/squisq/markdown";
|
|
192
|
+
import { markdownToDoc } from "@bendyline/squisq/doc";
|
|
30
193
|
import { MemoryContentContainer } from "@bendyline/squisq/storage";
|
|
31
194
|
import { zipToContainer } from "@bendyline/squisq-formats/container";
|
|
195
|
+
import { defaultRegistry as defaultRegistry2 } from "@bendyline/squisq-formats";
|
|
32
196
|
var MIME_TYPES = {
|
|
33
197
|
".md": "text/markdown",
|
|
34
198
|
".txt": "text/plain",
|
|
@@ -45,6 +209,7 @@ var MIME_TYPES = {
|
|
|
45
209
|
".mp4": "video/mp4",
|
|
46
210
|
".webm": "video/webm"
|
|
47
211
|
};
|
|
212
|
+
var IMPORTER_EXTS = [".docx", ".pptx", ".pdf", ".xlsx", ".csv", ".html", ".htm"];
|
|
48
213
|
function mimeFromExt(filePath) {
|
|
49
214
|
return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
50
215
|
}
|
|
@@ -54,7 +219,7 @@ async function walkDir(root, prefix = "") {
|
|
|
54
219
|
for (const entry of entries) {
|
|
55
220
|
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
56
221
|
if (entry.isDirectory()) {
|
|
57
|
-
paths.push(...await walkDir(
|
|
222
|
+
paths.push(...await walkDir(join2(root, entry.name), relPath));
|
|
58
223
|
} else if (entry.isFile()) {
|
|
59
224
|
paths.push(relPath);
|
|
60
225
|
}
|
|
@@ -73,71 +238,99 @@ async function readInput(inputPath) {
|
|
|
73
238
|
if (ext === ".json") {
|
|
74
239
|
return readDocJsonFile(inputPath);
|
|
75
240
|
}
|
|
241
|
+
if (IMPORTER_EXTS.includes(ext)) {
|
|
242
|
+
const def = defaultRegistry2().byExtension(ext);
|
|
243
|
+
if (def && (def.importContainer || def.importDoc)) {
|
|
244
|
+
return readViaImporter(inputPath, def);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
76
247
|
return readMarkdownFile(inputPath);
|
|
77
248
|
}
|
|
249
|
+
async function readArrayBuffer(filePath) {
|
|
250
|
+
const data = await readFile2(filePath);
|
|
251
|
+
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
252
|
+
}
|
|
78
253
|
async function readMarkdownFile(filePath) {
|
|
79
|
-
const content = await
|
|
254
|
+
const content = await readFile2(filePath, "utf-8");
|
|
80
255
|
const container = new MemoryContentContainer();
|
|
81
256
|
await container.writeDocument(content);
|
|
82
257
|
const markdownDoc = parseMarkdown(content);
|
|
83
|
-
return { container, markdownDoc };
|
|
258
|
+
return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: "md" };
|
|
84
259
|
}
|
|
85
260
|
async function readDocJsonFile(filePath) {
|
|
86
|
-
const content = await
|
|
261
|
+
const content = await readFile2(filePath, "utf-8");
|
|
87
262
|
const doc = JSON.parse(content);
|
|
88
263
|
const container = new MemoryContentContainer();
|
|
89
|
-
return { container,
|
|
264
|
+
return { doc, container, sourceFormat: "json" };
|
|
265
|
+
}
|
|
266
|
+
async function readViaImporter(filePath, def) {
|
|
267
|
+
const buffer = await readArrayBuffer(filePath);
|
|
268
|
+
let container;
|
|
269
|
+
let markdownDoc;
|
|
270
|
+
if (def.importContainer) {
|
|
271
|
+
container = await def.importContainer(buffer, {});
|
|
272
|
+
const text = await container.readDocument();
|
|
273
|
+
markdownDoc = text ? parseMarkdown(text) : { type: "document", children: [] };
|
|
274
|
+
} else {
|
|
275
|
+
markdownDoc = await def.importDoc(buffer, {});
|
|
276
|
+
const mem = new MemoryContentContainer();
|
|
277
|
+
await mem.writeDocument(stringifyMarkdown(markdownDoc));
|
|
278
|
+
container = mem;
|
|
279
|
+
}
|
|
280
|
+
return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: def.id };
|
|
90
281
|
}
|
|
91
282
|
var DOC_JSON_NAMES = ["doc.json", "story.json"];
|
|
92
|
-
async function
|
|
93
|
-
const data = await readFile(filePath);
|
|
94
|
-
const container = await zipToContainer(
|
|
95
|
-
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
|
|
96
|
-
);
|
|
283
|
+
async function resolveContainer(container, sourceFormat, missingMessage) {
|
|
97
284
|
for (const name of DOC_JSON_NAMES) {
|
|
98
285
|
const jsonData = await container.readFile(name);
|
|
99
286
|
if (jsonData) {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
return { container, markdownDoc: null, doc };
|
|
287
|
+
const doc = JSON.parse(new TextDecoder().decode(jsonData));
|
|
288
|
+
return { doc, container, sourceFormat };
|
|
103
289
|
}
|
|
104
290
|
}
|
|
105
291
|
const markdown = await container.readDocument();
|
|
106
292
|
if (!markdown) {
|
|
107
|
-
throw new Error(
|
|
293
|
+
throw new Error(missingMessage);
|
|
108
294
|
}
|
|
109
295
|
const markdownDoc = parseMarkdown(markdown);
|
|
110
|
-
return { container, markdownDoc };
|
|
296
|
+
return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat };
|
|
297
|
+
}
|
|
298
|
+
async function readContainer(filePath) {
|
|
299
|
+
const container = await zipToContainer(await readArrayBuffer(filePath));
|
|
300
|
+
return resolveContainer(
|
|
301
|
+
container,
|
|
302
|
+
"dbk",
|
|
303
|
+
`No markdown document or doc.json found in container: ${filePath}`
|
|
304
|
+
);
|
|
111
305
|
}
|
|
112
306
|
async function readFolder(dirPath) {
|
|
113
307
|
const container = new MemoryContentContainer();
|
|
114
308
|
const files = await walkDir(dirPath);
|
|
115
309
|
for (const relPath of files) {
|
|
116
|
-
const absPath =
|
|
117
|
-
const data = await
|
|
310
|
+
const absPath = join2(dirPath, relPath);
|
|
311
|
+
const data = await readFile2(absPath);
|
|
118
312
|
await container.writeFile(
|
|
119
313
|
relPath,
|
|
120
314
|
new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
|
121
315
|
mimeFromExt(relPath)
|
|
122
316
|
);
|
|
123
317
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return { container, markdownDoc: null, doc };
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
const markdown = await container.readDocument();
|
|
133
|
-
if (!markdown) {
|
|
134
|
-
throw new Error(`No markdown document or doc.json found in folder: ${dirPath}`);
|
|
135
|
-
}
|
|
136
|
-
const markdownDoc = parseMarkdown(markdown);
|
|
137
|
-
return { container, markdownDoc };
|
|
318
|
+
return resolveContainer(
|
|
319
|
+
container,
|
|
320
|
+
"folder",
|
|
321
|
+
`No markdown document or doc.json found in folder: ${dirPath}`
|
|
322
|
+
);
|
|
138
323
|
}
|
|
139
324
|
|
|
140
325
|
// src/api.ts
|
|
326
|
+
import { ConversionError } from "@bendyline/squisq-formats";
|
|
327
|
+
async function convert(source, to, options = {}) {
|
|
328
|
+
return formatsConvert(source, to, {
|
|
329
|
+
registry: createCliRegistry(),
|
|
330
|
+
resolvePlayerScript: () => import("@bendyline/squisq-react/standalone-source").then((m) => m.PLAYER_BUNDLE),
|
|
331
|
+
...options
|
|
332
|
+
});
|
|
333
|
+
}
|
|
141
334
|
async function renderDocToMp4(doc, container, options) {
|
|
142
335
|
const {
|
|
143
336
|
outputPath,
|
|
@@ -156,7 +349,7 @@ async function renderDocToMp4(doc, container, options) {
|
|
|
156
349
|
const ffmpegPath = await detectFfmpeg();
|
|
157
350
|
if (!ffmpegPath) {
|
|
158
351
|
throw new Error(
|
|
159
|
-
"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"
|
|
352
|
+
"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."
|
|
160
353
|
);
|
|
161
354
|
}
|
|
162
355
|
onProgress?.("collecting media", 0);
|
|
@@ -170,39 +363,26 @@ async function renderDocToMp4(doc, container, options) {
|
|
|
170
363
|
}
|
|
171
364
|
}
|
|
172
365
|
const audio = /* @__PURE__ */ new Map();
|
|
173
|
-
const audioBuffers = [];
|
|
174
366
|
if (doc.audio?.segments?.length) {
|
|
175
367
|
for (const seg of doc.audio.segments) {
|
|
176
368
|
const data = await container.readFile(seg.src);
|
|
177
369
|
if (data) {
|
|
178
370
|
audio.set(seg.src, data);
|
|
179
371
|
audio.set(seg.name, data);
|
|
180
|
-
audioBuffers.push(data);
|
|
181
372
|
}
|
|
182
373
|
}
|
|
183
374
|
}
|
|
184
|
-
|
|
185
|
-
if (audioBuffers.length > 0) {
|
|
186
|
-
concatenatedAudio = await concatenateAudioBuffers(audioBuffers, ffmpegPath);
|
|
187
|
-
}
|
|
188
|
-
const schedule = resolveMediaSchedule(doc);
|
|
189
|
-
const mediaSrcs = new Set(schedule.map((c) => c.src));
|
|
375
|
+
const mediaSrcs = new Set(resolveMediaSchedule(doc).map((c) => c.src));
|
|
190
376
|
for (const block of flattenBlocks(doc.blocks)) {
|
|
191
377
|
for (const layer of block.layers ?? []) {
|
|
192
378
|
if (layer.type === "video") mediaSrcs.add(layer.content.src);
|
|
193
379
|
}
|
|
194
380
|
}
|
|
195
|
-
const clipBuffers = /* @__PURE__ */ new Map();
|
|
196
381
|
for (const src of mediaSrcs) {
|
|
197
382
|
if (images.has(src)) continue;
|
|
198
383
|
const data = await container.readFile(src);
|
|
199
384
|
if (data) images.set(src, data);
|
|
200
385
|
}
|
|
201
|
-
for (const clip of schedule) {
|
|
202
|
-
if (clip.kind !== "audio") continue;
|
|
203
|
-
const data = images.get(clip.src) ?? await container.readFile(clip.src);
|
|
204
|
-
if (data) clipBuffers.set(clip.id, data);
|
|
205
|
-
}
|
|
206
386
|
onProgress?.("generating render HTML", 10);
|
|
207
387
|
const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
|
|
208
388
|
const renderHtml = generateRenderHtml(doc, {
|
|
@@ -215,7 +395,16 @@ async function renderDocToMp4(doc, container, options) {
|
|
|
215
395
|
});
|
|
216
396
|
onProgress?.("launching browser", 15);
|
|
217
397
|
const { chromium } = await import("playwright-core");
|
|
218
|
-
|
|
398
|
+
let browser;
|
|
399
|
+
try {
|
|
400
|
+
browser = await chromium.launch({ headless: true });
|
|
401
|
+
} catch (err) {
|
|
402
|
+
const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
403
|
+
throw new Error(
|
|
404
|
+
`Playwright Chromium is not installed. Run: npx playwright install chromium
|
|
405
|
+
(launch failed: ${detail})`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
219
408
|
const page = await browser.newPage({
|
|
220
409
|
viewport: { width: dimensions.width, height: dimensions.height }
|
|
221
410
|
});
|
|
@@ -233,7 +422,9 @@ async function renderDocToMp4(doc, container, options) {
|
|
|
233
422
|
const errorDetail = pageErrors.length ? `
|
|
234
423
|
Page errors:
|
|
235
424
|
${pageErrors.join("\n ")}` : "\nNo page errors captured \u2014 the player may have failed to mount.";
|
|
236
|
-
throw new Error(
|
|
425
|
+
throw new Error(
|
|
426
|
+
`The standalone player failed to boot in headless Chromium. Render API did not initialize within 15 seconds.${errorDetail}`
|
|
427
|
+
);
|
|
237
428
|
}
|
|
238
429
|
const docDuration = await page.evaluate(() => {
|
|
239
430
|
return window.getDuration();
|
|
@@ -281,24 +472,8 @@ Page errors:
|
|
|
281
472
|
}
|
|
282
473
|
await browser.close();
|
|
283
474
|
onProgress?.("encoding video", 80);
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
if (scheduledAudioClips.length > 0 && ffmpegPath) {
|
|
287
|
-
encodingAudio = await mixScheduledAudio(
|
|
288
|
-
ffmpegPath,
|
|
289
|
-
concatenatedAudio,
|
|
290
|
-
scheduledAudioClips.map((c) => ({
|
|
291
|
-
buffer: clipBuffers.get(c.id),
|
|
292
|
-
delaySec: c.absoluteStart + coverPreRoll,
|
|
293
|
-
trimStart: c.sourceIn,
|
|
294
|
-
trimLen: Math.max(0, c.absoluteEnd - c.absoluteStart)
|
|
295
|
-
})),
|
|
296
|
-
coverPreRoll
|
|
297
|
-
);
|
|
298
|
-
} else if (coverPreRoll > 0 && concatenatedAudio) {
|
|
299
|
-
encodingAudio = await addAudioDelay(ffmpegPath, concatenatedAudio, coverPreRoll);
|
|
300
|
-
}
|
|
301
|
-
const { framesToMp4Native } = await import("./nativeEncoder-MWHOONST.js");
|
|
475
|
+
const encodingAudio = await buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll);
|
|
476
|
+
const { framesToMp4Native } = await import("./nativeEncoder-2HGWDEZZ.js");
|
|
302
477
|
await framesToMp4Native(ffmpegPath, frames, encodingAudio, outputPath, {
|
|
303
478
|
fps,
|
|
304
479
|
quality,
|
|
@@ -317,162 +492,10 @@ Page errors:
|
|
|
317
492
|
outputPath
|
|
318
493
|
};
|
|
319
494
|
}
|
|
320
|
-
async function concatenateAudioBuffers(buffers, ffmpegPath) {
|
|
321
|
-
if (buffers.length === 0) return new Uint8Array(0);
|
|
322
|
-
if (buffers.length === 1) return new Uint8Array(buffers[0]);
|
|
323
|
-
if (ffmpegPath) {
|
|
324
|
-
return concatenateAudioNative(ffmpegPath, buffers);
|
|
325
|
-
}
|
|
326
|
-
const totalLength = buffers.reduce((sum, b) => sum + b.byteLength, 0);
|
|
327
|
-
const result = new Uint8Array(totalLength);
|
|
328
|
-
let offset = 0;
|
|
329
|
-
for (const buf of buffers) {
|
|
330
|
-
result.set(new Uint8Array(buf), offset);
|
|
331
|
-
offset += buf.byteLength;
|
|
332
|
-
}
|
|
333
|
-
return result;
|
|
334
|
-
}
|
|
335
|
-
async function concatenateAudioNative(ffmpegPath, buffers) {
|
|
336
|
-
const { writeFile, readFile: readFile2, mkdir, rm } = await import("fs/promises");
|
|
337
|
-
const { join: join2 } = await import("path");
|
|
338
|
-
const { tmpdir } = await import("os");
|
|
339
|
-
const { randomBytes } = await import("crypto");
|
|
340
|
-
const { execFile: execFile2 } = await import("child_process");
|
|
341
|
-
const tmpId = randomBytes(8).toString("hex");
|
|
342
|
-
const workDir = join2(tmpdir(), `squisq-audio-concat-${tmpId}`);
|
|
343
|
-
await mkdir(workDir, { recursive: true });
|
|
344
|
-
try {
|
|
345
|
-
const segmentPaths = [];
|
|
346
|
-
for (let i = 0; i < buffers.length; i++) {
|
|
347
|
-
const segPath = join2(workDir, `seg-${i}.mp3`);
|
|
348
|
-
await writeFile(segPath, new Uint8Array(buffers[i]));
|
|
349
|
-
segmentPaths.push(segPath);
|
|
350
|
-
}
|
|
351
|
-
const listPath = join2(workDir, "concat-list.txt");
|
|
352
|
-
const listContent = segmentPaths.map((p) => `file '${p.replace(/\\/g, "/")}'`).join("\n");
|
|
353
|
-
await writeFile(listPath, listContent);
|
|
354
|
-
const outputPath = join2(workDir, "combined.mp3");
|
|
355
|
-
await new Promise((resolve, reject) => {
|
|
356
|
-
execFile2(
|
|
357
|
-
ffmpegPath,
|
|
358
|
-
["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", outputPath],
|
|
359
|
-
{ timeout: 12e4 },
|
|
360
|
-
(err) => {
|
|
361
|
-
if (err) reject(new Error(`ffmpeg audio concat failed: ${err.message}`));
|
|
362
|
-
else resolve();
|
|
363
|
-
}
|
|
364
|
-
);
|
|
365
|
-
});
|
|
366
|
-
const data = await readFile2(outputPath);
|
|
367
|
-
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
368
|
-
} finally {
|
|
369
|
-
await rm(workDir, { recursive: true, force: true });
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
async function addAudioDelay(ffmpegPath, audioData, delaySecs) {
|
|
373
|
-
const { writeFile, readFile: readFile2, rm } = await import("fs/promises");
|
|
374
|
-
const { join: join2 } = await import("path");
|
|
375
|
-
const { tmpdir } = await import("os");
|
|
376
|
-
const { randomBytes } = await import("crypto");
|
|
377
|
-
const { execFile: execFile2 } = await import("child_process");
|
|
378
|
-
const tmpId = randomBytes(8).toString("hex");
|
|
379
|
-
const inputPath = join2(tmpdir(), `squisq-audio-delay-in-${tmpId}.mp3`);
|
|
380
|
-
const outputPath = join2(tmpdir(), `squisq-audio-delay-out-${tmpId}.mp3`);
|
|
381
|
-
try {
|
|
382
|
-
await writeFile(inputPath, audioData);
|
|
383
|
-
const delayMs = Math.round(delaySecs * 1e3);
|
|
384
|
-
await new Promise((resolve, reject) => {
|
|
385
|
-
execFile2(
|
|
386
|
-
ffmpegPath,
|
|
387
|
-
[
|
|
388
|
-
"-y",
|
|
389
|
-
"-i",
|
|
390
|
-
inputPath,
|
|
391
|
-
"-af",
|
|
392
|
-
`adelay=${delayMs}|${delayMs}`,
|
|
393
|
-
"-c:a",
|
|
394
|
-
"libmp3lame",
|
|
395
|
-
"-b:a",
|
|
396
|
-
"128k",
|
|
397
|
-
outputPath
|
|
398
|
-
],
|
|
399
|
-
{ timeout: 6e4 },
|
|
400
|
-
(err) => {
|
|
401
|
-
if (err) reject(new Error(`ffmpeg audio delay failed: ${err.message}`));
|
|
402
|
-
else resolve();
|
|
403
|
-
}
|
|
404
|
-
);
|
|
405
|
-
});
|
|
406
|
-
const data = await readFile2(outputPath);
|
|
407
|
-
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
408
|
-
} finally {
|
|
409
|
-
await rm(inputPath, { force: true });
|
|
410
|
-
await rm(outputPath, { force: true });
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
async function mixScheduledAudio(ffmpegPath, narration, clips, coverPreRoll) {
|
|
414
|
-
if (!narration && clips.length === 0) return null;
|
|
415
|
-
const { writeFile, readFile: readFile2, mkdir, rm } = await import("fs/promises");
|
|
416
|
-
const { join: join2 } = await import("path");
|
|
417
|
-
const { tmpdir } = await import("os");
|
|
418
|
-
const { randomBytes } = await import("crypto");
|
|
419
|
-
const { execFile: execFile2 } = await import("child_process");
|
|
420
|
-
const workDir = join2(tmpdir(), `squisq-audio-mix-${randomBytes(8).toString("hex")}`);
|
|
421
|
-
await mkdir(workDir, { recursive: true });
|
|
422
|
-
try {
|
|
423
|
-
const inputs = [];
|
|
424
|
-
const filters = [];
|
|
425
|
-
const labels = [];
|
|
426
|
-
const ms = (s) => Math.max(0, Math.round(s * 1e3));
|
|
427
|
-
if (narration) {
|
|
428
|
-
const p = join2(workDir, "narration.mp3");
|
|
429
|
-
await writeFile(p, narration);
|
|
430
|
-
const i = inputs.push(p) - 1;
|
|
431
|
-
const d = ms(coverPreRoll);
|
|
432
|
-
filters.push(`[${i}:a]adelay=${d}|${d}[a${i}]`);
|
|
433
|
-
labels.push(`[a${i}]`);
|
|
434
|
-
}
|
|
435
|
-
for (const clip of clips) {
|
|
436
|
-
const p = join2(workDir, `clip-${inputs.length}.mp3`);
|
|
437
|
-
await writeFile(p, new Uint8Array(clip.buffer));
|
|
438
|
-
const i = inputs.push(p) - 1;
|
|
439
|
-
const d = ms(clip.delaySec);
|
|
440
|
-
const end = clip.trimStart + clip.trimLen;
|
|
441
|
-
filters.push(
|
|
442
|
-
`[${i}:a]atrim=start=${clip.trimStart}:end=${end},asetpts=PTS-STARTPTS,adelay=${d}|${d}[a${i}]`
|
|
443
|
-
);
|
|
444
|
-
labels.push(`[a${i}]`);
|
|
445
|
-
}
|
|
446
|
-
const graph = `${filters.join(";")};${labels.join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[aout]`;
|
|
447
|
-
const args = ["-y"];
|
|
448
|
-
for (const p of inputs) args.push("-i", p);
|
|
449
|
-
args.push(
|
|
450
|
-
"-filter_complex",
|
|
451
|
-
graph,
|
|
452
|
-
"-map",
|
|
453
|
-
"[aout]",
|
|
454
|
-
"-c:a",
|
|
455
|
-
"libmp3lame",
|
|
456
|
-
"-b:a",
|
|
457
|
-
"192k",
|
|
458
|
-
join2(workDir, "mixed.mp3")
|
|
459
|
-
);
|
|
460
|
-
await new Promise((resolve, reject) => {
|
|
461
|
-
execFile2(ffmpegPath, args, { timeout: 18e4 }, (err) => {
|
|
462
|
-
if (err) reject(new Error(`ffmpeg audio mix failed: ${err.message}`));
|
|
463
|
-
else resolve();
|
|
464
|
-
});
|
|
465
|
-
});
|
|
466
|
-
const data = await readFile2(join2(workDir, "mixed.mp3"));
|
|
467
|
-
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
468
|
-
} finally {
|
|
469
|
-
await rm(workDir, { recursive: true, force: true });
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
495
|
async function extractThumbnails(options) {
|
|
473
496
|
const { videoPath, outputDir, slug, sizes, force } = options;
|
|
474
497
|
const { existsSync } = await import("fs");
|
|
475
|
-
const { join:
|
|
498
|
+
const { join: join3 } = await import("path");
|
|
476
499
|
const { execFile: execFile2 } = await import("child_process");
|
|
477
500
|
const ffmpegPath = await detectFfmpeg();
|
|
478
501
|
if (!ffmpegPath) {
|
|
@@ -481,7 +504,7 @@ async function extractThumbnails(options) {
|
|
|
481
504
|
);
|
|
482
505
|
}
|
|
483
506
|
for (const thumb of sizes) {
|
|
484
|
-
const outputPath =
|
|
507
|
+
const outputPath = join3(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
|
|
485
508
|
if (!force && existsSync(outputPath)) continue;
|
|
486
509
|
await new Promise((resolve, reject) => {
|
|
487
510
|
execFile2(
|
|
@@ -497,7 +520,10 @@ async function extractThumbnails(options) {
|
|
|
497
520
|
}
|
|
498
521
|
}
|
|
499
522
|
export {
|
|
523
|
+
ConversionError,
|
|
500
524
|
MemoryContentContainer2 as MemoryContentContainer,
|
|
525
|
+
convert,
|
|
526
|
+
createCliRegistry,
|
|
501
527
|
extractThumbnails,
|
|
502
528
|
readInput,
|
|
503
529
|
renderDocToMp4
|