@shotstack/shotstack-canvas 2.1.3 → 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,234 +2225,598 @@ 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
- };
2329
- }
2330
- const adjustedDuration = ctx.animationDuration / speed;
2331
- const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2332
- const progress = calculateAnimationProgress(adjustedCtx);
2333
- const easedProgress = easeOutBack(progress);
2334
- const startScale = 0.5;
2335
- const endScale = isWordActive(ctx) ? activeScale : 1;
2336
- const scale = startScale + (endScale - startScale) * easedProgress;
2337
- return {
2338
- scale: Math.min(scale, activeScale),
2339
- opacity: easedProgress,
2340
- isActive: isWordActive(ctx)
2341
- };
2340
+ function makeShapingKey(text, fontFamily, fontSize, fontWeight, letterSpacing = 0) {
2341
+ return `${text}\0${fontFamily}\0${fontSize}\0${fontWeight}\0${letterSpacing}`;
2342
2342
  }
2343
- function calculateFadeState(ctx, speed) {
2344
- if (ctx.currentTime < ctx.wordStart) {
2345
- return {
2346
- opacity: 0,
2347
- isActive: false
2348
- };
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;
2349
2353
  }
2350
- const adjustedDuration = ctx.animationDuration / speed;
2351
- const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2352
- const progress = calculateAnimationProgress(adjustedCtx);
2353
- const easedProgress = easeInOutQuad(progress);
2354
- return {
2355
- opacity: easedProgress,
2356
- isActive: isWordActive(ctx)
2357
- };
2358
2354
  }
2359
- function calculateSlideState(ctx, direction, speed, fontSize) {
2360
- const slideDistance = fontSize * 1.5;
2361
- if (ctx.currentTime < ctx.wordStart) {
2362
- const offset2 = getDirectionOffset(direction, slideDistance);
2363
- return {
2364
- translateX: offset2.x,
2365
- translateY: offset2.y,
2366
- opacity: 0,
2367
- isActive: false
2368
- };
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));
2369
2359
  }
2370
- const adjustedDuration = ctx.animationDuration / speed;
2371
- const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2372
- const progress = calculateAnimationProgress(adjustedCtx);
2373
- const easedProgress = easeOutCirc(progress);
2374
- const offset = getDirectionOffset(direction, slideDistance);
2375
- const translateX = offset.x * (1 - easedProgress);
2376
- const translateY = offset.y * (1 - easedProgress);
2377
- return {
2378
- translateX,
2379
- translateY,
2380
- opacity: easeOutQuad2(progress),
2381
- isActive: isWordActive(ctx)
2382
- };
2360
+ return chunks;
2383
2361
  }
2384
- function getDirectionOffset(direction, distance) {
2385
- switch (direction) {
2386
- case "left":
2387
- return { x: -distance, y: 0 };
2388
- case "right":
2389
- return { x: distance, y: 0 };
2390
- case "up":
2391
- return { x: 0, y: -distance };
2392
- case "down":
2393
- return { x: 0, y: distance };
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);
2394
2370
  }
2395
- }
2396
- function calculateBounceState(ctx, speed, fontSize) {
2397
- const bounceDistance = fontSize * 0.8;
2398
- if (ctx.currentTime < ctx.wordStart) {
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;
2410
+ }
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
+ }
2399
2524
  return {
2400
- translateY: -bounceDistance,
2401
- opacity: 0,
2402
- isActive: false
2525
+ store,
2526
+ groups,
2527
+ shapedWords,
2528
+ paragraphDirection
2403
2529
  };
2404
2530
  }
