@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.
@@ -590,19 +590,28 @@ var richCaptionFontSchema = import_zod.z.object({
590
590
  weight: import_zod.z.union([import_zod.z.string(), import_zod.z.number()]).default("400"),
591
591
  color: import_zod.z.string().regex(HEX6).default("#ffffff"),
592
592
  opacity: import_zod.z.number().min(0).max(1).default(1),
593
- background: import_zod.z.string().regex(HEX6).optional()
593
+ background: import_zod.z.string().regex(HEX6).optional(),
594
+ textDecoration: import_zod.z.enum(["none", "underline", "line-through"]).default("none")
594
595
  });
595
596
  var richCaptionActiveSchema = import_zod2.richCaptionActiveSchema.extend({
596
597
  font: import_zod.z.object({
597
- color: import_zod.z.string().regex(HEX6).default("#ffffff"),
598
+ color: import_zod.z.string().regex(HEX6).optional(),
598
599
  background: import_zod.z.string().regex(HEX6).optional(),
599
- opacity: import_zod.z.number().min(0).max(1).default(1)
600
+ opacity: import_zod.z.number().min(0).max(1).optional(),
601
+ textDecoration: import_zod.z.enum(["none", "underline", "line-through"]).optional()
600
602
  }).optional(),
601
603
  stroke: import_zod.z.object({
602
604
  width: import_zod.z.number().min(0).optional(),
603
605
  color: import_zod.z.string().regex(HEX6).optional(),
604
606
  opacity: import_zod.z.number().min(0).max(1).optional()
605
607
  }).optional(),
608
+ shadow: import_zod.z.object({
609
+ offsetX: import_zod.z.number().optional(),
610
+ offsetY: import_zod.z.number().optional(),
611
+ blur: import_zod.z.number().min(0).optional(),
612
+ color: import_zod.z.string().regex(HEX6).optional(),
613
+ opacity: import_zod.z.number().min(0).max(1).optional()
614
+ }).optional(),
606
615
  scale: import_zod.z.number().min(0.5).max(2).default(1)
607
616
  });
