@bendyline/squisq-cli 1.1.6 → 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 +265 -147
- package/dist/api.js.map +1 -1
- package/dist/index.js +580 -367
- 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 +9 -7
- package/dist/nativeEncoder-EXDP2O5B.js.map +0 -1
- package/dist/nativeEncoder-MWHOONST.js.map +0 -1
package/dist/api.js
CHANGED
|
@@ -1,32 +1,198 @@
|
|
|
1
1
|
// src/api.ts
|
|
2
|
+
import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
|
|
3
|
+
import { flattenBlocks } from "@bendyline/squisq/doc";
|
|
2
4
|
import { generateRenderHtml } from "@bendyline/squisq-video";
|
|
3
5
|
import { resolveDimensions } from "@bendyline/squisq-video";
|
|
6
|
+
import { convert as formatsConvert } from "@bendyline/squisq-formats";
|
|
4
7
|
|
|
5
8
|
// src/util/detectFfmpeg.ts
|
|
6
9
|
import { execFile } from "child_process";
|
|
7
|
-
|
|
8
|
-
const command = process.platform === "win32" ? "where" : "which";
|
|
10
|
+
function run(command, args) {
|
|
9
11
|
return new Promise((resolve) => {
|
|
10
|
-
execFile(command,
|
|
12
|
+
execFile(command, args, { timeout: 5e3 }, (err, stdout) => {
|
|
11
13
|
if (err || !stdout.trim()) {
|
|
12
14
|
resolve(null);
|
|
13
15
|
return;
|
|
14
16
|
}
|
|
15
|
-
|
|
16
|
-
resolve(path);
|
|
17
|
+
resolve(stdout.trim());
|
|
17
18
|
});
|
|
18
19
|
});
|
|
19
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
|
+
}
|
|
20
177
|
|
|
21
178
|
// src/api.ts
|
|
22
179
|
import { MemoryContentContainer as MemoryContentContainer2 } from "@bendyline/squisq/storage";
|
|
23
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
|
+
|
|
24
188
|
// src/util/readInput.ts
|
|
25
|
-
import { readFile, readdir, stat } from "fs/promises";
|
|
26
|
-
import { join, extname } from "path";
|
|
27
|
-
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";
|
|
28
193
|
import { MemoryContentContainer } from "@bendyline/squisq/storage";
|
|
29
194
|
import { zipToContainer } from "@bendyline/squisq-formats/container";
|
|
195
|
+
import { defaultRegistry as defaultRegistry2 } from "@bendyline/squisq-formats";
|
|
30
196
|
var MIME_TYPES = {
|
|
31
197
|
".md": "text/markdown",
|
|
32
198
|
".txt": "text/plain",
|
|
@@ -43,6 +209,7 @@ var MIME_TYPES = {
|
|
|
43
209
|
".mp4": "video/mp4",
|
|
44
210
|
".webm": "video/webm"
|
|
45
211
|
};
|
|
212
|
+
var IMPORTER_EXTS = [".docx", ".pptx", ".pdf", ".xlsx", ".csv", ".html", ".htm"];
|
|
46
213
|
function mimeFromExt(filePath) {
|
|
47
214
|
return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
48
215
|
}
|
|
@@ -52,7 +219,7 @@ async function walkDir(root, prefix = "") {
|
|
|
52
219
|
for (const entry of entries) {
|
|
53
220
|
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
54
221
|
if (entry.isDirectory()) {
|
|
55
|
-
paths.push(...await walkDir(
|
|
222
|
+
paths.push(...await walkDir(join2(root, entry.name), relPath));
|
|
56
223
|
} else if (entry.isFile()) {
|
|
57
224
|
paths.push(relPath);
|
|
58
225
|
}
|
|
@@ -71,71 +238,99 @@ async function readInput(inputPath) {
|
|
|
71
238
|
if (ext === ".json") {
|
|
72
239
|
return readDocJsonFile(inputPath);
|
|
73
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
|
+
}
|
|
74
247
|
return readMarkdownFile(inputPath);
|
|
75
248
|
}
|
|
249
|
+
async function readArrayBuffer(filePath) {
|
|
250
|
+
const data = await readFile2(filePath);
|
|
251
|
+
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
252
|
+
}
|
|
76
253
|
async function readMarkdownFile(filePath) {
|
|
77
|
-
const content = await
|
|
254
|
+
const content = await readFile2(filePath, "utf-8");
|
|
78
255
|
const container = new MemoryContentContainer();
|
|
79
256
|
await container.writeDocument(content);
|
|
80
257
|
const markdownDoc = parseMarkdown(content);
|
|
81
|
-
return { container, markdownDoc };
|
|
258
|
+
return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: "md" };
|
|
82
259
|
}
|
|
83
260
|
async function readDocJsonFile(filePath) {
|
|
84
|
-
const content = await
|
|
261
|
+
const content = await readFile2(filePath, "utf-8");
|
|
85
262
|
const doc = JSON.parse(content);
|
|
86
263
|
const container = new MemoryContentContainer();
|
|
87
|
-
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 };
|
|
88
281
|
}
|
|
89
282
|
var DOC_JSON_NAMES = ["doc.json", "story.json"];
|
|
90
|
-
async function
|
|
91
|
-
const data = await readFile(filePath);
|
|
92
|
-
const container = await zipToContainer(
|
|
93
|
-
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
|
|
94
|
-
);
|
|
283
|
+
async function resolveContainer(container, sourceFormat, missingMessage) {
|
|
95
284
|
for (const name of DOC_JSON_NAMES) {
|
|
96
285
|
const jsonData = await container.readFile(name);
|
|
97
286
|
if (jsonData) {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
return { container, markdownDoc: null, doc };
|
|
287
|
+
const doc = JSON.parse(new TextDecoder().decode(jsonData));
|
|
288
|
+
return { doc, container, sourceFormat };
|
|
101
289
|
}
|
|
102
290
|
}
|
|
103
291
|
const markdown = await container.readDocument();
|
|
104
292
|
if (!markdown) {
|
|
105
|
-
throw new Error(
|
|
293
|
+
throw new Error(missingMessage);
|
|
106
294
|
}
|
|
107
295
|
const markdownDoc = parseMarkdown(markdown);
|
|
108
|
-
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
|
+
);
|
|
109
305
|
}
|
|
110
306
|
async function readFolder(dirPath) {
|
|
111
307
|
const container = new MemoryContentContainer();
|
|
112
308
|
const files = await walkDir(dirPath);
|
|
113
309
|
for (const relPath of files) {
|
|
114
|
-
const absPath =
|
|
115
|
-
const data = await
|
|
310
|
+
const absPath = join2(dirPath, relPath);
|
|
311
|
+
const data = await readFile2(absPath);
|
|
116
312
|
await container.writeFile(
|
|
117
313
|
relPath,
|
|
118
314
|
new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
|
119
315
|
mimeFromExt(relPath)
|
|
120
316
|
);
|
|
121
317
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return { container, markdownDoc: null, doc };
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
const markdown = await container.readDocument();
|
|
131
|
-
if (!markdown) {
|
|
132
|
-
throw new Error(`No markdown document or doc.json found in folder: ${dirPath}`);
|
|
133
|
-
}
|
|
134
|
-
const markdownDoc = parseMarkdown(markdown);
|
|
135
|
-
return { container, markdownDoc };
|
|
318
|
+
return resolveContainer(
|
|
319
|
+
container,
|
|
320
|
+
"folder",
|
|
321
|
+
`No markdown document or doc.json found in folder: ${dirPath}`
|
|
322
|
+
);
|
|
136
323
|
}
|
|
137
324
|
|
|
138
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
|
+
}
|
|
139
334
|
async function renderDocToMp4(doc, container, options) {
|
|
140
335
|
const {
|
|
141
336
|
outputPath,
|
|
@@ -154,7 +349,7 @@ async function renderDocToMp4(doc, container, options) {
|
|
|
154
349
|
const ffmpegPath = await detectFfmpeg();
|
|
155
350
|
if (!ffmpegPath) {
|
|
156
351
|
throw new Error(
|
|
157
|
-
"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."
|
|
158
353
|
);
|
|
159
354
|
}
|
|
160
355
|
onProgress?.("collecting media", 0);
|
|
@@ -168,20 +363,25 @@ async function renderDocToMp4(doc, container, options) {
|
|
|
168
363
|
}
|
|
169
364
|
}
|
|
170
365
|
const audio = /* @__PURE__ */ new Map();
|
|
171
|
-
const audioBuffers = [];
|
|
172
366
|
if (doc.audio?.segments?.length) {
|
|
173
367
|
for (const seg of doc.audio.segments) {
|
|
174
368
|
const data = await container.readFile(seg.src);
|
|
175
369
|
if (data) {
|
|
176
370
|
audio.set(seg.src, data);
|
|
177
371
|
audio.set(seg.name, data);
|
|
178
|
-
audioBuffers.push(data);
|
|
179
372
|
}
|
|
180
373
|
}
|
|
181
374
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
375
|
+
const mediaSrcs = new Set(resolveMediaSchedule(doc).map((c) => c.src));
|
|
376
|
+
for (const block of flattenBlocks(doc.blocks)) {
|
|
377
|
+
for (const layer of block.layers ?? []) {
|
|
378
|
+
if (layer.type === "video") mediaSrcs.add(layer.content.src);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
for (const src of mediaSrcs) {
|
|
382
|
+
if (images.has(src)) continue;
|
|
383
|
+
const data = await container.readFile(src);
|
|
384
|
+
if (data) images.set(src, data);
|
|
185
385
|
}
|
|
186
386
|
onProgress?.("generating render HTML", 10);
|
|
187
387
|
const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
|
|
@@ -195,7 +395,16 @@ async function renderDocToMp4(doc, container, options) {
|
|
|
195
395
|
});
|
|
196
396
|
onProgress?.("launching browser", 15);
|
|
197
397
|
const { chromium } = await import("playwright-core");
|
|
198
|
-
|
|
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
|
+
}
|
|
199
408
|
const page = await browser.newPage({
|
|
200
409
|
viewport: { width: dimensions.width, height: dimensions.height }
|
|
201
410
|
});
|
|
@@ -213,7 +422,9 @@ async function renderDocToMp4(doc, container, options) {
|
|
|
213
422
|
const errorDetail = pageErrors.length ? `
|
|
214
423
|
Page errors:
|
|
215
424
|
${pageErrors.join("\n ")}` : "\nNo page errors captured \u2014 the player may have failed to mount.";
|
|
216
|
-
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
|
+
);
|
|
217
428
|
}
|
|
218
429
|
const docDuration = await page.evaluate(() => {
|
|
219
430
|
return window.getDuration();
|
|
@@ -261,11 +472,8 @@ Page errors:
|
|
|
261
472
|
}
|
|
262
473
|
await browser.close();
|
|
263
474
|
onProgress?.("encoding video", 80);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
encodingAudio = await addAudioDelay(ffmpegPath, concatenatedAudio, coverPreRoll);
|
|
267
|
-
}
|
|
268
|
-
const { framesToMp4Native } = await import("./nativeEncoder-MWHOONST.js");
|
|
475
|
+
const encodingAudio = await buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll);
|
|
476
|
+
const { framesToMp4Native } = await import("./nativeEncoder-2HGWDEZZ.js");
|
|
269
477
|
await framesToMp4Native(ffmpegPath, frames, encodingAudio, outputPath, {
|
|
270
478
|
fps,
|
|
271
479
|
quality,
|
|
@@ -284,103 +492,10 @@ Page errors:
|
|
|
284
492
|
outputPath
|
|
285
493
|
};
|
|
286
494
|
}
|
|
287
|
-
async function concatenateAudioBuffers(buffers, ffmpegPath) {
|
|
288
|
-
if (buffers.length === 0) return new Uint8Array(0);
|
|
289
|
-
if (buffers.length === 1) return new Uint8Array(buffers[0]);
|
|
290
|
-
if (ffmpegPath) {
|
|
291
|
-
return concatenateAudioNative(ffmpegPath, buffers);
|
|
292
|
-
}
|
|
293
|
-
const totalLength = buffers.reduce((sum, b) => sum + b.byteLength, 0);
|
|
294
|
-
const result = new Uint8Array(totalLength);
|
|
295
|
-
let offset = 0;
|
|
296
|
-
for (const buf of buffers) {
|
|
297
|
-
result.set(new Uint8Array(buf), offset);
|
|
298
|
-
offset += buf.byteLength;
|
|
299
|
-
}
|
|
300
|
-
return result;
|
|
301
|
-
}
|
|
302
|
-
async function concatenateAudioNative(ffmpegPath, buffers) {
|
|
303
|
-
const { writeFile, readFile: readFile2, mkdir, rm } = await import("fs/promises");
|
|
304
|
-
const { join: join2 } = await import("path");
|
|
305
|
-
const { tmpdir } = await import("os");
|
|
306
|
-
const { randomBytes } = await import("crypto");
|
|
307
|
-
const { execFile: execFile2 } = await import("child_process");
|
|
308
|
-
const tmpId = randomBytes(8).toString("hex");
|
|
309
|
-
const workDir = join2(tmpdir(), `squisq-audio-concat-${tmpId}`);
|
|
310
|
-
await mkdir(workDir, { recursive: true });
|
|
311
|
-
try {
|
|
312
|
-
const segmentPaths = [];
|
|
313
|
-
for (let i = 0; i < buffers.length; i++) {
|
|
314
|
-
const segPath = join2(workDir, `seg-${i}.mp3`);
|
|
315
|
-
await writeFile(segPath, new Uint8Array(buffers[i]));
|
|
316
|
-
segmentPaths.push(segPath);
|
|
317
|
-
}
|
|
318
|
-
const listPath = join2(workDir, "concat-list.txt");
|
|
319
|
-
const listContent = segmentPaths.map((p) => `file '${p.replace(/\\/g, "/")}'`).join("\n");
|
|
320
|
-
await writeFile(listPath, listContent);
|
|
321
|
-
const outputPath = join2(workDir, "combined.mp3");
|
|
322
|
-
await new Promise((resolve, reject) => {
|
|
323
|
-
execFile2(
|
|
324
|
-
ffmpegPath,
|
|
325
|
-
["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", outputPath],
|
|
326
|
-
{ timeout: 12e4 },
|
|
327
|
-
(err) => {
|
|
328
|
-
if (err) reject(new Error(`ffmpeg audio concat failed: ${err.message}`));
|
|
329
|
-
else resolve();
|
|
330
|
-
}
|
|
331
|
-
);
|
|
332
|
-
});
|
|
333
|
-
const data = await readFile2(outputPath);
|
|
334
|
-
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
335
|
-
} finally {
|
|
336
|
-
await rm(workDir, { recursive: true, force: true });
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
async function addAudioDelay(ffmpegPath, audioData, delaySecs) {
|
|
340
|
-
const { writeFile, readFile: readFile2, rm } = await import("fs/promises");
|
|
341
|
-
const { join: join2 } = await import("path");
|
|
342
|
-
const { tmpdir } = await import("os");
|
|
343
|
-
const { randomBytes } = await import("crypto");
|
|
344
|
-
const { execFile: execFile2 } = await import("child_process");
|
|
345
|
-
const tmpId = randomBytes(8).toString("hex");
|
|
346
|
-
const inputPath = join2(tmpdir(), `squisq-audio-delay-in-${tmpId}.mp3`);
|
|
347
|
-
const outputPath = join2(tmpdir(), `squisq-audio-delay-out-${tmpId}.mp3`);
|
|
348
|
-
try {
|
|
349
|
-
await writeFile(inputPath, audioData);
|
|
350
|
-
const delayMs = Math.round(delaySecs * 1e3);
|
|
351
|
-
await new Promise((resolve, reject) => {
|
|
352
|
-
execFile2(
|
|
353
|
-
ffmpegPath,
|
|
354
|
-
[
|
|
355
|
-
"-y",
|
|
356
|
-
"-i",
|
|
357
|
-
inputPath,
|
|
358
|
-
"-af",
|
|
359
|
-
`adelay=${delayMs}|${delayMs}`,
|
|
360
|
-
"-c:a",
|
|
361
|
-
"libmp3lame",
|
|
362
|
-
"-b:a",
|
|
363
|
-
"128k",
|
|
364
|
-
outputPath
|
|
365
|
-
],
|
|
366
|
-
{ timeout: 6e4 },
|
|
367
|
-
(err) => {
|
|
368
|
-
if (err) reject(new Error(`ffmpeg audio delay failed: ${err.message}`));
|
|
369
|
-
else resolve();
|
|
370
|
-
}
|
|
371
|
-
);
|
|
372
|
-
});
|
|
373
|
-
const data = await readFile2(outputPath);
|
|
374
|
-
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
375
|
-
} finally {
|
|
376
|
-
await rm(inputPath, { force: true });
|
|
377
|
-
await rm(outputPath, { force: true });
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
495
|
async function extractThumbnails(options) {
|
|
381
496
|
const { videoPath, outputDir, slug, sizes, force } = options;
|
|
382
497
|
const { existsSync } = await import("fs");
|
|
383
|
-
const { join:
|
|
498
|
+
const { join: join3 } = await import("path");
|
|
384
499
|
const { execFile: execFile2 } = await import("child_process");
|
|
385
500
|
const ffmpegPath = await detectFfmpeg();
|
|
386
501
|
if (!ffmpegPath) {
|
|
@@ -389,7 +504,7 @@ async function extractThumbnails(options) {
|
|
|
389
504
|
);
|
|
390
505
|
}
|
|
391
506
|
for (const thumb of sizes) {
|
|
392
|
-
const outputPath =
|
|
507
|
+
const outputPath = join3(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
|
|
393
508
|
if (!force && existsSync(outputPath)) continue;
|
|
394
509
|
await new Promise((resolve, reject) => {
|
|
395
510
|
execFile2(
|
|
@@ -405,7 +520,10 @@ async function extractThumbnails(options) {
|
|
|
405
520
|
}
|
|
406
521
|
}
|
|
407
522
|
export {
|
|
523
|
+
ConversionError,
|
|
408
524
|
MemoryContentContainer2 as MemoryContentContainer,
|
|
525
|
+
convert,
|
|
526
|
+
createCliRegistry,
|
|
409
527
|
extractThumbnails,
|
|
410
528
|
readInput,
|
|
411
529
|
renderDocToMp4
|