2405
- const adjustedDuration = ctx.animationDuration / speed;
2406
- const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2407
- const progress = calculateAnimationProgress(adjustedCtx);
2408
- const easedProgress = easeOutBounce(progress);
2409
- return {
2410
- translateY: -bounceDistance * (1 - easedProgress),
2411
- opacity: easeOutQuad2(progress),
2412
- isActive: isWordActive(ctx)
2413
- };
2414
- }
2415
- function calculateTypewriterState(ctx, charCount, speed) {
2416
- const wordDuration = ctx.wordEnd - ctx.wordStart;
2417
- const adjustedDuration = wordDuration / speed;
2418
- const adjustedEnd = ctx.wordStart + adjustedDuration;
2419
- const adjustedCtx = { ...ctx, wordEnd: adjustedEnd };
2420
- 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
+ }
2421
2554
  return {
2422
- visibleCharacters: 0,
2423
- opacity: 1,
2424
- 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
2425
2563
  };
2426
2564
  }
2427
- if (ctx.currentTime >= adjustedEnd) {
2565
+ clearCache() {
2566
+ this.cache.clear();
2567
+ }
2568
+ getCacheStats() {
2428
2569
  return {
2429
- visibleCharacters: charCount,
2430
- opacity: 1,
2431
- isActive: false
2570
+ size: this.cache.size,
2571
+ calculatedSize: this.cache.calculatedSize
2432
2572
  };
2433
2573
  }
2434
- const progress = calculateWordProgress(adjustedCtx);
2435
- const visibleCharacters = Math.ceil(progress * charCount);
2436
- return {
2437
- visibleCharacters: clamp(visibleCharacters, 0, charCount),
2438
- opacity: 1,
2439
- isActive: isWordActive(ctx)
2440
- };
2441
- }
2442
- function calculateNoneState(_ctx) {
2443
- return {
2444
- opacity: 1,
2445
- isActive: false,
2446
- fillProgress: 0
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
2795
+ };
2796
+ }
2797
+ if (ctx.currentTime >= adjustedEnd) {
2798
+ return {
2799
+ visibleCharacters: charCount,
2800
+ opacity: 1,
2801
+ isActive: false,
2802
+ fillProgress: 0
2803
+ };
2804
+ }
2805
+ const progress = calculateWordProgress(adjustedCtx);
2806
+ const visibleCharacters = Math.ceil(progress * charCount);
2807
+ const isActive = isWordActive(ctx);
2808
+ return {
2809
+ visibleCharacters: clamp(visibleCharacters, 0, charCount),
2810
+ opacity: 1,
2811
+ isActive,
2812
+ fillProgress: isActive ? 1 : 0
2813
+ };
2814
+ }
2815
+ function calculateNoneState(_ctx) {
2816
+ return {
2817
+ opacity: 1,
2818
+ isActive: false,
2819
+ fillProgress: 0
2447
2820
  };
2448
2821
  }
