@shotstack/shotstack-canvas 1.5.6 → 1.5.8

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),
@@ -854,7 +855,7 @@ function normalizePadding(padding) {
854
855
  async function buildDrawOps(p) {
855
856
  const ops = [];
856
857
  const padding = normalizePadding(p.padding);
857
- const borderWidth = p.background?.border?.width ?? 0;
858
+ const borderWidth = p.border?.width ?? 0;
858
859
  ops.push({
859
860
  op: "BeginFrame",
860
861
  width: p.canvas.width,
@@ -887,6 +888,7 @@ async function buildDrawOps(p) {
887
888
  const fill = p.style.gradient ? gradientSpecFrom(p.style.gradient, 1) : { kind: "solid", color: p.font.color, opacity: p.font.opacity };
888
889
  const decoColor = p.style.gradient ? p.style.gradient.stops[p.style.gradient.stops.length - 1]?.color ?? p.font.color : p.font.color;
889
890
  const textOps = [];
891
+ const highlighterOps = [];
890
892
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
891
893
  for (const line of p.lines) {
892
894
  let lineX;
@@ -905,6 +907,20 @@ async function buildDrawOps(p) {
905
907
  let xCursor = lineX;
906
908
  const lineIndex = p.lines.indexOf(line);
907
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
+ }
908
924
  for (const glyph of line.glyphs) {
909
925
  const path = await p.glyphPathProvider(glyph.id, glyph.fontDesc);
910
926
  if (!path || path === "M 0 0") {
@@ -1018,6 +1034,7 @@ async function buildDrawOps(p) {
1018
1034
  });
1019
1035
  }
1020
1036
  }
1037
+ ops.push(...highlighterOps);
1021
1038
  ops.push(...textOps);
1022
1039
  return ops;
1023
1040
  }
@@ -1145,7 +1162,9 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1145
1162
  const wordSegments = getWordSegments(lines);
1146
1163
  const totalWords = wordSegments.length;
1147
1164
  const visibleWords = Math.floor(progress * totalWords);
1148
- 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
+ }
1149
1168
  let totalVisibleGlyphs = 0;
1150
1169
  for (let i = 0; i < Math.min(visibleWords, wordSegments.length); i++) {
1151
1170
  totalVisibleGlyphs += wordSegments[i].glyphCount;
@@ -1159,7 +1178,9 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1159
1178
  } else {
1160
1179
  const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1161
1180
  const visibleGlyphs = Math.floor(progress * totalGlyphs);
1162
- 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
+ }
1163
1184
  const visibleOpsRaw = sliceGlyphOps(ops, visibleGlyphs);
1164
1185
  const visibleOps = progress >= DECORATION_DONE_THRESHOLD ? visibleOpsRaw : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
1165
1186
  if (progress < 1 && visibleGlyphs > 0) {
@@ -1175,8 +1196,10 @@ function applyAscendAnimation(ops, lines, progress, direction, fontSize, duratio
1175
1196
  const result = [];
1176
1197
  let glyphIndex = 0;
1177
1198
  for (const op of ops) {
1178
- if (op.op === "BeginFrame") {
1199
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1179
1200
  result.push(op);
1201
+ }
1202
+ if (op.op === "FillPath" || op.op === "StrokePath") {
1180
1203
  break;
1181
1204
  }
1182
1205
  }
@@ -1235,8 +1258,10 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1235
1258
  if (totalUnits === 0) return ops;
1236
1259
  const result = [];
1237
1260
  for (const op of ops) {
1238
- if (op.op === "BeginFrame") {
1261
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1239
1262
  result.push(op);
1263
+ }
1264
+ if (op.op === "FillPath" || op.op === "StrokePath") {
1240
1265
  break;
1241
1266
  }
1242
1267
  }
@@ -1510,7 +1535,7 @@ function sliceGlyphOps(ops, maxGlyphs) {
1510
1535
  let glyphCount = 0;
1511
1536
  let foundGlyphs = false;
1512
1537
  for (const op of ops) {
1513
- if (op.op === "BeginFrame") {
1538
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1514
1539
  result.push(op);
1515
1540
  continue;
1516
1541
  }
@@ -1538,9 +1563,11 @@ function sliceGlyphOps(ops, maxGlyphs) {
1538
1563
  return result;
1539
1564
  }
1540
1565
  function addTypewriterCursor(ops, glyphCount, fontSize, time) {
1541
- const blinkRate = 2;
1566
+ if (glyphCount === 0) return ops;
1567
+ const blinkRate = 1;
1542
1568
  const cursorVisible = Math.floor(time * blinkRate * 2) % 2 === 0;
1543
- if (!cursorVisible || glyphCount === 0) return ops;
1569
+ const alwaysShowCursor = true;
1570
+ if (!alwaysShowCursor && !cursorVisible) return ops;
1544
1571
  let last = null;
1545
1572
  let count = 0;
1546
1573
  for (const op of ops) {
@@ -1556,13 +1583,16 @@ function addTypewriterCursor(ops, glyphCount, fontSize, time) {
1556
1583
  const color = getTextColorFromOps(ops);
1557
1584
  const cursorX = last.x + fontSize * 0.5;
1558
1585
  const cursorY = last.y;
1586
+ const cursorWidth = Math.max(3, fontSize / 15);
1559
1587
  const cursorOp = {
1560
1588
  op: "DecorationLine",
1561
- from: { x: cursorX, y: cursorY - fontSize * 0.7 },
1562
- to: { x: cursorX, y: cursorY + fontSize * 0.1 },
1563
- 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,
1564
1593
  color,
1565
- opacity: 1
1594
+ opacity: alwaysShowCursor ? 1 : cursorVisible ? 1 : 0
1595
+ // Always visible or blink
1566
1596
  };
1567
1597
  return [...ops, cursorOp];
1568
1598
  }
@@ -2288,16 +2318,29 @@ var VideoGenerator = class {
2288
2318
  });
2289
2319
  try {
2290
2320
  const painter = await createNodePainter({ width, height, pixelRatio });
2291
- for (let frame = 0; frame < totalFrames; frame++) {
2292
- const t = frame / (totalFrames - 1) * duration;
2293
- const ops = await frameGenerator(t);
2294
- await painter.render(ops);
2295
- const png = await painter.toPNG();
2296
- const ok = ffmpeg.stdin.write(png);
2297
- if (!ok) await new Promise((r) => ffmpeg.stdin.once("drain", r));
2298
- if (frame % Math.max(1, Math.floor(fps / 2)) === 0) {
2299
- const pct = Math.round((frame + 1) / totalFrames * 100);
2300
- console.log(`\u{1F39E}\uFE0F Rendering frames: ${pct}%`);
2321
+ const CHUNK_SIZE = 30;
2322
+ for (let chunkStart = 0; chunkStart < totalFrames; chunkStart += CHUNK_SIZE) {
2323
+ const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, totalFrames);
2324
+ const chunkSize = chunkEnd - chunkStart;
2325
+ const chunkOps = [];
2326
+ for (let frame = chunkStart; frame < chunkEnd; frame++) {
2327
+ const t = frame / (totalFrames - 1) * duration;
2328
+ chunkOps.push(await frameGenerator(t));
2329
+ }
2330
+ for (let i = 0; i < chunkSize; i++) {
2331
+ const frame = chunkStart + i;
2332
+ await painter.render(chunkOps[i]);
2333
+ const png = await painter.toPNG();
2334
+ const ok = ffmpeg.stdin.write(png);
2335
+ if (!ok) await new Promise((r) => ffmpeg.stdin.once("drain", r));
2336
+ if (frame % Math.max(1, Math.floor(fps / 2)) === 0) {
2337
+ const pct = Math.round((frame + 1) / totalFrames * 100);
2338
+ console.log(`\u{1F39E}\uFE0F Rendering frames: ${pct}%`);
2339
+ }
2340
+ }
2341
+ chunkOps.length = 0;
2342
+ if (global.gc && chunkEnd < totalFrames) {
2343
+ global.gc();
2301
2344
  }
2302
2345
  }
2303
2346
  ffmpeg.stdin.end();
@@ -2452,7 +2495,8 @@ async function createTextEngine(opts = {}) {
2452
2495
  size: main.size,
2453
2496
  weight: `${main.weight}`,
2454
2497
  color: main.color,
2455
- opacity: main.opacity
2498
+ opacity: main.opacity,
2499
+ background: main.background
2456
2500
  },
2457
2501
  style: {
2458
2502
  lineHeight: asset.style?.lineHeight ?? 1.2,
@@ -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;
@@ -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;
@@ -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),
@@ -815,7 +816,7 @@ function normalizePadding(padding) {
815
816
  async function buildDrawOps(p) {
816
817
  const ops = [];
817
818
  const padding = normalizePadding(p.padding);
818
- const borderWidth = p.background?.border?.width ?? 0;
819
+ const borderWidth = p.border?.width ?? 0;
819
820
  ops.push({
820
821
  op: "BeginFrame",
821
822
  width: p.canvas.width,
@@ -848,6 +849,7 @@ async function buildDrawOps(p) {
848
849
  const fill = p.style.gradient ? gradientSpecFrom(p.style.gradient, 1) : { kind: "solid", color: p.font.color, opacity: p.font.opacity };
849
850
  const decoColor = p.style.gradient ? p.style.gradient.stops[p.style.gradient.stops.length - 1]?.color ?? p.font.color : p.font.color;
850
851
  const textOps = [];
852
+ const highlighterOps = [];
851
853
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
852
854
  for (const line of p.lines) {
853
855
  let lineX;
@@ -866,6 +868,20 @@ async function buildDrawOps(p) {
866
868
  let xCursor = lineX;
867
869
  const lineIndex = p.lines.indexOf(line);
868
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
+ }
869
885
  for (const glyph of line.glyphs) {
870
886
  const path = await p.glyphPathProvider(glyph.id, glyph.fontDesc);
871
887
  if (!path || path === "M 0 0") {
@@ -979,6 +995,7 @@ async function buildDrawOps(p) {
979
995
  });
980
996
  }
981
997
  }
998
+ ops.push(...highlighterOps);
982
999
  ops.push(...textOps);
983
1000
  return ops;
984
1001
  }
@@ -1106,7 +1123,9 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1106
1123
  const wordSegments = getWordSegments(lines);
1107
1124
  const totalWords = wordSegments.length;
1108
1125
  const visibleWords = Math.floor(progress * totalWords);
1109
- 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
+ }
1110
1129
  let totalVisibleGlyphs = 0;
1111
1130
  for (let i = 0; i < Math.min(visibleWords, wordSegments.length); i++) {
1112
1131
  totalVisibleGlyphs += wordSegments[i].glyphCount;
@@ -1120,7 +1139,9 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1120
1139
  } else {
1121
1140
  const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1122
1141
  const visibleGlyphs = Math.floor(progress * totalGlyphs);
1123
- 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
+ }
1124
1145
  const visibleOpsRaw = sliceGlyphOps(ops, visibleGlyphs);
1125
1146
  const visibleOps = progress >= DECORATION_DONE_THRESHOLD ? visibleOpsRaw : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
1126
1147
  if (progress < 1 && visibleGlyphs > 0) {
@@ -1136,8 +1157,10 @@ function applyAscendAnimation(ops, lines, progress, direction, fontSize, duratio
1136
1157
  const result = [];
1137
1158
  let glyphIndex = 0;
1138
1159
  for (const op of ops) {
1139
- if (op.op === "BeginFrame") {
1160
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1140
1161
  result.push(op);
1162
+ }
1163
+ if (op.op === "FillPath" || op.op === "StrokePath") {
1141
1164
  break;
1142
1165
  }
1143
1166
  }
@@ -1196,8 +1219,10 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1196
1219
  if (totalUnits === 0) return ops;
1197
1220
  const result = [];
1198
1221
  for (const op of ops) {
1199
- if (op.op === "BeginFrame") {
1222
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1200
1223
  result.push(op);
1224
+ }
1225
+ if (op.op === "FillPath" || op.op === "StrokePath") {
1201
1226
  break;
1202
1227
  }
1203
1228
  }
@@ -1471,7 +1496,7 @@ function sliceGlyphOps(ops, maxGlyphs) {
1471
1496
  let glyphCount = 0;
1472
1497
  let foundGlyphs = false;
1473
1498
  for (const op of ops) {
1474
- if (op.op === "BeginFrame") {
1499
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1475
1500
  result.push(op);
1476
1501
  continue;
1477
1502
  }
@@ -1499,9 +1524,11 @@ function sliceGlyphOps(ops, maxGlyphs) {
1499
1524
  return result;
1500
1525
  }
1501
1526
  function addTypewriterCursor(ops, glyphCount, fontSize, time) {
1502
- const blinkRate = 2;
1527
+ if (glyphCount === 0) return ops;
1528
+ const blinkRate = 1;
1503
1529
  const cursorVisible = Math.floor(time * blinkRate * 2) % 2 === 0;
1504
- if (!cursorVisible || glyphCount === 0) return ops;
1530
+ const alwaysShowCursor = true;
1531
+ if (!alwaysShowCursor && !cursorVisible) return ops;
1505
1532
  let last = null;
1506
1533
  let count = 0;
1507
1534
  for (const op of ops) {
@@ -1517,13 +1544,16 @@ function addTypewriterCursor(ops, glyphCount, fontSize, time) {
1517
1544
  const color = getTextColorFromOps(ops);
1518
1545
  const cursorX = last.x + fontSize * 0.5;
1519
1546
  const cursorY = last.y;
1547
+ const cursorWidth = Math.max(3, fontSize / 15);
1520
1548
  const cursorOp = {
1521
1549
  op: "DecorationLine",
1522
- from: { x: cursorX, y: cursorY - fontSize * 0.7 },
1523
- to: { x: cursorX, y: cursorY + fontSize * 0.1 },
1524
- 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,
1525
1554
  color,
1526
- opacity: 1
1555
+ opacity: alwaysShowCursor ? 1 : cursorVisible ? 1 : 0
1556
+ // Always visible or blink
1527
1557
  };
1528
1558
  return [...ops, cursorOp];
1529
1559
  }
@@ -2249,16 +2279,29 @@ var VideoGenerator = class {
2249
2279
  });
2250
2280
  try {
2251
2281
  const painter = await createNodePainter({ width, height, pixelRatio });
2252
- for (let frame = 0; frame < totalFrames; frame++) {
2253
- const t = frame / (totalFrames - 1) * duration;
2254
- const ops = await frameGenerator(t);
2255
- await painter.render(ops);
2256
- const png = await painter.toPNG();
2257
- const ok = ffmpeg.stdin.write(png);
2258
- if (!ok) await new Promise((r) => ffmpeg.stdin.once("drain", r));
2259
- if (frame % Math.max(1, Math.floor(fps / 2)) === 0) {
2260
- const pct = Math.round((frame + 1) / totalFrames * 100);
2261
- console.log(`\u{1F39E}\uFE0F Rendering frames: ${pct}%`);
2282
+ const CHUNK_SIZE = 30;
2283
+ for (let chunkStart = 0; chunkStart < totalFrames; chunkStart += CHUNK_SIZE) {
2284
+ const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, totalFrames);
2285
+ const chunkSize = chunkEnd - chunkStart;
2286
+ const chunkOps = [];
2287
+ for (let frame = chunkStart; frame < chunkEnd; frame++) {
2288
+ const t = frame / (totalFrames - 1) * duration;
2289
+ chunkOps.push(await frameGenerator(t));
2290
+ }
2291
+ for (let i = 0; i < chunkSize; i++) {
2292
+ const frame = chunkStart + i;
2293
+ await painter.render(chunkOps[i]);
2294
+ const png = await painter.toPNG();
2295
+ const ok = ffmpeg.stdin.write(png);
2296
+ if (!ok) await new Promise((r) => ffmpeg.stdin.once("drain", r));
2297
+ if (frame % Math.max(1, Math.floor(fps / 2)) === 0) {
2298
+ const pct = Math.round((frame + 1) / totalFrames * 100);
2299
+ console.log(`\u{1F39E}\uFE0F Rendering frames: ${pct}%`);
2300
+ }
2301
+ }
2302
+ chunkOps.length = 0;
2303
+ if (global.gc && chunkEnd < totalFrames) {
2304
+ global.gc();
2262
2305
  }
2263
2306
  }
2264
2307
  ffmpeg.stdin.end();
@@ -2413,7 +2456,8 @@ async function createTextEngine(opts = {}) {
2413
2456
  size: main.size,
2414
2457
  weight: `${main.weight}`,
2415
2458
  color: main.color,
2416
- opacity: main.opacity
2459
+ opacity: main.opacity,
2460
+ background: main.background
2417
2461
  },
2418
2462
  style: {
2419
2463
  lineHeight: asset.style?.lineHeight ?? 1.2,
@@ -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;
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),
@@ -820,7 +821,7 @@ function normalizePadding(padding) {
820
821
  async function buildDrawOps(p) {
821
822
  const ops = [];
822
823
  const padding = normalizePadding(p.padding);
823
- const borderWidth = p.background?.border?.width ?? 0;
824
+ const borderWidth = p.border?.width ?? 0;
824
825
  ops.push({
825
826
  op: "BeginFrame",
826
827
  width: p.canvas.width,
@@ -853,6 +854,7 @@ async function buildDrawOps(p) {
853
854
  const fill = p.style.gradient ? gradientSpecFrom(p.style.gradient, 1) : { kind: "solid", color: p.font.color, opacity: p.font.opacity };
854
855
  const decoColor = p.style.gradient ? p.style.gradient.stops[p.style.gradient.stops.length - 1]?.color ?? p.font.color : p.font.color;
855
856
  const textOps = [];
857
+ const highlighterOps = [];
856
858
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
857
859
  for (const line of p.lines) {
858
860
  let lineX;
@@ -871,6 +873,20 @@ async function buildDrawOps(p) {
871
873
  let xCursor = lineX;
872
874
  const lineIndex = p.lines.indexOf(line);
873
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
+ }
874
890
  for (const glyph of line.glyphs) {
875
891
  const path = await p.glyphPathProvider(glyph.id, glyph.fontDesc);
876
892
  if (!path || path === "M 0 0") {
@@ -984,6 +1000,7 @@ async function buildDrawOps(p) {
984
1000
  });
985
1001
  }
986
1002
  }
1003
+ ops.push(...highlighterOps);
987
1004
  ops.push(...textOps);
988
1005
  return ops;
989
1006
  }
@@ -1111,7 +1128,9 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1111
1128
  const wordSegments = getWordSegments(lines);
1112
1129
  const totalWords = wordSegments.length;
1113
1130
  const visibleWords = Math.floor(progress * totalWords);
1114
- 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
+ }
1115
1134
  let totalVisibleGlyphs = 0;
1116
1135
  for (let i = 0; i < Math.min(visibleWords, wordSegments.length); i++) {
1117
1136
  totalVisibleGlyphs += wordSegments[i].glyphCount;
@@ -1125,7 +1144,9 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1125
1144
  } else {
1126
1145
  const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1127
1146
  const visibleGlyphs = Math.floor(progress * totalGlyphs);
1128
- 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
+ }
1129
1150
  const visibleOpsRaw = sliceGlyphOps(ops, visibleGlyphs);
1130
1151
  const visibleOps = progress >= DECORATION_DONE_THRESHOLD ? visibleOpsRaw : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
1131
1152
  if (progress < 1 && visibleGlyphs > 0) {
@@ -1141,8 +1162,10 @@ function applyAscendAnimation(ops, lines, progress, direction, fontSize, duratio
1141
1162
  const result = [];
1142
1163
  let glyphIndex = 0;
1143
1164
  for (const op of ops) {
1144
- if (op.op === "BeginFrame") {
1165
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1145
1166
  result.push(op);
1167
+ }
1168
+ if (op.op === "FillPath" || op.op === "StrokePath") {
1146
1169
  break;
1147
1170
  }
1148
1171
  }
@@ -1201,8 +1224,10 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1201
1224
  if (totalUnits === 0) return ops;
1202
1225
  const result = [];
1203
1226
  for (const op of ops) {
1204
- if (op.op === "BeginFrame") {
1227
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1205
1228
  result.push(op);
1229
+ }
1230
+ if (op.op === "FillPath" || op.op === "StrokePath") {
1206
1231
  break;
1207
1232
  }
1208
1233
  }
@@ -1476,7 +1501,7 @@ function sliceGlyphOps(ops, maxGlyphs) {
1476
1501
  let glyphCount = 0;
1477
1502
  let foundGlyphs = false;
1478
1503
  for (const op of ops) {
1479
- if (op.op === "BeginFrame") {
1504
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
1480
1505
  result.push(op);
1481
1506
  continue;
1482
1507
  }
@@ -1504,9 +1529,11 @@ function sliceGlyphOps(ops, maxGlyphs) {
1504
1529
  return result;
1505
1530
  }
1506
1531
  function addTypewriterCursor(ops, glyphCount, fontSize, time) {
1507
- const blinkRate = 2;
1532
+ if (glyphCount === 0) return ops;
1533
+ const blinkRate = 1;
1508
1534
  const cursorVisible = Math.floor(time * blinkRate * 2) % 2 === 0;
1509
- if (!cursorVisible || glyphCount === 0) return ops;
1535
+ const alwaysShowCursor = true;
1536
+ if (!alwaysShowCursor && !cursorVisible) return ops;
1510
1537
  let last = null;
1511
1538
  let count = 0;
1512
1539
  for (const op of ops) {
@@ -1522,13 +1549,16 @@ function addTypewriterCursor(ops, glyphCount, fontSize, time) {
1522
1549
  const color = getTextColorFromOps(ops);
1523
1550
  const cursorX = last.x + fontSize * 0.5;
1524
1551
  const cursorY = last.y;
1552
+ const cursorWidth = Math.max(3, fontSize / 15);
1525
1553
  const cursorOp = {
1526
1554
  op: "DecorationLine",
1527
- from: { x: cursorX, y: cursorY - fontSize * 0.7 },
1528
- to: { x: cursorX, y: cursorY + fontSize * 0.1 },
1529
- 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,
1530
1559
  color,
1531
- opacity: 1
1560
+ opacity: alwaysShowCursor ? 1 : cursorVisible ? 1 : 0
1561
+ // Always visible or blink
1532
1562
  };
1533
1563
  return [...ops, cursorOp];
1534
1564
  }
@@ -2137,7 +2167,8 @@ async function createTextEngine(opts = {}) {
2137
2167
  size: main.size,
2138
2168
  weight: `${main.weight}`,
2139
2169
  color: main.color,
2140
- opacity: main.opacity
2170
+ opacity: main.opacity,
2171
+ background: main.background
2141
2172
  },
2142
2173
  style: {
2143
2174
  lineHeight: asset.style?.lineHeight ?? 1.2,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shotstack/shotstack-canvas",
3
- "version": "1.5.6",
3
+ "version": "1.5.8",
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",