@bendyline/squisq-cli 1.2.2 → 2.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 +81 -30
- package/dist/api-LE75OSRQ.js +40 -0
- package/dist/api-LE75OSRQ.js.map +1 -0
- package/dist/api.d.ts +211 -39
- package/dist/api.js +580 -183
- package/dist/api.js.map +1 -1
- package/dist/chunk-FVFK4ZJU.js +923 -0
- package/dist/chunk-FVFK4ZJU.js.map +1 -0
- package/dist/chunk-JDHONKFZ.js +210 -0
- package/dist/chunk-JDHONKFZ.js.map +1 -0
- package/dist/chunk-OEN2RADG.js +208 -0
- package/dist/chunk-OEN2RADG.js.map +1 -0
- package/dist/index.js +116 -539
- package/dist/index.js.map +1 -1
- package/dist/nativeEncoder-ET3HUSOE.js +15 -0
- package/dist/nativeEncoder-ET3HUSOE.js.map +1 -0
- package/dist/nativeEncoder-LRPK2YV5.js +16 -0
- package/dist/nativeEncoder-LRPK2YV5.js.map +1 -0
- package/package.json +7 -7
- package/dist/nativeEncoder-2HGWDEZZ.js +0 -86
- package/dist/nativeEncoder-2HGWDEZZ.js.map +0 -1
- package/dist/nativeEncoder-DLLAT2RT.js +0 -88
- package/dist/nativeEncoder-DLLAT2RT.js.map +0 -1
package/dist/api.js
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GIF_EXPORT_DEFAULTS,
|
|
3
|
+
framesToGifNative,
|
|
4
|
+
framesToGifNativeBytes,
|
|
5
|
+
framesToMp4Native,
|
|
6
|
+
framesToMp4NativeBytes,
|
|
7
|
+
runFfmpeg
|
|
8
|
+
} from "./chunk-OEN2RADG.js";
|
|
9
|
+
|
|
1
10
|
// src/api.ts
|
|
2
11
|
import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
|
|
3
12
|
import { flattenBlocks } from "@bendyline/squisq/doc";
|
|
4
|
-
import { generateRenderHtml } from "@bendyline/squisq-video";
|
|
13
|
+
import { ffmpegGifOutputArgs, generateRenderHtml } from "@bendyline/squisq-video";
|
|
5
14
|
import { resolveDimensions } from "@bendyline/squisq-video";
|
|
6
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
convert as formatsConvert,
|
|
17
|
+
prepareConversion as formatsPrepareConversion
|
|
18
|
+
} from "@bendyline/squisq-formats";
|
|
7
19
|
|
|
8
20
|
// src/util/detectFfmpeg.ts
|
|
9
21
|
import { execFile } from "child_process";
|
|
10
|
-
function run(command, args) {
|
|
11
|
-
|
|
12
|
-
|
|
22
|
+
function run(command, args, signal) {
|
|
23
|
+
signal?.throwIfAborted();
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
execFile(command, args, { timeout: 5e3, signal }, (err, stdout) => {
|
|
26
|
+
if (signal?.aborted) {
|
|
27
|
+
reject(signal.reason);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
13
30
|
if (err || !stdout.trim()) {
|
|
14
31
|
resolve(null);
|
|
15
32
|
return;
|
|
@@ -18,14 +35,15 @@ function run(command, args) {
|
|
|
18
35
|
});
|
|
19
36
|
});
|
|
20
37
|
}
|
|
21
|
-
async function getFfmpegVersion(path) {
|
|
22
|
-
const out = await run(path, ["-version"]);
|
|
38
|
+
async function getFfmpegVersion(path, signal) {
|
|
39
|
+
const out = await run(path, ["-version"], signal);
|
|
23
40
|
return out ? out.split("\n")[0].trim() : null;
|
|
24
41
|
}
|
|
25
|
-
async function detectFfmpegDetailed() {
|
|
42
|
+
async function detectFfmpegDetailed(signal) {
|
|
43
|
+
signal?.throwIfAborted();
|
|
26
44
|
const envPath = process.env.SQUISQ_FFMPEG;
|
|
27
45
|
if (envPath) {
|
|
28
|
-
const version = await getFfmpegVersion(envPath);
|
|
46
|
+
const version = await getFfmpegVersion(envPath, signal);
|
|
29
47
|
if (!version) {
|
|
30
48
|
throw new Error(
|
|
31
49
|
`SQUISQ_FFMPEG is set to "${envPath}" but running "${envPath} -version" failed. Fix the path or unset SQUISQ_FFMPEG.`
|
|
@@ -34,11 +52,12 @@ async function detectFfmpegDetailed() {
|
|
|
34
52
|
return { path: envPath, source: "env" };
|
|
35
53
|
}
|
|
36
54
|
const command = process.platform === "win32" ? "where" : "which";
|
|
37
|
-
const found = await run(command, ["ffmpeg"]);
|
|
55
|
+
const found = await run(command, ["ffmpeg"], signal);
|
|
38
56
|
if (found) {
|
|
39
57
|
return { path: found.split("\n")[0].trim(), source: "path" };
|
|
40
58
|
}
|
|
41
59
|
try {
|
|
60
|
+
signal?.throwIfAborted();
|
|
42
61
|
const specifier = "ffmpeg-static";
|
|
43
62
|
const mod = await import(specifier);
|
|
44
63
|
const candidate = typeof mod === "string" ? mod : typeof mod.default === "string" ? mod.default : null;
|
|
@@ -46,50 +65,55 @@ async function detectFfmpegDetailed() {
|
|
|
46
65
|
return { path: candidate, source: "ffmpeg-static" };
|
|
47
66
|
}
|
|
48
67
|
} catch {
|
|
68
|
+
signal?.throwIfAborted();
|
|
49
69
|
}
|
|
70
|
+
signal?.throwIfAborted();
|
|
50
71
|
return null;
|
|
51
72
|
}
|
|
52
|
-
async function detectFfmpeg() {
|
|
53
|
-
const detection = await detectFfmpegDetailed();
|
|
54
|
-
return detection?.path ?? null;
|
|
55
|
-
}
|
|
56
73
|
|
|
57
74
|
// src/util/audioMix.ts
|
|
58
75
|
import { computeAudioTimeline } from "@bendyline/squisq-video";
|
|
59
|
-
async function buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll) {
|
|
76
|
+
async function buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll, signal) {
|
|
77
|
+
signal?.throwIfAborted();
|
|
60
78
|
const timeline = computeAudioTimeline(doc, coverPreRoll);
|
|
61
79
|
if (timeline.length === 0) return null;
|
|
62
80
|
const bytesBySrc = /* @__PURE__ */ new Map();
|
|
63
81
|
const readSrc = async (src) => {
|
|
82
|
+
signal?.throwIfAborted();
|
|
64
83
|
if (bytesBySrc.has(src)) return bytesBySrc.get(src);
|
|
65
84
|
const data = await container.readFile(src);
|
|
85
|
+
signal?.throwIfAborted();
|
|
66
86
|
bytesBySrc.set(src, data ?? null);
|
|
67
87
|
return data ?? null;
|
|
68
88
|
};
|
|
69
89
|
const usable = [];
|
|
70
90
|
for (const clip of timeline) {
|
|
91
|
+
signal?.throwIfAborted();
|
|
71
92
|
const buffer = await readSrc(clip.src);
|
|
72
93
|
if (buffer) usable.push({ clip, buffer });
|
|
73
94
|
}
|
|
74
95
|
if (usable.length === 0) return null;
|
|
75
|
-
return mixTimelineClips(ffmpegPath, usable);
|
|
96
|
+
return mixTimelineClips(ffmpegPath, usable, signal);
|
|
76
97
|
}
|
|
77
|
-
async function mixTimelineClips(ffmpegPath, usable) {
|
|
98
|
+
async function mixTimelineClips(ffmpegPath, usable, signal) {
|
|
78
99
|
const { writeFile, readFile: readFile3, mkdir, rm: rm2 } = await import("fs/promises");
|
|
79
100
|
const { join: join3 } = await import("path");
|
|
80
101
|
const { tmpdir: tmpdir2 } = await import("os");
|
|
81
102
|
const { randomBytes: randomBytes2 } = await import("crypto");
|
|
82
|
-
|
|
103
|
+
signal?.throwIfAborted();
|
|
83
104
|
const workDir = join3(tmpdir2(), `squisq-audio-mix-${randomBytes2(8).toString("hex")}`);
|
|
84
105
|
await mkdir(workDir, { recursive: true });
|
|
85
106
|
const ms = (s) => Math.max(0, Math.round(s * 1e3));
|
|
86
107
|
try {
|
|
108
|
+
signal?.throwIfAborted();
|
|
87
109
|
const inputs = [];
|
|
88
110
|
const filters = [];
|
|
89
111
|
const labels = [];
|
|
90
112
|
for (const { clip, buffer } of usable) {
|
|
113
|
+
signal?.throwIfAborted();
|
|
91
114
|
const p = join3(workDir, `clip-${inputs.length}.mp3`);
|
|
92
115
|
await writeFile(p, new Uint8Array(buffer));
|
|
116
|
+
signal?.throwIfAborted();
|
|
93
117
|
const i = inputs.push(p) - 1;
|
|
94
118
|
const delayMs = ms(clip.startSec);
|
|
95
119
|
const start = Math.max(0, clip.sourceInSec);
|
|
@@ -114,19 +138,99 @@ async function mixTimelineClips(ffmpegPath, usable) {
|
|
|
114
138
|
"192k",
|
|
115
139
|
outputPath
|
|
116
140
|
);
|
|
117
|
-
await
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
});
|
|
141
|
+
await runFfmpeg(ffmpegPath, args, {
|
|
142
|
+
timeoutMs: 18e4,
|
|
143
|
+
failureMessage: "ffmpeg audio mix failed",
|
|
144
|
+
signal
|
|
122
145
|
});
|
|
146
|
+
signal?.throwIfAborted();
|
|
123
147
|
const data = await readFile3(outputPath);
|
|
148
|
+
signal?.throwIfAborted();
|
|
124
149
|
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
125
150
|
} finally {
|
|
126
151
|
await rm2(workDir, { recursive: true, force: true });
|
|
127
152
|
}
|
|
128
153
|
}
|
|
129
154
|
|
|
155
|
+
// src/util/capturedFrameBudget.ts
|
|
156
|
+
var MAX_CAPTURED_FRAME_BYTES = 256 * 1024 * 1024;
|
|
157
|
+
var CapturedFrameBudgetError = class extends Error {
|
|
158
|
+
constructor(capturedBytes, attemptedFrameBytes, maximumBytes) {
|
|
159
|
+
super(
|
|
160
|
+
`Captured PNG frames would retain ${capturedBytes + attemptedFrameBytes} bytes, exceeding the ${maximumBytes}-byte rendered-media memory limit.`
|
|
161
|
+
);
|
|
162
|
+
this.capturedBytes = capturedBytes;
|
|
163
|
+
this.attemptedFrameBytes = attemptedFrameBytes;
|
|
164
|
+
this.maximumBytes = maximumBytes;
|
|
165
|
+
this.code = "captured-frame-budget-exceeded";
|
|
166
|
+
this.name = "CapturedFrameBudgetError";
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
var CapturedFrameCollector = class {
|
|
170
|
+
constructor(maximumBytes = MAX_CAPTURED_FRAME_BYTES) {
|
|
171
|
+
this.maximumBytes = maximumBytes;
|
|
172
|
+
this.values = [];
|
|
173
|
+
this.seen = /* @__PURE__ */ new WeakSet();
|
|
174
|
+
this.bytes = 0;
|
|
175
|
+
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1 || maximumBytes > MAX_CAPTURED_FRAME_BYTES) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Captured-frame byte limit must be between 1 and ${MAX_CAPTURED_FRAME_BYTES}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
get frameCount() {
|
|
182
|
+
return this.values.length;
|
|
183
|
+
}
|
|
184
|
+
get retainedBytes() {
|
|
185
|
+
return this.bytes;
|
|
186
|
+
}
|
|
187
|
+
append(frame, repetitions = 1) {
|
|
188
|
+
if (!Number.isSafeInteger(repetitions) || repetitions < 1) {
|
|
189
|
+
throw new Error("Captured-frame repetition count must be a positive integer");
|
|
190
|
+
}
|
|
191
|
+
const additionalBytes = this.seen.has(frame) ? 0 : frame.byteLength;
|
|
192
|
+
if (this.bytes + additionalBytes > this.maximumBytes) {
|
|
193
|
+
const capturedBytes = this.bytes;
|
|
194
|
+
this.clear();
|
|
195
|
+
throw new CapturedFrameBudgetError(capturedBytes, additionalBytes, this.maximumBytes);
|
|
196
|
+
}
|
|
197
|
+
if (additionalBytes > 0) {
|
|
198
|
+
this.seen.add(frame);
|
|
199
|
+
this.bytes += additionalBytes;
|
|
200
|
+
}
|
|
201
|
+
for (let index = 0; index < repetitions; index += 1) this.values.push(frame);
|
|
202
|
+
}
|
|
203
|
+
/** Clear retained frame references before preserving the caller's abort reason. */
|
|
204
|
+
throwIfAborted(signal) {
|
|
205
|
+
if (!signal?.aborted) return;
|
|
206
|
+
this.clear();
|
|
207
|
+
signal.throwIfAborted();
|
|
208
|
+
}
|
|
209
|
+
/** Transfer ownership of the captured frames to the encoder. */
|
|
210
|
+
release() {
|
|
211
|
+
const released = this.values;
|
|
212
|
+
this.values = [];
|
|
213
|
+
this.seen = /* @__PURE__ */ new WeakSet();
|
|
214
|
+
this.bytes = 0;
|
|
215
|
+
return released;
|
|
216
|
+
}
|
|
217
|
+
/** Drop every retained reference so failed/cancelled captures can be collected promptly. */
|
|
218
|
+
clear() {
|
|
219
|
+
this.values.length = 0;
|
|
220
|
+
this.values = [];
|
|
221
|
+
this.seen = /* @__PURE__ */ new WeakSet();
|
|
222
|
+
this.bytes = 0;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// src/util/coverPreRoll.ts
|
|
227
|
+
function resolveAppliedCoverPreRoll(requestedSeconds, hasCover) {
|
|
228
|
+
if (!Number.isFinite(requestedSeconds) || requestedSeconds < 0) {
|
|
229
|
+
throw new Error("Cover pre-roll must be a finite number of seconds greater than or equal to 0");
|
|
230
|
+
}
|
|
231
|
+
return hasCover ? requestedSeconds : 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
130
234
|
// src/registry.ts
|
|
131
235
|
import { randomBytes } from "crypto";
|
|
132
236
|
import { readFile, rm } from "fs/promises";
|
|
@@ -137,7 +241,19 @@ var MP4_DEFAULTS = {
|
|
|
137
241
|
fps: 30,
|
|
138
242
|
quality: "normal",
|
|
139
243
|
orientation: "landscape",
|
|
140
|
-
coverPreRoll: 0
|
|
244
|
+
coverPreRoll: 0,
|
|
245
|
+
animationsEnabled: true
|
|
246
|
+
};
|
|
247
|
+
var GIF_DEFAULTS = {
|
|
248
|
+
fps: 10,
|
|
249
|
+
orientation: "landscape",
|
|
250
|
+
width: 960,
|
|
251
|
+
height: 540,
|
|
252
|
+
coverPreRoll: 0,
|
|
253
|
+
animationsEnabled: false,
|
|
254
|
+
loop: 0,
|
|
255
|
+
maxColors: 256,
|
|
256
|
+
dither: "sierra2_4a"
|
|
141
257
|
};
|
|
142
258
|
function mp4Format() {
|
|
143
259
|
return {
|
|
@@ -146,21 +262,32 @@ function mp4Format() {
|
|
|
146
262
|
mimeType: "video/mp4",
|
|
147
263
|
extensions: [".mp4"],
|
|
148
264
|
async exportDoc(input, options) {
|
|
265
|
+
options.signal?.throwIfAborted();
|
|
149
266
|
const mp4Opts = options.formatOptions?.mp4 ?? {};
|
|
150
267
|
const fps = typeof mp4Opts.fps === "number" ? mp4Opts.fps : MP4_DEFAULTS.fps;
|
|
151
268
|
const quality = typeof mp4Opts.quality === "string" ? mp4Opts.quality : MP4_DEFAULTS.quality;
|
|
152
269
|
const orientation = typeof mp4Opts.orientation === "string" ? mp4Opts.orientation : MP4_DEFAULTS.orientation;
|
|
153
270
|
const coverPreRoll = typeof mp4Opts.coverPreRoll === "number" ? mp4Opts.coverPreRoll : MP4_DEFAULTS.coverPreRoll;
|
|
271
|
+
const animationsEnabled = typeof mp4Opts.animationsEnabled === "boolean" ? mp4Opts.animationsEnabled : MP4_DEFAULTS.animationsEnabled;
|
|
154
272
|
const outputPath = join(tmpdir(), `squisq-mp4-${randomBytes(8).toString("hex")}.mp4`);
|
|
155
273
|
try {
|
|
156
|
-
await
|
|
274
|
+
const { renderDocToMp4: renderDocToMp42 } = await import("./api.js");
|
|
275
|
+
await renderDocToMp42(input.doc, input.container, {
|
|
157
276
|
outputPath,
|
|
158
277
|
fps,
|
|
159
278
|
quality,
|
|
160
279
|
orientation,
|
|
161
|
-
|
|
280
|
+
width: mp4Opts.width,
|
|
281
|
+
height: mp4Opts.height,
|
|
282
|
+
captionStyle: mp4Opts.captionStyle,
|
|
283
|
+
coverPreRoll,
|
|
284
|
+
animationsEnabled,
|
|
285
|
+
signal: options.signal,
|
|
286
|
+
onProgress: mp4Opts.onProgress
|
|
162
287
|
});
|
|
163
|
-
|
|
288
|
+
options.signal?.throwIfAborted();
|
|
289
|
+
const data = await readFile(outputPath, { signal: options.signal });
|
|
290
|
+
options.signal?.throwIfAborted();
|
|
164
291
|
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
165
292
|
return { bytes, mimeType: "video/mp4", suggestedFilename: "", warnings: [] };
|
|
166
293
|
} finally {
|
|
@@ -169,27 +296,69 @@ function mp4Format() {
|
|
|
169
296
|
}
|
|
170
297
|
};
|
|
171
298
|
}
|
|
299
|
+
function gifFormat() {
|
|
300
|
+
return {
|
|
301
|
+
id: "gif",
|
|
302
|
+
label: "Animated GIF",
|
|
303
|
+
mimeType: "image/gif",
|
|
304
|
+
extensions: [".gif"],
|
|
305
|
+
async exportDoc(input, options) {
|
|
306
|
+
options.signal?.throwIfAborted();
|
|
307
|
+
const gifOpts = options.formatOptions?.gif ?? {};
|
|
308
|
+
const orientation = typeof gifOpts.orientation === "string" ? gifOpts.orientation : GIF_DEFAULTS.orientation;
|
|
309
|
+
const portrait = orientation === "portrait";
|
|
310
|
+
const defaultWidth = portrait ? GIF_DEFAULTS.height : GIF_DEFAULTS.width;
|
|
311
|
+
const defaultHeight = portrait ? GIF_DEFAULTS.width : GIF_DEFAULTS.height;
|
|
312
|
+
const outputPath = join(tmpdir(), `squisq-gif-${randomBytes(8).toString("hex")}.gif`);
|
|
313
|
+
try {
|
|
314
|
+
const { renderDocToGif: renderDocToGif2 } = await import("./api.js");
|
|
315
|
+
const result = await renderDocToGif2(input.doc, input.container, {
|
|
316
|
+
outputPath,
|
|
317
|
+
fps: typeof gifOpts.fps === "number" ? gifOpts.fps : GIF_DEFAULTS.fps,
|
|
318
|
+
orientation,
|
|
319
|
+
width: typeof gifOpts.width === "number" ? gifOpts.width : defaultWidth,
|
|
320
|
+
height: typeof gifOpts.height === "number" ? gifOpts.height : defaultHeight,
|
|
321
|
+
captionStyle: gifOpts.captionStyle,
|
|
322
|
+
coverPreRoll: typeof gifOpts.coverPreRoll === "number" ? gifOpts.coverPreRoll : GIF_DEFAULTS.coverPreRoll,
|
|
323
|
+
animationsEnabled: typeof gifOpts.animationsEnabled === "boolean" ? gifOpts.animationsEnabled : GIF_DEFAULTS.animationsEnabled,
|
|
324
|
+
loop: typeof gifOpts.loop === "number" ? gifOpts.loop : GIF_DEFAULTS.loop,
|
|
325
|
+
maxColors: typeof gifOpts.maxColors === "number" ? gifOpts.maxColors : GIF_DEFAULTS.maxColors,
|
|
326
|
+
dither: gifOpts.dither ?? GIF_DEFAULTS.dither,
|
|
327
|
+
bayerScale: gifOpts.bayerScale,
|
|
328
|
+
signal: options.signal,
|
|
329
|
+
onProgress: gifOpts.onProgress
|
|
330
|
+
});
|
|
331
|
+
options.signal?.throwIfAborted();
|
|
332
|
+
const data = await readFile(outputPath, { signal: options.signal });
|
|
333
|
+
options.signal?.throwIfAborted();
|
|
334
|
+
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
335
|
+
return {
|
|
336
|
+
bytes,
|
|
337
|
+
mimeType: "image/gif",
|
|
338
|
+
suggestedFilename: "",
|
|
339
|
+
warnings: result.warnings
|
|
340
|
+
};
|
|
341
|
+
} finally {
|
|
342
|
+
await rm(outputPath, { force: true });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
172
347
|
function createCliRegistry() {
|
|
173
348
|
const registry = defaultRegistry();
|
|
174
349
|
registry.register(mp4Format());
|
|
350
|
+
registry.register(gifFormat());
|
|
175
351
|
return registry;
|
|
176
352
|
}
|
|
177
353
|
|
|
178
354
|
// src/api.ts
|
|
179
355
|
import { MemoryContentContainer as MemoryContentContainer2 } from "@bendyline/squisq/storage";
|
|
180
356
|
|
|
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
|
-
|
|
188
357
|
// src/util/readInput.ts
|
|
189
358
|
import { readFile as readFile2, readdir, stat } from "fs/promises";
|
|
190
359
|
import { join as join2, extname } from "path";
|
|
191
360
|
import { parseMarkdown, stringifyMarkdown } from "@bendyline/squisq/markdown";
|
|
192
|
-
import { markdownToDoc } from "@bendyline/squisq/doc";
|
|
361
|
+
import { markdownToDoc, resolveAudioMapping } from "@bendyline/squisq/doc";
|
|
193
362
|
import { MemoryContentContainer } from "@bendyline/squisq/storage";
|
|
194
363
|
import { zipToContainer } from "@bendyline/squisq-formats/container";
|
|
195
364
|
import { defaultRegistry as defaultRegistry2 } from "@bendyline/squisq-formats";
|
|
@@ -213,114 +382,187 @@ var IMPORTER_EXTS = [".docx", ".pptx", ".pdf", ".xlsx", ".csv", ".html", ".htm"]
|
|
|
213
382
|
function mimeFromExt(filePath) {
|
|
214
383
|
return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
215
384
|
}
|
|
216
|
-
async function walkDir(root, prefix = "") {
|
|
385
|
+
async function walkDir(root, prefix = "", signal) {
|
|
386
|
+
throwIfAborted(signal);
|
|
217
387
|
const entries = await readdir(root, { withFileTypes: true });
|
|
388
|
+
throwIfAborted(signal);
|
|
218
389
|
const paths = [];
|
|
219
390
|
for (const entry of entries) {
|
|
391
|
+
throwIfAborted(signal);
|
|
220
392
|
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
221
393
|
if (entry.isDirectory()) {
|
|
222
|
-
paths.push(...await walkDir(join2(root, entry.name), relPath));
|
|
394
|
+
paths.push(...await walkDir(join2(root, entry.name), relPath, signal));
|
|
223
395
|
} else if (entry.isFile()) {
|
|
224
396
|
paths.push(relPath);
|
|
225
397
|
}
|
|
226
398
|
}
|
|
227
399
|
return paths;
|
|
228
400
|
}
|
|
229
|
-
async function readInput(inputPath) {
|
|
401
|
+
async function readInput(inputPath, options) {
|
|
402
|
+
throwIfAborted(options?.signal);
|
|
403
|
+
const result = await readInputRaw(inputPath, options);
|
|
404
|
+
throwIfAborted(options?.signal);
|
|
405
|
+
const doc = await resolveAudioMapping(result.doc, result.container);
|
|
406
|
+
throwIfAborted(options?.signal);
|
|
407
|
+
return doc === result.doc ? result : { ...result, doc };
|
|
408
|
+
}
|
|
409
|
+
async function readInputRaw(inputPath, options) {
|
|
410
|
+
throwIfAborted(options?.signal);
|
|
230
411
|
const info = await stat(inputPath);
|
|
412
|
+
throwIfAborted(options?.signal);
|
|
231
413
|
if (info.isDirectory()) {
|
|
232
|
-
return readFolder(inputPath);
|
|
414
|
+
return readFolder(inputPath, options?.signal);
|
|
233
415
|
}
|
|
234
416
|
const ext = extname(inputPath).toLowerCase();
|
|
235
417
|
if (ext === ".zip" || ext === ".dbk") {
|
|
236
|
-
return readContainer(inputPath);
|
|
418
|
+
return readContainer(inputPath, options?.signal);
|
|
237
419
|
}
|
|
238
420
|
if (ext === ".json") {
|
|
239
|
-
return readDocJsonFile(inputPath);
|
|
421
|
+
return readDocJsonFile(inputPath, options?.signal);
|
|
240
422
|
}
|
|
241
423
|
if (IMPORTER_EXTS.includes(ext)) {
|
|
242
424
|
const def = defaultRegistry2().byExtension(ext);
|
|
243
425
|
if (def && (def.importContainer || def.importDoc)) {
|
|
244
|
-
return readViaImporter(inputPath, def);
|
|
426
|
+
return readViaImporter(inputPath, def, options);
|
|
245
427
|
}
|
|
246
428
|
}
|
|
247
|
-
return readMarkdownFile(inputPath);
|
|
429
|
+
return readMarkdownFile(inputPath, options?.signal);
|
|
248
430
|
}
|
|
249
|
-
async function readArrayBuffer(filePath) {
|
|
250
|
-
const data = await
|
|
431
|
+
async function readArrayBuffer(filePath, signal) {
|
|
432
|
+
const data = await readBinaryFile(filePath, signal);
|
|
251
433
|
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
252
434
|
}
|
|
253
|
-
async function
|
|
254
|
-
|
|
435
|
+
async function readBinaryFile(filePath, signal) {
|
|
436
|
+
throwIfAborted(signal);
|
|
437
|
+
try {
|
|
438
|
+
const data = await readFile2(filePath, { signal });
|
|
439
|
+
throwIfAborted(signal);
|
|
440
|
+
return data;
|
|
441
|
+
} catch (error) {
|
|
442
|
+
throwIfAborted(signal);
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
async function readUtf8File(filePath, signal) {
|
|
447
|
+
throwIfAborted(signal);
|
|
448
|
+
try {
|
|
449
|
+
const content = await readFile2(filePath, { encoding: "utf-8", signal });
|
|
450
|
+
throwIfAborted(signal);
|
|
451
|
+
return content;
|
|
452
|
+
} catch (error) {
|
|
453
|
+
throwIfAborted(signal);
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async function readMarkdownFile(filePath, signal) {
|
|
458
|
+
const content = await readUtf8File(filePath, signal);
|
|
255
459
|
const container = new MemoryContentContainer();
|
|
256
460
|
await container.writeDocument(content);
|
|
461
|
+
throwIfAborted(signal);
|
|
257
462
|
const markdownDoc = parseMarkdown(content);
|
|
258
463
|
return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: "md" };
|
|
259
464
|
}
|
|
260
|
-
async function readDocJsonFile(filePath) {
|
|
261
|
-
const content = await
|
|
465
|
+
async function readDocJsonFile(filePath, signal) {
|
|
466
|
+
const content = await readUtf8File(filePath, signal);
|
|
262
467
|
const doc = JSON.parse(content);
|
|
263
468
|
const container = new MemoryContentContainer();
|
|
264
469
|
return { doc, container, sourceFormat: "json" };
|
|
265
470
|
}
|
|
266
|
-
async function readViaImporter(filePath, def) {
|
|
267
|
-
const
|
|
471
|
+
async function readViaImporter(filePath, def, options) {
|
|
472
|
+
const signal = options?.signal;
|
|
473
|
+
throwIfAborted(signal);
|
|
474
|
+
const buffer = await readArrayBuffer(filePath, signal);
|
|
475
|
+
const convertOptions = {
|
|
476
|
+
signal,
|
|
477
|
+
formatOptions: {
|
|
478
|
+
[def.id]: {
|
|
479
|
+
inferTheme: options?.inferTheme !== false,
|
|
480
|
+
inferLayouts: options?.inferLayouts !== false
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
};
|
|
268
484
|
let container;
|
|
269
485
|
let markdownDoc;
|
|
270
486
|
if (def.importContainer) {
|
|
271
|
-
|
|
487
|
+
throwIfAborted(signal);
|
|
488
|
+
container = await def.importContainer(buffer, convertOptions);
|
|
489
|
+
throwIfAborted(signal);
|
|
272
490
|
const text = await container.readDocument();
|
|
491
|
+
throwIfAborted(signal);
|
|
273
492
|
markdownDoc = text ? parseMarkdown(text) : { type: "document", children: [] };
|
|
274
493
|
} else {
|
|
275
|
-
|
|
494
|
+
throwIfAborted(signal);
|
|
495
|
+
markdownDoc = await def.importDoc(buffer, convertOptions);
|
|
496
|
+
throwIfAborted(signal);
|
|
276
497
|
const mem = new MemoryContentContainer();
|
|
277
498
|
await mem.writeDocument(stringifyMarkdown(markdownDoc));
|
|
499
|
+
throwIfAborted(signal);
|
|
278
500
|
container = mem;
|
|
279
501
|
}
|
|
280
502
|
return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: def.id };
|
|
281
503
|
}
|
|
282
504
|
var DOC_JSON_NAMES = ["doc.json", "story.json"];
|
|
283
|
-
async function resolveContainer(container, sourceFormat, missingMessage) {
|
|
505
|
+
async function resolveContainer(container, sourceFormat, missingMessage, signal) {
|
|
284
506
|
for (const name of DOC_JSON_NAMES) {
|
|
507
|
+
throwIfAborted(signal);
|
|
285
508
|
const jsonData = await container.readFile(name);
|
|
509
|
+
throwIfAborted(signal);
|
|
286
510
|
if (jsonData) {
|
|
287
511
|
const doc = JSON.parse(new TextDecoder().decode(jsonData));
|
|
288
512
|
return { doc, container, sourceFormat };
|
|
289
513
|
}
|
|
290
514
|
}
|
|
515
|
+
throwIfAborted(signal);
|
|
291
516
|
const markdown = await container.readDocument();
|
|
517
|
+
throwIfAborted(signal);
|
|
292
518
|
if (!markdown) {
|
|
293
519
|
throw new Error(missingMessage);
|
|
294
520
|
}
|
|
295
521
|
const markdownDoc = parseMarkdown(markdown);
|
|
296
522
|
return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat };
|
|
297
523
|
}
|
|
298
|
-
async function readContainer(filePath) {
|
|
299
|
-
|
|
524
|
+
async function readContainer(filePath, signal) {
|
|
525
|
+
throwIfAborted(signal);
|
|
526
|
+
const container = await zipToContainer(await readArrayBuffer(filePath, signal));
|
|
527
|
+
throwIfAborted(signal);
|
|
300
528
|
return resolveContainer(
|
|
301
529
|
container,
|
|
302
530
|
"dbk",
|
|
303
|
-
`No markdown document or doc.json found in container: ${filePath}
|
|
531
|
+
`No markdown document or doc.json found in container: ${filePath}`,
|
|
532
|
+
signal
|
|
304
533
|
);
|
|
305
534
|
}
|
|
306
|
-
async function readFolder(dirPath) {
|
|
535
|
+
async function readFolder(dirPath, signal) {
|
|
536
|
+
throwIfAborted(signal);
|
|
307
537
|
const container = new MemoryContentContainer();
|
|
308
|
-
const files = await walkDir(dirPath);
|
|
538
|
+
const files = await walkDir(dirPath, "", signal);
|
|
539
|
+
throwIfAborted(signal);
|
|
309
540
|
for (const relPath of files) {
|
|
541
|
+
throwIfAborted(signal);
|
|
310
542
|
const absPath = join2(dirPath, relPath);
|
|
311
|
-
const data = await
|
|
543
|
+
const data = await readBinaryFile(absPath, signal);
|
|
544
|
+
throwIfAborted(signal);
|
|
312
545
|
await container.writeFile(
|
|
313
546
|
relPath,
|
|
314
547
|
new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
|
315
548
|
mimeFromExt(relPath)
|
|
316
549
|
);
|
|
550
|
+
throwIfAborted(signal);
|
|
317
551
|
}
|
|
318
552
|
return resolveContainer(
|
|
319
553
|
container,
|
|
320
554
|
"folder",
|
|
321
|
-
`No markdown document or doc.json found in folder: ${dirPath}
|
|
555
|
+
`No markdown document or doc.json found in folder: ${dirPath}`,
|
|
556
|
+
signal
|
|
322
557
|
);
|
|
323
558
|
}
|
|
559
|
+
function throwIfAborted(signal) {
|
|
560
|
+
if (!signal?.aborted) return;
|
|
561
|
+
if (signal.reason !== void 0) throw signal.reason;
|
|
562
|
+
const error = new Error("Input reading was cancelled");
|
|
563
|
+
error.name = "AbortError";
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
324
566
|
|
|
325
567
|
// src/api.ts
|
|
326
568
|
import { ConversionError } from "@bendyline/squisq-formats";
|
|
@@ -331,201 +573,356 @@ async function convert(source, to, options = {}) {
|
|
|
331
573
|
...options
|
|
332
574
|
});
|
|
333
575
|
}
|
|
334
|
-
async function
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
orientation = "landscape",
|
|
340
|
-
captionStyle,
|
|
341
|
-
coverPreRoll = 0,
|
|
342
|
-
onProgress
|
|
343
|
-
} = options;
|
|
344
|
-
const dimensions = resolveDimensions({
|
|
345
|
-
orientation,
|
|
346
|
-
width: options.width,
|
|
347
|
-
height: options.height
|
|
576
|
+
async function prepareConversion(source, options = {}) {
|
|
577
|
+
return formatsPrepareConversion(source, {
|
|
578
|
+
registry: createCliRegistry(),
|
|
579
|
+
resolvePlayerScript: () => import("@bendyline/squisq-react/standalone-source").then((m) => m.PLAYER_BUNDLE),
|
|
580
|
+
...options
|
|
348
581
|
});
|
|
349
|
-
|
|
582
|
+
}
|
|
583
|
+
async function captureDocFrames(doc, container, options) {
|
|
584
|
+
const { fps, width, height, captionStyle, coverPreRoll, animationsEnabled, onProgress, signal } = options;
|
|
585
|
+
signal?.throwIfAborted();
|
|
586
|
+
resolveAppliedCoverPreRoll(coverPreRoll, true);
|
|
587
|
+
const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
|
|
588
|
+
signal?.throwIfAborted();
|
|
350
589
|
if (!ffmpegPath) {
|
|
351
590
|
throw new Error(
|
|
352
591
|
"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."
|
|
353
592
|
);
|
|
354
593
|
}
|
|
355
594
|
onProgress?.("collecting media", 0);
|
|
595
|
+
signal?.throwIfAborted();
|
|
356
596
|
const { collectImagePaths } = await import("@bendyline/squisq-formats/html");
|
|
357
|
-
|
|
597
|
+
signal?.throwIfAborted();
|
|
358
598
|
const images = /* @__PURE__ */ new Map();
|
|
359
|
-
for (const imgPath of
|
|
599
|
+
for (const imgPath of collectImagePaths(doc)) {
|
|
600
|
+
signal?.throwIfAborted();
|
|
360
601
|
const data = await container.readFile(imgPath);
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
602
|
+
signal?.throwIfAborted();
|
|
603
|
+
if (data) images.set(imgPath, data);
|
|
364
604
|
}
|
|
365
605
|
const audio = /* @__PURE__ */ new Map();
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
606
|
+
for (const seg of doc.audio?.segments ?? []) {
|
|
607
|
+
signal?.throwIfAborted();
|
|
608
|
+
const data = await container.readFile(seg.src);
|
|
609
|
+
signal?.throwIfAborted();
|
|
610
|
+
if (data) {
|
|
611
|
+
audio.set(seg.src, data);
|
|
612
|
+
audio.set(seg.name, data);
|
|
373
613
|
}
|
|
374
614
|
}
|
|
375
|
-
const mediaSrcs = new Set(resolveMediaSchedule(doc).map((
|
|
615
|
+
const mediaSrcs = new Set(resolveMediaSchedule(doc).map((clip) => clip.src));
|
|
376
616
|
for (const block of flattenBlocks(doc.blocks)) {
|
|
377
617
|
for (const layer of block.layers ?? []) {
|
|
378
618
|
if (layer.type === "video") mediaSrcs.add(layer.content.src);
|
|
379
619
|
}
|
|
380
620
|
}
|
|
381
621
|
for (const src of mediaSrcs) {
|
|
622
|
+
signal?.throwIfAborted();
|
|
382
623
|
if (images.has(src)) continue;
|
|
383
624
|
const data = await container.readFile(src);
|
|
625
|
+
signal?.throwIfAborted();
|
|
384
626
|
if (data) images.set(src, data);
|
|
385
627
|
}
|
|
386
628
|
onProgress?.("generating render HTML", 10);
|
|
629
|
+
signal?.throwIfAborted();
|
|
387
630
|
const { PLAYER_BUNDLE } = await import("@bendyline/squisq-react/standalone-source");
|
|
631
|
+
signal?.throwIfAborted();
|
|
388
632
|
const renderHtml = generateRenderHtml(doc, {
|
|
389
633
|
playerScript: PLAYER_BUNDLE,
|
|
390
634
|
images,
|
|
391
635
|
audio: audio.size > 0 ? audio : void 0,
|
|
392
|
-
width
|
|
393
|
-
height
|
|
394
|
-
captionStyle
|
|
636
|
+
width,
|
|
637
|
+
height,
|
|
638
|
+
captionStyle,
|
|
639
|
+
animationsEnabled
|
|
395
640
|
});
|
|
396
641
|
onProgress?.("launching browser", 15);
|
|
642
|
+
signal?.throwIfAborted();
|
|
397
643
|
const { chromium } = await import("playwright-core");
|
|
644
|
+
signal?.throwIfAborted();
|
|
398
645
|
let browser;
|
|
399
646
|
try {
|
|
400
647
|
browser = await chromium.launch({ headless: true });
|
|
401
648
|
} catch (err) {
|
|
649
|
+
signal?.throwIfAborted();
|
|
402
650
|
const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
403
651
|
throw new Error(
|
|
404
652
|
`Playwright Chromium is not installed. Run: npx playwright install chromium
|
|
405
653
|
(launch failed: ${detail})`
|
|
406
654
|
);
|
|
407
655
|
}
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
656
|
+
const handleAbort = () => {
|
|
657
|
+
void browser.close().catch(() => void 0);
|
|
658
|
+
};
|
|
659
|
+
if (signal?.aborted) {
|
|
660
|
+
await browser.close().catch(() => void 0);
|
|
661
|
+
signal.throwIfAborted();
|
|
662
|
+
}
|
|
663
|
+
signal?.addEventListener("abort", handleAbort, { once: true });
|
|
664
|
+
let renderAPI = null;
|
|
665
|
+
const capturedFrames = new CapturedFrameCollector();
|
|
415
666
|
try {
|
|
416
|
-
await
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
);
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
667
|
+
const page = await browser.newPage({ viewport: { width, height } });
|
|
668
|
+
const pageErrors = [];
|
|
669
|
+
page.on("pageerror", (err) => pageErrors.push(err.message));
|
|
670
|
+
signal?.throwIfAborted();
|
|
671
|
+
await page.setContent(renderHtml, { waitUntil: "load" });
|
|
672
|
+
signal?.throwIfAborted();
|
|
673
|
+
await page.waitForTimeout(500);
|
|
674
|
+
signal?.throwIfAborted();
|
|
675
|
+
try {
|
|
676
|
+
await page.waitForFunction(
|
|
677
|
+
() => {
|
|
678
|
+
const root = document.getElementById("squisq-root");
|
|
679
|
+
const player = window.SquisqPlayer;
|
|
680
|
+
return root ? player?.getHandle(root)?.getRenderAPI() != null : false;
|
|
681
|
+
},
|
|
682
|
+
{ timeout: 15e3 }
|
|
683
|
+
);
|
|
684
|
+
} catch {
|
|
685
|
+
signal?.throwIfAborted();
|
|
686
|
+
const errorDetail = pageErrors.length ? `
|
|
423
687
|
Page errors:
|
|
424
688
|
${pageErrors.join("\n ")}` : "\nNo page errors captured \u2014 the player may have failed to mount.";
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
const storyFrameCount = Math.ceil(docDuration * fps);
|
|
437
|
-
const preRollFrameCount = Math.ceil(coverPreRoll * fps);
|
|
438
|
-
const totalFrames = preRollFrameCount + storyFrameCount;
|
|
439
|
-
const frames = [];
|
|
440
|
-
onProgress?.("capturing frames", 20);
|
|
441
|
-
if (preRollFrameCount > 0) {
|
|
442
|
-
const hasCover = await page.evaluate(() => {
|
|
443
|
-
const w = window;
|
|
444
|
-
return typeof w.hasCoverBlock === "function" ? w.hasCoverBlock() : false;
|
|
689
|
+
throw new Error(
|
|
690
|
+
`The standalone player failed to boot in headless Chromium. Render API did not initialize within 15 seconds.${errorDetail}`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
renderAPI = await page.evaluateHandle(() => {
|
|
694
|
+
const root = document.getElementById("squisq-root");
|
|
695
|
+
const player = window.SquisqPlayer;
|
|
696
|
+
const api = root ? player?.getHandle(root)?.getRenderAPI() : null;
|
|
697
|
+
if (!api) throw new Error("Squisq render API disappeared after initialization.");
|
|
698
|
+
return api;
|
|
445
699
|
});
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
700
|
+
signal?.throwIfAborted();
|
|
701
|
+
const docDuration = await renderAPI.evaluate((api) => api.getDuration());
|
|
702
|
+
signal?.throwIfAborted();
|
|
703
|
+
if (docDuration <= 0) throw new Error("Document has zero duration \u2014 nothing to render");
|
|
704
|
+
const hasCover = coverPreRoll > 0 ? await renderAPI.evaluate((api) => api.hasCoverBlock()) : false;
|
|
705
|
+
const appliedCoverPreRoll = resolveAppliedCoverPreRoll(coverPreRoll, hasCover);
|
|
706
|
+
const storyFrameCount = Math.ceil(docDuration * fps);
|
|
707
|
+
const preRollFrameCount = Math.ceil(appliedCoverPreRoll * fps);
|
|
708
|
+
const totalFrames = preRollFrameCount + storyFrameCount;
|
|
709
|
+
onProgress?.("capturing frames", 20);
|
|
710
|
+
capturedFrames.throwIfAborted(signal);
|
|
711
|
+
if (preRollFrameCount > 0) {
|
|
712
|
+
capturedFrames.throwIfAborted(signal);
|
|
713
|
+
await renderAPI.evaluate((api) => api.showCover());
|
|
450
714
|
await page.waitForTimeout(100);
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
await
|
|
456
|
-
window.hideCover();
|
|
457
|
-
});
|
|
715
|
+
capturedFrames.throwIfAborted(signal);
|
|
716
|
+
const coverFrame = await page.screenshot({ type: "png" });
|
|
717
|
+
capturedFrames.throwIfAborted(signal);
|
|
718
|
+
capturedFrames.append(coverFrame, preRollFrameCount);
|
|
719
|
+
await renderAPI.evaluate((api) => api.hideCover());
|
|
458
720
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
721
|
+
const frameInterval = 1 / fps;
|
|
722
|
+
for (let i = 0; i < storyFrameCount; i++) {
|
|
723
|
+
capturedFrames.throwIfAborted(signal);
|
|
724
|
+
const time = i * frameInterval;
|
|
725
|
+
await renderAPI.evaluate((api, t) => api.seekTo(t), time);
|
|
726
|
+
const frame = await page.screenshot({ type: "png" });
|
|
727
|
+
capturedFrames.throwIfAborted(signal);
|
|
728
|
+
capturedFrames.append(frame);
|
|
729
|
+
if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {
|
|
730
|
+
onProgress?.(
|
|
731
|
+
"capturing frames",
|
|
732
|
+
20 + Math.round(capturedFrames.frameCount / totalFrames * 60)
|
|
733
|
+
);
|
|
734
|
+
capturedFrames.throwIfAborted(signal);
|
|
735
|
+
}
|
|
471
736
|
}
|
|
737
|
+
return {
|
|
738
|
+
frames: capturedFrames.release(),
|
|
739
|
+
totalDuration: docDuration + appliedCoverPreRoll,
|
|
740
|
+
appliedCoverPreRoll,
|
|
741
|
+
ffmpegPath
|
|
742
|
+
};
|
|
743
|
+
} catch (err) {
|
|
744
|
+
capturedFrames.clear();
|
|
745
|
+
signal?.throwIfAborted();
|
|
746
|
+
throw err;
|
|
747
|
+
} finally {
|
|
748
|
+
signal?.removeEventListener("abort", handleAbort);
|
|
749
|
+
await renderAPI?.dispose().catch(() => void 0);
|
|
750
|
+
await browser.close().catch(() => void 0);
|
|
472
751
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
752
|
+
}
|
|
753
|
+
async function renderDocToMp4(doc, container, options) {
|
|
754
|
+
options.signal?.throwIfAborted();
|
|
755
|
+
const fps = options.fps ?? 30;
|
|
756
|
+
const quality = options.quality ?? "normal";
|
|
757
|
+
const orientation = options.orientation ?? "landscape";
|
|
758
|
+
const dimensions = resolveDimensions({
|
|
480
759
|
orientation,
|
|
760
|
+
width: options.width,
|
|
761
|
+
height: options.height,
|
|
762
|
+
fps,
|
|
763
|
+
quality
|
|
764
|
+
});
|
|
765
|
+
const capture = await captureDocFrames(doc, container, {
|
|
766
|
+
fps,
|
|
481
767
|
width: dimensions.width,
|
|
482
768
|
height: dimensions.height,
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
769
|
+
captionStyle: options.captionStyle,
|
|
770
|
+
coverPreRoll: options.coverPreRoll ?? 0,
|
|
771
|
+
animationsEnabled: options.animationsEnabled ?? true,
|
|
772
|
+
onProgress: options.onProgress,
|
|
773
|
+
signal: options.signal
|
|
486
774
|
});
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
775
|
+
const frameCount = capture.frames.length;
|
|
776
|
+
try {
|
|
777
|
+
options.onProgress?.("encoding video", 80);
|
|
778
|
+
options.signal?.throwIfAborted();
|
|
779
|
+
const encodingAudio = await buildMixedAudioTrack(
|
|
780
|
+
doc,
|
|
781
|
+
container,
|
|
782
|
+
capture.ffmpegPath,
|
|
783
|
+
capture.appliedCoverPreRoll,
|
|
784
|
+
options.signal
|
|
785
|
+
);
|
|
786
|
+
options.signal?.throwIfAborted();
|
|
787
|
+
const { framesToMp4Native: framesToMp4Native2 } = await import("./nativeEncoder-ET3HUSOE.js");
|
|
788
|
+
await framesToMp4Native2(capture.ffmpegPath, capture.frames, encodingAudio, options.outputPath, {
|
|
789
|
+
fps,
|
|
790
|
+
quality,
|
|
791
|
+
orientation,
|
|
792
|
+
width: dimensions.width,
|
|
793
|
+
height: dimensions.height,
|
|
794
|
+
signal: options.signal,
|
|
795
|
+
onProgress: (percent, phase) => options.onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2))
|
|
796
|
+
});
|
|
797
|
+
options.signal?.throwIfAborted();
|
|
798
|
+
options.onProgress?.("done", 100);
|
|
799
|
+
options.signal?.throwIfAborted();
|
|
800
|
+
return {
|
|
801
|
+
duration: capture.totalDuration,
|
|
802
|
+
frameCount,
|
|
803
|
+
outputPath: options.outputPath
|
|
804
|
+
};
|
|
805
|
+
} finally {
|
|
806
|
+
capture.frames.length = 0;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
async function renderDocToGif(doc, container, options) {
|
|
810
|
+
options.signal?.throwIfAborted();
|
|
811
|
+
const fps = options.fps ?? GIF_EXPORT_DEFAULTS.fps;
|
|
812
|
+
if (!Number.isFinite(fps) || fps <= 0 || fps > 100) {
|
|
813
|
+
throw new RangeError("GIF FPS must be a finite number between 1 and 100.");
|
|
814
|
+
}
|
|
815
|
+
const orientation = options.orientation ?? "landscape";
|
|
816
|
+
const portrait = orientation === "portrait";
|
|
817
|
+
const width = options.width ?? (portrait ? GIF_EXPORT_DEFAULTS.height : GIF_EXPORT_DEFAULTS.width);
|
|
818
|
+
const height = options.height ?? (portrait ? GIF_EXPORT_DEFAULTS.width : GIF_EXPORT_DEFAULTS.height);
|
|
819
|
+
resolveDimensions({ orientation, width, height, fps });
|
|
820
|
+
ffmpegGifOutputArgs({
|
|
821
|
+
width,
|
|
822
|
+
height,
|
|
823
|
+
loop: options.loop ?? GIF_EXPORT_DEFAULTS.loop,
|
|
824
|
+
maxColors: options.maxColors ?? GIF_EXPORT_DEFAULTS.maxColors,
|
|
825
|
+
dither: options.dither ?? GIF_EXPORT_DEFAULTS.dither,
|
|
826
|
+
bayerScale: options.bayerScale
|
|
827
|
+
});
|
|
828
|
+
const capture = await captureDocFrames(doc, container, {
|
|
829
|
+
fps,
|
|
830
|
+
width,
|
|
831
|
+
height,
|
|
832
|
+
captionStyle: options.captionStyle,
|
|
833
|
+
coverPreRoll: options.coverPreRoll ?? 0,
|
|
834
|
+
animationsEnabled: options.animationsEnabled ?? false,
|
|
835
|
+
onProgress: options.onProgress,
|
|
836
|
+
signal: options.signal
|
|
837
|
+
});
|
|
838
|
+
const frameCount = capture.frames.length;
|
|
839
|
+
try {
|
|
840
|
+
options.onProgress?.("encoding GIF", 80);
|
|
841
|
+
options.signal?.throwIfAborted();
|
|
842
|
+
const { framesToGifNative: framesToGifNative2 } = await import("./nativeEncoder-ET3HUSOE.js");
|
|
843
|
+
await framesToGifNative2(capture.ffmpegPath, capture.frames, options.outputPath, {
|
|
844
|
+
fps,
|
|
845
|
+
orientation,
|
|
846
|
+
width,
|
|
847
|
+
height,
|
|
848
|
+
loop: options.loop,
|
|
849
|
+
maxColors: options.maxColors,
|
|
850
|
+
dither: options.dither,
|
|
851
|
+
bayerScale: options.bayerScale,
|
|
852
|
+
signal: options.signal,
|
|
853
|
+
onProgress: (percent, phase) => options.onProgress?.(`encoding: ${phase}`, 80 + Math.round(percent * 0.2))
|
|
854
|
+
});
|
|
855
|
+
options.signal?.throwIfAborted();
|
|
856
|
+
options.onProgress?.("done", 100);
|
|
857
|
+
options.signal?.throwIfAborted();
|
|
858
|
+
const hasAudio = (doc.audio?.segments?.length ?? 0) > 0 || resolveMediaSchedule(doc).some((clip) => clip.kind === "audio");
|
|
859
|
+
return {
|
|
860
|
+
duration: capture.totalDuration,
|
|
861
|
+
frameCount,
|
|
862
|
+
outputPath: options.outputPath,
|
|
863
|
+
warnings: hasAudio ? ["Animated GIF does not support audio; audio tracks were omitted."] : []
|
|
864
|
+
};
|
|
865
|
+
} finally {
|
|
866
|
+
capture.frames.length = 0;
|
|
867
|
+
}
|
|
494
868
|
}
|
|
495
869
|
async function extractThumbnails(options) {
|
|
496
|
-
const { videoPath, outputDir, slug, sizes, force } = options;
|
|
870
|
+
const { videoPath, outputDir, slug, sizes, force, signal } = options;
|
|
497
871
|
const { existsSync } = await import("fs");
|
|
872
|
+
const { rm: rm2 } = await import("fs/promises");
|
|
498
873
|
const { join: join3 } = await import("path");
|
|
499
|
-
|
|
500
|
-
const ffmpegPath = await
|
|
874
|
+
signal?.throwIfAborted();
|
|
875
|
+
const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
|
|
501
876
|
if (!ffmpegPath) {
|
|
502
877
|
throw new Error(
|
|
503
878
|
"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"
|
|
504
879
|
);
|
|
505
880
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
881
|
+
const generatedPaths = [];
|
|
882
|
+
try {
|
|
883
|
+
for (const thumb of sizes) {
|
|
884
|
+
signal?.throwIfAborted();
|
|
885
|
+
const outputPath = join3(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
|
|
886
|
+
if (!force && existsSync(outputPath)) continue;
|
|
887
|
+
try {
|
|
888
|
+
await runFfmpeg(
|
|
889
|
+
ffmpegPath,
|
|
890
|
+
["-y", "-i", videoPath, "-vf", thumb.filter, "-frames:v", "1", "-q:v", "2", outputPath],
|
|
891
|
+
{
|
|
892
|
+
timeoutMs: 3e4,
|
|
893
|
+
failureMessage: `Thumbnail extraction failed (${thumb.name})`,
|
|
894
|
+
signal
|
|
895
|
+
}
|
|
896
|
+
);
|
|
897
|
+
signal?.throwIfAborted();
|
|
898
|
+
generatedPaths.push(outputPath);
|
|
899
|
+
} catch (error) {
|
|
900
|
+
await rm2(outputPath, { force: true });
|
|
901
|
+
throw error;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
signal?.throwIfAborted();
|
|
905
|
+
} catch (error) {
|
|
906
|
+
await Promise.allSettled(generatedPaths.map((path) => rm2(path, { force: true })));
|
|
907
|
+
throw error;
|
|
520
908
|
}
|
|
521
909
|
}
|
|
522
910
|
export {
|
|
911
|
+
CapturedFrameBudgetError,
|
|
523
912
|
ConversionError,
|
|
913
|
+
GIF_EXPORT_DEFAULTS,
|
|
914
|
+
MAX_CAPTURED_FRAME_BYTES,
|
|
524
915
|
MemoryContentContainer2 as MemoryContentContainer,
|
|
525
916
|
convert,
|
|
526
917
|
createCliRegistry,
|
|
527
918
|
extractThumbnails,
|
|
919
|
+
framesToGifNative,
|
|
920
|
+
framesToGifNativeBytes,
|
|
921
|
+
framesToMp4Native,
|
|
922
|
+
framesToMp4NativeBytes,
|
|
923
|
+
prepareConversion,
|
|
528
924
|
readInput,
|
|
925
|
+
renderDocToGif,
|
|
529
926
|
renderDocToMp4
|
|
530
927
|
};
|
|
531
928
|
//# sourceMappingURL=api.js.map
|