2449
2822
  function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, activeScale = 1, charCount = 0, fontSize = 48, isRTL = false) {
@@ -2517,28 +2890,35 @@ function getDefaultAnimationConfig() {
2517
2890
  }
2518
2891
 
2519
2892
  // src/core/rich-caption-generator.ts
2520
- var ASCENT_RATIO = 0.8;
2521
- var DESCENT_RATIO = 0.2;
2522
2893
  var WORD_BG_OPACITY = 1;
2523
2894
  var WORD_BG_BORDER_RADIUS = 4;
2524
2895
  var WORD_BG_PADDING_RATIO = 0.12;
2525
2896
  function extractFontConfig(asset) {
2526
2897
  const font = asset.font;
2527
2898
  const active = asset.active?.font;
2899
+ const hasExplicitActiveColor = active?.color !== void 0;
2528
2900
  const baseColor = font?.color ?? "#ffffff";
2529
- const explicitActiveColor = active?.color;
2530
- const animStyle = asset.wordAnimation?.style ?? "highlight";
2531
- const isFillAnimation = animStyle === "karaoke" || animStyle === "highlight";
2532
- const DEFAULT_ACTIVE_COLOR = "#ffff00";
2533
- const activeColor = explicitActiveColor ?? (isFillAnimation ? DEFAULT_ACTIVE_COLOR : baseColor);
2901
+ const baseOpacity = font?.opacity ?? 1;
2902
+ let activeColor;
2903
+ let activeOpacity;
2904
+ if (!hasExplicitActiveColor) {
2905
+ activeColor = baseColor;
2906
+ activeOpacity = active?.opacity ?? baseOpacity;
2907
+ } else {
2908
+ const animStyle = asset.wordAnimation?.style ?? "highlight";
2909
+ const isFillAnimation = animStyle === "karaoke" || animStyle === "highlight";
2910
+ const DEFAULT_ACTIVE_COLOR = "#ffff00";
2911
+ activeColor = active.color ?? (isFillAnimation ? DEFAULT_ACTIVE_COLOR : baseColor);
2912
+ activeOpacity = active?.opacity ?? baseOpacity;
2913
+ }
2534
2914
  return {
2535
2915
  family: font?.family ?? "Roboto",
2536
2916
  size: font?.size ?? 24,
2537
2917
  weight: String(font?.weight ?? "400"),
2538
2918
  baseColor,
2539
2919
  activeColor,
2540
- baseOpacity: font?.opacity ?? 1,
2541
- activeOpacity: active?.opacity ?? font?.opacity ?? 1,
2920
+ baseOpacity,
2921
+ activeOpacity,
2542
2922
  letterSpacing: asset.style?.letterSpacing ?? 0
2543
2923
  };
2544
2924
  }
@@ -2568,20 +2948,33 @@ function extractStrokeConfig(asset, isActive) {
2568
2948
  return void 0;
2569
2949
  }
2570
2950
  function extractShadowConfig(asset, isActive) {
2571
- if (isActive) {
2951
+ const baseShadow = asset.shadow;
2952
+ const activeShadow = asset.active?.shadow;
2953
+ if (!baseShadow && !activeShadow) {
2572
2954
  return void 0;
2573
2955
  }
2574
- const shadow = asset.shadow;
2575
- if (!shadow) {
2576
- 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
+ };
2577
2967
  }
2578
- return {
2579
- offsetX: shadow.offsetX ?? 0,
2580
- offsetY: shadow.offsetY ?? 0,
2581
- blur: shadow.blur ?? 0,
2582
- color: shadow.color ?? "#000000",
2583
- opacity: shadow.opacity ?? 0.5
2584
- };
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;
2585
2978
  }
2586
2979
  function extractBackgroundConfig(asset, isActive, fontSize) {
2587
2980
  const fontBackground = asset.font?.background;
@@ -2636,6 +3029,17 @@ function extractCaptionBorder(asset) {
2636
3029
  radius: border.radius ?? 0
2637
3030
  };
2638
3031
  }
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
+ }
2639
3043
  function extractAnimationConfig(asset) {
2640
3044
  const wordAnim = asset.wordAnimation;
2641
3045
  if (!wordAnim) {
@@ -2678,7 +3082,8 @@ function createDrawCaptionWordOp(word, animState, asset, fontConfig) {
2678
3082
  letterSpacing: fontConfig.letterSpacing > 0 ? fontConfig.letterSpacing : void 0,
2679
3083
  stroke: extractStrokeConfig(asset, isActive),
2680
3084
  shadow: extractShadowConfig(asset, isActive),
2681
- background: extractBackgroundConfig(asset, isActive, fontConfig.size)
3085
+ background: extractBackgroundConfig(asset, isActive, fontConfig.size),
3086
+ textDecoration: extractTextDecoration(asset, isActive)
2682
3087
  };
2683
3088
  }
2684
3089
  function generateRichCaptionDrawOps(asset, layout, frameTimeMs, layoutEngine, config) {
@@ -3182,7 +3587,8 @@ async function createNodePainter(opts) {
3182
3587
  context.lineCap = "round";
3183
3588
  context.strokeText(displayText, 0, 0);
3184
3589
  }
3185
- if (wordOp.fillProgress <= 0) {
3590
+ const sameColor = wordOp.activeColor === wordOp.baseColor && wordOp.activeOpacity === wordOp.baseOpacity;
3591
+ if (wordOp.fillProgress <= 0 || sameColor) {
3186
3592
  const baseC = parseHex6(wordOp.baseColor, wordOp.baseOpacity);
3187
3593
  context.fillStyle = `rgba(${baseC.r},${baseC.g},${baseC.b},${baseC.a})`;
3188
3594
  context.fillText(displayText, 0, 0);
@@ -3209,7 +3615,26 @@ async function createNodePainter(opts) {
3209
3615
  context.fillText(displayText, 0, 0);
3210
3616
  context.restore();
3211
3617
  }
3212
- context.restore();
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
+ }
3637
+ context.restore();
3213
3638
  }
3214
3639
  });
