@shotstack/shotstack-canvas 1.4.0 → 1.4.2

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.
@@ -99,7 +99,6 @@ var fontSchema = import_joi.default.object({
99
99
  family: import_joi.default.string().default(CANVAS_CONFIG.DEFAULTS.fontFamily),
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
- style: import_joi.default.string().valid("normal", "italic", "oblique").default("normal"),
103
102
  color: import_joi.default.string().pattern(HEX6).default(CANVAS_CONFIG.DEFAULTS.color),
104
103
  opacity: import_joi.default.number().min(0).max(1).default(1)
105
104
  }).unknown(false);
@@ -119,7 +118,7 @@ var animationSchema = import_joi.default.object({
119
118
  speed: import_joi.default.number().min(0.1).max(10).default(1),
120
119
  duration: import_joi.default.number().min(CANVAS_CONFIG.LIMITS.minDuration).max(CANVAS_CONFIG.LIMITS.maxDuration).optional(),
121
120
  style: import_joi.default.string().valid("character", "word").optional().when("preset", {
122
- is: import_joi.default.valid("typewriter", "shift"),
121
+ is: import_joi.default.valid("typewriter", "shift", "fadeIn", "slideIn"),
123
122
  then: import_joi.default.optional(),
124
123
  otherwise: import_joi.default.forbidden()
125
124
  }),
@@ -419,8 +418,7 @@ var FontRegistry = class _FontRegistry {
419
418
  return this.hb;
420
419
  }
421
420
  key(desc) {
422
- const normalizedStyle = desc.style === "oblique" ? "italic" : desc.style ?? "normal";
423
- return `${desc.family}__${desc.weight ?? "400"}__${normalizedStyle}`;
421
+ return `${desc.family}__${desc.weight ?? "400"}`;
424
422
  }
425
423
  async registerFromBytes(bytes, desc) {
426
424
  try {
@@ -469,14 +467,12 @@ var FontRegistry = class _FontRegistry {
469
467
  try {
470
468
  const bytes = await loader({
471
469
  family: desc.family,
472
- weight: desc.weight ?? "400",
473
- style: desc.style ?? "normal"
470
+ weight: desc.weight ?? "400"
474
471
  });
475
472
  if (!bytes) return false;
476
473
  await this.registerFromBytes(bytes, {
477
474
  family: desc.family,
478
- weight: desc.weight ?? "400",
479
- style: desc.style ?? "normal"
475
+ weight: desc.weight ?? "400"
480
476
  });
481
477
  return true;
482
478
  } catch {
@@ -733,13 +729,11 @@ var LayoutEngine = class {
733
729
  const glyph = glyphs[i];
734
730
  const glyphWidth = glyph.xAdvance;
735
731
  if (glyph.char === "\n") {
736
- if (currentLine.length > 0) {
737
- lines.push({
738
- glyphs: currentLine,
739
- width: currentWidth,
740
- y: 0
741
- });
742
- }
732
+ lines.push({
733
+ glyphs: currentLine,
734
+ width: currentWidth,
735
+ y: 0
736
+ });
743
737
  currentLine = [];
744
738
  currentWidth = 0;
745
739
  lastBreakIndex = i;
@@ -998,7 +992,11 @@ function applyAnimation(ops, lines, p) {
998
992
  if (!p.anim || !p.anim.preset) return ops;
999
993
  const { preset } = p.anim;
1000
994
  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);
995
+ let autoDuration = Math.max(0.3, totalGlyphs / (30 * p.anim.speed));
996
+ if (p.clipDuration && !p.anim.duration) {
997
+ autoDuration = Math.min(autoDuration, p.clipDuration * 0.9);
998
+ }
999
+ const duration = p.anim.duration ?? autoDuration;
1002
1000
  const progress = Math.max(0, Math.min(1, p.t / duration));
1003
1001
  switch (preset) {
1004
1002
  case "typewriter":
@@ -1012,9 +1010,9 @@ function applyAnimation(ops, lines, p) {
1012
1010
  duration
1013
1011
  );
1014
1012
  case "fadeIn":
1015
- return applyFadeInAnimation(ops, progress);
1013
+ return applyFadeInAnimation(ops, lines, progress, p.anim.style, p.fontSize, duration);
1016
1014
  case "slideIn":
1017
- return applySlideInAnimation(ops, progress, p.anim.direction ?? "left", p.fontSize);
1015
+ return applySlideInAnimation(ops, lines, progress, p.anim.direction ?? "left", p.fontSize, p.anim.style, duration);
1018
1016
  case "shift":
1019
1017
  return applyShiftAnimation(
1020
1018
  ops,
@@ -1035,7 +1033,7 @@ function applyAnimation(ops, lines, p) {
1035
1033
  duration
1036
1034
  );
1037
1035
  case "movingLetters":
1038
- return applyMovingLettersAnimation(ops, progress, p.anim.direction ?? "up", p.fontSize);
1036
+ return applyMovingLettersAnimation(ops, p.t, p.anim.direction ?? "up", p.fontSize);
1039
1037
  default:
1040
1038
  return ops;
1041
1039
  }
@@ -1217,20 +1215,177 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1217
1215
  }
1218
1216
  return result;
1219
1217
  }
1220
- function applyFadeInAnimation(ops, progress) {
1221
- const alpha = easeOutQuad(progress);
1222
- const scale = 0.95 + 0.05 * alpha;
1223
- return scaleAndFade(ops, alpha, scale);
1218
+ function applyFadeInAnimation(ops, lines, progress, style, fontSize, duration) {
1219
+ const byWord = style === "word";
1220
+ const byCharacter = style === "character";
1221
+ if (!byWord && !byCharacter) {
1222
+ const alpha = easeOutQuad(progress);
1223
+ const scale = 0.95 + 0.05 * alpha;
1224
+ return scaleAndFade(ops, alpha, scale);
1225
+ }
1226
+ const wordSegments = byWord ? getWordSegments(lines) : [];
1227
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1228
+ const totalUnits = byWord ? wordSegments.length : totalGlyphs;
1229
+ if (totalUnits === 0) return ops;
1230
+ const result = [];
1231
+ for (const op of ops) {
1232
+ if (op.op === "BeginFrame") {
1233
+ result.push(op);
1234
+ break;
1235
+ }
1236
+ }
1237
+ const windowDuration = 0.3;
1238
+ const overlapFactor = 0.7;
1239
+ const staggerDelay = duration * overlapFactor / Math.max(1, totalUnits - 1);
1240
+ const windowFor = (unitIdx) => {
1241
+ const startTime = unitIdx * staggerDelay;
1242
+ const startF = startTime / duration;
1243
+ const endF = Math.min(1, (startTime + windowDuration) / duration);
1244
+ return { startF, endF };
1245
+ };
1246
+ let glyphIndex = 0;
1247
+ for (const op of ops) {
1248
+ if (op.op !== "FillPath" && op.op !== "StrokePath") {
1249
+ if (op.op === "DecorationLine" && progress > 0.8) result.push(op);
1250
+ continue;
1251
+ }
1252
+ let unitIndex;
1253
+ if (!byWord) {
1254
+ unitIndex = glyphIndex;
1255
+ } else {
1256
+ let wordIndex = -1, acc = 0;
1257
+ for (let i = 0; i < wordSegments.length; i++) {
1258
+ const gcount = wordSegments[i].glyphCount;
1259
+ if (glyphIndex >= acc && glyphIndex < acc + gcount) {
1260
+ wordIndex = i;
1261
+ break;
1262
+ }
1263
+ acc += gcount;
1264
+ }
1265
+ unitIndex = Math.max(0, wordIndex);
1266
+ }
1267
+ const { startF, endF } = windowFor(unitIndex);
1268
+ if (progress <= startF) {
1269
+ const animated = { ...op };
1270
+ if (op.op === "FillPath") {
1271
+ if (animated.fill.kind === "solid") animated.fill = { ...animated.fill, opacity: 0 };
1272
+ else animated.fill = { ...animated.fill, opacity: 0 };
1273
+ } else {
1274
+ animated.opacity = 0;
1275
+ }
1276
+ result.push(animated);
1277
+ } else if (progress >= endF) {
1278
+ result.push(op);
1279
+ } else {
1280
+ const local = (progress - startF) / Math.max(1e-6, endF - startF);
1281
+ const ease = easeOutQuad(Math.min(1, local));
1282
+ const animated = { ...op };
1283
+ if (op.op === "FillPath") {
1284
+ const targetOpacity = animated.fill.kind === "solid" ? animated.fill.opacity : animated.fill.opacity ?? 1;
1285
+ if (animated.fill.kind === "solid")
1286
+ animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1287
+ else animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1288
+ } else {
1289
+ animated.opacity = animated.opacity * ease;
1290
+ }
1291
+ result.push(animated);
1292
+ }
1293
+ if (isGlyphFill(op)) glyphIndex++;
1294
+ }
1295
+ return result;
1224
1296
  }
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);
1297
+ function applySlideInAnimation(ops, lines, progress, direction, fontSize, style, duration) {
1298
+ const byWord = style === "word";
1299
+ const byCharacter = style === "character";
1300
+ if (!byWord && !byCharacter) {
1301
+ const easeProgress = easeOutCubic(progress);
1302
+ const shift = shiftFor(1 - easeProgress, direction, fontSize * 2);
1303
+ const alpha = easeOutQuad(progress);
1304
+ return translateGlyphOps(ops, shift.dx, shift.dy, alpha);
1305
+ }
1306
+ const startOffsets = {
1307
+ left: { x: fontSize * 2, y: 0 },
1308
+ right: { x: -fontSize * 2, y: 0 },
1309
+ up: { x: 0, y: fontSize * 2 },
1310
+ down: { x: 0, y: -fontSize * 2 }
1311
+ };
1312
+ const offset = startOffsets[direction];
1313
+ const wordSegments = byWord ? getWordSegments(lines) : [];
1314
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1315
+ const totalUnits = byWord ? wordSegments.length : totalGlyphs;
1316
+ if (totalUnits === 0) return ops;
1317
+ const result = [];
1318
+ for (const op of ops) {
1319
+ if (op.op === "BeginFrame") {
1320
+ result.push(op);
1321
+ break;
1322
+ }
1323
+ }
1324
+ const windowDuration = 0.3;
1325
+ const overlapFactor = 0.7;
1326
+ const staggerDelay = duration * overlapFactor / Math.max(1, totalUnits - 1);
1327
+ const windowFor = (unitIdx) => {
1328
+ const startTime = unitIdx * staggerDelay;
1329
+ const startF = startTime / duration;
1330
+ const endF = Math.min(1, (startTime + windowDuration) / duration);
1331
+ return { startF, endF };
1332
+ };
1333
+ let glyphIndex = 0;
1334
+ for (const op of ops) {
1335
+ if (op.op !== "FillPath" && op.op !== "StrokePath") {
1336
+ if (op.op === "DecorationLine" && progress > 0.8) result.push(op);
1337
+ continue;
1338
+ }
1339
+ let unitIndex;
1340
+ if (!byWord) {
1341
+ unitIndex = glyphIndex;
1342
+ } else {
1343
+ let wordIndex = -1, acc = 0;
1344
+ for (let i = 0; i < wordSegments.length; i++) {
1345
+ const gcount = wordSegments[i].glyphCount;
1346
+ if (glyphIndex >= acc && glyphIndex < acc + gcount) {
1347
+ wordIndex = i;
1348
+ break;
1349
+ }
1350
+ acc += gcount;
1351
+ }
1352
+ unitIndex = Math.max(0, wordIndex);
1353
+ }
1354
+ const { startF, endF } = windowFor(unitIndex);
1355
+ if (progress <= startF) {
1356
+ const animated = { ...op, x: op.x + offset.x, y: op.y + offset.y };
1357
+ if (op.op === "FillPath") {
1358
+ if (animated.fill.kind === "solid") animated.fill = { ...animated.fill, opacity: 0 };
1359
+ else animated.fill = { ...animated.fill, opacity: 0 };
1360
+ } else {
1361
+ animated.opacity = 0;
1362
+ }
1363
+ result.push(animated);
1364
+ } else if (progress >= endF) {
1365
+ result.push(op);
1366
+ } else {
1367
+ const local = (progress - startF) / Math.max(1e-6, endF - startF);
1368
+ const ease = easeOutCubic(Math.min(1, local));
1369
+ const dx = offset.x * (1 - ease);
1370
+ const dy = offset.y * (1 - ease);
1371
+ const animated = { ...op, x: op.x + dx, y: op.y + dy };
1372
+ if (op.op === "FillPath") {
1373
+ const targetOpacity = animated.fill.kind === "solid" ? animated.fill.opacity : animated.fill.opacity ?? 1;
1374
+ if (animated.fill.kind === "solid")
1375
+ animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1376
+ else animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1377
+ } else {
1378
+ animated.opacity = animated.opacity * ease;
1379
+ }
1380
+ result.push(animated);
1381
+ }
1382
+ if (isGlyphFill(op)) glyphIndex++;
1383
+ }
1384
+ return result;
1230
1385
  }
1231
- function applyMovingLettersAnimation(ops, progress, direction, fontSize) {
1386
+ function applyMovingLettersAnimation(ops, time, direction, fontSize) {
1232
1387
  const amp = fontSize * 0.3;
1233
- return waveTransform(ops, direction, amp, progress);
1388
+ return waveTransform(ops, direction, amp, time);
1234
1389
  }
1235
1390
  function getWordSegments(lines) {
1236
1391
  const segments = [];
@@ -1388,14 +1543,15 @@ function translateGlyphOps(ops, dx, dy, alpha = 1) {
1388
1543
  return op;
1389
1544
  });
1390
1545
  }
1391
- function waveTransform(ops, dir, amp, p) {
1546
+ function waveTransform(ops, dir, amp, time) {
1392
1547
  let glyphIndex = 0;
1548
+ const fadeInDuration = 0.5;
1549
+ const waveAlpha = Math.min(1, time / fadeInDuration);
1393
1550
  return ops.map((op) => {
1394
1551
  if (op.op === "FillPath" || op.op === "StrokePath") {
1395
- const phase = Math.sin(glyphIndex / 5 * Math.PI + p * Math.PI * 4);
1552
+ const phase = Math.sin(glyphIndex / 5 * Math.PI + time * Math.PI * 2);
1396
1553
  const dx = dir === "left" || dir === "right" ? phase * amp * (dir === "left" ? -1 : 1) : 0;
1397
1554
  const dy = dir === "up" || dir === "down" ? phase * amp * (dir === "up" ? -1 : 1) : 0;
1398
- const waveAlpha = Math.min(1, p * 2);
1399
1555
  if (op.op === "FillPath") {
1400
1556
  if (!isShadowFill(op)) glyphIndex++;
1401
1557
  const out = { ...op, x: op.x + dx, y: op.y + dy };
@@ -2060,8 +2216,7 @@ async function createTextEngine(opts = {}) {
2060
2216
  const bytes = await loadFileOrHttpToArrayBuffer(cf.src);
2061
2217
  await fonts.registerFromBytes(bytes, {
2062
2218
  family: cf.family,
2063
- weight: cf.weight ?? "400",
2064
- style: cf.style ?? "normal"
2219
+ weight: cf.weight ?? "400"
2065
2220
  });
2066
2221
  } catch (err) {
2067
2222
  throw new Error(
@@ -2073,7 +2228,6 @@ async function createTextEngine(opts = {}) {
2073
2228
  const main = asset.font ?? {
2074
2229
  family: "Roboto",
2075
2230
  weight: "400",
2076
- style: "normal",
2077
2231
  size: 48,
2078
2232
  color: "#000000",
2079
2233
  opacity: 1
@@ -2122,13 +2276,13 @@ async function createTextEngine(opts = {}) {
2122
2276
  );
2123
2277
  }
2124
2278
  },
2125
- async renderFrame(asset, tSeconds) {
2279
+ async renderFrame(asset, tSeconds, clipDuration) {
2126
2280
  try {
2127
2281
  const main = await ensureFonts(asset);
2128
- const desc = { family: main.family, weight: `${main.weight}`, style: main.style };
2282
+ const desc = { family: main.family, weight: `${main.weight}` };
2129
2283
  let lines;
2130
2284
  try {
2131
- const emojiDesc = { family: "NotoEmoji", weight: "400", style: "normal" };
2285
+ const emojiDesc = { family: "NotoEmoji", weight: "400" };
2132
2286
  let emojiAvailable = false;
2133
2287
  try {
2134
2288
  await fonts.getFace(emojiDesc);
@@ -2169,7 +2323,6 @@ async function createTextEngine(opts = {}) {
2169
2323
  family: main.family,
2170
2324
  size: main.size,
2171
2325
  weight: `${main.weight}`,
2172
- style: main.style,
2173
2326
  color: main.color,
2174
2327
  opacity: main.opacity
2175
2328
  },
@@ -2197,6 +2350,7 @@ async function createTextEngine(opts = {}) {
2197
2350
  const ops = applyAnimation(ops0, lines, {
2198
2351
  t: tSeconds,
2199
2352
  fontSize: main.size,
2353
+ clipDuration,
2200
2354
  anim: asset.animation ? {
2201
2355
  preset: asset.animation.preset,
2202
2356
  speed: asset.animation.speed,
@@ -2244,14 +2398,15 @@ async function createTextEngine(opts = {}) {
2244
2398
  width: asset.width ?? width,
2245
2399
  height: asset.height ?? height,
2246
2400
  fps,
2247
- duration: asset.animation?.duration ?? 3,
2401
+ duration: options.duration ?? 3,
2248
2402
  outputPath: options.outputPath ?? "output.mp4",
2249
2403
  pixelRatio: asset.pixelRatio ?? pixelRatio,
2250
2404
  hasAlpha: needsAlpha,
2251
2405
  ...options
2252
2406
  };
2407
+ const clipDuration = finalOptions.duration;
2253
2408
  const frameGenerator = async (time) => {
2254
- return this.renderFrame(asset, time);
2409
+ return this.renderFrame(asset, time, clipDuration);
2255
2410
  };
2256
2411
  const actualOutputPath = await videoGenerator.generateVideo(frameGenerator, finalOptions);
2257
2412
  return actualOutputPath;
@@ -31,7 +31,6 @@ type RichTextValidated = Required<{
31
31
  family: string;
32
32
  size: number;
33
33
  weight: string | number;
34
- style: "normal" | "italic" | "oblique";
35
34
  color: string;
36
35
  opacity: number;
37
36
  };
@@ -124,7 +123,6 @@ type Glyph = {
124
123
  fontDesc?: {
125
124
  family: string;
126
125
  weight?: string | number;
127
- style?: string;
128
126
  };
129
127
  };
130
128
  type ShapedLine = {
@@ -232,14 +230,12 @@ declare function createTextEngine(opts?: {
232
230
  registerFontFromFile(path: string, desc: {
233
231
  family: string;
234
232
  weight?: string | number;
235
- style?: string;
236
233
  }): Promise<void>;
237
234
  registerFontFromUrl(url: string, desc: {
238
235
  family: string;
239
236
  weight?: string | number;
240
- style?: string;
241
237
  }): Promise<void>;
242
- renderFrame(asset: RichTextValidated, tSeconds: number): Promise<DrawOp[]>;
238
+ renderFrame(asset: RichTextValidated, tSeconds: number, clipDuration?: number): Promise<DrawOp[]>;
243
239
  createRenderer(p: {
244
240
  width?: number;
245
241
  height?: number;
@@ -31,7 +31,6 @@ type RichTextValidated = Required<{
31
31
  family: string;
32
32
  size: number;
33
33
  weight: string | number;
34
- style: "normal" | "italic" | "oblique";
35
34
  color: string;
36
35
  opacity: number;
37
36
  };
@@ -124,7 +123,6 @@ type Glyph = {
124
123
  fontDesc?: {
125
124
  family: string;
126
125
  weight?: string | number;
127
- style?: string;
128
126
  };
129
127
  };
130
128
  type ShapedLine = {
@@ -232,14 +230,12 @@ declare function createTextEngine(opts?: {
232
230
  registerFontFromFile(path: string, desc: {
233
231
  family: string;
234
232
  weight?: string | number;
235
- style?: string;
236
233
  }): Promise<void>;
237
234
  registerFontFromUrl(url: string, desc: {
238
235
  family: string;
239
236
  weight?: string | number;
240
- style?: string;
241
237
  }): Promise<void>;
242
- renderFrame(asset: RichTextValidated, tSeconds: number): Promise<DrawOp[]>;
238
+ renderFrame(asset: RichTextValidated, tSeconds: number, clipDuration?: number): Promise<DrawOp[]>;
243
239
  createRenderer(p: {
244
240
  width?: number;
245
241
  height?: number;
@@ -61,7 +61,6 @@ var fontSchema = Joi.object({
61
61
  family: Joi.string().default(CANVAS_CONFIG.DEFAULTS.fontFamily),
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
- style: Joi.string().valid("normal", "italic", "oblique").default("normal"),
65
64
  color: Joi.string().pattern(HEX6).default(CANVAS_CONFIG.DEFAULTS.color),
66
65
  opacity: Joi.number().min(0).max(1).default(1)
67
66
  }).unknown(false);
@@ -81,7 +80,7 @@ var animationSchema = Joi.object({
81
80
  speed: Joi.number().min(0.1).max(10).default(1),
82
81
  duration: Joi.number().min(CANVAS_CONFIG.LIMITS.minDuration).max(CANVAS_CONFIG.LIMITS.maxDuration).optional(),
83
82
  style: Joi.string().valid("character", "word").optional().when("preset", {
84
- is: Joi.valid("typewriter", "shift"),
83
+ is: Joi.valid("typewriter", "shift", "fadeIn", "slideIn"),
85
84
  then: Joi.optional(),
86
85
  otherwise: Joi.forbidden()
87
86
  }),
@@ -380,8 +379,7 @@ var FontRegistry = class _FontRegistry {
380
379
  return this.hb;
381
380
  }
382
381
  key(desc) {
383
- const normalizedStyle = desc.style === "oblique" ? "italic" : desc.style ?? "normal";
384
- return `${desc.family}__${desc.weight ?? "400"}__${normalizedStyle}`;
382
+ return `${desc.family}__${desc.weight ?? "400"}`;
385
383
  }
386
384
  async registerFromBytes(bytes, desc) {
387
385
  try {
@@ -430,14 +428,12 @@ var FontRegistry = class _FontRegistry {
430
428
  try {
431
429
  const bytes = await loader({
432
430
  family: desc.family,
433
- weight: desc.weight ?? "400",
434
- style: desc.style ?? "normal"
431
+ weight: desc.weight ?? "400"
435
432
  });
436
433
  if (!bytes) return false;
437
434
  await this.registerFromBytes(bytes, {
438
435
  family: desc.family,
439
- weight: desc.weight ?? "400",
440
- style: desc.style ?? "normal"
436
+ weight: desc.weight ?? "400"
441
437
  });
442
438
  return true;
443
439
  } catch {
@@ -694,13 +690,11 @@ var LayoutEngine = class {
694
690
  const glyph = glyphs[i];
695
691
  const glyphWidth = glyph.xAdvance;
696
692
  if (glyph.char === "\n") {
697
- if (currentLine.length > 0) {
698
- lines.push({
699
- glyphs: currentLine,
700
- width: currentWidth,
701
- y: 0
702
- });
703
- }
693
+ lines.push({
694
+ glyphs: currentLine,
695
+ width: currentWidth,
696
+ y: 0
697
+ });
704
698
  currentLine = [];
705
699
  currentWidth = 0;
706
700
  lastBreakIndex = i;
@@ -959,7 +953,11 @@ function applyAnimation(ops, lines, p) {
959
953
  if (!p.anim || !p.anim.preset) return ops;
960
954
  const { preset } = p.anim;
961
955
  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);
956
+ let autoDuration = Math.max(0.3, totalGlyphs / (30 * p.anim.speed));
957
+ if (p.clipDuration && !p.anim.duration) {
958
+ autoDuration = Math.min(autoDuration, p.clipDuration * 0.9);
959
+ }
960
+ const duration = p.anim.duration ?? autoDuration;
963
961
  const progress = Math.max(0, Math.min(1, p.t / duration));
964
962
  switch (preset) {
965
963
  case "typewriter":
@@ -973,9 +971,9 @@ function applyAnimation(ops, lines, p) {
973
971
  duration
974
972
  );
975
973
  case "fadeIn":
976
- return applyFadeInAnimation(ops, progress);
974
+ return applyFadeInAnimation(ops, lines, progress, p.anim.style, p.fontSize, duration);
977
975
  case "slideIn":
978
- return applySlideInAnimation(ops, progress, p.anim.direction ?? "left", p.fontSize);
976
+ return applySlideInAnimation(ops, lines, progress, p.anim.direction ?? "left", p.fontSize, p.anim.style, duration);
979
977
  case "shift":
980
978
  return applyShiftAnimation(
981
979
  ops,
@@ -996,7 +994,7 @@ function applyAnimation(ops, lines, p) {
996
994
  duration
997
995
  );
998
996
  case "movingLetters":
999
- return applyMovingLettersAnimation(ops, progress, p.anim.direction ?? "up", p.fontSize);
997
+ return applyMovingLettersAnimation(ops, p.t, p.anim.direction ?? "up", p.fontSize);
1000
998
  default:
1001
999
  return ops;
1002
1000
  }
@@ -1178,20 +1176,177 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1178
1176
  }
1179
1177
  return result;
1180
1178
  }
1181
- function applyFadeInAnimation(ops, progress) {
1182
- const alpha = easeOutQuad(progress);
1183
- const scale = 0.95 + 0.05 * alpha;
1184
- return scaleAndFade(ops, alpha, scale);
1179
+ function applyFadeInAnimation(ops, lines, progress, style, fontSize, duration) {
1180
+ const byWord = style === "word";
1181
+ const byCharacter = style === "character";
1182
+ if (!byWord && !byCharacter) {
1183
+ const alpha = easeOutQuad(progress);
1184
+ const scale = 0.95 + 0.05 * alpha;
1185
+ return scaleAndFade(ops, alpha, scale);
1186
+ }
1187
+ const wordSegments = byWord ? getWordSegments(lines) : [];
1188
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1189
+ const totalUnits = byWord ? wordSegments.length : totalGlyphs;
1190
+ if (totalUnits === 0) return ops;
1191
+ const result = [];
1192
+ for (const op of ops) {
1193
+ if (op.op === "BeginFrame") {
1194
+ result.push(op);
1195
+ break;
1196
+ }
1197
+ }
1198
+ const windowDuration = 0.3;
1199
+ const overlapFactor = 0.7;
1200
+ const staggerDelay = duration * overlapFactor / Math.max(1, totalUnits - 1);
1201
+ const windowFor = (unitIdx) => {
1202
+ const startTime = unitIdx * staggerDelay;
1203
+ const startF = startTime / duration;
1204
+ const endF = Math.min(1, (startTime + windowDuration) / duration);
1205
+ return { startF, endF };
1206
+ };
1207
+ let glyphIndex = 0;
1208
+ for (const op of ops) {
1209
+ if (op.op !== "FillPath" && op.op !== "StrokePath") {
1210
+ if (op.op === "DecorationLine" && progress > 0.8) result.push(op);
1211
+ continue;
1212
+ }
1213
+ let unitIndex;
1214
+ if (!byWord) {
1215
+ unitIndex = glyphIndex;
1216
+ } else {
1217
+ let wordIndex = -1, acc = 0;
1218
+ for (let i = 0; i < wordSegments.length; i++) {
1219
+ const gcount = wordSegments[i].glyphCount;
1220
+ if (glyphIndex >= acc && glyphIndex < acc + gcount) {
1221
+ wordIndex = i;
1222
+ break;
1223
+ }
1224
+ acc += gcount;
1225
+ }
1226
+ unitIndex = Math.max(0, wordIndex);
1227
+ }
1228
+ const { startF, endF } = windowFor(unitIndex);
1229
+ if (progress <= startF) {
1230
+ const animated = { ...op };
1231
+ if (op.op === "FillPath") {
1232
+ if (animated.fill.kind === "solid") animated.fill = { ...animated.fill, opacity: 0 };
1233
+ else animated.fill = { ...animated.fill, opacity: 0 };
1234
+ } else {
1235
+ animated.opacity = 0;
1236
+ }
1237
+ result.push(animated);
1238
+ } else if (progress >= endF) {
1239
+ result.push(op);
1240
+ } else {
1241
+ const local = (progress - startF) / Math.max(1e-6, endF - startF);
1242
+ const ease = easeOutQuad(Math.min(1, local));
1243
+ const animated = { ...op };
1244
+ if (op.op === "FillPath") {
1245
+ const targetOpacity = animated.fill.kind === "solid" ? animated.fill.opacity : animated.fill.opacity ?? 1;
1246
+ if (animated.fill.kind === "solid")
1247
+ animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1248
+ else animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1249
+ } else {
1250
+ animated.opacity = animated.opacity * ease;
1251
+ }
1252
+ result.push(animated);
1253
+ }
1254
+ if (isGlyphFill(op)) glyphIndex++;
1255
+ }
1256
+ return result;
1185
1257
  }
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);
1258
+ function applySlideInAnimation(ops, lines, progress, direction, fontSize, style, duration) {
1259
+ const byWord = style === "word";
1260
+ const byCharacter = style === "character";
1261
+ if (!byWord && !byCharacter) {
1262
+ const easeProgress = easeOutCubic(progress);
1263
+ const shift = shiftFor(1 - easeProgress, direction, fontSize * 2);
1264
+ const alpha = easeOutQuad(progress);
1265
+ return translateGlyphOps(ops, shift.dx, shift.dy, alpha);
1266
+ }
1267
+ const startOffsets = {
1268
+ left: { x: fontSize * 2, y: 0 },
1269
+ right: { x: -fontSize * 2, y: 0 },
1270
+ up: { x: 0, y: fontSize * 2 },
1271
+ down: { x: 0, y: -fontSize * 2 }
1272
+ };
1273
+ const offset = startOffsets[direction];
1274
+ const wordSegments = byWord ? getWordSegments(lines) : [];
1275
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1276
+ const totalUnits = byWord ? wordSegments.length : totalGlyphs;
1277
+ if (totalUnits === 0) return ops;
1278
+ const result = [];
1279
+ for (const op of ops) {
1280
+ if (op.op === "BeginFrame") {
1281
+ result.push(op);
1282
+ break;
1283
+ }
1284
+ }
1285
+ const windowDuration = 0.3;
1286
+ const overlapFactor = 0.7;
1287
+ const staggerDelay = duration * overlapFactor / Math.max(1, totalUnits - 1);
1288
+ const windowFor = (unitIdx) => {
1289
+ const startTime = unitIdx * staggerDelay;
1290
+ const startF = startTime / duration;
1291
+ const endF = Math.min(1, (startTime + windowDuration) / duration);
1292
+ return { startF, endF };
1293
+ };
1294
+ let glyphIndex = 0;
1295
+ for (const op of ops) {
1296
+ if (op.op !== "FillPath" && op.op !== "StrokePath") {
1297
+ if (op.op === "DecorationLine" && progress > 0.8) result.push(op);
1298
+ continue;
1299
+ }
1300
+ let unitIndex;
1301
+ if (!byWord) {
1302
+ unitIndex = glyphIndex;
1303
+ } else {
1304
+ let wordIndex = -1, acc = 0;
1305
+ for (let i = 0; i < wordSegments.length; i++) {
1306
+ const gcount = wordSegments[i].glyphCount;
1307
+ if (glyphIndex >= acc && glyphIndex < acc + gcount) {
1308
+ wordIndex = i;
1309
+ break;
1310
+ }
1311
+ acc += gcount;
1312
+ }
1313
+ unitIndex = Math.max(0, wordIndex);
1314
+ }
1315
+ const { startF, endF } = windowFor(unitIndex);
1316
+ if (progress <= startF) {
1317
+ const animated = { ...op, x: op.x + offset.x, y: op.y + offset.y };
1318
+ if (op.op === "FillPath") {
1319
+ if (animated.fill.kind === "solid") animated.fill = { ...animated.fill, opacity: 0 };
1320
+ else animated.fill = { ...animated.fill, opacity: 0 };
1321
+ } else {
1322
+ animated.opacity = 0;
1323
+ }
1324
+ result.push(animated);
1325
+ } else if (progress >= endF) {
1326
+ result.push(op);
1327
+ } else {
1328
+ const local = (progress - startF) / Math.max(1e-6, endF - startF);
1329
+ const ease = easeOutCubic(Math.min(1, local));
1330
+ const dx = offset.x * (1 - ease);
1331
+ const dy = offset.y * (1 - ease);
1332
+ const animated = { ...op, x: op.x + dx, y: op.y + dy };
1333
+ if (op.op === "FillPath") {
1334
+ const targetOpacity = animated.fill.kind === "solid" ? animated.fill.opacity : animated.fill.opacity ?? 1;
1335
+ if (animated.fill.kind === "solid")
1336
+ animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1337
+ else animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1338
+ } else {
1339
+ animated.opacity = animated.opacity * ease;
1340
+ }
1341
+ result.push(animated);
1342
+ }
1343
+ if (isGlyphFill(op)) glyphIndex++;
1344
+ }
1345
+ return result;
1191
1346
  }
1192
- function applyMovingLettersAnimation(ops, progress, direction, fontSize) {
1347
+ function applyMovingLettersAnimation(ops, time, direction, fontSize) {
1193
1348
  const amp = fontSize * 0.3;
1194
- return waveTransform(ops, direction, amp, progress);
1349
+ return waveTransform(ops, direction, amp, time);
1195
1350
  }
1196
1351
  function getWordSegments(lines) {
1197
1352
  const segments = [];
@@ -1349,14 +1504,15 @@ function translateGlyphOps(ops, dx, dy, alpha = 1) {
1349
1504
  return op;
1350
1505
  });
1351
1506
  }
1352
- function waveTransform(ops, dir, amp, p) {
1507
+ function waveTransform(ops, dir, amp, time) {
1353
1508
  let glyphIndex = 0;
1509
+ const fadeInDuration = 0.5;
1510
+ const waveAlpha = Math.min(1, time / fadeInDuration);
1354
1511
  return ops.map((op) => {
1355
1512
  if (op.op === "FillPath" || op.op === "StrokePath") {
1356
- const phase = Math.sin(glyphIndex / 5 * Math.PI + p * Math.PI * 4);
1513
+ const phase = Math.sin(glyphIndex / 5 * Math.PI + time * Math.PI * 2);
1357
1514
  const dx = dir === "left" || dir === "right" ? phase * amp * (dir === "left" ? -1 : 1) : 0;
1358
1515
  const dy = dir === "up" || dir === "down" ? phase * amp * (dir === "up" ? -1 : 1) : 0;
1359
- const waveAlpha = Math.min(1, p * 2);
1360
1516
  if (op.op === "FillPath") {
1361
1517
  if (!isShadowFill(op)) glyphIndex++;
1362
1518
  const out = { ...op, x: op.x + dx, y: op.y + dy };
@@ -2021,8 +2177,7 @@ async function createTextEngine(opts = {}) {
2021
2177
  const bytes = await loadFileOrHttpToArrayBuffer(cf.src);
2022
2178
  await fonts.registerFromBytes(bytes, {
2023
2179
  family: cf.family,
2024
- weight: cf.weight ?? "400",
2025
- style: cf.style ?? "normal"
2180
+ weight: cf.weight ?? "400"
2026
2181
  });
2027
2182
  } catch (err) {
2028
2183
  throw new Error(
@@ -2034,7 +2189,6 @@ async function createTextEngine(opts = {}) {
2034
2189
  const main = asset.font ?? {
2035
2190
  family: "Roboto",
2036
2191
  weight: "400",
2037
- style: "normal",
2038
2192
  size: 48,
2039
2193
  color: "#000000",
2040
2194
  opacity: 1
@@ -2083,13 +2237,13 @@ async function createTextEngine(opts = {}) {
2083
2237
  );
2084
2238
  }
2085
2239
  },
2086
- async renderFrame(asset, tSeconds) {
2240
+ async renderFrame(asset, tSeconds, clipDuration) {
2087
2241
  try {
2088
2242
  const main = await ensureFonts(asset);
2089
- const desc = { family: main.family, weight: `${main.weight}`, style: main.style };
2243
+ const desc = { family: main.family, weight: `${main.weight}` };
2090
2244
  let lines;
2091
2245
  try {
2092
- const emojiDesc = { family: "NotoEmoji", weight: "400", style: "normal" };
2246
+ const emojiDesc = { family: "NotoEmoji", weight: "400" };
2093
2247
  let emojiAvailable = false;
2094
2248
  try {
2095
2249
  await fonts.getFace(emojiDesc);
@@ -2130,7 +2284,6 @@ async function createTextEngine(opts = {}) {
2130
2284
  family: main.family,
2131
2285
  size: main.size,
2132
2286
  weight: `${main.weight}`,
2133
- style: main.style,
2134
2287
  color: main.color,
2135
2288
  opacity: main.opacity
2136
2289
  },
@@ -2158,6 +2311,7 @@ async function createTextEngine(opts = {}) {
2158
2311
  const ops = applyAnimation(ops0, lines, {
2159
2312
  t: tSeconds,
2160
2313
  fontSize: main.size,
2314
+ clipDuration,
2161
2315
  anim: asset.animation ? {
2162
2316
  preset: asset.animation.preset,
2163
2317
  speed: asset.animation.speed,
@@ -2205,14 +2359,15 @@ async function createTextEngine(opts = {}) {
2205
2359
  width: asset.width ?? width,
2206
2360
  height: asset.height ?? height,
2207
2361
  fps,
2208
- duration: asset.animation?.duration ?? 3,
2362
+ duration: options.duration ?? 3,
2209
2363
  outputPath: options.outputPath ?? "output.mp4",
2210
2364
  pixelRatio: asset.pixelRatio ?? pixelRatio,
2211
2365
  hasAlpha: needsAlpha,
2212
2366
  ...options
2213
2367
  };
2368
+ const clipDuration = finalOptions.duration;
2214
2369
  const frameGenerator = async (time) => {
2215
- return this.renderFrame(asset, time);
2370
+ return this.renderFrame(asset, time, clipDuration);
2216
2371
  };
2217
2372
  const actualOutputPath = await videoGenerator.generateVideo(frameGenerator, finalOptions);
2218
2373
  return actualOutputPath;
@@ -31,7 +31,6 @@ type RichTextValidated = Required<{
31
31
  family: string;
32
32
  size: number;
33
33
  weight: string | number;
34
- style: "normal" | "italic" | "oblique";
35
34
  color: string;
36
35
  opacity: number;
37
36
  };
@@ -124,7 +123,6 @@ type Glyph = {
124
123
  fontDesc?: {
125
124
  family: string;
126
125
  weight?: string | number;
127
- style?: string;
128
126
  };
129
127
  };
130
128
  type ShapedLine = {
@@ -215,14 +213,12 @@ declare function createTextEngine(opts?: {
215
213
  registerFontFromUrl(url: string, desc: {
216
214
  family: string;
217
215
  weight?: string | number;
218
- style?: string;
219
216
  }): Promise<void>;
220
217
  registerFontFromFile(source: string | Blob, desc: {
221
218
  family: string;
222
219
  weight?: string | number;
223
- style?: string;
224
220
  }): Promise<void>;
225
- renderFrame(asset: RichTextValidated, tSeconds: number): Promise<DrawOp[]>;
221
+ renderFrame(asset: RichTextValidated, tSeconds: number, clipDuration?: number): Promise<DrawOp[]>;
226
222
  createRenderer(canvas: HTMLCanvasElement | OffscreenCanvas): {
227
223
  render(ops: DrawOp[]): Promise<void>;
228
224
  };
package/dist/entry.web.js CHANGED
@@ -65,7 +65,6 @@ var fontSchema = Joi.object({
65
65
  family: Joi.string().default(CANVAS_CONFIG.DEFAULTS.fontFamily),
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
- style: Joi.string().valid("normal", "italic", "oblique").default("normal"),
69
68
  color: Joi.string().pattern(HEX6).default(CANVAS_CONFIG.DEFAULTS.color),
70
69
  opacity: Joi.number().min(0).max(1).default(1)
71
70
  }).unknown(false);
@@ -85,7 +84,7 @@ var animationSchema = Joi.object({
85
84
  speed: Joi.number().min(0.1).max(10).default(1),
86
85
  duration: Joi.number().min(CANVAS_CONFIG.LIMITS.minDuration).max(CANVAS_CONFIG.LIMITS.maxDuration).optional(),
87
86
  style: Joi.string().valid("character", "word").optional().when("preset", {
88
- is: Joi.valid("typewriter", "shift"),
87
+ is: Joi.valid("typewriter", "shift", "fadeIn", "slideIn"),
89
88
  then: Joi.optional(),
90
89
  otherwise: Joi.forbidden()
91
90
  }),
@@ -383,8 +382,7 @@ var _FontRegistry = class _FontRegistry {
383
382
  return this.hb;
384
383
  }
385
384
  key(desc) {
386
- const normalizedStyle = desc.style === "oblique" ? "italic" : desc.style ?? "normal";
387
- return `${desc.family}__${desc.weight ?? "400"}__${normalizedStyle}`;
385
+ return `${desc.family}__${desc.weight ?? "400"}`;
388
386
  }
389
387
  async registerFromBytes(bytes, desc) {
390
388
  try {
@@ -433,14 +431,12 @@ var _FontRegistry = class _FontRegistry {
433
431
  try {
434
432
  const bytes = await loader({
435
433
  family: desc.family,
436
- weight: desc.weight ?? "400",
437
- style: desc.style ?? "normal"
434
+ weight: desc.weight ?? "400"
438
435
  });
439
436
  if (!bytes) return false;
440
437
  await this.registerFromBytes(bytes, {
441
438
  family: desc.family,
442
- weight: desc.weight ?? "400",
443
- style: desc.style ?? "normal"
439
+ weight: desc.weight ?? "400"
444
440
  });
445
441
  return true;
446
442
  } catch {
@@ -699,13 +695,11 @@ var LayoutEngine = class {
699
695
  const glyph = glyphs[i];
700
696
  const glyphWidth = glyph.xAdvance;
701
697
  if (glyph.char === "\n") {
702
- if (currentLine.length > 0) {
703
- lines.push({
704
- glyphs: currentLine,
705
- width: currentWidth,
706
- y: 0
707
- });
708
- }
698
+ lines.push({
699
+ glyphs: currentLine,
700
+ width: currentWidth,
701
+ y: 0
702
+ });
709
703
  currentLine = [];
710
704
  currentWidth = 0;
711
705
  lastBreakIndex = i;
@@ -964,7 +958,11 @@ function applyAnimation(ops, lines, p) {
964
958
  if (!p.anim || !p.anim.preset) return ops;
965
959
  const { preset } = p.anim;
966
960
  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);
961
+ let autoDuration = Math.max(0.3, totalGlyphs / (30 * p.anim.speed));
962
+ if (p.clipDuration && !p.anim.duration) {
963
+ autoDuration = Math.min(autoDuration, p.clipDuration * 0.9);
964
+ }
965
+ const duration = p.anim.duration ?? autoDuration;
968
966
  const progress = Math.max(0, Math.min(1, p.t / duration));
969
967
  switch (preset) {
970
968
  case "typewriter":
@@ -978,9 +976,9 @@ function applyAnimation(ops, lines, p) {
978
976
  duration
979
977
  );
980
978
  case "fadeIn":
981
- return applyFadeInAnimation(ops, progress);
979
+ return applyFadeInAnimation(ops, lines, progress, p.anim.style, p.fontSize, duration);
982
980
  case "slideIn":
983
- return applySlideInAnimation(ops, progress, p.anim.direction ?? "left", p.fontSize);
981
+ return applySlideInAnimation(ops, lines, progress, p.anim.direction ?? "left", p.fontSize, p.anim.style, duration);
984
982
  case "shift":
985
983
  return applyShiftAnimation(
986
984
  ops,
@@ -1001,7 +999,7 @@ function applyAnimation(ops, lines, p) {
1001
999
  duration
1002
1000
  );
1003
1001
  case "movingLetters":
1004
- return applyMovingLettersAnimation(ops, progress, p.anim.direction ?? "up", p.fontSize);
1002
+ return applyMovingLettersAnimation(ops, p.t, p.anim.direction ?? "up", p.fontSize);
1005
1003
  default:
1006
1004
  return ops;
1007
1005
  }
@@ -1183,20 +1181,177 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1183
1181
  }
1184
1182
  return result;
1185
1183
  }
1186
- function applyFadeInAnimation(ops, progress) {
1187
- const alpha = easeOutQuad(progress);
1188
- const scale = 0.95 + 0.05 * alpha;
1189
- return scaleAndFade(ops, alpha, scale);
1184
+ function applyFadeInAnimation(ops, lines, progress, style, fontSize, duration) {
1185
+ const byWord = style === "word";
1186
+ const byCharacter = style === "character";
1187
+ if (!byWord && !byCharacter) {
1188
+ const alpha = easeOutQuad(progress);
1189
+ const scale = 0.95 + 0.05 * alpha;
1190
+ return scaleAndFade(ops, alpha, scale);
1191
+ }
1192
+ const wordSegments = byWord ? getWordSegments(lines) : [];
1193
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1194
+ const totalUnits = byWord ? wordSegments.length : totalGlyphs;
1195
+ if (totalUnits === 0) return ops;
1196
+ const result = [];
1197
+ for (const op of ops) {
1198
+ if (op.op === "BeginFrame") {
1199
+ result.push(op);
1200
+ break;
1201
+ }
1202
+ }
1203
+ const windowDuration = 0.3;
1204
+ const overlapFactor = 0.7;
1205
+ const staggerDelay = duration * overlapFactor / Math.max(1, totalUnits - 1);
1206
+ const windowFor = (unitIdx) => {
1207
+ const startTime = unitIdx * staggerDelay;
1208
+ const startF = startTime / duration;
1209
+ const endF = Math.min(1, (startTime + windowDuration) / duration);
1210
+ return { startF, endF };
1211
+ };
1212
+ let glyphIndex = 0;
1213
+ for (const op of ops) {
1214
+ if (op.op !== "FillPath" && op.op !== "StrokePath") {
1215
+ if (op.op === "DecorationLine" && progress > 0.8) result.push(op);
1216
+ continue;
1217
+ }
1218
+ let unitIndex;
1219
+ if (!byWord) {
1220
+ unitIndex = glyphIndex;
1221
+ } else {
1222
+ let wordIndex = -1, acc = 0;
1223
+ for (let i = 0; i < wordSegments.length; i++) {
1224
+ const gcount = wordSegments[i].glyphCount;
1225
+ if (glyphIndex >= acc && glyphIndex < acc + gcount) {
1226
+ wordIndex = i;
1227
+ break;
1228
+ }
1229
+ acc += gcount;
1230
+ }
1231
+ unitIndex = Math.max(0, wordIndex);
1232
+ }
1233
+ const { startF, endF } = windowFor(unitIndex);
1234
+ if (progress <= startF) {
1235
+ const animated = { ...op };
1236
+ if (op.op === "FillPath") {
1237
+ if (animated.fill.kind === "solid") animated.fill = { ...animated.fill, opacity: 0 };
1238
+ else animated.fill = { ...animated.fill, opacity: 0 };
1239
+ } else {
1240
+ animated.opacity = 0;
1241
+ }
1242
+ result.push(animated);
1243
+ } else if (progress >= endF) {
1244
+ result.push(op);
1245
+ } else {
1246
+ const local = (progress - startF) / Math.max(1e-6, endF - startF);
1247
+ const ease = easeOutQuad(Math.min(1, local));
1248
+ const animated = { ...op };
1249
+ if (op.op === "FillPath") {
1250
+ const targetOpacity = animated.fill.kind === "solid" ? animated.fill.opacity : animated.fill.opacity ?? 1;
1251
+ if (animated.fill.kind === "solid")
1252
+ animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1253
+ else animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1254
+ } else {
1255
+ animated.opacity = animated.opacity * ease;
1256
+ }
1257
+ result.push(animated);
1258
+ }
1259
+ if (isGlyphFill(op)) glyphIndex++;
1260
+ }
1261
+ return result;
1190
1262
  }
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);
1263
+ function applySlideInAnimation(ops, lines, progress, direction, fontSize, style, duration) {
1264
+ const byWord = style === "word";
1265
+ const byCharacter = style === "character";
1266
+ if (!byWord && !byCharacter) {
1267
+ const easeProgress = easeOutCubic(progress);
1268
+ const shift = shiftFor(1 - easeProgress, direction, fontSize * 2);
1269
+ const alpha = easeOutQuad(progress);
1270
+ return translateGlyphOps(ops, shift.dx, shift.dy, alpha);
1271
+ }
1272
+ const startOffsets = {
1273
+ left: { x: fontSize * 2, y: 0 },
1274
+ right: { x: -fontSize * 2, y: 0 },
1275
+ up: { x: 0, y: fontSize * 2 },
1276
+ down: { x: 0, y: -fontSize * 2 }
1277
+ };
1278
+ const offset = startOffsets[direction];
1279
+ const wordSegments = byWord ? getWordSegments(lines) : [];
1280
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1281
+ const totalUnits = byWord ? wordSegments.length : totalGlyphs;
1282
+ if (totalUnits === 0) return ops;
1283
+ const result = [];
1284
+ for (const op of ops) {
1285
+ if (op.op === "BeginFrame") {
1286
+ result.push(op);
1287
+ break;
1288
+ }
1289
+ }
1290
+ const windowDuration = 0.3;
1291
+ const overlapFactor = 0.7;
1292
+ const staggerDelay = duration * overlapFactor / Math.max(1, totalUnits - 1);
1293
+ const windowFor = (unitIdx) => {
1294
+ const startTime = unitIdx * staggerDelay;
1295
+ const startF = startTime / duration;
1296
+ const endF = Math.min(1, (startTime + windowDuration) / duration);
1297
+ return { startF, endF };
1298
+ };
1299
+ let glyphIndex = 0;
1300
+ for (const op of ops) {
1301
+ if (op.op !== "FillPath" && op.op !== "StrokePath") {
1302
+ if (op.op === "DecorationLine" && progress > 0.8) result.push(op);
1303
+ continue;
1304
+ }
1305
+ let unitIndex;
1306
+ if (!byWord) {
1307
+ unitIndex = glyphIndex;
1308
+ } else {
1309
+ let wordIndex = -1, acc = 0;
1310
+ for (let i = 0; i < wordSegments.length; i++) {
1311
+ const gcount = wordSegments[i].glyphCount;
1312
+ if (glyphIndex >= acc && glyphIndex < acc + gcount) {
1313
+ wordIndex = i;
1314
+ break;
1315
+ }
1316
+ acc += gcount;
1317
+ }
1318
+ unitIndex = Math.max(0, wordIndex);
1319
+ }
1320
+ const { startF, endF } = windowFor(unitIndex);
1321
+ if (progress <= startF) {
1322
+ const animated = { ...op, x: op.x + offset.x, y: op.y + offset.y };
1323
+ if (op.op === "FillPath") {
1324
+ if (animated.fill.kind === "solid") animated.fill = { ...animated.fill, opacity: 0 };
1325
+ else animated.fill = { ...animated.fill, opacity: 0 };
1326
+ } else {
1327
+ animated.opacity = 0;
1328
+ }
1329
+ result.push(animated);
1330
+ } else if (progress >= endF) {
1331
+ result.push(op);
1332
+ } else {
1333
+ const local = (progress - startF) / Math.max(1e-6, endF - startF);
1334
+ const ease = easeOutCubic(Math.min(1, local));
1335
+ const dx = offset.x * (1 - ease);
1336
+ const dy = offset.y * (1 - ease);
1337
+ const animated = { ...op, x: op.x + dx, y: op.y + dy };
1338
+ if (op.op === "FillPath") {
1339
+ const targetOpacity = animated.fill.kind === "solid" ? animated.fill.opacity : animated.fill.opacity ?? 1;
1340
+ if (animated.fill.kind === "solid")
1341
+ animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1342
+ else animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
1343
+ } else {
1344
+ animated.opacity = animated.opacity * ease;
1345
+ }
1346
+ result.push(animated);
1347
+ }
1348
+ if (isGlyphFill(op)) glyphIndex++;
1349
+ }
1350
+ return result;
1196
1351
  }
1197
- function applyMovingLettersAnimation(ops, progress, direction, fontSize) {
1352
+ function applyMovingLettersAnimation(ops, time, direction, fontSize) {
1198
1353
  const amp = fontSize * 0.3;
1199
- return waveTransform(ops, direction, amp, progress);
1354
+ return waveTransform(ops, direction, amp, time);
1200
1355
  }
1201
1356
  function getWordSegments(lines) {
1202
1357
  const segments = [];
@@ -1354,14 +1509,15 @@ function translateGlyphOps(ops, dx, dy, alpha = 1) {
1354
1509
  return op;
1355
1510
  });
1356
1511
  }
1357
- function waveTransform(ops, dir, amp, p) {
1512
+ function waveTransform(ops, dir, amp, time) {
1358
1513
  let glyphIndex = 0;
1514
+ const fadeInDuration = 0.5;
1515
+ const waveAlpha = Math.min(1, time / fadeInDuration);
1359
1516
  return ops.map((op) => {
1360
1517
  if (op.op === "FillPath" || op.op === "StrokePath") {
1361
- const phase = Math.sin(glyphIndex / 5 * Math.PI + p * Math.PI * 4);
1518
+ const phase = Math.sin(glyphIndex / 5 * Math.PI + time * Math.PI * 2);
1362
1519
  const dx = dir === "left" || dir === "right" ? phase * amp * (dir === "left" ? -1 : 1) : 0;
1363
1520
  const dy = dir === "up" || dir === "down" ? phase * amp * (dir === "up" ? -1 : 1) : 0;
1364
- const waveAlpha = Math.min(1, p * 2);
1365
1521
  if (op.op === "FillPath") {
1366
1522
  if (!isShadowFill(op)) glyphIndex++;
1367
1523
  const out = { ...op, x: op.x + dx, y: op.y + dy };
@@ -1683,8 +1839,7 @@ async function createTextEngine(opts = {}) {
1683
1839
  FontRegistry.setFallbackLoader(async (desc) => {
1684
1840
  const family = (desc.family ?? "Roboto").toLowerCase();
1685
1841
  const weight = `${desc.weight ?? "400"}`;
1686
- const style = desc.style ?? "normal";
1687
- if (family === "roboto" && weight === "400" && style === "normal") {
1842
+ if (family === "roboto" && weight === "400") {
1688
1843
  return fetchToArrayBuffer(DEFAULT_ROBOTO_URL);
1689
1844
  }
1690
1845
  return void 0;
@@ -1703,8 +1858,7 @@ async function createTextEngine(opts = {}) {
1703
1858
  const bytes = await fetchToArrayBuffer(cf.src);
1704
1859
  await fonts.registerFromBytes(bytes, {
1705
1860
  family: cf.family,
1706
- weight: cf.weight ?? "400",
1707
- style: cf.style ?? "normal"
1861
+ weight: cf.weight ?? "400"
1708
1862
  });
1709
1863
  } catch (err) {
1710
1864
  throw new Error(
@@ -1716,27 +1870,25 @@ async function createTextEngine(opts = {}) {
1716
1870
  const main = asset.font ?? {
1717
1871
  family: "Roboto",
1718
1872
  weight: "400",
1719
- style: "normal",
1720
1873
  size: 48,
1721
1874
  color: "#000000",
1722
1875
  opacity: 1
1723
1876
  };
1724
- const desc = { family: main.family, weight: `${main.weight}`, style: main.style };
1877
+ const desc = { family: main.family, weight: `${main.weight}` };
1725
1878
  const ensureFace = async () => {
1726
1879
  try {
1727
1880
  await fonts.getFace(desc);
1728
1881
  } catch {
1729
- const wantsDefaultRoboto = (main.family || "Roboto").toLowerCase() === "roboto" && `${main.weight}` === "400" && main.style === "normal";
1882
+ const wantsDefaultRoboto = (main.family || "Roboto").toLowerCase() === "roboto" && `${main.weight}` === "400";
1730
1883
  if (wantsDefaultRoboto) {
1731
1884
  const bytes = await fetchToArrayBuffer(DEFAULT_ROBOTO_URL);
1732
1885
  await fonts.registerFromBytes(bytes, {
1733
1886
  family: "Roboto",
1734
- weight: "400",
1735
- style: "normal"
1887
+ weight: "400"
1736
1888
  });
1737
1889
  } else {
1738
1890
  throw new Error(
1739
- `Font not registered for ${desc.family}__${desc.weight}__${desc.style}`
1891
+ `Font not registered for ${desc.family}__${desc.weight}`
1740
1892
  );
1741
1893
  }
1742
1894
  }
@@ -1804,13 +1956,13 @@ async function createTextEngine(opts = {}) {
1804
1956
  );
1805
1957
  }
1806
1958
  },
1807
- async renderFrame(asset, tSeconds) {
1959
+ async renderFrame(asset, tSeconds, clipDuration) {
1808
1960
  try {
1809
1961
  const main = await ensureFonts(asset);
1810
- const desc = { family: main.family, weight: `${main.weight}`, style: main.style };
1962
+ const desc = { family: main.family, weight: `${main.weight}` };
1811
1963
  let lines;
1812
1964
  try {
1813
- const emojiDesc = { family: "NotoEmoji", weight: "400", style: "normal" };
1965
+ const emojiDesc = { family: "NotoEmoji", weight: "400" };
1814
1966
  let emojiAvailable = false;
1815
1967
  try {
1816
1968
  await fonts.getFace(emojiDesc);
@@ -1851,7 +2003,6 @@ async function createTextEngine(opts = {}) {
1851
2003
  family: main.family,
1852
2004
  size: main.size,
1853
2005
  weight: `${main.weight}`,
1854
- style: main.style,
1855
2006
  color: main.color,
1856
2007
  opacity: main.opacity
1857
2008
  },
@@ -1879,6 +2030,7 @@ async function createTextEngine(opts = {}) {
1879
2030
  const ops = applyAnimation(ops0, lines, {
1880
2031
  t: tSeconds,
1881
2032
  fontSize: main.size,
2033
+ clipDuration,
1882
2034
  anim: asset.animation ? {
1883
2035
  preset: asset.animation.preset,
1884
2036
  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.2",
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",