@shotstack/shotstack-canvas 2.1.4 → 2.1.6

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.
@@ -18,8 +18,7 @@ import {
18
18
  svgShadowSchema,
19
19
  svgTransformSchema,
20
20
  svgGradientStopSchema,
21
- richCaptionActiveSchema as baseCaptionActiveSchema,
22
- richCaptionWordAnimationSchema as baseCaptionWordAnimationSchema
21
+ richCaptionActiveSchema as baseCaptionActiveSchema
23
22
  } from "@shotstack/schemas/zod";
24
23
 
25
24
  // src/config/canvas-constants.ts
@@ -188,24 +187,38 @@ var richCaptionFontSchema = z.object({
188
187
  weight: z.union([z.string(), z.number()]).default("400"),
189
188
  color: z.string().regex(HEX6).default("#ffffff"),
190
189
  opacity: z.number().min(0).max(1).default(1),
191
- background: z.string().regex(HEX6).optional()
190
+ background: z.string().regex(HEX6).optional(),
191
+ textDecoration: z.enum(["none", "underline", "line-through"]).default("none")
192
192
  });
193
193
  var richCaptionActiveSchema = baseCaptionActiveSchema.extend({
194
194
  font: z.object({
195
- color: z.string().regex(HEX6).default("#ffffff"),
196
- background: z.string().regex(HEX6).optional(),
197
- opacity: z.number().min(0).max(1).default(1)
198
- }).optional(),
199
- stroke: z.object({
200
- width: z.number().min(0).optional(),
201
195
  color: z.string().regex(HEX6).optional(),
202
- opacity: z.number().min(0).max(1).optional()
196
+ background: z.string().regex(HEX6).optional(),
197
+ opacity: z.number().min(0).max(1).optional(),
198
+ textDecoration: z.enum(["none", "underline", "line-through"]).optional()
203
199
  }).optional(),
200
+ stroke: z.union([
201
+ z.object({
202
+ width: z.number().min(0).optional(),
203
+ color: z.string().regex(HEX6).optional(),
204
+ opacity: z.number().min(0).max(1).optional()
205
+ }),
206
+ z.literal("none")
207
+ ]).optional(),
208
+ shadow: z.union([
209
+ z.object({
210
+ offsetX: z.number().optional(),
211
+ offsetY: z.number().optional(),
212
+ blur: z.number().min(0).optional(),
213
+ color: z.string().regex(HEX6).optional(),
214
+ opacity: z.number().min(0).max(1).optional()
215
+ }),
216
+ z.literal("none")
217
+ ]).optional(),
204
218
  scale: z.number().min(0.5).max(2).default(1)
205
219
  });