3215
3640
  continue;
@@ -4668,353 +5093,6 @@ function extractSvgDimensions(svgString) {
4668
5093
  return { width, height };
4669
5094
  }
4670
5095
 
4671
- // src/core/rich-caption-layout.ts
4672
- import { LRUCache } from "lru-cache";
4673
- function isRTLText(text) {
4674
- return containsRTLCharacters(text);
4675
- }
4676
- var WordTimingStore = class {
4677
- startTimes;
4678
- endTimes;
4679
- xPositions;
4680
- yPositions;
4681
- widths;
4682
- words;
4683
- length;
4684
- constructor(words) {
4685
- this.length = words.length;
4686
- this.startTimes = new Uint32Array(this.length);
4687
- this.endTimes = new Uint32Array(this.length);
4688
- this.xPositions = new Float32Array(this.length);
4689
- this.yPositions = new Float32Array(this.length);
4690
- this.widths = new Float32Array(this.length);
4691
- this.words = new Array(this.length);
4692
- for (let i = 0; i < this.length; i++) {
4693
- this.startTimes[i] = Math.floor(words[i].start);
4694
- this.endTimes[i] = Math.floor(words[i].end);
4695
- this.words[i] = words[i].text;
4696
- }
4697
- }
4698
- };
4699
- function findWordAtTime(store, timeMs) {
4700
- let left = 0;
4701
- let right = store.length - 1;
4702
- while (left <= right) {
4703
- const mid = left + right >>> 1;
4704
- const start = store.startTimes[mid];
4705
- const end = store.endTimes[mid];
4706
- if (timeMs >= start && timeMs < end) {
4707
- return mid;
4708
- }
4709
- if (timeMs < start) {
4710
- right = mid - 1;
4711
- } else {
4712
- left = mid + 1;
4713
- }
4714
- }
4715
- return -1;
4716
- }
4717
- function groupWordsByPause(store, pauseThreshold = 500) {
4718
- if (store.length === 0) {
4719
- return [];
4720
- }
4721
- const groups = [];
4722
- let currentGroup = [];
4723
- for (let i = 0; i < store.length; i++) {
4724
- if (currentGroup.length === 0) {
4725
- currentGroup.push(i);
4726
- continue;
4727
- }
4728
- const prevEnd = store.endTimes[currentGroup[currentGroup.length - 1]];
4729
- const currStart = store.startTimes[i];
4730
- const gap = currStart - prevEnd;
4731
- const prevText = store.words[currentGroup[currentGroup.length - 1]];
4732
- const endsWithPunctuation = /[.!?]$/.test(prevText);
4733
- if (gap >= pauseThreshold || endsWithPunctuation) {
4734
- groups.push(currentGroup);
4735
- currentGroup = [i];
4736
- } else {
4737
- currentGroup.push(i);
4738
- }
4739
- }
4740
- if (currentGroup.length > 0) {
4741
- groups.push(currentGroup);
4742
- }
4743
- return groups;
4744
- }
4745
- function breakIntoLines(wordWidths, maxWidth, spaceWidth) {
4746
- const lines = [];
4747
- let currentLine = [];
4748
- let currentWidth = 0;
4749
- for (let i = 0; i < wordWidths.length; i++) {
4750
- const wordWidth = wordWidths[i];
4751
- const spaceNeeded = currentLine.length > 0 ? spaceWidth : 0;
4752
- if (currentWidth + spaceNeeded + wordWidth <= maxWidth) {
4753
- currentLine.push(i);
4754
- currentWidth += spaceNeeded + wordWidth;
4755
- } else {
4756
- if (currentLine.length > 0) {
4757
- lines.push(currentLine);
4758
- }
4759
- currentLine = [i];
4760
- currentWidth = wordWidth;
4761
- }
4762
- }
4763
- if (currentLine.length > 0) {
4764
- lines.push(currentLine);
4765
- }
4766
- return lines;
4767
- }
4768
- var GLYPH_SIZE_ESTIMATE = 64;
4769
- function createShapedWordCache() {
4770
- return new LRUCache({
4771
- max: 5e4,
4772
- maxSize: 50 * 1024 * 1024,
4773
- maxEntrySize: 100 * 1024,
4774
- sizeCalculation: (value, key) => {
4775
- const keySize = key.length * 2;
4776
- const glyphsSize = value.glyphs.length * GLYPH_SIZE_ESTIMATE;
4777
- return keySize + glyphsSize + 100;
4778
- }
4779
- });
4780
- }
4781
- function makeShapingKey(text, fontFamily, fontSize, fontWeight, letterSpacing = 0) {
4782
- return `${text}\0${fontFamily}\0${fontSize}\0${fontWeight}\0${letterSpacing}`;
4783
- }
4784
- function transformText(text, transform) {
4785
- switch (transform) {
4786
- case "uppercase":
4787
- return text.toUpperCase();
4788
- case "lowercase":
4789
- return text.toLowerCase();
4790
- case "capitalize":
4791
- return text.replace(/\b\w/g, (c) => c.toUpperCase());
4792
- default:
4793
- return text;
4794
- }
4795
- }
4796
- function splitIntoChunks(arr, chunkSize) {
4797
- const chunks = [];
4798
- for (let i = 0; i < arr.length; i += chunkSize) {
4799
- chunks.push(arr.slice(i, i + chunkSize));
4800
- }
4801
- return chunks;
4802
- }
4803
- var CaptionLayoutEngine = class {
4804
- fontRegistry;
4805
- cache;
4806
- layoutEngine;
4807
- constructor(fontRegistry) {
4808
- this.fontRegistry = fontRegistry;
4809
- this.cache = createShapedWordCache();
4810
- this.layoutEngine = new LayoutEngine(fontRegistry);
4811
- }
4812
- async measureWord(text, config) {
4813
- const transformedText = transformText(text, config.textTransform);
4814
- const cacheKey = makeShapingKey(
4815
- transformedText,
4816
- config.fontFamily,
4817
- config.fontSize,
4818
- config.fontWeight,
4819
- config.letterSpacing
4820
- );
4821
- const cached = this.cache.get(cacheKey);
4822
- if (cached) {
4823
- return cached;
4824
- }
4825
- const lines = await this.layoutEngine.layout({
4826
- text: transformedText,
4827
- width: 1e5,
4828
- letterSpacing: config.letterSpacing,
4829
- fontSize: config.fontSize,
4830
- lineHeight: 1,
4831
- desc: { family: config.fontFamily, weight: config.fontWeight },
4832
- textTransform: "none"
4833
- });
4834
- const width = lines[0]?.width ?? 0;
4835
- const glyphs = lines[0]?.glyphs ?? [];
4836
- const isRTL = isRTLText(transformedText);
4837
- const shaped = {
4838
- text: transformedText,
4839
- width,
4840
- glyphs: glyphs.map((g) => ({
4841
- id: g.id,
4842
- xAdvance: g.xAdvance,
4843
- xOffset: g.xOffset,
4844
- yOffset: g.yOffset,
4845
- cluster: g.cluster
4846
- })),
4847
- isRTL
4848
- };
4849
- this.cache.set(cacheKey, shaped);
4850
- return shaped;
4851
- }
4852
- async layoutCaption(words, config) {
4853
- const store = new WordTimingStore(words);
4854
- const measurementConfig = {
4855
- fontFamily: config.fontFamily,
4856
- fontSize: config.fontSize,
4857
- fontWeight: config.fontWeight,
4858
- letterSpacing: config.letterSpacing,
4859
- textTransform: config.textTransform
4860
- };
4861
- const shapedWords = await Promise.all(
4862
- words.map((w) => this.measureWord(w.text, measurementConfig))
4863
- );
4864
- if (config.measureTextWidth) {
4865
- const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
4866
- for (let i = 0; i < shapedWords.length; i++) {
4867
- store.widths[i] = config.measureTextWidth(shapedWords[i].text, fontString);
4868
- }
4869
- } else {
4870
- for (let i = 0; i < shapedWords.length; i++) {
4871
- store.widths[i] = shapedWords[i].width;
4872
- }
4873
- }
4874
- if (config.textTransform !== "none") {
4875
- for (let i = 0; i < shapedWords.length; i++) {
4876
- store.words[i] = shapedWords[i].text;
4877
- }
4878
- }
4879
- const wordGroups = groupWordsByPause(store, config.pauseThreshold);
4880
- const pixelMaxWidth = config.availableWidth;
4881
- let spaceWidth;
4882
- if (config.measureTextWidth) {
4883
- const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
4884
- spaceWidth = config.measureTextWidth(" ", fontString) + config.wordSpacing;
4885
- } else {
4886
- const spaceWord = await this.measureWord(" ", measurementConfig);
4887
- spaceWidth = spaceWord.width + config.wordSpacing;
4888
- }
4889
- const groups = wordGroups.flatMap((indices) => {
4890
- const groupWidths = indices.map((i) => store.widths[i]);
4891
- const allLines = breakIntoLines(
4892
- groupWidths,
4893
- pixelMaxWidth,
4894
- spaceWidth
4895
- );
4896
- const lineChunks = splitIntoChunks(allLines, config.maxLines);
4897
- return lineChunks.map((chunkLines) => {
4898
- const lines = chunkLines.map((lineWordIndices, lineIndex) => {
4899
- const actualIndices = lineWordIndices.map((i) => indices[i]);
4900
- const lineWidth = actualIndices.reduce((sum, idx) => sum + store.widths[idx], 0) + (actualIndices.length - 1) * spaceWidth;
4901
- return {
4902
- wordIndices: actualIndices,
4903
- x: 0,
4904
- y: lineIndex * config.fontSize * config.lineHeight,
4905
- width: lineWidth,
4906
- height: config.fontSize
4907
- };
4908
- });
4909
- const allWordIndices = lines.flatMap((l) => l.wordIndices);
4910
- if (allWordIndices.length === 0) {
4911
- return null;
4912
- }
4913
- return {
4914
- wordIndices: allWordIndices,
4915
- startTime: store.startTimes[allWordIndices[0]],
4916
- endTime: store.endTimes[allWordIndices[allWordIndices.length - 1]],
4917
- lines
4918
- };
4919
- }).filter((g) => g !== null);
4920
- });
4921
- const contentHeight = config.frameHeight - config.padding.top - config.padding.bottom;
4922
- const contentWidth = config.frameWidth - config.padding.left - config.padding.right;
4923
- const ASCENT_RATIO2 = 0.8;
4924
- const calculateGroupY = (group) => {
4925
- const totalHeight = group.lines.length * config.fontSize * config.lineHeight;
4926
- switch (config.verticalAlign) {
4927
- case "top":
4928
- return config.padding.top + config.fontSize * ASCENT_RATIO2;
4929
- case "bottom":
4930
- return config.frameHeight - config.padding.bottom - totalHeight + config.fontSize * ASCENT_RATIO2;
4931
- case "middle":
4932
- default:
4933
- return config.padding.top + (contentHeight - totalHeight) / 2 + config.fontSize * ASCENT_RATIO2;
4934
- }
4935
- };
4936
- const allWordTexts = store.words.slice(0, store.length);
4937
- const paragraphDirection = detectParagraphDirectionFromWords(allWordTexts);
4938
- const calculateLineX = (lineWidth) => {
4939
- switch (config.horizontalAlign) {
4940
- case "left":
4941
- return config.padding.left;
4942
- case "right":
4943
- return config.frameWidth - lineWidth - config.padding.right;
4944
- case "center":
4945
- default:
4946
- return config.padding.left + (contentWidth - lineWidth) / 2;
4947
- }
4948
- };
4949
- for (const group of groups) {
4950
- const baseY = calculateGroupY(group);
4951
- for (let lineIdx = 0; lineIdx < group.lines.length; lineIdx++) {
4952
- const line = group.lines[lineIdx];
4953
- line.x = calculateLineX(line.width);
4954
- line.y = baseY + lineIdx * config.fontSize * config.lineHeight;
4955
- const lineWordTexts = line.wordIndices.map((idx) => store.words[idx]);
4956
- const visualOrder = reorderWordsForLine(lineWordTexts, paragraphDirection);
4957
- let xCursor = line.x;
4958
- for (const visualIdx of visualOrder) {
4959
- const wordIdx = line.wordIndices[visualIdx];
4960
- store.xPositions[wordIdx] = xCursor;
4961
- store.yPositions[wordIdx] = line.y;
4962
- xCursor += store.widths[wordIdx] + spaceWidth;
4963
- }
4964
- }
4965
- }
4966
- return {
4967
- store,
4968
- groups,
4969
- shapedWords,
4970
- paragraphDirection
4971
- };
4972
- }
4973
- getVisibleWordsAtTime(layout, timeMs) {
4974
- const activeGroup = layout.groups.find(
4975
- (g) => timeMs >= g.startTime && timeMs <= g.endTime
4976
- );
4977
- if (!activeGroup) {
4978
- return [];
4979
- }
4980
- return activeGroup.wordIndices.map((idx) => ({
4981
- wordIndex: idx,
4982
- text: layout.store.words[idx],
4983
- x: layout.store.xPositions[idx],
4984
- y: layout.store.yPositions[idx],
4985
- width: layout.store.widths[idx],
4986
- startTime: layout.store.startTimes[idx],
4987
- endTime: layout.store.endTimes[idx],
4988
- isRTL: layout.shapedWords[idx].isRTL
4989
- }));
4990
- }
4991
- getActiveWordAtTime(layout, timeMs) {
4992
- const wordIndex = findWordAtTime(layout.store, timeMs);
4993
- if (wordIndex === -1) {
4994
- return null;
4995
- }
4996
- return {
4997
- wordIndex,
4998
- text: layout.store.words[wordIndex],
4999
- x: layout.store.xPositions[wordIndex],
5000
- y: layout.store.yPositions[wordIndex],
5001
- width: layout.store.widths[wordIndex],
5002
- startTime: layout.store.startTimes[wordIndex],
5003
- endTime: layout.store.endTimes[wordIndex],
5004
- isRTL: layout.shapedWords[wordIndex].isRTL
5005
- };
5006
- }
5007
- clearCache() {
5008
- this.cache.clear();
5009
- }
5010
- getCacheStats() {
5011
- return {
5012
- size: this.cache.size,
5013
- calculatedSize: this.cache.calculatedSize
5014
- };
5015
- }
5016
- };
5017
-
5018
5096
  // src/core/canvas-text-measurer.ts
5019
5097
  async function createCanvasTextMeasurer() {
5020
5098
  const canvasMod = await import("canvas");