@shotstack/shotstack-canvas 1.5.5 → 1.5.7

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.
@@ -100,7 +100,8 @@ var fontSchema = import_joi.default.object({
100
100
  size: import_joi.default.number().min(CANVAS_CONFIG.LIMITS.minFontSize).max(CANVAS_CONFIG.LIMITS.maxFontSize).default(CANVAS_CONFIG.DEFAULTS.fontSize),
101
101
  weight: import_joi.default.alternatives().try(import_joi.default.string(), import_joi.default.number()).default("400"),
102
102
  color: import_joi.default.string().pattern(HEX6).default(CANVAS_CONFIG.DEFAULTS.color),
103
- opacity: import_joi.default.number().min(0).max(1).default(1)
103
+ opacity: import_joi.default.number().min(0).max(1).default(1),
104
+ background: import_joi.default.string().pattern(HEX6).optional()
104
105
  }).unknown(false);
105
106
  var styleSchema = import_joi.default.object({
106
107
  letterSpacing: import_joi.default.number().default(0),
@@ -136,15 +137,11 @@ var borderSchema = import_joi.default.object({
136
137
  width: import_joi.default.number().min(0).default(0),
137
138
  color: import_joi.default.string().pattern(HEX6).default("#000000"),
138
139
  opacity: import_joi.default.number().min(0).max(1).default(1),
139
- borderRadius: import_joi.default.number().min(0).optional()
140
+ radius: import_joi.default.number().min(0).default(0)
140
141
  }).unknown(false);
141
142
  var backgroundSchema = import_joi.default.object({
142
143
  color: import_joi.default.string().pattern(HEX6).optional(),
143
- opacity: import_joi.default.number().min(0).max(1).default(1),
144
- backgroundRadius: import_joi.default.number().min(0).default(0),
145
- borderRadius: import_joi.default.number().min(0).optional(),
146
- // Deprecated: use backgroundRadius
147
- border: borderSchema.optional()
144
+ opacity: import_joi.default.number().min(0).max(1).default(1)
148
145
  }).unknown(false);
149
146
  var paddingSchema = import_joi.default.alternatives().try(
150
147
  import_joi.default.number().min(0).default(0),
@@ -172,6 +169,7 @@ var RichTextAssetSchema = import_joi.default.object({
172
169
  stroke: strokeSchema.optional(),
173
170
  shadow: shadowSchema.optional(),
174
171
  background: backgroundSchema.optional(),
172
+ border: borderSchema.optional(),
175
173
  padding: paddingSchema.optional(),
176
174
  align: alignmentSchema.optional(),
177
175
  animation: animationSchema.optional(),
@@ -857,7 +855,7 @@ function normalizePadding(padding) {
857
855
  async function buildDrawOps(p) {
858
856
  const ops = [];
859
857
  const padding = normalizePadding(p.padding);
860
- const borderWidth = p.background?.border?.width ?? 0;
858
+ const borderWidth = p.border?.width ?? 0;
861
859
  ops.push({
862
860
  op: "BeginFrame",
863
861
  width: p.canvas.width,
@@ -890,6 +888,7 @@ async function buildDrawOps(p) {
890
888
  const fill = p.style.gradient ? gradientSpecFrom(p.style.gradient, 1) : { kind: "solid", color: p.font.color, opacity: p.font.opacity };
891
889
  const decoColor = p.style.gradient ? p.style.gradient.stops[p.style.gradient.stops.length - 1]?.color ?? p.font.color : p.font.color;
892
890
  const textOps = [];
891
+ const highlighterOps = [];
893
892
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
894
893
  for (const line of p.lines) {
895
894
  let lineX;
@@ -908,6 +907,20 @@ async function buildDrawOps(p) {
908
907
  let xCursor = lineX;
909
908
  const lineIndex = p.lines.indexOf(line);
910
909
  const baselineY = blockY + lineIndex * lineHeightPx;
910
+ if (p.font.background) {
911
+ const highlightPadding = p.font.size * 0.15;
912
+ const verticalPadding = p.font.size * 0.12;
913
+ const highlightHeight = p.font.size * 0.92 + verticalPadding * 2;
914
+ const highlightY = baselineY - p.font.size * 0.78 - verticalPadding;
915
+ highlighterOps.push({
916
+ op: "Rectangle",
917
+ x: lineX - highlightPadding,
918
+ y: highlightY,
919
+ width: line.width + highlightPadding * 2,
920
+ height: highlightHeight,
921
+ fill: { kind: "solid", color: p.font.background, opacity: 1 }
922
+ });
923
+ }
911
924
  for (const glyph of line.glyphs) {
912
925
  const path = await p.glyphPathProvider(glyph.id, glyph.fontDesc);
913
926
  if (!path || path === "M 0 0") {
@@ -983,43 +996,45 @@ async function buildDrawOps(p) {
983
996
  }
984
997
  }
985
998
  }
986
- if (p.background && (p.background.color || p.background.border)) {
999
+ if (p.background || p.border) {
987
1000
  const bgX = 0;
988
1001
  const bgY = 0;
989
1002
  const bgWidth = p.canvas.width;
990
1003
  const bgHeight = p.canvas.height;
991
- const backgroundRadius = p.background.backgroundRadius ?? p.background.borderRadius ?? 0;
992
- const halfBorder = p.background.border?.width ? p.background.border.width / 2 : 0;
993
- if (p.background.color) {
994
- const bgInset = halfBorder;
995
- const bgFillRadius = Math.max(0, backgroundRadius - bgInset);
1004
+ const borderWidth2 = p.border?.width ?? 0;
1005
+ const borderRadius = p.border?.radius ?? 0;
1006
+ const halfBorder = borderWidth2 / 2;
1007
+ const maxRadius = Math.min(bgWidth - borderWidth2, bgHeight - borderWidth2) / 2;
1008
+ const outerRadius = Math.min(borderRadius, maxRadius);
1009
+ const innerRadius = Math.max(0, outerRadius - halfBorder);
1010
+ if (p.background?.color) {
996
1011
  ops.push({
997
1012
  op: "Rectangle",
998
- x: bgX + bgInset,
999
- y: bgY + bgInset,
1000
- width: bgWidth - bgInset * 2,
1001
- height: bgHeight - bgInset * 2,
1013
+ x: bgX + borderWidth2,
1014
+ y: bgY + borderWidth2,
1015
+ width: bgWidth - borderWidth2 * 2,
1016
+ height: bgHeight - borderWidth2 * 2,
1002
1017
  fill: { kind: "solid", color: p.background.color, opacity: p.background.opacity },
1003
- borderRadius: bgFillRadius
1018
+ borderRadius: innerRadius
1004
1019
  });
1005
1020
  }
1006
- if (p.background.border && p.background.border.width > 0) {
1007
- const borderRadius = p.background.border.borderRadius !== void 0 ? p.background.border.borderRadius : backgroundRadius;
1021
+ if (p.border && p.border.width > 0) {
1008
1022
  ops.push({
1009
1023
  op: "RectangleStroke",
1010
- x: bgX,
1011
- y: bgY,
1012
- width: bgWidth,
1013
- height: bgHeight,
1024
+ x: bgX + halfBorder,
1025
+ y: bgY + halfBorder,
1026
+ width: bgWidth - borderWidth2,
1027
+ height: bgHeight - borderWidth2,
1014
1028
  stroke: {
1015
- width: p.background.border.width,
1016
- color: p.background.border.color,
1017
- opacity: p.background.border.opacity
1029
+ width: p.border.width,
1030
+ color: p.border.color,
1031
+ opacity: p.border.opacity
1018
1032
  },
1019
- borderRadius
1033
+ borderRadius: outerRadius
1020
1034
  });
1021
1035
  }
1022
1036
  }
1037
+ ops.push(...highlighterOps);
1023
1038
  ops.push(...textOps);
1024
1039
  return ops;
1025
1040
  }
@@ -1147,7 +1162,9 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1147
1162
  const wordSegments = getWordSegments(lines);
1148
1163
  const totalWords = wordSegments.length;
1149
1164
  const visibleWords = Math.floor(progress * totalWords);
1150
- if (visibleWords === 0) return ops.filter((x) => x.op === "BeginFrame");
1165
+ if (visibleWords === 0) {
1166
+ return ops.filter((x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke");
1167
+ }
1151
1168
  let totalVisibleGlyphs = 0;
1152
1169
  for (let i = 0; i < Math.min(visibleWords, wordSegments.length); i++) {
1153
1170
  totalVisibleGlyphs += wordSegments[i].glyphCount;
@@ -1161,7 +1178,9 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1161
1178
  } else {
1162
1179
  const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1163
1180
  const visibleGlyphs = Math.floor(progress * totalGlyphs);
1164
- if (visibleGlyphs === 0) return ops.filter((x) => x.op === "BeginFrame");
1181
+ if (visibleGlyphs === 0) {
1182
+ return ops.filter((x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke");
1183
+ }
1165
1184
  const visibleOpsRaw = sliceGlyphOps(ops, visibleGlyphs);
1166
1185
  const visibleOps = progress >= DECORATION_DONE_THRESHOLD ? visibleOpsRaw : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
1167
1186
  if (progress < 1 && visibleGlyphs > 0) {
@@ -1177,8 +1196,10 @@ function applyAscendAnimation(ops, lines, progress, direction, fontSize, duratio
1177
1196
  const result = [];
1178
1197
  let glyphIndex = 0;
1179
1198
  for (const op of ops) {
1180
- if (op.op === "BeginFrame") {
1199
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1181
1200
  result.push(op);
1201
+ }
1202
+ if (op.op === "FillPath" || op.op === "StrokePath") {
1182
1203
  break;
1183
1204
  }
1184
1205
  }
@@ -1237,8 +1258,10 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1237
1258
  if (totalUnits === 0) return ops;
1238
1259
  const result = [];
1239
1260
  for (const op of ops) {
1240
- if (op.op === "BeginFrame") {
1261
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1241
1262
  result.push(op);
1263
+ }
1264
+ if (op.op === "FillPath" || op.op === "StrokePath") {
1242
1265
  break;
1243
1266
  }
1244
1267
  }
@@ -1512,7 +1535,7 @@ function sliceGlyphOps(ops, maxGlyphs) {
1512
1535
  let glyphCount = 0;
1513
1536
  let foundGlyphs = false;
1514
1537
  for (const op of ops) {
1515
- if (op.op === "BeginFrame") {
1538
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1516
1539
  result.push(op);
1517
1540
  continue;
1518
1541
  }
@@ -1540,9 +1563,11 @@ function sliceGlyphOps(ops, maxGlyphs) {
1540
1563
  return result;
1541
1564
  }
1542
1565
  function addTypewriterCursor(ops, glyphCount, fontSize, time) {
1543
- const blinkRate = 2;
1566
+ if (glyphCount === 0) return ops;
1567
+ const blinkRate = 1;
1544
1568
  const cursorVisible = Math.floor(time * blinkRate * 2) % 2 === 0;
1545
- if (!cursorVisible || glyphCount === 0) return ops;
1569
+ const alwaysShowCursor = true;
1570
+ if (!alwaysShowCursor && !cursorVisible) return ops;
1546
1571
  let last = null;
1547
1572
  let count = 0;
1548
1573
  for (const op of ops) {
@@ -1558,13 +1583,16 @@ function addTypewriterCursor(ops, glyphCount, fontSize, time) {
1558
1583
  const color = getTextColorFromOps(ops);
1559
1584
  const cursorX = last.x + fontSize * 0.5;
1560
1585
  const cursorY = last.y;
1586
+ const cursorWidth = Math.max(3, fontSize / 15);
1561
1587
  const cursorOp = {
1562
1588
  op: "DecorationLine",
1563
- from: { x: cursorX, y: cursorY - fontSize * 0.7 },
1564
- to: { x: cursorX, y: cursorY + fontSize * 0.1 },
1565
- width: Math.max(2, fontSize / 25),
1589
+ from: { x: cursorX, y: cursorY - fontSize * 0.75 },
1590
+ // Slightly taller
1591
+ to: { x: cursorX, y: cursorY + fontSize * 0.15 },
1592
+ width: cursorWidth,
1566
1593
  color,
1567
- opacity: 1
1594
+ opacity: alwaysShowCursor ? 1 : cursorVisible ? 1 : 0
1595
+ // Always visible or blink
1568
1596
  };
1569
1597
  return [...ops, cursorOp];
1570
1598
  }
@@ -2454,7 +2482,8 @@ async function createTextEngine(opts = {}) {
2454
2482
  size: main.size,
2455
2483
  weight: `${main.weight}`,
2456
2484
  color: main.color,
2457
- opacity: main.opacity
2485
+ opacity: main.opacity,
2486
+ background: main.background
2458
2487
  },
2459
2488
  style: {
2460
2489
  lineHeight: asset.style?.lineHeight ?? 1.2,
@@ -2468,6 +2497,7 @@ async function createTextEngine(opts = {}) {
2468
2497
  vertical: asset.align?.vertical ?? "middle"
2469
2498
  },
2470
2499
  background: asset.background,
2500
+ border: asset.border,
2471
2501
  padding: asset.padding,
2472
2502
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
2473
2503
  getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
@@ -2520,7 +2550,7 @@ async function createTextEngine(opts = {}) {
2520
2550
  try {
2521
2551
  const hasBackground = !!asset.background?.color;
2522
2552
  const hasAnimation = !!asset.animation?.preset;
2523
- const hasBorderRadius = (asset.background?.borderRadius ?? 0) > 0;
2553
+ const hasBorderRadius = (asset.border?.radius ?? 0) > 0;
2524
2554
  const needsAlpha = !hasBackground && hasAnimation || hasBorderRadius;
2525
2555
  console.log(
2526
2556
  `\u{1F3A8} Video settings: Animation=${hasAnimation}, Background=${hasBackground}, BorderRadius=${hasBorderRadius}, Alpha=${needsAlpha}`
@@ -33,6 +33,7 @@ type RichTextValidated = Required<{
33
33
  weight: string | number;
34
34
  color: string;
35
35
  opacity: number;
36
+ background?: string;
36
37
  };
37
38
  style?: {
38
39
  letterSpacing: number;
@@ -63,14 +64,12 @@ type RichTextValidated = Required<{
63
64
  background?: {
64
65
  color?: string;
65
66
  opacity: number;
66
- backgroundRadius: number;
67
- borderRadius?: number;
68
- border?: {
69
- width: number;
70
- color: string;
71
- opacity: number;
72
- borderRadius?: number;
73
- };
67
+ };
68
+ border?: {
69
+ width: number;
70
+ color: string;
71
+ opacity: number;
72
+ radius: number;
74
73
  };
75
74
  padding?: number | {
76
75
  top: number;
@@ -33,6 +33,7 @@ type RichTextValidated = Required<{
33
33
  weight: string | number;
34
34
  color: string;
35
35
  opacity: number;
36
+ background?: string;
36
37
  };
37
38
  style?: {
38
39
  letterSpacing: number;
@@ -63,14 +64,12 @@ type RichTextValidated = Required<{
63
64
  background?: {
64
65
  color?: string;
65
66
  opacity: number;
66
- backgroundRadius: number;
67
- borderRadius?: number;
68
- border?: {
69
- width: number;
70
- color: string;
71
- opacity: number;
72
- borderRadius?: number;
73
- };
67
+ };
68
+ border?: {
69
+ width: number;
70
+ color: string;
71
+ opacity: number;
72
+ radius: number;
74
73
  };
75
74
  padding?: number | {
76
75
  top: number;
@@ -62,7 +62,8 @@ var fontSchema = Joi.object({
62
62
  size: Joi.number().min(CANVAS_CONFIG.LIMITS.minFontSize).max(CANVAS_CONFIG.LIMITS.maxFontSize).default(CANVAS_CONFIG.DEFAULTS.fontSize),
63
63
  weight: Joi.alternatives().try(Joi.string(), Joi.number()).default("400"),
64
64
  color: Joi.string().pattern(HEX6).default(CANVAS_CONFIG.DEFAULTS.color),
65
- opacity: Joi.number().min(0).max(1).default(1)
65
+ opacity: Joi.number().min(0).max(1).default(1),
66
+ background: Joi.string().pattern(HEX6).optional()
66
67
  }).unknown(false);
67
68
  var styleSchema = Joi.object({
68
69
  letterSpacing: Joi.number().default(0),
@@ -98,15 +99,11 @@ var borderSchema = Joi.object({
98
99
  width: Joi.number().min(0).default(0),
99
100
  color: Joi.string().pattern(HEX6).default("#000000"),
100
101
  opacity: Joi.number().min(0).max(1).default(1),
101
- borderRadius: Joi.number().min(0).optional()
102
+ radius: Joi.number().min(0).default(0)
102
103
  }).unknown(false);
103
104
  var backgroundSchema = Joi.object({
104
105
  color: Joi.string().pattern(HEX6).optional(),
105
- opacity: Joi.number().min(0).max(1).default(1),
106
- backgroundRadius: Joi.number().min(0).default(0),
107
- borderRadius: Joi.number().min(0).optional(),
108
- // Deprecated: use backgroundRadius
109
- border: borderSchema.optional()
106
+ opacity: Joi.number().min(0).max(1).default(1)
110
107
  }).unknown(false);
111
108
  var paddingSchema = Joi.alternatives().try(
112
109
  Joi.number().min(0).default(0),
@@ -134,6 +131,7 @@ var RichTextAssetSchema = Joi.object({
134
131
  stroke: strokeSchema.optional(),
135
132
  shadow: shadowSchema.optional(),
136
133
  background: backgroundSchema.optional(),
134
+ border: borderSchema.optional(),
137
135
  padding: paddingSchema.optional(),
138
136
  align: alignmentSchema.optional(),
139
137
  animation: animationSchema.optional(),
@@ -818,7 +816,7 @@ function normalizePadding(padding) {
818
816
  async function buildDrawOps(p) {
819
817
  const ops = [];
820
818
  const padding = normalizePadding(p.padding);
821
- const borderWidth = p.background?.border?.width ?? 0;
819
+ const borderWidth = p.border?.width ?? 0;
822
820
  ops.push({
823
821
  op: "BeginFrame",
824
822
  width: p.canvas.width,
@@ -851,6 +849,7 @@ async function buildDrawOps(p) {
851
849
  const fill = p.style.gradient ? gradientSpecFrom(p.style.gradient, 1) : { kind: "solid", color: p.font.color, opacity: p.font.opacity };
852
850
  const decoColor = p.style.gradient ? p.style.gradient.stops[p.style.gradient.stops.length - 1]?.color ?? p.font.color : p.font.color;
853
851
  const textOps = [];
852
+ const highlighterOps = [];
854
853
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
855
854
  for (const line of p.lines) {
856
855
  let lineX;
@@ -869,6 +868,20 @@ async function buildDrawOps(p) {
869
868
  let xCursor = lineX;
870
869
  const lineIndex = p.lines.indexOf(line);
871
870
  const baselineY = blockY + lineIndex * lineHeightPx;
871
+ if (p.font.background) {
872
+ const highlightPadding = p.font.size * 0.15;
873
+ const verticalPadding = p.font.size * 0.12;
874
+ const highlightHeight = p.font.size * 0.92 + verticalPadding * 2;
875
+ const highlightY = baselineY - p.font.size * 0.78 - verticalPadding;
876
+ highlighterOps.push({
877
+ op: "Rectangle",
878
+ x: lineX - highlightPadding,
879
+ y: highlightY,
880
+ width: line.width + highlightPadding * 2,
881
+ height: highlightHeight,
882
+ fill: { kind: "solid", color: p.font.background, opacity: 1 }
883
+ });
884
+ }
872
885
  for (const glyph of line.glyphs) {
873
886
  const path = await p.glyphPathProvider(glyph.id, glyph.fontDesc);
874
887
  if (!path || path === "M 0 0") {
@@ -944,43 +957,45 @@ async function buildDrawOps(p) {
944
957
  }
945
958
  }
946
959
  }
947
- if (p.background && (p.background.color || p.background.border)) {
960
+ if (p.background || p.border) {
948
961
  const bgX = 0;
949
962
  const bgY = 0;
950
963
  const bgWidth = p.canvas.width;
951
964
  const bgHeight = p.canvas.height;
952
- const backgroundRadius = p.background.backgroundRadius ?? p.background.borderRadius ?? 0;
953
- const halfBorder = p.background.border?.width ? p.background.border.width / 2 : 0;
954
- if (p.background.color) {
955
- const bgInset = halfBorder;
956
- const bgFillRadius = Math.max(0, backgroundRadius - bgInset);
965
+ const borderWidth2 = p.border?.width ?? 0;
966
+ const borderRadius = p.border?.radius ?? 0;
967
+ const halfBorder = borderWidth2 / 2;
968
+ const maxRadius = Math.min(bgWidth - borderWidth2, bgHeight - borderWidth2) / 2;
969
+ const outerRadius = Math.min(borderRadius, maxRadius);
970
+ const innerRadius = Math.max(0, outerRadius - halfBorder);
971
+ if (p.background?.color) {
957
972
  ops.push({
958
973
  op: "Rectangle",
959
- x: bgX + bgInset,
960
- y: bgY + bgInset,
961
- width: bgWidth - bgInset * 2,
962
- height: bgHeight - bgInset * 2,
974
+ x: bgX + borderWidth2,
975
+ y: bgY + borderWidth2,
976
+ width: bgWidth - borderWidth2 * 2,
977
+ height: bgHeight - borderWidth2 * 2,
963
978
  fill: { kind: "solid", color: p.background.color, opacity: p.background.opacity },
964
- borderRadius: bgFillRadius
979
+ borderRadius: innerRadius
965
980
  });
966
981
  }
967
- if (p.background.border && p.background.border.width > 0) {
968
- const borderRadius = p.background.border.borderRadius !== void 0 ? p.background.border.borderRadius : backgroundRadius;
982
+ if (p.border && p.border.width > 0) {
969
983
  ops.push({
970
984
  op: "RectangleStroke",
971
- x: bgX,
972
- y: bgY,
973
- width: bgWidth,
974
- height: bgHeight,
985
+ x: bgX + halfBorder,
986
+ y: bgY + halfBorder,
987
+ width: bgWidth - borderWidth2,
988
+ height: bgHeight - borderWidth2,
975
989
  stroke: {
976
- width: p.background.border.width,
977
- color: p.background.border.color,
978
- opacity: p.background.border.opacity
990
+ width: p.border.width,
991
+ color: p.border.color,
992
+ opacity: p.border.opacity
979
993
  },
980
- borderRadius
994
+ borderRadius: outerRadius
981
995
  });
982
996
  }
983
997
  }
998
+ ops.push(...highlighterOps);
984
999
  ops.push(...textOps);
985
1000
  return ops;
986
1001
  }
@@ -1108,7 +1123,9 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1108
1123
  const wordSegments = getWordSegments(lines);
1109
1124
  const totalWords = wordSegments.length;
1110
1125
  const visibleWords = Math.floor(progress * totalWords);
1111
- if (visibleWords === 0) return ops.filter((x) => x.op === "BeginFrame");
1126
+ if (visibleWords === 0) {
1127
+ return ops.filter((x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke");
1128
+ }
1112
1129
  let totalVisibleGlyphs = 0;
1113
1130
  for (let i = 0; i < Math.min(visibleWords, wordSegments.length); i++) {
1114
1131
  totalVisibleGlyphs += wordSegments[i].glyphCount;
@@ -1122,7 +1139,9 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1122
1139
  } else {
1123
1140
  const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1124
1141
  const visibleGlyphs = Math.floor(progress * totalGlyphs);
1125
- if (visibleGlyphs === 0) return ops.filter((x) => x.op === "BeginFrame");
1142
+ if (visibleGlyphs === 0) {
1143
+ return ops.filter((x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke");
1144
+ }
1126
1145
  const visibleOpsRaw = sliceGlyphOps(ops, visibleGlyphs);
1127
1146
  const visibleOps = progress >= DECORATION_DONE_THRESHOLD ? visibleOpsRaw : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
1128
1147
  if (progress < 1 && visibleGlyphs > 0) {
@@ -1138,8 +1157,10 @@ function applyAscendAnimation(ops, lines, progress, direction, fontSize, duratio
1138
1157
  const result = [];
1139
1158
  let glyphIndex = 0;
1140
1159
  for (const op of ops) {
1141
- if (op.op === "BeginFrame") {
1160
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1142
1161
  result.push(op);
1162
+ }
1163
+ if (op.op === "FillPath" || op.op === "StrokePath") {
1143
1164
  break;
1144
1165
  }
1145
1166
  }
@@ -1198,8 +1219,10 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1198
1219
  if (totalUnits === 0) return ops;
1199
1220
  const result = [];
1200
1221
  for (const op of ops) {
1201
- if (op.op === "BeginFrame") {
1222
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1202
1223
  result.push(op);
1224
+ }
1225
+ if (op.op === "FillPath" || op.op === "StrokePath") {
1203
1226
  break;
1204
1227
  }
1205
1228
  }
@@ -1473,7 +1496,7 @@ function sliceGlyphOps(ops, maxGlyphs) {
1473
1496
  let glyphCount = 0;
1474
1497
  let foundGlyphs = false;
1475
1498
  for (const op of ops) {
1476
- if (op.op === "BeginFrame") {
1499
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1477
1500
  result.push(op);
1478
1501
  continue;
1479
1502
  }
@@ -1501,9 +1524,11 @@ function sliceGlyphOps(ops, maxGlyphs) {
1501
1524
  return result;
1502
1525
  }
1503
1526
  function addTypewriterCursor(ops, glyphCount, fontSize, time) {
1504
- const blinkRate = 2;
1527
+ if (glyphCount === 0) return ops;
1528
+ const blinkRate = 1;
1505
1529
  const cursorVisible = Math.floor(time * blinkRate * 2) % 2 === 0;
1506
- if (!cursorVisible || glyphCount === 0) return ops;
1530
+ const alwaysShowCursor = true;
1531
+ if (!alwaysShowCursor && !cursorVisible) return ops;
1507
1532
  let last = null;
1508
1533
  let count = 0;
1509
1534
  for (const op of ops) {
@@ -1519,13 +1544,16 @@ function addTypewriterCursor(ops, glyphCount, fontSize, time) {
1519
1544
  const color = getTextColorFromOps(ops);
1520
1545
  const cursorX = last.x + fontSize * 0.5;
1521
1546
  const cursorY = last.y;
1547
+ const cursorWidth = Math.max(3, fontSize / 15);
1522
1548
  const cursorOp = {
1523
1549
  op: "DecorationLine",
1524
- from: { x: cursorX, y: cursorY - fontSize * 0.7 },
1525
- to: { x: cursorX, y: cursorY + fontSize * 0.1 },
1526
- width: Math.max(2, fontSize / 25),
1550
+ from: { x: cursorX, y: cursorY - fontSize * 0.75 },
1551
+ // Slightly taller
1552
+ to: { x: cursorX, y: cursorY + fontSize * 0.15 },
1553
+ width: cursorWidth,
1527
1554
  color,
1528
- opacity: 1
1555
+ opacity: alwaysShowCursor ? 1 : cursorVisible ? 1 : 0
1556
+ // Always visible or blink
1529
1557
  };
1530
1558
  return [...ops, cursorOp];
1531
1559
  }
@@ -2415,7 +2443,8 @@ async function createTextEngine(opts = {}) {
2415
2443
  size: main.size,
2416
2444
  weight: `${main.weight}`,
2417
2445
  color: main.color,
2418
- opacity: main.opacity
2446
+ opacity: main.opacity,
2447
+ background: main.background
2419
2448
  },
2420
2449
  style: {
2421
2450
  lineHeight: asset.style?.lineHeight ?? 1.2,
@@ -2429,6 +2458,7 @@ async function createTextEngine(opts = {}) {
2429
2458
  vertical: asset.align?.vertical ?? "middle"
2430
2459
  },
2431
2460
  background: asset.background,
2461
+ border: asset.border,
2432
2462
  padding: asset.padding,
2433
2463
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
2434
2464
  getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
@@ -2481,7 +2511,7 @@ async function createTextEngine(opts = {}) {
2481
2511
  try {
2482
2512
  const hasBackground = !!asset.background?.color;
2483
2513
  const hasAnimation = !!asset.animation?.preset;
2484
- const hasBorderRadius = (asset.background?.borderRadius ?? 0) > 0;
2514
+ const hasBorderRadius = (asset.border?.radius ?? 0) > 0;
2485
2515
  const needsAlpha = !hasBackground && hasAnimation || hasBorderRadius;
2486
2516
  console.log(
2487
2517
  `\u{1F3A8} Video settings: Animation=${hasAnimation}, Background=${hasBackground}, BorderRadius=${hasBorderRadius}, Alpha=${needsAlpha}`
@@ -33,6 +33,7 @@ type RichTextValidated = Required<{
33
33
  weight: string | number;
34
34
  color: string;
35
35
  opacity: number;
36
+ background?: string;
36
37
  };
37
38
  style?: {
38
39
  letterSpacing: number;
@@ -63,14 +64,12 @@ type RichTextValidated = Required<{
63
64
  background?: {
64
65
  color?: string;
65
66
  opacity: number;
66
- backgroundRadius: number;
67
- borderRadius?: number;
68
- border?: {
69
- width: number;
70
- color: string;
71
- opacity: number;
72
- borderRadius?: number;
73
- };
67
+ };
68
+ border?: {
69
+ width: number;
70
+ color: string;
71
+ opacity: number;
72
+ radius: number;
74
73
  };
75
74
  padding?: number | {
76
75
  top: number;
package/dist/entry.web.js CHANGED
@@ -66,7 +66,8 @@ var fontSchema = Joi.object({
66
66
  size: Joi.number().min(CANVAS_CONFIG.LIMITS.minFontSize).max(CANVAS_CONFIG.LIMITS.maxFontSize).default(CANVAS_CONFIG.DEFAULTS.fontSize),
67
67
  weight: Joi.alternatives().try(Joi.string(), Joi.number()).default("400"),
68
68
  color: Joi.string().pattern(HEX6).default(CANVAS_CONFIG.DEFAULTS.color),
69
- opacity: Joi.number().min(0).max(1).default(1)
69
+ opacity: Joi.number().min(0).max(1).default(1),
70
+ background: Joi.string().pattern(HEX6).optional()
70
71
  }).unknown(false);
71
72
  var styleSchema = Joi.object({
72
73
  letterSpacing: Joi.number().default(0),
@@ -102,15 +103,11 @@ var borderSchema = Joi.object({
102
103
  width: Joi.number().min(0).default(0),
103
104
  color: Joi.string().pattern(HEX6).default("#000000"),
104
105
  opacity: Joi.number().min(0).max(1).default(1),
105
- borderRadius: Joi.number().min(0).optional()
106
+ radius: Joi.number().min(0).default(0)
106
107
  }).unknown(false);
107
108
  var backgroundSchema = Joi.object({
108
109
  color: Joi.string().pattern(HEX6).optional(),
109
- opacity: Joi.number().min(0).max(1).default(1),
110
- backgroundRadius: Joi.number().min(0).default(0),
111
- borderRadius: Joi.number().min(0).optional(),
112
- // Deprecated: use backgroundRadius
113
- border: borderSchema.optional()
110
+ opacity: Joi.number().min(0).max(1).default(1)
114
111
  }).unknown(false);
115
112
  var paddingSchema = Joi.alternatives().try(
116
113
  Joi.number().min(0).default(0),
@@ -138,6 +135,7 @@ var RichTextAssetSchema = Joi.object({
138
135
  stroke: strokeSchema.optional(),
139
136
  shadow: shadowSchema.optional(),
140
137
  background: backgroundSchema.optional(),
138
+ border: borderSchema.optional(),
141
139
  padding: paddingSchema.optional(),
142
140
  align: alignmentSchema.optional(),
143
141
  animation: animationSchema.optional(),
@@ -823,7 +821,7 @@ function normalizePadding(padding) {
823
821
  async function buildDrawOps(p) {
824
822
  const ops = [];
825
823
  const padding = normalizePadding(p.padding);
826
- const borderWidth = p.background?.border?.width ?? 0;
824
+ const borderWidth = p.border?.width ?? 0;
827
825
  ops.push({
828
826
  op: "BeginFrame",
829
827
  width: p.canvas.width,
@@ -856,6 +854,7 @@ async function buildDrawOps(p) {
856
854
  const fill = p.style.gradient ? gradientSpecFrom(p.style.gradient, 1) : { kind: "solid", color: p.font.color, opacity: p.font.opacity };
857
855
  const decoColor = p.style.gradient ? p.style.gradient.stops[p.style.gradient.stops.length - 1]?.color ?? p.font.color : p.font.color;
858
856
  const textOps = [];
857
+ const highlighterOps = [];
859
858
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
860
859
  for (const line of p.lines) {
861
860
  let lineX;
@@ -874,6 +873,20 @@ async function buildDrawOps(p) {
874
873
  let xCursor = lineX;
875
874
  const lineIndex = p.lines.indexOf(line);
876
875
  const baselineY = blockY + lineIndex * lineHeightPx;
876
+ if (p.font.background) {
877
+ const highlightPadding = p.font.size * 0.15;
878
+ const verticalPadding = p.font.size * 0.12;
879
+ const highlightHeight = p.font.size * 0.92 + verticalPadding * 2;
880
+ const highlightY = baselineY - p.font.size * 0.78 - verticalPadding;
881
+ highlighterOps.push({
882
+ op: "Rectangle",
883
+ x: lineX - highlightPadding,
884
+ y: highlightY,
885
+ width: line.width + highlightPadding * 2,
886
+ height: highlightHeight,
887
+ fill: { kind: "solid", color: p.font.background, opacity: 1 }
888
+ });
889
+ }
877
890
  for (const glyph of line.glyphs) {
878
891
  const path = await p.glyphPathProvider(glyph.id, glyph.fontDesc);
879
892
  if (!path || path === "M 0 0") {
@@ -949,43 +962,45 @@ async function buildDrawOps(p) {
949
962
  }
950
963
  }
951
964
  }
952
- if (p.background && (p.background.color || p.background.border)) {
965
+ if (p.background || p.border) {
953
966
  const bgX = 0;
954
967
  const bgY = 0;
955
968
  const bgWidth = p.canvas.width;
956
969
  const bgHeight = p.canvas.height;
957
- const backgroundRadius = p.background.backgroundRadius ?? p.background.borderRadius ?? 0;
958
- const halfBorder = p.background.border?.width ? p.background.border.width / 2 : 0;
959
- if (p.background.color) {
960
- const bgInset = halfBorder;
961
- const bgFillRadius = Math.max(0, backgroundRadius - bgInset);
970
+ const borderWidth2 = p.border?.width ?? 0;
971
+ const borderRadius = p.border?.radius ?? 0;
972
+ const halfBorder = borderWidth2 / 2;
973
+ const maxRadius = Math.min(bgWidth - borderWidth2, bgHeight - borderWidth2) / 2;
974
+ const outerRadius = Math.min(borderRadius, maxRadius);
975
+ const innerRadius = Math.max(0, outerRadius - halfBorder);
976
+ if (p.background?.color) {
962
977
  ops.push({
963
978
  op: "Rectangle",
964
- x: bgX + bgInset,
965
- y: bgY + bgInset,
966
- width: bgWidth - bgInset * 2,
967
- height: bgHeight - bgInset * 2,
979
+ x: bgX + borderWidth2,
980
+ y: bgY + borderWidth2,
981
+ width: bgWidth - borderWidth2 * 2,
982
+ height: bgHeight - borderWidth2 * 2,
968
983
  fill: { kind: "solid", color: p.background.color, opacity: p.background.opacity },
969
- borderRadius: bgFillRadius
984
+ borderRadius: innerRadius
970
985
  });
971
986
  }
972
- if (p.background.border && p.background.border.width > 0) {
973
- const borderRadius = p.background.border.borderRadius !== void 0 ? p.background.border.borderRadius : backgroundRadius;
987
+ if (p.border && p.border.width > 0) {
974
988
  ops.push({
975
989
  op: "RectangleStroke",
976
- x: bgX,
977
- y: bgY,
978
- width: bgWidth,
979
- height: bgHeight,
990
+ x: bgX + halfBorder,
991
+ y: bgY + halfBorder,
992
+ width: bgWidth - borderWidth2,
993
+ height: bgHeight - borderWidth2,
980
994
  stroke: {
981
- width: p.background.border.width,
982
- color: p.background.border.color,
983
- opacity: p.background.border.opacity
995
+ width: p.border.width,
996
+ color: p.border.color,
997
+ opacity: p.border.opacity
984
998
  },
985
- borderRadius
999
+ borderRadius: outerRadius
986
1000
  });
987
1001
  }
988
1002
  }
1003
+ ops.push(...highlighterOps);
989
1004
  ops.push(...textOps);
990
1005
  return ops;
991
1006
  }
@@ -1113,7 +1128,9 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1113
1128
  const wordSegments = getWordSegments(lines);
1114
1129
  const totalWords = wordSegments.length;
1115
1130
  const visibleWords = Math.floor(progress * totalWords);
1116
- if (visibleWords === 0) return ops.filter((x) => x.op === "BeginFrame");
1131
+ if (visibleWords === 0) {
1132
+ return ops.filter((x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke");
1133
+ }
1117
1134
  let totalVisibleGlyphs = 0;
1118
1135
  for (let i = 0; i < Math.min(visibleWords, wordSegments.length); i++) {
1119
1136
  totalVisibleGlyphs += wordSegments[i].glyphCount;
@@ -1127,7 +1144,9 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1127
1144
  } else {
1128
1145
  const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1129
1146
  const visibleGlyphs = Math.floor(progress * totalGlyphs);
1130
- if (visibleGlyphs === 0) return ops.filter((x) => x.op === "BeginFrame");
1147
+ if (visibleGlyphs === 0) {
1148
+ return ops.filter((x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke");
1149
+ }
1131
1150
  const visibleOpsRaw = sliceGlyphOps(ops, visibleGlyphs);
1132
1151
  const visibleOps = progress >= DECORATION_DONE_THRESHOLD ? visibleOpsRaw : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
1133
1152
  if (progress < 1 && visibleGlyphs > 0) {
@@ -1143,8 +1162,10 @@ function applyAscendAnimation(ops, lines, progress, direction, fontSize, duratio
1143
1162
  const result = [];
1144
1163
  let glyphIndex = 0;
1145
1164
  for (const op of ops) {
1146
- if (op.op === "BeginFrame") {
1165
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1147
1166
  result.push(op);
1167
+ }
1168
+ if (op.op === "FillPath" || op.op === "StrokePath") {
1148
1169
  break;
1149
1170
  }
1150
1171
  }
@@ -1203,8 +1224,10 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1203
1224
  if (totalUnits === 0) return ops;
1204
1225
  const result = [];
1205
1226
  for (const op of ops) {
1206
- if (op.op === "BeginFrame") {
1227
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1207
1228
  result.push(op);
1229
+ }
1230
+ if (op.op === "FillPath" || op.op === "StrokePath") {
1208
1231
  break;
1209
1232
  }
1210
1233
  }
@@ -1478,7 +1501,7 @@ function sliceGlyphOps(ops, maxGlyphs) {
1478
1501
  let glyphCount = 0;
1479
1502
  let foundGlyphs = false;
1480
1503
  for (const op of ops) {
1481
- if (op.op === "BeginFrame") {
1504
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1482
1505
  result.push(op);
1483
1506
  continue;
1484
1507
  }
@@ -1506,9 +1529,11 @@ function sliceGlyphOps(ops, maxGlyphs) {
1506
1529
  return result;
1507
1530
  }
1508
1531
  function addTypewriterCursor(ops, glyphCount, fontSize, time) {
1509
- const blinkRate = 2;
1532
+ if (glyphCount === 0) return ops;
1533
+ const blinkRate = 1;
1510
1534
  const cursorVisible = Math.floor(time * blinkRate * 2) % 2 === 0;
1511
- if (!cursorVisible || glyphCount === 0) return ops;
1535
+ const alwaysShowCursor = true;
1536
+ if (!alwaysShowCursor && !cursorVisible) return ops;
1512
1537
  let last = null;
1513
1538
  let count = 0;
1514
1539
  for (const op of ops) {
@@ -1524,13 +1549,16 @@ function addTypewriterCursor(ops, glyphCount, fontSize, time) {
1524
1549
  const color = getTextColorFromOps(ops);
1525
1550
  const cursorX = last.x + fontSize * 0.5;
1526
1551
  const cursorY = last.y;
1552
+ const cursorWidth = Math.max(3, fontSize / 15);
1527
1553
  const cursorOp = {
1528
1554
  op: "DecorationLine",
1529
- from: { x: cursorX, y: cursorY - fontSize * 0.7 },
1530
- to: { x: cursorX, y: cursorY + fontSize * 0.1 },
1531
- width: Math.max(2, fontSize / 25),
1555
+ from: { x: cursorX, y: cursorY - fontSize * 0.75 },
1556
+ // Slightly taller
1557
+ to: { x: cursorX, y: cursorY + fontSize * 0.15 },
1558
+ width: cursorWidth,
1532
1559
  color,
1533
- opacity: 1
1560
+ opacity: alwaysShowCursor ? 1 : cursorVisible ? 1 : 0
1561
+ // Always visible or blink
1534
1562
  };
1535
1563
  return [...ops, cursorOp];
1536
1564
  }
@@ -2139,7 +2167,8 @@ async function createTextEngine(opts = {}) {
2139
2167
  size: main.size,
2140
2168
  weight: `${main.weight}`,
2141
2169
  color: main.color,
2142
- opacity: main.opacity
2170
+ opacity: main.opacity,
2171
+ background: main.background
2143
2172
  },
2144
2173
  style: {
2145
2174
  lineHeight: asset.style?.lineHeight ?? 1.2,
@@ -2153,6 +2182,7 @@ async function createTextEngine(opts = {}) {
2153
2182
  vertical: asset.align?.vertical ?? "middle"
2154
2183
  },
2155
2184
  background: asset.background,
2185
+ border: asset.border,
2156
2186
  padding: asset.padding,
2157
2187
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
2158
2188
  getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shotstack/shotstack-canvas",
3
- "version": "1.5.5",
3
+ "version": "1.5.7",
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",