@shotstack/shotstack-canvas 1.4.0 → 1.4.1

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.
@@ -119,7 +119,7 @@ var animationSchema = import_joi.default.object({
119
119
  speed: import_joi.default.number().min(0.1).max(10).default(1),
120
120
  duration: import_joi.default.number().min(CANVAS_CONFIG.LIMITS.minDuration).max(CANVAS_CONFIG.LIMITS.maxDuration).optional(),
121
121
  style: import_joi.default.string().valid("character", "word").optional().when("preset", {
122
- is: import_joi.default.valid("typewriter", "shift"),
122
+ is: import_joi.default.valid("typewriter", "shift", "fadeIn", "slideIn"),
123
123
  then: import_joi.default.optional(),
124
124
  otherwise: import_joi.default.forbidden()
125
125
  }),
@@ -733,13 +733,11 @@ var LayoutEngine = class {
733
733
  const glyph = glyphs[i];
734
734
  const glyphWidth = glyph.xAdvance;
735
735
  if (glyph.char === "\n") {
736
- if (currentLine.length > 0) {
737
- lines.push({
738
- glyphs: currentLine,
739
- width: currentWidth,
740
- y: 0
741
- });
742
- }
736
+ lines.push({
737
+ glyphs: currentLine,
738
+ width: currentWidth,
739
+ y: 0
740
+ });
743
741
  currentLine = [];
744
742
  currentWidth = 0;
745
743
  lastBreakIndex = i;
@@ -998,7 +996,11 @@ function applyAnimation(ops, lines, p) {
998
996
  if (!p.anim || !p.anim.preset) return ops;
999
997
  const { preset } = p.anim;
1000
998
  const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1001
- const duration = p.anim.duration ?? Math.max(0.3, totalGlyphs / 30 / p.anim.speed);
999
+ let autoDuration = Math.max(0.3, totalGlyphs / (30 * p.anim.speed));
1000
+ if (p.clipDuration && !p.anim.duration) {
1001
+ autoDuration = Math.min(autoDuration, p.clipDuration * 0.9);
1002
+ }
1003
+ const duration = p.anim.duration ?? autoDuration;
1002
1004
  const progress = Math.max(0, Math.min(1, p.t / duration));
1003
1005
  switch (preset) {
1004
1006
  case "typewriter":
@@ -1012,9 +1014,9 @@ function applyAnimation(ops, lines, p) {
1012
1014
  duration
1013
1015
  );
1014
1016
  case "fadeIn":
1015
- return applyFadeInAnimation(ops, progress);
1017
+ return applyFadeInAnimation(ops, lines, progress, p.anim.style, p.fontSize, duration);
1016
1018
  case "slideIn":
1017
- return applySlideInAnimation(ops, progress, p.anim.direction ?? "left", p.fontSize);
1019
+ return applySlideInAnimation(ops, lines, progress, p.anim.direction ?? "left", p.fontSize, p.anim.style, duration);
1018
1020
  case "shift":
1019
1021
  return applyShiftAnimation(
1020
1022
  ops,
@@ -1035,7 +1037,7 @@ function applyAnimation(ops, lines, p) {
1035
1037
  duration
1036
1038
  );
1037
1039
  case "movingLetters":
1038
- return applyMovingLettersAnimation(ops, progress, p.anim.direction ?? "up", p.fontSize);
1040
+ return applyMovingLettersAnimation(ops, p.t, p.anim.direction ?? "up", p.fontSize);
1039
1041
  default:
1040
1042
  return ops;
1041
1043
  }
@@ -1217,20 +1219,177 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1217
1219
  }
1218
1220
  return result;
1219
1221
  }
1220
- function applyFadeInAnimation(ops, progress) {
1221
- const alpha = easeOutQuad(progress);
1222
- const scale = 0.95 + 0.05 * alpha;
1223
- return scaleAndFade(ops, alpha, scale);
1222
+ function applyFadeInAnimation(ops, lines, progress, style, fontSize, duration) {
1223
+ const byWord = style === "word";
1224
+ const byCharacter = style === "character";
1225
+ if (!byWord && !byCharacter) {
1226
+ const alpha = easeOutQuad(progress);
1227
+ const scale = 0.95 + 0.05 * alpha;
1228
+ return scaleAndFade(ops, alpha, scale);
1229
+ }
1230
+ const wordSegments = byWord ? getWordSegments(lines) : [];
1231
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1232
+ const totalUnits = byWord ? wordSegments.length : totalGlyphs;
1233
+ if (totalUnits === 0) return ops;
1234
+ const result = [];
1235
+ for (const op of ops) {
1236
+ if (op.op === "BeginFrame") {
1237
+ result.push(op);
1238
+ break;
1239
+ }
1240
+ }
1241
+ const windowDuration = 0.3;
1242
+ const overlapFactor = 0.7;
1243
+ const staggerDelay = duration * overlapFactor / Math.max(1, totalUnits - 1);
1244
+ const windowFor = (unitIdx) => {
1245
+ const startTime = unitIdx * staggerDelay;
1246
+ const startF = startTime / duration;
1247
+ const endF = Math.min(1, (startTime + windowDuration) / duration);
1248
+ return { startF, endF };
1249
+ };
1250
+ let glyphIndex = 0;
1251
+ for (const op of ops) {
1252
+ if (op.op !== "FillPath" && op.op !== "StrokePath") {
1253
+ if (op.op === "DecorationLine" && progress > 0.8) result.push(op);
1254
+ continue;
1255
+ }
1256
+ let unitIndex;
1257
+ if (!byWord) {
1258
+ unitIndex = glyphIndex;
1259
+ } else {
1260
+ let wordIndex = -1, acc = 0;
1261
+ for (let i = 0; i < wordSegments.length; i++) {
1262
+ const gcount = wordSegments[i].glyphCount;
1263
+ if (glyphIndex >= acc && glyphIndex < acc + gcount) {
1264
+ wordIndex = i;
1265
+ break;
1266
+ }
1267
+ acc += gcount;
1268
+ }
1269
+ unitIndex = Math.max(0, wordIndex);
1270
+ }
1271
+ const { startF, endF } = windowFor(unitIndex);
1272
+ if (progress <= startF) {
1273
+ const animated = { ...op };
1274
+ if (op.op === "FillPath") {
1275
+ if (animated.fill.kind === "solid") animated.fill = { ...animated.fill, opacity: 0 };
1276
+ else animated.fill = { ...animated.fill, opacity: 0 };
1277
+ } else {
1278
+ animated.opacity = 0;
1279
+ }
1280
+ result.push(animated);
1281
+ } else if (progress >= endF) {
1282
+ result.push(op);
1283
+ } else {
1284
+ const local = (progress - startF) / Math.max(1e-6, endF - startF);
1285
+ const ease = easeOutQuad(Math.min(1, local));
1286
+ const animated = { ...op };
1287
+ if (op.op === "FillPath") {
1288
+ const targetOpacity = animated.fill.kind === "solid" ? animated.fill.opacity : animated.fill.opacity ?? 1;
1289
+ if (animated.fill.kind === "solid")
1290
+ animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1291
+ else animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1292
+ } else {
1293
+ animated.opacity = animated.opacity * ease;
1294
+ }
1295
+ result.push(animated);
1296
+ }
1297
+ if (isGlyphFill(op)) glyphIndex++;
1298
+ }
1299
+ return result;
1224
1300
  }
1225
- function applySlideInAnimation(ops, progress, direction, fontSize) {
1226
- const easeProgress = easeOutCubic(progress);
1227
- const shift = shiftFor(1 - easeProgress, direction, fontSize * 2);
1228
- const alpha = easeOutQuad(progress);
1229
- return translateGlyphOps(ops, shift.dx, shift.dy, alpha);
1301
+ function applySlideInAnimation(ops, lines, progress, direction, fontSize, style, duration) {
1302
+ const byWord = style === "word";
1303
+ const byCharacter = style === "character";
1304
+ if (!byWord && !byCharacter) {
1305
+ const easeProgress = easeOutCubic(progress);
1306
+ const shift = shiftFor(1 - easeProgress, direction, fontSize * 2);
1307
+ const alpha = easeOutQuad(progress);
1308
+ return translateGlyphOps(ops, shift.dx, shift.dy, alpha);
1309
+ }
1310
+ const startOffsets = {
1311
+ left: { x: fontSize * 2, y: 0 },
1312
+ right: { x: -fontSize * 2, y: 0 },
1313
+ up: { x: 0, y: fontSize * 2 },
1314
+ down: { x: 0, y: -fontSize * 2 }
1315
+ };
1316
+ const offset = startOffsets[direction];
1317
+ const wordSegments = byWord ? getWordSegments(lines) : [];
1318
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1319
+ const totalUnits = byWord ? wordSegments.length : totalGlyphs;
1320
+ if (totalUnits === 0) return ops;
1321
+ const result = [];
1322
+ for (const op of ops) {
1323
+ if (op.op === "BeginFrame") {
1324
+ result.push(op);
1325
+ break;
1326
+ }
1327
+ }
1328
+ const windowDuration = 0.3;
1329
+ const overlapFactor = 0.7;
1330
+ const staggerDelay = duration * overlapFactor / Math.max(1, totalUnits - 1);
1331
+ const windowFor = (unitIdx) => {
1332
+ const startTime = unitIdx * staggerDelay;
1333
+ const startF = startTime / duration;
1334
+ const endF = Math.min(1, (startTime + windowDuration) / duration);
1335
+ return { startF, endF };
1336
+ };
1337
+ let glyphIndex = 0;
1338
+ for (const op of ops) {
1339
+ if (op.op !== "FillPath" && op.op !== "StrokePath") {
1340
+ if (op.op === "DecorationLine" && progress > 0.8) result.push(op);
1341
+ continue;
1342
+ }
1343
+ let unitIndex;
1344
+ if (!byWord) {
1345
+ unitIndex = glyphIndex;
1346
+ } else {
1347
+ let wordIndex = -1, acc = 0;
1348
+ for (let i = 0; i < wordSegments.length; i++) {
1349
+ const gcount = wordSegments[i].glyphCount;
1350
+ if (glyphIndex >= acc && glyphIndex < acc + gcount) {
1351
+ wordIndex = i;
1352
+ break;
1353
+ }
1354
+ acc += gcount;
1355
+ }
1356
+ unitIndex = Math.max(0, wordIndex);
1357
+ }
1358
+ const { startF, endF } = windowFor(unitIndex);
1359
+ if (progress <= startF) {
1360
+ const animated = { ...op, x: op.x + offset.x, y: op.y + offset.y };
1361
+ if (op.op === "FillPath") {
1362
+ if (animated.fill.kind === "solid") animated.fill = { ...animated.fill, opacity: 0 };
1363
+ else animated.fill = { ...animated.fill, opacity: 0 };
1364
+ } else {
1365
+ animated.opacity = 0;
1366
+ }
1367
+ result.push(animated);
1368
+ } else if (progress >= endF) {
1369
+ result.push(op);
1370
+ } else {
1371
+ const local = (progress - startF) / Math.max(1e-6, endF - startF);
1372
+ const ease = easeOutCubic(Math.min(1, local));
1373
+ const dx = offset.x * (1 - ease);
1374
+ const dy = offset.y * (1 - ease);
1375
+ const animated = { ...op, x: op.x + dx, y: op.y + dy };
1376
+ if (op.op === "FillPath") {
1377
+ const targetOpacity = animated.fill.kind === "solid" ? animated.fill.opacity : animated.fill.opacity ?? 1;
1378
+ if (animated.fill.kind === "solid")
1379
+ animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1380
+ else animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1381
+ } else {
1382
+ animated.opacity = animated.opacity * ease;
1383
+ }
1384
+ result.push(animated);
1385
+ }
1386
+ if (isGlyphFill(op)) glyphIndex++;
1387
+ }
1388
+ return result;
1230
1389
  }
1231
- function applyMovingLettersAnimation(ops, progress, direction, fontSize) {
1390
+ function applyMovingLettersAnimation(ops, time, direction, fontSize) {
1232
1391
  const amp = fontSize * 0.3;
1233
- return waveTransform(ops, direction, amp, progress);
1392
+ return waveTransform(ops, direction, amp, time);
1234
1393
  }
1235
1394
  function getWordSegments(lines) {
1236
1395
  const segments = [];
@@ -1388,14 +1547,15 @@ function translateGlyphOps(ops, dx, dy, alpha = 1) {
1388
1547
  return op;
1389
1548
  });
1390
1549
  }
1391
- function waveTransform(ops, dir, amp, p) {
1550
+ function waveTransform(ops, dir, amp, time) {
1392
1551
  let glyphIndex = 0;
1552
+ const fadeInDuration = 0.5;
1553
+ const waveAlpha = Math.min(1, time / fadeInDuration);
1393
1554
  return ops.map((op) => {
1394
1555
  if (op.op === "FillPath" || op.op === "StrokePath") {
1395
- const phase = Math.sin(glyphIndex / 5 * Math.PI + p * Math.PI * 4);
1556
+ const phase = Math.sin(glyphIndex / 5 * Math.PI + time * Math.PI * 2);
1396
1557
  const dx = dir === "left" || dir === "right" ? phase * amp * (dir === "left" ? -1 : 1) : 0;
1397
1558
  const dy = dir === "up" || dir === "down" ? phase * amp * (dir === "up" ? -1 : 1) : 0;
1398
- const waveAlpha = Math.min(1, p * 2);
1399
1559
  if (op.op === "FillPath") {
1400
1560
  if (!isShadowFill(op)) glyphIndex++;
1401
1561
  const out = { ...op, x: op.x + dx, y: op.y + dy };
@@ -2122,7 +2282,7 @@ async function createTextEngine(opts = {}) {
2122
2282
  );
2123
2283
  }
2124
2284
  },
2125
- async renderFrame(asset, tSeconds) {
2285
+ async renderFrame(asset, tSeconds, clipDuration) {
2126
2286
  try {
2127
2287
  const main = await ensureFonts(asset);
2128
2288
  const desc = { family: main.family, weight: `${main.weight}`, style: main.style };
@@ -2197,6 +2357,7 @@ async function createTextEngine(opts = {}) {
2197
2357
  const ops = applyAnimation(ops0, lines, {
2198
2358
  t: tSeconds,
2199
2359
  fontSize: main.size,
2360
+ clipDuration,
2200
2361
  anim: asset.animation ? {
2201
2362
  preset: asset.animation.preset,
2202
2363
  speed: asset.animation.speed,
@@ -2244,14 +2405,15 @@ async function createTextEngine(opts = {}) {
2244
2405
  width: asset.width ?? width,
2245
2406
  height: asset.height ?? height,
2246
2407
  fps,
2247
- duration: asset.animation?.duration ?? 3,
2408
+ duration: options.duration ?? 3,
2248
2409
  outputPath: options.outputPath ?? "output.mp4",
2249
2410
  pixelRatio: asset.pixelRatio ?? pixelRatio,
2250
2411
  hasAlpha: needsAlpha,
2251
2412
  ...options
2252
2413
  };
2414
+ const clipDuration = finalOptions.duration;
2253
2415
  const frameGenerator = async (time) => {
2254
- return this.renderFrame(asset, time);
2416
+ return this.renderFrame(asset, time, clipDuration);
2255
2417
  };
2256
2418
  const actualOutputPath = await videoGenerator.generateVideo(frameGenerator, finalOptions);
2257
2419
  return actualOutputPath;
@@ -239,7 +239,7 @@ declare function createTextEngine(opts?: {
239
239
  weight?: string | number;
240
240
  style?: string;
241
241
  }): Promise<void>;
242
- renderFrame(asset: RichTextValidated, tSeconds: number): Promise<DrawOp[]>;
242
+ renderFrame(asset: RichTextValidated, tSeconds: number, clipDuration?: number): Promise<DrawOp[]>;
243
243
  createRenderer(p: {
244
244
  width?: number;
245
245
  height?: number;
@@ -239,7 +239,7 @@ declare function createTextEngine(opts?: {
239
239
  weight?: string | number;
240
240
  style?: string;
241
241
  }): Promise<void>;
242
- renderFrame(asset: RichTextValidated, tSeconds: number): Promise<DrawOp[]>;
242
+ renderFrame(asset: RichTextValidated, tSeconds: number, clipDuration?: number): Promise<DrawOp[]>;
243
243
  createRenderer(p: {
244
244
  width?: number;
245
245
  height?: number;
@@ -81,7 +81,7 @@ var animationSchema = Joi.object({
81
81
  speed: Joi.number().min(0.1).max(10).default(1),
82
82
  duration: Joi.number().min(CANVAS_CONFIG.LIMITS.minDuration).max(CANVAS_CONFIG.LIMITS.maxDuration).optional(),
83
83
  style: Joi.string().valid("character", "word").optional().when("preset", {
84
- is: Joi.valid("typewriter", "shift"),
84
+ is: Joi.valid("typewriter", "shift", "fadeIn", "slideIn"),
85
85
  then: Joi.optional(),
86
86
  otherwise: Joi.forbidden()
87
87
  }),
@@ -694,13 +694,11 @@ var LayoutEngine = class {
694
694
  const glyph = glyphs[i];
695
695
  const glyphWidth = glyph.xAdvance;
696
696
  if (glyph.char === "\n") {
697
- if (currentLine.length > 0) {
698
- lines.push({
699
- glyphs: currentLine,
700
- width: currentWidth,
701
- y: 0
702
- });
703
- }
697
+ lines.push({
698
+ glyphs: currentLine,
699
+ width: currentWidth,
700
+ y: 0
701
+ });
704
702
  currentLine = [];
705
703
  currentWidth = 0;
706
704
  lastBreakIndex = i;
@@ -959,7 +957,11 @@ function applyAnimation(ops, lines, p) {
959
957
  if (!p.anim || !p.anim.preset) return ops;
960
958
  const { preset } = p.anim;
961
959
  const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
962
- const duration = p.anim.duration ?? Math.max(0.3, totalGlyphs / 30 / p.anim.speed);
960
+ let autoDuration = Math.max(0.3, totalGlyphs / (30 * p.anim.speed));
961
+ if (p.clipDuration && !p.anim.duration) {
962
+ autoDuration = Math.min(autoDuration, p.clipDuration * 0.9);
963
+ }
964
+ const duration = p.anim.duration ?? autoDuration;
963
965
  const progress = Math.max(0, Math.min(1, p.t / duration));
964
966
  switch (preset) {
965
967
  case "typewriter":
@@ -973,9 +975,9 @@ function applyAnimation(ops, lines, p) {
973
975
  duration
974
976
  );
975
977
  case "fadeIn":
976
- return applyFadeInAnimation(ops, progress);
978
+ return applyFadeInAnimation(ops, lines, progress, p.anim.style, p.fontSize, duration);
977
979
  case "slideIn":
978
- return applySlideInAnimation(ops, progress, p.anim.direction ?? "left", p.fontSize);
980
+ return applySlideInAnimation(ops, lines, progress, p.anim.direction ?? "left", p.fontSize, p.anim.style, duration);
979
981
  case "shift":
980
982
  return applyShiftAnimation(
981
983
  ops,
@@ -996,7 +998,7 @@ function applyAnimation(ops, lines, p) {
996
998
  duration
997
999
  );
998
1000
  case "movingLetters":
999
- return applyMovingLettersAnimation(ops, progress, p.anim.direction ?? "up", p.fontSize);
1001
+ return applyMovingLettersAnimation(ops, p.t, p.anim.direction ?? "up", p.fontSize);
1000
1002
  default:
1001
1003
  return ops;
1002
1004
  }
@@ -1178,20 +1180,177 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1178
1180
  }
1179
1181
  return result;
1180
1182
  }
1181
- function applyFadeInAnimation(ops, progress) {
1182
- const alpha = easeOutQuad(progress);
1183
- const scale = 0.95 + 0.05 * alpha;
1184
- return scaleAndFade(ops, alpha, scale);
1183
+ function applyFadeInAnimation(ops, lines, progress, style, fontSize, duration) {
1184
+ const byWord = style === "word";
1185
+ const byCharacter = style === "character";
1186
+ if (!byWord && !byCharacter) {
1187
+ const alpha = easeOutQuad(progress);
1188
+ const scale = 0.95 + 0.05 * alpha;
1189
+ return scaleAndFade(ops, alpha, scale);
1190
+ }
1191
+ const wordSegments = byWord ? getWordSegments(lines) : [];
1192
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1193
+ const totalUnits = byWord ? wordSegments.length : totalGlyphs;
1194
+ if (totalUnits === 0) return ops;
1195
+ const result = [];
1196
+ for (const op of ops) {
1197
+ if (op.op === "BeginFrame") {
1198
+ result.push(op);
1199
+ break;
1200
+ }
1201
+ }
1202
+ const windowDuration = 0.3;
1203
+ const overlapFactor = 0.7;
1204
+ const staggerDelay = duration * overlapFactor / Math.max(1, totalUnits - 1);
1205
+ const windowFor = (unitIdx) => {
1206
+ const startTime = unitIdx * staggerDelay;
1207
+ const startF = startTime / duration;
1208
+ const endF = Math.min(1, (startTime + windowDuration) / duration);
1209
+ return { startF, endF };
1210
+ };
1211
+ let glyphIndex = 0;
1212
+ for (const op of ops) {
1213
+ if (op.op !== "FillPath" && op.op !== "StrokePath") {
1214
+ if (op.op === "DecorationLine" && progress > 0.8) result.push(op);
1215
+ continue;
1216
+ }
1217
+ let unitIndex;
1218
+ if (!byWord) {
1219
+ unitIndex = glyphIndex;
1220
+ } else {
1221
+ let wordIndex = -1, acc = 0;
1222
+ for (let i = 0; i < wordSegments.length; i++) {
1223
+ const gcount = wordSegments[i].glyphCount;
1224
+ if (glyphIndex >= acc && glyphIndex < acc + gcount) {
1225
+ wordIndex = i;
1226
+ break;
1227
+ }
1228
+ acc += gcount;
1229
+ }
1230
+ unitIndex = Math.max(0, wordIndex);
1231
+ }
1232
+ const { startF, endF } = windowFor(unitIndex);
1233
+ if (progress <= startF) {
1234
+ const animated = { ...op };
1235
+ if (op.op === "FillPath") {
1236
+ if (animated.fill.kind === "solid") animated.fill = { ...animated.fill, opacity: 0 };
1237
+ else animated.fill = { ...animated.fill, opacity: 0 };
1238
+ } else {
1239
+ animated.opacity = 0;
1240
+ }
1241
+ result.push(animated);
1242
+ } else if (progress >= endF) {
1243
+ result.push(op);
1244
+ } else {
1245
+ const local = (progress - startF) / Math.max(1e-6, endF - startF);
1246
+ const ease = easeOutQuad(Math.min(1, local));
1247
+ const animated = { ...op };
1248
+ if (op.op === "FillPath") {
1249
+ const targetOpacity = animated.fill.kind === "solid" ? animated.fill.opacity : animated.fill.opacity ?? 1;
1250
+ if (animated.fill.kind === "solid")
1251
+ animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1252
+ else animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1253
+ } else {
1254
+ animated.opacity = animated.opacity * ease;
1255
+ }
1256
+ result.push(animated);
1257
+ }
1258
+ if (isGlyphFill(op)) glyphIndex++;
1259
+ }
1260
+ return result;
1185
1261
  }
1186
- function applySlideInAnimation(ops, progress, direction, fontSize) {
1187
- const easeProgress = easeOutCubic(progress);
1188
- const shift = shiftFor(1 - easeProgress, direction, fontSize * 2);
1189
- const alpha = easeOutQuad(progress);
1190
- return translateGlyphOps(ops, shift.dx, shift.dy, alpha);
1262
+ function applySlideInAnimation(ops, lines, progress, direction, fontSize, style, duration) {
1263
+ const byWord = style === "word";
1264
+ const byCharacter = style === "character";
1265
+ if (!byWord && !byCharacter) {
1266
+ const easeProgress = easeOutCubic(progress);
1267
+ const shift = shiftFor(1 - easeProgress, direction, fontSize * 2);
1268
+ const alpha = easeOutQuad(progress);
1269
+ return translateGlyphOps(ops, shift.dx, shift.dy, alpha);
1270
+ }
1271
+ const startOffsets = {
1272
+ left: { x: fontSize * 2, y: 0 },
1273
+ right: { x: -fontSize * 2, y: 0 },
1274
+ up: { x: 0, y: fontSize * 2 },
1275
+ down: { x: 0, y: -fontSize * 2 }
1276
+ };
1277
+ const offset = startOffsets[direction];
1278
+ const wordSegments = byWord ? getWordSegments(lines) : [];
1279
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1280
+ const totalUnits = byWord ? wordSegments.length : totalGlyphs;
1281
+ if (totalUnits === 0) return ops;
1282
+ const result = [];
1283
+ for (const op of ops) {
1284
+ if (op.op === "BeginFrame") {
1285
+ result.push(op);
1286
+ break;
1287
+ }
1288
+ }
1289
+ const windowDuration = 0.3;
1290
+ const overlapFactor = 0.7;
1291
+ const staggerDelay = duration * overlapFactor / Math.max(1, totalUnits - 1);
1292
+ const windowFor = (unitIdx) => {
1293
+ const startTime = unitIdx * staggerDelay;
1294
+ const startF = startTime / duration;
1295
+ const endF = Math.min(1, (startTime + windowDuration) / duration);
1296
+ return { startF, endF };
1297
+ };
1298
+ let glyphIndex = 0;
1299
+ for (const op of ops) {
1300
+ if (op.op !== "FillPath" && op.op !== "StrokePath") {
1301
+ if (op.op === "DecorationLine" && progress > 0.8) result.push(op);
1302
+ continue;
1303
+ }
1304
+ let unitIndex;
1305
+ if (!byWord) {
1306
+ unitIndex = glyphIndex;
1307
+ } else {
1308
+ let wordIndex = -1, acc = 0;
1309
+ for (let i = 0; i < wordSegments.length; i++) {
1310
+ const gcount = wordSegments[i].glyphCount;
1311
+ if (glyphIndex >= acc && glyphIndex < acc + gcount) {
1312
+ wordIndex = i;
1313
+ break;
1314
+ }
1315
+ acc += gcount;
1316
+ }
1317
+ unitIndex = Math.max(0, wordIndex);
1318
+ }
1319
+ const { startF, endF } = windowFor(unitIndex);
1320
+ if (progress <= startF) {
1321
+ const animated = { ...op, x: op.x + offset.x, y: op.y + offset.y };
1322
+ if (op.op === "FillPath") {
1323
+ if (animated.fill.kind === "solid") animated.fill = { ...animated.fill, opacity: 0 };
1324
+ else animated.fill = { ...animated.fill, opacity: 0 };
1325
+ } else {
1326
+ animated.opacity = 0;
1327
+ }
1328
+ result.push(animated);
1329
+ } else if (progress >= endF) {
1330
+ result.push(op);
1331
+ } else {
1332
+ const local = (progress - startF) / Math.max(1e-6, endF - startF);
1333
+ const ease = easeOutCubic(Math.min(1, local));
1334
+ const dx = offset.x * (1 - ease);
1335
+ const dy = offset.y * (1 - ease);
1336
+ const animated = { ...op, x: op.x + dx, y: op.y + dy };
1337
+ if (op.op === "FillPath") {
1338
+ const targetOpacity = animated.fill.kind === "solid" ? animated.fill.opacity : animated.fill.opacity ?? 1;
1339
+ if (animated.fill.kind === "solid")
1340
+ animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1341
+ else animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1342
+ } else {
1343
+ animated.opacity = animated.opacity * ease;
1344
+ }
1345
+ result.push(animated);
1346
+ }
1347
+ if (isGlyphFill(op)) glyphIndex++;
1348
+ }
1349
+ return result;
1191
1350
  }
1192
- function applyMovingLettersAnimation(ops, progress, direction, fontSize) {
1351
+ function applyMovingLettersAnimation(ops, time, direction, fontSize) {
1193
1352
  const amp = fontSize * 0.3;
1194
- return waveTransform(ops, direction, amp, progress);
1353
+ return waveTransform(ops, direction, amp, time);
1195
1354
  }
1196
1355
  function getWordSegments(lines) {
1197
1356
  const segments = [];
@@ -1349,14 +1508,15 @@ function translateGlyphOps(ops, dx, dy, alpha = 1) {
1349
1508
  return op;
1350
1509
  });
1351
1510
  }
1352
- function waveTransform(ops, dir, amp, p) {
1511
+ function waveTransform(ops, dir, amp, time) {
1353
1512
  let glyphIndex = 0;
1513
+ const fadeInDuration = 0.5;
1514
+ const waveAlpha = Math.min(1, time / fadeInDuration);
1354
1515
  return ops.map((op) => {
1355
1516
  if (op.op === "FillPath" || op.op === "StrokePath") {
1356
- const phase = Math.sin(glyphIndex / 5 * Math.PI + p * Math.PI * 4);
1517
+ const phase = Math.sin(glyphIndex / 5 * Math.PI + time * Math.PI * 2);
1357
1518
  const dx = dir === "left" || dir === "right" ? phase * amp * (dir === "left" ? -1 : 1) : 0;
1358
1519
  const dy = dir === "up" || dir === "down" ? phase * amp * (dir === "up" ? -1 : 1) : 0;
1359
- const waveAlpha = Math.min(1, p * 2);
1360
1520
  if (op.op === "FillPath") {
1361
1521
  if (!isShadowFill(op)) glyphIndex++;
1362
1522
  const out = { ...op, x: op.x + dx, y: op.y + dy };
@@ -2083,7 +2243,7 @@ async function createTextEngine(opts = {}) {
2083
2243
  );
2084
2244
  }
2085
2245
  },
2086
- async renderFrame(asset, tSeconds) {
2246
+ async renderFrame(asset, tSeconds, clipDuration) {
2087
2247
  try {
2088
2248
  const main = await ensureFonts(asset);
2089
2249
  const desc = { family: main.family, weight: `${main.weight}`, style: main.style };
@@ -2158,6 +2318,7 @@ async function createTextEngine(opts = {}) {
2158
2318
  const ops = applyAnimation(ops0, lines, {
2159
2319
  t: tSeconds,
2160
2320
  fontSize: main.size,
2321
+ clipDuration,
2161
2322
  anim: asset.animation ? {
2162
2323
  preset: asset.animation.preset,
2163
2324
  speed: asset.animation.speed,
@@ -2205,14 +2366,15 @@ async function createTextEngine(opts = {}) {
2205
2366
  width: asset.width ?? width,
2206
2367
  height: asset.height ?? height,
2207
2368
  fps,
2208
- duration: asset.animation?.duration ?? 3,
2369
+ duration: options.duration ?? 3,
2209
2370
  outputPath: options.outputPath ?? "output.mp4",
2210
2371
  pixelRatio: asset.pixelRatio ?? pixelRatio,
2211
2372
  hasAlpha: needsAlpha,
2212
2373
  ...options
2213
2374
  };
2375
+ const clipDuration = finalOptions.duration;
2214
2376
  const frameGenerator = async (time) => {
2215
- return this.renderFrame(asset, time);
2377
+ return this.renderFrame(asset, time, clipDuration);
2216
2378
  };
2217
2379
  const actualOutputPath = await videoGenerator.generateVideo(frameGenerator, finalOptions);
2218
2380
  return actualOutputPath;
@@ -222,7 +222,7 @@ declare function createTextEngine(opts?: {
222
222
  weight?: string | number;
223
223
  style?: string;
224
224
  }): Promise<void>;
225
- renderFrame(asset: RichTextValidated, tSeconds: number): Promise<DrawOp[]>;
225
+ renderFrame(asset: RichTextValidated, tSeconds: number, clipDuration?: number): Promise<DrawOp[]>;
226
226
  createRenderer(canvas: HTMLCanvasElement | OffscreenCanvas): {
227
227
  render(ops: DrawOp[]): Promise<void>;
228
228
  };
package/dist/entry.web.js CHANGED
@@ -85,7 +85,7 @@ var animationSchema = Joi.object({
85
85
  speed: Joi.number().min(0.1).max(10).default(1),
86
86
  duration: Joi.number().min(CANVAS_CONFIG.LIMITS.minDuration).max(CANVAS_CONFIG.LIMITS.maxDuration).optional(),
87
87
  style: Joi.string().valid("character", "word").optional().when("preset", {
88
- is: Joi.valid("typewriter", "shift"),
88
+ is: Joi.valid("typewriter", "shift", "fadeIn", "slideIn"),
89
89
  then: Joi.optional(),
90
90
  otherwise: Joi.forbidden()
91
91
  }),
@@ -699,13 +699,11 @@ var LayoutEngine = class {
699
699
  const glyph = glyphs[i];
700
700
  const glyphWidth = glyph.xAdvance;
701
701
  if (glyph.char === "\n") {
702
- if (currentLine.length > 0) {
703
- lines.push({
704
- glyphs: currentLine,
705
- width: currentWidth,
706
- y: 0
707
- });
708
- }
702
+ lines.push({
703
+ glyphs: currentLine,
704
+ width: currentWidth,
705
+ y: 0
706
+ });
709
707
  currentLine = [];
710
708
  currentWidth = 0;
711
709
  lastBreakIndex = i;
@@ -964,7 +962,11 @@ function applyAnimation(ops, lines, p) {
964
962
  if (!p.anim || !p.anim.preset) return ops;
965
963
  const { preset } = p.anim;
966
964
  const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
967
- const duration = p.anim.duration ?? Math.max(0.3, totalGlyphs / 30 / p.anim.speed);
965
+ let autoDuration = Math.max(0.3, totalGlyphs / (30 * p.anim.speed));
966
+ if (p.clipDuration && !p.anim.duration) {
967
+ autoDuration = Math.min(autoDuration, p.clipDuration * 0.9);
968
+ }
969
+ const duration = p.anim.duration ?? autoDuration;
968
970
  const progress = Math.max(0, Math.min(1, p.t / duration));
969
971
  switch (preset) {
970
972
  case "typewriter":
@@ -978,9 +980,9 @@ function applyAnimation(ops, lines, p) {
978
980
  duration
979
981
  );
980
982
  case "fadeIn":
981
- return applyFadeInAnimation(ops, progress);
983
+ return applyFadeInAnimation(ops, lines, progress, p.anim.style, p.fontSize, duration);
982
984
  case "slideIn":
983
- return applySlideInAnimation(ops, progress, p.anim.direction ?? "left", p.fontSize);
985
+ return applySlideInAnimation(ops, lines, progress, p.anim.direction ?? "left", p.fontSize, p.anim.style, duration);
984
986
  case "shift":
985
987
  return applyShiftAnimation(
986
988
  ops,
@@ -1001,7 +1003,7 @@ function applyAnimation(ops, lines, p) {
1001
1003
  duration
1002
1004
  );
1003
1005
  case "movingLetters":
1004
- return applyMovingLettersAnimation(ops, progress, p.anim.direction ?? "up", p.fontSize);
1006
+ return applyMovingLettersAnimation(ops, p.t, p.anim.direction ?? "up", p.fontSize);
1005
1007
  default:
1006
1008
  return ops;
1007
1009
  }
@@ -1183,20 +1185,177 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1183
1185
  }
1184
1186
  return result;
1185
1187
  }
1186
- function applyFadeInAnimation(ops, progress) {
1187
- const alpha = easeOutQuad(progress);
1188
- const scale = 0.95 + 0.05 * alpha;
1189
- return scaleAndFade(ops, alpha, scale);
1188
+ function applyFadeInAnimation(ops, lines, progress, style, fontSize, duration) {
1189
+ const byWord = style === "word";
1190
+ const byCharacter = style === "character";
1191
+ if (!byWord && !byCharacter) {
1192
+ const alpha = easeOutQuad(progress);
1193
+ const scale = 0.95 + 0.05 * alpha;
1194
+ return scaleAndFade(ops, alpha, scale);
1195
+ }
1196
+ const wordSegments = byWord ? getWordSegments(lines) : [];
1197
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1198
+ const totalUnits = byWord ? wordSegments.length : totalGlyphs;
1199
+ if (totalUnits === 0) return ops;
1200
+ const result = [];
1201
+ for (const op of ops) {
1202
+ if (op.op === "BeginFrame") {
1203
+ result.push(op);
1204
+ break;
1205
+ }
1206
+ }
1207
+ const windowDuration = 0.3;
1208
+ const overlapFactor = 0.7;
1209
+ const staggerDelay = duration * overlapFactor / Math.max(1, totalUnits - 1);
1210
+ const windowFor = (unitIdx) => {
1211
+ const startTime = unitIdx * staggerDelay;
1212
+ const startF = startTime / duration;
1213
+ const endF = Math.min(1, (startTime + windowDuration) / duration);
1214
+ return { startF, endF };
1215
+ };
1216
+ let glyphIndex = 0;
1217
+ for (const op of ops) {
1218
+ if (op.op !== "FillPath" && op.op !== "StrokePath") {
1219
+ if (op.op === "DecorationLine" && progress > 0.8) result.push(op);
1220
+ continue;
1221
+ }
1222
+ let unitIndex;
1223
+ if (!byWord) {
1224
+ unitIndex = glyphIndex;
1225
+ } else {
1226
+ let wordIndex = -1, acc = 0;
1227
+ for (let i = 0; i < wordSegments.length; i++) {
1228
+ const gcount = wordSegments[i].glyphCount;
1229
+ if (glyphIndex >= acc && glyphIndex < acc + gcount) {
1230
+ wordIndex = i;
1231
+ break;
1232
+ }
1233
+ acc += gcount;
1234
+ }
1235
+ unitIndex = Math.max(0, wordIndex);
1236
+ }
1237
+ const { startF, endF } = windowFor(unitIndex);
1238
+ if (progress <= startF) {
1239
+ const animated = { ...op };
1240
+ if (op.op === "FillPath") {
1241
+ if (animated.fill.kind === "solid") animated.fill = { ...animated.fill, opacity: 0 };
1242
+ else animated.fill = { ...animated.fill, opacity: 0 };
1243
+ } else {
1244
+ animated.opacity = 0;
1245
+ }
1246
+ result.push(animated);
1247
+ } else if (progress >= endF) {
1248
+ result.push(op);
1249
+ } else {
1250
+ const local = (progress - startF) / Math.max(1e-6, endF - startF);
1251
+ const ease = easeOutQuad(Math.min(1, local));
1252
+ const animated = { ...op };
1253
+ if (op.op === "FillPath") {
1254
+ const targetOpacity = animated.fill.kind === "solid" ? animated.fill.opacity : animated.fill.opacity ?? 1;
1255
+ if (animated.fill.kind === "solid")
1256
+ animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1257
+ else animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1258
+ } else {
1259
+ animated.opacity = animated.opacity * ease;
1260
+ }
1261
+ result.push(animated);
1262
+ }
1263
+ if (isGlyphFill(op)) glyphIndex++;
1264
+ }
1265
+ return result;
1190
1266
  }
1191
- function applySlideInAnimation(ops, progress, direction, fontSize) {
1192
- const easeProgress = easeOutCubic(progress);
1193
- const shift = shiftFor(1 - easeProgress, direction, fontSize * 2);
1194
- const alpha = easeOutQuad(progress);
1195
- return translateGlyphOps(ops, shift.dx, shift.dy, alpha);
1267
+ function applySlideInAnimation(ops, lines, progress, direction, fontSize, style, duration) {
1268
+ const byWord = style === "word";
1269
+ const byCharacter = style === "character";
1270
+ if (!byWord && !byCharacter) {
1271
+ const easeProgress = easeOutCubic(progress);
1272
+ const shift = shiftFor(1 - easeProgress, direction, fontSize * 2);
1273
+ const alpha = easeOutQuad(progress);
1274
+ return translateGlyphOps(ops, shift.dx, shift.dy, alpha);
1275
+ }
1276
+ const startOffsets = {
1277
+ left: { x: fontSize * 2, y: 0 },
1278
+ right: { x: -fontSize * 2, y: 0 },
1279
+ up: { x: 0, y: fontSize * 2 },
1280
+ down: { x: 0, y: -fontSize * 2 }
1281
+ };
1282
+ const offset = startOffsets[direction];
1283
+ const wordSegments = byWord ? getWordSegments(lines) : [];
1284
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1285
+ const totalUnits = byWord ? wordSegments.length : totalGlyphs;
1286
+ if (totalUnits === 0) return ops;
1287
+ const result = [];
1288
+ for (const op of ops) {
1289
+ if (op.op === "BeginFrame") {
1290
+ result.push(op);
1291
+ break;
1292
+ }
1293
+ }
1294
+ const windowDuration = 0.3;
1295
+ const overlapFactor = 0.7;
1296
+ const staggerDelay = duration * overlapFactor / Math.max(1, totalUnits - 1);
1297
+ const windowFor = (unitIdx) => {
1298
+ const startTime = unitIdx * staggerDelay;
1299
+ const startF = startTime / duration;
1300
+ const endF = Math.min(1, (startTime + windowDuration) / duration);
1301
+ return { startF, endF };
1302
+ };
1303
+ let glyphIndex = 0;
1304
+ for (const op of ops) {
1305
+ if (op.op !== "FillPath" && op.op !== "StrokePath") {
1306
+ if (op.op === "DecorationLine" && progress > 0.8) result.push(op);
1307
+ continue;
1308
+ }
1309
+ let unitIndex;
1310
+ if (!byWord) {
1311
+ unitIndex = glyphIndex;
1312
+ } else {
1313
+ let wordIndex = -1, acc = 0;
1314
+ for (let i = 0; i < wordSegments.length; i++) {
1315
+ const gcount = wordSegments[i].glyphCount;
1316
+ if (glyphIndex >= acc && glyphIndex < acc + gcount) {
1317
+ wordIndex = i;
1318
+ break;
1319
+ }
1320
+ acc += gcount;
1321
+ }
1322
+ unitIndex = Math.max(0, wordIndex);
1323
+ }
1324
+ const { startF, endF } = windowFor(unitIndex);
1325
+ if (progress <= startF) {
1326
+ const animated = { ...op, x: op.x + offset.x, y: op.y + offset.y };
1327
+ if (op.op === "FillPath") {
1328
+ if (animated.fill.kind === "solid") animated.fill = { ...animated.fill, opacity: 0 };
1329
+ else animated.fill = { ...animated.fill, opacity: 0 };
1330
+ } else {
1331
+ animated.opacity = 0;
1332
+ }
1333
+ result.push(animated);
1334
+ } else if (progress >= endF) {
1335
+ result.push(op);
1336
+ } else {
1337
+ const local = (progress - startF) / Math.max(1e-6, endF - startF);
1338
+ const ease = easeOutCubic(Math.min(1, local));
1339
+ const dx = offset.x * (1 - ease);
1340
+ const dy = offset.y * (1 - ease);
1341
+ const animated = { ...op, x: op.x + dx, y: op.y + dy };
1342
+ if (op.op === "FillPath") {
1343
+ const targetOpacity = animated.fill.kind === "solid" ? animated.fill.opacity : animated.fill.opacity ?? 1;
1344
+ if (animated.fill.kind === "solid")
1345
+ animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1346
+ else animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1347
+ } else {
1348
+ animated.opacity = animated.opacity * ease;
1349
+ }
1350
+ result.push(animated);
1351
+ }
1352
+ if (isGlyphFill(op)) glyphIndex++;
1353
+ }
1354
+ return result;
1196
1355
  }
1197
- function applyMovingLettersAnimation(ops, progress, direction, fontSize) {
1356
+ function applyMovingLettersAnimation(ops, time, direction, fontSize) {
1198
1357
  const amp = fontSize * 0.3;
1199
- return waveTransform(ops, direction, amp, progress);
1358
+ return waveTransform(ops, direction, amp, time);
1200
1359
  }
1201
1360
  function getWordSegments(lines) {
1202
1361
  const segments = [];
@@ -1354,14 +1513,15 @@ function translateGlyphOps(ops, dx, dy, alpha = 1) {
1354
1513
  return op;
1355
1514
  });
1356
1515
  }
1357
- function waveTransform(ops, dir, amp, p) {
1516
+ function waveTransform(ops, dir, amp, time) {
1358
1517
  let glyphIndex = 0;
1518
+ const fadeInDuration = 0.5;
1519
+ const waveAlpha = Math.min(1, time / fadeInDuration);
1359
1520
  return ops.map((op) => {
1360
1521
  if (op.op === "FillPath" || op.op === "StrokePath") {
1361
- const phase = Math.sin(glyphIndex / 5 * Math.PI + p * Math.PI * 4);
1522
+ const phase = Math.sin(glyphIndex / 5 * Math.PI + time * Math.PI * 2);
1362
1523
  const dx = dir === "left" || dir === "right" ? phase * amp * (dir === "left" ? -1 : 1) : 0;
1363
1524
  const dy = dir === "up" || dir === "down" ? phase * amp * (dir === "up" ? -1 : 1) : 0;
1364
- const waveAlpha = Math.min(1, p * 2);
1365
1525
  if (op.op === "FillPath") {
1366
1526
  if (!isShadowFill(op)) glyphIndex++;
1367
1527
  const out = { ...op, x: op.x + dx, y: op.y + dy };
@@ -1804,7 +1964,7 @@ async function createTextEngine(opts = {}) {
1804
1964
  );
1805
1965
  }
1806
1966
  },
1807
- async renderFrame(asset, tSeconds) {
1967
+ async renderFrame(asset, tSeconds, clipDuration) {
1808
1968
  try {
1809
1969
  const main = await ensureFonts(asset);
1810
1970
  const desc = { family: main.family, weight: `${main.weight}`, style: main.style };
@@ -1879,6 +2039,7 @@ async function createTextEngine(opts = {}) {
1879
2039
  const ops = applyAnimation(ops0, lines, {
1880
2040
  t: tSeconds,
1881
2041
  fontSize: main.size,
2042
+ clipDuration,
1882
2043
  anim: asset.animation ? {
1883
2044
  preset: asset.animation.preset,
1884
2045
  speed: asset.animation.speed,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shotstack/shotstack-canvas",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
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",