@shotstack/shotstack-canvas 2.1.4 → 2.1.5

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.
@@ -188,19 +188,28 @@ var richCaptionFontSchema = z.object({
188
188
  weight: z.union([z.string(), z.number()]).default("400"),
189
189
  color: z.string().regex(HEX6).default("#ffffff"),
190
190
  opacity: z.number().min(0).max(1).default(1),
191
- background: z.string().regex(HEX6).optional()
191
+ background: z.string().regex(HEX6).optional(),
192
+ textDecoration: z.enum(["none", "underline", "line-through"]).default("none")
192
193
  });
193
194
  var richCaptionActiveSchema = baseCaptionActiveSchema.extend({
194
195
  font: z.object({
195
- color: z.string().regex(HEX6).default("#ffffff"),
196
+ color: z.string().regex(HEX6).optional(),
196
197
  background: z.string().regex(HEX6).optional(),
197
- opacity: z.number().min(0).max(1).default(1)
198
+ opacity: z.number().min(0).max(1).optional(),
199
+ textDecoration: z.enum(["none", "underline", "line-through"]).optional()
198
200
  }).optional(),
199
201
  stroke: z.object({
200
202
  width: z.number().min(0).optional(),
201
203
  color: z.string().regex(HEX6).optional(),
202
204
  opacity: z.number().min(0).max(1).optional()
203
205
  }).optional(),
206
+ shadow: z.object({
207
+ offsetX: z.number().optional(),
208
+ offsetY: z.number().optional(),
209
+ blur: z.number().min(0).optional(),
210
+ color: z.string().regex(HEX6).optional(),
211
+ opacity: z.number().min(0).max(1).optional()
212
+ }).optional(),
204
213
  scale: z.number().min(0.5).max(2).default(1)
205
214
  });
206
215
  var richCaptionWordAnimationSchema = baseCaptionWordAnimationSchema.extend({
@@ -2216,239 +2225,591 @@ function parseHex6(hex, alpha = 1) {
2216
2225
  return { r, g, b, a: alpha };
2217
2226
  }
2218
2227
 
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));
2228
+ // src/core/rich-caption-layout.ts
2229
+ import { LRUCache } from "lru-cache";
2230
+ var ASCENT_RATIO = 0.8;
2231
+ var DESCENT_RATIO = 0.2;
2232
+ function isRTLText(text) {
2233
+ return containsRTLCharacters(text);
2252
2234
  }
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;
2235
+ var WordTimingStore = class {
2236
+ startTimes;
2237
+ endTimes;
2238
+ xPositions;
2239
+ yPositions;
2240
+ widths;
2241
+ words;
2242
+ length;
2243
+ constructor(words) {
2244
+ this.length = words.length;
2245
+ this.startTimes = new Uint32Array(this.length);
2246
+ this.endTimes = new Uint32Array(this.length);
2247
+ this.xPositions = new Float32Array(this.length);
2248
+ this.yPositions = new Float32Array(this.length);
2249
+ this.widths = new Float32Array(this.length);
2250
+ this.words = new Array(this.length);
2251
+ for (let i = 0; i < this.length; i++) {
2252
+ this.startTimes[i] = Math.floor(words[i].start);
2253
+ this.endTimes[i] = Math.floor(words[i].end);
2254
+ this.words[i] = words[i].text;
2255
+ }
2261
2256
  }
2262
- if (t < 2.5 / d1) {
2263
- return n1 * (t -= 2.25 / d1) * t + 0.9375;
2257
+ };
2258
+ function findWordAtTime(store, timeMs) {
2259
+ let left = 0;
2260
+ let right = store.length - 1;
2261
+ while (left <= right) {
2262
+ const mid = left + right >>> 1;
2263
+ const start = store.startTimes[mid];
2264
+ const end = store.endTimes[mid];
2265
+ if (timeMs >= start && timeMs < end) {
2266
+ return mid;
2267
+ }
2268
+ if (timeMs < start) {
2269
+ right = mid - 1;
2270
+ } else {
2271
+ left = mid + 1;
2272
+ }
2264
2273
  }
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);
2274
+ return -1;
2269
2275
  }
