@shotstack/shotstack-canvas 1.9.5 → 2.0.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/chunk-5BH5YLM5.js +140 -0
- package/dist/chunk-FPPKRKBX.js +66 -0
- package/dist/chunk-KBAXJEJG.js +178 -0
- package/dist/entry.node.cjs +2659 -58
- package/dist/entry.node.d.cts +746 -39
- package/dist/entry.node.d.ts +746 -39
- package/dist/entry.node.js +2313 -57
- package/dist/entry.web.d.ts +718 -39
- package/dist/entry.web.js +37420 -3012
- package/dist/{hb-ODWKSLMB.js → hb-HSWG3Q47.js} +1 -1
- package/dist/{hbjs-HHU2TAW7.js → hbjs-VGYWXH44.js} +1 -1
- package/dist/mediarecorder-fallback-5JYZBGT3.js +133 -0
- package/dist/mediarecorder-fallback-TSLY4MAU.js +11 -0
- package/dist/web-encoder-7CLF7KX4.js +171 -0
- package/dist/web-encoder-MXBT3N36.js +9 -0
- package/package.json +65 -65
- package/dist/chunk-HYGMWVDX.js +0 -19
package/dist/entry.node.cjs
CHANGED
|
@@ -5,6 +5,9 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
5
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
6
|
var __getProtoOf = Object.getPrototypeOf;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __esm = (fn, res) => function __init() {
|
|
9
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
|
+
};
|
|
8
11
|
var __export = (target, all) => {
|
|
9
12
|
for (var name in all)
|
|
10
13
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
@@ -27,25 +30,370 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
27
30
|
));
|
|
28
31
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
32
|
|
|
33
|
+
// src/core/video/web-encoder.ts
|
|
34
|
+
var web_encoder_exports = {};
|
|
35
|
+
__export(web_encoder_exports, {
|
|
36
|
+
WebCodecsEncoder: () => WebCodecsEncoder,
|
|
37
|
+
createWebCodecsEncoder: () => createWebCodecsEncoder
|
|
38
|
+
});
|
|
39
|
+
function getH264CodecCandidates(profile) {
|
|
40
|
+
switch (profile) {
|
|
41
|
+
case "baseline":
|
|
42
|
+
return ["avc1.42E028", "avc1.42001F", "avc1.42001E"];
|
|
43
|
+
case "main":
|
|
44
|
+
return ["avc1.4D0028", "avc1.4D001F", "avc1.4D001E"];
|
|
45
|
+
case "high":
|
|
46
|
+
default:
|
|
47
|
+
return ["avc1.640028", "avc1.640029", "avc1.64001F", "avc1.64001E"];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function createWebCodecsEncoder(config) {
|
|
51
|
+
const encoder = new WebCodecsEncoder();
|
|
52
|
+
await encoder.configure(config);
|
|
53
|
+
return encoder;
|
|
54
|
+
}
|
|
55
|
+
var WebCodecsEncoder;
|
|
56
|
+
var init_web_encoder = __esm({
|
|
57
|
+
"src/core/video/web-encoder.ts"() {
|
|
58
|
+
"use strict";
|
|
59
|
+
WebCodecsEncoder = class {
|
|
60
|
+
encoder = null;
|
|
61
|
+
muxer = null;
|
|
62
|
+
config = null;
|
|
63
|
+
frameCount = 0;
|
|
64
|
+
totalFrames = 0;
|
|
65
|
+
startTime = 0;
|
|
66
|
+
fps = 30;
|
|
67
|
+
keyframeInterval = 150;
|
|
68
|
+
encoderError = null;
|
|
69
|
+
onProgress;
|
|
70
|
+
async configure(config) {
|
|
71
|
+
if (typeof VideoEncoder === "undefined") {
|
|
72
|
+
throw new Error("WebCodecs API not supported in this browser.");
|
|
73
|
+
}
|
|
74
|
+
this.config = config;
|
|
75
|
+
this.fps = config.fps;
|
|
76
|
+
this.totalFrames = Math.max(2, Math.round(config.duration * config.fps) + 1);
|
|
77
|
+
this.frameCount = 0;
|
|
78
|
+
this.startTime = Date.now();
|
|
79
|
+
this.keyframeInterval = Math.round(config.fps * 10);
|
|
80
|
+
this.encoderError = null;
|
|
81
|
+
const { Muxer, ArrayBufferTarget } = await import("mp4-muxer");
|
|
82
|
+
this.muxer = new Muxer({
|
|
83
|
+
target: new ArrayBufferTarget(),
|
|
84
|
+
video: {
|
|
85
|
+
codec: "avc",
|
|
86
|
+
width: config.width,
|
|
87
|
+
height: config.height
|
|
88
|
+
},
|
|
89
|
+
fastStart: "in-memory"
|
|
90
|
+
});
|
|
91
|
+
const candidates = getH264CodecCandidates(config.profile || "high");
|
|
92
|
+
let encoderConfig = null;
|
|
93
|
+
for (const codec of candidates) {
|
|
94
|
+
const candidate = {
|
|
95
|
+
codec,
|
|
96
|
+
width: config.width,
|
|
97
|
+
height: config.height,
|
|
98
|
+
bitrate: config.bitrate ?? 8e6,
|
|
99
|
+
framerate: config.fps,
|
|
100
|
+
hardwareAcceleration: config.hardwareAcceleration ?? "prefer-hardware",
|
|
101
|
+
latencyMode: "quality"
|
|
102
|
+
};
|
|
103
|
+
const support = await VideoEncoder.isConfigSupported(candidate);
|
|
104
|
+
if (support.supported) {
|
|
105
|
+
encoderConfig = candidate;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!encoderConfig) {
|
|
110
|
+
throw new Error("H.264 encoding not supported. Tried codecs: " + candidates.join(", "));
|
|
111
|
+
}
|
|
112
|
+
this.encoder = new VideoEncoder({
|
|
113
|
+
output: (chunk, metadata) => {
|
|
114
|
+
this.muxer.addVideoChunk(chunk, metadata);
|
|
115
|
+
},
|
|
116
|
+
error: (e) => {
|
|
117
|
+
this.encoderError = e;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
this.encoder.configure(encoderConfig);
|
|
121
|
+
}
|
|
122
|
+
async encodeFrame(frameData, frameIndex) {
|
|
123
|
+
if (this.encoderError) {
|
|
124
|
+
throw this.encoderError;
|
|
125
|
+
}
|
|
126
|
+
if (!this.encoder || !this.config) {
|
|
127
|
+
throw new Error("Encoder not configured. Call configure() first.");
|
|
128
|
+
}
|
|
129
|
+
const { width, height } = this.config;
|
|
130
|
+
const timestamp = Math.round(frameIndex * 1e6 / this.fps);
|
|
131
|
+
const pixelData = frameData instanceof ArrayBuffer ? new Uint8ClampedArray(frameData) : frameData;
|
|
132
|
+
const imageData = new ImageData(pixelData, width, height);
|
|
133
|
+
const videoFrame = new VideoFrame(imageData, { timestamp });
|
|
134
|
+
const isKeyFrame = frameIndex % this.keyframeInterval === 0;
|
|
135
|
+
this.encoder.encode(videoFrame, { keyFrame: isKeyFrame });
|
|
136
|
+
videoFrame.close();
|
|
137
|
+
this.frameCount++;
|
|
138
|
+
this.reportProgress();
|
|
139
|
+
}
|
|
140
|
+
async encodeCanvas(canvas, frameIndex) {
|
|
141
|
+
if (this.encoderError) {
|
|
142
|
+
throw this.encoderError;
|
|
143
|
+
}
|
|
144
|
+
if (!this.encoder || !this.config) {
|
|
145
|
+
throw new Error("Encoder not configured. Call configure() first.");
|
|
146
|
+
}
|
|
147
|
+
const timestamp = Math.round(frameIndex * 1e6 / this.fps);
|
|
148
|
+
const videoFrame = new VideoFrame(canvas, { timestamp });
|
|
149
|
+
const isKeyFrame = frameIndex % this.keyframeInterval === 0;
|
|
150
|
+
this.encoder.encode(videoFrame, { keyFrame: isKeyFrame });
|
|
151
|
+
videoFrame.close();
|
|
152
|
+
this.frameCount++;
|
|
153
|
+
this.reportProgress();
|
|
154
|
+
}
|
|
155
|
+
async encodeCanvasRepeat(canvas, startFrameIndex, repeatCount) {
|
|
156
|
+
if (this.encoderError) {
|
|
157
|
+
throw this.encoderError;
|
|
158
|
+
}
|
|
159
|
+
if (!this.encoder || !this.config) {
|
|
160
|
+
throw new Error("Encoder not configured. Call configure() first.");
|
|
161
|
+
}
|
|
162
|
+
for (let i = 0; i < repeatCount; i++) {
|
|
163
|
+
const actualFrameIndex = startFrameIndex + i;
|
|
164
|
+
const timestamp = Math.round(actualFrameIndex * 1e6 / this.fps);
|
|
165
|
+
const videoFrame = new VideoFrame(canvas, { timestamp });
|
|
166
|
+
const isKeyFrame = actualFrameIndex % this.keyframeInterval === 0;
|
|
167
|
+
this.encoder.encode(videoFrame, { keyFrame: isKeyFrame });
|
|
168
|
+
videoFrame.close();
|
|
169
|
+
this.frameCount++;
|
|
170
|
+
}
|
|
171
|
+
this.reportProgress();
|
|
172
|
+
}
|
|
173
|
+
async flush() {
|
|
174
|
+
if (this.encoderError) {
|
|
175
|
+
throw this.encoderError;
|
|
176
|
+
}
|
|
177
|
+
if (!this.encoder || !this.muxer) {
|
|
178
|
+
throw new Error("Encoder not configured.");
|
|
179
|
+
}
|
|
180
|
+
await this.encoder.flush();
|
|
181
|
+
this.muxer.finalize();
|
|
182
|
+
const buffer = this.muxer.target.buffer;
|
|
183
|
+
return new Blob([buffer], { type: "video/mp4" });
|
|
184
|
+
}
|
|
185
|
+
close() {
|
|
186
|
+
if (this.encoder && this.encoder.state !== "closed") {
|
|
187
|
+
this.encoder.close();
|
|
188
|
+
}
|
|
189
|
+
this.encoder = null;
|
|
190
|
+
this.muxer = null;
|
|
191
|
+
}
|
|
192
|
+
reportProgress() {
|
|
193
|
+
if (!this.onProgress) return;
|
|
194
|
+
const elapsedMs = Date.now() - this.startTime;
|
|
195
|
+
if (elapsedMs === 0) return;
|
|
196
|
+
const framesPerSecond = this.frameCount / (elapsedMs / 1e3);
|
|
197
|
+
const remainingFrames = this.totalFrames - this.frameCount;
|
|
198
|
+
const estimatedRemainingMs = remainingFrames / framesPerSecond * 1e3;
|
|
199
|
+
this.onProgress({
|
|
200
|
+
framesEncoded: this.frameCount,
|
|
201
|
+
totalFrames: this.totalFrames,
|
|
202
|
+
percentage: this.frameCount / this.totalFrames * 100,
|
|
203
|
+
elapsedMs,
|
|
204
|
+
estimatedRemainingMs: Math.round(estimatedRemainingMs),
|
|
205
|
+
currentFps: Math.round(framesPerSecond * 10) / 10
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// src/core/video/mediarecorder-fallback.ts
|
|
213
|
+
var mediarecorder_fallback_exports = {};
|
|
214
|
+
__export(mediarecorder_fallback_exports, {
|
|
215
|
+
MediaRecorderFallback: () => MediaRecorderFallback,
|
|
216
|
+
createMediaRecorderFallback: () => createMediaRecorderFallback,
|
|
217
|
+
isMediaRecorderSupported: () => isMediaRecorderSupported
|
|
218
|
+
});
|
|
219
|
+
function getPreferredMimeType() {
|
|
220
|
+
const mimeTypes = [
|
|
221
|
+
"video/webm;codecs=vp9",
|
|
222
|
+
"video/webm;codecs=vp8",
|
|
223
|
+
"video/webm",
|
|
224
|
+
"video/mp4"
|
|
225
|
+
];
|
|
226
|
+
for (const mimeType of mimeTypes) {
|
|
227
|
+
if (MediaRecorder.isTypeSupported(mimeType)) {
|
|
228
|
+
return mimeType;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return "video/webm";
|
|
232
|
+
}
|
|
233
|
+
function isMediaRecorderSupported() {
|
|
234
|
+
return typeof MediaRecorder !== "undefined" && typeof HTMLCanvasElement !== "undefined" && typeof HTMLCanvasElement.prototype.captureStream === "function";
|
|
235
|
+
}
|
|
236
|
+
async function createMediaRecorderFallback(config, canvas) {
|
|
237
|
+
const encoder = new MediaRecorderFallback();
|
|
238
|
+
await encoder.configure(config, canvas);
|
|
239
|
+
return encoder;
|
|
240
|
+
}
|
|
241
|
+
var MediaRecorderFallback;
|
|
242
|
+
var init_mediarecorder_fallback = __esm({
|
|
243
|
+
"src/core/video/mediarecorder-fallback.ts"() {
|
|
244
|
+
"use strict";
|
|
245
|
+
MediaRecorderFallback = class {
|
|
246
|
+
mediaRecorder = null;
|
|
247
|
+
canvas = null;
|
|
248
|
+
config = null;
|
|
249
|
+
chunks = [];
|
|
250
|
+
frameCount = 0;
|
|
251
|
+
totalFrames = 0;
|
|
252
|
+
startTime = 0;
|
|
253
|
+
ctx = null;
|
|
254
|
+
onProgress;
|
|
255
|
+
async configure(config, canvas) {
|
|
256
|
+
this.config = config;
|
|
257
|
+
this.totalFrames = Math.max(2, Math.round(config.duration * config.fps) + 1);
|
|
258
|
+
this.frameCount = 0;
|
|
259
|
+
this.startTime = Date.now();
|
|
260
|
+
this.chunks = [];
|
|
261
|
+
if (canvas) {
|
|
262
|
+
this.canvas = canvas;
|
|
263
|
+
} else {
|
|
264
|
+
if (typeof OffscreenCanvas !== "undefined") {
|
|
265
|
+
this.canvas = new OffscreenCanvas(config.width, config.height);
|
|
266
|
+
} else if (typeof document !== "undefined") {
|
|
267
|
+
this.canvas = document.createElement("canvas");
|
|
268
|
+
this.canvas.width = config.width;
|
|
269
|
+
this.canvas.height = config.height;
|
|
270
|
+
} else {
|
|
271
|
+
throw new Error("No canvas available for MediaRecorder fallback");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
this.ctx = this.canvas.getContext("2d");
|
|
275
|
+
if (!this.ctx) {
|
|
276
|
+
throw new Error("Failed to get 2D context");
|
|
277
|
+
}
|
|
278
|
+
const stream = this.canvas.captureStream?.(config.fps);
|
|
279
|
+
if (!stream) {
|
|
280
|
+
throw new Error("captureStream not supported on this canvas type");
|
|
281
|
+
}
|
|
282
|
+
const mimeType = getPreferredMimeType();
|
|
283
|
+
this.mediaRecorder = new MediaRecorder(stream, {
|
|
284
|
+
mimeType,
|
|
285
|
+
videoBitsPerSecond: config.bitrate ?? 8e6
|
|
286
|
+
});
|
|
287
|
+
this.mediaRecorder.ondataavailable = (event) => {
|
|
288
|
+
if (event.data.size > 0) {
|
|
289
|
+
this.chunks.push(event.data);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
this.mediaRecorder.start(100);
|
|
293
|
+
}
|
|
294
|
+
async encodeFrame(frameData, _frameIndex) {
|
|
295
|
+
if (!this.ctx || !this.config) {
|
|
296
|
+
throw new Error("Encoder not configured. Call configure() first.");
|
|
297
|
+
}
|
|
298
|
+
const { width, height } = this.config;
|
|
299
|
+
const pixelData = frameData instanceof ArrayBuffer ? new Uint8ClampedArray(frameData) : frameData;
|
|
300
|
+
const imageData = new ImageData(pixelData, width, height);
|
|
301
|
+
this.ctx.putImageData(imageData, 0, 0);
|
|
302
|
+
this.frameCount++;
|
|
303
|
+
if (this.onProgress) {
|
|
304
|
+
const elapsedMs = Date.now() - this.startTime;
|
|
305
|
+
if (elapsedMs > 0) {
|
|
306
|
+
const framesPerSecond = this.frameCount / (elapsedMs / 1e3);
|
|
307
|
+
const remainingFrames = this.totalFrames - this.frameCount;
|
|
308
|
+
const estimatedRemainingMs = remainingFrames / framesPerSecond * 1e3;
|
|
309
|
+
this.onProgress({
|
|
310
|
+
framesEncoded: this.frameCount,
|
|
311
|
+
totalFrames: this.totalFrames,
|
|
312
|
+
percentage: this.frameCount / this.totalFrames * 100,
|
|
313
|
+
elapsedMs,
|
|
314
|
+
estimatedRemainingMs: Math.round(estimatedRemainingMs),
|
|
315
|
+
currentFps: Math.round(framesPerSecond * 10) / 10
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const frameInterval = 1e3 / this.config.fps;
|
|
320
|
+
await new Promise((resolve) => setTimeout(resolve, frameInterval));
|
|
321
|
+
}
|
|
322
|
+
async flush() {
|
|
323
|
+
if (!this.mediaRecorder) {
|
|
324
|
+
throw new Error("MediaRecorder not configured.");
|
|
325
|
+
}
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
this.mediaRecorder.onstop = () => {
|
|
328
|
+
const blob = new Blob(this.chunks, { type: this.mediaRecorder.mimeType });
|
|
329
|
+
resolve(blob);
|
|
330
|
+
};
|
|
331
|
+
this.mediaRecorder.onerror = (event) => {
|
|
332
|
+
reject(new Error(`MediaRecorder error: ${event}`));
|
|
333
|
+
};
|
|
334
|
+
this.mediaRecorder.stop();
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
close() {
|
|
338
|
+
if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
|
|
339
|
+
this.mediaRecorder.stop();
|
|
340
|
+
}
|
|
341
|
+
this.mediaRecorder = null;
|
|
342
|
+
this.canvas = null;
|
|
343
|
+
this.ctx = null;
|
|
344
|
+
this.chunks = [];
|
|
345
|
+
}
|
|
346
|
+
getOutputMimeType() {
|
|
347
|
+
return this.mediaRecorder?.mimeType || "video/webm";
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
30
353
|
// src/env/entry.node.ts
|
|
31
354
|
var entry_node_exports = {};
|
|
32
355
|
__export(entry_node_exports, {
|
|
356
|
+
CanvasRichCaptionAssetSchema: () => CanvasRichCaptionAssetSchema,
|
|
33
357
|
CanvasRichTextAssetSchema: () => CanvasRichTextAssetSchema,
|
|
34
358
|
CanvasSvgAssetSchema: () => CanvasSvgAssetSchema,
|
|
359
|
+
CaptionLayoutEngine: () => CaptionLayoutEngine,
|
|
360
|
+
FontRegistry: () => FontRegistry,
|
|
361
|
+
NodeRawEncoder: () => NodeRawEncoder,
|
|
362
|
+
RichCaptionRenderer: () => RichCaptionRenderer,
|
|
363
|
+
WordTimingStore: () => WordTimingStore,
|
|
35
364
|
arcToCubicBeziers: () => arcToCubicBeziers,
|
|
365
|
+
calculateAnimationStatesForGroup: () => calculateAnimationStatesForGroup,
|
|
36
366
|
commandsToPathString: () => commandsToPathString,
|
|
37
367
|
computeSimplePathBounds: () => computeSimplePathBounds,
|
|
368
|
+
createDefaultGeneratorConfig: () => createDefaultGeneratorConfig,
|
|
369
|
+
createFrameSchedule: () => createFrameSchedule,
|
|
38
370
|
createNodePainter: () => createNodePainter,
|
|
371
|
+
createNodeRawEncoder: () => createNodeRawEncoder,
|
|
372
|
+
createRichCaptionRenderer: () => createRichCaptionRenderer,
|
|
39
373
|
createTextEngine: () => createTextEngine,
|
|
374
|
+
createVideoEncoder: () => createVideoEncoder,
|
|
375
|
+
detectPlatform: () => detectPlatform,
|
|
376
|
+
detectSubtitleFormat: () => detectSubtitleFormat,
|
|
377
|
+
findWordAtTime: () => findWordAtTime,
|
|
378
|
+
generateRichCaptionDrawOps: () => generateRichCaptionDrawOps,
|
|
379
|
+
generateRichCaptionFrame: () => generateRichCaptionFrame,
|
|
40
380
|
generateShapePathData: () => generateShapePathData,
|
|
41
|
-
|
|
42
|
-
|
|
381
|
+
getDefaultAnimationConfig: () => getDefaultAnimationConfig,
|
|
382
|
+
getDrawCaptionWordOps: () => getDrawCaptionWordOps,
|
|
383
|
+
getEncoderCapabilities: () => getEncoderCapabilities,
|
|
384
|
+
getEncoderWarning: () => getEncoderWarning,
|
|
385
|
+
groupWordsByPause: () => groupWordsByPause,
|
|
386
|
+
isDrawCaptionWordOp: () => isDrawCaptionWordOp,
|
|
387
|
+
isRTLText: () => isRTLText,
|
|
388
|
+
isWebCodecsH264Supported: () => isWebCodecsH264Supported,
|
|
43
389
|
normalizePath: () => normalizePath,
|
|
44
390
|
normalizePathString: () => normalizePathString,
|
|
391
|
+
parseSubtitleToWords: () => parseSubtitleToWords,
|
|
45
392
|
parseSvgPath: () => parseSvgPath,
|
|
46
393
|
quadraticToCubic: () => quadraticToCubic,
|
|
47
394
|
renderSvgAssetToPng: () => renderSvgAssetToPng,
|
|
48
395
|
renderSvgToPng: () => renderSvgToPng,
|
|
396
|
+
richCaptionAssetSchema: () => richCaptionAssetSchema,
|
|
49
397
|
shapeToSvgString: () => shapeToSvgString,
|
|
50
398
|
svgAssetSchema: () => import_zod2.svgAssetSchema,
|
|
51
399
|
svgGradientStopSchema: () => import_zod2.svgGradientStopSchema,
|
|
@@ -71,7 +419,7 @@ var CANVAS_CONFIG = {
|
|
|
71
419
|
pixelRatio: 2,
|
|
72
420
|
fontFamily: "Roboto",
|
|
73
421
|
fontSize: 48,
|
|
74
|
-
color: "#
|
|
422
|
+
color: "#ffffff",
|
|
75
423
|
textAlign: "center"
|
|
76
424
|
},
|
|
77
425
|
LIMITS: {
|
|
@@ -217,6 +565,71 @@ var CanvasRichTextAssetSchema = import_zod2.richTextAssetSchema.extend({
|
|
|
217
565
|
customFonts: import_zod.z.array(customFontSchema).optional()
|
|
218
566
|
}).strict();
|
|
219
567
|
var CanvasSvgAssetSchema = import_zod2.svgAssetSchema;
|
|
568
|
+
var wordTimingSchema = import_zod2.wordTimingSchema.extend({
|
|
569
|
+
text: import_zod.z.string().min(1),
|
|
570
|
+
start: import_zod.z.number().min(0),
|
|
571
|
+
end: import_zod.z.number().min(0),
|
|
572
|
+
confidence: import_zod.z.number().min(0).max(1).optional()
|
|
573
|
+
});
|
|
574
|
+
var richCaptionFontSchema = import_zod.z.object({
|
|
575
|
+
family: import_zod.z.string().default("Open Sans"),
|
|
576
|
+
size: import_zod.z.number().int().min(1).max(500).default(24),
|
|
577
|
+
weight: import_zod.z.union([import_zod.z.string(), import_zod.z.number()]).default("400"),
|
|
578
|
+
color: import_zod.z.string().regex(HEX6).default("#ffffff"),
|
|
579
|
+
opacity: import_zod.z.number().min(0).max(1).default(1),
|
|
580
|
+
background: import_zod.z.string().regex(HEX6).optional()
|
|
581
|
+
});
|
|
582
|
+
var richCaptionActiveSchema = import_zod2.richCaptionActiveSchema.extend({
|
|
583
|
+
font: import_zod.z.object({
|
|
584
|
+
color: import_zod.z.string().regex(HEX6).default("#ffff00"),
|
|
585
|
+
background: import_zod.z.string().regex(HEX6).optional(),
|
|
586
|
+
opacity: import_zod.z.number().min(0).max(1).default(1)
|
|
587
|
+
}).optional(),
|
|
588
|
+
stroke: import_zod.z.object({
|
|
589
|
+
width: import_zod.z.number().min(0).optional(),
|
|
590
|
+
color: import_zod.z.string().regex(HEX6).optional(),
|
|
591
|
+
opacity: import_zod.z.number().min(0).max(1).optional()
|
|
592
|
+
}).optional(),
|
|
593
|
+
scale: import_zod.z.number().min(0.5).max(2).default(1)
|
|
594
|
+
});
|
|
595
|
+
var richCaptionWordAnimationSchema = import_zod2.richCaptionWordAnimationSchema.extend({
|
|
596
|
+
style: import_zod.z.enum(["karaoke", "highlight", "pop", "fade", "slide", "bounce", "typewriter", "none"]).default("highlight"),
|
|
597
|
+
speed: import_zod.z.number().min(0.5).max(2).default(1),
|
|
598
|
+
direction: import_zod.z.enum(["left", "right", "up", "down"]).default("up")
|
|
599
|
+
});
|
|
600
|
+
var richCaptionAssetSchema = import_zod.z.object({
|
|
601
|
+
type: import_zod.z.literal("rich-caption"),
|
|
602
|
+
src: import_zod.z.string().min(1).optional(),
|
|
603
|
+
words: import_zod.z.array(wordTimingSchema).max(1e5).optional(),
|
|
604
|
+
font: richCaptionFontSchema.optional(),
|
|
605
|
+
style: canvasStyleSchema.optional(),
|
|
606
|
+
stroke: canvasStrokeSchema.optional(),
|
|
607
|
+
shadow: canvasShadowSchema.optional(),
|
|
608
|
+
background: canvasBackgroundSchema.optional(),
|
|
609
|
+
padding: paddingSchema.optional(),
|
|
610
|
+
align: canvasAlignmentSchema.optional(),
|
|
611
|
+
active: richCaptionActiveSchema.optional(),
|
|
612
|
+
wordAnimation: richCaptionWordAnimationSchema.optional(),
|
|
613
|
+
position: import_zod.z.enum(["top", "center", "bottom"]).default("bottom"),
|
|
614
|
+
maxWidth: import_zod.z.number().min(0.1).max(1).default(0.9),
|
|
615
|
+
maxLines: import_zod.z.number().int().min(1).max(10).default(2)
|
|
616
|
+
}).superRefine((data, ctx) => {
|
|
617
|
+
if (data.src && data.words) {
|
|
618
|
+
ctx.addIssue({
|
|
619
|
+
code: import_zod.z.ZodIssueCode.custom,
|
|
620
|
+
message: "src and words are mutually exclusive",
|
|
621
|
+
path: ["src"]
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
if (!data.src && !data.words) {
|
|
625
|
+
ctx.addIssue({
|
|
626
|
+
code: import_zod.z.ZodIssueCode.custom,
|
|
627
|
+
message: "Either src or words must be provided",
|
|
628
|
+
path: ["words"]
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
var CanvasRichCaptionAssetSchema = richCaptionAssetSchema;
|
|
220
633
|
|
|
221
634
|
// src/wasm/hb-loader.ts
|
|
222
635
|
var import_meta = {};
|
|
@@ -608,6 +1021,9 @@ var FontRegistry = class _FontRegistry {
|
|
|
608
1021
|
const normalizedWeight = normalizeWeight(desc.weight);
|
|
609
1022
|
return `${desc.family}__${normalizedWeight}`;
|
|
610
1023
|
}
|
|
1024
|
+
hasRegisteredFace(desc) {
|
|
1025
|
+
return this.faces.has(this.key(desc));
|
|
1026
|
+
}
|
|
611
1027
|
async registerFromBytes(bytes, desc) {
|
|
612
1028
|
try {
|
|
613
1029
|
if (!this.hb) await this.init();
|
|
@@ -1966,12 +2382,14 @@ async function createNodePainter(opts) {
|
|
|
1966
2382
|
if (!ctx) throw new Error("2D context unavailable in Node (canvas).");
|
|
1967
2383
|
const offscreenCanvas = createCanvas(canvas.width, canvas.height);
|
|
1968
2384
|
const offscreenCtx = offscreenCanvas.getContext("2d");
|
|
2385
|
+
const GRADIENT_CACHE_MAX = 500;
|
|
1969
2386
|
const gradientCache = /* @__PURE__ */ new Map();
|
|
1970
2387
|
const api = {
|
|
1971
2388
|
async render(ops) {
|
|
1972
2389
|
const globalBox = computeGlobalTextBounds(ops);
|
|
1973
2390
|
let needsAlphaExtraction = false;
|
|
1974
|
-
for (
|
|
2391
|
+
for (let i = 0; i < ops.length; i++) {
|
|
2392
|
+
const op = ops[i];
|
|
1975
2393
|
if (op.op === "BeginFrame") {
|
|
1976
2394
|
const dpr = op.pixelRatio ?? opts.pixelRatio;
|
|
1977
2395
|
const wantW = Math.floor(op.width);
|
|
@@ -1983,7 +2401,9 @@ async function createNodePainter(opts) {
|
|
|
1983
2401
|
offscreenCanvas.height = wantH;
|
|
1984
2402
|
}
|
|
1985
2403
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2404
|
+
ctx.globalAlpha = 1;
|
|
1986
2405
|
offscreenCtx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2406
|
+
offscreenCtx.globalAlpha = 1;
|
|
1987
2407
|
const hasBackground = !!(op.bg && op.bg.color);
|
|
1988
2408
|
const hasRoundedBackground = hasBackground && op.bg && op.bg.radius && op.bg.radius > 0;
|
|
1989
2409
|
needsAlphaExtraction = !!hasRoundedBackground;
|
|
@@ -2116,7 +2536,7 @@ async function createNodePainter(opts) {
|
|
|
2116
2536
|
context.save();
|
|
2117
2537
|
const c = parseHex6(op.stroke.color, op.stroke.opacity);
|
|
2118
2538
|
context.strokeStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
|
|
2119
|
-
context.lineWidth = op.stroke.width;
|
|
2539
|
+
context.lineWidth = op.stroke.width * 2;
|
|
2120
2540
|
if (op.borderRadius && op.borderRadius > 0) {
|
|
2121
2541
|
context.beginPath();
|
|
2122
2542
|
roundRectPath(context, op.x, op.y, op.width, op.height, op.borderRadius);
|
|
@@ -2219,6 +2639,140 @@ async function createNodePainter(opts) {
|
|
|
2219
2639
|
});
|
|
2220
2640
|
continue;
|
|
2221
2641
|
}
|
|
2642
|
+
if (op.op === "DrawCaptionBackground") {
|
|
2643
|
+
renderToBoth((context) => {
|
|
2644
|
+
context.save();
|
|
2645
|
+
const bgC = parseHex6(op.color, op.opacity);
|
|
2646
|
+
context.fillStyle = `rgba(${bgC.r},${bgC.g},${bgC.b},${bgC.a})`;
|
|
2647
|
+
context.beginPath();
|
|
2648
|
+
roundRectPath(context, op.x, op.y, op.width, op.height, op.borderRadius);
|
|
2649
|
+
context.fill();
|
|
2650
|
+
context.restore();
|
|
2651
|
+
});
|
|
2652
|
+
continue;
|
|
2653
|
+
}
|
|
2654
|
+
if (op.op === "DrawCaptionWord") {
|
|
2655
|
+
const captionWordOps = [op];
|
|
2656
|
+
while (i + 1 < ops.length) {
|
|
2657
|
+
const nextOp = ops[i + 1];
|
|
2658
|
+
if (nextOp.op !== "DrawCaptionWord") break;
|
|
2659
|
+
captionWordOps.push(nextOp);
|
|
2660
|
+
i++;
|
|
2661
|
+
}
|
|
2662
|
+
renderToBoth((context) => {
|
|
2663
|
+
for (const wordOp of captionWordOps) {
|
|
2664
|
+
if (!wordOp.background) continue;
|
|
2665
|
+
const wordDisplayText = wordOp.visibleCharacters >= 0 && wordOp.visibleCharacters < wordOp.text.length ? wordOp.text.slice(0, wordOp.visibleCharacters) : wordOp.text;
|
|
2666
|
+
if (wordDisplayText.length === 0) continue;
|
|
2667
|
+
context.save();
|
|
2668
|
+
const bgTx = Math.round(wordOp.x + wordOp.transform.translateX);
|
|
2669
|
+
const bgTy = Math.round(wordOp.y + wordOp.transform.translateY);
|
|
2670
|
+
context.translate(bgTx, bgTy);
|
|
2671
|
+
if (wordOp.transform.scale !== 1) {
|
|
2672
|
+
const halfWidth = wordOp.width / 2;
|
|
2673
|
+
context.translate(halfWidth, 0);
|
|
2674
|
+
context.scale(wordOp.transform.scale, wordOp.transform.scale);
|
|
2675
|
+
context.translate(-halfWidth, 0);
|
|
2676
|
+
}
|
|
2677
|
+
context.globalAlpha = wordOp.transform.opacity;
|
|
2678
|
+
context.font = `${wordOp.fontWeight} ${wordOp.fontSize}px "${wordOp.fontFamily}"`;
|
|
2679
|
+
context.textBaseline = "alphabetic";
|
|
2680
|
+
if (wordOp.letterSpacing) {
|
|
2681
|
+
context.letterSpacing = `${wordOp.letterSpacing}px`;
|
|
2682
|
+
}
|
|
2683
|
+
const bgMetrics = context.measureText(wordDisplayText);
|
|
2684
|
+
const bgTextWidth = bgMetrics.width;
|
|
2685
|
+
const bgAscent = wordOp.fontSize * 0.8;
|
|
2686
|
+
const bgDescent = wordOp.fontSize * 0.2;
|
|
2687
|
+
const bgTextHeight = bgAscent + bgDescent;
|
|
2688
|
+
const bgX = -wordOp.background.padding;
|
|
2689
|
+
const bgY = -bgAscent - wordOp.background.padding;
|
|
2690
|
+
const bgW = bgTextWidth + wordOp.background.padding * 2;
|
|
2691
|
+
const bgH = bgTextHeight + wordOp.background.padding * 2;
|
|
2692
|
+
const bgC = parseHex6(wordOp.background.color, wordOp.background.opacity);
|
|
2693
|
+
context.fillStyle = `rgba(${bgC.r},${bgC.g},${bgC.b},${bgC.a})`;
|
|
2694
|
+
context.beginPath();
|
|
2695
|
+
roundRectPath(context, bgX, bgY, bgW, bgH, wordOp.background.borderRadius);
|
|
2696
|
+
context.fill();
|
|
2697
|
+
context.restore();
|
|
2698
|
+
}
|
|
2699
|
+
for (const wordOp of captionWordOps) {
|
|
2700
|
+
const displayText = wordOp.visibleCharacters >= 0 && wordOp.visibleCharacters < wordOp.text.length ? wordOp.text.slice(0, wordOp.visibleCharacters) : wordOp.text;
|
|
2701
|
+
if (displayText.length === 0) continue;
|
|
2702
|
+
context.save();
|
|
2703
|
+
const tx = Math.round(wordOp.x + wordOp.transform.translateX);
|
|
2704
|
+
const ty = Math.round(wordOp.y + wordOp.transform.translateY);
|
|
2705
|
+
context.translate(tx, ty);
|
|
2706
|
+
if (wordOp.transform.scale !== 1) {
|
|
2707
|
+
const halfWidth = wordOp.width / 2;
|
|
2708
|
+
context.translate(halfWidth, 0);
|
|
2709
|
+
context.scale(wordOp.transform.scale, wordOp.transform.scale);
|
|
2710
|
+
context.translate(-halfWidth, 0);
|
|
2711
|
+
}
|
|
2712
|
+
context.globalAlpha = wordOp.transform.opacity;
|
|
2713
|
+
context.font = `${wordOp.fontWeight} ${wordOp.fontSize}px "${wordOp.fontFamily}"`;
|
|
2714
|
+
context.textBaseline = "alphabetic";
|
|
2715
|
+
if (wordOp.letterSpacing) {
|
|
2716
|
+
context.letterSpacing = `${wordOp.letterSpacing}px`;
|
|
2717
|
+
}
|
|
2718
|
+
const metrics = context.measureText(displayText);
|
|
2719
|
+
const textWidth = metrics.width;
|
|
2720
|
+
const ascent = metrics.actualBoundingBoxAscent ?? wordOp.fontSize * 0.8;
|
|
2721
|
+
const descent = metrics.actualBoundingBoxDescent ?? wordOp.fontSize * 0.2;
|
|
2722
|
+
const textHeight = ascent + descent;
|
|
2723
|
+
if (wordOp.shadow) {
|
|
2724
|
+
const shadowC = parseHex6(wordOp.shadow.color, wordOp.shadow.opacity);
|
|
2725
|
+
context.fillStyle = `rgba(${shadowC.r},${shadowC.g},${shadowC.b},${shadowC.a})`;
|
|
2726
|
+
context.shadowColor = `rgba(${shadowC.r},${shadowC.g},${shadowC.b},${shadowC.a})`;
|
|
2727
|
+
context.shadowOffsetX = wordOp.shadow.offsetX;
|
|
2728
|
+
context.shadowOffsetY = wordOp.shadow.offsetY;
|
|
2729
|
+
context.shadowBlur = wordOp.shadow.blur;
|
|
2730
|
+
context.fillText(displayText, 0, 0);
|
|
2731
|
+
context.shadowColor = "transparent";
|
|
2732
|
+
context.shadowOffsetX = 0;
|
|
2733
|
+
context.shadowOffsetY = 0;
|
|
2734
|
+
context.shadowBlur = 0;
|
|
2735
|
+
}
|
|
2736
|
+
if (wordOp.stroke && wordOp.stroke.width > 0) {
|
|
2737
|
+
const strokeC = parseHex6(wordOp.stroke.color, wordOp.stroke.opacity);
|
|
2738
|
+
context.strokeStyle = `rgba(${strokeC.r},${strokeC.g},${strokeC.b},${strokeC.a})`;
|
|
2739
|
+
context.lineWidth = wordOp.stroke.width * 2;
|
|
2740
|
+
context.lineJoin = "round";
|
|
2741
|
+
context.lineCap = "round";
|
|
2742
|
+
context.strokeText(displayText, 0, 0);
|
|
2743
|
+
}
|
|
2744
|
+
if (wordOp.fillProgress <= 0) {
|
|
2745
|
+
const baseC = parseHex6(wordOp.baseColor, wordOp.baseOpacity);
|
|
2746
|
+
context.fillStyle = `rgba(${baseC.r},${baseC.g},${baseC.b},${baseC.a})`;
|
|
2747
|
+
context.fillText(displayText, 0, 0);
|
|
2748
|
+
} else if (wordOp.fillProgress >= 1) {
|
|
2749
|
+
const activeC = parseHex6(wordOp.activeColor, wordOp.activeOpacity);
|
|
2750
|
+
context.fillStyle = `rgba(${activeC.r},${activeC.g},${activeC.b},${activeC.a})`;
|
|
2751
|
+
context.fillText(displayText, 0, 0);
|
|
2752
|
+
} else {
|
|
2753
|
+
const baseC = parseHex6(wordOp.baseColor, wordOp.baseOpacity);
|
|
2754
|
+
context.fillStyle = `rgba(${baseC.r},${baseC.g},${baseC.b},${baseC.a})`;
|
|
2755
|
+
context.fillText(displayText, 0, 0);
|
|
2756
|
+
context.save();
|
|
2757
|
+
context.beginPath();
|
|
2758
|
+
const clipWidth = textWidth * wordOp.fillProgress;
|
|
2759
|
+
if (wordOp.isRTL) {
|
|
2760
|
+
const clipX = textWidth - clipWidth;
|
|
2761
|
+
context.rect(clipX, -ascent - 5, clipWidth + 5, textHeight + 10);
|
|
2762
|
+
} else {
|
|
2763
|
+
context.rect(-5, -ascent - 5, clipWidth + 5, textHeight + 10);
|
|
2764
|
+
}
|
|
2765
|
+
context.clip();
|
|
2766
|
+
const activeC = parseHex6(wordOp.activeColor, wordOp.activeOpacity);
|
|
2767
|
+
context.fillStyle = `rgba(${activeC.r},${activeC.g},${activeC.b},${activeC.a})`;
|
|
2768
|
+
context.fillText(displayText, 0, 0);
|
|
2769
|
+
context.restore();
|
|
2770
|
+
}
|
|
2771
|
+
context.restore();
|
|
2772
|
+
}
|
|
2773
|
+
});
|
|
2774
|
+
continue;
|
|
2775
|
+
}
|
|
2222
2776
|
}
|
|
2223
2777
|
if (needsAlphaExtraction) {
|
|
2224
2778
|
const whiteData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
@@ -2256,6 +2810,17 @@ async function createNodePainter(opts) {
|
|
|
2256
2810
|
},
|
|
2257
2811
|
async toPNG() {
|
|
2258
2812
|
return canvas.toBuffer("image/png");
|
|
2813
|
+
},
|
|
2814
|
+
toRawRGBA() {
|
|
2815
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
2816
|
+
return {
|
|
2817
|
+
data: new Uint8ClampedArray(imageData.data),
|
|
2818
|
+
width: canvas.width,
|
|
2819
|
+
height: canvas.height
|
|
2820
|
+
};
|
|
2821
|
+
},
|
|
2822
|
+
getCanvasSize() {
|
|
2823
|
+
return { width: canvas.width, height: canvas.height };
|
|
2259
2824
|
}
|
|
2260
2825
|
};
|
|
2261
2826
|
return api;
|
|
@@ -2718,14 +3283,6 @@ var VideoGenerator = class {
|
|
|
2718
3283
|
}
|
|
2719
3284
|
};
|
|
2720
3285
|
|
|
2721
|
-
// src/types.ts
|
|
2722
|
-
var isShadowFill2 = (op) => {
|
|
2723
|
-
return op.op === "FillPath" && op.isShadow === true;
|
|
2724
|
-
};
|
|
2725
|
-
var isGlyphFill2 = (op) => {
|
|
2726
|
-
return op.op === "FillPath" && op.isShadow !== true;
|
|
2727
|
-
};
|
|
2728
|
-
|
|
2729
3286
|
// src/core/svg-path-utils.ts
|
|
2730
3287
|
var TAU = Math.PI * 2;
|
|
2731
3288
|
var PATH_COMMAND_REGEX = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g;
|
|
@@ -3670,51 +4227,2072 @@ function extractSvgDimensions(svgString) {
|
|
|
3670
4227
|
return { width, height };
|
|
3671
4228
|
}
|
|
3672
4229
|
|
|
3673
|
-
// src/
|
|
3674
|
-
var
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
4230
|
+
// src/core/rich-caption-layout.ts
|
|
4231
|
+
var import_lru_cache = require("lru-cache");
|
|
4232
|
+
var RTL_RANGES = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/;
|
|
4233
|
+
function isRTLText(text) {
|
|
4234
|
+
return RTL_RANGES.test(text);
|
|
4235
|
+
}
|
|
4236
|
+
var WordTimingStore = class {
|
|
4237
|
+
startTimes;
|
|
4238
|
+
endTimes;
|
|
4239
|
+
xPositions;
|
|
4240
|
+
yPositions;
|
|
4241
|
+
widths;
|
|
4242
|
+
words;
|
|
4243
|
+
length;
|
|
4244
|
+
constructor(words) {
|
|
4245
|
+
this.length = words.length;
|
|
4246
|
+
this.startTimes = new Uint32Array(this.length);
|
|
4247
|
+
this.endTimes = new Uint32Array(this.length);
|
|
4248
|
+
this.xPositions = new Float32Array(this.length);
|
|
4249
|
+
this.yPositions = new Float32Array(this.length);
|
|
4250
|
+
this.widths = new Float32Array(this.length);
|
|
4251
|
+
this.words = new Array(this.length);
|
|
4252
|
+
for (let i = 0; i < this.length; i++) {
|
|
4253
|
+
this.startTimes[i] = Math.floor(words[i].start);
|
|
4254
|
+
this.endTimes[i] = Math.floor(words[i].end);
|
|
4255
|
+
this.words[i] = words[i].text;
|
|
4256
|
+
}
|
|
3678
4257
|
}
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
4258
|
+
};
|
|
4259
|
+
function findWordAtTime(store, timeMs) {
|
|
4260
|
+
let left = 0;
|
|
4261
|
+
let right = store.length - 1;
|
|
4262
|
+
while (left <= right) {
|
|
4263
|
+
const mid = left + right >>> 1;
|
|
4264
|
+
const start = store.startTimes[mid];
|
|
4265
|
+
const end = store.endTimes[mid];
|
|
4266
|
+
if (timeMs >= start && timeMs < end) {
|
|
4267
|
+
return mid;
|
|
4268
|
+
}
|
|
4269
|
+
if (timeMs < start) {
|
|
4270
|
+
right = mid - 1;
|
|
4271
|
+
} else {
|
|
4272
|
+
left = mid + 1;
|
|
3687
4273
|
}
|
|
3688
|
-
} catch (err) {
|
|
3689
|
-
console.warn(`\u26A0\uFE0F Could not register color emoji font with canvas GlobalFonts: ${err}`);
|
|
3690
4274
|
}
|
|
4275
|
+
return -1;
|
|
3691
4276
|
}
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
const
|
|
3697
|
-
|
|
3698
|
-
let
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
4277
|
+
function groupWordsByPause(store, pauseThreshold = 500) {
|
|
4278
|
+
if (store.length === 0) {
|
|
4279
|
+
return [];
|
|
4280
|
+
}
|
|
4281
|
+
const groups = [];
|
|
4282
|
+
let currentGroup = [];
|
|
4283
|
+
for (let i = 0; i < store.length; i++) {
|
|
4284
|
+
if (currentGroup.length === 0) {
|
|
4285
|
+
currentGroup.push(i);
|
|
4286
|
+
continue;
|
|
4287
|
+
}
|
|
4288
|
+
const prevEnd = store.endTimes[currentGroup[currentGroup.length - 1]];
|
|
4289
|
+
const currStart = store.startTimes[i];
|
|
4290
|
+
const gap = currStart - prevEnd;
|
|
4291
|
+
const prevText = store.words[currentGroup[currentGroup.length - 1]];
|
|
4292
|
+
const endsWithPunctuation = /[.!?]$/.test(prevText);
|
|
4293
|
+
if (gap >= pauseThreshold || endsWithPunctuation) {
|
|
4294
|
+
groups.push(currentGroup);
|
|
4295
|
+
currentGroup = [i];
|
|
4296
|
+
} else {
|
|
4297
|
+
currentGroup.push(i);
|
|
4298
|
+
}
|
|
4299
|
+
}
|
|
4300
|
+
if (currentGroup.length > 0) {
|
|
4301
|
+
groups.push(currentGroup);
|
|
4302
|
+
}
|
|
4303
|
+
return groups;
|
|
4304
|
+
}
|
|
4305
|
+
function breakIntoLines(wordWidths, maxWidth, maxLines, spaceWidth) {
|
|
4306
|
+
const lines = [];
|
|
4307
|
+
let currentLine = [];
|
|
4308
|
+
let currentWidth = 0;
|
|
4309
|
+
for (let i = 0; i < wordWidths.length; i++) {
|
|
4310
|
+
const wordWidth = wordWidths[i];
|
|
4311
|
+
const spaceNeeded = currentLine.length > 0 ? spaceWidth : 0;
|
|
4312
|
+
if (currentWidth + spaceNeeded + wordWidth <= maxWidth) {
|
|
4313
|
+
currentLine.push(i);
|
|
4314
|
+
currentWidth += spaceNeeded + wordWidth;
|
|
4315
|
+
} else {
|
|
4316
|
+
if (currentLine.length > 0) {
|
|
4317
|
+
lines.push(currentLine);
|
|
4318
|
+
if (lines.length >= maxLines) {
|
|
4319
|
+
return lines;
|
|
4320
|
+
}
|
|
4321
|
+
}
|
|
4322
|
+
currentLine = [i];
|
|
4323
|
+
currentWidth = wordWidth;
|
|
4324
|
+
}
|
|
4325
|
+
}
|
|
4326
|
+
if (currentLine.length > 0 && lines.length < maxLines) {
|
|
4327
|
+
lines.push(currentLine);
|
|
4328
|
+
}
|
|
4329
|
+
return lines;
|
|
4330
|
+
}
|
|
4331
|
+
var GLYPH_SIZE_ESTIMATE = 64;
|
|
4332
|
+
function createShapedWordCache() {
|
|
4333
|
+
return new import_lru_cache.LRUCache({
|
|
4334
|
+
max: 5e4,
|
|
4335
|
+
maxSize: 50 * 1024 * 1024,
|
|
4336
|
+
maxEntrySize: 100 * 1024,
|
|
4337
|
+
sizeCalculation: (value, key) => {
|
|
4338
|
+
const keySize = key.length * 2;
|
|
4339
|
+
const glyphsSize = value.glyphs.length * GLYPH_SIZE_ESTIMATE;
|
|
4340
|
+
return keySize + glyphsSize + 100;
|
|
4341
|
+
}
|
|
4342
|
+
});
|
|
4343
|
+
}
|
|
4344
|
+
function makeShapingKey(text, fontFamily, fontSize, fontWeight, letterSpacing = 0) {
|
|
4345
|
+
return `${text}\0${fontFamily}\0${fontSize}\0${fontWeight}\0${letterSpacing}`;
|
|
4346
|
+
}
|
|
4347
|
+
function transformText(text, transform) {
|
|
4348
|
+
switch (transform) {
|
|
4349
|
+
case "uppercase":
|
|
4350
|
+
return text.toUpperCase();
|
|
4351
|
+
case "lowercase":
|
|
4352
|
+
return text.toLowerCase();
|
|
4353
|
+
case "capitalize":
|
|
4354
|
+
return text.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
4355
|
+
default:
|
|
4356
|
+
return text;
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
4359
|
+
var CaptionLayoutEngine = class {
|
|
4360
|
+
fontRegistry;
|
|
4361
|
+
cache;
|
|
4362
|
+
layoutEngine;
|
|
4363
|
+
constructor(fontRegistry) {
|
|
4364
|
+
this.fontRegistry = fontRegistry;
|
|
4365
|
+
this.cache = createShapedWordCache();
|
|
4366
|
+
this.layoutEngine = new LayoutEngine(fontRegistry);
|
|
4367
|
+
}
|
|
4368
|
+
async measureWord(text, config) {
|
|
4369
|
+
const transformedText = transformText(text, config.textTransform);
|
|
4370
|
+
const cacheKey = makeShapingKey(
|
|
4371
|
+
transformedText,
|
|
4372
|
+
config.fontFamily,
|
|
4373
|
+
config.fontSize,
|
|
4374
|
+
config.fontWeight,
|
|
4375
|
+
config.letterSpacing
|
|
3704
4376
|
);
|
|
4377
|
+
const cached = this.cache.get(cacheKey);
|
|
4378
|
+
if (cached) {
|
|
4379
|
+
return cached;
|
|
4380
|
+
}
|
|
4381
|
+
const lines = await this.layoutEngine.layout({
|
|
4382
|
+
text: transformedText,
|
|
4383
|
+
width: 1e5,
|
|
4384
|
+
letterSpacing: config.letterSpacing,
|
|
4385
|
+
fontSize: config.fontSize,
|
|
4386
|
+
lineHeight: 1,
|
|
4387
|
+
desc: { family: config.fontFamily, weight: config.fontWeight },
|
|
4388
|
+
textTransform: "none"
|
|
4389
|
+
});
|
|
4390
|
+
const width = lines[0]?.width ?? 0;
|
|
4391
|
+
const glyphs = lines[0]?.glyphs ?? [];
|
|
4392
|
+
const isRTL = isRTLText(transformedText);
|
|
4393
|
+
const shaped = {
|
|
4394
|
+
text: transformedText,
|
|
4395
|
+
width,
|
|
4396
|
+
glyphs: glyphs.map((g) => ({
|
|
4397
|
+
id: g.id,
|
|
4398
|
+
xAdvance: g.xAdvance,
|
|
4399
|
+
xOffset: g.xOffset,
|
|
4400
|
+
yOffset: g.yOffset,
|
|
4401
|
+
cluster: g.cluster
|
|
4402
|
+
})),
|
|
4403
|
+
isRTL
|
|
4404
|
+
};
|
|
4405
|
+
this.cache.set(cacheKey, shaped);
|
|
4406
|
+
return shaped;
|
|
3705
4407
|
}
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
4408
|
+
async layoutCaption(words, config) {
|
|
4409
|
+
const store = new WordTimingStore(words);
|
|
4410
|
+
const measurementConfig = {
|
|
4411
|
+
fontFamily: config.fontFamily,
|
|
4412
|
+
fontSize: config.fontSize,
|
|
4413
|
+
fontWeight: config.fontWeight,
|
|
4414
|
+
letterSpacing: config.letterSpacing,
|
|
4415
|
+
textTransform: config.textTransform
|
|
4416
|
+
};
|
|
4417
|
+
const shapedWords = await Promise.all(
|
|
4418
|
+
words.map((w) => this.measureWord(w.text, measurementConfig))
|
|
4419
|
+
);
|
|
4420
|
+
if (config.measureTextWidth) {
|
|
4421
|
+
const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
|
|
4422
|
+
for (let i = 0; i < shapedWords.length; i++) {
|
|
4423
|
+
store.widths[i] = config.measureTextWidth(shapedWords[i].text, fontString);
|
|
4424
|
+
}
|
|
4425
|
+
} else {
|
|
4426
|
+
for (let i = 0; i < shapedWords.length; i++) {
|
|
4427
|
+
store.widths[i] = shapedWords[i].width;
|
|
4428
|
+
}
|
|
4429
|
+
}
|
|
4430
|
+
if (config.textTransform !== "none") {
|
|
4431
|
+
for (let i = 0; i < shapedWords.length; i++) {
|
|
4432
|
+
store.words[i] = shapedWords[i].text;
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
const wordGroups = groupWordsByPause(store, config.pauseThreshold);
|
|
4436
|
+
const pixelMaxWidth = config.frameWidth * config.maxWidth;
|
|
4437
|
+
let spaceWidth;
|
|
4438
|
+
if (config.measureTextWidth) {
|
|
4439
|
+
const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
|
|
4440
|
+
spaceWidth = config.measureTextWidth(" ", fontString) + config.wordSpacing;
|
|
4441
|
+
} else {
|
|
4442
|
+
const spaceWord = await this.measureWord(" ", measurementConfig);
|
|
4443
|
+
spaceWidth = spaceWord.width + config.wordSpacing;
|
|
4444
|
+
}
|
|
4445
|
+
const groups = wordGroups.map((indices) => {
|
|
4446
|
+
const groupWidths = indices.map((i) => store.widths[i]);
|
|
4447
|
+
const lineIndices = breakIntoLines(
|
|
4448
|
+
groupWidths,
|
|
4449
|
+
pixelMaxWidth,
|
|
4450
|
+
config.maxLines,
|
|
4451
|
+
spaceWidth
|
|
4452
|
+
);
|
|
4453
|
+
const lines = lineIndices.map((lineWordIndices, lineIndex) => {
|
|
4454
|
+
const actualIndices = lineWordIndices.map((i) => indices[i]);
|
|
4455
|
+
const lineWidth = actualIndices.reduce((sum, idx) => sum + store.widths[idx], 0) + (actualIndices.length - 1) * spaceWidth;
|
|
4456
|
+
return {
|
|
4457
|
+
wordIndices: actualIndices,
|
|
4458
|
+
x: 0,
|
|
4459
|
+
y: lineIndex * config.fontSize * config.lineHeight,
|
|
4460
|
+
width: lineWidth,
|
|
4461
|
+
height: config.fontSize
|
|
4462
|
+
};
|
|
4463
|
+
});
|
|
4464
|
+
return {
|
|
4465
|
+
wordIndices: lines.flatMap((l) => l.wordIndices),
|
|
4466
|
+
startTime: store.startTimes[indices[0]],
|
|
4467
|
+
endTime: store.endTimes[indices[indices.length - 1]],
|
|
4468
|
+
lines
|
|
4469
|
+
};
|
|
4470
|
+
});
|
|
4471
|
+
const calculateGroupY = (group) => {
|
|
4472
|
+
const totalHeight = group.lines.length * config.fontSize * config.lineHeight;
|
|
4473
|
+
switch (config.position) {
|
|
4474
|
+
case "top":
|
|
4475
|
+
return config.fontSize * 1.5;
|
|
4476
|
+
case "bottom":
|
|
4477
|
+
return config.frameHeight - totalHeight - config.fontSize * 0.5;
|
|
4478
|
+
case "center":
|
|
4479
|
+
default:
|
|
4480
|
+
return (config.frameHeight - totalHeight) / 2 + config.fontSize;
|
|
4481
|
+
}
|
|
4482
|
+
};
|
|
4483
|
+
for (const group of groups) {
|
|
4484
|
+
const baseY = calculateGroupY(group);
|
|
4485
|
+
for (let lineIdx = 0; lineIdx < group.lines.length; lineIdx++) {
|
|
4486
|
+
const line = group.lines[lineIdx];
|
|
4487
|
+
line.x = (config.frameWidth - line.width) / 2;
|
|
4488
|
+
line.y = baseY + lineIdx * config.fontSize * config.lineHeight;
|
|
4489
|
+
let xCursor = line.x;
|
|
4490
|
+
for (const wordIdx of line.wordIndices) {
|
|
4491
|
+
store.xPositions[wordIdx] = xCursor;
|
|
4492
|
+
store.yPositions[wordIdx] = line.y;
|
|
4493
|
+
xCursor += store.widths[wordIdx] + spaceWidth;
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
4496
|
+
}
|
|
4497
|
+
return {
|
|
4498
|
+
store,
|
|
4499
|
+
groups,
|
|
4500
|
+
shapedWords
|
|
4501
|
+
};
|
|
4502
|
+
}
|
|
4503
|
+
getVisibleWordsAtTime(layout, timeMs) {
|
|
4504
|
+
const activeGroup = layout.groups.find(
|
|
4505
|
+
(g) => timeMs >= g.startTime && timeMs <= g.endTime
|
|
4506
|
+
);
|
|
4507
|
+
if (!activeGroup) {
|
|
4508
|
+
return [];
|
|
4509
|
+
}
|
|
4510
|
+
return activeGroup.wordIndices.map((idx) => ({
|
|
4511
|
+
wordIndex: idx,
|
|
4512
|
+
text: layout.store.words[idx],
|
|
4513
|
+
x: layout.store.xPositions[idx],
|
|
4514
|
+
y: layout.store.yPositions[idx],
|
|
4515
|
+
width: layout.store.widths[idx],
|
|
4516
|
+
startTime: layout.store.startTimes[idx],
|
|
4517
|
+
endTime: layout.store.endTimes[idx],
|
|
4518
|
+
isRTL: layout.shapedWords[idx].isRTL
|
|
4519
|
+
}));
|
|
4520
|
+
}
|
|
4521
|
+
getActiveWordAtTime(layout, timeMs) {
|
|
4522
|
+
const wordIndex = findWordAtTime(layout.store, timeMs);
|
|
4523
|
+
if (wordIndex === -1) {
|
|
4524
|
+
return null;
|
|
4525
|
+
}
|
|
4526
|
+
return {
|
|
4527
|
+
wordIndex,
|
|
4528
|
+
text: layout.store.words[wordIndex],
|
|
4529
|
+
x: layout.store.xPositions[wordIndex],
|
|
4530
|
+
y: layout.store.yPositions[wordIndex],
|
|
4531
|
+
width: layout.store.widths[wordIndex],
|
|
4532
|
+
startTime: layout.store.startTimes[wordIndex],
|
|
4533
|
+
endTime: layout.store.endTimes[wordIndex],
|
|
4534
|
+
isRTL: layout.shapedWords[wordIndex].isRTL
|
|
4535
|
+
};
|
|
4536
|
+
}
|
|
4537
|
+
clearCache() {
|
|
4538
|
+
this.cache.clear();
|
|
4539
|
+
}
|
|
4540
|
+
getCacheStats() {
|
|
4541
|
+
return {
|
|
4542
|
+
size: this.cache.size,
|
|
4543
|
+
calculatedSize: this.cache.calculatedSize
|
|
4544
|
+
};
|
|
4545
|
+
}
|
|
4546
|
+
};
|
|
4547
|
+
|
|
4548
|
+
// src/core/rich-caption-animator.ts
|
|
4549
|
+
var ANIMATION_DURATIONS = {
|
|
4550
|
+
karaoke: 0,
|
|
4551
|
+
highlight: 0,
|
|
4552
|
+
pop: 200,
|
|
4553
|
+
fade: 150,
|
|
4554
|
+
slide: 250,
|
|
4555
|
+
bounce: 400,
|
|
4556
|
+
typewriter: 0,
|
|
4557
|
+
none: 0
|
|
4558
|
+
};
|
|
4559
|
+
var DEFAULT_ANIMATION_STATE = {
|
|
4560
|
+
opacity: 1,
|
|
4561
|
+
scale: 1,
|
|
4562
|
+
translateX: 0,
|
|
4563
|
+
translateY: 0,
|
|
4564
|
+
fillProgress: 1,
|
|
4565
|
+
isActive: false,
|
|
4566
|
+
visibleCharacters: -1
|
|
4567
|
+
};
|
|
4568
|
+
function easeOutQuad2(t) {
|
|
4569
|
+
return t * (2 - t);
|
|
4570
|
+
}
|
|
4571
|
+
function easeInOutQuad(t) {
|
|
4572
|
+
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
|
4573
|
+
}
|
|
4574
|
+
function easeOutBack(t) {
|
|
4575
|
+
const c1 = 1.70158;
|
|
4576
|
+
const c3 = c1 + 1;
|
|
4577
|
+
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
|
4578
|
+
}
|
|
4579
|
+
function easeOutCirc(t) {
|
|
4580
|
+
return Math.sqrt(1 - Math.pow(t - 1, 2));
|
|
4581
|
+
}
|
|
4582
|
+
function easeOutBounce(t) {
|
|
4583
|
+
const n1 = 7.5625;
|
|
4584
|
+
const d1 = 2.75;
|
|
4585
|
+
if (t < 1 / d1) {
|
|
4586
|
+
return n1 * t * t;
|
|
4587
|
+
}
|
|
4588
|
+
if (t < 2 / d1) {
|
|
4589
|
+
return n1 * (t -= 1.5 / d1) * t + 0.75;
|
|
4590
|
+
}
|
|
4591
|
+
if (t < 2.5 / d1) {
|
|
4592
|
+
return n1 * (t -= 2.25 / d1) * t + 0.9375;
|
|
4593
|
+
}
|
|
4594
|
+
return n1 * (t -= 2.625 / d1) * t + 0.984375;
|
|
4595
|
+
}
|
|
4596
|
+
function clamp(value, min, max) {
|
|
4597
|
+
return Math.min(Math.max(value, min), max);
|
|
4598
|
+
}
|
|
4599
|
+
function calculateAnimationProgress(ctx) {
|
|
4600
|
+
if (ctx.animationDuration <= 0) {
|
|
4601
|
+
return ctx.currentTime >= ctx.wordStart ? 1 : 0;
|
|
4602
|
+
}
|
|
4603
|
+
const elapsed = ctx.currentTime - ctx.wordStart;
|
|
4604
|
+
return clamp(elapsed / ctx.animationDuration, 0, 1);
|
|
4605
|
+
}
|
|
4606
|
+
function calculateWordProgress(ctx) {
|
|
4607
|
+
const duration = ctx.wordEnd - ctx.wordStart;
|
|
4608
|
+
if (duration <= 0) {
|
|
4609
|
+
return ctx.currentTime >= ctx.wordStart ? 1 : 0;
|
|
4610
|
+
}
|
|
4611
|
+
const elapsed = ctx.currentTime - ctx.wordStart;
|
|
4612
|
+
return clamp(elapsed / duration, 0, 1);
|
|
4613
|
+
}
|
|
4614
|
+
function isWordActive(ctx) {
|
|
4615
|
+
return ctx.currentTime >= ctx.wordStart && ctx.currentTime < ctx.wordEnd;
|
|
4616
|
+
}
|
|
4617
|
+
function calculateKaraokeState(ctx, speed) {
|
|
4618
|
+
const isActive = isWordActive(ctx);
|
|
4619
|
+
const wordDuration = ctx.wordEnd - ctx.wordStart;
|
|
4620
|
+
const adjustedDuration = wordDuration / speed;
|
|
4621
|
+
const adjustedEnd = ctx.wordStart + adjustedDuration;
|
|
4622
|
+
const adjustedCtx = { ...ctx, wordEnd: adjustedEnd };
|
|
4623
|
+
if (ctx.currentTime < ctx.wordStart) {
|
|
4624
|
+
return {
|
|
4625
|
+
fillProgress: 0,
|
|
4626
|
+
isActive: false,
|
|
4627
|
+
opacity: 1
|
|
4628
|
+
};
|
|
4629
|
+
}
|
|
4630
|
+
if (ctx.currentTime >= adjustedEnd) {
|
|
4631
|
+
return {
|
|
4632
|
+
fillProgress: 1,
|
|
4633
|
+
isActive: false,
|
|
4634
|
+
opacity: 1
|
|
4635
|
+
};
|
|
4636
|
+
}
|
|
4637
|
+
return {
|
|
4638
|
+
fillProgress: calculateWordProgress(adjustedCtx),
|
|
4639
|
+
isActive,
|
|
4640
|
+
opacity: 1
|
|
4641
|
+
};
|
|
4642
|
+
}
|
|
4643
|
+
function calculateHighlightState(ctx) {
|
|
4644
|
+
const isActive = isWordActive(ctx);
|
|
4645
|
+
return {
|
|
4646
|
+
isActive,
|
|
4647
|
+
fillProgress: isActive ? 1 : 0,
|
|
4648
|
+
opacity: 1
|
|
4649
|
+
};
|
|
4650
|
+
}
|
|
4651
|
+
function calculatePopState(ctx, activeScale, speed) {
|
|
4652
|
+
if (ctx.currentTime < ctx.wordStart) {
|
|
4653
|
+
return {
|
|
4654
|
+
scale: 0.5,
|
|
4655
|
+
opacity: 0,
|
|
4656
|
+
isActive: false
|
|
4657
|
+
};
|
|
4658
|
+
}
|
|
4659
|
+
const adjustedDuration = ctx.animationDuration / speed;
|
|
4660
|
+
const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
|
|
4661
|
+
const progress = calculateAnimationProgress(adjustedCtx);
|
|
4662
|
+
const easedProgress = easeOutBack(progress);
|
|
4663
|
+
const startScale = 0.5;
|
|
4664
|
+
const endScale = isWordActive(ctx) ? activeScale : 1;
|
|
4665
|
+
const scale = startScale + (endScale - startScale) * easedProgress;
|
|
4666
|
+
return {
|
|
4667
|
+
scale: Math.min(scale, activeScale),
|
|
4668
|
+
opacity: easedProgress,
|
|
4669
|
+
isActive: isWordActive(ctx)
|
|
4670
|
+
};
|
|
4671
|
+
}
|
|
4672
|
+
function calculateFadeState(ctx, speed) {
|
|
4673
|
+
if (ctx.currentTime < ctx.wordStart) {
|
|
4674
|
+
return {
|
|
4675
|
+
opacity: 0,
|
|
4676
|
+
isActive: false
|
|
4677
|
+
};
|
|
4678
|
+
}
|
|
4679
|
+
const adjustedDuration = ctx.animationDuration / speed;
|
|
4680
|
+
const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
|
|
4681
|
+
const progress = calculateAnimationProgress(adjustedCtx);
|
|
4682
|
+
const easedProgress = easeInOutQuad(progress);
|
|
4683
|
+
return {
|
|
4684
|
+
opacity: easedProgress,
|
|
4685
|
+
isActive: isWordActive(ctx)
|
|
4686
|
+
};
|
|
4687
|
+
}
|
|
4688
|
+
function calculateSlideState(ctx, direction, speed, fontSize) {
|
|
4689
|
+
const slideDistance = fontSize * 1.5;
|
|
4690
|
+
if (ctx.currentTime < ctx.wordStart) {
|
|
4691
|
+
const offset2 = getDirectionOffset(direction, slideDistance);
|
|
4692
|
+
return {
|
|
4693
|
+
translateX: offset2.x,
|
|
4694
|
+
translateY: offset2.y,
|
|
4695
|
+
opacity: 0,
|
|
4696
|
+
isActive: false
|
|
4697
|
+
};
|
|
4698
|
+
}
|
|
4699
|
+
const adjustedDuration = ctx.animationDuration / speed;
|
|
4700
|
+
const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
|
|
4701
|
+
const progress = calculateAnimationProgress(adjustedCtx);
|
|
4702
|
+
const easedProgress = easeOutCirc(progress);
|
|
4703
|
+
const offset = getDirectionOffset(direction, slideDistance);
|
|
4704
|
+
const translateX = offset.x * (1 - easedProgress);
|
|
4705
|
+
const translateY = offset.y * (1 - easedProgress);
|
|
4706
|
+
return {
|
|
4707
|
+
translateX,
|
|
4708
|
+
translateY,
|
|
4709
|
+
opacity: easeOutQuad2(progress),
|
|
4710
|
+
isActive: isWordActive(ctx)
|
|
4711
|
+
};
|
|
4712
|
+
}
|
|
4713
|
+
function getDirectionOffset(direction, distance) {
|
|
4714
|
+
switch (direction) {
|
|
4715
|
+
case "left":
|
|
4716
|
+
return { x: -distance, y: 0 };
|
|
4717
|
+
case "right":
|
|
4718
|
+
return { x: distance, y: 0 };
|
|
4719
|
+
case "up":
|
|
4720
|
+
return { x: 0, y: -distance };
|
|
4721
|
+
case "down":
|
|
4722
|
+
return { x: 0, y: distance };
|
|
4723
|
+
}
|
|
4724
|
+
}
|
|
4725
|
+
function calculateBounceState(ctx, speed, fontSize) {
|
|
4726
|
+
const bounceDistance = fontSize * 0.8;
|
|
4727
|
+
if (ctx.currentTime < ctx.wordStart) {
|
|
4728
|
+
return {
|
|
4729
|
+
translateY: -bounceDistance,
|
|
4730
|
+
opacity: 0,
|
|
4731
|
+
isActive: false
|
|
4732
|
+
};
|
|
4733
|
+
}
|
|
4734
|
+
const adjustedDuration = ctx.animationDuration / speed;
|
|
4735
|
+
const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
|
|
4736
|
+
const progress = calculateAnimationProgress(adjustedCtx);
|
|
4737
|
+
const easedProgress = easeOutBounce(progress);
|
|
4738
|
+
return {
|
|
4739
|
+
translateY: -bounceDistance * (1 - easedProgress),
|
|
4740
|
+
opacity: easeOutQuad2(progress),
|
|
4741
|
+
isActive: isWordActive(ctx)
|
|
4742
|
+
};
|
|
4743
|
+
}
|
|
4744
|
+
function calculateTypewriterState(ctx, charCount, speed) {
|
|
4745
|
+
const wordDuration = ctx.wordEnd - ctx.wordStart;
|
|
4746
|
+
const adjustedDuration = wordDuration / speed;
|
|
4747
|
+
const adjustedEnd = ctx.wordStart + adjustedDuration;
|
|
4748
|
+
const adjustedCtx = { ...ctx, wordEnd: adjustedEnd };
|
|
4749
|
+
if (ctx.currentTime < ctx.wordStart) {
|
|
4750
|
+
return {
|
|
4751
|
+
visibleCharacters: 0,
|
|
4752
|
+
opacity: 1,
|
|
4753
|
+
isActive: false
|
|
4754
|
+
};
|
|
4755
|
+
}
|
|
4756
|
+
if (ctx.currentTime >= adjustedEnd) {
|
|
4757
|
+
return {
|
|
4758
|
+
visibleCharacters: charCount,
|
|
4759
|
+
opacity: 1,
|
|
4760
|
+
isActive: false
|
|
4761
|
+
};
|
|
4762
|
+
}
|
|
4763
|
+
const progress = calculateWordProgress(adjustedCtx);
|
|
4764
|
+
const visibleCharacters = Math.ceil(progress * charCount);
|
|
4765
|
+
return {
|
|
4766
|
+
visibleCharacters: clamp(visibleCharacters, 0, charCount),
|
|
4767
|
+
opacity: 1,
|
|
4768
|
+
isActive: isWordActive(ctx)
|
|
4769
|
+
};
|
|
4770
|
+
}
|
|
4771
|
+
function calculateNoneState(ctx) {
|
|
4772
|
+
return {
|
|
4773
|
+
opacity: 1,
|
|
4774
|
+
isActive: isWordActive(ctx)
|
|
4775
|
+
};
|
|
4776
|
+
}
|
|
4777
|
+
function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, activeScale = 1, charCount = 0, fontSize = 48) {
|
|
4778
|
+
const ctx = {
|
|
4779
|
+
wordStart,
|
|
4780
|
+
wordEnd,
|
|
4781
|
+
currentTime,
|
|
4782
|
+
animationDuration: ANIMATION_DURATIONS[config.style]
|
|
4783
|
+
};
|
|
4784
|
+
const baseState = { ...DEFAULT_ANIMATION_STATE };
|
|
4785
|
+
let partialState;
|
|
4786
|
+
switch (config.style) {
|
|
4787
|
+
case "karaoke":
|
|
4788
|
+
partialState = calculateKaraokeState(ctx, config.speed);
|
|
4789
|
+
break;
|
|
4790
|
+
case "highlight":
|
|
4791
|
+
partialState = calculateHighlightState(ctx);
|
|
4792
|
+
break;
|
|
4793
|
+
case "pop":
|
|
4794
|
+
partialState = calculatePopState(ctx, activeScale, config.speed);
|
|
4795
|
+
break;
|
|
4796
|
+
case "fade":
|
|
4797
|
+
partialState = calculateFadeState(ctx, config.speed);
|
|
4798
|
+
break;
|
|
4799
|
+
case "slide":
|
|
4800
|
+
partialState = calculateSlideState(ctx, config.direction, config.speed, fontSize);
|
|
4801
|
+
break;
|
|
4802
|
+
case "bounce":
|
|
4803
|
+
partialState = calculateBounceState(ctx, config.speed, fontSize);
|
|
4804
|
+
break;
|
|
4805
|
+
case "typewriter":
|
|
4806
|
+
partialState = calculateTypewriterState(ctx, charCount, config.speed);
|
|
4807
|
+
break;
|
|
4808
|
+
case "none":
|
|
4809
|
+
default:
|
|
4810
|
+
partialState = calculateNoneState(ctx);
|
|
4811
|
+
break;
|
|
4812
|
+
}
|
|
4813
|
+
return { ...baseState, ...partialState };
|
|
4814
|
+
}
|
|
4815
|
+
function calculateAnimationStatesForGroup(words, currentTime, config, activeScale = 1, fontSize = 48) {
|
|
4816
|
+
const states = /* @__PURE__ */ new Map();
|
|
4817
|
+
for (const word of words) {
|
|
4818
|
+
const state = calculateWordAnimationState(
|
|
4819
|
+
word.startTime,
|
|
4820
|
+
word.endTime,
|
|
4821
|
+
currentTime,
|
|
4822
|
+
config,
|
|
4823
|
+
activeScale,
|
|
4824
|
+
word.text.length,
|
|
4825
|
+
fontSize
|
|
4826
|
+
);
|
|
4827
|
+
states.set(word.wordIndex, state);
|
|
4828
|
+
}
|
|
4829
|
+
return states;
|
|
4830
|
+
}
|
|
4831
|
+
function getDefaultAnimationConfig() {
|
|
4832
|
+
return {
|
|
4833
|
+
style: "highlight",
|
|
4834
|
+
speed: 1,
|
|
4835
|
+
direction: "up"
|
|
4836
|
+
};
|
|
4837
|
+
}
|
|
4838
|
+
|
|
4839
|
+
// src/core/rich-caption-generator.ts
|
|
4840
|
+
function extractFontConfig(asset) {
|
|
4841
|
+
const font = asset.font;
|
|
4842
|
+
const active = asset.active?.font;
|
|
4843
|
+
return {
|
|
4844
|
+
family: font?.family ?? "Open Sans",
|
|
4845
|
+
size: font?.size ?? 24,
|
|
4846
|
+
weight: String(font?.weight ?? "400"),
|
|
4847
|
+
baseColor: font?.color ?? "#ffffff",
|
|
4848
|
+
activeColor: active?.color ?? "#ffff00",
|
|
4849
|
+
baseOpacity: font?.opacity ?? 1,
|
|
4850
|
+
activeOpacity: active?.opacity ?? 1,
|
|
4851
|
+
letterSpacing: asset.style?.letterSpacing ?? 0
|
|
4852
|
+
};
|
|
4853
|
+
}
|
|
4854
|
+
function extractStrokeConfig(asset, isActive) {
|
|
4855
|
+
const baseStroke = asset.stroke;
|
|
4856
|
+
const activeStroke = asset.active?.stroke;
|
|
4857
|
+
if (!baseStroke && !activeStroke) {
|
|
4858
|
+
return void 0;
|
|
4859
|
+
}
|
|
4860
|
+
if (isActive && activeStroke) {
|
|
4861
|
+
return {
|
|
4862
|
+
width: activeStroke.width ?? baseStroke?.width ?? 0,
|
|
4863
|
+
color: activeStroke.color ?? baseStroke?.color ?? "#000000",
|
|
4864
|
+
opacity: activeStroke.opacity ?? baseStroke?.opacity ?? 1
|
|
4865
|
+
};
|
|
4866
|
+
}
|
|
4867
|
+
if (baseStroke) {
|
|
4868
|
+
return {
|
|
4869
|
+
width: baseStroke.width ?? 0,
|
|
4870
|
+
color: baseStroke.color ?? "#000000",
|
|
4871
|
+
opacity: baseStroke.opacity ?? 1
|
|
4872
|
+
};
|
|
4873
|
+
}
|
|
4874
|
+
return void 0;
|
|
4875
|
+
}
|
|
4876
|
+
function extractShadowConfig(asset) {
|
|
4877
|
+
const shadow = asset.shadow;
|
|
4878
|
+
if (!shadow) {
|
|
4879
|
+
return void 0;
|
|
4880
|
+
}
|
|
4881
|
+
return {
|
|
4882
|
+
offsetX: shadow.offsetX ?? 0,
|
|
4883
|
+
offsetY: shadow.offsetY ?? 0,
|
|
4884
|
+
blur: shadow.blur ?? 0,
|
|
4885
|
+
color: shadow.color ?? "#000000",
|
|
4886
|
+
opacity: shadow.opacity ?? 0.5
|
|
4887
|
+
};
|
|
4888
|
+
}
|
|
4889
|
+
function extractBackgroundConfig(asset, isActive) {
|
|
4890
|
+
const fontBackground = asset.font?.background;
|
|
4891
|
+
const activeBackground = asset.active?.font?.background;
|
|
4892
|
+
const bgColor = isActive && activeBackground ? activeBackground : fontBackground;
|
|
4893
|
+
if (!bgColor) {
|
|
4894
|
+
return void 0;
|
|
4895
|
+
}
|
|
4896
|
+
const paddingValues = extractCaptionPadding(asset);
|
|
4897
|
+
const paddingValue = Math.max(paddingValues.top, paddingValues.right, paddingValues.bottom, paddingValues.left);
|
|
4898
|
+
return {
|
|
4899
|
+
color: bgColor,
|
|
4900
|
+
opacity: 1,
|
|
4901
|
+
borderRadius: 4,
|
|
4902
|
+
padding: paddingValue
|
|
4903
|
+
};
|
|
4904
|
+
}
|
|
4905
|
+
function extractCaptionPadding(asset) {
|
|
4906
|
+
const padding = asset.padding;
|
|
4907
|
+
if (!padding) {
|
|
4908
|
+
return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
4909
|
+
}
|
|
4910
|
+
if (typeof padding === "number") {
|
|
4911
|
+
return { top: padding, right: padding, bottom: padding, left: padding };
|
|
4912
|
+
}
|
|
4913
|
+
return {
|
|
4914
|
+
top: padding.top ?? 0,
|
|
4915
|
+
right: padding.right ?? 0,
|
|
4916
|
+
bottom: padding.bottom ?? 0,
|
|
4917
|
+
left: padding.left ?? 0
|
|
4918
|
+
};
|
|
4919
|
+
}
|
|
4920
|
+
function extractCaptionBackground(asset) {
|
|
4921
|
+
const bg = asset.background;
|
|
4922
|
+
if (!bg || !bg.color) {
|
|
4923
|
+
return void 0;
|
|
4924
|
+
}
|
|
4925
|
+
return {
|
|
4926
|
+
color: bg.color,
|
|
4927
|
+
opacity: bg.opacity ?? 1
|
|
4928
|
+
};
|
|
4929
|
+
}
|
|
4930
|
+
function extractAnimationConfig(asset) {
|
|
4931
|
+
const wordAnim = asset.wordAnimation;
|
|
4932
|
+
if (!wordAnim) {
|
|
4933
|
+
return getDefaultAnimationConfig();
|
|
4934
|
+
}
|
|
4935
|
+
return {
|
|
4936
|
+
style: wordAnim.style ?? "highlight",
|
|
4937
|
+
speed: wordAnim.speed ?? 1,
|
|
4938
|
+
direction: wordAnim.direction ?? "up"
|
|
4939
|
+
};
|
|
4940
|
+
}
|
|
4941
|
+
function extractActiveScale(asset) {
|
|
4942
|
+
return asset.active?.scale ?? 1;
|
|
4943
|
+
}
|
|
4944
|
+
function createDrawCaptionWordOp(word, animState, asset, fontConfig) {
|
|
4945
|
+
const isActive = animState.isActive;
|
|
4946
|
+
const displayText = animState.visibleCharacters >= 0 && animState.visibleCharacters < word.text.length ? word.text.slice(0, animState.visibleCharacters) : word.text;
|
|
4947
|
+
return {
|
|
4948
|
+
op: "DrawCaptionWord",
|
|
4949
|
+
text: displayText,
|
|
4950
|
+
x: word.x,
|
|
4951
|
+
y: word.y,
|
|
4952
|
+
width: word.width,
|
|
4953
|
+
fontSize: fontConfig.size,
|
|
4954
|
+
fontFamily: fontConfig.family,
|
|
4955
|
+
fontWeight: fontConfig.weight,
|
|
4956
|
+
baseColor: fontConfig.baseColor,
|
|
4957
|
+
activeColor: fontConfig.activeColor,
|
|
4958
|
+
baseOpacity: fontConfig.baseOpacity,
|
|
4959
|
+
activeOpacity: fontConfig.activeOpacity,
|
|
4960
|
+
fillProgress: animState.fillProgress,
|
|
4961
|
+
transform: {
|
|
4962
|
+
scale: animState.scale,
|
|
4963
|
+
translateX: animState.translateX,
|
|
4964
|
+
translateY: animState.translateY,
|
|
4965
|
+
opacity: animState.opacity
|
|
4966
|
+
},
|
|
4967
|
+
isRTL: word.isRTL,
|
|
4968
|
+
visibleCharacters: animState.visibleCharacters,
|
|
4969
|
+
letterSpacing: fontConfig.letterSpacing > 0 ? fontConfig.letterSpacing : void 0,
|
|
4970
|
+
stroke: extractStrokeConfig(asset, isActive),
|
|
4971
|
+
shadow: extractShadowConfig(asset),
|
|
4972
|
+
background: extractBackgroundConfig(asset, isActive)
|
|
4973
|
+
};
|
|
4974
|
+
}
|
|
4975
|
+
function generateRichCaptionDrawOps(asset, layout, frameTimeMs, layoutEngine, _config) {
|
|
4976
|
+
if (layout.store.length === 0) {
|
|
4977
|
+
return [];
|
|
4978
|
+
}
|
|
4979
|
+
const visibleWords = layoutEngine.getVisibleWordsAtTime(layout, frameTimeMs);
|
|
4980
|
+
if (visibleWords.length === 0) {
|
|
4981
|
+
return [];
|
|
4982
|
+
}
|
|
4983
|
+
const animConfig = extractAnimationConfig(asset);
|
|
4984
|
+
const activeScale = extractActiveScale(asset);
|
|
4985
|
+
const fontConfig = extractFontConfig(asset);
|
|
4986
|
+
const animationStates = calculateAnimationStatesForGroup(
|
|
4987
|
+
visibleWords,
|
|
4988
|
+
frameTimeMs,
|
|
4989
|
+
animConfig,
|
|
4990
|
+
activeScale,
|
|
4991
|
+
fontConfig.size
|
|
4992
|
+
);
|
|
4993
|
+
const ops = [];
|
|
4994
|
+
const captionBg = extractCaptionBackground(asset);
|
|
4995
|
+
if (captionBg) {
|
|
4996
|
+
const activeGroup = layout.groups.find(
|
|
4997
|
+
(g) => frameTimeMs >= g.startTime && frameTimeMs <= g.endTime
|
|
4998
|
+
);
|
|
4999
|
+
if (activeGroup && activeGroup.lines.length > 0) {
|
|
5000
|
+
const padding = extractCaptionPadding(asset);
|
|
5001
|
+
let minX = Infinity;
|
|
5002
|
+
let maxX = -Infinity;
|
|
5003
|
+
let minY = Infinity;
|
|
5004
|
+
let maxY = -Infinity;
|
|
5005
|
+
for (const line of activeGroup.lines) {
|
|
5006
|
+
const lineX = line.x;
|
|
5007
|
+
const lineRight = line.x + line.width;
|
|
5008
|
+
const lineY = line.y - line.height * 0.8;
|
|
5009
|
+
const lineBottom = line.y + line.height * 0.2;
|
|
5010
|
+
if (lineX < minX) minX = lineX;
|
|
5011
|
+
if (lineRight > maxX) maxX = lineRight;
|
|
5012
|
+
if (lineY < minY) minY = lineY;
|
|
5013
|
+
if (lineBottom > maxY) maxY = lineBottom;
|
|
5014
|
+
}
|
|
5015
|
+
ops.push({
|
|
5016
|
+
op: "DrawCaptionBackground",
|
|
5017
|
+
x: minX - padding.left,
|
|
5018
|
+
y: minY - padding.top,
|
|
5019
|
+
width: maxX - minX + padding.left + padding.right,
|
|
5020
|
+
height: maxY - minY + padding.top + padding.bottom,
|
|
5021
|
+
color: captionBg.color,
|
|
5022
|
+
opacity: captionBg.opacity,
|
|
5023
|
+
borderRadius: 8
|
|
5024
|
+
});
|
|
5025
|
+
}
|
|
5026
|
+
}
|
|
5027
|
+
for (const word of visibleWords) {
|
|
5028
|
+
const animState = animationStates.get(word.wordIndex);
|
|
5029
|
+
if (!animState) {
|
|
5030
|
+
continue;
|
|
5031
|
+
}
|
|
5032
|
+
if (animState.opacity <= 0) {
|
|
5033
|
+
continue;
|
|
5034
|
+
}
|
|
5035
|
+
const drawOp = createDrawCaptionWordOp(word, animState, asset, fontConfig);
|
|
5036
|
+
ops.push(drawOp);
|
|
5037
|
+
}
|
|
5038
|
+
return ops;
|
|
5039
|
+
}
|
|
5040
|
+
function generateRichCaptionFrame(asset, layout, frameTimeMs, layoutEngine, config) {
|
|
5041
|
+
const ops = generateRichCaptionDrawOps(
|
|
5042
|
+
asset,
|
|
5043
|
+
layout,
|
|
5044
|
+
frameTimeMs,
|
|
5045
|
+
layoutEngine,
|
|
5046
|
+
config
|
|
5047
|
+
);
|
|
5048
|
+
const activeWord = layoutEngine.getActiveWordAtTime(layout, frameTimeMs);
|
|
5049
|
+
return {
|
|
5050
|
+
ops,
|
|
5051
|
+
visibleWordCount: ops.length,
|
|
5052
|
+
activeWordIndex: activeWord?.wordIndex ?? -1
|
|
5053
|
+
};
|
|
5054
|
+
}
|
|
5055
|
+
function createDefaultGeneratorConfig(frameWidth = 1920, frameHeight = 1080, pixelRatio = 1) {
|
|
5056
|
+
return {
|
|
5057
|
+
frameWidth,
|
|
5058
|
+
frameHeight,
|
|
5059
|
+
pixelRatio
|
|
5060
|
+
};
|
|
5061
|
+
}
|
|
5062
|
+
function isDrawCaptionWordOp(op) {
|
|
5063
|
+
return op.op === "DrawCaptionWord";
|
|
5064
|
+
}
|
|
5065
|
+
function getDrawCaptionWordOps(ops) {
|
|
5066
|
+
return ops.filter(isDrawCaptionWordOp);
|
|
5067
|
+
}
|
|
5068
|
+
|
|
5069
|
+
// src/core/canvas-text-measurer.ts
|
|
5070
|
+
async function createCanvasTextMeasurer() {
|
|
5071
|
+
const canvasMod = await import("canvas");
|
|
5072
|
+
const canvas = canvasMod.createCanvas(1, 1);
|
|
5073
|
+
const ctx = canvas.getContext("2d");
|
|
5074
|
+
ctx.textBaseline = "alphabetic";
|
|
5075
|
+
let lastFont = "";
|
|
5076
|
+
return (text, font) => {
|
|
5077
|
+
if (font !== lastFont) {
|
|
5078
|
+
ctx.font = font;
|
|
5079
|
+
lastFont = font;
|
|
5080
|
+
}
|
|
5081
|
+
return ctx.measureText(text).width;
|
|
5082
|
+
};
|
|
5083
|
+
}
|
|
5084
|
+
|
|
5085
|
+
// src/core/subtitle-parser.ts
|
|
5086
|
+
function detectSubtitleFormat(content) {
|
|
5087
|
+
const firstNewline = content.indexOf("\n");
|
|
5088
|
+
const firstLine = (firstNewline === -1 ? content : content.substring(0, firstNewline)).trim();
|
|
5089
|
+
return firstLine.startsWith("WEBVTT") ? "vtt" : "srt";
|
|
5090
|
+
}
|
|
5091
|
+
function parseSubtitleToWords(content) {
|
|
5092
|
+
const normalized = normalizeContent(content);
|
|
5093
|
+
if (normalized.length === 0) {
|
|
5094
|
+
return [];
|
|
5095
|
+
}
|
|
5096
|
+
const format = detectSubtitleFormat(normalized);
|
|
5097
|
+
const cues = format === "vtt" ? parseVTTCues(normalized) : parseSRTCues(normalized);
|
|
5098
|
+
const words = [];
|
|
5099
|
+
for (let i = 0; i < cues.length; i++) {
|
|
5100
|
+
const cueWords = distributeCueToWords(cues[i]);
|
|
5101
|
+
for (let j = 0; j < cueWords.length; j++) {
|
|
5102
|
+
words.push(cueWords[j]);
|
|
5103
|
+
}
|
|
5104
|
+
}
|
|
5105
|
+
return words;
|
|
5106
|
+
}
|
|
5107
|
+
function normalizeContent(content) {
|
|
5108
|
+
let start = 0;
|
|
5109
|
+
if (content.charCodeAt(0) === 65279) {
|
|
5110
|
+
start = 1;
|
|
5111
|
+
}
|
|
5112
|
+
let result = start > 0 ? content.substring(start) : content;
|
|
5113
|
+
result = result.replace(/\r\n?/g, "\n");
|
|
5114
|
+
return result.trim();
|
|
5115
|
+
}
|
|
5116
|
+
function parseVTTCues(content) {
|
|
5117
|
+
const cues = [];
|
|
5118
|
+
let pos = 0;
|
|
5119
|
+
const len = content.length;
|
|
5120
|
+
const firstNewline = content.indexOf("\n", pos);
|
|
5121
|
+
if (firstNewline === -1) {
|
|
5122
|
+
return cues;
|
|
5123
|
+
}
|
|
5124
|
+
pos = firstNewline + 1;
|
|
5125
|
+
while (pos < len) {
|
|
5126
|
+
pos = skipWhitespaceAndNewlines(content, pos);
|
|
5127
|
+
if (pos >= len) break;
|
|
5128
|
+
const lineEnd = findLineEnd(content, pos);
|
|
5129
|
+
const line = content.substring(pos, lineEnd);
|
|
5130
|
+
if (line.startsWith("NOTE") || line.startsWith("STYLE") || line.startsWith("REGION")) {
|
|
5131
|
+
pos = skipBlock(content, lineEnd + 1);
|
|
5132
|
+
continue;
|
|
5133
|
+
}
|
|
5134
|
+
const arrowIdx = line.indexOf("-->");
|
|
5135
|
+
let timeLine;
|
|
5136
|
+
if (arrowIdx !== -1) {
|
|
5137
|
+
timeLine = line;
|
|
5138
|
+
pos = lineEnd + 1;
|
|
5139
|
+
} else {
|
|
5140
|
+
pos = lineEnd + 1;
|
|
5141
|
+
if (pos >= len) break;
|
|
5142
|
+
const nextLineEnd = findLineEnd(content, pos);
|
|
5143
|
+
const nextLine = content.substring(pos, nextLineEnd);
|
|
5144
|
+
if (nextLine.indexOf("-->") === -1) {
|
|
5145
|
+
pos = skipBlock(content, nextLineEnd + 1);
|
|
5146
|
+
continue;
|
|
5147
|
+
}
|
|
5148
|
+
timeLine = nextLine;
|
|
5149
|
+
pos = nextLineEnd + 1;
|
|
5150
|
+
}
|
|
5151
|
+
const timestamps = parseTimeLineVTT(timeLine);
|
|
5152
|
+
if (!timestamps) {
|
|
5153
|
+
pos = skipBlock(content, pos);
|
|
5154
|
+
continue;
|
|
5155
|
+
}
|
|
5156
|
+
let textLines = [];
|
|
5157
|
+
while (pos < len) {
|
|
5158
|
+
const tLineEnd = findLineEnd(content, pos);
|
|
5159
|
+
const tLine = content.substring(pos, tLineEnd);
|
|
5160
|
+
pos = tLineEnd + 1;
|
|
5161
|
+
if (tLine.length === 0) break;
|
|
5162
|
+
textLines.push(tLine);
|
|
5163
|
+
}
|
|
5164
|
+
if (textLines.length === 0) continue;
|
|
5165
|
+
const rawText = textLines.join(" ");
|
|
5166
|
+
const { cleanText, timestamps: inlineTs } = extractInlineTimestamps(rawText);
|
|
5167
|
+
const strippedText = stripMarkupTags(cleanText).trim();
|
|
5168
|
+
if (strippedText.length === 0) continue;
|
|
5169
|
+
if (timestamps.endMs <= timestamps.startMs) continue;
|
|
5170
|
+
cues.push({
|
|
5171
|
+
startMs: timestamps.startMs,
|
|
5172
|
+
endMs: timestamps.endMs,
|
|
5173
|
+
text: strippedText,
|
|
5174
|
+
inlineTimestamps: inlineTs
|
|
5175
|
+
});
|
|
5176
|
+
}
|
|
5177
|
+
return cues;
|
|
5178
|
+
}
|
|
5179
|
+
function parseSRTCues(content) {
|
|
5180
|
+
const cues = [];
|
|
5181
|
+
let pos = 0;
|
|
5182
|
+
const len = content.length;
|
|
5183
|
+
while (pos < len) {
|
|
5184
|
+
pos = skipWhitespaceAndNewlines(content, pos);
|
|
5185
|
+
if (pos >= len) break;
|
|
5186
|
+
let lineEnd = findLineEnd(content, pos);
|
|
5187
|
+
let line = content.substring(pos, lineEnd);
|
|
5188
|
+
pos = lineEnd + 1;
|
|
5189
|
+
if (line.indexOf("-->") === -1) {
|
|
5190
|
+
if (pos >= len) break;
|
|
5191
|
+
lineEnd = findLineEnd(content, pos);
|
|
5192
|
+
line = content.substring(pos, lineEnd);
|
|
5193
|
+
pos = lineEnd + 1;
|
|
5194
|
+
}
|
|
5195
|
+
if (line.indexOf("-->") === -1) {
|
|
5196
|
+
continue;
|
|
5197
|
+
}
|
|
5198
|
+
const timestamps = parseTimeLineSRT(line);
|
|
5199
|
+
if (!timestamps) continue;
|
|
5200
|
+
let textLines = [];
|
|
5201
|
+
while (pos < len) {
|
|
5202
|
+
const tLineEnd = findLineEnd(content, pos);
|
|
5203
|
+
const tLine = content.substring(pos, tLineEnd);
|
|
5204
|
+
pos = tLineEnd + 1;
|
|
5205
|
+
if (tLine.length === 0) break;
|
|
5206
|
+
textLines.push(tLine);
|
|
5207
|
+
}
|
|
5208
|
+
if (textLines.length === 0) continue;
|
|
5209
|
+
const rawText = textLines.join(" ");
|
|
5210
|
+
const strippedText = stripMarkupTags(rawText).trim();
|
|
5211
|
+
if (strippedText.length === 0) continue;
|
|
5212
|
+
if (timestamps.endMs <= timestamps.startMs) continue;
|
|
5213
|
+
cues.push({
|
|
5214
|
+
startMs: timestamps.startMs,
|
|
5215
|
+
endMs: timestamps.endMs,
|
|
5216
|
+
text: strippedText,
|
|
5217
|
+
inlineTimestamps: []
|
|
5218
|
+
});
|
|
5219
|
+
}
|
|
5220
|
+
return cues;
|
|
5221
|
+
}
|
|
5222
|
+
function parseTimeLineVTT(line) {
|
|
5223
|
+
const arrowIdx = line.indexOf("-->");
|
|
5224
|
+
if (arrowIdx === -1) return null;
|
|
5225
|
+
const startRaw = line.substring(0, arrowIdx).trim();
|
|
5226
|
+
const afterArrow = line.substring(arrowIdx + 3).trim();
|
|
5227
|
+
const spaceIdx = afterArrow.indexOf(" ");
|
|
5228
|
+
const endRaw = spaceIdx === -1 ? afterArrow : afterArrow.substring(0, spaceIdx);
|
|
5229
|
+
const startMs = parseTimestampVTT(startRaw);
|
|
5230
|
+
const endMs = parseTimestampVTT(endRaw);
|
|
5231
|
+
if (startMs < 0 || endMs < 0) return null;
|
|
5232
|
+
return { startMs, endMs };
|
|
5233
|
+
}
|
|
5234
|
+
function parseTimeLineSRT(line) {
|
|
5235
|
+
const arrowIdx = line.indexOf("-->");
|
|
5236
|
+
if (arrowIdx === -1) return null;
|
|
5237
|
+
const startRaw = line.substring(0, arrowIdx).trim();
|
|
5238
|
+
const endRaw = line.substring(arrowIdx + 3).trim();
|
|
5239
|
+
const startMs = parseTimestampSRT(startRaw);
|
|
5240
|
+
const endMs = parseTimestampSRT(endRaw);
|
|
5241
|
+
if (startMs < 0 || endMs < 0) return null;
|
|
5242
|
+
return { startMs, endMs };
|
|
5243
|
+
}
|
|
5244
|
+
function parseTimestampVTT(raw) {
|
|
5245
|
+
const dotIdx = raw.lastIndexOf(".");
|
|
5246
|
+
if (dotIdx === -1) return -1;
|
|
5247
|
+
const msStr = raw.substring(dotIdx + 1);
|
|
5248
|
+
const ms = parseIntFast(msStr);
|
|
5249
|
+
if (ms < 0) return -1;
|
|
5250
|
+
const beforeDot = raw.substring(0, dotIdx);
|
|
5251
|
+
const parts = beforeDot.split(":");
|
|
5252
|
+
if (parts.length === 2) {
|
|
5253
|
+
const minutes = parseIntFast(parts[0]);
|
|
5254
|
+
const seconds = parseIntFast(parts[1]);
|
|
5255
|
+
if (minutes < 0 || seconds < 0) return -1;
|
|
5256
|
+
return minutes * 6e4 + seconds * 1e3 + ms;
|
|
5257
|
+
}
|
|
5258
|
+
if (parts.length === 3) {
|
|
5259
|
+
const hours = parseIntFast(parts[0]);
|
|
5260
|
+
const minutes = parseIntFast(parts[1]);
|
|
5261
|
+
const seconds = parseIntFast(parts[2]);
|
|
5262
|
+
if (hours < 0 || minutes < 0 || seconds < 0) return -1;
|
|
5263
|
+
return hours * 36e5 + minutes * 6e4 + seconds * 1e3 + ms;
|
|
5264
|
+
}
|
|
5265
|
+
return -1;
|
|
5266
|
+
}
|
|
5267
|
+
function parseTimestampSRT(raw) {
|
|
5268
|
+
const commaIdx = raw.lastIndexOf(",");
|
|
5269
|
+
if (commaIdx === -1) return -1;
|
|
5270
|
+
const msStr = raw.substring(commaIdx + 1);
|
|
5271
|
+
const ms = parseIntFast(msStr);
|
|
5272
|
+
if (ms < 0) return -1;
|
|
5273
|
+
const beforeComma = raw.substring(0, commaIdx);
|
|
5274
|
+
const parts = beforeComma.split(":");
|
|
5275
|
+
if (parts.length !== 3) return -1;
|
|
5276
|
+
const hours = parseIntFast(parts[0]);
|
|
5277
|
+
const minutes = parseIntFast(parts[1]);
|
|
5278
|
+
const seconds = parseIntFast(parts[2]);
|
|
5279
|
+
if (hours < 0 || minutes < 0 || seconds < 0) return -1;
|
|
5280
|
+
return hours * 36e5 + minutes * 6e4 + seconds * 1e3 + ms;
|
|
5281
|
+
}
|
|
5282
|
+
function parseIntFast(str) {
|
|
5283
|
+
let result = 0;
|
|
5284
|
+
for (let i = 0; i < str.length; i++) {
|
|
5285
|
+
const code = str.charCodeAt(i);
|
|
5286
|
+
if (code < 48 || code > 57) return -1;
|
|
5287
|
+
result = result * 10 + (code - 48);
|
|
5288
|
+
}
|
|
5289
|
+
return result;
|
|
5290
|
+
}
|
|
5291
|
+
var MARKUP_TAG_REGEX = /<[^>]+>/g;
|
|
5292
|
+
function stripMarkupTags(text) {
|
|
5293
|
+
return text.replace(MARKUP_TAG_REGEX, "");
|
|
5294
|
+
}
|
|
5295
|
+
var INLINE_TIMESTAMP_REGEX = /<(\d{2}:)?(\d{2}):(\d{2}\.\d{3})>/g;
|
|
5296
|
+
function extractInlineTimestamps(text) {
|
|
5297
|
+
const timestamps = [];
|
|
5298
|
+
let cleanText = "";
|
|
5299
|
+
let lastIndex = 0;
|
|
5300
|
+
let match;
|
|
5301
|
+
INLINE_TIMESTAMP_REGEX.lastIndex = 0;
|
|
5302
|
+
while ((match = INLINE_TIMESTAMP_REGEX.exec(text)) !== null) {
|
|
5303
|
+
cleanText += text.substring(lastIndex, match.index);
|
|
5304
|
+
const position = cleanText.length;
|
|
5305
|
+
const hoursStr = match[1] ? match[1].substring(0, match[1].length - 1) : "00";
|
|
5306
|
+
const minutesStr = match[2];
|
|
5307
|
+
const secondsAndMs = match[3];
|
|
5308
|
+
const dotIdx = secondsAndMs.indexOf(".");
|
|
5309
|
+
const secondsStr = secondsAndMs.substring(0, dotIdx);
|
|
5310
|
+
const msStr = secondsAndMs.substring(dotIdx + 1);
|
|
5311
|
+
const hours = parseIntFast(hoursStr);
|
|
5312
|
+
const minutes = parseIntFast(minutesStr);
|
|
5313
|
+
const seconds = parseIntFast(secondsStr);
|
|
5314
|
+
const ms = parseIntFast(msStr);
|
|
5315
|
+
if (hours >= 0 && minutes >= 0 && seconds >= 0 && ms >= 0) {
|
|
5316
|
+
const timeMs = hours * 36e5 + minutes * 6e4 + seconds * 1e3 + ms;
|
|
5317
|
+
timestamps.push({ timeMs, position });
|
|
5318
|
+
}
|
|
5319
|
+
lastIndex = match.index + match[0].length;
|
|
5320
|
+
}
|
|
5321
|
+
cleanText += text.substring(lastIndex);
|
|
5322
|
+
return { cleanText, timestamps };
|
|
5323
|
+
}
|
|
5324
|
+
function distributeCueToWords(cue) {
|
|
5325
|
+
const wordTexts = cue.text.split(/\s+/).filter((w) => w.length > 0);
|
|
5326
|
+
if (wordTexts.length === 0) return [];
|
|
5327
|
+
if (wordTexts.length === 1) {
|
|
5328
|
+
return [{ text: wordTexts[0], start: cue.startMs, end: cue.endMs }];
|
|
5329
|
+
}
|
|
5330
|
+
if (cue.inlineTimestamps.length > 0) {
|
|
5331
|
+
return distributeWithInlineTimestamps(wordTexts, cue);
|
|
5332
|
+
}
|
|
5333
|
+
return distributeByCharacterProportion(wordTexts, cue.startMs, cue.endMs);
|
|
5334
|
+
}
|
|
5335
|
+
function distributeWithInlineTimestamps(wordTexts, cue) {
|
|
5336
|
+
const wordPositions = [];
|
|
5337
|
+
let charPos = 0;
|
|
5338
|
+
for (let i = 0; i < wordTexts.length; i++) {
|
|
5339
|
+
wordPositions.push(charPos);
|
|
5340
|
+
charPos += wordTexts[i].length + 1;
|
|
5341
|
+
}
|
|
5342
|
+
const sortedTimestamps = [...cue.inlineTimestamps].sort((a, b) => a.position - b.position);
|
|
5343
|
+
const wordStartTimes = new Array(wordTexts.length);
|
|
5344
|
+
wordStartTimes[0] = cue.startMs;
|
|
5345
|
+
for (let i = 1; i < wordTexts.length; i++) {
|
|
5346
|
+
const wp = wordPositions[i];
|
|
5347
|
+
let bestTs = -1;
|
|
5348
|
+
for (let t = 0; t < sortedTimestamps.length; t++) {
|
|
5349
|
+
if (sortedTimestamps[t].position <= wp) {
|
|
5350
|
+
bestTs = t;
|
|
5351
|
+
}
|
|
5352
|
+
}
|
|
5353
|
+
if (bestTs >= 0) {
|
|
5354
|
+
wordStartTimes[i] = sortedTimestamps[bestTs].timeMs;
|
|
5355
|
+
} else {
|
|
5356
|
+
wordStartTimes[i] = wordStartTimes[i - 1];
|
|
5357
|
+
}
|
|
5358
|
+
}
|
|
5359
|
+
const words = [];
|
|
5360
|
+
for (let i = 0; i < wordTexts.length; i++) {
|
|
5361
|
+
const start = wordStartTimes[i];
|
|
5362
|
+
const end = i < wordTexts.length - 1 ? wordStartTimes[i + 1] : cue.endMs;
|
|
5363
|
+
words.push({ text: wordTexts[i], start, end: Math.max(end, start) });
|
|
5364
|
+
}
|
|
5365
|
+
return words;
|
|
5366
|
+
}
|
|
5367
|
+
function distributeByCharacterProportion(wordTexts, startMs, endMs) {
|
|
5368
|
+
const totalChars = wordTexts.reduce((sum, w) => sum + w.length, 0);
|
|
5369
|
+
const duration = endMs - startMs;
|
|
5370
|
+
const words = [];
|
|
5371
|
+
let cursor = startMs;
|
|
5372
|
+
for (let i = 0; i < wordTexts.length; i++) {
|
|
5373
|
+
const wordStart = cursor;
|
|
5374
|
+
if (i === wordTexts.length - 1) {
|
|
5375
|
+
words.push({ text: wordTexts[i], start: wordStart, end: endMs });
|
|
5376
|
+
} else {
|
|
5377
|
+
const proportion = wordTexts[i].length / totalChars;
|
|
5378
|
+
const wordDuration = Math.round(proportion * duration);
|
|
5379
|
+
cursor = wordStart + wordDuration;
|
|
5380
|
+
words.push({ text: wordTexts[i], start: wordStart, end: cursor });
|
|
5381
|
+
}
|
|
5382
|
+
}
|
|
5383
|
+
return words;
|
|
5384
|
+
}
|
|
5385
|
+
function findLineEnd(content, pos) {
|
|
5386
|
+
const idx = content.indexOf("\n", pos);
|
|
5387
|
+
return idx === -1 ? content.length : idx;
|
|
5388
|
+
}
|
|
5389
|
+
function skipWhitespaceAndNewlines(content, pos) {
|
|
5390
|
+
while (pos < content.length) {
|
|
5391
|
+
const ch = content.charCodeAt(pos);
|
|
5392
|
+
if (ch === 10 || ch === 13 || ch === 32 || ch === 9) {
|
|
5393
|
+
pos++;
|
|
5394
|
+
} else {
|
|
5395
|
+
break;
|
|
5396
|
+
}
|
|
5397
|
+
}
|
|
5398
|
+
return pos;
|
|
5399
|
+
}
|
|
5400
|
+
function skipBlock(content, pos) {
|
|
5401
|
+
while (pos < content.length) {
|
|
5402
|
+
const lineEnd = findLineEnd(content, pos);
|
|
5403
|
+
const line = content.substring(pos, lineEnd);
|
|
5404
|
+
pos = lineEnd + 1;
|
|
5405
|
+
if (line.length === 0) break;
|
|
5406
|
+
}
|
|
5407
|
+
return pos;
|
|
5408
|
+
}
|
|
5409
|
+
|
|
5410
|
+
// src/core/video/frame-scheduler.ts
|
|
5411
|
+
var PER_FRAME_ANIMATION_STYLES = /* @__PURE__ */ new Set([
|
|
5412
|
+
"karaoke",
|
|
5413
|
+
"typewriter"
|
|
5414
|
+
]);
|
|
5415
|
+
var TRANSITION_ANIMATION_STYLES = /* @__PURE__ */ new Set([
|
|
5416
|
+
"pop",
|
|
5417
|
+
"fade",
|
|
5418
|
+
"slide",
|
|
5419
|
+
"bounce"
|
|
5420
|
+
]);
|
|
5421
|
+
var ANIMATION_DURATION_MS = {
|
|
5422
|
+
pop: 200,
|
|
5423
|
+
fade: 150,
|
|
5424
|
+
slide: 250,
|
|
5425
|
+
bounce: 400
|
|
5426
|
+
};
|
|
5427
|
+
function findGroupIndexAtTime(groups, timeMs) {
|
|
5428
|
+
for (let i = 0; i < groups.length; i++) {
|
|
5429
|
+
if (timeMs >= groups[i].startTime && timeMs <= groups[i].endTime) {
|
|
5430
|
+
return i;
|
|
5431
|
+
}
|
|
5432
|
+
}
|
|
5433
|
+
return -1;
|
|
5434
|
+
}
|
|
5435
|
+
function findActiveWordIndex(store, groupWordIndices, timeMs) {
|
|
5436
|
+
for (const idx of groupWordIndices) {
|
|
5437
|
+
if (timeMs >= store.startTimes[idx] && timeMs < store.endTimes[idx]) {
|
|
5438
|
+
return idx;
|
|
5439
|
+
}
|
|
5440
|
+
}
|
|
5441
|
+
return -1;
|
|
5442
|
+
}
|
|
5443
|
+
function getAnimationPhase(store, groupWordIndices, timeMs, animationStyle, speed) {
|
|
5444
|
+
if (groupWordIndices.length === 0) {
|
|
5445
|
+
return "idle";
|
|
5446
|
+
}
|
|
5447
|
+
const activeWordIdx = findActiveWordIndex(store, groupWordIndices, timeMs);
|
|
5448
|
+
if (PER_FRAME_ANIMATION_STYLES.has(animationStyle)) {
|
|
5449
|
+
if (activeWordIdx !== -1) {
|
|
5450
|
+
return "animating";
|
|
5451
|
+
}
|
|
5452
|
+
for (const idx of groupWordIndices) {
|
|
5453
|
+
if (timeMs < store.startTimes[idx]) {
|
|
5454
|
+
return "before";
|
|
5455
|
+
}
|
|
5456
|
+
}
|
|
5457
|
+
return "after";
|
|
5458
|
+
}
|
|
5459
|
+
if (TRANSITION_ANIMATION_STYLES.has(animationStyle)) {
|
|
5460
|
+
const transitionDurationMs = (ANIMATION_DURATION_MS[animationStyle] ?? 200) / speed;
|
|
5461
|
+
for (const idx of groupWordIndices) {
|
|
5462
|
+
const wordStart = store.startTimes[idx];
|
|
5463
|
+
if (timeMs >= wordStart && timeMs < wordStart + transitionDurationMs) {
|
|
5464
|
+
return "animating";
|
|
5465
|
+
}
|
|
5466
|
+
}
|
|
5467
|
+
if (activeWordIdx !== -1) {
|
|
5468
|
+
return "active";
|
|
5469
|
+
}
|
|
5470
|
+
for (const idx of groupWordIndices) {
|
|
5471
|
+
if (timeMs < store.startTimes[idx]) {
|
|
5472
|
+
return "before";
|
|
5473
|
+
}
|
|
5474
|
+
}
|
|
5475
|
+
return "after";
|
|
5476
|
+
}
|
|
5477
|
+
if (activeWordIdx !== -1) {
|
|
5478
|
+
return "active";
|
|
5479
|
+
}
|
|
5480
|
+
return "before";
|
|
5481
|
+
}
|
|
5482
|
+
function computeStateSignature(layout, timeMs, animationStyle, speed) {
|
|
5483
|
+
const groupIndex = findGroupIndexAtTime(layout.groups, timeMs);
|
|
5484
|
+
if (groupIndex === -1) {
|
|
5485
|
+
return { groupIndex: -1, activeWordIndex: -1, animationPhase: "idle" };
|
|
5486
|
+
}
|
|
5487
|
+
const group = layout.groups[groupIndex];
|
|
5488
|
+
const activeWordIndex = findActiveWordIndex(layout.store, group.wordIndices, timeMs);
|
|
5489
|
+
const animationPhase = getAnimationPhase(
|
|
5490
|
+
layout.store,
|
|
5491
|
+
group.wordIndices,
|
|
5492
|
+
timeMs,
|
|
5493
|
+
animationStyle,
|
|
5494
|
+
speed
|
|
5495
|
+
);
|
|
5496
|
+
return { groupIndex, activeWordIndex, animationPhase };
|
|
5497
|
+
}
|
|
5498
|
+
function signaturesMatch(a, b) {
|
|
5499
|
+
return a.groupIndex === b.groupIndex && a.activeWordIndex === b.activeWordIndex && a.animationPhase === b.animationPhase;
|
|
5500
|
+
}
|
|
5501
|
+
function createFrameSchedule(layout, durationMs, fps, animationStyle = "highlight", speed = 1) {
|
|
5502
|
+
const totalFrames = Math.max(2, Math.round(durationMs / 1e3 * fps) + 1);
|
|
5503
|
+
const renderFrames = [];
|
|
5504
|
+
let previousSignature = null;
|
|
5505
|
+
for (let frame = 0; frame < totalFrames; frame++) {
|
|
5506
|
+
const timeMs = frame / (totalFrames - 1) * durationMs;
|
|
5507
|
+
const signature = computeStateSignature(layout, timeMs, animationStyle, speed);
|
|
5508
|
+
const isAnimating = signature.animationPhase === "animating";
|
|
5509
|
+
if (isAnimating || previousSignature === null || !signaturesMatch(signature, previousSignature)) {
|
|
5510
|
+
renderFrames.push({
|
|
5511
|
+
frameIndex: frame,
|
|
5512
|
+
repeatCount: 1,
|
|
5513
|
+
timeMs
|
|
5514
|
+
});
|
|
5515
|
+
} else {
|
|
5516
|
+
renderFrames[renderFrames.length - 1].repeatCount++;
|
|
5517
|
+
}
|
|
5518
|
+
previousSignature = signature;
|
|
5519
|
+
}
|
|
5520
|
+
const uniqueFrameCount = renderFrames.length;
|
|
5521
|
+
const skipRatio = 1 - uniqueFrameCount / totalFrames;
|
|
5522
|
+
return {
|
|
5523
|
+
renderFrames,
|
|
5524
|
+
totalFrames,
|
|
5525
|
+
uniqueFrameCount,
|
|
5526
|
+
skipRatio
|
|
5527
|
+
};
|
|
5528
|
+
}
|
|
5529
|
+
|
|
5530
|
+
// src/core/video/node-raw-encoder.ts
|
|
5531
|
+
var import_child_process2 = require("child_process");
|
|
5532
|
+
var import_node_fs2 = __toESM(require("fs"), 1);
|
|
5533
|
+
var NodeRawEncoder = class _NodeRawEncoder {
|
|
5534
|
+
ffmpegPath = null;
|
|
5535
|
+
ffmpegProcess = null;
|
|
5536
|
+
config = null;
|
|
5537
|
+
outputPath = "";
|
|
5538
|
+
frameCount = 0;
|
|
5539
|
+
totalFrames = 0;
|
|
5540
|
+
startTime = 0;
|
|
5541
|
+
chunks = [];
|
|
5542
|
+
outputToMemory = false;
|
|
5543
|
+
ffmpegError = null;
|
|
5544
|
+
static DRAIN_TIMEOUT_MS = 3e4;
|
|
5545
|
+
onProgress;
|
|
5546
|
+
trySetPath(p) {
|
|
5547
|
+
if (p && import_node_fs2.default.existsSync(p)) {
|
|
5548
|
+
this.ffmpegPath = p;
|
|
5549
|
+
return true;
|
|
5550
|
+
}
|
|
5551
|
+
return false;
|
|
5552
|
+
}
|
|
5553
|
+
async initFFmpeg(ffmpegPath) {
|
|
5554
|
+
if (this.trySetPath(ffmpegPath)) return;
|
|
5555
|
+
if (this.trySetPath(process.env.FFMPEG_PATH)) return;
|
|
5556
|
+
if (this.trySetPath(process.env.FFMPEG_BIN)) return;
|
|
5557
|
+
if (this.trySetPath("/opt/bin/ffmpeg")) return;
|
|
5558
|
+
try {
|
|
5559
|
+
const ffmpegStatic = await import("ffmpeg-static");
|
|
5560
|
+
const p = ffmpegStatic.default;
|
|
5561
|
+
if (this.trySetPath(p)) return;
|
|
5562
|
+
} catch {
|
|
5563
|
+
}
|
|
5564
|
+
throw new Error("FFmpeg not available. Please install ffmpeg-static or provide FFMPEG_PATH.");
|
|
5565
|
+
}
|
|
5566
|
+
async configure(config, options) {
|
|
5567
|
+
this.config = config;
|
|
5568
|
+
this.outputPath = options?.outputPath || "";
|
|
5569
|
+
this.outputToMemory = !this.outputPath;
|
|
5570
|
+
this.totalFrames = Math.max(2, Math.round(config.duration * config.fps) + 1);
|
|
5571
|
+
this.frameCount = 0;
|
|
5572
|
+
this.startTime = Date.now();
|
|
5573
|
+
this.chunks = [];
|
|
5574
|
+
this.ffmpegError = null;
|
|
5575
|
+
await this.initFFmpeg(options?.ffmpegPath);
|
|
5576
|
+
const {
|
|
5577
|
+
width,
|
|
5578
|
+
height,
|
|
5579
|
+
fps,
|
|
5580
|
+
crf = 17,
|
|
5581
|
+
preset = "ultrafast",
|
|
5582
|
+
profile = "high"
|
|
5583
|
+
} = config;
|
|
5584
|
+
const args = [
|
|
5585
|
+
"-y",
|
|
5586
|
+
"-f",
|
|
5587
|
+
"rawvideo",
|
|
5588
|
+
"-pix_fmt",
|
|
5589
|
+
"rgba",
|
|
5590
|
+
"-s",
|
|
5591
|
+
`${width}x${height}`,
|
|
5592
|
+
"-r",
|
|
5593
|
+
String(fps),
|
|
5594
|
+
"-thread_queue_size",
|
|
5595
|
+
"512",
|
|
5596
|
+
"-i",
|
|
5597
|
+
"pipe:0",
|
|
5598
|
+
"-c:v",
|
|
5599
|
+
"libx264",
|
|
5600
|
+
"-preset",
|
|
5601
|
+
preset,
|
|
5602
|
+
"-tune",
|
|
5603
|
+
"stillimage",
|
|
5604
|
+
"-crf",
|
|
5605
|
+
String(crf),
|
|
5606
|
+
"-profile:v",
|
|
5607
|
+
profile,
|
|
5608
|
+
"-g",
|
|
5609
|
+
"300",
|
|
5610
|
+
"-bf",
|
|
5611
|
+
"2",
|
|
5612
|
+
"-threads",
|
|
5613
|
+
"0",
|
|
5614
|
+
"-pix_fmt",
|
|
5615
|
+
"yuv420p",
|
|
5616
|
+
"-r",
|
|
5617
|
+
String(fps),
|
|
5618
|
+
"-movflags",
|
|
5619
|
+
"+faststart"
|
|
5620
|
+
];
|
|
5621
|
+
if (this.outputToMemory) {
|
|
5622
|
+
args.push("-f", "mp4", "pipe:1");
|
|
5623
|
+
} else {
|
|
5624
|
+
args.push(this.outputPath);
|
|
5625
|
+
}
|
|
5626
|
+
this.ffmpegProcess = (0, import_child_process2.spawn)(this.ffmpegPath, args, {
|
|
5627
|
+
stdio: ["pipe", this.outputToMemory ? "pipe" : "inherit", "pipe"]
|
|
5628
|
+
});
|
|
5629
|
+
if (this.outputToMemory && this.ffmpegProcess.stdout) {
|
|
5630
|
+
this.ffmpegProcess.stdout.on("data", (chunk) => {
|
|
5631
|
+
this.chunks.push(chunk);
|
|
5632
|
+
});
|
|
5633
|
+
}
|
|
5634
|
+
this.ffmpegProcess.on("error", (err) => {
|
|
5635
|
+
this.ffmpegError = err;
|
|
5636
|
+
});
|
|
5637
|
+
this.ffmpegProcess.stderr?.on("data", () => {
|
|
5638
|
+
});
|
|
5639
|
+
}
|
|
5640
|
+
async encodeFrame(frameData, _frameIndex) {
|
|
5641
|
+
if (this.ffmpegError) {
|
|
5642
|
+
throw this.ffmpegError;
|
|
5643
|
+
}
|
|
5644
|
+
if (!this.ffmpegProcess || !this.ffmpegProcess.stdin) {
|
|
5645
|
+
throw new Error("FFmpeg process not initialized. Call configure() first.");
|
|
5646
|
+
}
|
|
5647
|
+
const buffer = this.toBuffer(frameData);
|
|
5648
|
+
const ok = this.ffmpegProcess.stdin.write(buffer);
|
|
5649
|
+
if (!ok) {
|
|
5650
|
+
await this.waitForDrain();
|
|
5651
|
+
}
|
|
5652
|
+
this.frameCount++;
|
|
5653
|
+
this.reportProgress();
|
|
5654
|
+
}
|
|
5655
|
+
async encodeFrameRepeat(frameData, repeatCount) {
|
|
5656
|
+
if (this.ffmpegError) {
|
|
5657
|
+
throw this.ffmpegError;
|
|
5658
|
+
}
|
|
5659
|
+
if (!this.ffmpegProcess || !this.ffmpegProcess.stdin) {
|
|
5660
|
+
throw new Error("FFmpeg process not initialized. Call configure() first.");
|
|
5661
|
+
}
|
|
5662
|
+
const buffer = this.toBuffer(frameData);
|
|
5663
|
+
for (let i = 0; i < repeatCount; i++) {
|
|
5664
|
+
const ok = this.ffmpegProcess.stdin.write(buffer);
|
|
5665
|
+
if (!ok) {
|
|
5666
|
+
await this.waitForDrain();
|
|
5667
|
+
}
|
|
5668
|
+
this.frameCount++;
|
|
5669
|
+
}
|
|
5670
|
+
this.reportProgress();
|
|
5671
|
+
}
|
|
5672
|
+
async flush() {
|
|
5673
|
+
if (!this.ffmpegProcess) {
|
|
5674
|
+
throw new Error("FFmpeg process not initialized.");
|
|
5675
|
+
}
|
|
5676
|
+
return new Promise((resolve, reject) => {
|
|
5677
|
+
this.ffmpegProcess.on("close", (code) => {
|
|
5678
|
+
if (code === 0) {
|
|
5679
|
+
if (this.outputToMemory) {
|
|
5680
|
+
const result = Buffer.concat(this.chunks);
|
|
5681
|
+
resolve(new Uint8Array(result));
|
|
5682
|
+
} else {
|
|
5683
|
+
const fileBuffer = import_node_fs2.default.readFileSync(this.outputPath);
|
|
5684
|
+
resolve(new Uint8Array(fileBuffer));
|
|
5685
|
+
}
|
|
5686
|
+
} else {
|
|
5687
|
+
reject(new Error(`FFmpeg exited with code ${code}`));
|
|
5688
|
+
}
|
|
5689
|
+
});
|
|
5690
|
+
this.ffmpegProcess.on("error", (err) => {
|
|
5691
|
+
reject(err);
|
|
5692
|
+
});
|
|
5693
|
+
this.ffmpegProcess.stdin?.end();
|
|
5694
|
+
});
|
|
5695
|
+
}
|
|
5696
|
+
close() {
|
|
5697
|
+
if (this.ffmpegProcess) {
|
|
5698
|
+
this.ffmpegProcess.kill("SIGTERM");
|
|
5699
|
+
this.ffmpegProcess = null;
|
|
5700
|
+
}
|
|
5701
|
+
this.chunks = [];
|
|
5702
|
+
}
|
|
5703
|
+
waitForDrain() {
|
|
5704
|
+
return new Promise((resolve, reject) => {
|
|
5705
|
+
const timer = setTimeout(() => {
|
|
5706
|
+
reject(new Error("FFmpeg stdin drain timeout"));
|
|
5707
|
+
}, _NodeRawEncoder.DRAIN_TIMEOUT_MS);
|
|
5708
|
+
const onError = (err) => {
|
|
5709
|
+
clearTimeout(timer);
|
|
5710
|
+
reject(err);
|
|
5711
|
+
};
|
|
5712
|
+
this.ffmpegProcess.once("error", onError);
|
|
5713
|
+
this.ffmpegProcess.stdin.once("drain", () => {
|
|
5714
|
+
clearTimeout(timer);
|
|
5715
|
+
this.ffmpegProcess?.removeListener("error", onError);
|
|
5716
|
+
resolve();
|
|
5717
|
+
});
|
|
5718
|
+
});
|
|
5719
|
+
}
|
|
5720
|
+
toBuffer(frameData) {
|
|
5721
|
+
if (frameData instanceof ArrayBuffer) {
|
|
5722
|
+
return Buffer.from(frameData);
|
|
5723
|
+
}
|
|
5724
|
+
return Buffer.from(frameData.buffer, frameData.byteOffset, frameData.byteLength);
|
|
5725
|
+
}
|
|
5726
|
+
reportProgress() {
|
|
5727
|
+
if (!this.onProgress) return;
|
|
5728
|
+
const elapsedMs = Date.now() - this.startTime;
|
|
5729
|
+
if (elapsedMs === 0) return;
|
|
5730
|
+
const framesPerSecond = this.frameCount / (elapsedMs / 1e3);
|
|
5731
|
+
const remainingFrames = this.totalFrames - this.frameCount;
|
|
5732
|
+
const estimatedRemainingMs = remainingFrames / framesPerSecond * 1e3;
|
|
5733
|
+
this.onProgress({
|
|
5734
|
+
framesEncoded: this.frameCount,
|
|
5735
|
+
totalFrames: this.totalFrames,
|
|
5736
|
+
percentage: this.frameCount / this.totalFrames * 100,
|
|
5737
|
+
elapsedMs,
|
|
5738
|
+
estimatedRemainingMs: Math.round(estimatedRemainingMs),
|
|
5739
|
+
currentFps: Math.round(framesPerSecond * 10) / 10
|
|
5740
|
+
});
|
|
5741
|
+
}
|
|
5742
|
+
};
|
|
5743
|
+
async function createNodeRawEncoder(config, options) {
|
|
5744
|
+
const encoder = new NodeRawEncoder();
|
|
5745
|
+
await encoder.configure(config, options);
|
|
5746
|
+
return encoder;
|
|
5747
|
+
}
|
|
5748
|
+
|
|
5749
|
+
// src/core/rich-caption-renderer.ts
|
|
5750
|
+
var ROBOTO_FONT_URLS = {
|
|
5751
|
+
"100": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbGmT.ttf",
|
|
5752
|
+
"300": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabWmT.ttf",
|
|
5753
|
+
"400": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbWmT.ttf",
|
|
5754
|
+
"500": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bWmT.ttf",
|
|
5755
|
+
"600": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYaammT.ttf",
|
|
5756
|
+
"700": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjammT.ttf",
|
|
5757
|
+
"800": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZEammT.ttf",
|
|
5758
|
+
"900": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtammT.ttf"
|
|
5759
|
+
};
|
|
5760
|
+
var RichCaptionRenderer = class {
|
|
5761
|
+
width;
|
|
5762
|
+
height;
|
|
5763
|
+
pixelRatio;
|
|
5764
|
+
fps;
|
|
5765
|
+
wasmBaseURL;
|
|
5766
|
+
fetchFile;
|
|
5767
|
+
fontRegistry = null;
|
|
5768
|
+
layoutEngine = null;
|
|
5769
|
+
currentAsset = null;
|
|
5770
|
+
currentLayout = null;
|
|
5771
|
+
generatorConfig;
|
|
5772
|
+
frameCount = 0;
|
|
5773
|
+
totalRenderTimeMs = 0;
|
|
5774
|
+
peakMemoryMB = 0;
|
|
5775
|
+
lastMemoryCheckFrame = 0;
|
|
5776
|
+
constructor(options) {
|
|
5777
|
+
this.width = options.width;
|
|
5778
|
+
this.height = options.height;
|
|
5779
|
+
this.pixelRatio = options.pixelRatio ?? 1;
|
|
5780
|
+
this.fps = options.fps ?? 30;
|
|
5781
|
+
this.wasmBaseURL = options.wasmBaseURL;
|
|
5782
|
+
this.fetchFile = options.fetchFile ?? loadFileOrHttpToArrayBuffer;
|
|
5783
|
+
this.generatorConfig = createDefaultGeneratorConfig(this.width, this.height, this.pixelRatio);
|
|
5784
|
+
}
|
|
5785
|
+
async initialize() {
|
|
5786
|
+
this.fontRegistry = await FontRegistry.getSharedInstance(this.wasmBaseURL);
|
|
5787
|
+
this.layoutEngine = new CaptionLayoutEngine(this.fontRegistry);
|
|
5788
|
+
const weightsToLoad = Object.keys(ROBOTO_FONT_URLS);
|
|
5789
|
+
const loadPromises = weightsToLoad.map(async (weight) => {
|
|
5790
|
+
const existingFace = await this.fontRegistry.getFace({ family: "Roboto", weight });
|
|
5791
|
+
if (!existingFace) {
|
|
5792
|
+
const bytes = await loadFileOrHttpToArrayBuffer(ROBOTO_FONT_URLS[weight]);
|
|
5793
|
+
await this.fontRegistry.registerFromBytes(bytes, { family: "Roboto", weight });
|
|
5794
|
+
}
|
|
5795
|
+
});
|
|
5796
|
+
await Promise.all(loadPromises);
|
|
5797
|
+
}
|
|
5798
|
+
async registerFont(source, desc) {
|
|
5799
|
+
if (!this.fontRegistry) {
|
|
5800
|
+
throw new Error("Renderer not initialized. Call initialize() first.");
|
|
5801
|
+
}
|
|
5802
|
+
const bytes = await loadFileOrHttpToArrayBuffer(source);
|
|
5803
|
+
await this.fontRegistry.registerFromBytes(bytes, desc);
|
|
5804
|
+
}
|
|
5805
|
+
async loadAsset(asset) {
|
|
5806
|
+
if (!this.layoutEngine || !this.fontRegistry) {
|
|
5807
|
+
throw new Error("Renderer not initialized. Call initialize() first.");
|
|
5808
|
+
}
|
|
5809
|
+
this.currentAsset = asset;
|
|
5810
|
+
let words;
|
|
5811
|
+
if (asset.src) {
|
|
5812
|
+
const bytes = await this.fetchFile(asset.src);
|
|
5813
|
+
const text = new TextDecoder().decode(bytes);
|
|
5814
|
+
words = parseSubtitleToWords(text);
|
|
5815
|
+
} else {
|
|
5816
|
+
words = (asset.words ?? []).map((w) => ({
|
|
5817
|
+
text: w.text,
|
|
5818
|
+
start: w.start,
|
|
5819
|
+
end: w.end,
|
|
5820
|
+
confidence: w.confidence
|
|
5821
|
+
}));
|
|
5822
|
+
}
|
|
5823
|
+
if (words.length === 0) {
|
|
5824
|
+
this.currentLayout = null;
|
|
5825
|
+
return;
|
|
5826
|
+
}
|
|
5827
|
+
const font = asset.font;
|
|
5828
|
+
const style = asset.style;
|
|
5829
|
+
const measureTextWidth = await createCanvasTextMeasurer();
|
|
5830
|
+
const layoutConfig = {
|
|
5831
|
+
frameWidth: this.width,
|
|
5832
|
+
frameHeight: this.height,
|
|
5833
|
+
maxWidth: asset.maxWidth ?? 0.9,
|
|
5834
|
+
maxLines: asset.maxLines ?? 2,
|
|
5835
|
+
position: asset.position ?? "bottom",
|
|
5836
|
+
fontSize: font?.size ?? 24,
|
|
5837
|
+
fontFamily: font?.family ?? "Roboto",
|
|
5838
|
+
fontWeight: String(font?.weight ?? "400"),
|
|
5839
|
+
letterSpacing: style?.letterSpacing ?? 0,
|
|
5840
|
+
wordSpacing: typeof style?.wordSpacing === "number" ? style.wordSpacing : 0,
|
|
5841
|
+
lineHeight: style?.lineHeight ?? 1.2,
|
|
5842
|
+
textTransform: style?.textTransform ?? "none",
|
|
5843
|
+
pauseThreshold: 500,
|
|
5844
|
+
measureTextWidth
|
|
5845
|
+
};
|
|
5846
|
+
this.currentLayout = await this.layoutEngine.layoutCaption(words, layoutConfig);
|
|
5847
|
+
}
|
|
5848
|
+
renderFrame(timeMs) {
|
|
5849
|
+
if (!this.currentAsset || !this.currentLayout || !this.layoutEngine) {
|
|
5850
|
+
return [];
|
|
5851
|
+
}
|
|
5852
|
+
const startTime = performance.now();
|
|
5853
|
+
const ops = generateRichCaptionDrawOps(
|
|
5854
|
+
this.currentAsset,
|
|
5855
|
+
this.currentLayout,
|
|
5856
|
+
timeMs,
|
|
5857
|
+
this.layoutEngine,
|
|
5858
|
+
this.generatorConfig
|
|
5859
|
+
);
|
|
5860
|
+
const endTime = performance.now();
|
|
5861
|
+
this.totalRenderTimeMs += endTime - startTime;
|
|
5862
|
+
this.frameCount++;
|
|
5863
|
+
if (this.frameCount - this.lastMemoryCheckFrame >= 1e3) {
|
|
5864
|
+
this.checkMemoryUsage();
|
|
5865
|
+
this.lastMemoryCheckFrame = this.frameCount;
|
|
5866
|
+
}
|
|
5867
|
+
return ops;
|
|
5868
|
+
}
|
|
5869
|
+
async generateVideo(outputPath, duration, options) {
|
|
5870
|
+
if (!this.currentAsset || !this.currentLayout) {
|
|
5871
|
+
throw new Error("No asset loaded. Call loadAsset() first.");
|
|
5872
|
+
}
|
|
5873
|
+
const animationStyle = this.extractAnimationStyle();
|
|
5874
|
+
const animationSpeed = this.extractAnimationSpeed();
|
|
5875
|
+
const durationMs = duration * 1e3;
|
|
5876
|
+
const schedule = createFrameSchedule(
|
|
5877
|
+
this.currentLayout,
|
|
5878
|
+
durationMs,
|
|
5879
|
+
this.fps,
|
|
5880
|
+
animationStyle,
|
|
5881
|
+
animationSpeed
|
|
5882
|
+
);
|
|
5883
|
+
const encoder = new NodeRawEncoder();
|
|
5884
|
+
await encoder.configure(
|
|
5885
|
+
{
|
|
5886
|
+
width: this.width * this.pixelRatio,
|
|
5887
|
+
height: this.height * this.pixelRatio,
|
|
5888
|
+
fps: this.fps,
|
|
5889
|
+
duration,
|
|
5890
|
+
crf: options?.crf ?? 23,
|
|
5891
|
+
preset: options?.preset ?? "ultrafast",
|
|
5892
|
+
profile: options?.profile ?? "high"
|
|
5893
|
+
},
|
|
5894
|
+
{
|
|
5895
|
+
outputPath,
|
|
5896
|
+
ffmpegPath: options?.ffmpegPath
|
|
5897
|
+
}
|
|
5898
|
+
);
|
|
5899
|
+
const painter = await createNodePainter({
|
|
5900
|
+
width: this.width,
|
|
5901
|
+
height: this.height,
|
|
5902
|
+
pixelRatio: this.pixelRatio
|
|
5903
|
+
});
|
|
5904
|
+
const bgColor = options?.bgColor ?? "#000000";
|
|
5905
|
+
const totalStart = performance.now();
|
|
5906
|
+
let framesProcessed = 0;
|
|
5907
|
+
let lastPct = -1;
|
|
5908
|
+
try {
|
|
5909
|
+
for (let i = 0; i < schedule.renderFrames.length; i++) {
|
|
5910
|
+
const renderFrame = schedule.renderFrames[i];
|
|
5911
|
+
const captionOps = this.renderFrame(renderFrame.timeMs);
|
|
5912
|
+
const beginOp = {
|
|
5913
|
+
op: "BeginFrame",
|
|
5914
|
+
width: this.width * this.pixelRatio,
|
|
5915
|
+
height: this.height * this.pixelRatio,
|
|
5916
|
+
pixelRatio: this.pixelRatio,
|
|
5917
|
+
clear: true,
|
|
5918
|
+
bg: { color: bgColor, opacity: 1, radius: 0 }
|
|
5919
|
+
};
|
|
5920
|
+
await painter.render([beginOp, ...captionOps]);
|
|
5921
|
+
const rawResult = painter.toRawRGBA();
|
|
5922
|
+
await encoder.encodeFrameRepeat(rawResult.data, renderFrame.repeatCount);
|
|
5923
|
+
framesProcessed += renderFrame.repeatCount;
|
|
5924
|
+
const pct = Math.floor(framesProcessed / schedule.totalFrames * 100);
|
|
5925
|
+
if (pct % 5 === 0 && pct !== lastPct) {
|
|
5926
|
+
lastPct = pct;
|
|
5927
|
+
const elapsed = performance.now() - totalStart;
|
|
5928
|
+
const fps = framesProcessed / (elapsed / 1e3);
|
|
5929
|
+
const eta = (schedule.totalFrames - framesProcessed) / fps * 1e3;
|
|
5930
|
+
this.logProgress(pct, framesProcessed, schedule.totalFrames, i + 1, schedule.uniqueFrameCount, fps, eta);
|
|
5931
|
+
}
|
|
5932
|
+
if (i % 500 === 0 && i > 0) {
|
|
5933
|
+
this.checkMemoryUsage();
|
|
5934
|
+
if (typeof global !== "undefined" && global.gc) {
|
|
5935
|
+
global.gc();
|
|
5936
|
+
}
|
|
5937
|
+
}
|
|
5938
|
+
}
|
|
5939
|
+
await encoder.flush();
|
|
5940
|
+
const totalTimeMs = performance.now() - totalStart;
|
|
5941
|
+
const realtimeMultiplier = duration / (totalTimeMs / 1e3);
|
|
5942
|
+
this.logCompletion(totalTimeMs, realtimeMultiplier);
|
|
5943
|
+
return outputPath;
|
|
5944
|
+
} catch (error) {
|
|
5945
|
+
encoder.close();
|
|
5946
|
+
throw error;
|
|
5947
|
+
}
|
|
5948
|
+
}
|
|
5949
|
+
async generateVideoLegacy(outputPath, duration, options) {
|
|
5950
|
+
if (!this.currentAsset || !this.currentLayout) {
|
|
5951
|
+
throw new Error("No asset loaded. Call loadAsset() first.");
|
|
5952
|
+
}
|
|
5953
|
+
const videoGenerator = new VideoGenerator();
|
|
5954
|
+
const frameGenerator = async (timeSeconds) => {
|
|
5955
|
+
const timeMs = timeSeconds * 1e3;
|
|
5956
|
+
const ops = this.renderFrame(timeMs);
|
|
5957
|
+
const beginFrameOp = {
|
|
5958
|
+
op: "BeginFrame",
|
|
5959
|
+
width: this.width * this.pixelRatio,
|
|
5960
|
+
height: this.height * this.pixelRatio,
|
|
5961
|
+
pixelRatio: this.pixelRatio,
|
|
5962
|
+
clear: true,
|
|
5963
|
+
bg: {
|
|
5964
|
+
color: options?.bgColor ?? "#000000",
|
|
5965
|
+
opacity: 1,
|
|
5966
|
+
radius: 0
|
|
5967
|
+
}
|
|
5968
|
+
};
|
|
5969
|
+
return [beginFrameOp, ...ops];
|
|
5970
|
+
};
|
|
5971
|
+
const videoOptions = {
|
|
5972
|
+
width: this.width,
|
|
5973
|
+
height: this.height,
|
|
5974
|
+
fps: this.fps,
|
|
5975
|
+
duration,
|
|
5976
|
+
outputPath,
|
|
5977
|
+
pixelRatio: this.pixelRatio,
|
|
5978
|
+
hasAlpha: false,
|
|
5979
|
+
...options
|
|
5980
|
+
};
|
|
5981
|
+
return videoGenerator.generateVideo(frameGenerator, videoOptions);
|
|
5982
|
+
}
|
|
5983
|
+
async generateVideoWithChunking(outputPath, duration, options) {
|
|
5984
|
+
if (!this.currentAsset || !this.currentLayout) {
|
|
5985
|
+
throw new Error("No asset loaded. Call loadAsset() first.");
|
|
5986
|
+
}
|
|
5987
|
+
const videoGenerator = new VideoGenerator();
|
|
5988
|
+
const chunkSize = 1e3;
|
|
5989
|
+
let processedFrames = 0;
|
|
5990
|
+
const frameGenerator = async (timeSeconds) => {
|
|
5991
|
+
const timeMs = timeSeconds * 1e3;
|
|
5992
|
+
const ops = this.renderFrame(timeMs);
|
|
5993
|
+
processedFrames++;
|
|
5994
|
+
if (processedFrames % chunkSize === 0) {
|
|
5995
|
+
this.checkMemoryUsage();
|
|
5996
|
+
if (typeof global !== "undefined" && global.gc) {
|
|
5997
|
+
global.gc();
|
|
5998
|
+
}
|
|
5999
|
+
}
|
|
6000
|
+
const beginFrameOp = {
|
|
6001
|
+
op: "BeginFrame",
|
|
6002
|
+
width: this.width * this.pixelRatio,
|
|
6003
|
+
height: this.height * this.pixelRatio,
|
|
6004
|
+
pixelRatio: this.pixelRatio,
|
|
6005
|
+
clear: true,
|
|
6006
|
+
bg: {
|
|
6007
|
+
color: options?.bgColor ?? "#000000",
|
|
6008
|
+
opacity: 1,
|
|
6009
|
+
radius: 0
|
|
6010
|
+
}
|
|
6011
|
+
};
|
|
6012
|
+
return [beginFrameOp, ...ops];
|
|
6013
|
+
};
|
|
6014
|
+
const videoOptions = {
|
|
6015
|
+
width: this.width,
|
|
6016
|
+
height: this.height,
|
|
6017
|
+
fps: this.fps,
|
|
6018
|
+
duration,
|
|
6019
|
+
outputPath,
|
|
6020
|
+
pixelRatio: this.pixelRatio,
|
|
6021
|
+
hasAlpha: false,
|
|
6022
|
+
...options
|
|
6023
|
+
};
|
|
6024
|
+
return videoGenerator.generateVideo(frameGenerator, videoOptions);
|
|
6025
|
+
}
|
|
6026
|
+
getFrameSchedule(duration) {
|
|
6027
|
+
if (!this.currentLayout) {
|
|
6028
|
+
throw new Error("No asset loaded. Call loadAsset() first.");
|
|
6029
|
+
}
|
|
6030
|
+
const animationStyle = this.extractAnimationStyle();
|
|
6031
|
+
const animationSpeed = this.extractAnimationSpeed();
|
|
6032
|
+
return createFrameSchedule(
|
|
6033
|
+
this.currentLayout,
|
|
6034
|
+
duration * 1e3,
|
|
6035
|
+
this.fps,
|
|
6036
|
+
animationStyle,
|
|
6037
|
+
animationSpeed
|
|
6038
|
+
);
|
|
6039
|
+
}
|
|
6040
|
+
getStats() {
|
|
6041
|
+
const cacheStats = this.layoutEngine?.getCacheStats() ?? { size: 0, calculatedSize: 0 };
|
|
6042
|
+
return {
|
|
6043
|
+
frameCount: this.frameCount,
|
|
6044
|
+
totalRenderTimeMs: this.totalRenderTimeMs,
|
|
6045
|
+
averageFrameTimeMs: this.frameCount > 0 ? this.totalRenderTimeMs / this.frameCount : 0,
|
|
6046
|
+
peakMemoryMB: this.peakMemoryMB,
|
|
6047
|
+
cacheHitRate: cacheStats.size > 0 ? 0.95 : 0
|
|
6048
|
+
};
|
|
6049
|
+
}
|
|
6050
|
+
resetStats() {
|
|
6051
|
+
this.frameCount = 0;
|
|
6052
|
+
this.totalRenderTimeMs = 0;
|
|
6053
|
+
this.peakMemoryMB = 0;
|
|
6054
|
+
this.lastMemoryCheckFrame = 0;
|
|
6055
|
+
}
|
|
6056
|
+
clearCache() {
|
|
6057
|
+
this.layoutEngine?.clearCache();
|
|
6058
|
+
}
|
|
6059
|
+
extractAnimationStyle() {
|
|
6060
|
+
const wordAnim = this.currentAsset?.wordAnimation;
|
|
6061
|
+
return wordAnim?.style ?? "highlight";
|
|
6062
|
+
}
|
|
6063
|
+
extractAnimationSpeed() {
|
|
6064
|
+
const wordAnim = this.currentAsset?.wordAnimation;
|
|
6065
|
+
return wordAnim?.speed ?? 1;
|
|
6066
|
+
}
|
|
6067
|
+
logProgress(pct, framesProcessed, totalFrames, uniqueProcessed, uniqueTotal, fps, eta) {
|
|
6068
|
+
if (typeof process !== "undefined" && process.stderr) {
|
|
6069
|
+
process.stderr.write(
|
|
6070
|
+
` [${String(pct).padStart(3)}%] Frame ${framesProcessed}/${totalFrames} (${uniqueProcessed}/${uniqueTotal} unique) | ${fps.toFixed(1)} fps | ETA: ${formatMs(eta)}
|
|
6071
|
+
`
|
|
6072
|
+
);
|
|
6073
|
+
}
|
|
6074
|
+
}
|
|
6075
|
+
logCompletion(totalTimeMs, realtimeMultiplier) {
|
|
6076
|
+
if (typeof process !== "undefined" && process.stderr) {
|
|
6077
|
+
process.stderr.write(
|
|
6078
|
+
` Done: ${formatMs(totalTimeMs)} (${realtimeMultiplier.toFixed(1)}x realtime)
|
|
6079
|
+
`
|
|
6080
|
+
);
|
|
6081
|
+
}
|
|
6082
|
+
}
|
|
6083
|
+
checkMemoryUsage() {
|
|
6084
|
+
if (typeof process !== "undefined" && process.memoryUsage) {
|
|
6085
|
+
const usage = process.memoryUsage();
|
|
6086
|
+
const heapUsedMB = usage.heapUsed / (1024 * 1024);
|
|
6087
|
+
if (heapUsedMB > this.peakMemoryMB) {
|
|
6088
|
+
this.peakMemoryMB = heapUsedMB;
|
|
6089
|
+
}
|
|
6090
|
+
if (usage.heapUsed > 1500 * 1024 * 1024) {
|
|
6091
|
+
if (typeof global !== "undefined" && global.gc) {
|
|
6092
|
+
global.gc();
|
|
6093
|
+
}
|
|
6094
|
+
}
|
|
6095
|
+
}
|
|
6096
|
+
}
|
|
6097
|
+
destroy() {
|
|
6098
|
+
this.currentAsset = null;
|
|
6099
|
+
this.currentLayout = null;
|
|
6100
|
+
this.layoutEngine?.clearCache();
|
|
6101
|
+
if (this.fontRegistry) {
|
|
6102
|
+
this.fontRegistry.release();
|
|
6103
|
+
this.fontRegistry = null;
|
|
6104
|
+
}
|
|
6105
|
+
this.layoutEngine = null;
|
|
6106
|
+
}
|
|
6107
|
+
};
|
|
6108
|
+
function formatMs(ms) {
|
|
6109
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
6110
|
+
if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
|
|
6111
|
+
return `${Math.floor(ms / 6e4)}m ${(ms % 6e4 / 1e3).toFixed(0)}s`;
|
|
6112
|
+
}
|
|
6113
|
+
async function createRichCaptionRenderer(options) {
|
|
6114
|
+
const renderer = new RichCaptionRenderer(options);
|
|
6115
|
+
await renderer.initialize();
|
|
6116
|
+
return renderer;
|
|
6117
|
+
}
|
|
6118
|
+
|
|
6119
|
+
// src/core/video/encoder-factory.ts
|
|
6120
|
+
async function createVideoEncoder(config, options) {
|
|
6121
|
+
const platform = options?.platform ?? detectPlatform();
|
|
6122
|
+
if (platform === "node") {
|
|
6123
|
+
throw new Error("Use createNodeRawEncoder from node-raw-encoder module for Node.js encoding");
|
|
6124
|
+
}
|
|
6125
|
+
if (options?.preferredEncoder === "mediarecorder") {
|
|
6126
|
+
return createMediaRecorderEncoder(config, options?.canvas);
|
|
6127
|
+
}
|
|
6128
|
+
const webCodecsSupported = await isWebCodecsH264Supported();
|
|
6129
|
+
if (webCodecsSupported) {
|
|
6130
|
+
try {
|
|
6131
|
+
const { WebCodecsEncoder: WebCodecsEncoder2 } = await Promise.resolve().then(() => (init_web_encoder(), web_encoder_exports));
|
|
6132
|
+
const encoder = new WebCodecsEncoder2();
|
|
6133
|
+
await encoder.configure(config);
|
|
6134
|
+
return encoder;
|
|
6135
|
+
} catch (error) {
|
|
6136
|
+
console.warn("WebCodecs encoder failed to initialize, falling back to MediaRecorder:", error);
|
|
6137
|
+
}
|
|
6138
|
+
}
|
|
6139
|
+
return createMediaRecorderEncoder(config, options?.canvas);
|
|
6140
|
+
}
|
|
6141
|
+
async function createMediaRecorderEncoder(config, canvas) {
|
|
6142
|
+
const { MediaRecorderFallback: MediaRecorderFallback2 } = await Promise.resolve().then(() => (init_mediarecorder_fallback(), mediarecorder_fallback_exports));
|
|
6143
|
+
const encoder = new MediaRecorderFallback2();
|
|
6144
|
+
await encoder.configure(config, canvas);
|
|
6145
|
+
return encoder;
|
|
6146
|
+
}
|
|
6147
|
+
async function isWebCodecsH264Supported() {
|
|
6148
|
+
if (typeof globalThis === "undefined") return false;
|
|
6149
|
+
const VideoEncoder2 = globalThis.VideoEncoder;
|
|
6150
|
+
if (!VideoEncoder2 || typeof VideoEncoder2.isConfigSupported !== "function") {
|
|
6151
|
+
return false;
|
|
6152
|
+
}
|
|
6153
|
+
try {
|
|
6154
|
+
const config = {
|
|
6155
|
+
codec: "avc1.42001E",
|
|
6156
|
+
width: 1920,
|
|
6157
|
+
height: 1080,
|
|
6158
|
+
bitrate: 8e6,
|
|
6159
|
+
framerate: 30
|
|
6160
|
+
};
|
|
6161
|
+
const support = await VideoEncoder2.isConfigSupported(config);
|
|
6162
|
+
return support.supported === true;
|
|
6163
|
+
} catch {
|
|
6164
|
+
return false;
|
|
6165
|
+
}
|
|
6166
|
+
}
|
|
6167
|
+
async function getEncoderCapabilities() {
|
|
6168
|
+
const platform = detectPlatform();
|
|
6169
|
+
if (platform === "node") {
|
|
6170
|
+
return {
|
|
6171
|
+
encoder: "node-raw",
|
|
6172
|
+
codec: "h264",
|
|
6173
|
+
hardwareAccelerated: false,
|
|
6174
|
+
supportsH264: true
|
|
6175
|
+
};
|
|
6176
|
+
}
|
|
6177
|
+
const webCodecsSupported = await isWebCodecsH264Supported();
|
|
6178
|
+
if (webCodecsSupported) {
|
|
6179
|
+
return {
|
|
6180
|
+
encoder: "webcodecs",
|
|
6181
|
+
codec: "h264",
|
|
6182
|
+
hardwareAccelerated: true,
|
|
6183
|
+
supportsH264: true
|
|
6184
|
+
};
|
|
6185
|
+
}
|
|
6186
|
+
return {
|
|
6187
|
+
encoder: "mediarecorder",
|
|
6188
|
+
codec: "vp9",
|
|
6189
|
+
hardwareAccelerated: false,
|
|
6190
|
+
supportsH264: false
|
|
6191
|
+
};
|
|
6192
|
+
}
|
|
6193
|
+
function detectPlatform() {
|
|
6194
|
+
if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
|
|
6195
|
+
return "node";
|
|
6196
|
+
}
|
|
6197
|
+
return "web";
|
|
6198
|
+
}
|
|
6199
|
+
function getEncoderWarning() {
|
|
6200
|
+
const platform = detectPlatform();
|
|
6201
|
+
if (platform === "node") return null;
|
|
6202
|
+
if (typeof globalThis !== "undefined") {
|
|
6203
|
+
const VideoEncoder2 = globalThis.VideoEncoder;
|
|
6204
|
+
if (!VideoEncoder2) {
|
|
6205
|
+
return "Your browser doesn't support fast H.264 encoding (WebCodecs). Using real-time recording with WebM format instead. For best performance, use Chrome 94+, Edge 94+, or Safari 16.4+.";
|
|
6206
|
+
}
|
|
6207
|
+
}
|
|
6208
|
+
return null;
|
|
6209
|
+
}
|
|
6210
|
+
|
|
6211
|
+
// src/env/entry.node.ts
|
|
6212
|
+
var registeredGlobalFonts = /* @__PURE__ */ new Set();
|
|
6213
|
+
async function registerColorEmojiWithCanvas(family, bytes) {
|
|
6214
|
+
if (registeredGlobalFonts.has(family)) {
|
|
6215
|
+
return;
|
|
6216
|
+
}
|
|
6217
|
+
try {
|
|
6218
|
+
const canvasMod = await import("canvas");
|
|
6219
|
+
const GlobalFonts = canvasMod.GlobalFonts;
|
|
6220
|
+
if (GlobalFonts && typeof GlobalFonts.register === "function") {
|
|
6221
|
+
const buffer = Buffer.from(bytes);
|
|
6222
|
+
GlobalFonts.register(buffer, family);
|
|
6223
|
+
registeredGlobalFonts.add(family);
|
|
6224
|
+
console.log(`\u{1F3A8} Registered color emoji font with canvas: ${family}`);
|
|
6225
|
+
}
|
|
6226
|
+
} catch (err) {
|
|
6227
|
+
console.warn(`\u26A0\uFE0F Could not register color emoji font with canvas GlobalFonts: ${err}`);
|
|
6228
|
+
}
|
|
6229
|
+
}
|
|
6230
|
+
async function createTextEngine(opts = {}) {
|
|
6231
|
+
const width = opts.width ?? CANVAS_CONFIG.DEFAULTS.width;
|
|
6232
|
+
const height = opts.height ?? CANVAS_CONFIG.DEFAULTS.height;
|
|
6233
|
+
const pixelRatio = opts.pixelRatio ?? CANVAS_CONFIG.DEFAULTS.pixelRatio;
|
|
6234
|
+
const fps = opts.fps ?? 30;
|
|
6235
|
+
const wasmBaseURL = opts.wasmBaseURL;
|
|
6236
|
+
let fonts;
|
|
6237
|
+
try {
|
|
6238
|
+
fonts = await FontRegistry.getSharedInstance(wasmBaseURL);
|
|
6239
|
+
} catch (err) {
|
|
6240
|
+
throw new Error(
|
|
6241
|
+
`Failed to initialize font registry: ${err instanceof Error ? err.message : String(err)}`
|
|
6242
|
+
);
|
|
6243
|
+
}
|
|
6244
|
+
const layout = new LayoutEngine(fonts);
|
|
6245
|
+
const videoGenerator = new VideoGenerator();
|
|
6246
|
+
const customFontLoadCache = /* @__PURE__ */ new Map();
|
|
6247
|
+
const directRegistrationCache = /* @__PURE__ */ new Map();
|
|
6248
|
+
const normalizeWeight2 = (weight) => `${weight ?? "400"}`;
|
|
6249
|
+
const fontDescKey = (family, weight) => `${family}__${normalizeWeight2(weight)}`;
|
|
6250
|
+
const registerFontFromSource = async (source, desc) => {
|
|
6251
|
+
const normalizedDesc = {
|
|
6252
|
+
family: desc.family,
|
|
6253
|
+
weight: normalizeWeight2(desc.weight)
|
|
6254
|
+
};
|
|
6255
|
+
const cacheKey = `${source}|${fontDescKey(normalizedDesc.family, normalizedDesc.weight)}`;
|
|
6256
|
+
const cached = directRegistrationCache.get(cacheKey);
|
|
6257
|
+
if (cached) {
|
|
6258
|
+
return cached;
|
|
6259
|
+
}
|
|
6260
|
+
const registrationTask = (async () => {
|
|
6261
|
+
if (fonts.hasRegisteredFace(normalizedDesc)) {
|
|
6262
|
+
return;
|
|
6263
|
+
}
|
|
6264
|
+
const bytes = await loadFileOrHttpToArrayBuffer(source);
|
|
6265
|
+
await fonts.registerFromBytes(bytes, normalizedDesc);
|
|
6266
|
+
})();
|
|
6267
|
+
directRegistrationCache.set(cacheKey, registrationTask);
|
|
6268
|
+
return registrationTask;
|
|
6269
|
+
};
|
|
6270
|
+
const ensureCustomFontLoaded = async (customFont) => {
|
|
6271
|
+
const normalizedDesc = {
|
|
6272
|
+
family: customFont.family,
|
|
6273
|
+
weight: normalizeWeight2(customFont.weight)
|
|
6274
|
+
};
|
|
6275
|
+
const cacheKey = `${customFont.src}|${fontDescKey(normalizedDesc.family, normalizedDesc.weight)}`;
|
|
6276
|
+
const cached = customFontLoadCache.get(cacheKey);
|
|
6277
|
+
if (cached) {
|
|
6278
|
+
return cached;
|
|
6279
|
+
}
|
|
6280
|
+
const loadTask = (async () => {
|
|
6281
|
+
if (fonts.hasRegisteredFace(normalizedDesc)) {
|
|
6282
|
+
return;
|
|
6283
|
+
}
|
|
6284
|
+
const bytes = await loadFileOrHttpToArrayBuffer(customFont.src);
|
|
6285
|
+
await fonts.registerFromBytes(bytes, normalizedDesc);
|
|
6286
|
+
})();
|
|
6287
|
+
customFontLoadCache.set(cacheKey, loadTask);
|
|
6288
|
+
return loadTask;
|
|
6289
|
+
};
|
|
6290
|
+
async function ensureFonts(asset) {
|
|
6291
|
+
try {
|
|
6292
|
+
if (asset.customFonts) {
|
|
6293
|
+
for (const cf of asset.customFonts) {
|
|
6294
|
+
try {
|
|
6295
|
+
await ensureCustomFontLoaded(cf);
|
|
3718
6296
|
if (fonts.isColorEmojiFont(cf.family)) {
|
|
3719
6297
|
const emojiBytes = fonts.getColorEmojiFontBytes(cf.family);
|
|
3720
6298
|
if (emojiBytes) {
|
|
@@ -3754,8 +6332,7 @@ async function createTextEngine(opts = {}) {
|
|
|
3754
6332
|
},
|
|
3755
6333
|
async registerFontFromFile(path, desc) {
|
|
3756
6334
|
try {
|
|
3757
|
-
|
|
3758
|
-
await fonts.registerFromBytes(bytes, desc);
|
|
6335
|
+
await registerFontFromSource(path, desc);
|
|
3759
6336
|
} catch (err) {
|
|
3760
6337
|
throw new Error(
|
|
3761
6338
|
`Failed to register font "${desc.family}" from file ${path}: ${err instanceof Error ? err.message : String(err)}`
|
|
@@ -3764,8 +6341,7 @@ async function createTextEngine(opts = {}) {
|
|
|
3764
6341
|
},
|
|
3765
6342
|
async registerFontFromUrl(url, desc) {
|
|
3766
6343
|
try {
|
|
3767
|
-
|
|
3768
|
-
await fonts.registerFromBytes(bytes, desc);
|
|
6344
|
+
await registerFontFromSource(url, desc);
|
|
3769
6345
|
} catch (err) {
|
|
3770
6346
|
throw new Error(
|
|
3771
6347
|
`Failed to register font "${desc.family}" from URL ${url}: ${err instanceof Error ? err.message : String(err)}`
|
|
@@ -3951,22 +6527,47 @@ async function createTextEngine(opts = {}) {
|
|
|
3951
6527
|
}
|
|
3952
6528
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3953
6529
|
0 && (module.exports = {
|
|
6530
|
+
CanvasRichCaptionAssetSchema,
|
|
3954
6531
|
CanvasRichTextAssetSchema,
|
|
3955
6532
|
CanvasSvgAssetSchema,
|
|
6533
|
+
CaptionLayoutEngine,
|
|
6534
|
+
FontRegistry,
|
|
6535
|
+
NodeRawEncoder,
|
|
6536
|
+
RichCaptionRenderer,
|
|
6537
|
+
WordTimingStore,
|
|
3956
6538
|
arcToCubicBeziers,
|
|
6539
|
+
calculateAnimationStatesForGroup,
|
|
3957
6540
|
commandsToPathString,
|
|
3958
6541
|
computeSimplePathBounds,
|
|
6542
|
+
createDefaultGeneratorConfig,
|
|
6543
|
+
createFrameSchedule,
|
|
3959
6544
|
createNodePainter,
|
|
6545
|
+
createNodeRawEncoder,
|
|
6546
|
+
createRichCaptionRenderer,
|
|
3960
6547
|
createTextEngine,
|
|
6548
|
+
createVideoEncoder,
|
|
6549
|
+
detectPlatform,
|
|
6550
|
+
detectSubtitleFormat,
|
|
6551
|
+
findWordAtTime,
|
|
6552
|
+
generateRichCaptionDrawOps,
|
|
6553
|
+
generateRichCaptionFrame,
|
|
3961
6554
|
generateShapePathData,
|
|
3962
|
-
|
|
3963
|
-
|
|
6555
|
+
getDefaultAnimationConfig,
|
|
6556
|
+
getDrawCaptionWordOps,
|
|
6557
|
+
getEncoderCapabilities,
|
|
6558
|
+
getEncoderWarning,
|
|
6559
|
+
groupWordsByPause,
|
|
6560
|
+
isDrawCaptionWordOp,
|
|
6561
|
+
isRTLText,
|
|
6562
|
+
isWebCodecsH264Supported,
|
|
3964
6563
|
normalizePath,
|
|
3965
6564
|
normalizePathString,
|
|
6565
|
+
parseSubtitleToWords,
|
|
3966
6566
|
parseSvgPath,
|
|
3967
6567
|
quadraticToCubic,
|
|
3968
6568
|
renderSvgAssetToPng,
|
|
3969
6569
|
renderSvgToPng,
|
|
6570
|
+
richCaptionAssetSchema,
|
|
3970
6571
|
shapeToSvgString,
|
|
3971
6572
|
svgAssetSchema,
|
|
3972
6573
|
svgGradientStopSchema,
|