608
617
  var richCaptionWordAnimationSchema = import_zod2.richCaptionWordAnimationSchema.extend({
@@ -2619,239 +2628,591 @@ function parseHex6(hex, alpha = 1) {
2619
2628
  return { r, g, b, a: alpha };
2620
2629
  }
2621
2630
 
2622
- // src/core/rich-caption-animator.ts
2623
- var ANIMATION_DURATIONS = {
2624
- karaoke: 0,
2625
- highlight: 0,
2626
- pop: 200,
2627
- fade: 150,
2628
- slide: 250,
2629
- bounce: 400,
2630
- typewriter: 0,
2631
- none: 0
2632
- };
2633
- var DEFAULT_ANIMATION_STATE = {
2634
- opacity: 1,
2635
- scale: 1,
2636
- translateX: 0,
2637
- translateY: 0,
2638
- fillProgress: 1,
2639
- isActive: false,
2640
- visibleCharacters: -1
2641
- };
2642
- function easeOutQuad2(t) {
2643
- return t * (2 - t);
2644
- }
2645
- function easeInOutQuad(t) {
2646
- return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
2647
- }
2648
- function easeOutBack(t) {
2649
- const c1 = 1.70158;
2650
- const c3 = c1 + 1;
2651
- return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
2652
- }
2653
- function easeOutCirc(t) {
2654
- return Math.sqrt(1 - Math.pow(t - 1, 2));
2631
+ // src/core/rich-caption-layout.ts
2632
+ var import_lru_cache = require("lru-cache");
2633
+ var ASCENT_RATIO = 0.8;
2634
+ var DESCENT_RATIO = 0.2;
2635
+ function isRTLText(text) {
2636
+ return containsRTLCharacters(text);
2655
2637
  }
2656
- function easeOutBounce(t) {
2657
- const n1 = 7.5625;
2658
- const d1 = 2.75;
2659
- if (t < 1 / d1) {
2660
- return n1 * t * t;
2661
- }
2662
- if (t < 2 / d1) {
2663
- return n1 * (t -= 1.5 / d1) * t + 0.75;
2638
+ var WordTimingStore = class {
2639
+ startTimes;
2640
+ endTimes;
2641
+ xPositions;
2642
+ yPositions;
2643
+ widths;
2644
+ words;
2645
+ length;
2646
+ constructor(words) {
2647
+ this.length = words.length;
2648
+ this.startTimes = new Uint32Array(this.length);
2649
+ this.endTimes = new Uint32Array(this.length);
2650
+ this.xPositions = new Float32Array(this.length);
2651
+ this.yPositions = new Float32Array(this.length);
2652
+ this.widths = new Float32Array(this.length);
2653
+ this.words = new Array(this.length);
2654
+ for (let i = 0; i < this.length; i++) {
2655
+ this.startTimes[i] = Math.floor(words[i].start);
2656
+ this.endTimes[i] = Math.floor(words[i].end);
2657
+ this.words[i] = words[i].text;
2658
+ }
2664
2659
  }
2665
- if (t < 2.5 / d1) {
2666
- return n1 * (t -= 2.25 / d1) * t + 0.9375;
2660
+ };
2661
+ function findWordAtTime(store, timeMs) {
2662
+ let left = 0;
2663
+ let right = store.length - 1;
2664
+ while (left <= right) {
2665
+ const mid = left + right >>> 1;
2666
+ const start = store.startTimes[mid];
2667
+ const end = store.endTimes[mid];
2668
+ if (timeMs >= start && timeMs < end) {
2669
+ return mid;
2670
+ }
2671
+ if (timeMs < start) {
2672
+ right = mid - 1;
2673
+ } else {
2674
+ left = mid + 1;
2675
+ }
2667
2676
  }
2668
- return n1 * (t -= 2.625 / d1) * t + 0.984375;
2669
- }
2670
- function clamp(value, min, max) {
2671
- return Math.min(Math.max(value, min), max);
2677
+ return -1;
2672
2678
  }
2673
- function calculateAnimationProgress(ctx) {
2674
- if (ctx.animationDuration <= 0) {
2675
- return ctx.currentTime >= ctx.wordStart ? 1 : 0;
2679
+ function groupWordsByPause(store, pauseThreshold = 500) {
2680
+ if (store.length === 0) {
2681
+ return [];
2676
2682
  }
2677
- const elapsed = ctx.currentTime - ctx.wordStart;
2678
- return clamp(elapsed / ctx.animationDuration, 0, 1);
2679
- }
2680
- function calculateWordProgress(ctx) {
2681
- const duration = ctx.wordEnd - ctx.wordStart;
2682
- if (duration <= 0) {
2683
- return ctx.currentTime >= ctx.wordStart ? 1 : 0;
2683
+ const groups = [];
2684
+ let currentGroup = [];
2685
+ for (let i = 0; i < store.length; i++) {
2686
+ if (currentGroup.length === 0) {
2687
+ currentGroup.push(i);
2688
+ continue;
2689
+ }
2690
+ const prevEnd = store.endTimes[currentGroup[currentGroup.length - 1]];
2691
+ const currStart = store.startTimes[i];
2692
+ const gap = currStart - prevEnd;
2693
+ const prevText = store.words[currentGroup[currentGroup.length - 1]];
2694
+ const endsWithPunctuation = /[.!?]$/.test(prevText);
2695
+ if (gap >= pauseThreshold || endsWithPunctuation) {
2696
+ groups.push(currentGroup);
2697
+ currentGroup = [i];
2698
+ } else {
2699
+ currentGroup.push(i);
2700
+ }
2684
2701
  }
2685
- const elapsed = ctx.currentTime - ctx.wordStart;
2686
- return clamp(elapsed / duration, 0, 1);
2687
- }
2688
- function isWordActive(ctx) {
2689
- return ctx.currentTime >= ctx.wordStart && ctx.currentTime < ctx.wordEnd;
2702
+ if (currentGroup.length > 0) {
2703
+ groups.push(currentGroup);
2704
+ }
2705
+ return groups;
2690
2706
  }
2691
- function calculateKaraokeState(ctx, speed) {
2692
- const isActive = isWordActive(ctx);
2693
- const wordDuration = ctx.wordEnd - ctx.wordStart;
2694
- const adjustedDuration = wordDuration / speed;
2695
- const adjustedEnd = ctx.wordStart + adjustedDuration;
2696
- const adjustedCtx = { ...ctx, wordEnd: adjustedEnd };
2697
- if (ctx.currentTime < ctx.wordStart) {
2698
- return {
2699
- fillProgress: 0,
2700
- isActive: false,
2701
- opacity: 1
2702
- };
2707
+ function breakIntoLines(wordWidths, maxWidth, spaceWidth) {
2708
+ const lines = [];
2709
+ let currentLine = [];
2710
+ let currentWidth = 0;
2711
+ for (let i = 0; i < wordWidths.length; i++) {
2712
+ const wordWidth = wordWidths[i];
2713
+ const spaceNeeded = currentLine.length > 0 ? spaceWidth : 0;
2714
+ if (currentWidth + spaceNeeded + wordWidth <= maxWidth) {
2715
+ currentLine.push(i);
2716
+ currentWidth += spaceNeeded + wordWidth;
2717
+ } else {
2718
+ if (currentLine.length > 0) {
2719
+ lines.push(currentLine);
2720
+ }
2721
+ currentLine = [i];
2722
+ currentWidth = wordWidth;
2723
+ }
2703
2724
  }
2704
- if (ctx.currentTime >= adjustedEnd) {
2705
- return {
2706
- fillProgress: 1,
2707
- isActive: false,
2708
- opacity: 1
2709
- };
2725
+ if (currentLine.length > 0) {
2726
+ lines.push(currentLine);
2710
2727
  }
2711
- return {
2712
- fillProgress: calculateWordProgress(adjustedCtx),
2713
- isActive,
2714
- opacity: 1
2715
- };
2728
+ return lines;
2716
2729
  }
2717
- function calculateHighlightState(ctx) {
2718
- const isActive = isWordActive(ctx);
2719
- return {
2720
- isActive,
2721
- fillProgress: isActive ? 1 : 0,
2722
- opacity: 1
2723
- };
2730
+ var GLYPH_SIZE_ESTIMATE = 64;
2731
+ function createShapedWordCache() {
2732
+ return new import_lru_cache.LRUCache({
2733
+ max: 5e4,
2734
+ maxSize: 50 * 1024 * 1024,
2735
+ maxEntrySize: 100 * 1024,
2736
+ sizeCalculation: (value, key) => {
2737
+ const keySize = key.length * 2;
2738
+ const glyphsSize = value.glyphs.length * GLYPH_SIZE_ESTIMATE;
2739
+ return keySize + glyphsSize + 100;
2740
+ }
2741
+ });
2724
2742
  }
2725
- function calculatePopState(ctx, activeScale, speed) {
2726
- if (ctx.currentTime < ctx.wordStart) {
2727
- return {
2728
- scale: 0.5,
2729
- opacity: 0,
2730
- isActive: false,
2731
- fillProgress: 0
2732
- };
2743
+ function makeShapingKey(text, fontFamily, fontSize, fontWeight, letterSpacing = 0) {
2744
+ return `${text}\0${fontFamily}\0${fontSize}\0${fontWeight}\0${letterSpacing}`;
2745
+ }
2746
+ function transformText(text, transform) {
2747
+ switch (transform) {
2748
+ case "uppercase":
2749
+ return text.toUpperCase();
2750
+ case "lowercase":
2751
+ return text.toLowerCase();
2752
+ case "capitalize":
2753
+ return text.replace(/\b\w/g, (c) => c.toUpperCase());
2754
+ default:
2755
+ return text;
2733
2756
  }
2734
- const adjustedDuration = ctx.animationDuration / speed;
2735
- const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2736
- const progress = calculateAnimationProgress(adjustedCtx);
2737
- const easedProgress = easeOutBack(progress);
2738
- const startScale = 0.5;
2739
- const isActive = isWordActive(ctx);
2740
- const endScale = isActive ? activeScale : 1;
2741
- const scale = startScale + (endScale - startScale) * easedProgress;
2742
- return {
2743
- scale: Math.min(scale, activeScale),
2744
- opacity: easedProgress,
2745
- isActive,
2746
- fillProgress: isActive ? 1 : 0
2747
- };
2748
2757
  }
2749
- function calculateFadeState(ctx, speed) {
2750
- if (ctx.currentTime < ctx.wordStart) {
2751
- return {
2752
- opacity: 0,
2753
- isActive: false,
2754
- fillProgress: 0
2755
- };
2758
+ function splitIntoChunks(arr, chunkSize) {
2759
+ const chunks = [];
2760
+ for (let i = 0; i < arr.length; i += chunkSize) {
2761
+ chunks.push(arr.slice(i, i + chunkSize));
2756
2762
  }
2757
- const adjustedDuration = ctx.animationDuration / speed;
2758
- const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2759
- const progress = calculateAnimationProgress(adjustedCtx);
2760
- const easedProgress = easeInOutQuad(progress);
2761
- const isActive = isWordActive(ctx);
2762
- return {
2763
- opacity: easedProgress,
2764
- isActive,
2765
- fillProgress: isActive ? 1 : 0
2766
- };
2763
+ return chunks;
2767
2764
  }
2768
- function calculateSlideState(ctx, direction, speed, fontSize) {
2769
- const slideDistance = fontSize * 1.5;
2770
- if (ctx.currentTime < ctx.wordStart) {
2771
- const offset2 = getDirectionOffset(direction, slideDistance);
2772
- return {
2773
- translateX: offset2.x,
2774
- translateY: offset2.y,
2775
- opacity: 0,
2776
- isActive: false,
2777
- fillProgress: 0
2778
- };
2765
+ var CaptionLayoutEngine = class {
2766
+ fontRegistry;
2767
+ cache;
2768
+ layoutEngine;
2769
+ constructor(fontRegistry) {
2770
+ this.fontRegistry = fontRegistry;
2771
+ this.cache = createShapedWordCache();
2772
+ this.layoutEngine = new LayoutEngine(fontRegistry);
2779
2773
  }
2780
- const adjustedDuration = ctx.animationDuration / speed;
2781
- const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2782
- const progress = calculateAnimationProgress(adjustedCtx);
2783
- const easedProgress = easeOutCirc(progress);
2784
- const offset = getDirectionOffset(direction, slideDistance);
2785
- const translateX = offset.x * (1 - easedProgress);
2786
- const translateY = offset.y * (1 - easedProgress);
2787
- const isActive = isWordActive(ctx);
2788
- return {
2789
- translateX,
2790
- translateY,
2791
- opacity: easeOutQuad2(progress),
2792
- isActive,
2793
- fillProgress: isActive ? 1 : 0
2794
- };
2795
- }
2796
- function getDirectionOffset(direction, distance) {
2797
- switch (direction) {
2798
- case "left":
2799
- return { x: -distance, y: 0 };
2800
- case "right":
2801
- return { x: distance, y: 0 };
2802
- case "up":
2803
- return { x: 0, y: -distance };
2804
- case "down":
2805
- return { x: 0, y: distance };
2774
+ async measureWord(text, config) {
2775
+ const transformedText = transformText(text, config.textTransform);
2776
+ const cacheKey = makeShapingKey(
2777
+ transformedText,
2778
+ config.fontFamily,
2779
+ config.fontSize,
2780
+ config.fontWeight,
2781
+ config.letterSpacing
2782
+ );
2783
+ const cached = this.cache.get(cacheKey);
2784
+ if (cached) {
2785
+ return cached;
2786
+ }
2787
+ const lines = await this.layoutEngine.layout({
2788
+ text: transformedText,
2789
+ width: 1e5,
2790
+ letterSpacing: config.letterSpacing,
2791
+ fontSize: config.fontSize,
2792
+ lineHeight: 1,
2793
+ desc: { family: config.fontFamily, weight: config.fontWeight },
2794
+ textTransform: "none"
2795
+ });
2796
+ const width = lines[0]?.width ?? 0;
2797
+ const glyphs = lines[0]?.glyphs ?? [];
2798
+ const isRTL = isRTLText(transformedText);
2799
+ const shaped = {
2800
+ text: transformedText,
2801
+ width,
2802
+ glyphs: glyphs.map((g) => ({
2803
+ id: g.id,
2804
+ xAdvance: g.xAdvance,
2805
+ xOffset: g.xOffset,
2806
+ yOffset: g.yOffset,
2807
+ cluster: g.cluster
2808
+ })),
2809
+ isRTL
2810
+ };
2811
+ this.cache.set(cacheKey, shaped);
2812
+ return shaped;
2806
2813
  }
2807
- }
2808
- function calculateBounceState(ctx, speed, fontSize) {
2809
- const bounceDistance = fontSize * 0.8;
2810
- if (ctx.currentTime < ctx.wordStart) {
2814
+ async layoutCaption(words, config) {
2815
+ const store = new WordTimingStore(words);
2816
+ const measurementConfig = {
2817
+ fontFamily: config.fontFamily,
2818
+ fontSize: config.fontSize,
2819
+ fontWeight: config.fontWeight,
2820
+ letterSpacing: config.letterSpacing,
2821
+ textTransform: config.textTransform
2822
+ };
2823
+ const shapedWords = await Promise.all(
2824
+ words.map((w) => this.measureWord(w.text, measurementConfig))
2825
+ );
2826
+ if (config.measureTextWidth) {
2827
+ const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
2828
+ for (let i = 0; i < shapedWords.length; i++) {
2829
+ store.widths[i] = config.measureTextWidth(shapedWords[i].text, fontString);
2830
+ }
2831
+ } else {
2832
+ for (let i = 0; i < shapedWords.length; i++) {
2833
+ store.widths[i] = shapedWords[i].width;
2834
+ }
2835
+ }
2836
+ if (config.textTransform !== "none") {
2837
+ for (let i = 0; i < shapedWords.length; i++) {
2838
+ store.words[i] = shapedWords[i].text;
2839
+ }
2840
+ }
2841
+ const wordGroups = groupWordsByPause(store, config.pauseThreshold);
2842
+ const pixelMaxWidth = config.availableWidth;
2843
+ let spaceWidth;
2844
+ if (config.measureTextWidth) {
2845
+ const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
2846
+ spaceWidth = config.measureTextWidth(" ", fontString) + config.wordSpacing;
2847
+ } else {
2848
+ const spaceWord = await this.measureWord(" ", measurementConfig);
2849
+ spaceWidth = spaceWord.width + config.wordSpacing;
2850
+ }
2851
+ const groups = wordGroups.flatMap((indices) => {
2852
+ const groupWidths = indices.map((i) => store.widths[i]);
2853
+ const allLines = breakIntoLines(
2854
+ groupWidths,
2855
+ pixelMaxWidth,
2856
+ spaceWidth
2857
+ );
2858
+ const lineChunks = splitIntoChunks(allLines, config.maxLines);
2859
+ return lineChunks.map((chunkLines) => {
2860
+ const lines = chunkLines.map((lineWordIndices, lineIndex) => {
2861
+ const actualIndices = lineWordIndices.map((i) => indices[i]);
2862
+ const lineWidth = actualIndices.reduce((sum, idx) => sum + store.widths[idx], 0) + (actualIndices.length - 1) * spaceWidth;
2863
+ return {
2864
+ wordIndices: actualIndices,
2865
+ x: 0,
2866
+ y: lineIndex * config.fontSize * config.lineHeight,
2867
+ width: lineWidth,
2868
+ height: config.fontSize
2869
+ };
2870
+ });
2871
+ const allWordIndices = lines.flatMap((l) => l.wordIndices);
2872
+ if (allWordIndices.length === 0) {
2873
+ return null;
2874
+ }
2875
+ return {
2876
+ wordIndices: allWordIndices,
2877
+ startTime: store.startTimes[allWordIndices[0]],
2878
+ endTime: store.endTimes[allWordIndices[allWordIndices.length - 1]],
2879
+ lines
2880
+ };
2881
+ }).filter((g) => g !== null);
2882
+ });
2883
+ const contentHeight = config.frameHeight - config.padding.top - config.padding.bottom;
2884
+ const contentWidth = config.frameWidth - config.padding.left - config.padding.right;
2885
+ const calculateGroupY = (group) => {
2886
+ const totalHeight = group.lines.length * config.fontSize * config.lineHeight;
2887
+ switch (config.verticalAlign) {
2888
+ case "top":
2889
+ return config.padding.top + config.fontSize * ASCENT_RATIO;
2890
+ case "bottom":
2891
+ return config.frameHeight - config.padding.bottom - totalHeight + config.fontSize * ASCENT_RATIO;
2892
+ case "middle":
2893
+ default:
2894
+ return config.padding.top + (contentHeight - totalHeight) / 2 + config.fontSize * ASCENT_RATIO;
2895
+ }
2896
+ };
2897
+ const allWordTexts = store.words.slice(0, store.length);
2898
+ const paragraphDirection = detectParagraphDirectionFromWords(allWordTexts);
2899
+ const calculateLineX = (lineWidth) => {
2900
+ switch (config.horizontalAlign) {
2901
+ case "left":
2902
+ return config.padding.left;
2903
+ case "right":
2904
+ return config.frameWidth - lineWidth - config.padding.right;
2905
+ case "center":
2906
+ default:
2907
+ return config.padding.left + (contentWidth - lineWidth) / 2;
2908
+ }
2909
+ };
2910
+ for (const group of groups) {
2911
+ const baseY = calculateGroupY(group);
2912
+ for (let lineIdx = 0; lineIdx < group.lines.length; lineIdx++) {
2913
+ const line = group.lines[lineIdx];
2914
+ line.x = calculateLineX(line.width);
2915
+ line.y = baseY + lineIdx * config.fontSize * config.lineHeight;
2916
+ const lineWordTexts = line.wordIndices.map((idx) => store.words[idx]);
2917
+ const visualOrder = reorderWordsForLine(lineWordTexts, paragraphDirection);
2918
+ let xCursor = line.x;
2919
+ for (const visualIdx of visualOrder) {
2920
+ const wordIdx = line.wordIndices[visualIdx];
2921
+ store.xPositions[wordIdx] = xCursor;
2922
+ store.yPositions[wordIdx] = line.y;
2923
+ xCursor += store.widths[wordIdx] + spaceWidth;
2924
+ }
2925
+ }
2926
+ }
2811
2927
  return {
2812
- translateY: -bounceDistance,
2813
- opacity: 0,
2814
- isActive: false,
2815
- fillProgress: 0
2928
+ store,
2929
+ groups,
2930
+ shapedWords,
2931
+ paragraphDirection
2816
2932
  };
2817
2933
  }
2818
- const adjustedDuration = ctx.animationDuration / speed;
2819
- const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
2820
- const progress = calculateAnimationProgress(adjustedCtx);
2821
- const easedProgress = easeOutBounce(progress);
2822
- const isActive = isWordActive(ctx);
2823
- return {
2824
- translateY: -bounceDistance * (1 - easedProgress),
2825
- opacity: easeOutQuad2(progress),
2826
- isActive,
2827
- fillProgress: isActive ? 1 : 0
2828
- };
2829
- }
2830
- function calculateTypewriterState(ctx, charCount, speed) {
2831
- const wordDuration = ctx.wordEnd - ctx.wordStart;
2832
- const adjustedDuration = wordDuration / speed;
2833
- const adjustedEnd = ctx.wordStart + adjustedDuration;
2834
- const adjustedCtx = { ...ctx, wordEnd: adjustedEnd };
2835
- if (ctx.currentTime < ctx.wordStart) {
2934
+ getVisibleWordsAtTime(layout, timeMs) {
2935
+ const activeGroup = layout.groups.find(
2936
+ (g) => timeMs >= g.startTime && timeMs <= g.endTime
2937
+ );
2938
+ if (!activeGroup) {
2939
+ return [];
2940
+ }
2941
+ return activeGroup.wordIndices.map((idx) => ({
2942
+ wordIndex: idx,
2943
+ text: layout.store.words[idx],
2944
+ x: layout.store.xPositions[idx],
2945
+ y: layout.store.yPositions[idx],
2946
+ width: layout.store.widths[idx],
2947
+ startTime: layout.store.startTimes[idx],
2948
+ endTime: layout.store.endTimes[idx],
2949
+ isRTL: layout.shapedWords[idx].isRTL
2950
+ }));
2951
+ }
2952
+ getActiveWordAtTime(layout, timeMs) {
2953
+ const wordIndex = findWordAtTime(layout.store, timeMs);
2954
+ if (wordIndex === -1) {
2955
+ return null;
2956
+ }
2836
2957
  return {
2837
- visibleCharacters: 0,
2838
- opacity: 1,
2839
- isActive: false
2958
+ wordIndex,
2959
+ text: layout.store.words[wordIndex],
2960
+ x: layout.store.xPositions[wordIndex],
2961
+ y: layout.store.yPositions[wordIndex],
2962
+ width: layout.store.widths[wordIndex],
2963
+ startTime: layout.store.startTimes[wordIndex],
2964
+ endTime: layout.store.endTimes[wordIndex],
2965
+ isRTL: layout.shapedWords[wordIndex].isRTL
2966
+ };
2967
+ }
2968
+ clearCache() {
2969
+ this.cache.clear();
2970
+ }
2971
+ getCacheStats() {
2972
+ return {
2973
+ size: this.cache.size,
2974
+ calculatedSize: this.cache.calculatedSize
2975
+ };
2976
+ }
2977
+ };
2978
+
2979
+ // src/core/rich-caption-animator.ts
2980
+ var ANIMATION_DURATIONS = {
2981
+ karaoke: 0,
2982
+ highlight: 0,
2983
+ pop: 200,
2984
+ fade: 150,
2985
+ slide: 250,
2986
+ bounce: 400,
2987
+ typewriter: 0,
2988
+ none: 0
2989
+ };
2990
+ var DEFAULT_ANIMATION_STATE = {
2991
+ opacity: 1,
2992
+ scale: 1,
2993
+ translateX: 0,
2994
+ translateY: 0,
2995
+ fillProgress: 1,
2996
+ isActive: false,
2997
+ visibleCharacters: -1
2998
+ };
2999
+ function easeOutQuad2(t) {
3000
+ return t * (2 - t);
3001
+ }
3002
+ function easeInOutQuad(t) {
3003
+ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
3004
+ }
3005
+ function easeOutBack(t) {
3006
+ const c1 = 1.70158;
3007
+ const c3 = c1 + 1;
3008
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
3009
+ }
3010
+ function easeOutCirc(t) {
3011
+ return Math.sqrt(1 - Math.pow(t - 1, 2));
3012
+ }
3013
+ function easeOutBounce(t) {
3014
+ const n1 = 7.5625;
3015
+ const d1 = 2.75;
3016
+ if (t < 1 / d1) {
3017
+ return n1 * t * t;
3018
+ }
3019
+ if (t < 2 / d1) {
3020
+ return n1 * (t -= 1.5 / d1) * t + 0.75;
3021
+ }
3022
+ if (t < 2.5 / d1) {
3023
+ return n1 * (t -= 2.25 / d1) * t + 0.9375;
3024
+ }
3025
+ return n1 * (t -= 2.625 / d1) * t + 0.984375;
3026
+ }
3027
+ function clamp(value, min, max) {
3028
+ return Math.min(Math.max(value, min), max);
3029
+ }
3030
+ function calculateAnimationProgress(ctx) {
3031
+ if (ctx.animationDuration <= 0) {
3032
+ return ctx.currentTime >= ctx.wordStart ? 1 : 0;
3033
+ }
3034
+ const elapsed = ctx.currentTime - ctx.wordStart;
3035
+ return clamp(elapsed / ctx.animationDuration, 0, 1);
3036
+ }
3037
+ function calculateWordProgress(ctx) {
3038
+ const duration = ctx.wordEnd - ctx.wordStart;
3039
+ if (duration <= 0) {
3040
+ return ctx.currentTime >= ctx.wordStart ? 1 : 0;
3041
+ }
3042
+ const elapsed = ctx.currentTime - ctx.wordStart;
3043
+ return clamp(elapsed / duration, 0, 1);
3044
+ }
3045
+ function isWordActive(ctx) {
3046
+ return ctx.currentTime >= ctx.wordStart && ctx.currentTime < ctx.wordEnd;
3047
+ }
3048
+ function calculateKaraokeState(ctx, speed) {
3049
+ const isActive = isWordActive(ctx);
3050
+ const wordDuration = ctx.wordEnd - ctx.wordStart;
3051
+ const adjustedDuration = wordDuration / speed;
3052
+ const adjustedEnd = ctx.wordStart + adjustedDuration;
3053
+ const adjustedCtx = { ...ctx, wordEnd: adjustedEnd };
3054
+ if (ctx.currentTime < ctx.wordStart) {
3055
+ return {
3056
+ fillProgress: 0,
3057
+ isActive: false,
3058
+ opacity: 1
3059
+ };
3060
+ }
3061
+ if (ctx.currentTime >= adjustedEnd) {
3062
+ return {
3063
+ fillProgress: 1,
3064
+ isActive: false,
3065
+ opacity: 1
3066
+ };
3067
+ }
3068
+ return {
3069
+ fillProgress: calculateWordProgress(adjustedCtx),
3070
+ isActive,
3071
+ opacity: 1
3072
+ };
3073
+ }
3074
+ function calculateHighlightState(ctx) {
3075
+ const isActive = isWordActive(ctx);
3076
+ return {
3077
+ isActive,
3078
+ fillProgress: isActive ? 1 : 0,
3079
+ opacity: 1
3080
+ };
3081
+ }
3082
+ function calculatePopState(ctx, activeScale, speed) {
3083
+ if (ctx.currentTime < ctx.wordStart) {
3084
+ return {
3085
+ scale: 0.5,
3086
+ opacity: 0,
3087
+ isActive: false,
3088
+ fillProgress: 0
3089
+ };
3090
+ }
3091
+ const adjustedDuration = ctx.animationDuration / speed;
3092
+ const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
3093
+ const progress = calculateAnimationProgress(adjustedCtx);
3094
+ const easedProgress = easeOutBack(progress);
3095
+ const startScale = 0.5;
3096
+ const isActive = isWordActive(ctx);
3097
+ const endScale = isActive ? activeScale : 1;
3098
+ const scale = startScale + (endScale - startScale) * easedProgress;
3099
+ return {
3100
+ scale: Math.min(scale, activeScale),
3101
+ opacity: easedProgress,
3102
+ isActive,
3103
+ fillProgress: isActive ? 1 : 0
3104
+ };
3105
+ }
3106
+ function calculateFadeState(ctx, speed) {
3107
+ if (ctx.currentTime < ctx.wordStart) {
3108
+ return {
3109
+ opacity: 0,
3110
+ isActive: false,
3111
+ fillProgress: 0
3112
+ };
3113
+ }
3114
+ const adjustedDuration = ctx.animationDuration / speed;
3115
+ const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
3116
+ const progress = calculateAnimationProgress(adjustedCtx);
3117
+ const easedProgress = easeInOutQuad(progress);
3118
+ const isActive = isWordActive(ctx);
3119
+ return {
3120
+ opacity: easedProgress,
3121
+ isActive,
3122
+ fillProgress: isActive ? 1 : 0
3123
+ };
3124
+ }
3125
+ function calculateSlideState(ctx, direction, speed, fontSize) {
3126
+ const slideDistance = fontSize * 1.5;
3127
+ if (ctx.currentTime < ctx.wordStart) {
3128
+ const offset2 = getDirectionOffset(direction, slideDistance);
3129
+ return {
3130
+ translateX: offset2.x,
3131
+ translateY: offset2.y,
3132
+ opacity: 0,
3133
+ isActive: false,
3134
+ fillProgress: 0
3135
+ };
3136
+ }
3137
+ const adjustedDuration = ctx.animationDuration / speed;
3138
+ const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
3139
+ const progress = calculateAnimationProgress(adjustedCtx);
3140
+ const easedProgress = easeOutCirc(progress);
3141
+ const offset = getDirectionOffset(direction, slideDistance);
3142
+ const translateX = offset.x * (1 - easedProgress);
3143
+ const translateY = offset.y * (1 - easedProgress);
3144
+ const isActive = isWordActive(ctx);
3145
+ return {
3146
+ translateX,
3147
+ translateY,
3148
+ opacity: easeOutQuad2(progress),
3149
+ isActive,
3150
+ fillProgress: isActive ? 1 : 0
3151
+ };
3152
+ }
3153
+ function getDirectionOffset(direction, distance) {
3154
+ switch (direction) {
3155
+ case "left":
3156
+ return { x: -distance, y: 0 };
3157
+ case "right":
3158
+ return { x: distance, y: 0 };
3159
+ case "up":
3160
+ return { x: 0, y: distance };
3161
+ case "down":
3162
+ return { x: 0, y: -distance };
3163
+ }
3164
+ }
3165
+ function calculateBounceState(ctx, speed, fontSize) {
3166
+ const bounceDistance = fontSize * 0.8;
3167
+ if (ctx.currentTime < ctx.wordStart) {
3168
+ return {
3169
+ translateY: -bounceDistance,
3170
+ opacity: 0,
3171
+ isActive: false,
3172
+ fillProgress: 0
3173
+ };
3174
+ }
3175
+ const adjustedDuration = ctx.animationDuration / speed;
3176
+ const adjustedCtx = { ...ctx, animationDuration: adjustedDuration };
3177
+ const progress = calculateAnimationProgress(adjustedCtx);
3178
+ const easedProgress = easeOutBounce(progress);
3179
+ const isActive = isWordActive(ctx);
3180
+ return {
3181
+ translateY: -bounceDistance * (1 - easedProgress),
3182
+ opacity: easeOutQuad2(progress),
3183
+ isActive,
3184
+ fillProgress: isActive ? 1 : 0
3185
+ };
3186
+ }
3187
+ function calculateTypewriterState(ctx, charCount, speed) {
3188
+ const wordDuration = ctx.wordEnd - ctx.wordStart;
3189
+ const adjustedDuration = wordDuration / speed;
3190
+ const adjustedEnd = ctx.wordStart + adjustedDuration;
3191
+ const adjustedCtx = { ...ctx, wordEnd: adjustedEnd };
3192
+ if (ctx.currentTime < ctx.wordStart) {
3193
+ return {
3194
+ visibleCharacters: 0,
3195
+ opacity: 1,
3196
+ isActive: false,
3197
+ fillProgress: 0
2840
3198
  };
2841
3199
  }
2842
3200
  if (ctx.currentTime >= adjustedEnd) {
2843
3201
  return {
2844
3202
  visibleCharacters: charCount,
2845
3203
  opacity: 1,
2846
- isActive: false
3204
+ isActive: false,
3205
+ fillProgress: 0
2847
3206
  };
2848
3207
  }
2849
3208
  const progress = calculateWordProgress(adjustedCtx);
2850
3209
  const visibleCharacters = Math.ceil(progress * charCount);
3210
+ const isActive = isWordActive(ctx);
2851
3211
  return {
2852
3212
  visibleCharacters: clamp(visibleCharacters, 0, charCount),
2853
3213
  opacity: 1,
2854
- isActive: isWordActive(ctx)
3214
+ isActive,
3215
+ fillProgress: isActive ? 1 : 0
2855
3216
  };
2856
3217
  }
2857
3218
  function calculateNoneState(_ctx) {
@@ -2932,28 +3293,25 @@ function getDefaultAnimationConfig() {
2932
3293
  }
2933
3294
 
2934
3295
  // src/core/rich-caption-generator.ts
2935
- var ASCENT_RATIO = 0.8;
2936
- var DESCENT_RATIO = 0.2;
2937
3296
  var WORD_BG_OPACITY = 1;
2938
3297
  var WORD_BG_BORDER_RADIUS = 4;
2939
3298
  var WORD_BG_PADDING_RATIO = 0.12;
2940
3299
  function extractFontConfig(asset) {
2941
3300
  const font = asset.font;
2942
3301
  const active = asset.active?.font;
2943
- const hasActiveConfig = asset.active !== void 0;
3302
+ const hasExplicitActiveColor = active?.color !== void 0;
2944
3303
  const baseColor = font?.color ?? "#ffffff";
2945
3304
  const baseOpacity = font?.opacity ?? 1;
2946
3305
  let activeColor;
2947
3306
  let activeOpacity;
2948
- if (!hasActiveConfig) {
3307
+ if (!hasExplicitActiveColor) {
2949
3308
  activeColor = baseColor;
2950
- activeOpacity = baseOpacity;
3309
+ activeOpacity = active?.opacity ?? baseOpacity;
2951
3310
  } else {
2952
- const explicitActiveColor = active?.color;
2953
3311
  const animStyle = asset.wordAnimation?.style ?? "highlight";
2954
3312
  const isFillAnimation = animStyle === "karaoke" || animStyle === "highlight";
2955
3313
  const DEFAULT_ACTIVE_COLOR = "#ffff00";
2956
- activeColor = explicitActiveColor ?? (isFillAnimation ? DEFAULT_ACTIVE_COLOR : baseColor);
3314
+ activeColor = active.color ?? (isFillAnimation ? DEFAULT_ACTIVE_COLOR : baseColor);
2957
3315
  activeOpacity = active?.opacity ?? baseOpacity;
2958
3316
  }
2959
3317
  return {
@@ -2993,20 +3351,33 @@ function extractStrokeConfig(asset, isActive) {
2993
3351
  return void 0;
2994
3352
  }
2995
3353
  function extractShadowConfig(asset, isActive) {
2996
- if (isActive) {
3354
+ const baseShadow = asset.shadow;
3355
+ const activeShadow = asset.active?.shadow;
3356
+ if (!baseShadow && !activeShadow) {
2997
3357
  return void 0;
2998
3358
  }
2999
- const shadow = asset.shadow;
3000
- if (!shadow) {
3001
- return void 0;
3359
+ if (isActive) {
3360
+ if (!activeShadow) {
3361
+ return void 0;
3362
+ }
3363
+ return {
3364
+ offsetX: activeShadow.offsetX ?? baseShadow?.offsetX ?? 0,
3365
+ offsetY: activeShadow.offsetY ?? baseShadow?.offsetY ?? 0,
3366
+ blur: activeShadow.blur ?? baseShadow?.blur ?? 0,
3367
+ color: activeShadow.color ?? baseShadow?.color ?? "#000000",
3368
+ opacity: activeShadow.opacity ?? baseShadow?.opacity ?? 0.5
3369
+ };
3002
3370
  }
3003
- return {
3004
- offsetX: shadow.offsetX ?? 0,
3005
- offsetY: shadow.offsetY ?? 0,
3006
- blur: shadow.blur ?? 0,
3007
- color: shadow.color ?? "#000000",
3008
- opacity: shadow.opacity ?? 0.5
3009
- };
3371
+ if (baseShadow) {
3372
+ return {
3373
+ offsetX: baseShadow.offsetX ?? 0,
3374
+ offsetY: baseShadow.offsetY ?? 0,
3375
+ blur: baseShadow.blur ?? 0,
3376
+ color: baseShadow.color ?? "#000000",
3377
+ opacity: baseShadow.opacity ?? 0.5
3378
+ };
3379
+ }
3380
+ return void 0;
3010
3381
  }
3011
3382
  function extractBackgroundConfig(asset, isActive, fontSize) {
3012
3383
  const fontBackground = asset.font?.background;
@@ -3061,9 +3432,20 @@ function extractCaptionBorder(asset) {
3061
3432
  radius: border.radius ?? 0
3062
3433
  };
3063
3434
  }
3064
- function extractAnimationConfig(asset) {
3065
- const wordAnim = asset.wordAnimation;
3066
- if (!wordAnim) {
3435
+ function extractTextDecoration(asset, isActive) {
3436
+ const baseDecoration = asset.font?.textDecoration;
3437
+ const activeDecoration = asset.active?.font?.textDecoration;
3438
+ if (isActive && activeDecoration !== void 0) {
3439
+ return activeDecoration === "none" ? void 0 : activeDecoration;
3440
+ }
3441
+ if (!baseDecoration || baseDecoration === "none") {
3442
+ return void 0;
3443
+ }
3444
+ return baseDecoration;
3445
+ }
3446
+ function extractAnimationConfig(asset) {
3447
+ const wordAnim = asset.wordAnimation;
3448
+ if (!wordAnim) {
3067
3449
  return getDefaultAnimationConfig();
3068
3450
  }
3069
3451
  return {
@@ -3103,7 +3485,8 @@ function createDrawCaptionWordOp(word, animState, asset, fontConfig) {
3103
3485
  letterSpacing: fontConfig.letterSpacing > 0 ? fontConfig.letterSpacing : void 0,
3104
3486
  stroke: extractStrokeConfig(asset, isActive),
3105
3487
  shadow: extractShadowConfig(asset, isActive),
3106
- background: extractBackgroundConfig(asset, isActive, fontConfig.size)
3488
+ background: extractBackgroundConfig(asset, isActive, fontConfig.size),
3489
+ textDecoration: extractTextDecoration(asset, isActive)
3107
3490
  };
3108
3491
  }
3109
3492
  function generateRichCaptionDrawOps(asset, layout, frameTimeMs, layoutEngine, config) {
@@ -3607,7 +3990,8 @@ async function createNodePainter(opts) {
3607
3990
  context.lineCap = "round";
3608
3991
  context.strokeText(displayText, 0, 0);
3609
3992
  }
3610
- if (wordOp.fillProgress <= 0) {
3993
+ const sameColor = wordOp.activeColor === wordOp.baseColor && wordOp.activeOpacity === wordOp.baseOpacity;
3994
+ if (wordOp.fillProgress <= 0 || sameColor) {
3611
3995
  const baseC = parseHex6(wordOp.baseColor, wordOp.baseOpacity);
3612
3996
  context.fillStyle = `rgba(${baseC.r},${baseC.g},${baseC.b},${baseC.a})`;
3613
3997
  context.fillText(displayText, 0, 0);
@@ -3634,6 +4018,25 @@ async function createNodePainter(opts) {
3634
4018
  context.fillText(displayText, 0, 0);
3635
4019
  context.restore();
3636
4020
  }
4021
+ if (wordOp.textDecoration) {
4022
+ const geo = decorationGeometry(wordOp.textDecoration, {
4023
+ baselineY: 0,
4024
+ fontSize: wordOp.fontSize,
4025
+ lineWidth: textWidth,
4026
+ xStart: 0
4027
+ });
4028
+ const sameC = wordOp.activeColor === wordOp.baseColor && wordOp.activeOpacity === wordOp.baseOpacity;
4029
+ const decoIsActive = wordOp.fillProgress >= 1 && !sameC;
4030
+ const decoColor = decoIsActive ? wordOp.activeColor : wordOp.baseColor;
4031
+ const decoOpacity = decoIsActive ? wordOp.activeOpacity : wordOp.baseOpacity;
4032
+ const dc = parseHex6(decoColor, decoOpacity);
4033
+ context.strokeStyle = `rgba(${dc.r},${dc.g},${dc.b},${dc.a})`;
4034
+ context.lineWidth = geo.width;
4035
+ context.beginPath();
4036
+ context.moveTo(geo.x1, geo.y);
4037
+ context.lineTo(geo.x2, geo.y);
4038
+ context.stroke();
4039
+ }
3637
4040
  context.restore();
3638
4041
  }
3639
4042
  });
@@ -5093,353 +5496,6 @@ function extractSvgDimensions(svgString) {
5093
5496
  return { width, height };
5094
5497
  }
5095
5498
 
5096
- // src/core/rich-caption-layout.ts
5097
- var import_lru_cache = require("lru-cache");
5098
- function isRTLText(text) {
5099
- return containsRTLCharacters(text);
5100
- }
5101
- var WordTimingStore = class {
5102
- startTimes;
5103
- endTimes;
5104
- xPositions;
5105
- yPositions;
5106
- widths;
5107
- words;
5108
- length;
5109
- constructor(words) {
5110
- this.length = words.length;
5111
- this.startTimes = new Uint32Array(this.length);
5112
- this.endTimes = new Uint32Array(this.length);
5113
- this.xPositions = new Float32Array(this.length);
5114
- this.yPositions = new Float32Array(this.length);
5115
- this.widths = new Float32Array(this.length);
5116
- this.words = new Array(this.length);
5117
- for (let i = 0; i < this.length; i++) {
5118
- this.startTimes[i] = Math.floor(words[i].start);
5119
- this.endTimes[i] = Math.floor(words[i].end);
5120
- this.words[i] = words[i].text;
5121
- }
5122
- }
5123
- };
5124
- function findWordAtTime(store, timeMs) {
5125
- let left = 0;
5126
- let right = store.length - 1;
5127
- while (left <= right) {
5128
- const mid = left + right >>> 1;
5129
- const start = store.startTimes[mid];
5130
- const end = store.endTimes[mid];
5131
- if (timeMs >= start && timeMs < end) {
5132
- return mid;
5133
- }
5134
- if (timeMs < start) {
5135
- right = mid - 1;
5136
- } else {
5137
- left = mid + 1;
5138
- }
5139
- }
5140
- return -1;
5141
- }
5142
- function groupWordsByPause(store, pauseThreshold = 500) {
5143
- if (store.length === 0) {
5144
- return [];
5145
- }
5146
- const groups = [];
5147
- let currentGroup = [];
5148
- for (let i = 0; i < store.length; i++) {
5149
- if (currentGroup.length === 0) {
5150
- currentGroup.push(i);
5151
- continue;
5152
- }
5153
- const prevEnd = store.endTimes[currentGroup[currentGroup.length - 1]];
5154
- const currStart = store.startTimes[i];
5155
- const gap = currStart - prevEnd;
5156
- const prevText = store.words[currentGroup[currentGroup.length - 1]];
5157
- const endsWithPunctuation = /[.!?]$/.test(prevText);
5158
- if (gap >= pauseThreshold || endsWithPunctuation) {
5159
- groups.push(currentGroup);
5160
- currentGroup = [i];
5161
- } else {
5162
- currentGroup.push(i);
5163
- }
5164
- }
5165
- if (currentGroup.length > 0) {
5166
- groups.push(currentGroup);
5167
- }
5168
- return groups;
5169
- }
5170
- function breakIntoLines(wordWidths, maxWidth, spaceWidth) {
5171
- const lines = [];
5172
- let currentLine = [];
5173
- let currentWidth = 0;
5174
- for (let i = 0; i < wordWidths.length; i++) {
5175
- const wordWidth = wordWidths[i];
5176
- const spaceNeeded = currentLine.length > 0 ? spaceWidth : 0;
5177
- if (currentWidth + spaceNeeded + wordWidth <= maxWidth) {
5178
- currentLine.push(i);
5179
- currentWidth += spaceNeeded + wordWidth;
5180
- } else {
5181
- if (currentLine.length > 0) {
5182
- lines.push(currentLine);
5183
- }
5184
- currentLine = [i];
5185
- currentWidth = wordWidth;
5186
- }
5187
- }
5188
- if (currentLine.length > 0) {
5189
- lines.push(currentLine);
5190
- }
5191
- return lines;
5192
- }
5193
- var GLYPH_SIZE_ESTIMATE = 64;
5194
- function createShapedWordCache() {
5195
- return new import_lru_cache.LRUCache({
5196
- max: 5e4,
5197
- maxSize: 50 * 1024 * 1024,
5198
- maxEntrySize: 100 * 1024,
5199
- sizeCalculation: (value, key) => {
5200
- const keySize = key.length * 2;
5201
- const glyphsSize = value.glyphs.length * GLYPH_SIZE_ESTIMATE;
5202
- return keySize + glyphsSize + 100;
5203
- }
5204
- });
5205
- }
5206
- function makeShapingKey(text, fontFamily, fontSize, fontWeight, letterSpacing = 0) {
5207
- return `${text}\0${fontFamily}\0${fontSize}\0${fontWeight}\0${letterSpacing}`;
5208
- }
5209
- function transformText(text, transform) {
5210
- switch (transform) {
5211
- case "uppercase":
5212
- return text.toUpperCase();
5213
- case "lowercase":
5214
- return text.toLowerCase();
5215
- case "capitalize":
5216
- return text.replace(/\b\w/g, (c) => c.toUpperCase());
5217
- default:
5218
- return text;
5219
- }
5220
- }
5221
- function splitIntoChunks(arr, chunkSize) {
5222
- const chunks = [];
5223
- for (let i = 0; i < arr.length; i += chunkSize) {
5224
- chunks.push(arr.slice(i, i + chunkSize));
5225
- }
5226
- return chunks;
5227
- }
5228
- var CaptionLayoutEngine = class {
5229
- fontRegistry;
5230
- cache;
5231
- layoutEngine;
5232
- constructor(fontRegistry) {
5233
- this.fontRegistry = fontRegistry;
5234
- this.cache = createShapedWordCache();
5235
- this.layoutEngine = new LayoutEngine(fontRegistry);
5236
- }
5237
- async measureWord(text, config) {
5238
- const transformedText = transformText(text, config.textTransform);
5239
- const cacheKey = makeShapingKey(
5240
- transformedText,
5241
- config.fontFamily,
5242
- config.fontSize,
5243
- config.fontWeight,
5244
- config.letterSpacing
5245
- );
5246
- const cached = this.cache.get(cacheKey);
5247
- if (cached) {
5248
- return cached;
5249
- }
5250
- const lines = await this.layoutEngine.layout({
5251
- text: transformedText,
5252
- width: 1e5,
5253
- letterSpacing: config.letterSpacing,
5254
- fontSize: config.fontSize,
5255
- lineHeight: 1,
5256
- desc: { family: config.fontFamily, weight: config.fontWeight },
5257
- textTransform: "none"
5258
- });
5259
- const width = lines[0]?.width ?? 0;
5260
- const glyphs = lines[0]?.glyphs ?? [];
5261
- const isRTL = isRTLText(transformedText);
5262
- const shaped = {
5263
- text: transformedText,
5264
- width,
5265
- glyphs: glyphs.map((g) => ({
5266
- id: g.id,
5267
- xAdvance: g.xAdvance,
5268
- xOffset: g.xOffset,
5269
- yOffset: g.yOffset,
5270
- cluster: g.cluster
5271
- })),
5272
- isRTL
5273
- };
5274
- this.cache.set(cacheKey, shaped);
5275
- return shaped;
5276
- }
5277
- async layoutCaption(words, config) {
5278
- const store = new WordTimingStore(words);
5279
- const measurementConfig = {
5280
- fontFamily: config.fontFamily,
5281
- fontSize: config.fontSize,
5282
- fontWeight: config.fontWeight,
5283
- letterSpacing: config.letterSpacing,
5284
- textTransform: config.textTransform
5285
- };
5286
- const shapedWords = await Promise.all(
5287
- words.map((w) => this.measureWord(w.text, measurementConfig))
5288
- );
5289
- if (config.measureTextWidth) {
5290
- const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
5291
- for (let i = 0; i < shapedWords.length; i++) {
5292
- store.widths[i] = config.measureTextWidth(shapedWords[i].text, fontString);
5293
- }
5294
- } else {
5295
- for (let i = 0; i < shapedWords.length; i++) {
5296
- store.widths[i] = shapedWords[i].width;
5297
- }
5298
- }
5299
- if (config.textTransform !== "none") {
5300
- for (let i = 0; i < shapedWords.length; i++) {
5301
- store.words[i] = shapedWords[i].text;
5302
- }
5303
- }
5304
- const wordGroups = groupWordsByPause(store, config.pauseThreshold);
5305
- const pixelMaxWidth = config.availableWidth;
5306
- let spaceWidth;
5307
- if (config.measureTextWidth) {
5308
- const fontString = `${config.fontWeight} ${config.fontSize}px "${config.fontFamily}"`;
5309
- spaceWidth = config.measureTextWidth(" ", fontString) + config.wordSpacing;
5310
- } else {
5311
- const spaceWord = await this.measureWord(" ", measurementConfig);
5312
- spaceWidth = spaceWord.width + config.wordSpacing;
5313
- }
5314
- const groups = wordGroups.flatMap((indices) => {
5315
- const groupWidths = indices.map((i) => store.widths[i]);
5316
- const allLines = breakIntoLines(
5317
- groupWidths,
5318
- pixelMaxWidth,
5319
- spaceWidth
5320
- );
5321
- const lineChunks = splitIntoChunks(allLines, config.maxLines);
5322
- return lineChunks.map((chunkLines) => {
5323
- const lines = chunkLines.map((lineWordIndices, lineIndex) => {
5324
- const actualIndices = lineWordIndices.map((i) => indices[i]);
5325
- const lineWidth = actualIndices.reduce((sum, idx) => sum + store.widths[idx], 0) + (actualIndices.length - 1) * spaceWidth;
5326
- return {
5327
- wordIndices: actualIndices,
5328
- x: 0,
5329
- y: lineIndex * config.fontSize * config.lineHeight,
5330
- width: lineWidth,
5331
- height: config.fontSize
5332
- };
5333
- });
5334
- const allWordIndices = lines.flatMap((l) => l.wordIndices);
5335
- if (allWordIndices.length === 0) {
5336
- return null;
5337
- }
5338
- return {
5339
- wordIndices: allWordIndices,
5340
- startTime: store.startTimes[allWordIndices[0]],
5341
- endTime: store.endTimes[allWordIndices[allWordIndices.length - 1]],
5342
- lines
5343
- };
5344
- }).filter((g) => g !== null);
5345
- });
5346
- const contentHeight = config.frameHeight - config.padding.top - config.padding.bottom;
5347
- const contentWidth = config.frameWidth - config.padding.left - config.padding.right;
5348
- const ASCENT_RATIO2 = 0.8;
5349
- const calculateGroupY = (group) => {
5350
- const totalHeight = group.lines.length * config.fontSize * config.lineHeight;
5351
- switch (config.verticalAlign) {
5352
- case "top":
5353
- return config.padding.top + config.fontSize * ASCENT_RATIO2;
5354
- case "bottom":
5355
- return config.frameHeight - config.padding.bottom - totalHeight + config.fontSize * ASCENT_RATIO2;
5356
- case "middle":
5357
- default:
5358
- return config.padding.top + (contentHeight - totalHeight) / 2 + config.fontSize * ASCENT_RATIO2;
5359
- }
5360
- };
5361
- const allWordTexts = store.words.slice(0, store.length);
5362
- const paragraphDirection = detectParagraphDirectionFromWords(allWordTexts);
5363
- const calculateLineX = (lineWidth) => {
5364
- switch (config.horizontalAlign) {
5365
- case "left":
5366
- return config.padding.left;
5367
- case "right":
5368
- return config.frameWidth - lineWidth - config.padding.right;
5369
- case "center":
5370
- default:
5371
- return config.padding.left + (contentWidth - lineWidth) / 2;
5372
- }
5373
- };
5374
- for (const group of groups) {
5375
- const baseY = calculateGroupY(group);
5376
- for (let lineIdx = 0; lineIdx < group.lines.length; lineIdx++) {
5377
- const line = group.lines[lineIdx];
5378
- line.x = calculateLineX(line.width);
5379
- line.y = baseY + lineIdx * config.fontSize * config.lineHeight;
5380
- const lineWordTexts = line.wordIndices.map((idx) => store.words[idx]);
5381
- const visualOrder = reorderWordsForLine(lineWordTexts, paragraphDirection);
5382
- let xCursor = line.x;
5383
- for (const visualIdx of visualOrder) {
5384
- const wordIdx = line.wordIndices[visualIdx];
5385
- store.xPositions[wordIdx] = xCursor;
5386
- store.yPositions[wordIdx] = line.y;
5387
- xCursor += store.widths[wordIdx] + spaceWidth;
5388
- }
5389
- }
5390
- }
5391
- return {
5392
- store,
5393
- groups,
5394
- shapedWords,
5395
- paragraphDirection
5396
- };
5397
- }
5398
- getVisibleWordsAtTime(layout, timeMs) {
5399
- const activeGroup = layout.groups.find(
5400
- (g) => timeMs >= g.startTime && timeMs <= g.endTime
5401
- );
5402
- if (!activeGroup) {
5403
- return [];
5404
- }
5405
- return activeGroup.wordIndices.map((idx) => ({
5406
- wordIndex: idx,
5407
- text: layout.store.words[idx],
5408
- x: layout.store.xPositions[idx],
5409
- y: layout.store.yPositions[idx],
5410
- width: layout.store.widths[idx],
5411
- startTime: layout.store.startTimes[idx],
5412
- endTime: layout.store.endTimes[idx],
5413
- isRTL: layout.shapedWords[idx].isRTL
5414
- }));
5415
- }
5416
- getActiveWordAtTime(layout, timeMs) {
5417
- const wordIndex = findWordAtTime(layout.store, timeMs);
5418
- if (wordIndex === -1) {
5419
- return null;
5420
- }
5421
- return {
5422
- wordIndex,
5423
- text: layout.store.words[wordIndex],
5424
- x: layout.store.xPositions[wordIndex],
5425
- y: layout.store.yPositions[wordIndex],
5426
- width: layout.store.widths[wordIndex],
5427
- startTime: layout.store.startTimes[wordIndex],
5428
- endTime: layout.store.endTimes[wordIndex],
5429
- isRTL: layout.shapedWords[wordIndex].isRTL
5430
- };
5431
- }
5432
- clearCache() {
5433
- this.cache.clear();
5434
- }
5435
- getCacheStats() {
5436
- return {
5437
- size: this.cache.size,
5438
- calculatedSize: this.cache.calculatedSize
5439
- };
5440
- }
5441
- };
5442
-
5443
5499
  // src/core/canvas-text-measurer.ts
5444
5500
  async function createCanvasTextMeasurer() {
5445
5501
  const canvasMod = await import("canvas");