@shotstack/shotstack-canvas 1.9.6 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,10 @@ import {
17
17
  svgStrokeSchema,
18
18
  svgShadowSchema,
19
19
  svgTransformSchema,
20
- svgGradientStopSchema
20
+ svgGradientStopSchema,
21
+ richCaptionActiveSchema as baseCaptionActiveSchema,
22
+ richCaptionWordAnimationSchema as baseCaptionWordAnimationSchema,
23
+ wordTimingSchema as baseWordTimingSchema
21
24
  } from "@shotstack/schemas/zod";
22
25
 
23
26
  // src/config/canvas-constants.ts
@@ -28,7 +31,7 @@ var CANVAS_CONFIG = {
28
31
  pixelRatio: 2,
29
32
  fontFamily: "Roboto",
30
33
  fontSize: 48,
31
- color: "#000000",
34
+ color: "#ffffff",
32
35
  textAlign: "center"
33
36
  },
34
37
  LIMITS: {
@@ -174,6 +177,71 @@ var CanvasRichTextAssetSchema = richTextAssetSchema.extend({
174
177
  customFonts: z.array(customFontSchema).optional()
175
178
  }).strict();
176
179
  var CanvasSvgAssetSchema = svgAssetSchema;
180
+ var wordTimingSchema = baseWordTimingSchema.extend({
181
+ text: z.string().min(1),
182
+ start: z.number().min(0),
183
+ end: z.number().min(0),
184
+ confidence: z.number().min(0).max(1).optional()
185
+ });
186
+ var richCaptionFontSchema = z.object({
187
+ family: z.string().default("Open Sans"),
188
+ size: z.number().int().min(1).max(500).default(24),
189
+ weight: z.union([z.string(), z.number()]).default("400"),
190
+ color: z.string().regex(HEX6).default("#ffffff"),
191
+ opacity: z.number().min(0).max(1).default(1),
192
+ background: z.string().regex(HEX6).optional()
193
+ });
194
+ var richCaptionActiveSchema = baseCaptionActiveSchema.extend({
195
+ font: z.object({
196
+ color: z.string().regex(HEX6).default("#ffff00"),
197
+ background: z.string().regex(HEX6).optional(),
198
+ opacity: z.number().min(0).max(1).default(1)
199
+ }).optional(),
200
+ stroke: z.object({
201
+ width: z.number().min(0).optional(),
202
+ color: z.string().regex(HEX6).optional(),
203
+ opacity: z.number().min(0).max(1).optional()
204
+ }).optional(),
205
+ scale: z.number().min(0.5).max(2).default(1)
206
+ });
207
+ var richCaptionWordAnimationSchema = baseCaptionWordAnimationSchema.extend({
208
+ style: z.enum(["karaoke", "highlight", "pop", "fade", "slide", "bounce", "typewriter", "none"]).default("highlight"),
209
+ speed: z.number().min(0.5).max(2).default(1),
210
+ direction: z.enum(["left", "right", "up", "down"]).default("up")
211
+ });
212
+ var richCaptionAssetSchema = z.object({
213
+ type: z.literal("rich-caption"),
214
+ src: z.string().min(1).optional(),
215
+ words: z.array(wordTimingSchema).max(1e5).optional(),
216
+ font: richCaptionFontSchema.optional(),
217
+ style: canvasStyleSchema.optional(),
218
+ stroke: canvasStrokeSchema.optional(),
219
+ shadow: canvasShadowSchema.optional(),
220
+ background: canvasBackgroundSchema.optional(),
221
+ padding: paddingSchema.optional(),
222
+ align: canvasAlignmentSchema.optional(),
223
+ active: richCaptionActiveSchema.optional(),
224
+ wordAnimation: richCaptionWordAnimationSchema.optional(),
225
+ position: z.enum(["top", "center", "bottom"]).default("bottom"),
226
+ maxWidth: z.number().min(0.1).max(1).default(0.9),
227
+ maxLines: z.number().int().min(1).max(10).default(2)
228
+ }).superRefine((data, ctx) => {
229
+ if (data.src && data.words) {
230
+ ctx.addIssue({
231
+ code: z.ZodIssueCode.custom,
232
+ message: "src and words are mutually exclusive",
233
+ path: ["src"]
234
+ });
235
+ }
236
+ if (!data.src && !data.words) {
237
+ ctx.addIssue({
238
+ code: z.ZodIssueCode.custom,
239
+ message: "Either src or words must be provided",
240
+ path: ["words"]
241
+ });
242
+ }
243
+ });
244
+ var CanvasRichCaptionAssetSchema = richCaptionAssetSchema;
177
245
 
178
246
  // src/wasm/hb-loader.ts
179
247
  var hbSingleton = null;
@@ -414,6 +482,7 @@ var FontRegistry = class _FontRegistry {
414
482
  fontkitBaseFonts = /* @__PURE__ */ new Map();
415
483
  colorEmojiFonts = /* @__PURE__ */ new Set();
416
484
  colorEmojiFontBytes = /* @__PURE__ */ new Map();
485
+ registeredCanvasFonts = /* @__PURE__ */ new Set();
417
486
  wasmBaseURL;
418
487
  initPromise;
419
488
  emojiFallbackDesc;
@@ -611,6 +680,7 @@ var FontRegistry = class _FontRegistry {
611
680
  } catch (err) {
612
681
  console.warn(`\u26A0\uFE0F Fontkit failed for ${desc.family}:`, err);
613
682
  }
683
+ await this.registerWithCanvas(desc.family, bytes);
614
684
  } catch (err) {
615
685
  throw new Error(
616
686
  `Failed to register font "${desc.family}": ${err instanceof Error ? err.message : String(err)}`
@@ -753,6 +823,21 @@ var FontRegistry = class _FontRegistry {
753
823
  );
754
824
  }
755
825
  }
826
+ async registerWithCanvas(family, bytes) {
827
+ if (this.registeredCanvasFonts.has(family)) {
828
+ return;
829
+ }
830
+ try {
831
+ const canvasMod = await import("canvas");
832
+ const GlobalFonts = canvasMod.GlobalFonts;
833
+ if (GlobalFonts && typeof GlobalFonts.register === "function") {
834
+ const buffer = Buffer.from(bytes);
835
+ GlobalFonts.register(buffer, family);
836
+ this.registeredCanvasFonts.add(family);
837
+ }
838
+ } catch {
839
+ }
840
+ }
756
841
  destroy() {
757
842
  try {
758
843
  for (const [, f] of this.fonts) {
@@ -789,6 +874,7 @@ var FontRegistry = class _FontRegistry {
789
874
  this.fontkitBaseFonts.clear();
790
875
  this.colorEmojiFonts.clear();
791
876
  this.colorEmojiFontBytes.clear();
877
+ this.registeredCanvasFonts.clear();
792
878
  this.hb = void 0;
793
879
  this.initPromise = void 0;
794
880
  } catch (err) {
@@ -1925,12 +2011,14 @@ async function createNodePainter(opts) {
1925
2011
  if (!ctx) throw new Error("2D context unavailable in Node (canvas).");
1926
2012
  const offscreenCanvas = createCanvas(canvas.width, canvas.height);
1927
2013
  const offscreenCtx = offscreenCanvas.getContext("2d");
2014
+ const GRADIENT_CACHE_MAX = 500;
1928
2015
  const gradientCache = /* @__PURE__ */ new Map();
1929
2016
  const api = {
1930
2017
  async render(ops) {
1931
2018
  const globalBox = computeGlobalTextBounds(ops);
1932
2019
  let needsAlphaExtraction = false;
1933
- for (const op of ops) {
2020
+ for (let i = 0; i < ops.length; i++) {
2021
+ const op = ops[i];
1934
2022
  if (op.op === "BeginFrame") {
1935
2023
  const dpr = op.pixelRatio ?? opts.pixelRatio;
1936
2024
  const wantW = Math.floor(op.width);
@@ -1942,7 +2030,9 @@ async function createNodePainter(opts) {
1942
2030
  offscreenCanvas.height = wantH;
1943
2031
  }
1944
2032
  ctx.setTransform(1, 0, 0, 1, 0, 0);
2033
+ ctx.globalAlpha = 1;
1945
2034
  offscreenCtx.setTransform(1, 0, 0, 1, 0, 0);
2035
+ offscreenCtx.globalAlpha = 1;
1946
2036
  const hasBackground = !!(op.bg && op.bg.color);
1947
2037
  const hasRoundedBackground = hasBackground && op.bg && op.bg.radius && op.bg.radius > 0;
1948
2038
  needsAlphaExtraction = !!hasRoundedBackground;
@@ -2075,7 +2165,7 @@ async function createNodePainter(opts) {
2075
2165
  context.save();
2076
2166
  const c = parseHex6(op.stroke.color, op.stroke.opacity);
2077
2167
  context.strokeStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
2078
- context.lineWidth = op.stroke.width;
2168
+ context.lineWidth = op.stroke.width * 2;
2079
2169
  if (op.borderRadius && op.borderRadius > 0) {
2080
2170
  context.beginPath();
2081
2171
  roundRectPath(context, op.x, op.y, op.width, op.height, op.borderRadius);
@@ -2178,6 +2268,140 @@ async function createNodePainter(opts) {
2178
2268
  });
2179
2269
  continue;
2180
2270
  }
2271
+ if (op.op === "DrawCaptionBackground") {
2272
+ renderToBoth((context) => {
2273
+ context.save();
2274
+ const bgC = parseHex6(op.color, op.opacity);
2275
+ context.fillStyle = `rgba(${bgC.r},${bgC.g},${bgC.b},${bgC.a})`;
2276
+ context.beginPath();
2277
+ roundRectPath(context, op.x, op.y, op.width, op.height, op.borderRadius);
2278
+ context.fill();
2279
+ context.restore();
2280
+ });
2281
+ continue;
2282
+ }
2283
+ if (op.op === "DrawCaptionWord") {
2284
+ const captionWordOps = [op];
2285
+ while (i + 1 < ops.length) {
2286
+ const nextOp = ops[i + 1];
2287
+ if (nextOp.op !== "DrawCaptionWord") break;
2288
+ captionWordOps.push(nextOp);
2289
+ i++;
2290
+ }
2291
+ renderToBoth((context) => {
2292
+ for (const wordOp of captionWordOps) {
2293
+ if (!wordOp.background) continue;
2294
+ const wordDisplayText = wordOp.visibleCharacters >= 0 && wordOp.visibleCharacters < wordOp.text.length ? wordOp.text.slice(0, wordOp.visibleCharacters) : wordOp.text;
2295
+ if (wordDisplayText.length === 0) continue;
2296
+ context.save();
2297
+ const bgTx = Math.round(wordOp.x + wordOp.transform.translateX);
2298
+ const bgTy = Math.round(wordOp.y + wordOp.transform.translateY);
2299
+ context.translate(bgTx, bgTy);
2300
+ if (wordOp.transform.scale !== 1) {
2301
+ const halfWidth = wordOp.width / 2;
2302
+ context.translate(halfWidth, 0);
2303
+ context.scale(wordOp.transform.scale, wordOp.transform.scale);
2304
+ context.translate(-halfWidth, 0);
2305
+ }
2306
+ context.globalAlpha = wordOp.transform.opacity;
2307
+ context.font = `${wordOp.fontWeight} ${wordOp.fontSize}px "${wordOp.fontFamily}"`;
2308
+ context.textBaseline = "alphabetic";
2309
+ if (wordOp.letterSpacing) {
2310
+ context.letterSpacing = `${wordOp.letterSpacing}px`;
2311
+ }
2312
+ const bgMetrics = context.measureText(wordDisplayText);
2313
+ const bgTextWidth = bgMetrics.width;
2314
+ const bgAscent = wordOp.fontSize * 0.8;
2315
+ const bgDescent = wordOp.fontSize * 0.2;
2316
+ const bgTextHeight = bgAscent + bgDescent;
2317
+ const bgX = -wordOp.background.padding;
2318
+ const bgY = -bgAscent - wordOp.background.padding;
2319
+ const bgW = bgTextWidth + wordOp.background.padding * 2;
2320
+ const bgH = bgTextHeight + wordOp.background.padding * 2;
2321
+ const bgC = parseHex6(wordOp.background.color, wordOp.background.opacity);
2322
+ context.fillStyle = `rgba(${bgC.r},${bgC.g},${bgC.b},${bgC.a})`;
2323
+ context.beginPath();
2324
+ roundRectPath(context, bgX, bgY, bgW, bgH, wordOp.background.borderRadius);
2325
+ context.fill();
2326
+ context.restore();
2327
+ }
2328
+ for (const wordOp of captionWordOps) {
2329
+ const displayText = wordOp.visibleCharacters >= 0 && wordOp.visibleCharacters < wordOp.text.length ? wordOp.text.slice(0, wordOp.visibleCharacters) : wordOp.text;
2330
+ if (displayText.length === 0) continue;
2331
+ context.save();
2332
+ const tx = Math.round(wordOp.x + wordOp.transform.translateX);
2333
+ const ty = Math.round(wordOp.y + wordOp.transform.translateY);
2334
+ context.translate(tx, ty);
2335
+ if (wordOp.transform.scale !== 1) {
2336
+ const halfWidth = wordOp.width / 2;
2337
+ context.translate(halfWidth, 0);
2338
+ context.scale(wordOp.transform.scale, wordOp.transform.scale);
2339
+ context.translate(-halfWidth, 0);
2340
+ }
2341
+ context.globalAlpha = wordOp.transform.opacity;
2342
+ context.font = `${wordOp.fontWeight} ${wordOp.fontSize}px "${wordOp.fontFamily}"`;
2343
+ context.textBaseline = "alphabetic";
2344
+ if (wordOp.letterSpacing) {
2345
+ context.letterSpacing = `${wordOp.letterSpacing}px`;
2346
+ }
2347
+ const metrics = context.measureText(displayText);
2348
+ const textWidth = metrics.width;
2349
+ const ascent = metrics.actualBoundingBoxAscent ?? wordOp.fontSize * 0.8;
2350
+ const descent = metrics.actualBoundingBoxDescent ?? wordOp.fontSize * 0.2;
2351
+ const textHeight = ascent + descent;
2352
+ if (wordOp.shadow) {
2353
+ const shadowC = parseHex6(wordOp.shadow.color, wordOp.shadow.opacity);
2354
+ context.fillStyle = `rgba(${shadowC.r},${shadowC.g},${shadowC.b},${shadowC.a})`;
2355
+ context.shadowColor = `rgba(${shadowC.r},${shadowC.g},${shadowC.b},${shadowC.a})`;
2356
+ context.shadowOffsetX = wordOp.shadow.offsetX;
2357
+ context.shadowOffsetY = wordOp.shadow.offsetY;
2358
+ context.shadowBlur = wordOp.shadow.blur;
2359
+ context.fillText(displayText, 0, 0);
2360
+ context.shadowColor = "transparent";
2361
+ context.shadowOffsetX = 0;
2362
+ context.shadowOffsetY = 0;
2363
+ context.shadowBlur = 0;
2364
+ }
2365
+ if (wordOp.stroke && wordOp.stroke.width > 0) {
2366
+ const strokeC = parseHex6(wordOp.stroke.color, wordOp.stroke.opacity);
2367
+ context.strokeStyle = `rgba(${strokeC.r},${strokeC.g},${strokeC.b},${strokeC.a})`;
2368
+ context.lineWidth = wordOp.stroke.width * 2;
2369
+ context.lineJoin = "round";
2370
+ context.lineCap = "round";
2371
+ context.strokeText(displayText, 0, 0);
2372
+ }
2373
+ if (wordOp.fillProgress <= 0) {
2374
+ const baseC = parseHex6(wordOp.baseColor, wordOp.baseOpacity);
2375
+ context.fillStyle = `rgba(${baseC.r},${baseC.g},${baseC.b},${baseC.a})`;
2376
+ context.fillText(displayText, 0, 0);
2377
+ } else if (wordOp.fillProgress >= 1) {
2378
+ const activeC = parseHex6(wordOp.activeColor, wordOp.activeOpacity);
2379
+ context.fillStyle = `rgba(${activeC.r},${activeC.g},${activeC.b},${activeC.a})`;
2380
+ context.fillText(displayText, 0, 0);
2381
+ } else {
2382
+ const baseC = parseHex6(wordOp.baseColor, wordOp.baseOpacity);
2383
+ context.fillStyle = `rgba(${baseC.r},${baseC.g},${baseC.b},${baseC.a})`;
2384
+ context.fillText(displayText, 0, 0);
2385
+ context.save();
2386
+ context.beginPath();
2387
+ const clipWidth = textWidth * wordOp.fillProgress;
2388
+ if (wordOp.isRTL) {
2389
+ const clipX = textWidth - clipWidth;
2390
+ context.rect(clipX, -ascent - 5, clipWidth + 5, textHeight + 10);
2391
+ } else {
2392
+ context.rect(-5, -ascent - 5, clipWidth + 5, textHeight + 10);
2393
+ }
2394
+ context.clip();
2395
+ const activeC = parseHex6(wordOp.activeColor, wordOp.activeOpacity);
2396
+ context.fillStyle = `rgba(${activeC.r},${activeC.g},${activeC.b},${activeC.a})`;
2397
+ context.fillText(displayText, 0, 0);
2398
+ context.restore();
2399
+ }
2400
+ context.restore();
2401
+ }
2402
+ });
2403
+ continue;
2404
+ }
2181
2405
  }
2182
2406
  if (needsAlphaExtraction) {
2183
2407
  const whiteData = ctx.getImageData(0, 0, canvas.width, canvas.height);
@@ -2215,6 +2439,17 @@ async function createNodePainter(opts) {
2215
2439
  },
2216
2440
  async toPNG() {
2217
2441
  return canvas.toBuffer("image/png");
2442
+ },
2443
+ toRawRGBA() {
2444
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
2445
+ return {
2446
+ data: new Uint8ClampedArray(imageData.data),
2447
+ width: canvas.width,
2448
+ height: canvas.height
2449
+ };
2450
+ },
2451
+ getCanvasSize() {
2452
+ return { width: canvas.width, height: canvas.height };
2218
2453
  }
2219
2454
  };
2220
2455
  return api;
@@ -2677,14 +2912,6 @@ var VideoGenerator = class {
2677
2912
  }
2678
2913
  };
2679
2914
 
2680
- // src/types.ts
2681
- var isShadowFill2 = (op) => {
2682
- return op.op === "FillPath" && op.isShadow === true;
2683
- };
2684
- var isGlyphFill2 = (op) => {
2685
- return op.op === "FillPath" && op.isShadow !== true;
2686
- };
2687
-
2688
2915
  // src/core/svg-path-utils.ts
2689
2916
  var TAU = Math.PI * 2;
2690
2917
  var PATH_COMMAND_REGEX = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g;
@@ -3629,6 +3856,1987 @@ function extractSvgDimensions(svgString) {
3629
3856
  return { width, height };
3630
3857
  }
3631
3858
 
3859
+ // src/core/rich-caption-layout.ts
3860
+ import { LRUCache } from "lru-cache";
3861
+ var RTL_RANGES = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/;
3862
+ function isRTLText(text) {
3863
+ return RTL_RANGES.test(text);
3864
+ }
3865
+ var WordTimingStore = class {
3866
+ startTimes;
3867
+ endTimes;
3868
+ xPositions;
3869
+ yPositions;
3870
+ widths;
3871
+ words;
3872
+ length;
3873
+ constructor(words) {
3874
+ this.length = words.length;
3875
+ this.startTimes = new Uint32Array(this.length);
3876
+ this.endTimes = new Uint32Array(this.length);
3877
+ this.xPositions = new Float32Array(this.length);
3878
+ this.yPositions = new Float32Array(this.length);
3879
+ this.widths = new Float32Array(this.length);
3880
+ this.words = new Array(this.length);
3881
+ for (let i = 0; i < this.length; i++) {
3882
+ this.startTimes[i] = Math.floor(words[i].start);
3883
+ this.endTimes[i] = Math.floor(words[i].end);
3884
+ this.words[i] = words[i].text;
3885
+ }
3886
+ }
3887
+ };
3888
+ function findWordAtTime(store, timeMs) {
3889
+ let left = 0;
3890
+ let right = store.length - 1;
3891
+ while (left <= right) {
3892
+ const mid = left + right >>> 1;
3893
+ const start = store.startTimes[mid];
3894
+ const end = store.endTimes[mid];
3895
+ if (timeMs >= start && timeMs < end) {
3896
+ return mid;
3897
+ }
3898
+ if (timeMs < start) {
3899
+ right = mid - 1;
3900
+ } else {
3901
+ left = mid + 1;
3902
+ }
3903
+ }
3904
+ return -1;
3905
+ }
3906
+ function groupWordsByPause(store, pauseThreshold = 500) {
3907
+ if (store.length === 0) {
3908
+ return [];
3909
+ }
3910
+ const groups = [];
3911
+ let currentGroup = [];
3912
+ for (let i = 0; i < store.length; i++) {
3913
+ if (currentGroup.length === 0) {
3914
+ currentGroup.push(i);
3915
+ continue;
3916
+ }
3917
+ const prevEnd = store.endTimes[currentGroup[currentGroup.length - 1]];
3918
+ const currStart = store.startTimes[i];
3919
+ const gap = currStart - prevEnd;
3920
+ const prevText = store.words[currentGroup[currentGroup.length - 1]];
3921
+ const endsWithPunctuation = /[.!?]$/.test(prevText);
3922
+ if (gap >= pauseThreshold || endsWithPunctuation) {
3923
+ groups.push(currentGroup);
3924
+ currentGroup = [i];
3925
+ } else {
3926
+ currentGroup.push(i);
3927
+ }
3928
+ }
3929
+ if (currentGroup.length > 0) {
3930
+ groups.push(currentGroup);
3931
+ }
3932
+ return groups;
3933
+ }
3934
+ function breakIntoLines(wordWidths, maxWidth, maxLines, spaceWidth) {
3935
+ const lines = [];
3936
+ let currentLine = [];
3937
+ let currentWidth = 0;
3938
+ for (let i = 0; i < wordWidths.length; i++) {
3939
+ const wordWidth = wordWidths[i];
3940
+ const spaceNeeded = currentLine.length > 0 ? spaceWidth : 0;
3941
+ if (currentWidth + spaceNeeded + wordWidth <= maxWidth) {
3942
+ currentLine.push(i);
3943
+ currentWidth += spaceNeeded + wordWidth;
3944
+ } else {
3945
+ if (currentLine.length > 0) {
3946
+ lines.push(currentLine);
3947
+ if (lines.length >= maxLines) {
3948
+ return lines;
3949
+ }
3950
+ }
3951
+ currentLine = [i];
3952
+ currentWidth = wordWidth;
3953
+ }
3954
+ }
3955
+ if (currentLine.length > 0 && lines.length < maxLines) {
3956
+ lines.push(currentLine);
3957
+ }
3958
+ return lines;
3959
+ }
3960
+ var GLYPH_SIZE_ESTIMATE = 64;
3961
+ function createShapedWordCache() {
3962
+ return new LRUCache({
3963
+ max: 5e4,
3964
+ maxSize: 50 * 1024 * 1024,
3965
+ maxEntrySize: 100 * 1024,
3966
+ sizeCalculation: (value, key) => {
3967
+ const keySize = key.length * 2;
3968
+ const glyphsSize = value.glyphs.length * GLYPH_SIZE_ESTIMATE;
3969
+ return keySize + glyphsSize + 100;
3970
+ }
3971
+ });
3972
+ }
3973
+ function makeShapingKey(text, fontFamily, fontSize, fontWeight, letterSpacing = 0) {
3974
+ return `${text}\0${fontFamily}\0${fontSize}\0${fontWeight}\0${letterSpacing}`;
3975
+ }
3976
+ function transformText(text, transform) {
3977
+ switch (transform) {
3978
+ case "uppercase":
3979
+ return text.toUpperCase();
3980
+ case "lowercase":
3981
+ return text.toLowerCase();
3982
+ case "capitalize":
3983
+ return text.replace(/\b\w/g, (c) => c.toUpperCase());
3984
+ default:
3985
+ return text;
3986
+ }
3987
+ }
3988
+ var CaptionLayoutEngine = class {
3989
+ fontRegistry;
3990
+ cache;
3991
+ layoutEngine;
3992
+ constructor(fontRegistry) {
3993
+ this.fontRegistry = fontRegistry;
3994
+ this.cache = createShapedWordCache();
3995
+ this.layoutEngine = new LayoutEngine(fontRegistry);
3996
+ }
3997
+ async measureWord(text, config) {
3998
+ const transformedText = transformText(text, config.textTransform);
3999
+ const cacheKey = makeShapingKey(
4000
+ transformedText,
4001
+ config.fontFamily,
4002
+ config.fontSize,
4003
+ config.fontWeight,
4004
+ config.letterSpacing
4005
+ );
4006
+ const cached = this.cache.get(cacheKey);
4007
+ if (cached) {
4008
+ return cached;
4009
+ }
4010
+ const lines = await this.layoutEngine.layout({
4011
+ text: transformedText,
4012
+ width: 1e5,
4013
+ letterSpacing: config.letterSpacing,
4014
+ fontSize: config.fontSize,
4015
+ lineHeight: 1,
4016
+ desc: { family: config.fontFamily, weight: config.fontWeight },
4017
+ textTransform: "none"
4018
+ });
4019
+ const width = lines[0]?.width ?? 0;
4020
+ const glyphs = lines[0]?.glyphs ?? [];
4021
+ const isRTL = isRTLText(transformedText);
4022
+ const shaped = {
4023
+ text: transformedText,
4024
+ width,
4025
+ glyphs: glyphs.map((g) => ({
4026
+ id: g.id,
4027
+ xAdvance: g.xAdvance,
4028
+ xOffset: g.xOffset,
4029
+ yOffset: g.yOffset,
4030
+ cluster: g.cluster
4031
+ })),
4032
+ isRTL
4033
+ };
4034
+ this.cache.set(cacheKey, shaped);
4035
+ return shaped;
4036
+ }
4037
+ async layoutCaption(words, config) {
4038
+ const store = new WordTimingStore(words);
4039
+ const measurementConfig = {
4040
+ fontFamily: config.fontFamily,
4041
+ fontSize: config.fontSize,
4042
+ fontWeight: config.fontWeight,
4043
+ letterSpacing: config.letterSpacing,
4044
+ textTransform: config.textTransform
4045
+ };
4046
+ const shapedWords = await Promise.all(
4047
+ words.map((w) => this.measureWord(w.text, measurementConfig))
4048
+ );
4049
+ if (config.measureTextWidth) {
4050
+ const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
4051
+ for (let i = 0; i < shapedWords.length; i++) {
4052
+ store.widths[i] = config.measureTextWidth(shapedWords[i].text, fontString);
4053
+ }
4054
+ } else {
4055
+ for (let i = 0; i < shapedWords.length; i++) {
4056
+ store.widths[i] = shapedWords[i].width;
4057
+ }
4058
+ }
4059
+ if (config.textTransform !== "none") {
4060
+ for (let i = 0; i < shapedWords.length; i++) {
4061
+ store.words[i] = shapedWords[i].text;
4062
+ }
4063
+ }
4064
+ const wordGroups = groupWordsByPause(store, config.pauseThreshold);
4065
+ const pixelMaxWidth = config.frameWidth * config.maxWidth;
4066
+ let spaceWidth;
4067
+ if (config.measureTextWidth) {
4068
+ const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
4069
+ spaceWidth = config.measureTextWidth(" ", fontString) + config.wordSpacing;
4070
+ } else {
4071
+ const spaceWord = await this.measureWord(" ", measurementConfig);
4072
+ spaceWidth = spaceWord.width + config.wordSpacing;
4073
+ }
4074
+ const groups = wordGroups.map((indices) => {
4075
+ const groupWidths = indices.map((i) => store.widths[i]);
4076
+ const lineIndices = breakIntoLines(
4077
+ groupWidths,
4078
+ pixelMaxWidth,
4079
+ config.maxLines,
4080
+ spaceWidth
4081
+ );
4082
+ const lines = lineIndices.map((lineWordIndices, lineIndex) => {
4083
+ const actualIndices = lineWordIndices.map((i) => indices[i]);
4084
+ const lineWidth = actualIndices.reduce((sum, idx) => sum + store.widths[idx], 0) + (actualIndices.length - 1) * spaceWidth;
4085
+ return {
4086
+ wordIndices: actualIndices,
4087
+ x: 0,
4088
+ y: lineIndex * config.fontSize * config.lineHeight,
4089
+ width: lineWidth,
4090
+ height: config.fontSize
4091
+ };
4092
+ });
4093
+ return {
4094
+ wordIndices: lines.flatMap((l) => l.wordIndices),
4095
+ startTime: store.startTimes[indices[0]],
4096
+ endTime: store.endTimes[indices[indices.length - 1]],
4097
+ lines
4098
+ };
4099
+ });
4100
+ const calculateGroupY = (group) => {
4101
+ const totalHeight = group.lines.length * config.fontSize * config.lineHeight;
4102
+ switch (config.position) {
4103
+ case "top":
4104
+ return config.fontSize * 1.5;
4105
+ case "bottom":
4106
+ return config.frameHeight - totalHeight - config.fontSize * 0.5;
4107
+ case "center":
4108
+ default:
4109
+ return (config.frameHeight - totalHeight) / 2 + config.fontSize;
4110
+ }
4111
+ };
4112
+ for (const group of groups) {
4113
+ const baseY = calculateGroupY(group);
4114
+ for (let lineIdx = 0; lineIdx < group.lines.length; lineIdx++) {
4115
+ const line = group.lines[lineIdx];
4116
+ line.x = (config.frameWidth - line.width) / 2;
4117
+ line.y = baseY + lineIdx * config.fontSize * config.lineHeight;
4118
+ let xCursor = line.x;
4119
+ for (const wordIdx of line.wordIndices) {
4120
+ store.xPositions[wordIdx] = xCursor;
4121
+ store.yPositions[wordIdx] = line.y;
4122
+ xCursor += store.widths[wordIdx] + spaceWidth;
4123
+ }
4124
+ }
4125
+ }
4126
+ return {
4127
+ store,
4128
+ groups,
4129
+ shapedWords
4130
+ };
4131
+ }
4132
+ getVisibleWordsAtTime(layout, timeMs) {
4133
+ const activeGroup = layout.groups.find(
4134
+ (g) => timeMs >= g.startTime && timeMs <= g.endTime
4135
+ );
4136
+ if (!activeGroup) {
4137
+ return [];
4138
+ }
4139
+ return activeGroup.wordIndices.map((idx) => ({
4140
+ wordIndex: idx,
4141
+ text: layout.store.words[idx],
4142
+ x: layout.store.xPositions[idx],
4143
+ y: layout.store.yPositions[idx],
4144
+ width: layout.store.widths[idx],
4145
+ startTime: layout.store.startTimes[idx],
4146
+ endTime: layout.store.endTimes[idx],
4147
+ isRTL: layout.shapedWords[idx].isRTL
4148
+ }));
4149
+ }
4150
+ getActiveWordAtTime(layout, timeMs) {
4151
+ const wordIndex = findWordAtTime(layout.store, timeMs);
4152
+ if (wordIndex === -1) {
4153
+ return null;
4154
+ }
4155
+ return {
4156
+ wordIndex,
4157
+ text: layout.store.words[wordIndex],
4158
+ x: layout.store.xPositions[wordIndex],
4159
+ y: layout.store.yPositions[wordIndex],
4160
+ width: layout.store.widths[wordIndex],
4161
+ startTime: layout.store.startTimes[wordIndex],
4162
+ endTime: layout.store.endTimes[wordIndex],
4163
+ isRTL: layout.shapedWords[wordIndex].isRTL
4164
+ };
4165
+ }
4166
+ clearCache() {
4167
+ this.cache.clear();
4168
+ }
4169
+ getCacheStats() {
4170
+ return {
4171
+ size: this.cache.size,
4172
+ calculatedSize: this.cache.calculatedSize
4173
+ };
4174
+ }
4175
+ };
4176
+
4177
+ // src/core/rich-caption-animator.ts
4178
+ var ANIMATION_DURATIONS = {
4179
+ karaoke: 0,
4180
+ highlight: 0,
4181
+ pop: 200,
4182
+ fade: 150,
4183
+ slide: 250,
4184
+ bounce: 400,
4185
+ typewriter: 0,
4186
+ none: 0
4187
+ };
4188
+ var DEFAULT_ANIMATION_STATE = {
4189
+ opacity: 1,
4190
+ scale: 1,
4191
+ translateX: 0,
4192
+ translateY: 0,
4193
+ fillProgress: 1,
4194
+ isActive: false,
4195
+ visibleCharacters: -1
4196
+ };
4197
+ function easeOutQuad2(t) {
4198
+ return t * (2 - t);
4199
+ }
4200
+ function easeInOutQuad(t) {
4201
+ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
4202
+ }
4203
+ function easeOutBack(t) {
4204
+ const c1 = 1.70158;
4205
+ const c3 = c1 + 1;
4206
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
4207
+ }
4208
+ function easeOutCirc(t) {
4209
+ return Math.sqrt(1 - Math.pow(t - 1, 2));
4210
+ }
4211
+ function easeOutBounce(t) {
4212
+ const n1 = 7.5625;
4213
+ const d1 = 2.75;
4214
+ if (t < 1 / d1) {
4215
+ return n1 * t * t;
4216
+ }
4217
+ if (t < 2 / d1) {
4218
+ return n1 * (t -= 1.5 / d1) * t + 0.75;
4219
+ }
4220
+ if (t < 2.5 / d1) {
4221
+ return n1 * (t -= 2.25 / d1) * t + 0.9375;
4222
+ }
4223
+ return n1 * (t -= 2.625 / d1) * t + 0.984375;
4224
+ }
4225
+ function clamp(value, min, max) {
4226
+ return Math.min(Math.max(value, min), max);
4227
+ }
4228
+ function calculateAnimationProgress(ctx) {
4229
+ if (ctx.animationDuration <= 0) {
4230
+ return ctx.currentTime >= ctx.wordStart ? 1 : 0;
4231
+ }
4232
+ const elapsed = ctx.currentTime - ctx.wordStart;
4233
+ return clamp(elapsed / ctx.animationDuration, 0, 1);
4234
+ }
4235
+ function calculateWordProgress(ctx) {
4236
+ const duration = ctx.wordEnd - ctx.wordStart;
4237
+ if (duration <= 0) {
4238
+ return ctx.currentTime >= ctx.wordStart ? 1 : 0;
4239
+ }
4240
+ const elapsed = ctx.currentTime - ctx.wordStart;
4241
+ return clamp(elapsed / duration, 0, 1);
4242
+ }
4243
+ function isWordActive(ctx) {
4244
+ return ctx.currentTime >= ctx.wordStart && ctx.currentTime < ctx.wordEnd;
4245
+ }
4246
+ function calculateKaraokeState(ctx, speed) {
4247
+ const isActive = isWordActive(ctx);
4248
+ const wordDuration = ctx.wordEnd - ctx.wordStart;
4249
+ const adjustedDuration = wordDuration / speed;
4250
+ const adjustedEnd = ctx.wordStart + adjustedDuration;
4251
+ const adjustedCtx = { ...ctx, wordEnd: adjustedEnd };
4252
+ if (ctx.currentTime < ctx.wordStart) {
4253
+ return {
4254
+ fillProgress: 0,
4255
+ isActive: false,
4256
+ opacity: 1
4257
+ };
4258
+ }
4259
+ if (ctx.currentTime >= adjustedEnd) {
4260
+ return {
4261
+ fillProgress: 1,
4262
+ isActive: false,
4263
+ opacity: 1
4264
+ };
4265
+ }
4266
+ return {
4267
+ fillProgress: calculateWordProgress(adjustedCtx),
4268
+ isActive,
4269
+ opacity: 1
4270
+ };
4271
+ }
4272
+ function calculateHighlightState(ctx) {
4273
+ const isActive = isWordActive(ctx);
4274
+ return {
4275
+ isActive,
4276
+ fillProgress: isActive ? 1 : 0,
4277
+ opacity: 1
4278
+ };
4279
+ }
4280
+ function calculatePopState(ctx, activeScale, speed) {
4281
+ if (ctx.currentTime < ctx.wordStart) {
4282
+ return {
4283
+ scale: 0.5,
4284
+ opacity: 0,
4285
+ isActive: false
4286
+ };
4287
+ }
4288
+ const adjustedDuration = ctx.animationDuration / speed;
4289
+ const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
4290
+ const progress = calculateAnimationProgress(adjustedCtx);
4291
+ const easedProgress = easeOutBack(progress);
4292
+ const startScale = 0.5;
4293
+ const endScale = isWordActive(ctx) ? activeScale : 1;
4294
+ const scale = startScale + (endScale - startScale) * easedProgress;
4295
+ return {
4296
+ scale: Math.min(scale, activeScale),
4297
+ opacity: easedProgress,
4298
+ isActive: isWordActive(ctx)
4299
+ };
4300
+ }
4301
+ function calculateFadeState(ctx, speed) {
4302
+ if (ctx.currentTime < ctx.wordStart) {
4303
+ return {
4304
+ opacity: 0,
4305
+ isActive: false
4306
+ };
4307
+ }
4308
+ const adjustedDuration = ctx.animationDuration / speed;
4309
+ const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
4310
+ const progress = calculateAnimationProgress(adjustedCtx);
4311
+ const easedProgress = easeInOutQuad(progress);
4312
+ return {
4313
+ opacity: easedProgress,
4314
+ isActive: isWordActive(ctx)
4315
+ };
4316
+ }
4317
+ function calculateSlideState(ctx, direction, speed, fontSize) {
4318
+ const slideDistance = fontSize * 1.5;
4319
+ if (ctx.currentTime < ctx.wordStart) {
4320
+ const offset2 = getDirectionOffset(direction, slideDistance);
4321
+ return {
4322
+ translateX: offset2.x,
4323
+ translateY: offset2.y,
4324
+ opacity: 0,
4325
+ isActive: false
4326
+ };
4327
+ }
4328
+ const adjustedDuration = ctx.animationDuration / speed;
4329
+ const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
4330
+ const progress = calculateAnimationProgress(adjustedCtx);
4331
+ const easedProgress = easeOutCirc(progress);
4332
+ const offset = getDirectionOffset(direction, slideDistance);
4333
+ const translateX = offset.x * (1 - easedProgress);
4334
+ const translateY = offset.y * (1 - easedProgress);
4335
+ return {
4336
+ translateX,
4337
+ translateY,
4338
+ opacity: easeOutQuad2(progress),
4339
+ isActive: isWordActive(ctx)
4340
+ };
4341
+ }
4342
+ function getDirectionOffset(direction, distance) {
4343
+ switch (direction) {
4344
+ case "left":
4345
+ return { x: -distance, y: 0 };
4346
+ case "right":
4347
+ return { x: distance, y: 0 };
4348
+ case "up":
4349
+ return { x: 0, y: -distance };
4350
+ case "down":
4351
+ return { x: 0, y: distance };
4352
+ }
4353
+ }
4354
+ function calculateBounceState(ctx, speed, fontSize) {
4355
+ const bounceDistance = fontSize * 0.8;
4356
+ if (ctx.currentTime < ctx.wordStart) {
4357
+ return {
4358
+ translateY: -bounceDistance,
4359
+ opacity: 0,
4360
+ isActive: false
4361
+ };
4362
+ }
4363
+ const adjustedDuration = ctx.animationDuration / speed;
4364
+ const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
4365
+ const progress = calculateAnimationProgress(adjustedCtx);
4366
+ const easedProgress = easeOutBounce(progress);
4367
+ return {
4368
+ translateY: -bounceDistance * (1 - easedProgress),
4369
+ opacity: easeOutQuad2(progress),
4370
+ isActive: isWordActive(ctx)
4371
+ };
4372
+ }
4373
+ function calculateTypewriterState(ctx, charCount, speed) {
4374
+ const wordDuration = ctx.wordEnd - ctx.wordStart;
4375
+ const adjustedDuration = wordDuration / speed;
4376
+ const adjustedEnd = ctx.wordStart + adjustedDuration;
4377
+ const adjustedCtx = { ...ctx, wordEnd: adjustedEnd };
4378
+ if (ctx.currentTime < ctx.wordStart) {
4379
+ return {
4380
+ visibleCharacters: 0,
4381
+ opacity: 1,
4382
+ isActive: false
4383
+ };
4384
+ }
4385
+ if (ctx.currentTime >= adjustedEnd) {
4386
+ return {
4387
+ visibleCharacters: charCount,
4388
+ opacity: 1,
4389
+ isActive: false
4390
+ };
4391
+ }
4392
+ const progress = calculateWordProgress(adjustedCtx);
4393
+ const visibleCharacters = Math.ceil(progress * charCount);
4394
+ return {
4395
+ visibleCharacters: clamp(visibleCharacters, 0, charCount),
4396
+ opacity: 1,
4397
+ isActive: isWordActive(ctx)
4398
+ };
4399
+ }
4400
+ function calculateNoneState(ctx) {
4401
+ return {
4402
+ opacity: 1,
4403
+ isActive: isWordActive(ctx)
4404
+ };
4405
+ }
4406
+ function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, activeScale = 1, charCount = 0, fontSize = 48) {
4407
+ const ctx = {
4408
+ wordStart,
4409
+ wordEnd,
4410
+ currentTime,
4411
+ animationDuration: ANIMATION_DURATIONS[config.style]
4412
+ };
4413
+ const baseState = { ...DEFAULT_ANIMATION_STATE };
4414
+ let partialState;
4415
+ switch (config.style) {
4416
+ case "karaoke":
4417
+ partialState = calculateKaraokeState(ctx, config.speed);
4418
+ break;
4419
+ case "highlight":
4420
+ partialState = calculateHighlightState(ctx);
4421
+ break;
4422
+ case "pop":
4423
+ partialState = calculatePopState(ctx, activeScale, config.speed);
4424
+ break;
4425
+ case "fade":
4426
+ partialState = calculateFadeState(ctx, config.speed);
4427
+ break;
4428
+ case "slide":
4429
+ partialState = calculateSlideState(ctx, config.direction, config.speed, fontSize);
4430
+ break;
4431
+ case "bounce":
4432
+ partialState = calculateBounceState(ctx, config.speed, fontSize);
4433
+ break;
4434
+ case "typewriter":
4435
+ partialState = calculateTypewriterState(ctx, charCount, config.speed);
4436
+ break;
4437
+ case "none":
4438
+ default:
4439
+ partialState = calculateNoneState(ctx);
4440
+ break;
4441
+ }
4442
+ return { ...baseState, ...partialState };
4443
+ }
4444
+ function calculateAnimationStatesForGroup(words, currentTime, config, activeScale = 1, fontSize = 48) {
4445
+ const states = /* @__PURE__ */ new Map();
4446
+ for (const word of words) {
4447
+ const state = calculateWordAnimationState(
4448
+ word.startTime,
4449
+ word.endTime,
4450
+ currentTime,
4451
+ config,
4452
+ activeScale,
4453
+ word.text.length,
4454
+ fontSize
4455
+ );
4456
+ states.set(word.wordIndex, state);
4457
+ }
4458
+ return states;
4459
+ }
4460
+ function getDefaultAnimationConfig() {
4461
+ return {
4462
+ style: "highlight",
4463
+ speed: 1,
4464
+ direction: "up"
4465
+ };
4466
+ }
4467
+
4468
+ // src/core/rich-caption-generator.ts
4469
+ function extractFontConfig(asset) {
4470
+ const font = asset.font;
4471
+ const active = asset.active?.font;
4472
+ return {
4473
+ family: font?.family ?? "Open Sans",
4474
+ size: font?.size ?? 24,
4475
+ weight: String(font?.weight ?? "400"),
4476
+ baseColor: font?.color ?? "#ffffff",
4477
+ activeColor: active?.color ?? "#ffff00",
4478
+ baseOpacity: font?.opacity ?? 1,
4479
+ activeOpacity: active?.opacity ?? 1,
4480
+ letterSpacing: asset.style?.letterSpacing ?? 0
4481
+ };
4482
+ }
4483
+ function extractStrokeConfig(asset, isActive) {
4484
+ const baseStroke = asset.stroke;
4485
+ const activeStroke = asset.active?.stroke;
4486
+ if (!baseStroke && !activeStroke) {
4487
+ return void 0;
4488
+ }
4489
+ if (isActive && activeStroke) {
4490
+ return {
4491
+ width: activeStroke.width ?? baseStroke?.width ?? 0,
4492
+ color: activeStroke.color ?? baseStroke?.color ?? "#000000",
4493
+ opacity: activeStroke.opacity ?? baseStroke?.opacity ?? 1
4494
+ };
4495
+ }
4496
+ if (baseStroke) {
4497
+ return {
4498
+ width: baseStroke.width ?? 0,
4499
+ color: baseStroke.color ?? "#000000",
4500
+ opacity: baseStroke.opacity ?? 1
4501
+ };
4502
+ }
4503
+ return void 0;
4504
+ }
4505
+ function extractShadowConfig(asset) {
4506
+ const shadow = asset.shadow;
4507
+ if (!shadow) {
4508
+ return void 0;
4509
+ }
4510
+ return {
4511
+ offsetX: shadow.offsetX ?? 0,
4512
+ offsetY: shadow.offsetY ?? 0,
4513
+ blur: shadow.blur ?? 0,
4514
+ color: shadow.color ?? "#000000",
4515
+ opacity: shadow.opacity ?? 0.5
4516
+ };
4517
+ }
4518
+ function extractBackgroundConfig(asset, isActive) {
4519
+ const fontBackground = asset.font?.background;
4520
+ const activeBackground = asset.active?.font?.background;
4521
+ const bgColor = isActive && activeBackground ? activeBackground : fontBackground;
4522
+ if (!bgColor) {
4523
+ return void 0;
4524
+ }
4525
+ const paddingValues = extractCaptionPadding(asset);
4526
+ const paddingValue = Math.max(paddingValues.top, paddingValues.right, paddingValues.bottom, paddingValues.left);
4527
+ return {
4528
+ color: bgColor,
4529
+ opacity: 1,
4530
+ borderRadius: 4,
4531
+ padding: paddingValue
4532
+ };
4533
+ }
4534
+ function extractCaptionPadding(asset) {
4535
+ const padding = asset.padding;
4536
+ if (!padding) {
4537
+ return { top: 0, right: 0, bottom: 0, left: 0 };
4538
+ }
4539
+ if (typeof padding === "number") {
4540
+ return { top: padding, right: padding, bottom: padding, left: padding };
4541
+ }
4542
+ return {
4543
+ top: padding.top ?? 0,
4544
+ right: padding.right ?? 0,
4545
+ bottom: padding.bottom ?? 0,
4546
+ left: padding.left ?? 0
4547
+ };
4548
+ }
4549
+ function extractCaptionBackground(asset) {
4550
+ const bg = asset.background;
4551
+ if (!bg || !bg.color) {
4552
+ return void 0;
4553
+ }
4554
+ return {
4555
+ color: bg.color,
4556
+ opacity: bg.opacity ?? 1
4557
+ };
4558
+ }
4559
+ function extractAnimationConfig(asset) {
4560
+ const wordAnim = asset.wordAnimation;
4561
+ if (!wordAnim) {
4562
+ return getDefaultAnimationConfig();
4563
+ }
4564
+ return {
4565
+ style: wordAnim.style ?? "highlight",
4566
+ speed: wordAnim.speed ?? 1,
4567
+ direction: wordAnim.direction ?? "up"
4568
+ };
4569
+ }
4570
+ function extractActiveScale(asset) {
4571
+ return asset.active?.scale ?? 1;
4572
+ }
4573
+ function createDrawCaptionWordOp(word, animState, asset, fontConfig) {
4574
+ const isActive = animState.isActive;
4575
+ const displayText = animState.visibleCharacters >= 0 && animState.visibleCharacters < word.text.length ? word.text.slice(0, animState.visibleCharacters) : word.text;
4576
+ return {
4577
+ op: "DrawCaptionWord",
4578
+ text: displayText,
4579
+ x: word.x,
4580
+ y: word.y,
4581
+ width: word.width,
4582
+ fontSize: fontConfig.size,
4583
+ fontFamily: fontConfig.family,
4584
+ fontWeight: fontConfig.weight,
4585
+ baseColor: fontConfig.baseColor,
4586
+ activeColor: fontConfig.activeColor,
4587
+ baseOpacity: fontConfig.baseOpacity,
4588
+ activeOpacity: fontConfig.activeOpacity,
4589
+ fillProgress: animState.fillProgress,
4590
+ transform: {
4591
+ scale: animState.scale,
4592
+ translateX: animState.translateX,
4593
+ translateY: animState.translateY,
4594
+ opacity: animState.opacity
4595
+ },
4596
+ isRTL: word.isRTL,
4597
+ visibleCharacters: animState.visibleCharacters,
4598
+ letterSpacing: fontConfig.letterSpacing > 0 ? fontConfig.letterSpacing : void 0,
4599
+ stroke: extractStrokeConfig(asset, isActive),
4600
+ shadow: extractShadowConfig(asset),
4601
+ background: extractBackgroundConfig(asset, isActive)
4602
+ };
4603
+ }
4604
+ function generateRichCaptionDrawOps(asset, layout, frameTimeMs, layoutEngine, _config) {
4605
+ if (layout.store.length === 0) {
4606
+ return [];
4607
+ }
4608
+ const visibleWords = layoutEngine.getVisibleWordsAtTime(layout, frameTimeMs);
4609
+ if (visibleWords.length === 0) {
4610
+ return [];
4611
+ }
4612
+ const animConfig = extractAnimationConfig(asset);
4613
+ const activeScale = extractActiveScale(asset);
4614
+ const fontConfig = extractFontConfig(asset);
4615
+ const animationStates = calculateAnimationStatesForGroup(
4616
+ visibleWords,
4617
+ frameTimeMs,
4618
+ animConfig,
4619
+ activeScale,
4620
+ fontConfig.size
4621
+ );
4622
+ const ops = [];
4623
+ const captionBg = extractCaptionBackground(asset);
4624
+ if (captionBg) {
4625
+ const activeGroup = layout.groups.find(
4626
+ (g) => frameTimeMs >= g.startTime && frameTimeMs <= g.endTime
4627
+ );
4628
+ if (activeGroup && activeGroup.lines.length > 0) {
4629
+ const padding = extractCaptionPadding(asset);
4630
+ let minX = Infinity;
4631
+ let maxX = -Infinity;
4632
+ let minY = Infinity;
4633
+ let maxY = -Infinity;
4634
+ for (const line of activeGroup.lines) {
4635
+ const lineX = line.x;
4636
+ const lineRight = line.x + line.width;
4637
+ const lineY = line.y - line.height * 0.8;
4638
+ const lineBottom = line.y + line.height * 0.2;
4639
+ if (lineX < minX) minX = lineX;
4640
+ if (lineRight > maxX) maxX = lineRight;
4641
+ if (lineY < minY) minY = lineY;
4642
+ if (lineBottom > maxY) maxY = lineBottom;
4643
+ }
4644
+ ops.push({
4645
+ op: "DrawCaptionBackground",
4646
+ x: minX - padding.left,
4647
+ y: minY - padding.top,
4648
+ width: maxX - minX + padding.left + padding.right,
4649
+ height: maxY - minY + padding.top + padding.bottom,
4650
+ color: captionBg.color,
4651
+ opacity: captionBg.opacity,
4652
+ borderRadius: 8
4653
+ });
4654
+ }
4655
+ }
4656
+ for (const word of visibleWords) {
4657
+ const animState = animationStates.get(word.wordIndex);
4658
+ if (!animState) {
4659
+ continue;
4660
+ }
4661
+ if (animState.opacity <= 0) {
4662
+ continue;
4663
+ }
4664
+ const drawOp = createDrawCaptionWordOp(word, animState, asset, fontConfig);
4665
+ ops.push(drawOp);
4666
+ }
4667
+ return ops;
4668
+ }
4669
+ function generateRichCaptionFrame(asset, layout, frameTimeMs, layoutEngine, config) {
4670
+ const ops = generateRichCaptionDrawOps(
4671
+ asset,
4672
+ layout,
4673
+ frameTimeMs,
4674
+ layoutEngine,
4675
+ config
4676
+ );
4677
+ const activeWord = layoutEngine.getActiveWordAtTime(layout, frameTimeMs);
4678
+ return {
4679
+ ops,
4680
+ visibleWordCount: ops.length,
4681
+ activeWordIndex: activeWord?.wordIndex ?? -1
4682
+ };
4683
+ }
4684
+ function createDefaultGeneratorConfig(frameWidth = 1920, frameHeight = 1080, pixelRatio = 1) {
4685
+ return {
4686
+ frameWidth,
4687
+ frameHeight,
4688
+ pixelRatio
4689
+ };
4690
+ }
4691
+ function isDrawCaptionWordOp(op) {
4692
+ return op.op === "DrawCaptionWord";
4693
+ }
4694
+ function getDrawCaptionWordOps(ops) {
4695
+ return ops.filter(isDrawCaptionWordOp);
4696
+ }
4697
+
4698
+ // src/core/canvas-text-measurer.ts
4699
+ async function createCanvasTextMeasurer() {
4700
+ const canvasMod = await import("canvas");
4701
+ const canvas = canvasMod.createCanvas(1, 1);
4702
+ const ctx = canvas.getContext("2d");
4703
+ ctx.textBaseline = "alphabetic";
4704
+ let lastFont = "";
4705
+ return (text, font) => {
4706
+ if (font !== lastFont) {
4707
+ ctx.font = font;
4708
+ lastFont = font;
4709
+ }
4710
+ return ctx.measureText(text).width;
4711
+ };
4712
+ }
4713
+
4714
+ // src/core/subtitle-parser.ts
4715
+ function detectSubtitleFormat(content) {
4716
+ const firstNewline = content.indexOf("\n");
4717
+ const firstLine = (firstNewline === -1 ? content : content.substring(0, firstNewline)).trim();
4718
+ return firstLine.startsWith("WEBVTT") ? "vtt" : "srt";
4719
+ }
4720
+ function parseSubtitleToWords(content) {
4721
+ const normalized = normalizeContent(content);
4722
+ if (normalized.length === 0) {
4723
+ return [];
4724
+ }
4725
+ const format = detectSubtitleFormat(normalized);
4726
+ const cues = format === "vtt" ? parseVTTCues(normalized) : parseSRTCues(normalized);
4727
+ const words = [];
4728
+ for (let i = 0; i < cues.length; i++) {
4729
+ const cueWords = distributeCueToWords(cues[i]);
4730
+ for (let j = 0; j < cueWords.length; j++) {
4731
+ words.push(cueWords[j]);
4732
+ }
4733
+ }
4734
+ return words;
4735
+ }
4736
+ function normalizeContent(content) {
4737
+ let start = 0;
4738
+ if (content.charCodeAt(0) === 65279) {
4739
+ start = 1;
4740
+ }
4741
+ let result = start > 0 ? content.substring(start) : content;
4742
+ result = result.replace(/\r\n?/g, "\n");
4743
+ return result.trim();
4744
+ }
4745
+ function parseVTTCues(content) {
4746
+ const cues = [];
4747
+ let pos = 0;
4748
+ const len = content.length;
4749
+ const firstNewline = content.indexOf("\n", pos);
4750
+ if (firstNewline === -1) {
4751
+ return cues;
4752
+ }
4753
+ pos = firstNewline + 1;
4754
+ while (pos < len) {
4755
+ pos = skipWhitespaceAndNewlines(content, pos);
4756
+ if (pos >= len) break;
4757
+ const lineEnd = findLineEnd(content, pos);
4758
+ const line = content.substring(pos, lineEnd);
4759
+ if (line.startsWith("NOTE") || line.startsWith("STYLE") || line.startsWith("REGION")) {
4760
+ pos = skipBlock(content, lineEnd + 1);
4761
+ continue;
4762
+ }
4763
+ const arrowIdx = line.indexOf("-->");
4764
+ let timeLine;
4765
+ if (arrowIdx !== -1) {
4766
+ timeLine = line;
4767
+ pos = lineEnd + 1;
4768
+ } else {
4769
+ pos = lineEnd + 1;
4770
+ if (pos >= len) break;
4771
+ const nextLineEnd = findLineEnd(content, pos);
4772
+ const nextLine = content.substring(pos, nextLineEnd);
4773
+ if (nextLine.indexOf("-->") === -1) {
4774
+ pos = skipBlock(content, nextLineEnd + 1);
4775
+ continue;
4776
+ }
4777
+ timeLine = nextLine;
4778
+ pos = nextLineEnd + 1;
4779
+ }
4780
+ const timestamps = parseTimeLineVTT(timeLine);
4781
+ if (!timestamps) {
4782
+ pos = skipBlock(content, pos);
4783
+ continue;
4784
+ }
4785
+ let textLines = [];
4786
+ while (pos < len) {
4787
+ const tLineEnd = findLineEnd(content, pos);
4788
+ const tLine = content.substring(pos, tLineEnd);
4789
+ pos = tLineEnd + 1;
4790
+ if (tLine.length === 0) break;
4791
+ textLines.push(tLine);
4792
+ }
4793
+ if (textLines.length === 0) continue;
4794
+ const rawText = textLines.join(" ");
4795
+ const { cleanText, timestamps: inlineTs } = extractInlineTimestamps(rawText);
4796
+ const strippedText = stripMarkupTags(cleanText).trim();
4797
+ if (strippedText.length === 0) continue;
4798
+ if (timestamps.endMs <= timestamps.startMs) continue;
4799
+ cues.push({
4800
+ startMs: timestamps.startMs,
4801
+ endMs: timestamps.endMs,
4802
+ text: strippedText,
4803
+ inlineTimestamps: inlineTs
4804
+ });
4805
+ }
4806
+ return cues;
4807
+ }
4808
+ function parseSRTCues(content) {
4809
+ const cues = [];
4810
+ let pos = 0;
4811
+ const len = content.length;
4812
+ while (pos < len) {
4813
+ pos = skipWhitespaceAndNewlines(content, pos);
4814
+ if (pos >= len) break;
4815
+ let lineEnd = findLineEnd(content, pos);
4816
+ let line = content.substring(pos, lineEnd);
4817
+ pos = lineEnd + 1;
4818
+ if (line.indexOf("-->") === -1) {
4819
+ if (pos >= len) break;
4820
+ lineEnd = findLineEnd(content, pos);
4821
+ line = content.substring(pos, lineEnd);
4822
+ pos = lineEnd + 1;
4823
+ }
4824
+ if (line.indexOf("-->") === -1) {
4825
+ continue;
4826
+ }
4827
+ const timestamps = parseTimeLineSRT(line);
4828
+ if (!timestamps) continue;
4829
+ let textLines = [];
4830
+ while (pos < len) {
4831
+ const tLineEnd = findLineEnd(content, pos);
4832
+ const tLine = content.substring(pos, tLineEnd);
4833
+ pos = tLineEnd + 1;
4834
+ if (tLine.length === 0) break;
4835
+ textLines.push(tLine);
4836
+ }
4837
+ if (textLines.length === 0) continue;
4838
+ const rawText = textLines.join(" ");
4839
+ const strippedText = stripMarkupTags(rawText).trim();
4840
+ if (strippedText.length === 0) continue;
4841
+ if (timestamps.endMs <= timestamps.startMs) continue;
4842
+ cues.push({
4843
+ startMs: timestamps.startMs,
4844
+ endMs: timestamps.endMs,
4845
+ text: strippedText,
4846
+ inlineTimestamps: []
4847
+ });
4848
+ }
4849
+ return cues;
4850
+ }
4851
+ function parseTimeLineVTT(line) {
4852
+ const arrowIdx = line.indexOf("-->");
4853
+ if (arrowIdx === -1) return null;
4854
+ const startRaw = line.substring(0, arrowIdx).trim();
4855
+ const afterArrow = line.substring(arrowIdx + 3).trim();
4856
+ const spaceIdx = afterArrow.indexOf(" ");
4857
+ const endRaw = spaceIdx === -1 ? afterArrow : afterArrow.substring(0, spaceIdx);
4858
+ const startMs = parseTimestampVTT(startRaw);
4859
+ const endMs = parseTimestampVTT(endRaw);
4860
+ if (startMs < 0 || endMs < 0) return null;
4861
+ return { startMs, endMs };
4862
+ }
4863
+ function parseTimeLineSRT(line) {
4864
+ const arrowIdx = line.indexOf("-->");
4865
+ if (arrowIdx === -1) return null;
4866
+ const startRaw = line.substring(0, arrowIdx).trim();
4867
+ const endRaw = line.substring(arrowIdx + 3).trim();
4868
+ const startMs = parseTimestampSRT(startRaw);
4869
+ const endMs = parseTimestampSRT(endRaw);
4870
+ if (startMs < 0 || endMs < 0) return null;
4871
+ return { startMs, endMs };
4872
+ }
4873
+ function parseTimestampVTT(raw) {
4874
+ const dotIdx = raw.lastIndexOf(".");
4875
+ if (dotIdx === -1) return -1;
4876
+ const msStr = raw.substring(dotIdx + 1);
4877
+ const ms = parseIntFast(msStr);
4878
+ if (ms < 0) return -1;
4879
+ const beforeDot = raw.substring(0, dotIdx);
4880
+ const parts = beforeDot.split(":");
4881
+ if (parts.length === 2) {
4882
+ const minutes = parseIntFast(parts[0]);
4883
+ const seconds = parseIntFast(parts[1]);
4884
+ if (minutes < 0 || seconds < 0) return -1;
4885
+ return minutes * 6e4 + seconds * 1e3 + ms;
4886
+ }
4887
+ if (parts.length === 3) {
4888
+ const hours = parseIntFast(parts[0]);
4889
+ const minutes = parseIntFast(parts[1]);
4890
+ const seconds = parseIntFast(parts[2]);
4891
+ if (hours < 0 || minutes < 0 || seconds < 0) return -1;
4892
+ return hours * 36e5 + minutes * 6e4 + seconds * 1e3 + ms;
4893
+ }
4894
+ return -1;
4895
+ }
4896
+ function parseTimestampSRT(raw) {
4897
+ const commaIdx = raw.lastIndexOf(",");
4898
+ if (commaIdx === -1) return -1;
4899
+ const msStr = raw.substring(commaIdx + 1);
4900
+ const ms = parseIntFast(msStr);
4901
+ if (ms < 0) return -1;
4902
+ const beforeComma = raw.substring(0, commaIdx);
4903
+ const parts = beforeComma.split(":");
4904
+ if (parts.length !== 3) return -1;
4905
+ const hours = parseIntFast(parts[0]);
4906
+ const minutes = parseIntFast(parts[1]);
4907
+ const seconds = parseIntFast(parts[2]);
4908
+ if (hours < 0 || minutes < 0 || seconds < 0) return -1;
4909
+ return hours * 36e5 + minutes * 6e4 + seconds * 1e3 + ms;
4910
+ }
4911
+ function parseIntFast(str) {
4912
+ let result = 0;
4913
+ for (let i = 0; i < str.length; i++) {
4914
+ const code = str.charCodeAt(i);
4915
+ if (code < 48 || code > 57) return -1;
4916
+ result = result * 10 + (code - 48);
4917
+ }
4918
+ return result;
4919
+ }
4920
+ var MARKUP_TAG_REGEX = /<[^>]+>/g;
4921
+ function stripMarkupTags(text) {
4922
+ return text.replace(MARKUP_TAG_REGEX, "");
4923
+ }
4924
+ var INLINE_TIMESTAMP_REGEX = /<(\d{2}:)?(\d{2}):(\d{2}\.\d{3})>/g;
4925
+ function extractInlineTimestamps(text) {
4926
+ const timestamps = [];
4927
+ let cleanText = "";
4928
+ let lastIndex = 0;
4929
+ let match;
4930
+ INLINE_TIMESTAMP_REGEX.lastIndex = 0;
4931
+ while ((match = INLINE_TIMESTAMP_REGEX.exec(text)) !== null) {
4932
+ cleanText += text.substring(lastIndex, match.index);
4933
+ const position = cleanText.length;
4934
+ const hoursStr = match[1] ? match[1].substring(0, match[1].length - 1) : "00";
4935
+ const minutesStr = match[2];
4936
+ const secondsAndMs = match[3];
4937
+ const dotIdx = secondsAndMs.indexOf(".");
4938
+ const secondsStr = secondsAndMs.substring(0, dotIdx);
4939
+ const msStr = secondsAndMs.substring(dotIdx + 1);
4940
+ const hours = parseIntFast(hoursStr);
4941
+ const minutes = parseIntFast(minutesStr);
4942
+ const seconds = parseIntFast(secondsStr);
4943
+ const ms = parseIntFast(msStr);
4944
+ if (hours >= 0 && minutes >= 0 && seconds >= 0 && ms >= 0) {
4945
+ const timeMs = hours * 36e5 + minutes * 6e4 + seconds * 1e3 + ms;
4946
+ timestamps.push({ timeMs, position });
4947
+ }
4948
+ lastIndex = match.index + match[0].length;
4949
+ }
4950
+ cleanText += text.substring(lastIndex);
4951
+ return { cleanText, timestamps };
4952
+ }
4953
+ function distributeCueToWords(cue) {
4954
+ const wordTexts = cue.text.split(/\s+/).filter((w) => w.length > 0);
4955
+ if (wordTexts.length === 0) return [];
4956
+ if (wordTexts.length === 1) {
4957
+ return [{ text: wordTexts[0], start: cue.startMs, end: cue.endMs }];
4958
+ }
4959
+ if (cue.inlineTimestamps.length > 0) {
4960
+ return distributeWithInlineTimestamps(wordTexts, cue);
4961
+ }
4962
+ return distributeByCharacterProportion(wordTexts, cue.startMs, cue.endMs);
4963
+ }
4964
+ function distributeWithInlineTimestamps(wordTexts, cue) {
4965
+ const wordPositions = [];
4966
+ let charPos = 0;
4967
+ for (let i = 0; i < wordTexts.length; i++) {
4968
+ wordPositions.push(charPos);
4969
+ charPos += wordTexts[i].length + 1;
4970
+ }
4971
+ const sortedTimestamps = [...cue.inlineTimestamps].sort((a, b) => a.position - b.position);
4972
+ const wordStartTimes = new Array(wordTexts.length);
4973
+ wordStartTimes[0] = cue.startMs;
4974
+ for (let i = 1; i < wordTexts.length; i++) {
4975
+ const wp = wordPositions[i];
4976
+ let bestTs = -1;
4977
+ for (let t = 0; t < sortedTimestamps.length; t++) {
4978
+ if (sortedTimestamps[t].position <= wp) {
4979
+ bestTs = t;
4980
+ }
4981
+ }
4982
+ if (bestTs >= 0) {
4983
+ wordStartTimes[i] = sortedTimestamps[bestTs].timeMs;
4984
+ } else {
4985
+ wordStartTimes[i] = wordStartTimes[i - 1];
4986
+ }
4987
+ }
4988
+ const words = [];
4989
+ for (let i = 0; i < wordTexts.length; i++) {
4990
+ const start = wordStartTimes[i];
4991
+ const end = i < wordTexts.length - 1 ? wordStartTimes[i + 1] : cue.endMs;
4992
+ words.push({ text: wordTexts[i], start, end: Math.max(end, start) });
4993
+ }
4994
+ return words;
4995
+ }
4996
+ function distributeByCharacterProportion(wordTexts, startMs, endMs) {
4997
+ const totalChars = wordTexts.reduce((sum, w) => sum + w.length, 0);
4998
+ const duration = endMs - startMs;
4999
+ const words = [];
5000
+ let cursor = startMs;
5001
+ for (let i = 0; i < wordTexts.length; i++) {
5002
+ const wordStart = cursor;
5003
+ if (i === wordTexts.length - 1) {
5004
+ words.push({ text: wordTexts[i], start: wordStart, end: endMs });
5005
+ } else {
5006
+ const proportion = wordTexts[i].length / totalChars;
5007
+ const wordDuration = Math.round(proportion * duration);
5008
+ cursor = wordStart + wordDuration;
5009
+ words.push({ text: wordTexts[i], start: wordStart, end: cursor });
5010
+ }
5011
+ }
5012
+ return words;
5013
+ }
5014
+ function findLineEnd(content, pos) {
5015
+ const idx = content.indexOf("\n", pos);
5016
+ return idx === -1 ? content.length : idx;
5017
+ }
5018
+ function skipWhitespaceAndNewlines(content, pos) {
5019
+ while (pos < content.length) {
5020
+ const ch = content.charCodeAt(pos);
5021
+ if (ch === 10 || ch === 13 || ch === 32 || ch === 9) {
5022
+ pos++;
5023
+ } else {
5024
+ break;
5025
+ }
5026
+ }
5027
+ return pos;
5028
+ }
5029
+ function skipBlock(content, pos) {
5030
+ while (pos < content.length) {
5031
+ const lineEnd = findLineEnd(content, pos);
5032
+ const line = content.substring(pos, lineEnd);
5033
+ pos = lineEnd + 1;
5034
+ if (line.length === 0) break;
5035
+ }
5036
+ return pos;
5037
+ }
5038
+
5039
+ // src/core/video/frame-scheduler.ts
5040
+ var PER_FRAME_ANIMATION_STYLES = /* @__PURE__ */ new Set([
5041
+ "karaoke",
5042
+ "typewriter"
5043
+ ]);
5044
+ var TRANSITION_ANIMATION_STYLES = /* @__PURE__ */ new Set([
5045
+ "pop",
5046
+ "fade",
5047
+ "slide",
5048
+ "bounce"
5049
+ ]);
5050
+ var ANIMATION_DURATION_MS = {
5051
+ pop: 200,
5052
+ fade: 150,
5053
+ slide: 250,
5054
+ bounce: 400
5055
+ };
5056
+ function findGroupIndexAtTime(groups, timeMs) {
5057
+ for (let i = 0; i < groups.length; i++) {
5058
+ if (timeMs >= groups[i].startTime && timeMs <= groups[i].endTime) {
5059
+ return i;
5060
+ }
5061
+ }
5062
+ return -1;
5063
+ }
5064
+ function findActiveWordIndex(store, groupWordIndices, timeMs) {
5065
+ for (const idx of groupWordIndices) {
5066
+ if (timeMs >= store.startTimes[idx] && timeMs < store.endTimes[idx]) {
5067
+ return idx;
5068
+ }
5069
+ }
5070
+ return -1;
5071
+ }
5072
+ function getAnimationPhase(store, groupWordIndices, timeMs, animationStyle, speed) {
5073
+ if (groupWordIndices.length === 0) {
5074
+ return "idle";
5075
+ }
5076
+ const activeWordIdx = findActiveWordIndex(store, groupWordIndices, timeMs);
5077
+ if (PER_FRAME_ANIMATION_STYLES.has(animationStyle)) {
5078
+ if (activeWordIdx !== -1) {
5079
+ return "animating";
5080
+ }
5081
+ for (const idx of groupWordIndices) {
5082
+ if (timeMs < store.startTimes[idx]) {
5083
+ return "before";
5084
+ }
5085
+ }
5086
+ return "after";
5087
+ }
5088
+ if (TRANSITION_ANIMATION_STYLES.has(animationStyle)) {
5089
+ const transitionDurationMs = (ANIMATION_DURATION_MS[animationStyle] ?? 200) / speed;
5090
+ for (const idx of groupWordIndices) {
5091
+ const wordStart = store.startTimes[idx];
5092
+ if (timeMs >= wordStart && timeMs < wordStart + transitionDurationMs) {
5093
+ return "animating";
5094
+ }
5095
+ }
5096
+ if (activeWordIdx !== -1) {
5097
+ return "active";
5098
+ }
5099
+ for (const idx of groupWordIndices) {
5100
+ if (timeMs < store.startTimes[idx]) {
5101
+ return "before";
5102
+ }
5103
+ }
5104
+ return "after";
5105
+ }
5106
+ if (activeWordIdx !== -1) {
5107
+ return "active";
5108
+ }
5109
+ return "before";
5110
+ }
5111
+ function computeStateSignature(layout, timeMs, animationStyle, speed) {
5112
+ const groupIndex = findGroupIndexAtTime(layout.groups, timeMs);
5113
+ if (groupIndex === -1) {
5114
+ return { groupIndex: -1, activeWordIndex: -1, animationPhase: "idle" };
5115
+ }
5116
+ const group = layout.groups[groupIndex];
5117
+ const activeWordIndex = findActiveWordIndex(layout.store, group.wordIndices, timeMs);
5118
+ const animationPhase = getAnimationPhase(
5119
+ layout.store,
5120
+ group.wordIndices,
5121
+ timeMs,
5122
+ animationStyle,
5123
+ speed
5124
+ );
5125
+ return { groupIndex, activeWordIndex, animationPhase };
5126
+ }
5127
+ function signaturesMatch(a, b) {
5128
+ return a.groupIndex === b.groupIndex && a.activeWordIndex === b.activeWordIndex && a.animationPhase === b.animationPhase;
5129
+ }
5130
+ function createFrameSchedule(layout, durationMs, fps, animationStyle = "highlight", speed = 1) {
5131
+ const totalFrames = Math.max(2, Math.round(durationMs / 1e3 * fps) + 1);
5132
+ const renderFrames = [];
5133
+ let previousSignature = null;
5134
+ for (let frame = 0; frame < totalFrames; frame++) {
5135
+ const timeMs = frame / (totalFrames - 1) * durationMs;
5136
+ const signature = computeStateSignature(layout, timeMs, animationStyle, speed);
5137
+ const isAnimating = signature.animationPhase === "animating";
5138
+ if (isAnimating || previousSignature === null || !signaturesMatch(signature, previousSignature)) {
5139
+ renderFrames.push({
5140
+ frameIndex: frame,
5141
+ repeatCount: 1,
5142
+ timeMs
5143
+ });
5144
+ } else {
5145
+ renderFrames[renderFrames.length - 1].repeatCount++;
5146
+ }
5147
+ previousSignature = signature;
5148
+ }
5149
+ const uniqueFrameCount = renderFrames.length;
5150
+ const skipRatio = 1 - uniqueFrameCount / totalFrames;
5151
+ return {
5152
+ renderFrames,
5153
+ totalFrames,
5154
+ uniqueFrameCount,
5155
+ skipRatio
5156
+ };
5157
+ }
5158
+
5159
+ // src/core/video/node-raw-encoder.ts
5160
+ import { spawn as spawn2 } from "child_process";
5161
+ import fs2 from "fs";
5162
+ var NodeRawEncoder = class _NodeRawEncoder {
5163
+ ffmpegPath = null;
5164
+ ffmpegProcess = null;
5165
+ config = null;
5166
+ outputPath = "";
5167
+ frameCount = 0;
5168
+ totalFrames = 0;
5169
+ startTime = 0;
5170
+ chunks = [];
5171
+ outputToMemory = false;
5172
+ ffmpegError = null;
5173
+ static DRAIN_TIMEOUT_MS = 3e4;
5174
+ onProgress;
5175
+ trySetPath(p) {
5176
+ if (p && fs2.existsSync(p)) {
5177
+ this.ffmpegPath = p;
5178
+ return true;
5179
+ }
5180
+ return false;
5181
+ }
5182
+ async initFFmpeg(ffmpegPath) {
5183
+ if (this.trySetPath(ffmpegPath)) return;
5184
+ if (this.trySetPath(process.env.FFMPEG_PATH)) return;
5185
+ if (this.trySetPath(process.env.FFMPEG_BIN)) return;
5186
+ if (this.trySetPath("/opt/bin/ffmpeg")) return;
5187
+ try {
5188
+ const ffmpegStatic = await import("ffmpeg-static");
5189
+ const p = ffmpegStatic.default;
5190
+ if (this.trySetPath(p)) return;
5191
+ } catch {
5192
+ }
5193
+ throw new Error("FFmpeg not available. Please install ffmpeg-static or provide FFMPEG_PATH.");
5194
+ }
5195
+ async configure(config, options) {
5196
+ this.config = config;
5197
+ this.outputPath = options?.outputPath || "";
5198
+ this.outputToMemory = !this.outputPath;
5199
+ this.totalFrames = Math.max(2, Math.round(config.duration * config.fps) + 1);
5200
+ this.frameCount = 0;
5201
+ this.startTime = Date.now();
5202
+ this.chunks = [];
5203
+ this.ffmpegError = null;
5204
+ await this.initFFmpeg(options?.ffmpegPath);
5205
+ const {
5206
+ width,
5207
+ height,
5208
+ fps,
5209
+ crf = 17,
5210
+ preset = "ultrafast",
5211
+ profile = "high"
5212
+ } = config;
5213
+ const args = [
5214
+ "-y",
5215
+ "-f",
5216
+ "rawvideo",
5217
+ "-pix_fmt",
5218
+ "rgba",
5219
+ "-s",
5220
+ `${width}x${height}`,
5221
+ "-r",
5222
+ String(fps),
5223
+ "-thread_queue_size",
5224
+ "512",
5225
+ "-i",
5226
+ "pipe:0",
5227
+ "-c:v",
5228
+ "libx264",
5229
+ "-preset",
5230
+ preset,
5231
+ "-tune",
5232
+ "stillimage",
5233
+ "-crf",
5234
+ String(crf),
5235
+ "-profile:v",
5236
+ profile,
5237
+ "-g",
5238
+ "300",
5239
+ "-bf",
5240
+ "2",
5241
+ "-threads",
5242
+ "0",
5243
+ "-pix_fmt",
5244
+ "yuv420p",
5245
+ "-r",
5246
+ String(fps),
5247
+ "-movflags",
5248
+ "+faststart"
5249
+ ];
5250
+ if (this.outputToMemory) {
5251
+ args.push("-f", "mp4", "pipe:1");
5252
+ } else {
5253
+ args.push(this.outputPath);
5254
+ }
5255
+ this.ffmpegProcess = spawn2(this.ffmpegPath, args, {
5256
+ stdio: ["pipe", this.outputToMemory ? "pipe" : "inherit", "pipe"]
5257
+ });
5258
+ if (this.outputToMemory && this.ffmpegProcess.stdout) {
5259
+ this.ffmpegProcess.stdout.on("data", (chunk) => {
5260
+ this.chunks.push(chunk);
5261
+ });
5262
+ }
5263
+ this.ffmpegProcess.on("error", (err) => {
5264
+ this.ffmpegError = err;
5265
+ });
5266
+ this.ffmpegProcess.stderr?.on("data", () => {
5267
+ });
5268
+ }
5269
+ async encodeFrame(frameData, _frameIndex) {
5270
+ if (this.ffmpegError) {
5271
+ throw this.ffmpegError;
5272
+ }
5273
+ if (!this.ffmpegProcess || !this.ffmpegProcess.stdin) {
5274
+ throw new Error("FFmpeg process not initialized. Call configure() first.");
5275
+ }
5276
+ const buffer = this.toBuffer(frameData);
5277
+ const ok = this.ffmpegProcess.stdin.write(buffer);
5278
+ if (!ok) {
5279
+ await this.waitForDrain();
5280
+ }
5281
+ this.frameCount++;
5282
+ this.reportProgress();
5283
+ }
5284
+ async encodeFrameRepeat(frameData, repeatCount) {
5285
+ if (this.ffmpegError) {
5286
+ throw this.ffmpegError;
5287
+ }
5288
+ if (!this.ffmpegProcess || !this.ffmpegProcess.stdin) {
5289
+ throw new Error("FFmpeg process not initialized. Call configure() first.");
5290
+ }
5291
+ const buffer = this.toBuffer(frameData);
5292
+ for (let i = 0; i < repeatCount; i++) {
5293
+ const ok = this.ffmpegProcess.stdin.write(buffer);
5294
+ if (!ok) {
5295
+ await this.waitForDrain();
5296
+ }
5297
+ this.frameCount++;
5298
+ }
5299
+ this.reportProgress();
5300
+ }
5301
+ async flush() {
5302
+ if (!this.ffmpegProcess) {
5303
+ throw new Error("FFmpeg process not initialized.");
5304
+ }
5305
+ return new Promise((resolve, reject) => {
5306
+ this.ffmpegProcess.on("close", (code) => {
5307
+ if (code === 0) {
5308
+ if (this.outputToMemory) {
5309
+ const result = Buffer.concat(this.chunks);
5310
+ resolve(new Uint8Array(result));
5311
+ } else {
5312
+ const fileBuffer = fs2.readFileSync(this.outputPath);
5313
+ resolve(new Uint8Array(fileBuffer));
5314
+ }
5315
+ } else {
5316
+ reject(new Error(`FFmpeg exited with code ${code}`));
5317
+ }
5318
+ });
5319
+ this.ffmpegProcess.on("error", (err) => {
5320
+ reject(err);
5321
+ });
5322
+ this.ffmpegProcess.stdin?.end();
5323
+ });
5324
+ }
5325
+ close() {
5326
+ if (this.ffmpegProcess) {
5327
+ this.ffmpegProcess.kill("SIGTERM");
5328
+ this.ffmpegProcess = null;
5329
+ }
5330
+ this.chunks = [];
5331
+ }
5332
+ waitForDrain() {
5333
+ return new Promise((resolve, reject) => {
5334
+ const timer = setTimeout(() => {
5335
+ reject(new Error("FFmpeg stdin drain timeout"));
5336
+ }, _NodeRawEncoder.DRAIN_TIMEOUT_MS);
5337
+ const onError = (err) => {
5338
+ clearTimeout(timer);
5339
+ reject(err);
5340
+ };
5341
+ this.ffmpegProcess.once("error", onError);
5342
+ this.ffmpegProcess.stdin.once("drain", () => {
5343
+ clearTimeout(timer);
5344
+ this.ffmpegProcess?.removeListener("error", onError);
5345
+ resolve();
5346
+ });
5347
+ });
5348
+ }
5349
+ toBuffer(frameData) {
5350
+ if (frameData instanceof ArrayBuffer) {
5351
+ return Buffer.from(frameData);
5352
+ }
5353
+ return Buffer.from(frameData.buffer, frameData.byteOffset, frameData.byteLength);
5354
+ }
5355
+ reportProgress() {
5356
+ if (!this.onProgress) return;
5357
+ const elapsedMs = Date.now() - this.startTime;
5358
+ if (elapsedMs === 0) return;
5359
+ const framesPerSecond = this.frameCount / (elapsedMs / 1e3);
5360
+ const remainingFrames = this.totalFrames - this.frameCount;
5361
+ const estimatedRemainingMs = remainingFrames / framesPerSecond * 1e3;
5362
+ this.onProgress({
5363
+ framesEncoded: this.frameCount,
5364
+ totalFrames: this.totalFrames,
5365
+ percentage: this.frameCount / this.totalFrames * 100,
5366
+ elapsedMs,
5367
+ estimatedRemainingMs: Math.round(estimatedRemainingMs),
5368
+ currentFps: Math.round(framesPerSecond * 10) / 10
5369
+ });
5370
+ }
5371
+ };
5372
+ async function createNodeRawEncoder(config, options) {
5373
+ const encoder = new NodeRawEncoder();
5374
+ await encoder.configure(config, options);
5375
+ return encoder;
5376
+ }
5377
+
5378
+ // src/core/rich-caption-renderer.ts
5379
+ var ROBOTO_FONT_URLS = {
5380
+ "100": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbGmT.ttf",
5381
+ "300": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabWmT.ttf",
5382
+ "400": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbWmT.ttf",
5383
+ "500": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bWmT.ttf",
5384
+ "600": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYaammT.ttf",
5385
+ "700": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjammT.ttf",
5386
+ "800": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZEammT.ttf",
5387
+ "900": "https://fonts.gstatic.com/s/roboto/v50/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtammT.ttf"
5388
+ };
5389
+ var RichCaptionRenderer = class {
5390
+ width;
5391
+ height;
5392
+ pixelRatio;
5393
+ fps;
5394
+ wasmBaseURL;
5395
+ fetchFile;
5396
+ fontRegistry = null;
5397
+ layoutEngine = null;
5398
+ currentAsset = null;
5399
+ currentLayout = null;
5400
+ generatorConfig;
5401
+ frameCount = 0;
5402
+ totalRenderTimeMs = 0;
5403
+ peakMemoryMB = 0;
5404
+ lastMemoryCheckFrame = 0;
5405
+ constructor(options) {
5406
+ this.width = options.width;
5407
+ this.height = options.height;
5408
+ this.pixelRatio = options.pixelRatio ?? 1;
5409
+ this.fps = options.fps ?? 30;
5410
+ this.wasmBaseURL = options.wasmBaseURL;
5411
+ this.fetchFile = options.fetchFile ?? loadFileOrHttpToArrayBuffer;
5412
+ this.generatorConfig = createDefaultGeneratorConfig(this.width, this.height, this.pixelRatio);
5413
+ }
5414
+ async initialize() {
5415
+ this.fontRegistry = await FontRegistry.getSharedInstance(this.wasmBaseURL);
5416
+ this.layoutEngine = new CaptionLayoutEngine(this.fontRegistry);
5417
+ const weightsToLoad = Object.keys(ROBOTO_FONT_URLS);
5418
+ const loadPromises = weightsToLoad.map(async (weight) => {
5419
+ const existingFace = await this.fontRegistry.getFace({ family: "Roboto", weight });
5420
+ if (!existingFace) {
5421
+ const bytes = await loadFileOrHttpToArrayBuffer(ROBOTO_FONT_URLS[weight]);
5422
+ await this.fontRegistry.registerFromBytes(bytes, { family: "Roboto", weight });
5423
+ }
5424
+ });
5425
+ await Promise.all(loadPromises);
5426
+ }
5427
+ async registerFont(source, desc) {
5428
+ if (!this.fontRegistry) {
5429
+ throw new Error("Renderer not initialized. Call initialize() first.");
5430
+ }
5431
+ const bytes = await loadFileOrHttpToArrayBuffer(source);
5432
+ await this.fontRegistry.registerFromBytes(bytes, desc);
5433
+ }
5434
+ async loadAsset(asset) {
5435
+ if (!this.layoutEngine || !this.fontRegistry) {
5436
+ throw new Error("Renderer not initialized. Call initialize() first.");
5437
+ }
5438
+ this.currentAsset = asset;
5439
+ let words;
5440
+ if (asset.src) {
5441
+ const bytes = await this.fetchFile(asset.src);
5442
+ const text = new TextDecoder().decode(bytes);
5443
+ words = parseSubtitleToWords(text);
5444
+ } else {
5445
+ words = (asset.words ?? []).map((w) => ({
5446
+ text: w.text,
5447
+ start: w.start,
5448
+ end: w.end,
5449
+ confidence: w.confidence
5450
+ }));
5451
+ }
5452
+ if (words.length === 0) {
5453
+ this.currentLayout = null;
5454
+ return;
5455
+ }
5456
+ const font = asset.font;
5457
+ const style = asset.style;
5458
+ const measureTextWidth = await createCanvasTextMeasurer();
5459
+ const layoutConfig = {
5460
+ frameWidth: this.width,
5461
+ frameHeight: this.height,
5462
+ maxWidth: asset.maxWidth ?? 0.9,
5463
+ maxLines: asset.maxLines ?? 2,
5464
+ position: asset.position ?? "bottom",
5465
+ fontSize: font?.size ?? 24,
5466
+ fontFamily: font?.family ?? "Roboto",
5467
+ fontWeight: String(font?.weight ?? "400"),
5468
+ letterSpacing: style?.letterSpacing ?? 0,
5469
+ wordSpacing: typeof style?.wordSpacing === "number" ? style.wordSpacing : 0,
5470
+ lineHeight: style?.lineHeight ?? 1.2,
5471
+ textTransform: style?.textTransform ?? "none",
5472
+ pauseThreshold: 500,
5473
+ measureTextWidth
5474
+ };
5475
+ this.currentLayout = await this.layoutEngine.layoutCaption(words, layoutConfig);
5476
+ }
5477
+ renderFrame(timeMs) {
5478
+ if (!this.currentAsset || !this.currentLayout || !this.layoutEngine) {
5479
+ return [];
5480
+ }
5481
+ const startTime = performance.now();
5482
+ const ops = generateRichCaptionDrawOps(
5483
+ this.currentAsset,
5484
+ this.currentLayout,
5485
+ timeMs,
5486
+ this.layoutEngine,
5487
+ this.generatorConfig
5488
+ );
5489
+ const endTime = performance.now();
5490
+ this.totalRenderTimeMs += endTime - startTime;
5491
+ this.frameCount++;
5492
+ if (this.frameCount - this.lastMemoryCheckFrame >= 1e3) {
5493
+ this.checkMemoryUsage();
5494
+ this.lastMemoryCheckFrame = this.frameCount;
5495
+ }
5496
+ return ops;
5497
+ }
5498
+ async generateVideo(outputPath, duration, options) {
5499
+ if (!this.currentAsset || !this.currentLayout) {
5500
+ throw new Error("No asset loaded. Call loadAsset() first.");
5501
+ }
5502
+ const animationStyle = this.extractAnimationStyle();
5503
+ const animationSpeed = this.extractAnimationSpeed();
5504
+ const durationMs = duration * 1e3;
5505
+ const schedule = createFrameSchedule(
5506
+ this.currentLayout,
5507
+ durationMs,
5508
+ this.fps,
5509
+ animationStyle,
5510
+ animationSpeed
5511
+ );
5512
+ const encoder = new NodeRawEncoder();
5513
+ await encoder.configure(
5514
+ {
5515
+ width: this.width * this.pixelRatio,
5516
+ height: this.height * this.pixelRatio,
5517
+ fps: this.fps,
5518
+ duration,
5519
+ crf: options?.crf ?? 23,
5520
+ preset: options?.preset ?? "ultrafast",
5521
+ profile: options?.profile ?? "high"
5522
+ },
5523
+ {
5524
+ outputPath,
5525
+ ffmpegPath: options?.ffmpegPath
5526
+ }
5527
+ );
5528
+ const painter = await createNodePainter({
5529
+ width: this.width,
5530
+ height: this.height,
5531
+ pixelRatio: this.pixelRatio
5532
+ });
5533
+ const bgColor = options?.bgColor ?? "#000000";
5534
+ const totalStart = performance.now();
5535
+ let framesProcessed = 0;
5536
+ let lastPct = -1;
5537
+ try {
5538
+ for (let i = 0; i < schedule.renderFrames.length; i++) {
5539
+ const renderFrame = schedule.renderFrames[i];
5540
+ const captionOps = this.renderFrame(renderFrame.timeMs);
5541
+ const beginOp = {
5542
+ op: "BeginFrame",
5543
+ width: this.width * this.pixelRatio,
5544
+ height: this.height * this.pixelRatio,
5545
+ pixelRatio: this.pixelRatio,
5546
+ clear: true,
5547
+ bg: { color: bgColor, opacity: 1, radius: 0 }
5548
+ };
5549
+ await painter.render([beginOp, ...captionOps]);
5550
+ const rawResult = painter.toRawRGBA();
5551
+ await encoder.encodeFrameRepeat(rawResult.data, renderFrame.repeatCount);
5552
+ framesProcessed += renderFrame.repeatCount;
5553
+ const pct = Math.floor(framesProcessed / schedule.totalFrames * 100);
5554
+ if (pct % 5 === 0 && pct !== lastPct) {
5555
+ lastPct = pct;
5556
+ const elapsed = performance.now() - totalStart;
5557
+ const fps = framesProcessed / (elapsed / 1e3);
5558
+ const eta = (schedule.totalFrames - framesProcessed) / fps * 1e3;
5559
+ this.logProgress(pct, framesProcessed, schedule.totalFrames, i + 1, schedule.uniqueFrameCount, fps, eta);
5560
+ }
5561
+ if (i % 500 === 0 && i > 0) {
5562
+ this.checkMemoryUsage();
5563
+ if (typeof global !== "undefined" && global.gc) {
5564
+ global.gc();
5565
+ }
5566
+ }
5567
+ }
5568
+ await encoder.flush();
5569
+ const totalTimeMs = performance.now() - totalStart;
5570
+ const realtimeMultiplier = duration / (totalTimeMs / 1e3);
5571
+ this.logCompletion(totalTimeMs, realtimeMultiplier);
5572
+ return outputPath;
5573
+ } catch (error) {
5574
+ encoder.close();
5575
+ throw error;
5576
+ }
5577
+ }
5578
+ async generateVideoLegacy(outputPath, duration, options) {
5579
+ if (!this.currentAsset || !this.currentLayout) {
5580
+ throw new Error("No asset loaded. Call loadAsset() first.");
5581
+ }
5582
+ const videoGenerator = new VideoGenerator();
5583
+ const frameGenerator = async (timeSeconds) => {
5584
+ const timeMs = timeSeconds * 1e3;
5585
+ const ops = this.renderFrame(timeMs);
5586
+ const beginFrameOp = {
5587
+ op: "BeginFrame",
5588
+ width: this.width * this.pixelRatio,
5589
+ height: this.height * this.pixelRatio,
5590
+ pixelRatio: this.pixelRatio,
5591
+ clear: true,
5592
+ bg: {
5593
+ color: options?.bgColor ?? "#000000",
5594
+ opacity: 1,
5595
+ radius: 0
5596
+ }
5597
+ };
5598
+ return [beginFrameOp, ...ops];
5599
+ };
5600
+ const videoOptions = {
5601
+ width: this.width,
5602
+ height: this.height,
5603
+ fps: this.fps,
5604
+ duration,
5605
+ outputPath,
5606
+ pixelRatio: this.pixelRatio,
5607
+ hasAlpha: false,
5608
+ ...options
5609
+ };
5610
+ return videoGenerator.generateVideo(frameGenerator, videoOptions);
5611
+ }
5612
+ async generateVideoWithChunking(outputPath, duration, options) {
5613
+ if (!this.currentAsset || !this.currentLayout) {
5614
+ throw new Error("No asset loaded. Call loadAsset() first.");
5615
+ }
5616
+ const videoGenerator = new VideoGenerator();
5617
+ const chunkSize = 1e3;
5618
+ let processedFrames = 0;
5619
+ const frameGenerator = async (timeSeconds) => {
5620
+ const timeMs = timeSeconds * 1e3;
5621
+ const ops = this.renderFrame(timeMs);
5622
+ processedFrames++;
5623
+ if (processedFrames % chunkSize === 0) {
5624
+ this.checkMemoryUsage();
5625
+ if (typeof global !== "undefined" && global.gc) {
5626
+ global.gc();
5627
+ }
5628
+ }
5629
+ const beginFrameOp = {
5630
+ op: "BeginFrame",
5631
+ width: this.width * this.pixelRatio,
5632
+ height: this.height * this.pixelRatio,
5633
+ pixelRatio: this.pixelRatio,
5634
+ clear: true,
5635
+ bg: {
5636
+ color: options?.bgColor ?? "#000000",
5637
+ opacity: 1,
5638
+ radius: 0
5639
+ }
5640
+ };
5641
+ return [beginFrameOp, ...ops];
5642
+ };
5643
+ const videoOptions = {
5644
+ width: this.width,
5645
+ height: this.height,
5646
+ fps: this.fps,
5647
+ duration,
5648
+ outputPath,
5649
+ pixelRatio: this.pixelRatio,
5650
+ hasAlpha: false,
5651
+ ...options
5652
+ };
5653
+ return videoGenerator.generateVideo(frameGenerator, videoOptions);
5654
+ }
5655
+ getFrameSchedule(duration) {
5656
+ if (!this.currentLayout) {
5657
+ throw new Error("No asset loaded. Call loadAsset() first.");
5658
+ }
5659
+ const animationStyle = this.extractAnimationStyle();
5660
+ const animationSpeed = this.extractAnimationSpeed();
5661
+ return createFrameSchedule(
5662
+ this.currentLayout,
5663
+ duration * 1e3,
5664
+ this.fps,
5665
+ animationStyle,
5666
+ animationSpeed
5667
+ );
5668
+ }
5669
+ getStats() {
5670
+ const cacheStats = this.layoutEngine?.getCacheStats() ?? { size: 0, calculatedSize: 0 };
5671
+ return {
5672
+ frameCount: this.frameCount,
5673
+ totalRenderTimeMs: this.totalRenderTimeMs,
5674
+ averageFrameTimeMs: this.frameCount > 0 ? this.totalRenderTimeMs / this.frameCount : 0,
5675
+ peakMemoryMB: this.peakMemoryMB,
5676
+ cacheHitRate: cacheStats.size > 0 ? 0.95 : 0
5677
+ };
5678
+ }
5679
+ resetStats() {
5680
+ this.frameCount = 0;
5681
+ this.totalRenderTimeMs = 0;
5682
+ this.peakMemoryMB = 0;
5683
+ this.lastMemoryCheckFrame = 0;
5684
+ }
5685
+ clearCache() {
5686
+ this.layoutEngine?.clearCache();
5687
+ }
5688
+ extractAnimationStyle() {
5689
+ const wordAnim = this.currentAsset?.wordAnimation;
5690
+ return wordAnim?.style ?? "highlight";
5691
+ }
5692
+ extractAnimationSpeed() {
5693
+ const wordAnim = this.currentAsset?.wordAnimation;
5694
+ return wordAnim?.speed ?? 1;
5695
+ }
5696
+ logProgress(pct, framesProcessed, totalFrames, uniqueProcessed, uniqueTotal, fps, eta) {
5697
+ if (typeof process !== "undefined" && process.stderr) {
5698
+ process.stderr.write(
5699
+ ` [${String(pct).padStart(3)}%] Frame ${framesProcessed}/${totalFrames} (${uniqueProcessed}/${uniqueTotal} unique) | ${fps.toFixed(1)} fps | ETA: ${formatMs(eta)}
5700
+ `
5701
+ );
5702
+ }
5703
+ }
5704
+ logCompletion(totalTimeMs, realtimeMultiplier) {
5705
+ if (typeof process !== "undefined" && process.stderr) {
5706
+ process.stderr.write(
5707
+ ` Done: ${formatMs(totalTimeMs)} (${realtimeMultiplier.toFixed(1)}x realtime)
5708
+ `
5709
+ );
5710
+ }
5711
+ }
5712
+ checkMemoryUsage() {
5713
+ if (typeof process !== "undefined" && process.memoryUsage) {
5714
+ const usage = process.memoryUsage();
5715
+ const heapUsedMB = usage.heapUsed / (1024 * 1024);
5716
+ if (heapUsedMB > this.peakMemoryMB) {
5717
+ this.peakMemoryMB = heapUsedMB;
5718
+ }
5719
+ if (usage.heapUsed > 1500 * 1024 * 1024) {
5720
+ if (typeof global !== "undefined" && global.gc) {
5721
+ global.gc();
5722
+ }
5723
+ }
5724
+ }
5725
+ }
5726
+ destroy() {
5727
+ this.currentAsset = null;
5728
+ this.currentLayout = null;
5729
+ this.layoutEngine?.clearCache();
5730
+ if (this.fontRegistry) {
5731
+ this.fontRegistry.release();
5732
+ this.fontRegistry = null;
5733
+ }
5734
+ this.layoutEngine = null;
5735
+ }
5736
+ };
5737
+ function formatMs(ms) {
5738
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
5739
+ if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
5740
+ return `${Math.floor(ms / 6e4)}m ${(ms % 6e4 / 1e3).toFixed(0)}s`;
5741
+ }
5742
+ async function createRichCaptionRenderer(options) {
5743
+ const renderer = new RichCaptionRenderer(options);
5744
+ await renderer.initialize();
5745
+ return renderer;
5746
+ }
5747
+
5748
+ // src/core/video/encoder-factory.ts
5749
+ async function createVideoEncoder(config, options) {
5750
+ const platform = options?.platform ?? detectPlatform();
5751
+ if (platform === "node") {
5752
+ throw new Error("Use createNodeRawEncoder from node-raw-encoder module for Node.js encoding");
5753
+ }
5754
+ if (options?.preferredEncoder === "mediarecorder") {
5755
+ return createMediaRecorderEncoder(config, options?.canvas);
5756
+ }
5757
+ const webCodecsSupported = await isWebCodecsH264Supported();
5758
+ if (webCodecsSupported) {
5759
+ try {
5760
+ const { WebCodecsEncoder } = await import("./web-encoder-7CLF7KX4.js");
5761
+ const encoder = new WebCodecsEncoder();
5762
+ await encoder.configure(config);
5763
+ return encoder;
5764
+ } catch (error) {
5765
+ console.warn("WebCodecs encoder failed to initialize, falling back to MediaRecorder:", error);
5766
+ }
5767
+ }
5768
+ return createMediaRecorderEncoder(config, options?.canvas);
5769
+ }
5770
+ async function createMediaRecorderEncoder(config, canvas) {
5771
+ const { MediaRecorderFallback } = await import("./mediarecorder-fallback-5JYZBGT3.js");
5772
+ const encoder = new MediaRecorderFallback();
5773
+ await encoder.configure(config, canvas);
5774
+ return encoder;
5775
+ }
5776
+ async function isWebCodecsH264Supported() {
5777
+ if (typeof globalThis === "undefined") return false;
5778
+ const VideoEncoder = globalThis.VideoEncoder;
5779
+ if (!VideoEncoder || typeof VideoEncoder.isConfigSupported !== "function") {
5780
+ return false;
5781
+ }
5782
+ try {
5783
+ const config = {
5784
+ codec: "avc1.42001E",
5785
+ width: 1920,
5786
+ height: 1080,
5787
+ bitrate: 8e6,
5788
+ framerate: 30
5789
+ };
5790
+ const support = await VideoEncoder.isConfigSupported(config);
5791
+ return support.supported === true;
5792
+ } catch {
5793
+ return false;
5794
+ }
5795
+ }
5796
+ async function getEncoderCapabilities() {
5797
+ const platform = detectPlatform();
5798
+ if (platform === "node") {
5799
+ return {
5800
+ encoder: "node-raw",
5801
+ codec: "h264",
5802
+ hardwareAccelerated: false,
5803
+ supportsH264: true
5804
+ };
5805
+ }
5806
+ const webCodecsSupported = await isWebCodecsH264Supported();
5807
+ if (webCodecsSupported) {
5808
+ return {
5809
+ encoder: "webcodecs",
5810
+ codec: "h264",
5811
+ hardwareAccelerated: true,
5812
+ supportsH264: true
5813
+ };
5814
+ }
5815
+ return {
5816
+ encoder: "mediarecorder",
5817
+ codec: "vp9",
5818
+ hardwareAccelerated: false,
5819
+ supportsH264: false
5820
+ };
5821
+ }
5822
+ function detectPlatform() {
5823
+ if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
5824
+ return "node";
5825
+ }
5826
+ return "web";
5827
+ }
5828
+ function getEncoderWarning() {
5829
+ const platform = detectPlatform();
5830
+ if (platform === "node") return null;
5831
+ if (typeof globalThis !== "undefined") {
5832
+ const VideoEncoder = globalThis.VideoEncoder;
5833
+ if (!VideoEncoder) {
5834
+ 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+.";
5835
+ }
5836
+ }
5837
+ return null;
5838
+ }
5839
+
3632
5840
  // src/env/entry.node.ts
3633
5841
  var registeredGlobalFonts = /* @__PURE__ */ new Set();
3634
5842
  async function registerColorEmojiWithCanvas(family, bytes) {
@@ -3947,22 +6155,47 @@ async function createTextEngine(opts = {}) {
3947
6155
  };
3948
6156
  }
3949
6157
  export {
6158
+ CanvasRichCaptionAssetSchema,
3950
6159
  CanvasRichTextAssetSchema,
3951
6160
  CanvasSvgAssetSchema,
6161
+ CaptionLayoutEngine,
6162
+ FontRegistry,
6163
+ NodeRawEncoder,
6164
+ RichCaptionRenderer,
6165
+ WordTimingStore,
3952
6166
  arcToCubicBeziers,
6167
+ calculateAnimationStatesForGroup,
3953
6168
  commandsToPathString,
3954
6169
  computeSimplePathBounds,
6170
+ createDefaultGeneratorConfig,
6171
+ createFrameSchedule,
3955
6172
  createNodePainter,
6173
+ createNodeRawEncoder,
6174
+ createRichCaptionRenderer,
3956
6175
  createTextEngine,
6176
+ createVideoEncoder,
6177
+ detectPlatform,
6178
+ detectSubtitleFormat,
6179
+ findWordAtTime,
6180
+ generateRichCaptionDrawOps,
6181
+ generateRichCaptionFrame,
3957
6182
  generateShapePathData,
3958
- isGlyphFill2 as isGlyphFill,
3959
- isShadowFill2 as isShadowFill,
6183
+ getDefaultAnimationConfig,
6184
+ getDrawCaptionWordOps,
6185
+ getEncoderCapabilities,
6186
+ getEncoderWarning,
6187
+ groupWordsByPause,
6188
+ isDrawCaptionWordOp,
6189
+ isRTLText,
6190
+ isWebCodecsH264Supported,
3960
6191
  normalizePath,
3961
6192
  normalizePathString,
6193
+ parseSubtitleToWords,
3962
6194
  parseSvgPath,
3963
6195
  quadraticToCubic,
3964
6196
  renderSvgAssetToPng,
3965
6197
  renderSvgToPng,
6198
+ richCaptionAssetSchema,
3966
6199
  shapeToSvgString,
3967
6200
  svgAssetSchema,
3968
6201
  svgGradientStopSchema,