@shotstack/shotstack-canvas 2.1.1 → 2.1.3

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.
@@ -1851,8 +1851,8 @@ async function buildDrawOps(p) {
1851
1851
  const canvasCenterY = p.canvas.height / 2;
1852
1852
  const bgX = canvasCenterX - contentWidth / 2;
1853
1853
  const bgY = canvasCenterY - contentHeight / 2;
1854
- const maxRadius = Math.min(contentWidth - borderWidth2, contentHeight - borderWidth2) / 2;
1855
- const outerRadius = Math.min(borderRadius, maxRadius);
1854
+ const maxRadius = Math.max(0, Math.min(contentWidth - borderWidth2, contentHeight - borderWidth2) / 2);
1855
+ const outerRadius = Math.max(0, Math.min(borderRadius, maxRadius));
1856
1856
  const innerRadius = Math.max(0, outerRadius - halfBorder);
1857
1857
  if (p.background?.color) {
1858
1858
  ops.push({
@@ -2842,10 +2842,11 @@ function calculateTypewriterState(ctx, charCount, speed) {
2842
2842
  isActive: isWordActive(ctx)
2843
2843
  };
2844
2844
  }
2845
- function calculateNoneState(ctx) {
2845
+ function calculateNoneState(_ctx) {
2846
2846
  return {
2847
2847
  opacity: 1,
2848
- isActive: isWordActive(ctx)
2848
+ isActive: false,
2849
+ fillProgress: 0
2849
2850
  };
2850
2851
  }
2851
2852
  function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, activeScale = 1, charCount = 0, fontSize = 48, isRTL = false) {
@@ -2888,7 +2889,7 @@ function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, ac
2888
2889
  break;
2889
2890
  }
2890
2891
  const mergedState = { ...baseState, ...partialState };
2891
- if (mergedState.isActive && activeScale !== 1 && config.style !== "pop") {
2892
+ if (mergedState.isActive && activeScale !== 1 && config.style !== "pop" && config.style !== "none") {
2892
2893
  mergedState.scale = activeScale;
2893
2894
  }
2894
2895
  return mergedState;
@@ -2950,7 +2951,10 @@ function extractStrokeConfig(asset, isActive) {
2950
2951
  if (!baseStroke && !activeStroke) {
2951
2952
  return void 0;
2952
2953
  }
2953
- if (isActive && activeStroke) {
2954
+ if (isActive) {
2955
+ if (!activeStroke) {
2956
+ return void 0;
2957
+ }
2954
2958
  return {
2955
2959
  width: activeStroke.width ?? baseStroke?.width ?? 0,
2956
2960
  color: activeStroke.color ?? baseStroke?.color ?? "#000000",
@@ -2966,7 +2970,10 @@ function extractStrokeConfig(asset, isActive) {
2966
2970
  }
2967
2971
  return void 0;
2968
2972
  }
2969
- function extractShadowConfig(asset) {
2973
+ function extractShadowConfig(asset, isActive) {
2974
+ if (isActive) {
2975
+ return void 0;
2976
+ }
2970
2977
  const shadow = asset.shadow;
2971
2978
  if (!shadow) {
2972
2979
  return void 0;
@@ -3073,27 +3080,10 @@ function createDrawCaptionWordOp(word, animState, asset, fontConfig) {
3073
3080
  visibleCharacters: animState.visibleCharacters,
3074
3081
  letterSpacing: fontConfig.letterSpacing > 0 ? fontConfig.letterSpacing : void 0,
3075
3082
  stroke: extractStrokeConfig(asset, isActive),
3076
- shadow: extractShadowConfig(asset),
3083
+ shadow: extractShadowConfig(asset, isActive),
3077
3084
  background: extractBackgroundConfig(asset, isActive, fontConfig.size)
3078
3085
  };
3079
3086
  }
3080
- function calculateGroupBounds(activeGroup, padding, frameWidth, activeScale, fontSize) {
3081
- let minY = Infinity;
3082
- let maxY = -Infinity;
3083
- for (const line of activeGroup.lines) {
3084
- const lineY = line.y - line.height * ASCENT_RATIO;
3085
- const lineBottom = line.y + line.height * DESCENT_RATIO;
3086
- if (lineY < minY) minY = lineY;
3087
- if (lineBottom > maxY) maxY = lineBottom;
3088
- }
3089
- const scaleExpansion = activeScale > 1 ? (activeScale - 1) * fontSize : 0;
3090
- return {
3091
- bgX: 0,
3092
- bgY: minY - padding.top - scaleExpansion,
3093
- bgWidth: frameWidth,
3094
- bgHeight: maxY - minY + padding.top + padding.bottom + scaleExpansion * 2
3095
- };
3096
- }
3097
3087
  function generateRichCaptionDrawOps(asset, layout, frameTimeMs, layoutEngine, config) {
3098
3088
  if (layout.store.length === 0) {
3099
3089
  return [];
@@ -3113,48 +3103,35 @@ function generateRichCaptionDrawOps(asset, layout, frameTimeMs, layoutEngine, co
3113
3103
  fontConfig.size
3114
3104
  );
3115
3105
  const ops = [];
3116
- const activeGroup = layout.groups.find(
3117
- (g) => frameTimeMs >= g.startTime && frameTimeMs <= g.endTime
3118
- );
3119
- if (activeGroup && activeGroup.lines.length > 0) {
3120
- const padding = extractCaptionPadding(asset);
3121
- const { bgX, bgY, bgWidth, bgHeight } = calculateGroupBounds(
3122
- activeGroup,
3123
- padding,
3124
- config.frameWidth,
3125
- activeScale,
3126
- fontConfig.size
3127
- );
3128
- const captionBg = extractCaptionBackground(asset);
3129
- if (captionBg) {
3130
- ops.push({
3131
- op: "DrawCaptionBackground",
3132
- x: bgX,
3133
- y: bgY,
3134
- width: bgWidth,
3135
- height: bgHeight,
3136
- color: captionBg.color,
3137
- opacity: captionBg.opacity,
3138
- borderRadius: captionBg.borderRadius
3139
- });
3140
- }
3141
- const borderConfig = extractCaptionBorder(asset);
3142
- if (borderConfig) {
3143
- const halfBorder = borderConfig.width / 2;
3144
- ops.push({
3145
- op: "RectangleStroke",
3146
- x: bgX + halfBorder,
3147
- y: bgY + halfBorder,
3148
- width: bgWidth - borderConfig.width,
3149
- height: bgHeight - borderConfig.width,
3150
- stroke: {
3151
- width: borderConfig.width,
3152
- color: borderConfig.color,
3153
- opacity: borderConfig.opacity
3154
- },
3155
- borderRadius: borderConfig.radius > 0 ? borderConfig.radius : void 0
3156
- });
3157
- }
3106
+ const captionBg = extractCaptionBackground(asset);
3107
+ if (captionBg) {
3108
+ ops.push({
3109
+ op: "DrawCaptionBackground",
3110
+ x: 0,
3111
+ y: 0,
3112
+ width: config.frameWidth,
3113
+ height: config.frameHeight,
3114
+ color: captionBg.color,
3115
+ opacity: captionBg.opacity,
3116
+ borderRadius: captionBg.borderRadius
3117
+ });
3118
+ }
3119
+ const borderConfig = extractCaptionBorder(asset);
3120
+ if (borderConfig) {
3121
+ const halfBorder = borderConfig.width / 2;
3122
+ ops.push({
3123
+ op: "RectangleStroke",
3124
+ x: halfBorder,
3125
+ y: halfBorder,
3126
+ width: config.frameWidth - borderConfig.width,
3127
+ height: config.frameHeight - borderConfig.width,
3128
+ stroke: {
3129
+ width: borderConfig.width,
3130
+ color: borderConfig.color,
3131
+ opacity: borderConfig.opacity
3132
+ },
3133
+ borderRadius: borderConfig.radius > 0 ? borderConfig.radius : void 0
3134
+ });
3158
3135
  }
3159
3136
  const nonActiveOps = [];
3160
3137
  const activeOps = [];
@@ -3501,40 +3478,67 @@ async function createNodePainter(opts) {
3501
3478
  i++;
3502
3479
  }
3503
3480
  renderToBoth((context) => {
3504
- for (const wordOp of captionWordOps) {
3505
- if (!wordOp.background) continue;
3481
+ const bgSortedOps = [...captionWordOps].sort((a, b) => {
3482
+ const dy = Math.round(a.y) - Math.round(b.y);
3483
+ if (dy !== 0) return dy;
3484
+ return a.x + a.transform.translateX - (b.x + b.transform.translateX);
3485
+ });
3486
+ let bgIdx = 0;
3487
+ while (bgIdx < bgSortedOps.length) {
3488
+ const wordOp = bgSortedOps[bgIdx];
3489
+ if (!wordOp.background) {
3490
+ bgIdx++;
3491
+ continue;
3492
+ }
3506
3493
  const wordDisplayText = getVisibleText(wordOp.text, wordOp.visibleCharacters, wordOp.isRTL);
3507
- if (wordDisplayText.length === 0) continue;
3494
+ if (wordDisplayText.length === 0) {
3495
+ bgIdx++;
3496
+ continue;
3497
+ }
3498
+ const mergeGroup = [wordOp];
3499
+ let nextIdx = bgIdx + 1;
3500
+ while (nextIdx < bgSortedOps.length) {
3501
+ const nextWord = bgSortedOps[nextIdx];
3502
+ if (!nextWord.background) break;
3503
+ const nextDisplay = getVisibleText(nextWord.text, nextWord.visibleCharacters, nextWord.isRTL);
3504
+ if (nextDisplay.length === 0) break;
3505
+ if (Math.round(nextWord.y) !== Math.round(wordOp.y)) break;
3506
+ mergeGroup.push(nextWord);
3507
+ nextIdx++;
3508
+ }
3509
+ const firstWord = mergeGroup[0];
3510
+ const lastWord = mergeGroup[mergeGroup.length - 1];
3511
+ const firstBg = firstWord.background;
3512
+ const mergedLeft = firstWord.x + firstWord.transform.translateX;
3513
+ const mergedRight = lastWord.x + lastWord.transform.translateX + lastWord.width;
3514
+ const mergedWidth = mergedRight - mergedLeft;
3508
3515
  context.save();
3509
- const bgTx = Math.round(wordOp.x + wordOp.transform.translateX);
3510
- const bgTy = Math.round(wordOp.y + wordOp.transform.translateY);
3516
+ const bgTx = Math.round(mergedLeft);
3517
+ const bgTy = Math.round(firstWord.y + firstWord.transform.translateY);
3511
3518
  context.translate(bgTx, bgTy);
3512
- if (wordOp.transform.scale !== 1) {
3513
- const halfWidth = wordOp.width / 2;
3519
+ if (firstWord.transform.scale !== 1) {
3520
+ const halfWidth = mergedWidth / 2;
3514
3521
  context.translate(halfWidth, 0);
3515
- context.scale(wordOp.transform.scale, wordOp.transform.scale);
3522
+ context.scale(firstWord.transform.scale, firstWord.transform.scale);
3516
3523
  context.translate(-halfWidth, 0);
3517
3524
  }
3518
- context.globalAlpha = wordOp.transform.opacity;
3519
- context.font = `${wordOp.fontWeight} ${wordOp.fontSize}px "${wordOp.fontFamily}"`;
3520
- context.textBaseline = "alphabetic";
3521
- if (wordOp.letterSpacing) {
3522
- context.letterSpacing = `${wordOp.letterSpacing}px`;
3523
- }
3524
- const bgTextWidth = wordOp.width;
3525
- const bgAscent = wordOp.fontSize * ASCENT_RATIO;
3526
- const bgDescent = wordOp.fontSize * DESCENT_RATIO;
3525
+ context.globalAlpha = firstWord.transform.opacity;
3526
+ const bgAscent = firstWord.fontSize * ASCENT_RATIO;
3527
+ const bgDescent = firstWord.fontSize * DESCENT_RATIO;
3527
3528
  const bgTextHeight = bgAscent + bgDescent;
3528
- const bgX = -wordOp.background.padding;
3529
- const bgY = -bgAscent - wordOp.background.padding;
3530
- const bgW = bgTextWidth + wordOp.background.padding * 2;
3531
- const bgH = bgTextHeight + wordOp.background.padding * 2;
3532
- const bgC = parseHex6(wordOp.background.color, wordOp.background.opacity);
3533
- context.fillStyle = `rgba(${bgC.r},${bgC.g},${bgC.b},${bgC.a})`;
3534
- context.beginPath();
3535
- roundRectPath(context, bgX, bgY, bgW, bgH, wordOp.background.borderRadius);
3536
- context.fill();
3529
+ const bgX = -firstBg.padding;
3530
+ const bgY = -bgAscent - firstBg.padding;
3531
+ const bgW = mergedWidth + firstBg.padding * 2;
3532
+ const bgH = bgTextHeight + firstBg.padding * 2;
3533
+ if (bgW > 0 && bgH > 0) {
3534
+ const bgC = parseHex6(firstBg.color, firstBg.opacity);
3535
+ context.fillStyle = `rgba(${bgC.r},${bgC.g},${bgC.b},${bgC.a})`;
3536
+ context.beginPath();
3537
+ roundRectPath(context, bgX, bgY, bgW, bgH, firstBg.borderRadius);
3538
+ context.fill();
3539
+ }
3537
3540
  context.restore();
3541
+ bgIdx = nextIdx;
3538
3542
  }
3539
3543
  for (const wordOp of captionWordOps) {
3540
3544
  const displayText = getVisibleText(wordOp.text, wordOp.visibleCharacters, wordOp.isRTL);
@@ -3829,8 +3833,8 @@ function computePathBounds2(d) {
3829
3833
  };
3830
3834
  }
3831
3835
  function roundRectPath(ctx, x, y, w, h, r) {
3832
- const maxRadius = Math.min(w, h) / 2;
3833
- const radius = Math.min(r, maxRadius);
3836
+ const maxRadius = Math.max(0, Math.min(w, h) / 2);
3837
+ const radius = Math.max(0, Math.min(r, maxRadius));
3834
3838
  ctx.moveTo(x + radius, y);
3835
3839
  ctx.arcTo(x + w, y, x + w, y + h, radius);
3836
3840
  ctx.arcTo(x + w, y + h, x, y + h, radius);
@@ -5319,16 +5323,17 @@ var CaptionLayoutEngine = class {
5319
5323
  });
5320
5324
  const contentHeight = config.frameHeight - config.padding.top - config.padding.bottom;
5321
5325
  const contentWidth = config.frameWidth - config.padding.left - config.padding.right;
5326
+ const ASCENT_RATIO2 = 0.8;
5322
5327
  const calculateGroupY = (group) => {
5323
5328
  const totalHeight = group.lines.length * config.fontSize * config.lineHeight;
5324
5329
  switch (config.verticalAlign) {
5325
5330
  case "top":
5326
- return config.padding.top + config.fontSize * 1.5;
5331
+ return config.padding.top + config.fontSize * ASCENT_RATIO2;
5327
5332
  case "bottom":
5328
- return config.frameHeight - config.padding.bottom - totalHeight - config.fontSize * 0.5;
5333
+ return config.frameHeight - config.padding.bottom - totalHeight + config.fontSize * ASCENT_RATIO2;
5329
5334
  case "middle":
5330
5335
  default:
5331
- return config.padding.top + (contentHeight - totalHeight) / 2 + config.fontSize;
5336
+ return config.padding.top + (contentHeight - totalHeight) / 2 + config.fontSize * ASCENT_RATIO2;
5332
5337
  }
5333
5338
  };
5334
5339
  const allWordTexts = store.words.slice(0, store.length);
@@ -1448,8 +1448,8 @@ async function buildDrawOps(p) {
1448
1448
  const canvasCenterY = p.canvas.height / 2;
1449
1449
  const bgX = canvasCenterX - contentWidth / 2;
1450
1450
  const bgY = canvasCenterY - contentHeight / 2;
1451
- const maxRadius = Math.min(contentWidth - borderWidth2, contentHeight - borderWidth2) / 2;
1452
- const outerRadius = Math.min(borderRadius, maxRadius);
1451
+ const maxRadius = Math.max(0, Math.min(contentWidth - borderWidth2, contentHeight - borderWidth2) / 2);
1452
+ const outerRadius = Math.max(0, Math.min(borderRadius, maxRadius));
1453
1453
  const innerRadius = Math.max(0, outerRadius - halfBorder);
1454
1454
  if (p.background?.color) {
1455
1455
  ops.push({
@@ -2439,10 +2439,11 @@ function calculateTypewriterState(ctx, charCount, speed) {
2439
2439
  isActive: isWordActive(ctx)
2440
2440
  };
2441
2441
  }
2442
- function calculateNoneState(ctx) {
2442
+ function calculateNoneState(_ctx) {
2443
2443
  return {
2444
2444
  opacity: 1,
2445
- isActive: isWordActive(ctx)
2445
+ isActive: false,
2446
+ fillProgress: 0
2446
2447
  };
2447
2448
  }
2448
2449
  function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, activeScale = 1, charCount = 0, fontSize = 48, isRTL = false) {
@@ -2485,7 +2486,7 @@ function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, ac
2485
2486
  break;
2486
2487
  }
2487
2488
  const mergedState = { ...baseState, ...partialState };
2488
- if (mergedState.isActive && activeScale !== 1 && config.style !== "pop") {
2489
+ if (mergedState.isActive && activeScale !== 1 && config.style !== "pop" && config.style !== "none") {
2489
2490
  mergedState.scale = activeScale;
2490
2491
  }
2491
2492
  return mergedState;
@@ -2547,7 +2548,10 @@ function extractStrokeConfig(asset, isActive) {
2547
2548
  if (!baseStroke && !activeStroke) {
2548
2549
  return void 0;
2549
2550
  }
2550
- if (isActive && activeStroke) {
2551
+ if (isActive) {
2552
+ if (!activeStroke) {
2553
+ return void 0;
2554
+ }
2551
2555
  return {
2552
2556
  width: activeStroke.width ?? baseStroke?.width ?? 0,
2553
2557
  color: activeStroke.color ?? baseStroke?.color ?? "#000000",
@@ -2563,7 +2567,10 @@ function extractStrokeConfig(asset, isActive) {
2563
2567
  }
2564
2568
  return void 0;
2565
2569
  }
2566
- function extractShadowConfig(asset) {
2570
+ function extractShadowConfig(asset, isActive) {
2571
+ if (isActive) {
2572
+ return void 0;
2573
+ }
2567
2574
  const shadow = asset.shadow;
2568
2575
  if (!shadow) {
2569
2576
  return void 0;
@@ -2670,27 +2677,10 @@ function createDrawCaptionWordOp(word, animState, asset, fontConfig) {
2670
2677
  visibleCharacters: animState.visibleCharacters,
2671
2678
  letterSpacing: fontConfig.letterSpacing > 0 ? fontConfig.letterSpacing : void 0,
2672
2679
  stroke: extractStrokeConfig(asset, isActive),
2673
- shadow: extractShadowConfig(asset),
2680
+ shadow: extractShadowConfig(asset, isActive),
2674
2681
  background: extractBackgroundConfig(asset, isActive, fontConfig.size)
2675
2682
  };
2676
2683
  }
2677
- function calculateGroupBounds(activeGroup, padding, frameWidth, activeScale, fontSize) {
2678
- let minY = Infinity;
2679
- let maxY = -Infinity;
2680
- for (const line of activeGroup.lines) {
2681
- const lineY = line.y - line.height * ASCENT_RATIO;
2682
- const lineBottom = line.y + line.height * DESCENT_RATIO;
2683
- if (lineY < minY) minY = lineY;
2684
- if (lineBottom > maxY) maxY = lineBottom;
2685
- }
2686
- const scaleExpansion = activeScale > 1 ? (activeScale - 1) * fontSize : 0;
2687
- return {
2688
- bgX: 0,
2689
- bgY: minY - padding.top - scaleExpansion,
2690
- bgWidth: frameWidth,
2691
- bgHeight: maxY - minY + padding.top + padding.bottom + scaleExpansion * 2
2692
- };
2693
- }
2694
2684
  function generateRichCaptionDrawOps(asset, layout, frameTimeMs, layoutEngine, config) {
2695
2685
  if (layout.store.length === 0) {
2696
2686
  return [];
@@ -2710,48 +2700,35 @@ function generateRichCaptionDrawOps(asset, layout, frameTimeMs, layoutEngine, co
2710
2700
  fontConfig.size
2711
2701
  );
2712
2702
  const ops = [];
2713
- const activeGroup = layout.groups.find(
2714
- (g) => frameTimeMs >= g.startTime && frameTimeMs <= g.endTime
2715
- );
2716
- if (activeGroup && activeGroup.lines.length > 0) {
2717
- const padding = extractCaptionPadding(asset);
2718
- const { bgX, bgY, bgWidth, bgHeight } = calculateGroupBounds(
2719
- activeGroup,
2720
- padding,
2721
- config.frameWidth,
2722
- activeScale,
2723
- fontConfig.size
2724
- );
2725
- const captionBg = extractCaptionBackground(asset);
2726
- if (captionBg) {
2727
- ops.push({
2728
- op: "DrawCaptionBackground",
2729
- x: bgX,
2730
- y: bgY,
2731
- width: bgWidth,
2732
- height: bgHeight,
2733
- color: captionBg.color,
2734
- opacity: captionBg.opacity,
2735
- borderRadius: captionBg.borderRadius
2736
- });
2737
- }
2738
- const borderConfig = extractCaptionBorder(asset);
2739
- if (borderConfig) {
2740
- const halfBorder = borderConfig.width / 2;
2741
- ops.push({
2742
- op: "RectangleStroke",
2743
- x: bgX + halfBorder,
2744
- y: bgY + halfBorder,
2745
- width: bgWidth - borderConfig.width,
2746
- height: bgHeight - borderConfig.width,
2747
- stroke: {
2748
- width: borderConfig.width,
2749
- color: borderConfig.color,
2750
- opacity: borderConfig.opacity
2751
- },
2752
- borderRadius: borderConfig.radius > 0 ? borderConfig.radius : void 0
2753
- });
2754
- }
2703
+ const captionBg = extractCaptionBackground(asset);
2704
+ if (captionBg) {
2705
+ ops.push({
2706
+ op: "DrawCaptionBackground",
2707
+ x: 0,
2708
+ y: 0,
2709
+ width: config.frameWidth,
2710
+ height: config.frameHeight,
2711
+ color: captionBg.color,
2712
+ opacity: captionBg.opacity,
2713
+ borderRadius: captionBg.borderRadius
2714
+ });
2715
+ }
2716
+ const borderConfig = extractCaptionBorder(asset);
2717
+ if (borderConfig) {
2718
+ const halfBorder = borderConfig.width / 2;
2719
+ ops.push({
2720
+ op: "RectangleStroke",
2721
+ x: halfBorder,
2722
+ y: halfBorder,
2723
+ width: config.frameWidth - borderConfig.width,
2724
+ height: config.frameHeight - borderConfig.width,
2725
+ stroke: {
2726
+ width: borderConfig.width,
2727
+ color: borderConfig.color,
2728
+ opacity: borderConfig.opacity
2729
+ },
2730
+ borderRadius: borderConfig.radius > 0 ? borderConfig.radius : void 0
2731
+ });
2755
2732
  }
2756
2733
  const nonActiveOps = [];
2757
2734
  const activeOps = [];
@@ -3098,40 +3075,67 @@ async function createNodePainter(opts) {
3098
3075
  i++;
3099
3076
  }
3100
3077
  renderToBoth((context) => {
3101
- for (const wordOp of captionWordOps) {
3102
- if (!wordOp.background) continue;
3078
+ const bgSortedOps = [...captionWordOps].sort((a, b) => {
3079
+ const dy = Math.round(a.y) - Math.round(b.y);
3080
+ if (dy !== 0) return dy;
3081
+ return a.x + a.transform.translateX - (b.x + b.transform.translateX);
3082
+ });
3083
+ let bgIdx = 0;
3084
+ while (bgIdx < bgSortedOps.length) {
3085
+ const wordOp = bgSortedOps[bgIdx];
3086
+ if (!wordOp.background) {
3087
+ bgIdx++;
3088
+ continue;
3089
+ }
3103
3090
  const wordDisplayText = getVisibleText(wordOp.text, wordOp.visibleCharacters, wordOp.isRTL);
3104
- if (wordDisplayText.length === 0) continue;
3091
+ if (wordDisplayText.length === 0) {
3092
+ bgIdx++;
3093
+ continue;
3094
+ }
3095
+ const mergeGroup = [wordOp];
3096
+ let nextIdx = bgIdx + 1;
3097
+ while (nextIdx < bgSortedOps.length) {
3098
+ const nextWord = bgSortedOps[nextIdx];
3099
+ if (!nextWord.background) break;
3100
+ const nextDisplay = getVisibleText(nextWord.text, nextWord.visibleCharacters, nextWord.isRTL);
3101
+ if (nextDisplay.length === 0) break;
3102
+ if (Math.round(nextWord.y) !== Math.round(wordOp.y)) break;
3103
+ mergeGroup.push(nextWord);
3104
+ nextIdx++;
3105
+ }
3106
+ const firstWord = mergeGroup[0];
3107
+ const lastWord = mergeGroup[mergeGroup.length - 1];
3108
+ const firstBg = firstWord.background;
3109
+ const mergedLeft = firstWord.x + firstWord.transform.translateX;
3110
+ const mergedRight = lastWord.x + lastWord.transform.translateX + lastWord.width;
3111
+ const mergedWidth = mergedRight - mergedLeft;
3105
3112
  context.save();
3106
- const bgTx = Math.round(wordOp.x + wordOp.transform.translateX);
3107
- const bgTy = Math.round(wordOp.y + wordOp.transform.translateY);
3113
+ const bgTx = Math.round(mergedLeft);
3114
+ const bgTy = Math.round(firstWord.y + firstWord.transform.translateY);
3108
3115
  context.translate(bgTx, bgTy);
3109
- if (wordOp.transform.scale !== 1) {
3110
- const halfWidth = wordOp.width / 2;
3116
+ if (firstWord.transform.scale !== 1) {
3117
+ const halfWidth = mergedWidth / 2;
3111
3118
  context.translate(halfWidth, 0);
3112
- context.scale(wordOp.transform.scale, wordOp.transform.scale);
3119
+ context.scale(firstWord.transform.scale, firstWord.transform.scale);
3113
3120
  context.translate(-halfWidth, 0);
3114
3121
  }
3115
- context.globalAlpha = wordOp.transform.opacity;
3116
- context.font = `${wordOp.fontWeight} ${wordOp.fontSize}px "${wordOp.fontFamily}"`;
3117
- context.textBaseline = "alphabetic";
3118
- if (wordOp.letterSpacing) {
3119
- context.letterSpacing = `${wordOp.letterSpacing}px`;
3120
- }
3121
- const bgTextWidth = wordOp.width;
3122
- const bgAscent = wordOp.fontSize * ASCENT_RATIO;
3123
- const bgDescent = wordOp.fontSize * DESCENT_RATIO;
3122
+ context.globalAlpha = firstWord.transform.opacity;
3123
+ const bgAscent = firstWord.fontSize * ASCENT_RATIO;
3124
+ const bgDescent = firstWord.fontSize * DESCENT_RATIO;
3124
3125
  const bgTextHeight = bgAscent + bgDescent;
3125
- const bgX = -wordOp.background.padding;
3126
- const bgY = -bgAscent - wordOp.background.padding;
3127
- const bgW = bgTextWidth + wordOp.background.padding * 2;
3128
- const bgH = bgTextHeight + wordOp.background.padding * 2;
3129
- const bgC = parseHex6(wordOp.background.color, wordOp.background.opacity);
3130
- context.fillStyle = `rgba(${bgC.r},${bgC.g},${bgC.b},${bgC.a})`;
3131
- context.beginPath();
3132
- roundRectPath(context, bgX, bgY, bgW, bgH, wordOp.background.borderRadius);
3133
- context.fill();
3126
+ const bgX = -firstBg.padding;
3127
+ const bgY = -bgAscent - firstBg.padding;
3128
+ const bgW = mergedWidth + firstBg.padding * 2;
3129
+ const bgH = bgTextHeight + firstBg.padding * 2;
3130
+ if (bgW > 0 && bgH > 0) {
3131
+ const bgC = parseHex6(firstBg.color, firstBg.opacity);
3132
+ context.fillStyle = `rgba(${bgC.r},${bgC.g},${bgC.b},${bgC.a})`;
3133
+ context.beginPath();
3134
+ roundRectPath(context, bgX, bgY, bgW, bgH, firstBg.borderRadius);
3135
+ context.fill();
3136
+ }
3134
3137
  context.restore();
3138
+ bgIdx = nextIdx;
3135
3139
  }
3136
3140
  for (const wordOp of captionWordOps) {
3137
3141
  const displayText = getVisibleText(wordOp.text, wordOp.visibleCharacters, wordOp.isRTL);
@@ -3426,8 +3430,8 @@ function computePathBounds2(d) {
3426
3430
  };
3427
3431
  }
3428
3432
  function roundRectPath(ctx, x, y, w, h, r) {
3429
- const maxRadius = Math.min(w, h) / 2;
3430
- const radius = Math.min(r, maxRadius);
3433
+ const maxRadius = Math.max(0, Math.min(w, h) / 2);
3434
+ const radius = Math.max(0, Math.min(r, maxRadius));
3431
3435
  ctx.moveTo(x + radius, y);
3432
3436
  ctx.arcTo(x + w, y, x + w, y + h, radius);
3433
3437
  ctx.arcTo(x + w, y + h, x, y + h, radius);
@@ -4916,16 +4920,17 @@ var CaptionLayoutEngine = class {
4916
4920
  });
4917
4921
  const contentHeight = config.frameHeight - config.padding.top - config.padding.bottom;
4918
4922
  const contentWidth = config.frameWidth - config.padding.left - config.padding.right;
4923
+ const ASCENT_RATIO2 = 0.8;
4919
4924
  const calculateGroupY = (group) => {
4920
4925
  const totalHeight = group.lines.length * config.fontSize * config.lineHeight;
4921
4926
  switch (config.verticalAlign) {
4922
4927
  case "top":
4923
- return config.padding.top + config.fontSize * 1.5;
4928
+ return config.padding.top + config.fontSize * ASCENT_RATIO2;
4924
4929
  case "bottom":
4925
- return config.frameHeight - config.padding.bottom - totalHeight - config.fontSize * 0.5;
4930
+ return config.frameHeight - config.padding.bottom - totalHeight + config.fontSize * ASCENT_RATIO2;
4926
4931
  case "middle":
4927
4932
  default:
4928
- return config.padding.top + (contentHeight - totalHeight) / 2 + config.fontSize;
4933
+ return config.padding.top + (contentHeight - totalHeight) / 2 + config.fontSize * ASCENT_RATIO2;
4929
4934
  }
4930
4935
  };
4931
4936
  const allWordTexts = store.words.slice(0, store.length);
package/dist/entry.web.js CHANGED
@@ -33483,8 +33483,8 @@ async function buildDrawOps(p) {
33483
33483
  const canvasCenterY = p.canvas.height / 2;
33484
33484
  const bgX = canvasCenterX - contentWidth / 2;
33485
33485
  const bgY = canvasCenterY - contentHeight / 2;
33486
- const maxRadius = Math.min(contentWidth - borderWidth2, contentHeight - borderWidth2) / 2;
33487
- const outerRadius = Math.min(borderRadius, maxRadius);
33486
+ const maxRadius = Math.max(0, Math.min(contentWidth - borderWidth2, contentHeight - borderWidth2) / 2);
33487
+ const outerRadius = Math.max(0, Math.min(borderRadius, maxRadius));
33488
33488
  const innerRadius = Math.max(0, outerRadius - halfBorder);
33489
33489
  if (p.background?.color) {
33490
33490
  ops.push({
@@ -34474,10 +34474,11 @@ function calculateTypewriterState(ctx, charCount, speed) {
34474
34474
  isActive: isWordActive(ctx)
34475
34475
  };
34476
34476
  }
34477
- function calculateNoneState(ctx) {
34477
+ function calculateNoneState(_ctx) {
34478
34478
  return {
34479
34479
  opacity: 1,
34480
- isActive: isWordActive(ctx)
34480
+ isActive: false,
34481
+ fillProgress: 0
34481
34482
  };
34482
34483
  }
34483
34484
  function calculateWordAnimationState(wordStart, wordEnd, currentTime, config2, activeScale = 1, charCount = 0, fontSize = 48, isRTL = false) {
@@ -34520,7 +34521,7 @@ function calculateWordAnimationState(wordStart, wordEnd, currentTime, config2, a
34520
34521
  break;
34521
34522
  }
34522
34523
  const mergedState = { ...baseState, ...partialState };
34523
- if (mergedState.isActive && activeScale !== 1 && config2.style !== "pop") {
34524
+ if (mergedState.isActive && activeScale !== 1 && config2.style !== "pop" && config2.style !== "none") {
34524
34525
  mergedState.scale = activeScale;
34525
34526
  }
34526
34527
  return mergedState;
@@ -34582,7 +34583,10 @@ function extractStrokeConfig(asset, isActive) {
34582
34583
  if (!baseStroke && !activeStroke) {
34583
34584
  return void 0;
34584
34585
  }
34585
- if (isActive && activeStroke) {
34586
+ if (isActive) {
34587
+ if (!activeStroke) {
34588
+ return void 0;
34589
+ }
34586
34590
  return {
34587
34591
  width: activeStroke.width ?? baseStroke?.width ?? 0,
34588
34592
  color: activeStroke.color ?? baseStroke?.color ?? "#000000",
@@ -34598,7 +34602,10 @@ function extractStrokeConfig(asset, isActive) {
34598
34602
  }
34599
34603
  return void 0;
34600
34604
  }
34601
- function extractShadowConfig(asset) {
34605
+ function extractShadowConfig(asset, isActive) {
34606
+ if (isActive) {
34607
+ return void 0;
34608
+ }
34602
34609
  const shadow = asset.shadow;
34603
34610
  if (!shadow) {
34604
34611
  return void 0;
@@ -34705,27 +34712,10 @@ function createDrawCaptionWordOp(word, animState, asset, fontConfig) {
34705
34712
  visibleCharacters: animState.visibleCharacters,
34706
34713
  letterSpacing: fontConfig.letterSpacing > 0 ? fontConfig.letterSpacing : void 0,
34707
34714
  stroke: extractStrokeConfig(asset, isActive),
34708
- shadow: extractShadowConfig(asset),
34715
+ shadow: extractShadowConfig(asset, isActive),
34709
34716
  background: extractBackgroundConfig(asset, isActive, fontConfig.size)
34710
34717
  };
34711
34718
  }
34712
- function calculateGroupBounds(activeGroup, padding, frameWidth, activeScale, fontSize) {
34713
- let minY = Infinity;
34714
- let maxY = -Infinity;
34715
- for (const line of activeGroup.lines) {
34716
- const lineY = line.y - line.height * ASCENT_RATIO;
34717
- const lineBottom = line.y + line.height * DESCENT_RATIO;
34718
- if (lineY < minY) minY = lineY;
34719
- if (lineBottom > maxY) maxY = lineBottom;
34720
- }
34721
- const scaleExpansion = activeScale > 1 ? (activeScale - 1) * fontSize : 0;
34722
- return {
34723
- bgX: 0,
34724
- bgY: minY - padding.top - scaleExpansion,
34725
- bgWidth: frameWidth,
34726
- bgHeight: maxY - minY + padding.top + padding.bottom + scaleExpansion * 2
34727
- };
34728
- }
34729
34719
  function generateRichCaptionDrawOps(asset, layout, frameTimeMs, layoutEngine, config2) {
34730
34720
  if (layout.store.length === 0) {
34731
34721
  return [];
@@ -34745,48 +34735,35 @@ function generateRichCaptionDrawOps(asset, layout, frameTimeMs, layoutEngine, co
34745
34735
  fontConfig.size
34746
34736
  );
34747
34737
  const ops = [];
34748
- const activeGroup = layout.groups.find(
34749
- (g) => frameTimeMs >= g.startTime && frameTimeMs <= g.endTime
34750
- );
34751
- if (activeGroup && activeGroup.lines.length > 0) {
34752
- const padding = extractCaptionPadding(asset);
34753
- const { bgX, bgY, bgWidth, bgHeight } = calculateGroupBounds(
34754
- activeGroup,
34755
- padding,
34756
- config2.frameWidth,
34757
- activeScale,
34758
- fontConfig.size
34759
- );
34760
- const captionBg = extractCaptionBackground(asset);
34761
- if (captionBg) {
34762
- ops.push({
34763
- op: "DrawCaptionBackground",
34764
- x: bgX,
34765
- y: bgY,
34766
- width: bgWidth,
34767
- height: bgHeight,
34768
- color: captionBg.color,
34769
- opacity: captionBg.opacity,
34770
- borderRadius: captionBg.borderRadius
34771
- });
34772
- }
34773
- const borderConfig = extractCaptionBorder(asset);
34774
- if (borderConfig) {
34775
- const halfBorder = borderConfig.width / 2;
34776
- ops.push({
34777
- op: "RectangleStroke",
34778
- x: bgX + halfBorder,
34779
- y: bgY + halfBorder,
34780
- width: bgWidth - borderConfig.width,
34781
- height: bgHeight - borderConfig.width,
34782
- stroke: {
34783
- width: borderConfig.width,
34784
- color: borderConfig.color,
34785
- opacity: borderConfig.opacity
34786
- },
34787
- borderRadius: borderConfig.radius > 0 ? borderConfig.radius : void 0
34788
- });
34789
- }
34738
+ const captionBg = extractCaptionBackground(asset);
34739
+ if (captionBg) {
34740
+ ops.push({
34741
+ op: "DrawCaptionBackground",
34742
+ x: 0,
34743
+ y: 0,
34744
+ width: config2.frameWidth,
34745
+ height: config2.frameHeight,
34746
+ color: captionBg.color,
34747
+ opacity: captionBg.opacity,
34748
+ borderRadius: captionBg.borderRadius
34749
+ });
34750
+ }
34751
+ const borderConfig = extractCaptionBorder(asset);
34752
+ if (borderConfig) {
34753
+ const halfBorder = borderConfig.width / 2;
34754
+ ops.push({
34755
+ op: "RectangleStroke",
34756
+ x: halfBorder,
34757
+ y: halfBorder,
34758
+ width: config2.frameWidth - borderConfig.width,
34759
+ height: config2.frameHeight - borderConfig.width,
34760
+ stroke: {
34761
+ width: borderConfig.width,
34762
+ color: borderConfig.color,
34763
+ opacity: borderConfig.opacity
34764
+ },
34765
+ borderRadius: borderConfig.radius > 0 ? borderConfig.radius : void 0
34766
+ });
34790
34767
  }
34791
34768
  const nonActiveOps = [];
34792
34769
  const activeOps = [];
@@ -34960,8 +34937,8 @@ function createWebPainter(canvas) {
34960
34937
  const y2 = op.y;
34961
34938
  const w = op.width;
34962
34939
  const h = op.height;
34963
- const maxRadius = Math.min(w, h) / 2;
34964
- const r = Math.min(op.borderRadius, maxRadius);
34940
+ const maxRadius = Math.max(0, Math.min(w, h) / 2);
34941
+ const r = Math.max(0, Math.min(op.borderRadius, maxRadius));
34965
34942
  p.moveTo(x2 + r, y2);
34966
34943
  p.arcTo(x2 + w, y2, x2 + w, y2 + h, r);
34967
34944
  p.arcTo(x2 + w, y2 + h, x2, y2 + h, r);
@@ -35066,38 +35043,65 @@ function createWebPainter(canvas) {
35066
35043
  captionWordOps.push(nextOp);
35067
35044
  i++;
35068
35045
  }
35069
- for (const wordOp of captionWordOps) {
35070
- if (!wordOp.background) continue;
35046
+ const bgSortedOps = [...captionWordOps].sort((a, b) => {
35047
+ const dy = Math.round(a.y) - Math.round(b.y);
35048
+ if (dy !== 0) return dy;
35049
+ return a.x + a.transform.translateX - (b.x + b.transform.translateX);
35050
+ });
35051
+ let bgIdx = 0;
35052
+ while (bgIdx < bgSortedOps.length) {
35053
+ const wordOp = bgSortedOps[bgIdx];
35054
+ if (!wordOp.background) {
35055
+ bgIdx++;
35056
+ continue;
35057
+ }
35071
35058
  const wordDisplayText = getVisibleText(wordOp.text, wordOp.visibleCharacters, wordOp.isRTL);
35072
- if (wordDisplayText.length === 0) continue;
35059
+ if (wordDisplayText.length === 0) {
35060
+ bgIdx++;
35061
+ continue;
35062
+ }
35063
+ const mergeGroup = [wordOp];
35064
+ let nextIdx = bgIdx + 1;
35065
+ while (nextIdx < bgSortedOps.length) {
35066
+ const nextWord = bgSortedOps[nextIdx];
35067
+ if (!nextWord.background) break;
35068
+ const nextDisplay = getVisibleText(nextWord.text, nextWord.visibleCharacters, nextWord.isRTL);
35069
+ if (nextDisplay.length === 0) break;
35070
+ if (Math.round(nextWord.y) !== Math.round(wordOp.y)) break;
35071
+ mergeGroup.push(nextWord);
35072
+ nextIdx++;
35073
+ }
35074
+ const firstWord = mergeGroup[0];
35075
+ const lastWord = mergeGroup[mergeGroup.length - 1];
35076
+ const firstBg = firstWord.background;
35077
+ const mergedLeft = firstWord.x + firstWord.transform.translateX;
35078
+ const mergedRight = lastWord.x + lastWord.transform.translateX + lastWord.width;
35079
+ const mergedWidth = mergedRight - mergedLeft;
35073
35080
  ctx.save();
35074
- const bgTx = Math.round(wordOp.x + wordOp.transform.translateX);
35075
- const bgTy = Math.round(wordOp.y + wordOp.transform.translateY);
35081
+ const bgTx = Math.round(mergedLeft);
35082
+ const bgTy = Math.round(firstWord.y + firstWord.transform.translateY);
35076
35083
  ctx.translate(bgTx, bgTy);
35077
- if (wordOp.transform.scale !== 1) {
35078
- const halfWidth = wordOp.width / 2;
35084
+ if (firstWord.transform.scale !== 1) {
35085
+ const halfWidth = mergedWidth / 2;
35079
35086
  ctx.translate(halfWidth, 0);
35080
- ctx.scale(wordOp.transform.scale, wordOp.transform.scale);
35087
+ ctx.scale(firstWord.transform.scale, firstWord.transform.scale);
35081
35088
  ctx.translate(-halfWidth, 0);
35082
35089
  }
35083
- ctx.globalAlpha = wordOp.transform.opacity;
35084
- ctx.font = `${wordOp.fontWeight} ${wordOp.fontSize}px "${wordOp.fontFamily}"`;
35085
- ctx.textBaseline = "alphabetic";
35086
- if (wordOp.letterSpacing) {
35087
- ctx.letterSpacing = `${wordOp.letterSpacing}px`;
35088
- }
35089
- const bgTextWidth = wordOp.width;
35090
- const bgAscent = wordOp.fontSize * ASCENT_RATIO;
35091
- const bgDescent = wordOp.fontSize * DESCENT_RATIO;
35090
+ ctx.globalAlpha = firstWord.transform.opacity;
35091
+ const bgAscent = firstWord.fontSize * ASCENT_RATIO;
35092
+ const bgDescent = firstWord.fontSize * DESCENT_RATIO;
35092
35093
  const bgTextHeight = bgAscent + bgDescent;
35093
- const bgX = -wordOp.background.padding;
35094
- const bgY = -bgAscent - wordOp.background.padding;
35095
- const bgW = bgTextWidth + wordOp.background.padding * 2;
35096
- const bgH = bgTextHeight + wordOp.background.padding * 2;
35097
- const bgC = parseHex6(wordOp.background.color, wordOp.background.opacity);
35098
- ctx.fillStyle = `rgba(${bgC.r},${bgC.g},${bgC.b},${bgC.a})`;
35099
- drawRoundedRect(ctx, bgX, bgY, bgW, bgH, wordOp.background.borderRadius);
35094
+ const bgX = -firstBg.padding;
35095
+ const bgY = -bgAscent - firstBg.padding;
35096
+ const bgW = mergedWidth + firstBg.padding * 2;
35097
+ const bgH = bgTextHeight + firstBg.padding * 2;
35098
+ if (bgW > 0 && bgH > 0) {
35099
+ const bgC = parseHex6(firstBg.color, firstBg.opacity);
35100
+ ctx.fillStyle = `rgba(${bgC.r},${bgC.g},${bgC.b},${bgC.a})`;
35101
+ drawRoundedRect(ctx, bgX, bgY, bgW, bgH, firstBg.borderRadius);
35102
+ }
35100
35103
  ctx.restore();
35104
+ bgIdx = nextIdx;
35101
35105
  }
35102
35106
  for (const wordOp of captionWordOps) {
35103
35107
  const displayText = getVisibleText(wordOp.text, wordOp.visibleCharacters, wordOp.isRTL);
@@ -35197,8 +35201,8 @@ function createWebPainter(canvas) {
35197
35201
  };
35198
35202
  }
35199
35203
  function drawRoundedRect(ctx, x2, y2, w, h, r) {
35200
- const maxRadius = Math.min(w, h) / 2;
35201
- const radius = Math.min(r, maxRadius);
35204
+ const maxRadius = Math.max(0, Math.min(w, h) / 2);
35205
+ const radius = Math.max(0, Math.min(r, maxRadius));
35202
35206
  const p = new Path2D();
35203
35207
  p.moveTo(x2 + radius, y2);
35204
35208
  p.arcTo(x2 + w, y2, x2 + w, y2 + h, radius);
@@ -37222,16 +37226,17 @@ var CaptionLayoutEngine = class {
37222
37226
  });
37223
37227
  const contentHeight = config2.frameHeight - config2.padding.top - config2.padding.bottom;
37224
37228
  const contentWidth = config2.frameWidth - config2.padding.left - config2.padding.right;
37229
+ const ASCENT_RATIO2 = 0.8;
37225
37230
  const calculateGroupY = (group) => {
37226
37231
  const totalHeight = group.lines.length * config2.fontSize * config2.lineHeight;
37227
37232
  switch (config2.verticalAlign) {
37228
37233
  case "top":
37229
- return config2.padding.top + config2.fontSize * 1.5;
37234
+ return config2.padding.top + config2.fontSize * ASCENT_RATIO2;
37230
37235
  case "bottom":
37231
- return config2.frameHeight - config2.padding.bottom - totalHeight - config2.fontSize * 0.5;
37236
+ return config2.frameHeight - config2.padding.bottom - totalHeight + config2.fontSize * ASCENT_RATIO2;
37232
37237
  case "middle":
37233
37238
  default:
37234
- return config2.padding.top + (contentHeight - totalHeight) / 2 + config2.fontSize;
37239
+ return config2.padding.top + (contentHeight - totalHeight) / 2 + config2.fontSize * ASCENT_RATIO2;
37235
37240
  }
37236
37241
  };
37237
37242
  const allWordTexts = store.words.slice(0, store.length);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shotstack/shotstack-canvas",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "Text layout & animation engine (HarfBuzz) for Node & Web - fully self-contained.",
5
5
  "type": "module",
6
6
  "main": "./dist/entry.node.cjs",
@@ -34,8 +34,11 @@
34
34
  "test:caption-web": "vite dev examples/caption-tests",
35
35
  "test:padding-node": "node examples/captionpaddingtests/node-test.mjs",
36
36
  "test:padding-web": "vite dev examples/captionpaddingtests",
37
+ "test:fixes-node": "node examples/caption-fix-tests/node-test.mjs",
38
+ "test:fixes-web": "vite dev examples/caption-fix-tests",
37
39
  "prepublishOnly": "node scripts/publish-guard.cjs && pnpm build",
38
- "test": "node --test tests/build-verify.mjs"
40
+ "test": "node --test tests/build-verify.mjs",
41
+ "test:logic": "node --test tests/caption-logic.mjs"
39
42
  },
40
43
  "publishConfig": {
41
44
  "access": "public",