206
- var richCaptionWordAnimationSchema = baseCaptionWordAnimationSchema.extend({
220
+ var richCaptionWordAnimationSchema = z.object({
207
221
  style: z.enum(["karaoke", "highlight", "pop", "fade", "slide", "bounce", "typewriter", "none"]).default("highlight"),
208
- speed: z.number().min(0.5).max(2).default(1),
209
222
  direction: z.enum(["left", "right", "up", "down"]).default("up")
210
223
  });
211
224
  var richCaptionAssetSchema = z.object({
@@ -2216,281 +2229,616 @@ function parseHex6(hex, alpha = 1) {
2216
2229
  return { r, g, b, a: alpha };
2217
2230
  }
2218
2231
 
2219
- // src/core/rich-caption-animator.ts
2220
- var ANIMATION_DURATIONS = {
2221
- karaoke: 0,
2222
- highlight: 0,
2223
- pop: 200,
2224
- fade: 150,
2225
- slide: 250,
2226
- bounce: 400,
2227
- typewriter: 0,
2228
- none: 0
2229
- };
2230
- var DEFAULT_ANIMATION_STATE = {
2231
- opacity: 1,
2232
- scale: 1,
2233
- translateX: 0,
2234
- translateY: 0,
2235
- fillProgress: 1,
2236
- isActive: false,
2237
- visibleCharacters: -1
2238
- };
2239
- function easeOutQuad2(t) {
2240
- return t * (2 - t);
2241
- }
2242
- function easeInOutQuad(t) {
2243
- return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
2244
- }
2245
- function easeOutBack(t) {
2246
- const c1 = 1.70158;
2247
- const c3 = c1 + 1;
2248
- return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
2249
- }
2250
- function easeOutCirc(t) {
2251
- return Math.sqrt(1 - Math.pow(t - 1, 2));
2232
+ // src/core/rich-caption-layout.ts
2233
+ import { LRUCache } from "lru-cache";
2234
+ var ASCENT_RATIO = 0.8;
2235
+ var DESCENT_RATIO = 0.2;
2236
+ function isRTLText(text) {
2237
+ return containsRTLCharacters(text);
2252
2238
  }
2253
- function easeOutBounce(t) {
2254
- const n1 = 7.5625;
2255
- const d1 = 2.75;
2256
- if (t < 1 / d1) {
2257
- return n1 * t * t;
2258
- }
2259
- if (t < 2 / d1) {
2260
- return n1 * (t -= 1.5 / d1) * t + 0.75;
2239
+ var WordTimingStore = class {
2240
+ startTimes;
2241
+ endTimes;
2242
+ xPositions;
2243
+ yPositions;
2244
+ widths;
2245
+ words;
2246
+ length;
2247
+ constructor(words) {
2248
+ this.length = words.length;
2249
+ this.startTimes = new Uint32Array(this.length);
2250
+ this.endTimes = new Uint32Array(this.length);
2251
+ this.xPositions = new Float32Array(this.length);
2252
+ this.yPositions = new Float32Array(this.length);
2253
+ this.widths = new Float32Array(this.length);
2254
+ this.words = new Array(this.length);
2255
+ for (let i = 0; i < this.length; i++) {
2256
+ this.startTimes[i] = Math.floor(words[i].start);
2257
+ this.endTimes[i] = Math.floor(words[i].end);
2258
+ this.words[i] = words[i].text;
2259
+ }
2261
2260
  }
2262
- if (t < 2.5 / d1) {
2263
- return n1 * (t -= 2.25 / d1) * t + 0.9375;
2261
+ };
2262
+ function findWordAtTime(store, timeMs) {
2263
+ let left = 0;
2264
+ let right = store.length - 1;
2265
+ while (left <= right) {
2266
+ const mid = left + right >>> 1;
2267
+ const start = store.startTimes[mid];
2268
+ const end = store.endTimes[mid];
2269
+ if (timeMs >= start && timeMs < end) {
2270
+ return mid;
2271
+ }
2272
+ if (timeMs < start) {
2273
+ right = mid - 1;
2274
+ } else {
2275
+ left = mid + 1;
2276
+ }
2264
2277
  }
2265
- return n1 * (t -= 2.625 / d1) * t + 0.984375;
2266
- }
2267
- function clamp(value, min, max) {
2268
- return Math.min(Math.max(value, min), max);
2278
+ return -1;
2269
2279
  }
2270
- function calculateAnimationProgress(ctx) {
2271
- if (ctx.animationDuration <= 0) {
2272
- return ctx.currentTime >= ctx.wordStart ? 1 : 0;
2280
+ function groupWordsByPause(store, pauseThreshold = 500) {
2281
+ if (store.length === 0) {
2282
+ return [];
2273
2283
  }
2274
- const elapsed = ctx.currentTime - ctx.wordStart;
2275
- return clamp(elapsed / ctx.animationDuration, 0, 1);
2276
- }
2277
- function calculateWordProgress(ctx) {
2278
- const duration = ctx.wordEnd - ctx.wordStart;
2279
- if (duration <= 0) {
2280
- return ctx.currentTime >= ctx.wordStart ? 1 : 0;
2284
+ const groups = [];
2285
+ let currentGroup = [];
2286
+ for (let i = 0; i < store.length; i++) {
2287
+ if (currentGroup.length === 0) {
2288
+ currentGroup.push(i);
2289
+ continue;
2290
+ }
2291
+ const prevEnd = store.endTimes[currentGroup[currentGroup.length - 1]];
2292
+ const currStart = store.startTimes[i];
2293
+ const gap = currStart - prevEnd;
2294
+ const prevText = store.words[currentGroup[currentGroup.length - 1]];
2295
+ const endsWithPunctuation = /[.!?]$/.test(prevText);
2296
+ if (gap >= pauseThreshold || endsWithPunctuation) {
2297
+ groups.push(currentGroup);
2298
+ currentGroup = [i];
2299
+ } else {
2300
+ currentGroup.push(i);
2301
+ }
2281
2302
  }
2282
- const elapsed = ctx.currentTime - ctx.wordStart;
2283
- return clamp(elapsed / duration, 0, 1);
2284
- }
2285
- function isWordActive(ctx) {
2286
- return ctx.currentTime >= ctx.wordStart && ctx.currentTime < ctx.wordEnd;
2303
+ if (currentGroup.length > 0) {
2304
+ groups.push(currentGroup);
2305
+ }
2306
+ return groups;
2287
2307
  }
2288
- function calculateKaraokeState(ctx, speed) {
2289
- const isActive = isWordActive(ctx);
2290
- const wordDuration = ctx.wordEnd - ctx.wordStart;
2291
- const adjustedDuration = wordDuration / speed;
2292
- const adjustedEnd = ctx.wordStart + adjustedDuration;
2293
- const adjustedCtx = { ...ctx, wordEnd: adjustedEnd };
2294
- if (ctx.currentTime < ctx.wordStart) {
2295
- return {
2296
- fillProgress: 0,
2297
- isActive: false,
2298
- opacity: 1
2299
- };
2308
+ function breakIntoLines(wordWidths, maxWidth, spaceWidth) {
2309
+ const lines = [];
2310
+ let currentLine = [];
2311
+ let currentWidth = 0;
2312
+ for (let i = 0; i < wordWidths.length; i++) {
2313
+ const wordWidth = wordWidths[i];
2314
+ const spaceNeeded = currentLine.length > 0 ? spaceWidth : 0;
2315
+ if (currentWidth + spaceNeeded + wordWidth <= maxWidth) {
2316
+ currentLine.push(i);
2317
+ currentWidth += spaceNeeded + wordWidth;
2318
+ } else {
2319
+ if (currentLine.length > 0) {
2320
+ lines.push(currentLine);
2321
+ }
2322
+ currentLine = [i];
2323
+ currentWidth = wordWidth;
2324
+ }
2300
2325
  }
2301
- if (ctx.currentTime >= adjustedEnd) {
2302
- return {
2303
- fillProgress: 1,
2304
- isActive: false,
2305
- opacity: 1
2306
- };
2326
+ if (currentLine.length > 0) {
2327
+ lines.push(currentLine);
2307
2328
  }
2308
- return {
2309
- fillProgress: calculateWordProgress(adjustedCtx),
2310
- isActive,
2311
- opacity: 1
2312
- };
2329
+ return lines;
2313
2330
  }
2314
- function calculateHighlightState(ctx) {
2315
- const isActive = isWordActive(ctx);
2316
- return {
2317
- isActive,
2318
- fillProgress: isActive ? 1 : 0,
2319
- opacity: 1
2320
- };
2331
+ var GLYPH_SIZE_ESTIMATE = 64;
2332
+ function createShapedWordCache() {
2333
+ return new LRUCache({
2334
+ max: 5e4,
2335
+ maxSize: 50 * 1024 * 1024,
2336
+ maxEntrySize: 100 * 1024,
2337
+ sizeCalculation: (value, key) => {
2338
+ const keySize = key.length * 2;
2339
+ const glyphsSize = value.glyphs.length * GLYPH_SIZE_ESTIMATE;
2340
+ return keySize + glyphsSize + 100;
2341
+ }
2342
+ });
2321
2343
  }
2322
- function calculatePopState(ctx, activeScale, speed) {
2323
- if (ctx.currentTime < ctx.wordStart) {
2324
- return {
2325
- scale: 0.5,
2326
- opacity: 0,
2327
- isActive: false,
2328
- fillProgress: 0
2329
- };
2330
- }
2331
- const adjustedDuration = ctx.animationDuration / speed;
2332
- const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2333
- const progress = calculateAnimationProgress(adjustedCtx);
2334
- const easedProgress = easeOutBack(progress);
2335
- const startScale = 0.5;
2336
- const isActive = isWordActive(ctx);
2337
- const endScale = isActive ? activeScale : 1;
2338
- const scale = startScale + (endScale - startScale) * easedProgress;
2339
- return {
2340
- scale: Math.min(scale, activeScale),
2341
- opacity: easedProgress,
2342
- isActive,
2343
- fillProgress: isActive ? 1 : 0
2344
- };
2344
+ function makeShapingKey(text, fontFamily, fontSize, fontWeight, letterSpacing = 0) {
2345
+ return `${text}\0${fontFamily}\0${fontSize}\0${fontWeight}\0${letterSpacing}`;
2345
2346
  }
2346
- function calculateFadeState(ctx, speed) {
2347
- if (ctx.currentTime < ctx.wordStart) {
2348
- return {
2349
- opacity: 0,
2350
- isActive: false,
2351
- fillProgress: 0
2352
- };
2347
+ function transformText(text, transform) {
2348
+ switch (transform) {
2349
+ case "uppercase":
2350
+ return text.toUpperCase();
2351
+ case "lowercase":
2352
+ return text.toLowerCase();
2353
+ case "capitalize":
2354
+ return text.replace(/\b\w/g, (c) => c.toUpperCase());
2355
+ default:
2356
+ return text;
2353
2357
  }
2354
- const adjustedDuration = ctx.animationDuration / speed;
2355
- const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2356
- const progress = calculateAnimationProgress(adjustedCtx);
2357
- const easedProgress = easeInOutQuad(progress);
2358
- const isActive = isWordActive(ctx);
2359
- return {
2360
- opacity: easedProgress,
2361
- isActive,
2362
- fillProgress: isActive ? 1 : 0
2363
- };
2364
2358
  }
2365
- function calculateSlideState(ctx, direction, speed, fontSize) {
2366
- const slideDistance = fontSize * 1.5;
2367
- if (ctx.currentTime < ctx.wordStart) {
2368
- const offset2 = getDirectionOffset(direction, slideDistance);
2369
- return {
2370
- translateX: offset2.x,
2371
- translateY: offset2.y,
2372
- opacity: 0,
2373
- isActive: false,
2374
- fillProgress: 0
2375
- };
2359
+ function splitIntoChunks(arr, chunkSize) {
2360
+ const chunks = [];
2361
+ for (let i = 0; i < arr.length; i += chunkSize) {
2362
+ chunks.push(arr.slice(i, i + chunkSize));
2376
2363
  }
2377
- const adjustedDuration = ctx.animationDuration / speed;
2378
- const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2379
- const progress = calculateAnimationProgress(adjustedCtx);
2380
- const easedProgress = easeOutCirc(progress);
2381
- const offset = getDirectionOffset(direction, slideDistance);
2382
- const translateX = offset.x * (1 - easedProgress);
2383
- const translateY = offset.y * (1 - easedProgress);
2384
- const isActive = isWordActive(ctx);
2385
- return {
2386
- translateX,
2387
- translateY,
2388
- opacity: easeOutQuad2(progress),
2389
- isActive,
2390
- fillProgress: isActive ? 1 : 0
2391
- };
2364
+ return chunks;
2392
2365
  }
2393
- function getDirectionOffset(direction, distance) {
2394
- switch (direction) {
2395
- case "left":
2396
- return { x: -distance, y: 0 };
2397
- case "right":
2398
- return { x: distance, y: 0 };
2399
- case "up":
2400
- return { x: 0, y: -distance };
2401
- case "down":
2402
- return { x: 0, y: distance };
2366
+ var CaptionLayoutEngine = class {
2367
+ fontRegistry;
2368
+ cache;
2369
+ layoutEngine;
2370
+ constructor(fontRegistry) {
2371
+ this.fontRegistry = fontRegistry;
2372
+ this.cache = createShapedWordCache();
2373
+ this.layoutEngine = new LayoutEngine(fontRegistry);
2403
2374
  }
2404
- }
2405
- function calculateBounceState(ctx, speed, fontSize) {
2406
- const bounceDistance = fontSize * 0.8;
2407
- if (ctx.currentTime < ctx.wordStart) {
2375
+ async measureWord(text, config) {
2376
+ const transformedText = transformText(text, config.textTransform);
2377
+ const cacheKey = makeShapingKey(
2378
+ transformedText,
2379
+ config.fontFamily,
2380
+ config.fontSize,
2381
+ config.fontWeight,
2382
+ config.letterSpacing
2383
+ );
2384
+ const cached = this.cache.get(cacheKey);
2385
+ if (cached) {
2386
+ return cached;
2387
+ }
2388
+ const lines = await this.layoutEngine.layout({
2389
+ text: transformedText,
2390
+ width: 1e5,
2391
+ letterSpacing: config.letterSpacing,
2392
+ fontSize: config.fontSize,
2393
+ lineHeight: 1,
2394
+ desc: { family: config.fontFamily, weight: config.fontWeight },
2395
+ textTransform: "none"
2396
+ });
2397
+ const width = lines[0]?.width ?? 0;
2398
+ const glyphs = lines[0]?.glyphs ?? [];
2399
+ const isRTL = isRTLText(transformedText);
2400
+ const shaped = {
2401
+ text: transformedText,
2402
+ width,
2403
+ glyphs: glyphs.map((g) => ({
2404
+ id: g.id,
2405
+ xAdvance: g.xAdvance,
2406
+ xOffset: g.xOffset,
2407
+ yOffset: g.yOffset,
2408
+ cluster: g.cluster
2409
+ })),
2410
+ isRTL
2411
+ };
2412
+ this.cache.set(cacheKey, shaped);
2413
+ return shaped;
2414
+ }
2415
+ async layoutCaption(words, config) {
2416
+ const store = new WordTimingStore(words);
2417
+ const measurementConfig = {
2418
+ fontFamily: config.fontFamily,
2419
+ fontSize: config.fontSize,
2420
+ fontWeight: config.fontWeight,
2421
+ letterSpacing: config.letterSpacing,
2422
+ textTransform: config.textTransform
2423
+ };
2424
+ const shapedWords = await Promise.all(
2425
+ words.map((w) => this.measureWord(w.text, measurementConfig))
2426
+ );
2427
+ if (config.measureTextWidth) {
2428
+ const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
2429
+ for (let i = 0; i < shapedWords.length; i++) {
2430
+ store.widths[i] = config.measureTextWidth(shapedWords[i].text, fontString);
2431
+ }
2432
+ } else {
2433
+ for (let i = 0; i < shapedWords.length; i++) {
2434
+ store.widths[i] = shapedWords[i].width;
2435
+ }
2436
+ }
2437
+ if (config.textTransform !== "none") {
2438
+ for (let i = 0; i < shapedWords.length; i++) {
2439
+ store.words[i] = shapedWords[i].text;
2440
+ }
2441
+ }
2442
+ const wordGroups = groupWordsByPause(store, config.pauseThreshold);
2443
+ const pixelMaxWidth = config.availableWidth;
2444
+ let spaceWidth;
2445
+ if (config.measureTextWidth) {
2446
+ const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
2447
+ spaceWidth = config.measureTextWidth(" ", fontString) + config.wordSpacing;
2448
+ } else {
2449
+ const spaceWord = await this.measureWord(" ", measurementConfig);
2450
+ spaceWidth = spaceWord.width + config.wordSpacing;
2451
+ }
2452
+ const groups = wordGroups.flatMap((indices) => {
2453
+ const groupWidths = indices.map((i) => store.widths[i]);
2454
+ const allLines = breakIntoLines(
2455
+ groupWidths,
2456
+ pixelMaxWidth,
2457
+ spaceWidth
2458
+ );
2459
+ const lineChunks = splitIntoChunks(allLines, config.maxLines);
2460
+ return lineChunks.map((chunkLines) => {
2461
+ const lines = chunkLines.map((lineWordIndices, lineIndex) => {
2462
+ const actualIndices = lineWordIndices.map((i) => indices[i]);
2463
+ const lineWidth = actualIndices.reduce((sum, idx) => sum + store.widths[idx], 0) + (actualIndices.length - 1) * spaceWidth;
2464
+ return {
2465
+ wordIndices: actualIndices,
2466
+ x: 0,
2467
+ y: lineIndex * config.fontSize * config.lineHeight,
2468
+ width: lineWidth,
2469
+ height: config.fontSize
2470
+ };
2471
+ });
2472
+ const allWordIndices = lines.flatMap((l) => l.wordIndices);
2473
+ if (allWordIndices.length === 0) {
2474
+ return null;
2475
+ }
2476
+ return {
2477
+ wordIndices: allWordIndices,
2478
+ startTime: store.startTimes[allWordIndices[0]],
2479
+ endTime: store.endTimes[allWordIndices[allWordIndices.length - 1]],
2480
+ lines
2481
+ };
2482
+ }).filter((g) => g !== null);
2483
+ });
2484
+ const contentHeight = config.frameHeight - config.padding.top - config.padding.bottom;
2485
+ const contentWidth = config.frameWidth - config.padding.left - config.padding.right;
2486
+ const calculateGroupY = (group) => {
2487
+ const totalHeight = group.lines.length * config.fontSize * config.lineHeight;
2488
+ switch (config.verticalAlign) {
2489
+ case "top":
2490
+ return config.padding.top + config.fontSize * ASCENT_RATIO;
2491
+ case "bottom":
2492
+ return config.frameHeight - config.padding.bottom - totalHeight + config.fontSize * ASCENT_RATIO;
2493
+ case "middle":
2494
+ default:
2495
+ return config.padding.top + (contentHeight - totalHeight) / 2 + config.fontSize * ASCENT_RATIO;
2496
+ }
2497
+ };
2498
+ const allWordTexts = store.words.slice(0, store.length);
2499
+ const paragraphDirection = detectParagraphDirectionFromWords(allWordTexts);
2500
+ const calculateLineX = (lineWidth) => {
2501
+ switch (config.horizontalAlign) {
2502
+ case "left":
2503
+ return config.padding.left;
2504
+ case "right":
2505
+ return config.frameWidth - lineWidth - config.padding.right;
2506
+ case "center":
2507
+ default:
2508
+ return config.padding.left + (contentWidth - lineWidth) / 2;
2509
+ }
2510
+ };
2511
+ for (const group of groups) {
2512
+ const baseY = calculateGroupY(group);
2513
+ for (let lineIdx = 0; lineIdx < group.lines.length; lineIdx++) {
2514
+ const line = group.lines[lineIdx];
2515
+ line.x = calculateLineX(line.width);
2516
+ line.y = baseY + lineIdx * config.fontSize * config.lineHeight;
2517
+ const lineWordTexts = line.wordIndices.map((idx) => store.words[idx]);
2518
+ const visualOrder = reorderWordsForLine(lineWordTexts, paragraphDirection);
2519
+ let xCursor = line.x;
2520
+ for (const visualIdx of visualOrder) {
2521
+ const wordIdx = line.wordIndices[visualIdx];
2522
+ store.xPositions[wordIdx] = xCursor;
2523
+ store.yPositions[wordIdx] = line.y;
2524
+ xCursor += store.widths[wordIdx] + spaceWidth;
2525
+ }
2526
+ }
2527
+ }
2408
2528
  return {
2409
- translateY: -bounceDistance,
2410
- opacity: 0,
2411
- isActive: false,
2412
- fillProgress: 0
2529
+ store,
2530
+ groups,
2531
+ shapedWords,
2532
+ paragraphDirection
2413
2533
  };
2414
2534
  }
2415
- const adjustedDuration = ctx.animationDuration / speed;
2416
- const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2417
- const progress = calculateAnimationProgress(adjustedCtx);
2418
- const easedProgress = easeOutBounce(progress);
2419
- const isActive = isWordActive(ctx);
2420
- return {
2421
- translateY: -bounceDistance * (1 - easedProgress),
2422
- opacity: easeOutQuad2(progress),
2423
- isActive,
2424
- fillProgress: isActive ? 1 : 0
2425
- };
2426
- }
2427
- function calculateTypewriterState(ctx, charCount, speed) {
2428
- const wordDuration = ctx.wordEnd - ctx.wordStart;
2429
- const adjustedDuration = wordDuration / speed;
2430
- const adjustedEnd = ctx.wordStart + adjustedDuration;
2431
- const adjustedCtx = { ...ctx, wordEnd: adjustedEnd };
2432
- if (ctx.currentTime < ctx.wordStart) {
2535
+ getVisibleWordsAtTime(layout, timeMs) {
2536
+ const activeGroup = layout.groups.find(
2537
+ (g) => timeMs >= g.startTime && timeMs <= g.endTime
2538
+ );
2539
+ if (!activeGroup) {
2540
+ return [];
2541
+ }
2542
+ return activeGroup.wordIndices.map((idx) => ({
2543
+ wordIndex: idx,
2544
+ text: layout.store.words[idx],
2545
+ x: layout.store.xPositions[idx],
2546
+ y: layout.store.yPositions[idx],
2547
+ width: layout.store.widths[idx],
2548
+ startTime: layout.store.startTimes[idx],
2549
+ endTime: layout.store.endTimes[idx],
2550
+ isRTL: layout.shapedWords[idx].isRTL
2551
+ }));
2552
+ }
2553
+ getActiveWordAtTime(layout, timeMs) {
2554
+ const wordIndex = findWordAtTime(layout.store, timeMs);
2555
+ if (wordIndex === -1) {
2556
+ return null;
2557
+ }
2433
2558
  return {
2434
- visibleCharacters: 0,
2435
- opacity: 1,
2436
- isActive: false
2559
+ wordIndex,
2560
+ text: layout.store.words[wordIndex],
2561
+ x: layout.store.xPositions[wordIndex],
2562
+ y: layout.store.yPositions[wordIndex],
2563
+ width: layout.store.widths[wordIndex],
2564
+ startTime: layout.store.startTimes[wordIndex],
2565
+ endTime: layout.store.endTimes[wordIndex],
2566
+ isRTL: layout.shapedWords[wordIndex].isRTL
2437
2567
  };
2438
2568
  }
2439
- if (ctx.currentTime >= adjustedEnd) {
2569
+ clearCache() {
2570
+ this.cache.clear();
2571
+ }
2572
+ getCacheStats() {
2440
2573
  return {
2441
- visibleCharacters: charCount,
2442
- opacity: 1,
2443
- isActive: false
2574
+ size: this.cache.size,
2575
+ calculatedSize: this.cache.calculatedSize
2444
2576
  };
2445
2577
  }
2446
- const progress = calculateWordProgress(adjustedCtx);
2447
- const visibleCharacters = Math.ceil(progress * charCount);
2448
- return {
2449
- visibleCharacters: clamp(visibleCharacters, 0, charCount),
2450
- opacity: 1,
2451
- isActive: isWordActive(ctx)
2452
- };
2453
- }
2454
- function calculateNoneState(_ctx) {
2455
- return {
2456
- opacity: 1,
2457
- isActive: false,
2458
- fillProgress: 0
2459
- };
2460
- }
2461
- function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, activeScale = 1, charCount = 0, fontSize = 48, isRTL = false) {
2462
- const safeSpeed = config.speed > 0 ? config.speed : 1;
2463
- const ctx = {
2464
- wordStart,
2465
- wordEnd,
2466
- currentTime,
2467
- animationDuration: ANIMATION_DURATIONS[config.style]
2468
- };
2469
- const baseState = { ...DEFAULT_ANIMATION_STATE };
2578
+ };
2579
+
2580
+ // src/core/rich-caption-animator.ts
2581
+ var ANIMATION_DURATIONS = {
2582
+ karaoke: 0,
2583
+ highlight: 0,
2584
+ pop: 200,
2585
+ fade: 150,
2586
+ slide: 250,
2587
+ bounce: 400,
2588
+ typewriter: 0,
2589
+ none: 0
2590
+ };
2591
+ var DEFAULT_ANIMATION_STATE = {
2592
+ opacity: 1,
2593
+ scale: 1,
2594
+ translateX: 0,
2595
+ translateY: 0,
2596
+ fillProgress: 1,
2597
+ isActive: false,
2598
+ visibleCharacters: -1
2599
+ };
2600
+ function easeOutQuad2(t) {
2601
+ return t * (2 - t);
2602
+ }
2603
+ function easeInOutQuad(t) {
2604
+ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
2605
+ }
2606
+ function easeOutBack(t) {
2607
+ const c1 = 1.70158;
2608
+ const c3 = c1 + 1;
2609
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
2610
+ }
2611
+ function easeOutCirc(t) {
2612
+ return Math.sqrt(1 - Math.pow(t - 1, 2));
2613
+ }
2614
+ function easeOutBounce(t) {
2615
+ const n1 = 7.5625;
2616
+ const d1 = 2.75;
2617
+ if (t < 1 / d1) {
2618
+ return n1 * t * t;
2619
+ }
2620
+ if (t < 2 / d1) {
2621
+ return n1 * (t -= 1.5 / d1) * t + 0.75;
2622
+ }
2623
+ if (t < 2.5 / d1) {
2624
+ return n1 * (t -= 2.25 / d1) * t + 0.9375;
2625
+ }
2626
+ return n1 * (t -= 2.625 / d1) * t + 0.984375;
2627
+ }
2628
+ function clamp(value, min, max) {
2629
+ return Math.min(Math.max(value, min), max);
2630
+ }
2631
+ function calculateAnimationProgress(ctx) {
2632
+ if (ctx.animationDuration <= 0) {
2633
+ return ctx.currentTime >= ctx.wordStart ? 1 : 0;
2634
+ }
2635
+ const elapsed = ctx.currentTime - ctx.wordStart;
2636
+ return clamp(elapsed / ctx.animationDuration, 0, 1);
2637
+ }
2638
+ function calculateWordProgress(ctx) {
2639
+ const duration = ctx.wordEnd - ctx.wordStart;
2640
+ if (duration <= 0) {
2641
+ return ctx.currentTime >= ctx.wordStart ? 1 : 0;
2642
+ }
2643
+ const elapsed = ctx.currentTime - ctx.wordStart;
2644
+ return clamp(elapsed / duration, 0, 1);
2645
+ }
2646
+ function isWordActive(ctx) {
2647
+ return ctx.currentTime >= ctx.wordStart && ctx.currentTime < ctx.wordEnd;
2648
+ }
2649
+ function calculateKaraokeState(ctx) {
2650
+ const isActive = isWordActive(ctx);
2651
+ if (ctx.currentTime < ctx.wordStart) {
2652
+ return {
2653
+ fillProgress: 0,
2654
+ isActive: false,
2655
+ opacity: 1
2656
+ };
2657
+ }
2658
+ if (ctx.currentTime >= ctx.wordEnd) {
2659
+ return {
2660
+ fillProgress: 1,
2661
+ isActive: false,
2662
+ opacity: 1
2663
+ };
2664
+ }
2665
+ return {
2666
+ fillProgress: calculateWordProgress(ctx),
2667
+ isActive,
2668
+ opacity: 1
2669
+ };
2670
+ }
2671
+ function calculateHighlightState(ctx) {
2672
+ const isActive = isWordActive(ctx);
2673
+ return {
2674
+ isActive,
2675
+ fillProgress: isActive ? 1 : 0,
2676
+ opacity: 1
2677
+ };
2678
+ }
2679
+ function calculatePopState(ctx, activeScale) {
2680
+ if (ctx.currentTime < ctx.wordStart) {
2681
+ return {
2682
+ scale: 0.5,
2683
+ opacity: 0,
2684
+ isActive: false,
2685
+ fillProgress: 0
2686
+ };
2687
+ }
2688
+ const progress = calculateAnimationProgress(ctx);
2689
+ const easedProgress = easeOutBack(progress);
2690
+ const startScale = 0.5;
2691
+ const isActive = isWordActive(ctx);
2692
+ const endScale = isActive ? activeScale : 1;
2693
+ const scale = startScale + (endScale - startScale) * easedProgress;
2694
+ return {
2695
+ scale: Math.min(scale, activeScale),
2696
+ opacity: easedProgress,
2697
+ isActive,
2698
+ fillProgress: isActive ? 1 : 0
2699
+ };
2700
+ }
2701
+ function calculateFadeState(ctx) {
2702
+ if (ctx.currentTime < ctx.wordStart) {
2703
+ return {
2704
+ opacity: 0,
2705
+ isActive: false,
2706
+ fillProgress: 0
2707
+ };
2708
+ }
2709
+ const progress = calculateAnimationProgress(ctx);
2710
+ const easedProgress = easeInOutQuad(progress);
2711
+ const isActive = isWordActive(ctx);
2712
+ return {
2713
+ opacity: easedProgress,
2714
+ isActive,
2715
+ fillProgress: isActive ? 1 : 0
2716
+ };
2717
+ }
2718
+ function calculateSlideState(ctx, direction, fontSize) {
2719
+ const slideDistance = fontSize * 1.5;
2720
+ if (ctx.currentTime < ctx.wordStart) {
2721
+ const offset2 = getDirectionOffset(direction, slideDistance);
2722
+ return {
2723
+ translateX: offset2.x,
2724
+ translateY: offset2.y,
2725
+ opacity: 0,
2726
+ isActive: false,
2727
+ fillProgress: 0
2728
+ };
2729
+ }
2730
+ const progress = calculateAnimationProgress(ctx);
2731
+ const easedProgress = easeOutCirc(progress);
2732
+ const offset = getDirectionOffset(direction, slideDistance);
2733
+ const translateX = offset.x * (1 - easedProgress);
2734
+ const translateY = offset.y * (1 - easedProgress);
2735
+ const isActive = isWordActive(ctx);
2736
+ return {
2737
+ translateX,
2738
+ translateY,
2739
+ opacity: easeOutQuad2(progress),
2740
+ isActive,
2741
+ fillProgress: isActive ? 1 : 0
2742
+ };
2743
+ }
2744
+ function getDirectionOffset(direction, distance) {
2745
+ switch (direction) {
2746
+ case "left":
2747
+ return { x: -distance, y: 0 };
2748
+ case "right":
2749
+ return { x: distance, y: 0 };
2750
+ case "up":
2751
+ return { x: 0, y: distance };
2752
+ case "down":
2753
+ return { x: 0, y: -distance };
2754
+ }
2755
+ }
2756
+ function calculateBounceState(ctx, fontSize) {
2757
+ const bounceDistance = fontSize * 0.8;
2758
+ if (ctx.currentTime < ctx.wordStart) {
2759
+ return {
2760
+ translateY: -bounceDistance,
2761
+ opacity: 0,
2762
+ isActive: false,
2763
+ fillProgress: 0
2764
+ };
2765
+ }
2766
+ const progress = calculateAnimationProgress(ctx);
2767
+ const easedProgress = easeOutBounce(progress);
2768
+ const isActive = isWordActive(ctx);
2769
+ return {
2770
+ translateY: -bounceDistance * (1 - easedProgress),
2771
+ opacity: easeOutQuad2(progress),
2772
+ isActive,
2773
+ fillProgress: isActive ? 1 : 0
2774
+ };
2775
+ }
2776
+ function calculateTypewriterState(ctx, charCount) {
2777
+ if (ctx.currentTime < ctx.wordStart) {
2778
+ return {
2779
+ visibleCharacters: 0,
2780
+ opacity: 1,
2781
+ isActive: false,
2782
+ fillProgress: 0
2783
+ };
2784
+ }
2785
+ if (ctx.currentTime >= ctx.wordEnd) {
2786
+ return {
2787
+ visibleCharacters: charCount,
2788
+ opacity: 1,
2789
+ isActive: false,
2790
+ fillProgress: 0
2791
+ };
2792
+ }
2793
+ const progress = calculateWordProgress(ctx);
2794
+ const visibleCharacters = Math.ceil(progress * charCount);
2795
+ const isActive = isWordActive(ctx);
2796
+ return {
2797
+ visibleCharacters: clamp(visibleCharacters, 0, charCount),
2798
+ opacity: 1,
2799
+ isActive,
2800
+ fillProgress: isActive ? 1 : 0
2801
+ };
2802
+ }
2803
+ function calculateNoneState(_ctx) {
2804
+ return {
2805
+ opacity: 1,
2806
+ isActive: false,
2807
+ fillProgress: 0
2808
+ };
2809
+ }
2810
+ function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, activeScale = 1, charCount = 0, fontSize = 48, isRTL = false) {
2811
+ const ctx = {
2812
+ wordStart,
2813
+ wordEnd,
2814
+ currentTime,
2815
+ animationDuration: ANIMATION_DURATIONS[config.style]
2816
+ };
2817
+ const baseState = { ...DEFAULT_ANIMATION_STATE };
2470
2818
  let partialState;
2471
2819
  switch (config.style) {
2472
2820
  case "karaoke":
2473
- partialState = calculateKaraokeState(ctx, safeSpeed);
2821
+ partialState = calculateKaraokeState(ctx);
2474
2822
  break;
2475
2823
  case "highlight":
2476
2824
  partialState = calculateHighlightState(ctx);
2477
2825
  break;
2478
2826
  case "pop":
2479
- partialState = calculatePopState(ctx, activeScale, safeSpeed);
2827
+ partialState = calculatePopState(ctx, activeScale);
2480
2828
  break;
2481
2829
  case "fade":
2482
- partialState = calculateFadeState(ctx, safeSpeed);
2830
+ partialState = calculateFadeState(ctx);
2483
2831
  break;
2484
2832
  case "slide": {
2485
2833
  const slideDir = mirrorAnimationDirection(config.direction, isRTL);
2486
- partialState = calculateSlideState(ctx, slideDir, config.speed, fontSize);
2834
+ partialState = calculateSlideState(ctx, slideDir, fontSize);
2487
2835
  break;
2488
2836
  }
2489
2837
  case "bounce":
2490
- partialState = calculateBounceState(ctx, safeSpeed, fontSize);
2838
+ partialState = calculateBounceState(ctx, fontSize);
2491
2839
  break;
2492
2840
  case "typewriter":
2493
- partialState = calculateTypewriterState(ctx, charCount, safeSpeed);
2841
+ partialState = calculateTypewriterState(ctx, charCount);
2494
2842
  break;
2495
2843
  case "none":
2496
2844
  default:
@@ -2523,34 +2871,30 @@ function calculateAnimationStatesForGroup(words, currentTime, config, activeScal
2523
2871
  function getDefaultAnimationConfig() {
2524
2872
  return {
2525
2873
  style: "highlight",
2526
- speed: 1,
2527
2874
  direction: "up"
2528
2875
  };
2529
2876
  }
2530
2877
 
2531
2878
  // src/core/rich-caption-generator.ts
2532
- var ASCENT_RATIO = 0.8;
2533
- var DESCENT_RATIO = 0.2;
2534
2879
  var WORD_BG_OPACITY = 1;
2535
2880
  var WORD_BG_BORDER_RADIUS = 4;
2536
2881
  var WORD_BG_PADDING_RATIO = 0.12;
2537
2882
  function extractFontConfig(asset) {
2538
2883
  const font = asset.font;
2539
2884
  const active = asset.active?.font;
2540
- const hasActiveConfig = asset.active !== void 0;
2885
+ const hasExplicitActiveColor = active?.color !== void 0;
2541
2886
  const baseColor = font?.color ?? "#ffffff";
2542
2887
  const baseOpacity = font?.opacity ?? 1;
2543
2888
  let activeColor;
2544
2889
  let activeOpacity;
2545
- if (!hasActiveConfig) {
2890
+ if (!hasExplicitActiveColor) {
2546
2891
  activeColor = baseColor;
2547
- activeOpacity = baseOpacity;
2892
+ activeOpacity = active?.opacity ?? baseOpacity;
2548
2893
  } else {
2549
- const explicitActiveColor = active?.color;
2550
2894
  const animStyle = asset.wordAnimation?.style ?? "highlight";
2551
2895
  const isFillAnimation = animStyle === "karaoke" || animStyle === "highlight";
2552
2896
  const DEFAULT_ACTIVE_COLOR = "#ffff00";
2553
- activeColor = explicitActiveColor ?? (isFillAnimation ? DEFAULT_ACTIVE_COLOR : baseColor);
2897
+ activeColor = active.color ?? (isFillAnimation ? DEFAULT_ACTIVE_COLOR : baseColor);
2554
2898
  activeOpacity = active?.opacity ?? baseOpacity;
2555
2899
  }
2556
2900
  return {
@@ -2567,18 +2911,17 @@ function extractFontConfig(asset) {
2567
2911
  function extractStrokeConfig(asset, isActive) {
2568
2912
  const baseStroke = asset.stroke;
2569
2913
  const activeStroke = asset.active?.stroke;
2570
- if (!baseStroke && !activeStroke) {
2571
- return void 0;
2572
- }
2573
2914
  if (isActive) {
2574
- if (!activeStroke) {
2915
+ if (activeStroke === "none") {
2575
2916
  return void 0;
2576
2917
  }
2577
- return {
2578
- width: activeStroke.width ?? baseStroke?.width ?? 0,
2579
- color: activeStroke.color ?? baseStroke?.color ?? "#000000",
2580
- opacity: activeStroke.opacity ?? baseStroke?.opacity ?? 1
2581
- };
2918
+ if (activeStroke && typeof activeStroke === "object") {
2919
+ return {
2920
+ width: activeStroke.width ?? baseStroke?.width ?? 0,
2921
+ color: activeStroke.color ?? baseStroke?.color ?? "#000000",
2922
+ opacity: activeStroke.opacity ?? baseStroke?.opacity ?? 1
2923
+ };
2924
+ }
2582
2925
  }
2583
2926
  if (baseStroke) {
2584
2927
  return {
@@ -2590,25 +2933,42 @@ function extractStrokeConfig(asset, isActive) {
2590
2933
  return void 0;
2591
2934
  }
2592
2935
  function extractShadowConfig(asset, isActive) {
2936
+ const baseShadow = asset.shadow;
2937
+ const activeShadow = asset.active?.shadow;
2593
2938
  if (isActive) {
2594
- return void 0;
2939
+ if (activeShadow === "none") {
2940
+ return void 0;
2941
+ }
2942
+ if (activeShadow && typeof activeShadow === "object") {
2943
+ return {
2944
+ offsetX: activeShadow.offsetX ?? baseShadow?.offsetX ?? 0,
2945
+ offsetY: activeShadow.offsetY ?? baseShadow?.offsetY ?? 0,
2946
+ blur: activeShadow.blur ?? baseShadow?.blur ?? 0,
2947
+ color: activeShadow.color ?? baseShadow?.color ?? "#000000",
2948
+ opacity: activeShadow.opacity ?? baseShadow?.opacity ?? 0.5
2949
+ };
2950
+ }
2595
2951
  }
2596
- const shadow = asset.shadow;
2597
- if (!shadow) {
2598
- return void 0;
2952
+ if (baseShadow) {
2953
+ return {
2954
+ offsetX: baseShadow.offsetX ?? 0,
2955
+ offsetY: baseShadow.offsetY ?? 0,
2956
+ blur: baseShadow.blur ?? 0,
2957
+ color: baseShadow.color ?? "#000000",
2958
+ opacity: baseShadow.opacity ?? 0.5
2959
+ };
2599
2960
  }
2600
- return {
2601
- offsetX: shadow.offsetX ?? 0,
2602
- offsetY: shadow.offsetY ?? 0,
2603
- blur: shadow.blur ?? 0,
2604
- color: shadow.color ?? "#000000",
2605
- opacity: shadow.opacity ?? 0.5
2606
- };
2961
+ return void 0;
2607
2962
  }
2608
2963
  function extractBackgroundConfig(asset, isActive, fontSize) {
2609
2964
  const fontBackground = asset.font?.background;
2610
2965
  const activeBackground = asset.active?.font?.background;
2611
- const bgColor = isActive && activeBackground ? activeBackground : fontBackground;
2966
+ let bgColor;
2967
+ if (isActive) {
2968
+ bgColor = activeBackground ?? fontBackground;
2969
+ } else {
2970
+ bgColor = fontBackground;
2971
+ }
2612
2972
  if (!bgColor) {
2613
2973
  return void 0;
2614
2974
  }
@@ -2658,6 +3018,17 @@ function extractCaptionBorder(asset) {
2658
3018
  radius: border.radius ?? 0
2659
3019
  };
2660
3020
  }
3021
+ function extractTextDecoration(asset, isActive) {
3022
+ const baseDecoration = asset.font?.textDecoration;
3023
+ const activeDecoration = asset.active?.font?.textDecoration;
3024
+ if (isActive && activeDecoration !== void 0) {
3025
+ return activeDecoration === "none" ? void 0 : activeDecoration;
3026
+ }
3027
+ if (!baseDecoration || baseDecoration === "none") {
3028
+ return void 0;
3029
+ }
3030
+ return baseDecoration;
3031
+ }
2661
3032
  function extractAnimationConfig(asset) {
2662
3033
  const wordAnim = asset.wordAnimation;
2663
3034
  if (!wordAnim) {
@@ -2665,7 +3036,6 @@ function extractAnimationConfig(asset) {
2665
3036
  }
2666
3037
  return {
2667
3038
  style: wordAnim.style ?? "highlight",
2668
- speed: wordAnim.speed ?? 1,
2669
3039
  direction: wordAnim.direction ?? "up"
2670
3040
  };
2671
3041
  }
@@ -2700,7 +3070,8 @@ function createDrawCaptionWordOp(word, animState, asset, fontConfig) {
2700
3070
  letterSpacing: fontConfig.letterSpacing > 0 ? fontConfig.letterSpacing : void 0,
2701
3071
  stroke: extractStrokeConfig(asset, isActive),
2702
3072
  shadow: extractShadowConfig(asset, isActive),
2703
- background: extractBackgroundConfig(asset, isActive, fontConfig.size)
3073
+ background: extractBackgroundConfig(asset, isActive, fontConfig.size),
3074
+ textDecoration: extractTextDecoration(asset, isActive)
2704
3075
  };
2705
3076
  }
2706
3077
  function generateRichCaptionDrawOps(asset, layout, frameTimeMs, layoutEngine, config) {
@@ -3204,7 +3575,8 @@ async function createNodePainter(opts) {
3204
3575
  context.lineCap = "round";
3205
3576
  context.strokeText(displayText, 0, 0);
3206
3577
  }
3207
- if (wordOp.fillProgress <= 0) {
3578
+ const sameColor = wordOp.activeColor === wordOp.baseColor && wordOp.activeOpacity === wordOp.baseOpacity;
3579
+ if (wordOp.fillProgress <= 0 || sameColor) {
3208
3580
  const baseC = parseHex6(wordOp.baseColor, wordOp.baseOpacity);
3209
3581
  context.fillStyle = `rgba(${baseC.r},${baseC.g},${baseC.b},${baseC.a})`;
3210
3582
  context.fillText(displayText, 0, 0);
@@ -3231,7 +3603,26 @@ async function createNodePainter(opts) {
3231
3603
  context.fillText(displayText, 0, 0);
3232
3604
  context.restore();
3233
3605
  }
3234
- context.restore();
3606
+ if (wordOp.textDecoration) {
3607
+ const geo = decorationGeometry(wordOp.textDecoration, {
3608
+ baselineY: 0,
3609
+ fontSize: wordOp.fontSize,
3610
+ lineWidth: textWidth,
3611
+ xStart: 0
3612
+ });
3613
+ const sameC = wordOp.activeColor === wordOp.baseColor && wordOp.activeOpacity === wordOp.baseOpacity;
3614
+ const decoIsActive = wordOp.fillProgress >= 1 && !sameC;
3615
+ const decoColor = decoIsActive ? wordOp.activeColor : wordOp.baseColor;
3616
+ const decoOpacity = decoIsActive ? wordOp.activeOpacity : wordOp.baseOpacity;
3617
+ const dc = parseHex6(decoColor, decoOpacity);
3618
+ context.strokeStyle = `rgba(${dc.r},${dc.g},${dc.b},${dc.a})`;
3619
+ context.lineWidth = geo.width;
3620
+ context.beginPath();
3621
+ context.moveTo(geo.x1, geo.y);
3622
+ context.lineTo(geo.x2, geo.y);
3623
+ context.stroke();
3624
+ }
3625
+ context.restore();
3235
3626
  }
3236
3627
  });
3237
3628
  continue;
@@ -4522,520 +4913,173 @@ function computeSimplePathBounds(d) {
4522
4913
  minX = Math.min(minX, currentX);
4523
4914
  maxX = Math.max(maxX, currentX);
4524
4915
  }
4525
- break;
4526
- case "h":
4527
- if (numIndex < numbers.length) {
4528
- currentX += numbers[numIndex++];
4529
- minX = Math.min(minX, currentX);
4530
- maxX = Math.max(maxX, currentX);
4531
- }
4532
- break;
4533
- case "V":
4534
- if (numIndex < numbers.length) {
4535
- currentY = numbers[numIndex++];
4536
- minY = Math.min(minY, currentY);
4537
- maxY = Math.max(maxY, currentY);
4538
- }
4539
- break;
4540
- case "v":
4541
- if (numIndex < numbers.length) {
4542
- currentY += numbers[numIndex++];
4543
- minY = Math.min(minY, currentY);
4544
- maxY = Math.max(maxY, currentY);
4545
- }
4546
- break;
4547
- case "C":
4548
- if (numIndex + 5 < numbers.length) {
4549
- for (let j = 0; j < 3; j++) {
4550
- const x = numbers[numIndex++];
4551
- const y = numbers[numIndex++];
4552
- minX = Math.min(minX, x);
4553
- maxX = Math.max(maxX, x);
4554
- minY = Math.min(minY, y);
4555
- maxY = Math.max(maxY, y);
4556
- if (j === 2) {
4557
- currentX = x;
4558
- currentY = y;
4559
- }
4560
- }
4561
- }
4562
- break;
4563
- case "c":
4564
- if (numIndex + 5 < numbers.length) {
4565
- for (let j = 0; j < 3; j++) {
4566
- const x = currentX + numbers[numIndex++];
4567
- const y = currentY + numbers[numIndex++];
4568
- minX = Math.min(minX, x);
4569
- maxX = Math.max(maxX, x);
4570
- minY = Math.min(minY, y);
4571
- maxY = Math.max(maxY, y);
4572
- if (j === 2) {
4573
- currentX = x;
4574
- currentY = y;
4575
- }
4576
- }
4577
- }
4578
- break;
4579
- case "S":
4580
- case "Q":
4581
- if (numIndex + 3 < numbers.length) {
4582
- for (let j = 0; j < 2; j++) {
4583
- const x = numbers[numIndex++];
4584
- const y = numbers[numIndex++];
4585
- minX = Math.min(minX, x);
4586
- maxX = Math.max(maxX, x);
4587
- minY = Math.min(minY, y);
4588
- maxY = Math.max(maxY, y);
4589
- if (j === 1) {
4590
- currentX = x;
4591
- currentY = y;
4592
- }
4593
- }
4594
- }
4595
- break;
4596
- case "s":
4597
- case "q":
4598
- if (numIndex + 3 < numbers.length) {
4599
- for (let j = 0; j < 2; j++) {
4600
- const x = currentX + numbers[numIndex++];
4601
- const y = currentY + numbers[numIndex++];
4602
- minX = Math.min(minX, x);
4603
- maxX = Math.max(maxX, x);
4604
- minY = Math.min(minY, y);
4605
- maxY = Math.max(maxY, y);
4606
- if (j === 1) {
4607
- currentX = x;
4608
- currentY = y;
4609
- }
4610
- }
4611
- }
4612
- break;
4613
- case "A":
4614
- if (numIndex + 6 < numbers.length) {
4615
- numIndex += 5;
4616
- currentX = numbers[numIndex++];
4617
- currentY = numbers[numIndex++];
4618
- minX = Math.min(minX, currentX);
4619
- maxX = Math.max(maxX, currentX);
4620
- minY = Math.min(minY, currentY);
4621
- maxY = Math.max(maxY, currentY);
4622
- }
4623
- break;
4624
- case "a":
4625
- if (numIndex + 6 < numbers.length) {
4626
- numIndex += 5;
4627
- currentX += numbers[numIndex++];
4628
- currentY += numbers[numIndex++];
4629
- minX = Math.min(minX, currentX);
4630
- maxX = Math.max(maxX, currentX);
4631
- minY = Math.min(minY, currentY);
4632
- maxY = Math.max(maxY, currentY);
4633
- }
4634
- break;
4635
- case "Z":
4636
- case "z":
4637
- break;
4638
- }
4639
- cmdIndex++;
4640
- }
4641
- if (minX === Infinity) {
4642
- return { x: 0, y: 0, w: 0, h: 0 };
4643
- }
4644
- return {
4645
- x: minX,
4646
- y: minY,
4647
- w: maxX - minX,
4648
- h: maxY - minY
4649
- };
4650
- }
4651
- async function renderSvgAssetToPng(asset, options = {}) {
4652
- const defaultWidth = options.defaultWidth ?? 1920;
4653
- const defaultHeight = options.defaultHeight ?? 1080;
4654
- let svgString;
4655
- let targetWidth;
4656
- let targetHeight;
4657
- if (asset.src) {
4658
- svgString = asset.src;
4659
- const dimensions = extractSvgDimensions(svgString);
4660
- targetWidth = dimensions.width || defaultWidth;
4661
- targetHeight = dimensions.height || defaultHeight;
4662
- } else if (asset.shape) {
4663
- targetWidth = toNumber(asset.width, defaultWidth);
4664
- targetHeight = toNumber(asset.height, defaultHeight);
4665
- svgString = shapeToSvgString(asset, targetWidth, targetHeight);
4666
- } else {
4667
- throw new Error("Either 'src' or 'shape' must be provided");
4668
- }
4669
- return renderSvgToPng(svgString, {
4670
- width: targetWidth,
4671
- height: targetHeight,
4672
- background: options.background
4673
- });
4674
- }
4675
- function extractSvgDimensions(svgString) {
4676
- const widthMatch = svgString.match(/width\s*=\s*["']?(\d+(?:\.\d+)?)/i);
4677
- const heightMatch = svgString.match(/height\s*=\s*["']?(\d+(?:\.\d+)?)/i);
4678
- let width = widthMatch ? parseFloat(widthMatch[1]) : 0;
4679
- let height = heightMatch ? parseFloat(heightMatch[1]) : 0;
4680
- if (!width || !height) {
4681
- const viewBoxMatch = svgString.match(/viewBox\s*=\s*["']([^"']+)["']/i);
4682
- if (viewBoxMatch) {
4683
- const parts = viewBoxMatch[1].trim().split(/[\s,]+/).map(parseFloat);
4684
- if (parts.length === 4) {
4685
- width = width || parts[2];
4686
- height = height || parts[3];
4687
- }
4688
- }
4689
- }
4690
- return { width, height };
4691
- }
4692
-
4693
- // src/core/rich-caption-layout.ts
4694
- import { LRUCache } from "lru-cache";
4695
- function isRTLText(text) {
4696
- return containsRTLCharacters(text);
4697
- }
4698
- var WordTimingStore = class {
4699
- startTimes;
4700
- endTimes;
4701
- xPositions;
4702
- yPositions;
4703
- widths;
4704
- words;
4705
- length;
4706
- constructor(words) {
4707
- this.length = words.length;
4708
- this.startTimes = new Uint32Array(this.length);
4709
- this.endTimes = new Uint32Array(this.length);
4710
- this.xPositions = new Float32Array(this.length);
4711
- this.yPositions = new Float32Array(this.length);
4712
- this.widths = new Float32Array(this.length);
4713
- this.words = new Array(this.length);
4714
- for (let i = 0; i < this.length; i++) {
4715
- this.startTimes[i] = Math.floor(words[i].start);
4716
- this.endTimes[i] = Math.floor(words[i].end);
4717
- this.words[i] = words[i].text;
4718
- }
4719
- }
4720
- };
4721
- function findWordAtTime(store, timeMs) {
4722
- let left = 0;
4723
- let right = store.length - 1;
4724
- while (left <= right) {
4725
- const mid = left + right >>> 1;
4726
- const start = store.startTimes[mid];
4727
- const end = store.endTimes[mid];
4728
- if (timeMs >= start && timeMs < end) {
4729
- return mid;
4730
- }
4731
- if (timeMs < start) {
4732
- right = mid - 1;
4733
- } else {
4734
- left = mid + 1;
4735
- }
4736
- }
4737
- return -1;
4738
- }
4739
- function groupWordsByPause(store, pauseThreshold = 500) {
4740
- if (store.length === 0) {
4741
- return [];
4742
- }
4743
- const groups = [];
4744
- let currentGroup = [];
4745
- for (let i = 0; i < store.length; i++) {
4746
- if (currentGroup.length === 0) {
4747
- currentGroup.push(i);
4748
- continue;
4749
- }
4750
- const prevEnd = store.endTimes[currentGroup[currentGroup.length - 1]];
4751
- const currStart = store.startTimes[i];
4752
- const gap = currStart - prevEnd;
4753
- const prevText = store.words[currentGroup[currentGroup.length - 1]];
4754
- const endsWithPunctuation = /[.!?]$/.test(prevText);
4755
- if (gap >= pauseThreshold || endsWithPunctuation) {
4756
- groups.push(currentGroup);
4757
- currentGroup = [i];
4758
- } else {
4759
- currentGroup.push(i);
4760
- }
4761
- }
4762
- if (currentGroup.length > 0) {
4763
- groups.push(currentGroup);
4764
- }
4765
- return groups;
4766
- }
4767
- function breakIntoLines(wordWidths, maxWidth, spaceWidth) {
4768
- const lines = [];
4769
- let currentLine = [];
4770
- let currentWidth = 0;
4771
- for (let i = 0; i < wordWidths.length; i++) {
4772
- const wordWidth = wordWidths[i];
4773
- const spaceNeeded = currentLine.length > 0 ? spaceWidth : 0;
4774
- if (currentWidth + spaceNeeded + wordWidth <= maxWidth) {
4775
- currentLine.push(i);
4776
- currentWidth += spaceNeeded + wordWidth;
4777
- } else {
4778
- if (currentLine.length > 0) {
4779
- lines.push(currentLine);
4780
- }
4781
- currentLine = [i];
4782
- currentWidth = wordWidth;
4783
- }
4784
- }
4785
- if (currentLine.length > 0) {
4786
- lines.push(currentLine);
4787
- }
4788
- return lines;
4789
- }
4790
- var GLYPH_SIZE_ESTIMATE = 64;
4791
- function createShapedWordCache() {
4792
- return new LRUCache({
4793
- max: 5e4,
4794
- maxSize: 50 * 1024 * 1024,
4795
- maxEntrySize: 100 * 1024,
4796
- sizeCalculation: (value, key) => {
4797
- const keySize = key.length * 2;
4798
- const glyphsSize = value.glyphs.length * GLYPH_SIZE_ESTIMATE;
4799
- return keySize + glyphsSize + 100;
4800
- }
4801
- });
4802
- }
4803
- function makeShapingKey(text, fontFamily, fontSize, fontWeight, letterSpacing = 0) {
4804
- return `${text}\0${fontFamily}\0${fontSize}\0${fontWeight}\0${letterSpacing}`;
4805
- }
4806
- function transformText(text, transform) {
4807
- switch (transform) {
4808
- case "uppercase":
4809
- return text.toUpperCase();
4810
- case "lowercase":
4811
- return text.toLowerCase();
4812
- case "capitalize":
4813
- return text.replace(/\b\w/g, (c) => c.toUpperCase());
4814
- default:
4815
- return text;
4816
- }
4817
- }
4818
- function splitIntoChunks(arr, chunkSize) {
4819
- const chunks = [];
4820
- for (let i = 0; i < arr.length; i += chunkSize) {
4821
- chunks.push(arr.slice(i, i + chunkSize));
4822
- }
4823
- return chunks;
4824
- }
4825
- var CaptionLayoutEngine = class {
4826
- fontRegistry;
4827
- cache;
4828
- layoutEngine;
4829
- constructor(fontRegistry) {
4830
- this.fontRegistry = fontRegistry;
4831
- this.cache = createShapedWordCache();
4832
- this.layoutEngine = new LayoutEngine(fontRegistry);
4833
- }
4834
- async measureWord(text, config) {
4835
- const transformedText = transformText(text, config.textTransform);
4836
- const cacheKey = makeShapingKey(
4837
- transformedText,
4838
- config.fontFamily,
4839
- config.fontSize,
4840
- config.fontWeight,
4841
- config.letterSpacing
4842
- );
4843
- const cached = this.cache.get(cacheKey);
4844
- if (cached) {
4845
- return cached;
4846
- }
4847
- const lines = await this.layoutEngine.layout({
4848
- text: transformedText,
4849
- width: 1e5,
4850
- letterSpacing: config.letterSpacing,
4851
- fontSize: config.fontSize,
4852
- lineHeight: 1,
4853
- desc: { family: config.fontFamily, weight: config.fontWeight },
4854
- textTransform: "none"
4855
- });
4856
- const width = lines[0]?.width ?? 0;
4857
- const glyphs = lines[0]?.glyphs ?? [];
4858
- const isRTL = isRTLText(transformedText);
4859
- const shaped = {
4860
- text: transformedText,
4861
- width,
4862
- glyphs: glyphs.map((g) => ({
4863
- id: g.id,
4864
- xAdvance: g.xAdvance,
4865
- xOffset: g.xOffset,
4866
- yOffset: g.yOffset,
4867
- cluster: g.cluster
4868
- })),
4869
- isRTL
4870
- };
4871
- this.cache.set(cacheKey, shaped);
4872
- return shaped;
4873
- }
4874
- async layoutCaption(words, config) {
4875
- const store = new WordTimingStore(words);
4876
- const measurementConfig = {
4877
- fontFamily: config.fontFamily,
4878
- fontSize: config.fontSize,
4879
- fontWeight: config.fontWeight,
4880
- letterSpacing: config.letterSpacing,
4881
- textTransform: config.textTransform
4882
- };
4883
- const shapedWords = await Promise.all(
4884
- words.map((w) => this.measureWord(w.text, measurementConfig))
4885
- );
4886
- if (config.measureTextWidth) {
4887
- const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
4888
- for (let i = 0; i < shapedWords.length; i++) {
4889
- store.widths[i] = config.measureTextWidth(shapedWords[i].text, fontString);
4890
- }
4891
- } else {
4892
- for (let i = 0; i < shapedWords.length; i++) {
4893
- store.widths[i] = shapedWords[i].width;
4894
- }
4895
- }
4896
- if (config.textTransform !== "none") {
4897
- for (let i = 0; i < shapedWords.length; i++) {
4898
- store.words[i] = shapedWords[i].text;
4899
- }
4900
- }
4901
- const wordGroups = groupWordsByPause(store, config.pauseThreshold);
4902
- const pixelMaxWidth = config.availableWidth;
4903
- let spaceWidth;
4904
- if (config.measureTextWidth) {
4905
- const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
4906
- spaceWidth = config.measureTextWidth(" ", fontString) + config.wordSpacing;
4907
- } else {
4908
- const spaceWord = await this.measureWord(" ", measurementConfig);
4909
- spaceWidth = spaceWord.width + config.wordSpacing;
4910
- }
4911
- const groups = wordGroups.flatMap((indices) => {
4912
- const groupWidths = indices.map((i) => store.widths[i]);
4913
- const allLines = breakIntoLines(
4914
- groupWidths,
4915
- pixelMaxWidth,
4916
- spaceWidth
4917
- );
4918
- const lineChunks = splitIntoChunks(allLines, config.maxLines);
4919
- return lineChunks.map((chunkLines) => {
4920
- const lines = chunkLines.map((lineWordIndices, lineIndex) => {
4921
- const actualIndices = lineWordIndices.map((i) => indices[i]);
4922
- const lineWidth = actualIndices.reduce((sum, idx) => sum + store.widths[idx], 0) + (actualIndices.length - 1) * spaceWidth;
4923
- return {
4924
- wordIndices: actualIndices,
4925
- x: 0,
4926
- y: lineIndex * config.fontSize * config.lineHeight,
4927
- width: lineWidth,
4928
- height: config.fontSize
4929
- };
4930
- });
4931
- const allWordIndices = lines.flatMap((l) => l.wordIndices);
4932
- if (allWordIndices.length === 0) {
4933
- return null;
4916
+ break;
4917
+ case "h":
4918
+ if (numIndex < numbers.length) {
4919
+ currentX += numbers[numIndex++];
4920
+ minX = Math.min(minX, currentX);
4921
+ maxX = Math.max(maxX, currentX);
4934
4922
  }
4935
- return {
4936
- wordIndices: allWordIndices,
4937
- startTime: store.startTimes[allWordIndices[0]],
4938
- endTime: store.endTimes[allWordIndices[allWordIndices.length - 1]],
4939
- lines
4940
- };
4941
- }).filter((g) => g !== null);
4942
- });
4943
- const contentHeight = config.frameHeight - config.padding.top - config.padding.bottom;
4944
- const contentWidth = config.frameWidth - config.padding.left - config.padding.right;
4945
- const ASCENT_RATIO2 = 0.8;
4946
- const calculateGroupY = (group) => {
4947
- const totalHeight = group.lines.length * config.fontSize * config.lineHeight;
4948
- switch (config.verticalAlign) {
4949
- case "top":
4950
- return config.padding.top + config.fontSize * ASCENT_RATIO2;
4951
- case "bottom":
4952
- return config.frameHeight - config.padding.bottom - totalHeight + config.fontSize * ASCENT_RATIO2;
4953
- case "middle":
4954
- default:
4955
- return config.padding.top + (contentHeight - totalHeight) / 2 + config.fontSize * ASCENT_RATIO2;
4956
- }
4957
- };
4958
- const allWordTexts = store.words.slice(0, store.length);
4959
- const paragraphDirection = detectParagraphDirectionFromWords(allWordTexts);
4960
- const calculateLineX = (lineWidth) => {
4961
- switch (config.horizontalAlign) {
4962
- case "left":
4963
- return config.padding.left;
4964
- case "right":
4965
- return config.frameWidth - lineWidth - config.padding.right;
4966
- case "center":
4967
- default:
4968
- return config.padding.left + (contentWidth - lineWidth) / 2;
4969
- }
4970
- };
4971
- for (const group of groups) {
4972
- const baseY = calculateGroupY(group);
4973
- for (let lineIdx = 0; lineIdx < group.lines.length; lineIdx++) {
4974
- const line = group.lines[lineIdx];
4975
- line.x = calculateLineX(line.width);
4976
- line.y = baseY + lineIdx * config.fontSize * config.lineHeight;
4977
- const lineWordTexts = line.wordIndices.map((idx) => store.words[idx]);
4978
- const visualOrder = reorderWordsForLine(lineWordTexts, paragraphDirection);
4979
- let xCursor = line.x;
4980
- for (const visualIdx of visualOrder) {
4981
- const wordIdx = line.wordIndices[visualIdx];
4982
- store.xPositions[wordIdx] = xCursor;
4983
- store.yPositions[wordIdx] = line.y;
4984
- xCursor += store.widths[wordIdx] + spaceWidth;
4923
+ break;
4924
+ case "V":
4925
+ if (numIndex < numbers.length) {
4926
+ currentY = numbers[numIndex++];
4927
+ minY = Math.min(minY, currentY);
4928
+ maxY = Math.max(maxY, currentY);
4985
4929
  }
4986
- }
4987
- }
4988
- return {
4989
- store,
4990
- groups,
4991
- shapedWords,
4992
- paragraphDirection
4993
- };
4994
- }
4995
- getVisibleWordsAtTime(layout, timeMs) {
4996
- const activeGroup = layout.groups.find(
4997
- (g) => timeMs >= g.startTime && timeMs <= g.endTime
4998
- );
4999
- if (!activeGroup) {
5000
- return [];
4930
+ break;
4931
+ case "v":
4932
+ if (numIndex < numbers.length) {
4933
+ currentY += numbers[numIndex++];
4934
+ minY = Math.min(minY, currentY);
4935
+ maxY = Math.max(maxY, currentY);
4936
+ }
4937
+ break;
4938
+ case "C":
4939
+ if (numIndex + 5 < numbers.length) {
4940
+ for (let j = 0; j < 3; j++) {
4941
+ const x = numbers[numIndex++];
4942
+ const y = numbers[numIndex++];
4943
+ minX = Math.min(minX, x);
4944
+ maxX = Math.max(maxX, x);
4945
+ minY = Math.min(minY, y);
4946
+ maxY = Math.max(maxY, y);
4947
+ if (j === 2) {
4948
+ currentX = x;
4949
+ currentY = y;
4950
+ }
4951
+ }
4952
+ }
4953
+ break;
4954
+ case "c":
4955
+ if (numIndex + 5 < numbers.length) {
4956
+ for (let j = 0; j < 3; j++) {
4957
+ const x = currentX + numbers[numIndex++];
4958
+ const y = currentY + numbers[numIndex++];
4959
+ minX = Math.min(minX, x);
4960
+ maxX = Math.max(maxX, x);
4961
+ minY = Math.min(minY, y);
4962
+ maxY = Math.max(maxY, y);
4963
+ if (j === 2) {
4964
+ currentX = x;
4965
+ currentY = y;
4966
+ }
4967
+ }
4968
+ }
4969
+ break;
4970
+ case "S":
4971
+ case "Q":
4972
+ if (numIndex + 3 < numbers.length) {
4973
+ for (let j = 0; j < 2; j++) {
4974
+ const x = numbers[numIndex++];
4975
+ const y = numbers[numIndex++];
4976
+ minX = Math.min(minX, x);
4977
+ maxX = Math.max(maxX, x);
4978
+ minY = Math.min(minY, y);
4979
+ maxY = Math.max(maxY, y);
4980
+ if (j === 1) {
4981
+ currentX = x;
4982
+ currentY = y;
4983
+ }
4984
+ }
4985
+ }
4986
+ break;
4987
+ case "s":
4988
+ case "q":
4989
+ if (numIndex + 3 < numbers.length) {
4990
+ for (let j = 0; j < 2; j++) {
4991
+ const x = currentX + numbers[numIndex++];
4992
+ const y = currentY + numbers[numIndex++];
4993
+ minX = Math.min(minX, x);
4994
+ maxX = Math.max(maxX, x);
4995
+ minY = Math.min(minY, y);
4996
+ maxY = Math.max(maxY, y);
4997
+ if (j === 1) {
4998
+ currentX = x;
4999
+ currentY = y;
5000
+ }
5001
+ }
5002
+ }
5003
+ break;
5004
+ case "A":
5005
+ if (numIndex + 6 < numbers.length) {
5006
+ numIndex += 5;
5007
+ currentX = numbers[numIndex++];
5008
+ currentY = numbers[numIndex++];
5009
+ minX = Math.min(minX, currentX);
5010
+ maxX = Math.max(maxX, currentX);
5011
+ minY = Math.min(minY, currentY);
5012
+ maxY = Math.max(maxY, currentY);
5013
+ }
5014
+ break;
5015
+ case "a":
5016
+ if (numIndex + 6 < numbers.length) {
5017
+ numIndex += 5;
5018
+ currentX += numbers[numIndex++];
5019
+ currentY += numbers[numIndex++];
5020
+ minX = Math.min(minX, currentX);
5021
+ maxX = Math.max(maxX, currentX);
5022
+ minY = Math.min(minY, currentY);
5023
+ maxY = Math.max(maxY, currentY);
5024
+ }
5025
+ break;
5026
+ case "Z":
5027
+ case "z":
5028
+ break;
5001
5029
  }
5002
- return activeGroup.wordIndices.map((idx) => ({
5003
- wordIndex: idx,
5004
- text: layout.store.words[idx],
5005
- x: layout.store.xPositions[idx],
5006
- y: layout.store.yPositions[idx],
5007
- width: layout.store.widths[idx],
5008
- startTime: layout.store.startTimes[idx],
5009
- endTime: layout.store.endTimes[idx],
5010
- isRTL: layout.shapedWords[idx].isRTL
5011
- }));
5030
+ cmdIndex++;
5012
5031
  }
5013
- getActiveWordAtTime(layout, timeMs) {
5014
- const wordIndex = findWordAtTime(layout.store, timeMs);
5015
- if (wordIndex === -1) {
5016
- return null;
5017
- }
5018
- return {
5019
- wordIndex,
5020
- text: layout.store.words[wordIndex],
5021
- x: layout.store.xPositions[wordIndex],
5022
- y: layout.store.yPositions[wordIndex],
5023
- width: layout.store.widths[wordIndex],
5024
- startTime: layout.store.startTimes[wordIndex],
5025
- endTime: layout.store.endTimes[wordIndex],
5026
- isRTL: layout.shapedWords[wordIndex].isRTL
5027
- };
5032
+ if (minX === Infinity) {
5033
+ return { x: 0, y: 0, w: 0, h: 0 };
5028
5034
  }
5029
- clearCache() {
5030
- this.cache.clear();
5035
+ return {
5036
+ x: minX,
5037
+ y: minY,
5038
+ w: maxX - minX,
5039
+ h: maxY - minY
5040
+ };
5041
+ }
5042
+ async function renderSvgAssetToPng(asset, options = {}) {
5043
+ const defaultWidth = options.defaultWidth ?? 1920;
5044
+ const defaultHeight = options.defaultHeight ?? 1080;
5045
+ let svgString;
5046
+ let targetWidth;
5047
+ let targetHeight;
5048
+ if (asset.src) {
5049
+ svgString = asset.src;
5050
+ const dimensions = extractSvgDimensions(svgString);
5051
+ targetWidth = dimensions.width || defaultWidth;
5052
+ targetHeight = dimensions.height || defaultHeight;
5053
+ } else if (asset.shape) {
5054
+ targetWidth = toNumber(asset.width, defaultWidth);
5055
+ targetHeight = toNumber(asset.height, defaultHeight);
5056
+ svgString = shapeToSvgString(asset, targetWidth, targetHeight);
5057
+ } else {
5058
+ throw new Error("Either 'src' or 'shape' must be provided");
5031
5059
  }
5032
- getCacheStats() {
5033
- return {
5034
- size: this.cache.size,
5035
- calculatedSize: this.cache.calculatedSize
5036
- };
5060
+ return renderSvgToPng(svgString, {
5061
+ width: targetWidth,
5062
+ height: targetHeight,
5063
+ background: options.background
5064
+ });
5065
+ }
5066
+ function extractSvgDimensions(svgString) {
5067
+ const widthMatch = svgString.match(/width\s*=\s*["']?(\d+(?:\.\d+)?)/i);
5068
+ const heightMatch = svgString.match(/height\s*=\s*["']?(\d+(?:\.\d+)?)/i);
5069
+ let width = widthMatch ? parseFloat(widthMatch[1]) : 0;
5070
+ let height = heightMatch ? parseFloat(heightMatch[1]) : 0;
5071
+ if (!width || !height) {
5072
+ const viewBoxMatch = svgString.match(/viewBox\s*=\s*["']([^"']+)["']/i);
5073
+ if (viewBoxMatch) {
5074
+ const parts = viewBoxMatch[1].trim().split(/[\s,]+/).map(parseFloat);
5075
+ if (parts.length === 4) {
5076
+ width = width || parts[2];
5077
+ height = height || parts[3];
5078
+ }
5079
+ }
5037
5080
  }
5038
- };
5081
+ return { width, height };
5082
+ }
5039
5083
 
5040
5084
  // src/core/canvas-text-measurer.ts
5041
5085
  async function createCanvasTextMeasurer() {
@@ -5411,7 +5455,7 @@ function findActiveWordIndex(store, groupWordIndices, timeMs) {
5411
5455
  }
5412
5456
  return -1;
5413
5457
  }
5414
- function getAnimationPhase(store, groupWordIndices, timeMs, animationStyle, speed) {
5458
+ function getAnimationPhase(store, groupWordIndices, timeMs, animationStyle) {
5415
5459
  if (groupWordIndices.length === 0) {
5416
5460
  return "idle";
5417
5461
  }
@@ -5428,7 +5472,7 @@ function getAnimationPhase(store, groupWordIndices, timeMs, animationStyle, spee
5428
5472
  return "after";
5429
5473
  }
5430
5474
  if (TRANSITION_ANIMATION_STYLES.has(animationStyle)) {
5431
- const transitionDurationMs = (ANIMATION_DURATION_MS[animationStyle] ?? 200) / speed;
5475
+ const transitionDurationMs = ANIMATION_DURATION_MS[animationStyle] ?? 200;
5432
5476
  for (const idx of groupWordIndices) {
5433
5477
  const wordStart = store.startTimes[idx];
5434
5478
  if (timeMs >= wordStart && timeMs < wordStart + transitionDurationMs) {
@@ -5450,7 +5494,7 @@ function getAnimationPhase(store, groupWordIndices, timeMs, animationStyle, spee
5450
5494
  }
5451
5495
  return "before";
5452
5496
  }
5453
- function computeStateSignature(layout, timeMs, animationStyle, speed) {
5497
+ function computeStateSignature(layout, timeMs, animationStyle) {
5454
5498
  const groupIndex = findGroupIndexAtTime(layout.groups, timeMs);
5455
5499
  if (groupIndex === -1) {
5456
5500
  return { groupIndex: -1, activeWordIndex: -1, animationPhase: "idle" };
@@ -5461,21 +5505,20 @@ function computeStateSignature(layout, timeMs, animationStyle, speed) {
5461
5505
  layout.store,
5462
5506
  group.wordIndices,
5463
5507
  timeMs,
5464
- animationStyle,
5465
- speed
5508
+ animationStyle
5466
5509
  );
5467
5510
  return { groupIndex, activeWordIndex, animationPhase };
5468
5511
  }
5469
5512
  function signaturesMatch(a, b) {
5470
5513
  return a.groupIndex === b.groupIndex && a.activeWordIndex === b.activeWordIndex && a.animationPhase === b.animationPhase;
5471
5514
  }
5472
- function createFrameSchedule(layout, durationMs, fps, animationStyle = "highlight", speed = 1) {
5515
+ function createFrameSchedule(layout, durationMs, fps, animationStyle = "highlight") {
5473
5516
  const totalFrames = Math.max(2, Math.round(durationMs / 1e3 * fps) + 1);
5474
5517
  const renderFrames = [];
5475
5518
  let previousSignature = null;
5476
5519
  for (let frame = 0; frame < totalFrames; frame++) {
5477
5520
  const timeMs = frame / (totalFrames - 1) * durationMs;
5478
- const signature = computeStateSignature(layout, timeMs, animationStyle, speed);
5521
+ const signature = computeStateSignature(layout, timeMs, animationStyle);
5479
5522
  const isAnimating = signature.animationPhase === "animating";
5480
5523
  if (isAnimating || previousSignature === null || !signaturesMatch(signature, previousSignature)) {
5481
5524
  renderFrames.push({
@@ -5877,14 +5920,12 @@ var RichCaptionRenderer = class {
5877
5920
  throw new Error("No asset loaded. Call loadAsset() first.");
5878
5921
  }
5879
5922
  const animationStyle = this.extractAnimationStyle();
5880
- const animationSpeed = this.extractAnimationSpeed();
5881
5923
  const durationMs = duration * 1e3;
5882
5924
  const schedule = createFrameSchedule(
5883
5925
  this.currentLayout,
5884
5926
  durationMs,
5885
5927
  this.fps,
5886
- animationStyle,
5887
- animationSpeed
5928
+ animationStyle
5888
5929
  );
5889
5930
  const bgColor = options?.bgColor;
5890
5931
  const hasAlpha = !bgColor;
@@ -6045,13 +6086,11 @@ var RichCaptionRenderer = class {
6045
6086
  throw new Error("No asset loaded. Call loadAsset() first.");
6046
6087
  }
6047
6088
  const animationStyle = this.extractAnimationStyle();
6048
- const animationSpeed = this.extractAnimationSpeed();
6049
6089
  return createFrameSchedule(
6050
6090
  this.currentLayout,
6051
6091
  duration * 1e3,
6052
6092
  this.fps,
6053
- animationStyle,
6054
- animationSpeed
6093
+ animationStyle
6055
6094
  );
6056
6095
  }
6057
6096
  getStats() {
@@ -6089,10 +6128,6 @@ var RichCaptionRenderer = class {
6089
6128
  const wordAnim = this.currentAsset?.wordAnimation;
6090
6129
  return wordAnim?.style ?? "highlight";
6091
6130
  }
6092
- extractAnimationSpeed() {
6093
- const wordAnim = this.currentAsset?.wordAnimation;
6094
- return wordAnim?.speed ?? 1;
6095
- }
6096
6131
  logProgress(pct, framesProcessed, totalFrames, uniqueProcessed, uniqueTotal, fps, eta) {
6097
6132
  if (typeof process !== "undefined" && process.stderr) {
6098
6133
  process.stderr.write(