@bendyline/squisq-cli 1.1.5 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +45 -14
- package/dist/api.js +482 -301
- package/dist/api.js.map +1 -1
- package/dist/index.d.ts +2 -12
- package/dist/index.js +889 -20
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/dist/__tests__/convert.test.d.ts +0 -7
- package/dist/__tests__/convert.test.d.ts.map +0 -1
- package/dist/__tests__/convert.test.js +0 -127
- package/dist/__tests__/convert.test.js.map +0 -1
- package/dist/__tests__/html-export.test.d.ts +0 -8
- package/dist/__tests__/html-export.test.d.ts.map +0 -1
- package/dist/__tests__/html-export.test.js +0 -126
- package/dist/__tests__/html-export.test.js.map +0 -1
- package/dist/__tests__/readInput.test.d.ts +0 -5
- package/dist/__tests__/readInput.test.d.ts.map +0 -1
- package/dist/__tests__/readInput.test.js +0 -120
- package/dist/__tests__/readInput.test.js.map +0 -1
- package/dist/api.d.ts.map +0 -1
- package/dist/commands/convert.d.ts +0 -15
- package/dist/commands/convert.d.ts.map +0 -1
- package/dist/commands/convert.js +0 -264
- package/dist/commands/convert.js.map +0 -1
- package/dist/commands/video.d.ts +0 -12
- package/dist/commands/video.d.ts.map +0 -1
- package/dist/commands/video.js +0 -122
- package/dist/commands/video.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/util/detectFfmpeg.d.ts +0 -15
- package/dist/util/detectFfmpeg.d.ts.map +0 -1
- package/dist/util/detectFfmpeg.js +0 -29
- package/dist/util/detectFfmpeg.js.map +0 -1
- package/dist/util/nativeEncoder.d.ts +0 -27
- package/dist/util/nativeEncoder.d.ts.map +0 -1
- package/dist/util/nativeEncoder.js +0 -106
- package/dist/util/nativeEncoder.js.map +0 -1
- package/dist/util/readInput.d.ts +0 -31
- package/dist/util/readInput.d.ts.map +0 -1
- package/dist/util/readInput.js +0 -142
- package/dist/util/readInput.js.map +0 -1
package/dist/api.js
CHANGED
|
@@ -1,324 +1,505 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
* });
|
|
20
|
-
*/
|
|
21
|
-
import { generateRenderHtml } from '@bendyline/squisq-video';
|
|
22
|
-
import { resolveDimensions } from '@bendyline/squisq-video';
|
|
23
|
-
import { detectFfmpeg } from './util/detectFfmpeg.js';
|
|
24
|
-
export { MemoryContentContainer } from '@bendyline/squisq/storage';
|
|
25
|
-
export { readInput } from './util/readInput.js';
|
|
26
|
-
/**
|
|
27
|
-
* Render a Doc + media container to an MP4 video file.
|
|
28
|
-
*
|
|
29
|
-
* The container should contain audio and image files referenced by the Doc's
|
|
30
|
-
* audio.segments[].src and block image paths. Files are embedded as base64
|
|
31
|
-
* data URIs in a self-contained render HTML page.
|
|
32
|
-
*
|
|
33
|
-
* Requires:
|
|
34
|
-
* - Playwright (chromium) — for headless frame capture
|
|
35
|
-
* - FFmpeg — for video encoding (must be on PATH)
|
|
36
|
-
*
|
|
37
|
-
* @param doc - The Doc structure to render
|
|
38
|
-
* @param container - MemoryContentContainer with audio/image files
|
|
39
|
-
* @param options - Rendering and encoding options
|
|
40
|
-
* @returns Result with duration and frame count
|
|
41
|
-
*/
|
|
42
|
-
export async function renderDocToMp4(doc, container, options) {
|
|
43
|
-
const { outputPath, fps = 30, quality = 'normal', orientation = 'landscape', captionStyle, coverPreRoll = 0, onProgress, } = options;
|
|
44
|
-
const dimensions = resolveDimensions({
|
|
45
|
-
orientation,
|
|
46
|
-
width: options.width,
|
|
47
|
-
height: options.height,
|
|
1
|
+
// src/api.ts
|
|
2
|
+
import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
|
|
3
|
+
import { flattenBlocks } from "@bendyline/squisq/doc";
|
|
4
|
+
import { generateRenderHtml } from "@bendyline/squisq-video";
|
|
5
|
+
import { resolveDimensions } from "@bendyline/squisq-video";
|
|
6
|
+
|
|
7
|
+
// src/util/detectFfmpeg.ts
|
|
8
|
+
import { execFile } from "child_process";
|
|
9
|
+
async function detectFfmpeg() {
|
|
10
|
+
const command = process.platform === "win32" ? "where" : "which";
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
execFile(command, ["ffmpeg"], { timeout: 5e3 }, (err, stdout) => {
|
|
13
|
+
if (err || !stdout.trim()) {
|
|
14
|
+
resolve(null);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const path = stdout.trim().split("\n")[0].trim();
|
|
18
|
+
resolve(path);
|
|
48
19
|
});
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/api.ts
|
|
24
|
+
import { MemoryContentContainer as MemoryContentContainer2 } from "@bendyline/squisq/storage";
|
|
25
|
+
|
|
26
|
+
// 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";
|
|
30
|
+
import { MemoryContentContainer } from "@bendyline/squisq/storage";
|
|
31
|
+
import { zipToContainer } from "@bendyline/squisq-formats/container";
|
|
32
|
+
var MIME_TYPES = {
|
|
33
|
+
".md": "text/markdown",
|
|
34
|
+
".txt": "text/plain",
|
|
35
|
+
".json": "application/json",
|
|
36
|
+
".jpg": "image/jpeg",
|
|
37
|
+
".jpeg": "image/jpeg",
|
|
38
|
+
".png": "image/png",
|
|
39
|
+
".gif": "image/gif",
|
|
40
|
+
".webp": "image/webp",
|
|
41
|
+
".svg": "image/svg+xml",
|
|
42
|
+
".mp3": "audio/mpeg",
|
|
43
|
+
".wav": "audio/wav",
|
|
44
|
+
".ogg": "audio/ogg",
|
|
45
|
+
".mp4": "video/mp4",
|
|
46
|
+
".webm": "video/webm"
|
|
47
|
+
};
|
|
48
|
+
function mimeFromExt(filePath) {
|
|
49
|
+
return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
50
|
+
}
|
|
51
|
+
async function walkDir(root, prefix = "") {
|
|
52
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
53
|
+
const paths = [];
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
56
|
+
if (entry.isDirectory()) {
|
|
57
|
+
paths.push(...await walkDir(join(root, entry.name), relPath));
|
|
58
|
+
} else if (entry.isFile()) {
|
|
59
|
+
paths.push(relPath);
|
|
57
60
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
}
|
|
62
|
+
return paths;
|
|
63
|
+
}
|
|
64
|
+
async function readInput(inputPath) {
|
|
65
|
+
const info = await stat(inputPath);
|
|
66
|
+
if (info.isDirectory()) {
|
|
67
|
+
return readFolder(inputPath);
|
|
68
|
+
}
|
|
69
|
+
const ext = extname(inputPath).toLowerCase();
|
|
70
|
+
if (ext === ".zip" || ext === ".dbk") {
|
|
71
|
+
return readContainer(inputPath);
|
|
72
|
+
}
|
|
73
|
+
if (ext === ".json") {
|
|
74
|
+
return readDocJsonFile(inputPath);
|
|
75
|
+
}
|
|
76
|
+
return readMarkdownFile(inputPath);
|
|
77
|
+
}
|
|
78
|
+
async function readMarkdownFile(filePath) {
|
|
79
|
+
const content = await readFile(filePath, "utf-8");
|
|
80
|
+
const container = new MemoryContentContainer();
|
|
81
|
+
await container.writeDocument(content);
|
|
82
|
+
const markdownDoc = parseMarkdown(content);
|
|
83
|
+
return { container, markdownDoc };
|
|
84
|
+
}
|
|
85
|
+
async function readDocJsonFile(filePath) {
|
|
86
|
+
const content = await readFile(filePath, "utf-8");
|
|
87
|
+
const doc = JSON.parse(content);
|
|
88
|
+
const container = new MemoryContentContainer();
|
|
89
|
+
return { container, markdownDoc: null, doc };
|
|
90
|
+
}
|
|
91
|
+
var DOC_JSON_NAMES = ["doc.json", "story.json"];
|
|
92
|
+
async function readContainer(filePath) {
|
|
93
|
+
const data = await readFile(filePath);
|
|
94
|
+
const container = await zipToContainer(
|
|
95
|
+
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
|
|
96
|
+
);
|
|
97
|
+
for (const name of DOC_JSON_NAMES) {
|
|
98
|
+
const jsonData = await container.readFile(name);
|
|
99
|
+
if (jsonData) {
|
|
100
|
+
const decoder = new TextDecoder();
|
|
101
|
+
const doc = JSON.parse(decoder.decode(jsonData));
|
|
102
|
+
return { container, markdownDoc: null, doc };
|
|
68
103
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
104
|
+
}
|
|
105
|
+
const markdown = await container.readDocument();
|
|
106
|
+
if (!markdown) {
|
|
107
|
+
throw new Error(`No markdown document or doc.json found in container: ${filePath}`);
|
|
108
|
+
}
|
|
109
|
+
const markdownDoc = parseMarkdown(markdown);
|
|
110
|
+
return { container, markdownDoc };
|
|
111
|
+
}
|
|
112
|
+
async function readFolder(dirPath) {
|
|
113
|
+
const container = new MemoryContentContainer();
|
|
114
|
+
const files = await walkDir(dirPath);
|
|
115
|
+
for (const relPath of files) {
|
|
116
|
+
const absPath = join(dirPath, relPath);
|
|
117
|
+
const data = await readFile(absPath);
|
|
118
|
+
await container.writeFile(
|
|
119
|
+
relPath,
|
|
120
|
+
new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
|
121
|
+
mimeFromExt(relPath)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
for (const name of DOC_JSON_NAMES) {
|
|
125
|
+
const jsonData = await container.readFile(name);
|
|
126
|
+
if (jsonData) {
|
|
127
|
+
const decoder = new TextDecoder();
|
|
128
|
+
const doc = JSON.parse(decoder.decode(jsonData));
|
|
129
|
+
return { container, markdownDoc: null, doc };
|
|
81
130
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/api.ts
|
|
141
|
+
async function renderDocToMp4(doc, container, options) {
|
|
142
|
+
const {
|
|
143
|
+
outputPath,
|
|
144
|
+
fps = 30,
|
|
145
|
+
quality = "normal",
|
|
146
|
+
orientation = "landscape",
|
|
147
|
+
captionStyle,
|
|
148
|
+
coverPreRoll = 0,
|
|
149
|
+
onProgress
|
|
150
|
+
} = options;
|
|
151
|
+
const dimensions = resolveDimensions({
|
|
152
|
+
orientation,
|
|
153
|
+
width: options.width,
|
|
154
|
+
height: options.height
|
|
155
|
+
});
|
|
156
|
+
const ffmpegPath = await detectFfmpeg();
|
|
157
|
+
if (!ffmpegPath) {
|
|
158
|
+
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"
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
onProgress?.("collecting media", 0);
|
|
163
|
+
const { collectImagePaths } = await import("@bendyline/squisq-formats/html");
|
|
164
|
+
const imagePaths = collectImagePaths(doc);
|
|
165
|
+
const images = /* @__PURE__ */ new Map();
|
|
166
|
+
for (const imgPath of imagePaths) {
|
|
167
|
+
const data = await container.readFile(imgPath);
|
|
168
|
+
if (data) {
|
|
169
|
+
images.set(imgPath, data);
|
|
86
170
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
onProgress?.('launching browser', 15);
|
|
99
|
-
// ── Playwright frame capture ────────────────────────────────────
|
|
100
|
-
const { chromium } = await import('playwright-core');
|
|
101
|
-
const browser = await chromium.launch({ headless: true });
|
|
102
|
-
const page = await browser.newPage({
|
|
103
|
-
viewport: { width: dimensions.width, height: dimensions.height },
|
|
104
|
-
});
|
|
105
|
-
const pageErrors = [];
|
|
106
|
-
page.on('pageerror', (err) => pageErrors.push(err.message));
|
|
107
|
-
await page.setContent(renderHtml, { waitUntil: 'load' });
|
|
108
|
-
await page.waitForTimeout(500);
|
|
109
|
-
try {
|
|
110
|
-
await page.waitForFunction(() => typeof window.getDuration === 'function', { timeout: 15000 });
|
|
171
|
+
}
|
|
172
|
+
const audio = /* @__PURE__ */ new Map();
|
|
173
|
+
const audioBuffers = [];
|
|
174
|
+
if (doc.audio?.segments?.length) {
|
|
175
|
+
for (const seg of doc.audio.segments) {
|
|
176
|
+
const data = await container.readFile(seg.src);
|
|
177
|
+
if (data) {
|
|
178
|
+
audio.set(seg.src, data);
|
|
179
|
+
audio.set(seg.name, data);
|
|
180
|
+
audioBuffers.push(data);
|
|
181
|
+
}
|
|
111
182
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
183
|
+
}
|
|
184
|
+
let concatenatedAudio = null;
|
|
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));
|
|
190
|
+
for (const block of flattenBlocks(doc.blocks)) {
|
|
191
|
+
for (const layer of block.layers ?? []) {
|
|
192
|
+
if (layer.type === "video") mediaSrcs.add(layer.content.src);
|
|
118
193
|
}
|
|
119
|
-
|
|
120
|
-
|
|
194
|
+
}
|
|
195
|
+
const clipBuffers = /* @__PURE__ */ new Map();
|
|
196
|
+
for (const src of mediaSrcs) {
|
|
197
|
+
if (images.has(src)) continue;
|
|
198
|
+
const data = await container.readFile(src);
|
|
199
|
+
if (data) images.set(src, data);
|
|
200
|
+
}
|
|
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
|
+
onProgress?.("generating render HTML", 10);
|
|
207
|
+
const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
|
|
208
|
+
const renderHtml = generateRenderHtml(doc, {
|
|
209
|
+
playerScript: PLAYER_BUNDLE,
|
|
210
|
+
images,
|
|
211
|
+
audio: audio.size > 0 ? audio : void 0,
|
|
212
|
+
width: dimensions.width,
|
|
213
|
+
height: dimensions.height,
|
|
214
|
+
captionStyle
|
|
215
|
+
});
|
|
216
|
+
onProgress?.("launching browser", 15);
|
|
217
|
+
const { chromium } = await import("playwright-core");
|
|
218
|
+
const browser = await chromium.launch({ headless: true });
|
|
219
|
+
const page = await browser.newPage({
|
|
220
|
+
viewport: { width: dimensions.width, height: dimensions.height }
|
|
221
|
+
});
|
|
222
|
+
const pageErrors = [];
|
|
223
|
+
page.on("pageerror", (err) => pageErrors.push(err.message));
|
|
224
|
+
await page.setContent(renderHtml, { waitUntil: "load" });
|
|
225
|
+
await page.waitForTimeout(500);
|
|
226
|
+
try {
|
|
227
|
+
await page.waitForFunction(
|
|
228
|
+
() => typeof window.getDuration === "function",
|
|
229
|
+
{ timeout: 15e3 }
|
|
230
|
+
);
|
|
231
|
+
} catch {
|
|
232
|
+
await browser.close();
|
|
233
|
+
const errorDetail = pageErrors.length ? `
|
|
234
|
+
Page errors:
|
|
235
|
+
${pageErrors.join("\n ")}` : "\nNo page errors captured \u2014 the player may have failed to mount.";
|
|
236
|
+
throw new Error(`Render API did not initialize within 15 seconds.${errorDetail}`);
|
|
237
|
+
}
|
|
238
|
+
const docDuration = await page.evaluate(() => {
|
|
239
|
+
return window.getDuration();
|
|
240
|
+
});
|
|
241
|
+
if (docDuration <= 0) {
|
|
242
|
+
await browser.close();
|
|
243
|
+
throw new Error("Document has zero duration \u2014 nothing to render");
|
|
244
|
+
}
|
|
245
|
+
const storyFrameCount = Math.ceil(docDuration * fps);
|
|
246
|
+
const preRollFrameCount = Math.ceil(coverPreRoll * fps);
|
|
247
|
+
const totalFrames = preRollFrameCount + storyFrameCount;
|
|
248
|
+
const frames = [];
|
|
249
|
+
onProgress?.("capturing frames", 20);
|
|
250
|
+
if (preRollFrameCount > 0) {
|
|
251
|
+
const hasCover = await page.evaluate(() => {
|
|
252
|
+
const w = window;
|
|
253
|
+
return typeof w.hasCoverBlock === "function" ? w.hasCoverBlock() : false;
|
|
121
254
|
});
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
255
|
+
if (hasCover) {
|
|
256
|
+
await page.evaluate(() => {
|
|
257
|
+
window.showCover();
|
|
258
|
+
});
|
|
259
|
+
await page.waitForTimeout(100);
|
|
260
|
+
const coverFrame = new Uint8Array(await page.screenshot({ type: "png" }));
|
|
261
|
+
for (let i = 0; i < preRollFrameCount; i++) {
|
|
262
|
+
frames.push(coverFrame);
|
|
263
|
+
}
|
|
264
|
+
await page.evaluate(() => {
|
|
265
|
+
window.hideCover();
|
|
266
|
+
});
|
|
125
267
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
await page.evaluate(() => {
|
|
139
|
-
window.showCover();
|
|
140
|
-
});
|
|
141
|
-
await page.waitForTimeout(100);
|
|
142
|
-
const coverFrame = new Uint8Array(await page.screenshot({ type: 'png' }));
|
|
143
|
-
for (let i = 0; i < preRollFrameCount; i++) {
|
|
144
|
-
frames.push(coverFrame);
|
|
145
|
-
}
|
|
146
|
-
await page.evaluate(() => {
|
|
147
|
-
window.hideCover();
|
|
148
|
-
});
|
|
149
|
-
}
|
|
268
|
+
}
|
|
269
|
+
const frameInterval = 1 / fps;
|
|
270
|
+
for (let i = 0; i < storyFrameCount; i++) {
|
|
271
|
+
const time = i * frameInterval;
|
|
272
|
+
await page.evaluate((t) => {
|
|
273
|
+
return window.seekTo(t);
|
|
274
|
+
}, time);
|
|
275
|
+
const screenshot = await page.screenshot({ type: "png" });
|
|
276
|
+
frames.push(new Uint8Array(screenshot));
|
|
277
|
+
if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {
|
|
278
|
+
const pct = 20 + Math.round(frames.length / totalFrames * 60);
|
|
279
|
+
onProgress?.("capturing frames", pct);
|
|
150
280
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
281
|
+
}
|
|
282
|
+
await browser.close();
|
|
283
|
+
onProgress?.("encoding video", 80);
|
|
284
|
+
const scheduledAudioClips = schedule.filter((c) => c.kind === "audio" && clipBuffers.has(c.id));
|
|
285
|
+
let encodingAudio = concatenatedAudio;
|
|
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");
|
|
302
|
+
await framesToMp4Native(ffmpegPath, frames, encodingAudio, outputPath, {
|
|
303
|
+
fps,
|
|
304
|
+
quality,
|
|
305
|
+
orientation,
|
|
306
|
+
width: dimensions.width,
|
|
307
|
+
height: dimensions.height,
|
|
308
|
+
onProgress: (percent, phase) => {
|
|
309
|
+
onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2));
|
|
165
310
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const { framesToMp4Native } = await import('./util/nativeEncoder.js');
|
|
175
|
-
await framesToMp4Native(ffmpegPath, frames, encodingAudio, outputPath, {
|
|
176
|
-
fps,
|
|
177
|
-
quality,
|
|
178
|
-
orientation,
|
|
179
|
-
width: dimensions.width,
|
|
180
|
-
height: dimensions.height,
|
|
181
|
-
onProgress: (percent, phase) => {
|
|
182
|
-
onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2));
|
|
183
|
-
},
|
|
184
|
-
});
|
|
185
|
-
onProgress?.('done', 100);
|
|
186
|
-
const totalDuration = docDuration + coverPreRoll;
|
|
187
|
-
return {
|
|
188
|
-
duration: totalDuration,
|
|
189
|
-
frameCount: frames.length,
|
|
190
|
-
outputPath,
|
|
191
|
-
};
|
|
311
|
+
});
|
|
312
|
+
onProgress?.("done", 100);
|
|
313
|
+
const totalDuration = docDuration + coverPreRoll;
|
|
314
|
+
return {
|
|
315
|
+
duration: totalDuration,
|
|
316
|
+
frameCount: frames.length,
|
|
317
|
+
outputPath
|
|
318
|
+
};
|
|
192
319
|
}
|
|
193
|
-
// ── Audio helpers ─────────────────────────────────────────────────
|
|
194
|
-
/**
|
|
195
|
-
* Concatenate multiple audio buffers into one.
|
|
196
|
-
* Uses native ffmpeg concat when available, falls back to byte concatenation.
|
|
197
|
-
*/
|
|
198
320
|
async function concatenateAudioBuffers(buffers, ffmpegPath) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
offset += buf.byteLength;
|
|
213
|
-
}
|
|
214
|
-
return result;
|
|
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;
|
|
215
334
|
}
|
|
216
335
|
async function concatenateAudioNative(ffmpegPath, buffers) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
const listPath = join(workDir, 'concat-list.txt');
|
|
233
|
-
const listContent = segmentPaths.map((p) => `file '${p.replace(/\\/g, '/')}'`).join('\n');
|
|
234
|
-
await writeFile(listPath, listContent);
|
|
235
|
-
const outputPath = join(workDir, 'combined.mp3');
|
|
236
|
-
await new Promise((resolve, reject) => {
|
|
237
|
-
execFile(ffmpegPath, ['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', outputPath], { timeout: 120000 }, (err) => {
|
|
238
|
-
if (err)
|
|
239
|
-
reject(new Error(`ffmpeg audio concat failed: ${err.message}`));
|
|
240
|
-
else
|
|
241
|
-
resolve();
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
const data = await readFile(outputPath);
|
|
245
|
-
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
246
|
-
}
|
|
247
|
-
finally {
|
|
248
|
-
await rm(workDir, { recursive: true, force: true });
|
|
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);
|
|
249
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
|
+
}
|
|
250
371
|
}
|
|
251
|
-
/**
|
|
252
|
-
* Add silence at the start of an audio track by re-encoding with adelay filter.
|
|
253
|
-
*/
|
|
254
372
|
async function addAudioDelay(ffmpegPath, audioData, delaySecs) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
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
|
+
}
|
|
292
412
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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}]`);
|
|
309
434
|
}
|
|
310
|
-
for (const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
});
|
|
321
|
-
});
|
|
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}]`);
|
|
322
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
|
+
async function extractThumbnails(options) {
|
|
473
|
+
const { videoPath, outputDir, slug, sizes, force } = options;
|
|
474
|
+
const { existsSync } = await import("fs");
|
|
475
|
+
const { join: join2 } = await import("path");
|
|
476
|
+
const { execFile: execFile2 } = await import("child_process");
|
|
477
|
+
const ffmpegPath = await detectFfmpeg();
|
|
478
|
+
if (!ffmpegPath) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
"ffmpeg is required for thumbnail extraction but not found in PATH.\nInstall it with:\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg\n Windows: winget install ffmpeg"
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
for (const thumb of sizes) {
|
|
484
|
+
const outputPath = join2(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
|
|
485
|
+
if (!force && existsSync(outputPath)) continue;
|
|
486
|
+
await new Promise((resolve, reject) => {
|
|
487
|
+
execFile2(
|
|
488
|
+
ffmpegPath,
|
|
489
|
+
["-y", "-i", videoPath, "-vf", thumb.filter, "-frames:v", "1", "-q:v", "2", outputPath],
|
|
490
|
+
{ timeout: 3e4 },
|
|
491
|
+
(err) => {
|
|
492
|
+
if (err) reject(new Error(`Thumbnail extraction failed (${thumb.name}): ${err.message}`));
|
|
493
|
+
else resolve();
|
|
494
|
+
}
|
|
495
|
+
);
|
|
496
|
+
});
|
|
497
|
+
}
|
|
323
498
|
}
|
|
499
|
+
export {
|
|
500
|
+
MemoryContentContainer2 as MemoryContentContainer,
|
|
501
|
+
extractThumbnails,
|
|
502
|
+
readInput,
|
|
503
|
+
renderDocToMp4
|
|
504
|
+
};
|
|
324
505
|
//# sourceMappingURL=api.js.map
|