2270
- function calculateAnimationProgress(ctx) {
2271
- if (ctx.animationDuration <= 0) {
2272
- return ctx.currentTime >= ctx.wordStart ? 1 : 0;
2276
+ function groupWordsByPause(store, pauseThreshold = 500) {
2277
+ if (store.length === 0) {
2278
+ return [];
2273
2279
  }
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;
2280
+ const groups = [];
2281
+ let currentGroup = [];
2282
+ for (let i = 0; i < store.length; i++) {
2283
+ if (currentGroup.length === 0) {
2284
+ currentGroup.push(i);
2285
+ continue;
2286
+ }
2287
+ const prevEnd = store.endTimes[currentGroup[currentGroup.length - 1]];
2288
+ const currStart = store.startTimes[i];
2289
+ const gap = currStart - prevEnd;
2290
+ const prevText = store.words[currentGroup[currentGroup.length - 1]];
2291
+ const endsWithPunctuation = /[.!?]$/.test(prevText);
2292
+ if (gap >= pauseThreshold || endsWithPunctuation) {
2293
+ groups.push(currentGroup);
2294
+ currentGroup = [i];
2295
+ } else {
2296
+ currentGroup.push(i);
2297
+ }
2281
2298
  }
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;
2299
+ if (currentGroup.length > 0) {
2300
+ groups.push(currentGroup);
2301
+ }
2302
+ return groups;
2287
2303
  }
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
- };
2304
+ function breakIntoLines(wordWidths, maxWidth, spaceWidth) {
2305
+ const lines = [];
2306
+ let currentLine = [];
2307
+ let currentWidth = 0;
2308
+ for (let i = 0; i < wordWidths.length; i++) {
2309
+ const wordWidth = wordWidths[i];
2310
+ const spaceNeeded = currentLine.length > 0 ? spaceWidth : 0;
2311
+ if (currentWidth + spaceNeeded + wordWidth <= maxWidth) {
2312
+ currentLine.push(i);
2313
+ currentWidth += spaceNeeded + wordWidth;
2314
+ } else {
2315
+ if (currentLine.length > 0) {
2316
+ lines.push(currentLine);
2317
+ }
2318
+ currentLine = [i];
2319
+ currentWidth = wordWidth;
2320
+ }
2300
2321
  }
2301
- if (ctx.currentTime >= adjustedEnd) {
2302
- return {
2303
- fillProgress: 1,
2304
- isActive: false,
2305
- opacity: 1
2306
- };
2322
+ if (currentLine.length > 0) {
2323
+ lines.push(currentLine);
2307
2324
  }
2308
- return {
2309
- fillProgress: calculateWordProgress(adjustedCtx),
2310
- isActive,
2311
- opacity: 1
2312
- };
2325
+ return lines;
2313
2326
  }
2314
- function calculateHighlightState(ctx) {
2315
- const isActive = isWordActive(ctx);
2316
- return {
2317
- isActive,
2318
- fillProgress: isActive ? 1 : 0,
2319
- opacity: 1
2320
- };
2327
+ var GLYPH_SIZE_ESTIMATE = 64;
2328
+ function createShapedWordCache() {
2329
+ return new LRUCache({
2330
+ max: 5e4,
2331
+ maxSize: 50 * 1024 * 1024,
2332
+ maxEntrySize: 100 * 1024,
2333
+ sizeCalculation: (value, key) => {
2334
+ const keySize = key.length * 2;
2335
+ const glyphsSize = value.glyphs.length * GLYPH_SIZE_ESTIMATE;
2336
+ return keySize + glyphsSize + 100;
2337
+ }
2338
+ });
2321
2339
  }
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
- };
2340
+ function makeShapingKey(text, fontFamily, fontSize, fontWeight, letterSpacing = 0) {
2341
+ return `${text}\0${fontFamily}\0${fontSize}\0${fontWeight}\0${letterSpacing}`;
2342
+ }
2343
+ function transformText(text, transform) {
2344
+ switch (transform) {
2345
+ case "uppercase":
2346
+ return text.toUpperCase();
2347
+ case "lowercase":
2348
+ return text.toLowerCase();
2349
+ case "capitalize":
2350
+ return text.replace(/\b\w/g, (c) => c.toUpperCase());
2351
+ default:
2352
+ return text;
2330
2353
  }
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
- };
2345
2354
  }
2346
- function calculateFadeState(ctx, speed) {
2347
- if (ctx.currentTime < ctx.wordStart) {
2348
- return {
2349
- opacity: 0,
2350
- isActive: false,
2351
- fillProgress: 0
2352
- };
2355
+ function splitIntoChunks(arr, chunkSize) {
2356
+ const chunks = [];
2357
+ for (let i = 0; i < arr.length; i += chunkSize) {
2358
+ chunks.push(arr.slice(i, i + chunkSize));
2353
2359
  }
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
- };
2360
+ return chunks;
2364
2361
  }
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
- };
2362
+ var CaptionLayoutEngine = class {
2363
+ fontRegistry;
2364
+ cache;
2365
+ layoutEngine;
2366
+ constructor(fontRegistry) {
2367
+ this.fontRegistry = fontRegistry;
2368
+ this.cache = createShapedWordCache();
2369
+ this.layoutEngine = new LayoutEngine(fontRegistry);
2376
2370
  }
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
- };
2392
- }
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 };
2371
+ async measureWord(text, config) {
2372
+ const transformedText = transformText(text, config.textTransform);
2373
+ const cacheKey = makeShapingKey(
2374
+ transformedText,
2375
+ config.fontFamily,
2376
+ config.fontSize,
2377
+ config.fontWeight,
2378
+ config.letterSpacing
2379
+ );
2380
+ const cached = this.cache.get(cacheKey);
2381
+ if (cached) {
2382
+ return cached;
2383
+ }
2384
+ const lines = await this.layoutEngine.layout({
2385
+ text: transformedText,
2386
+ width: 1e5,
2387
+ letterSpacing: config.letterSpacing,
2388
+ fontSize: config.fontSize,
2389
+ lineHeight: 1,
2390
+ desc: { family: config.fontFamily, weight: config.fontWeight },
2391
+ textTransform: "none"
2392
+ });
2393
+ const width = lines[0]?.width ?? 0;
2394
+ const glyphs = lines[0]?.glyphs ?? [];
2395
+ const isRTL = isRTLText(transformedText);
2396
+ const shaped = {
2397
+ text: transformedText,
2398
+ width,
2399
+ glyphs: glyphs.map((g) => ({
2400
+ id: g.id,
2401
+ xAdvance: g.xAdvance,
2402
+ xOffset: g.xOffset,
2403
+ yOffset: g.yOffset,
2404
+ cluster: g.cluster
2405
+ })),
2406
+ isRTL
2407
+ };
2408
+ this.cache.set(cacheKey, shaped);
2409
+ return shaped;
2403
2410
  }
2404
- }
2405
- function calculateBounceState(ctx, speed, fontSize) {
2406
- const bounceDistance = fontSize * 0.8;
2407
- if (ctx.currentTime < ctx.wordStart) {
2411
+ async layoutCaption(words, config) {
2412
+ const store = new WordTimingStore(words);
2413
+ const measurementConfig = {
2414
+ fontFamily: config.fontFamily,
2415
+ fontSize: config.fontSize,
2416
+ fontWeight: config.fontWeight,
2417
+ letterSpacing: config.letterSpacing,
2418
+ textTransform: config.textTransform
2419
+ };
2420
+ const shapedWords = await Promise.all(
2421
+ words.map((w) => this.measureWord(w.text, measurementConfig))
2422
+ );
2423
+ if (config.measureTextWidth) {
2424
+ const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
2425
+ for (let i = 0; i < shapedWords.length; i++) {
2426
+ store.widths[i] = config.measureTextWidth(shapedWords[i].text, fontString);
2427
+ }
2428
+ } else {
2429
+ for (let i = 0; i < shapedWords.length; i++) {
2430
+ store.widths[i] = shapedWords[i].width;
2431
+ }
2432
+ }
2433
+ if (config.textTransform !== "none") {
2434
+ for (let i = 0; i < shapedWords.length; i++) {
2435
+ store.words[i] = shapedWords[i].text;
2436
+ }
2437
+ }
2438
+ const wordGroups = groupWordsByPause(store, config.pauseThreshold);
2439
+ const pixelMaxWidth = config.availableWidth;
2440
+ let spaceWidth;
2441
+ if (config.measureTextWidth) {
2442
+ const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
2443
+ spaceWidth = config.measureTextWidth(" ", fontString) + config.wordSpacing;
2444
+ } else {
2445
+ const spaceWord = await this.measureWord(" ", measurementConfig);
2446
+ spaceWidth = spaceWord.width + config.wordSpacing;
2447
+ }
2448
+ const groups = wordGroups.flatMap((indices) => {
2449
+ const groupWidths = indices.map((i) => store.widths[i]);
2450
+ const allLines = breakIntoLines(
2451
+ groupWidths,
2452
+ pixelMaxWidth,
2453
+ spaceWidth
2454
+ );
2455
+ const lineChunks = splitIntoChunks(allLines, config.maxLines);
2456
+ return lineChunks.map((chunkLines) => {
2457
+ const lines = chunkLines.map((lineWordIndices, lineIndex) => {
2458
+ const actualIndices = lineWordIndices.map((i) => indices[i]);
2459
+ const lineWidth = actualIndices.reduce((sum, idx) => sum + store.widths[idx], 0) + (actualIndices.length - 1) * spaceWidth;
2460
+ return {
2461
+ wordIndices: actualIndices,
2462
+ x: 0,
2463
+ y: lineIndex * config.fontSize * config.lineHeight,
2464
+ width: lineWidth,
2465
+ height: config.fontSize
2466
+ };
2467
+ });
2468
+ const allWordIndices = lines.flatMap((l) => l.wordIndices);
2469
+ if (allWordIndices.length === 0) {
2470
+ return null;
2471
+ }
2472
+ return {
2473
+ wordIndices: allWordIndices,
2474
+ startTime: store.startTimes[allWordIndices[0]],
2475
+ endTime: store.endTimes[allWordIndices[allWordIndices.length - 1]],
2476
+ lines
2477
+ };
2478
+ }).filter((g) => g !== null);
2479
+ });
2480
+ const contentHeight = config.frameHeight - config.padding.top - config.padding.bottom;
2481
+ const contentWidth = config.frameWidth - config.padding.left - config.padding.right;
2482
+ const calculateGroupY = (group) => {
2483
+ const totalHeight = group.lines.length * config.fontSize * config.lineHeight;
2484
+ switch (config.verticalAlign) {
2485
+ case "top":
2486
+ return config.padding.top + config.fontSize * ASCENT_RATIO;
2487
+ case "bottom":
2488
+ return config.frameHeight - config.padding.bottom - totalHeight + config.fontSize * ASCENT_RATIO;
2489
+ case "middle":
2490
+ default:
2491
+ return config.padding.top + (contentHeight - totalHeight) / 2 + config.fontSize * ASCENT_RATIO;
2492
+ }
2493
+ };
2494
+ const allWordTexts = store.words.slice(0, store.length);
2495
+ const paragraphDirection = detectParagraphDirectionFromWords(allWordTexts);
2496
+ const calculateLineX = (lineWidth) => {
2497
+ switch (config.horizontalAlign) {
2498
+ case "left":
2499
+ return config.padding.left;
2500
+ case "right":
2501
+ return config.frameWidth - lineWidth - config.padding.right;
2502
+ case "center":
2503
+ default:
2504
+ return config.padding.left + (contentWidth - lineWidth) / 2;
2505
+ }
2506
+ };
2507
+ for (const group of groups) {
2508
+ const baseY = calculateGroupY(group);
2509
+ for (let lineIdx = 0; lineIdx < group.lines.length; lineIdx++) {
2510
+ const line = group.lines[lineIdx];
2511
+ line.x = calculateLineX(line.width);
2512
+ line.y = baseY + lineIdx * config.fontSize * config.lineHeight;
2513
+ const lineWordTexts = line.wordIndices.map((idx) => store.words[idx]);
2514
+ const visualOrder = reorderWordsForLine(lineWordTexts, paragraphDirection);
2515
+ let xCursor = line.x;
2516
+ for (const visualIdx of visualOrder) {
2517
+ const wordIdx = line.wordIndices[visualIdx];
2518
+ store.xPositions[wordIdx] = xCursor;
2519
+ store.yPositions[wordIdx] = line.y;
2520
+ xCursor += store.widths[wordIdx] + spaceWidth;
2521
+ }
2522
+ }
2523
+ }
2408
2524
  return {
2409
- translateY: -bounceDistance,
2410
- opacity: 0,
2411
- isActive: false,
2412
- fillProgress: 0
2525
+ store,
2526
+ groups,
2527
+ shapedWords,
2528
+ paragraphDirection
2413
2529
  };
2414
2530
  }
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) {
2531
+ getVisibleWordsAtTime(layout, timeMs) {
2532
+ const activeGroup = layout.groups.find(
2533
+ (g) => timeMs >= g.startTime && timeMs <= g.endTime
2534
+ );
2535
+ if (!activeGroup) {
2536
+ return [];
2537
+ }
2538
+ return activeGroup.wordIndices.map((idx) => ({
2539
+ wordIndex: idx,
2540
+ text: layout.store.words[idx],
2541
+ x: layout.store.xPositions[idx],
2542
+ y: layout.store.yPositions[idx],
2543
+ width: layout.store.widths[idx],
2544
+ startTime: layout.store.startTimes[idx],
2545
+ endTime: layout.store.endTimes[idx],
2546
+ isRTL: layout.shapedWords[idx].isRTL
2547
+ }));
2548
+ }
2549
+ getActiveWordAtTime(layout, timeMs) {
2550
+ const wordIndex = findWordAtTime(layout.store, timeMs);
2551
+ if (wordIndex === -1) {
2552
+ return null;
2553
+ }
2433
2554
  return {
2434
- visibleCharacters: 0,
2435
- opacity: 1,
2436
- isActive: false
2555
+ wordIndex,
2556
+ text: layout.store.words[wordIndex],
2557
+ x: layout.store.xPositions[wordIndex],
2558
+ y: layout.store.yPositions[wordIndex],
2559
+ width: layout.store.widths[wordIndex],
2560
+ startTime: layout.store.startTimes[wordIndex],
2561
+ endTime: layout.store.endTimes[wordIndex],
2562
+ isRTL: layout.shapedWords[wordIndex].isRTL
2563
+ };
2564
+ }
2565
+ clearCache() {
2566
+ this.cache.clear();
2567
+ }
2568
+ getCacheStats() {
2569
+ return {
2570
+ size: this.cache.size,
2571
+ calculatedSize: this.cache.calculatedSize
2572
+ };
2573
+ }
2574
+ };
2575
+
2576
+ // src/core/rich-caption-animator.ts
2577
+ var ANIMATION_DURATIONS = {
2578
+ karaoke: 0,
2579
+ highlight: 0,
2580
+ pop: 200,
2581
+ fade: 150,
2582
+ slide: 250,
2583
+ bounce: 400,
2584
+ typewriter: 0,
2585
+ none: 0
2586
+ };
2587
+ var DEFAULT_ANIMATION_STATE = {
2588
+ opacity: 1,
2589
+ scale: 1,
2590
+ translateX: 0,
2591
+ translateY: 0,
2592
+ fillProgress: 1,
2593
+ isActive: false,
2594
+ visibleCharacters: -1
2595
+ };
2596
+ function easeOutQuad2(t) {
2597
+ return t * (2 - t);
2598
+ }
2599
+ function easeInOutQuad(t) {
2600
+ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
2601
+ }
2602
+ function easeOutBack(t) {
2603
+ const c1 = 1.70158;
2604
+ const c3 = c1 + 1;
2605
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
2606
+ }
2607
+ function easeOutCirc(t) {
2608
+ return Math.sqrt(1 - Math.pow(t - 1, 2));
2609
+ }
2610
+ function easeOutBounce(t) {
2611
+ const n1 = 7.5625;
2612
+ const d1 = 2.75;
2613
+ if (t < 1 / d1) {
2614
+ return n1 * t * t;
2615
+ }
2616
+ if (t < 2 / d1) {
2617
+ return n1 * (t -= 1.5 / d1) * t + 0.75;
2618
+ }
2619
+ if (t < 2.5 / d1) {
2620
+ return n1 * (t -= 2.25 / d1) * t + 0.9375;
2621
+ }
2622
+ return n1 * (t -= 2.625 / d1) * t + 0.984375;
2623
+ }
2624
+ function clamp(value, min, max) {
2625
+ return Math.min(Math.max(value, min), max);
2626
+ }
2627
+ function calculateAnimationProgress(ctx) {
2628
+ if (ctx.animationDuration <= 0) {
2629
+ return ctx.currentTime >= ctx.wordStart ? 1 : 0;
2630
+ }
2631
+ const elapsed = ctx.currentTime - ctx.wordStart;
2632
+ return clamp(elapsed / ctx.animationDuration, 0, 1);
2633
+ }
2634
+ function calculateWordProgress(ctx) {
2635
+ const duration = ctx.wordEnd - ctx.wordStart;
2636
+ if (duration <= 0) {
2637
+ return ctx.currentTime >= ctx.wordStart ? 1 : 0;
2638
+ }
2639
+ const elapsed = ctx.currentTime - ctx.wordStart;
2640
+ return clamp(elapsed / duration, 0, 1);
2641
+ }
2642
+ function isWordActive(ctx) {
2643
+ return ctx.currentTime >= ctx.wordStart && ctx.currentTime < ctx.wordEnd;
2644
+ }
2645
+ function calculateKaraokeState(ctx, speed) {
2646
+ const isActive = isWordActive(ctx);
2647
+ const wordDuration = ctx.wordEnd - ctx.wordStart;
2648
+ const adjustedDuration = wordDuration / speed;
2649
+ const adjustedEnd = ctx.wordStart + adjustedDuration;
2650
+ const adjustedCtx = { ...ctx, wordEnd: adjustedEnd };
2651
+ if (ctx.currentTime < ctx.wordStart) {
2652
+ return {
2653
+ fillProgress: 0,
2654
+ isActive: false,
2655
+ opacity: 1
2656
+ };
2657
+ }
2658
+ if (ctx.currentTime >= adjustedEnd) {
2659
+ return {
2660
+ fillProgress: 1,
2661
+ isActive: false,
2662
+ opacity: 1
2663
+ };
2664
+ }
2665
+ return {
2666
+ fillProgress: calculateWordProgress(adjustedCtx),
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, speed) {
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 adjustedDuration = ctx.animationDuration / speed;
2689
+ const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2690
+ const progress = calculateAnimationProgress(adjustedCtx);
2691
+ const easedProgress = easeOutBack(progress);
2692
+ const startScale = 0.5;
2693
+ const isActive = isWordActive(ctx);
2694
+ const endScale = isActive ? activeScale : 1;
2695
+ const scale = startScale + (endScale - startScale) * easedProgress;
2696
+ return {
2697
+ scale: Math.min(scale, activeScale),
2698
+ opacity: easedProgress,
2699
+ isActive,
2700
+ fillProgress: isActive ? 1 : 0
2701
+ };
2702
+ }
2703
+ function calculateFadeState(ctx, speed) {
2704
+ if (ctx.currentTime < ctx.wordStart) {
2705
+ return {
2706
+ opacity: 0,
2707
+ isActive: false,
2708
+ fillProgress: 0
2709
+ };
2710
+ }
2711
+ const adjustedDuration = ctx.animationDuration / speed;
2712
+ const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2713
+ const progress = calculateAnimationProgress(adjustedCtx);
2714
+ const easedProgress = easeInOutQuad(progress);
2715
+ const isActive = isWordActive(ctx);
2716
+ return {
2717
+ opacity: easedProgress,
2718
+ isActive,
2719
+ fillProgress: isActive ? 1 : 0
2720
+ };
2721
+ }
2722
+ function calculateSlideState(ctx, direction, speed, fontSize) {
2723
+ const slideDistance = fontSize * 1.5;
2724
+ if (ctx.currentTime < ctx.wordStart) {
2725
+ const offset2 = getDirectionOffset(direction, slideDistance);
2726
+ return {
2727
+ translateX: offset2.x,
2728
+ translateY: offset2.y,
2729
+ opacity: 0,
2730
+ isActive: false,
2731
+ fillProgress: 0
2732
+ };
2733
+ }
2734
+ const adjustedDuration = ctx.animationDuration / speed;
2735
+ const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2736
+ const progress = calculateAnimationProgress(adjustedCtx);
2737
+ const easedProgress = easeOutCirc(progress);
2738
+ const offset = getDirectionOffset(direction, slideDistance);
2739
+ const translateX = offset.x * (1 - easedProgress);
2740
+ const translateY = offset.y * (1 - easedProgress);
2741
+ const isActive = isWordActive(ctx);
2742
+ return {
2743
+ translateX,
2744
+ translateY,
2745
+ opacity: easeOutQuad2(progress),
2746
+ isActive,
2747
+ fillProgress: isActive ? 1 : 0
2748
+ };
2749
+ }
2750
+ function getDirectionOffset(direction, distance) {
2751
+ switch (direction) {
2752
+ case "left":
2753
+ return { x: -distance, y: 0 };
2754
+ case "right":
2755
+ return { x: distance, y: 0 };
2756
+ case "up":
2757
+ return { x: 0, y: distance };
2758
+ case "down":
2759
+ return { x: 0, y: -distance };
2760
+ }
2761
+ }
2762
+ function calculateBounceState(ctx, speed, fontSize) {
2763
+ const bounceDistance = fontSize * 0.8;
2764
+ if (ctx.currentTime < ctx.wordStart) {
2765
+ return {
2766
+ translateY: -bounceDistance,
2767
+ opacity: 0,
2768
+ isActive: false,
2769
+ fillProgress: 0
2770
+ };
2771
+ }
2772
+ const adjustedDuration = ctx.animationDuration / speed;
2773
+ const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2774
+ const progress = calculateAnimationProgress(adjustedCtx);
2775
+ const easedProgress = easeOutBounce(progress);
2776
+ const isActive = isWordActive(ctx);
2777
+ return {
2778
+ translateY: -bounceDistance * (1 - easedProgress),
2779
+ opacity: easeOutQuad2(progress),
2780
+ isActive,
2781
+ fillProgress: isActive ? 1 : 0
2782
+ };
2783
+ }
2784
+ function calculateTypewriterState(ctx, charCount, speed) {
2785
+ const wordDuration = ctx.wordEnd - ctx.wordStart;
2786
+ const adjustedDuration = wordDuration / speed;
2787
+ const adjustedEnd = ctx.wordStart + adjustedDuration;
2788
+ const adjustedCtx = { ...ctx, wordEnd: adjustedEnd };
2789
+ if (ctx.currentTime < ctx.wordStart) {
2790
+ return {
2791
+ visibleCharacters: 0,
2792
+ opacity: 1,
2793
+ isActive: false,
2794
+ fillProgress: 0
2437
2795
  };
2438
2796
  }
2439
2797
  if (ctx.currentTime >= adjustedEnd) {
2440
2798
  return {
2441
2799
  visibleCharacters: charCount,
2442
2800
  opacity: 1,
2443
- isActive: false
2801
+ isActive: false,
2802
+ fillProgress: 0
2444
2803
  };
2445
2804
  }
2446
2805
  const progress = calculateWordProgress(adjustedCtx);
2447
2806
  const visibleCharacters = Math.ceil(progress * charCount);
2807
+ const isActive = isWordActive(ctx);
2448
2808
  return {
2449
2809
  visibleCharacters: clamp(visibleCharacters, 0, charCount),
2450
2810
  opacity: 1,
2451
- isActive: isWordActive(ctx)
2811
+ isActive,
2812
+ fillProgress: isActive ? 1 : 0
2452
2813
  };
2453
2814
  }
2454
2815
  function calculateNoneState(_ctx) {
@@ -2529,28 +2890,25 @@ function getDefaultAnimationConfig() {
2529
2890
  }
2530
2891
 
2531
2892
  // src/core/rich-caption-generator.ts
2532
- var ASCENT_RATIO = 0.8;
2533
- var DESCENT_RATIO = 0.2;
2534
2893
  var WORD_BG_OPACITY = 1;
2535
2894
  var WORD_BG_BORDER_RADIUS = 4;
2536
2895
  var WORD_BG_PADDING_RATIO = 0.12;
2537
2896
  function extractFontConfig(asset) {
2538
2897
  const font = asset.font;
2539
2898
  const active = asset.active?.font;
2540
- const hasActiveConfig = asset.active !== void 0;
2899
+ const hasExplicitActiveColor = active?.color !== void 0;
2541
2900
  const baseColor = font?.color ?? "#ffffff";
2542
2901
  const baseOpacity = font?.opacity ?? 1;
2543
2902
  let activeColor;
2544
2903
  let activeOpacity;
2545
- if (!hasActiveConfig) {
2904
+ if (!hasExplicitActiveColor) {
2546
2905
  activeColor = baseColor;
2547
- activeOpacity = baseOpacity;
2906
+ activeOpacity = active?.opacity ?? baseOpacity;
2548
2907
  } else {
2549
- const explicitActiveColor = active?.color;
2550
2908
  const animStyle = asset.wordAnimation?.style ?? "highlight";
2551
2909
  const isFillAnimation = animStyle === "karaoke" || animStyle === "highlight";
2552
2910
  const DEFAULT_ACTIVE_COLOR = "#ffff00";
2553
- activeColor = explicitActiveColor ?? (isFillAnimation ? DEFAULT_ACTIVE_COLOR : baseColor);
2911
+ activeColor = active.color ?? (isFillAnimation ? DEFAULT_ACTIVE_COLOR : baseColor);
2554
2912
  activeOpacity = active?.opacity ?? baseOpacity;
2555
2913
  }
2556
2914
  return {
@@ -2590,20 +2948,33 @@ function extractStrokeConfig(asset, isActive) {
2590
2948
  return void 0;
2591
2949
  }
2592
2950
  function extractShadowConfig(asset, isActive) {
2593
- if (isActive) {
2951
+ const baseShadow = asset.shadow;
2952
+ const activeShadow = asset.active?.shadow;
2953
+ if (!baseShadow && !activeShadow) {
2594
2954
  return void 0;
2595
2955
  }
2596
- const shadow = asset.shadow;
2597
- if (!shadow) {
2598
- return void 0;
2956
+ if (isActive) {
2957
+ if (!activeShadow) {
2958
+ return void 0;
2959
+ }
2960
+ return {
2961
+ offsetX: activeShadow.offsetX ?? baseShadow?.offsetX ?? 0,
2962
+ offsetY: activeShadow.offsetY ?? baseShadow?.offsetY ?? 0,
2963
+ blur: activeShadow.blur ?? baseShadow?.blur ?? 0,
2964
+ color: activeShadow.color ?? baseShadow?.color ?? "#000000",
2965
+ opacity: activeShadow.opacity ?? baseShadow?.opacity ?? 0.5
2966
+ };
2599
2967
  }
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
- };
2968
+ if (baseShadow) {
2969
+ return {
2970
+ offsetX: baseShadow.offsetX ?? 0,
2971
+ offsetY: baseShadow.offsetY ?? 0,
2972
+ blur: baseShadow.blur ?? 0,
2973
+ color: baseShadow.color ?? "#000000",
2974
+ opacity: baseShadow.opacity ?? 0.5
2975
+ };
2976
+ }
2977
+ return void 0;
2607
2978
  }
2608
2979
  function extractBackgroundConfig(asset, isActive, fontSize) {
2609
2980
  const fontBackground = asset.font?.background;
@@ -2658,9 +3029,20 @@ function extractCaptionBorder(asset) {
2658
3029
  radius: border.radius ?? 0
2659
3030
  };
2660
3031
  }
2661
- function extractAnimationConfig(asset) {
2662
- const wordAnim = asset.wordAnimation;
2663
- if (!wordAnim) {
3032
+ function extractTextDecoration(asset, isActive) {
3033
+ const baseDecoration = asset.font?.textDecoration;
3034
+ const activeDecoration = asset.active?.font?.textDecoration;
3035
+ if (isActive && activeDecoration !== void 0) {
3036
+ return activeDecoration === "none" ? void 0 : activeDecoration;
3037
+ }
3038
+ if (!baseDecoration || baseDecoration === "none") {
3039
+ return void 0;
3040
+ }
3041
+ return baseDecoration;
3042
+ }
3043
+ function extractAnimationConfig(asset) {
3044
+ const wordAnim = asset.wordAnimation;
3045
+ if (!wordAnim) {
2664
3046
  return getDefaultAnimationConfig();
2665
3047
  }
2666
3048
  return {
@@ -2700,7 +3082,8 @@ function createDrawCaptionWordOp(word, animState, asset, fontConfig) {
2700
3082
  letterSpacing: fontConfig.letterSpacing > 0 ? fontConfig.letterSpacing : void 0,
2701
3083
  stroke: extractStrokeConfig(asset, isActive),
2702
3084
  shadow: extractShadowConfig(asset, isActive),
2703
- background: extractBackgroundConfig(asset, isActive, fontConfig.size)
3085
+ background: extractBackgroundConfig(asset, isActive, fontConfig.size),
3086
+ textDecoration: extractTextDecoration(asset, isActive)
2704
3087
  };
2705
3088
  }
2706
3089
  function generateRichCaptionDrawOps(asset, layout, frameTimeMs, layoutEngine, config) {
@@ -3204,7 +3587,8 @@ async function createNodePainter(opts) {
3204
3587
  context.lineCap = "round";
3205
3588
  context.strokeText(displayText, 0, 0);
3206
3589
  }
3207
- if (wordOp.fillProgress <= 0) {
3590
+ const sameColor = wordOp.activeColor === wordOp.baseColor && wordOp.activeOpacity === wordOp.baseOpacity;
3591
+ if (wordOp.fillProgress <= 0 || sameColor) {
3208
3592
  const baseC = parseHex6(wordOp.baseColor, wordOp.baseOpacity);
3209
3593
  context.fillStyle = `rgba(${baseC.r},${baseC.g},${baseC.b},${baseC.a})`;
3210
3594
  context.fillText(displayText, 0, 0);
@@ -3231,6 +3615,25 @@ async function createNodePainter(opts) {
3231
3615
  context.fillText(displayText, 0, 0);
3232
3616
  context.restore();
3233
3617
  }
3618
+ if (wordOp.textDecoration) {
3619
+ const geo = decorationGeometry(wordOp.textDecoration, {
3620
+ baselineY: 0,
3621
+ fontSize: wordOp.fontSize,
3622
+ lineWidth: textWidth,
3623
+ xStart: 0
3624
+ });
3625
+ const sameC = wordOp.activeColor === wordOp.baseColor && wordOp.activeOpacity === wordOp.baseOpacity;
3626
+ const decoIsActive = wordOp.fillProgress >= 1 && !sameC;
3627
+ const decoColor = decoIsActive ? wordOp.activeColor : wordOp.baseColor;
3628
+ const decoOpacity = decoIsActive ? wordOp.activeOpacity : wordOp.baseOpacity;
3629
+ const dc = parseHex6(decoColor, decoOpacity);
3630
+ context.strokeStyle = `rgba(${dc.r},${dc.g},${dc.b},${dc.a})`;
3631
+ context.lineWidth = geo.width;
3632
+ context.beginPath();
3633
+ context.moveTo(geo.x1, geo.y);
3634
+ context.lineTo(geo.x2, geo.y);
3635
+ context.stroke();
3636
+ }
3234
3637
  context.restore();
3235
3638
  }
3236
3639
  });
@@ -4690,353 +5093,6 @@ function extractSvgDimensions(svgString) {
4690
5093
  return { width, height };
4691
5094
  }
4692
5095
 
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;
4934
- }
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;
4985
- }
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 [];
5001
- }
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
- }));
5012
- }
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
- };
5028
- }
5029
- clearCache() {
5030
- this.cache.clear();
5031
- }
5032
- getCacheStats() {
5033
- return {
5034
- size: this.cache.size,
5035
- calculatedSize: this.cache.calculatedSize
5036
- };
5037
- }
5038
- };
5039
-
5040
5096
  // src/core/canvas-text-measurer.ts
5041
5097
  async function createCanvasTextMeasurer() {
5042
5098
  const canvasMod = await import("canvas");