@shotstack/shotstack-canvas 1.0.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.
@@ -0,0 +1,570 @@
1
+ import { DrawOp, Glyph, ShapedLine } from "../types";
2
+
3
+ export type AnimationInput = {
4
+ preset?: "typewriter" | "fadeIn" | "slideIn" | "shift" | "ascend" | "movingLetters";
5
+ speed: number;
6
+ duration?: number;
7
+ style?: "character" | "word";
8
+ direction?: "left" | "right" | "up" | "down";
9
+ };
10
+
11
+ const DECORATION_DONE_THRESHOLD = 0.999;
12
+
13
+ export function applyAnimation(
14
+ ops: DrawOp[],
15
+ lines: ShapedLine[],
16
+ p: { t: number; fontSize: number; anim?: AnimationInput }
17
+ ): DrawOp[] {
18
+ if (!p.anim || !p.anim.preset) return ops;
19
+
20
+ const { preset } = p.anim;
21
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
22
+ const duration = p.anim.duration ?? Math.max(0.3, totalGlyphs / 30 / p.anim.speed);
23
+ const progress = Math.max(0, Math.min(1, p.t / duration));
24
+
25
+ switch (preset) {
26
+ case "typewriter":
27
+ return applyTypewriterAnimation(
28
+ ops,
29
+ lines,
30
+ progress,
31
+ p.anim.style,
32
+ p.fontSize,
33
+ p.t,
34
+ duration
35
+ );
36
+ case "fadeIn":
37
+ return applyFadeInAnimation(ops, progress);
38
+ case "slideIn":
39
+ return applySlideInAnimation(ops, progress, p.anim.direction ?? "left", p.fontSize);
40
+ case "shift":
41
+ return applyShiftAnimation(
42
+ ops,
43
+ lines,
44
+ progress,
45
+ p.anim.direction ?? "left",
46
+ p.fontSize,
47
+ p.anim.style,
48
+ duration
49
+ );
50
+ case "ascend":
51
+ return applyAscendAnimation(
52
+ ops,
53
+ lines,
54
+ progress,
55
+ p.anim.direction ?? "up",
56
+ p.fontSize,
57
+ duration
58
+ );
59
+ case "movingLetters":
60
+ return applyMovingLettersAnimation(ops, progress, p.anim.direction ?? "up", p.fontSize);
61
+ default:
62
+ return ops;
63
+ }
64
+ }
65
+
66
+ // ---- helpers
67
+ const isShadowFill = (op: DrawOp) => op.op === "FillPath" && (op as any).isShadow === true;
68
+ const isGlyphFill = (op: DrawOp) => op.op === "FillPath" && !(op as any).isShadow === true;
69
+
70
+ // Try to derive a text color from the first fill we find
71
+ function getTextColorFromOps(ops: DrawOp[]): string {
72
+ for (const op of ops) {
73
+ if (op.op === "FillPath") {
74
+ const fill: any = (op as any).fill;
75
+ if (fill?.kind === "solid") return fill.color;
76
+ if (
77
+ (fill?.kind === "linear" || fill?.kind === "radial") &&
78
+ Array.isArray(fill.stops) &&
79
+ fill.stops.length
80
+ ) {
81
+ return fill.stops[fill.stops.length - 1].color || "#000000";
82
+ }
83
+ }
84
+ }
85
+ return "#000000";
86
+ }
87
+
88
+ // ---------- TYPEWRITER ----------
89
+ function applyTypewriterAnimation(
90
+ ops: DrawOp[],
91
+ lines: ShapedLine[],
92
+ progress: number,
93
+ style: "character" | "word" | undefined,
94
+ fontSize: number,
95
+ time: number,
96
+ duration: number
97
+ ): DrawOp[] {
98
+ const byWord = style === "word";
99
+
100
+ if (byWord) {
101
+ const wordSegments = getWordSegments(lines);
102
+ const totalWords = wordSegments.length;
103
+ const visibleWords = Math.floor(progress * totalWords);
104
+ if (visibleWords === 0) return ops.filter((x) => x.op === "BeginFrame");
105
+
106
+ let totalVisibleGlyphs = 0;
107
+ for (let i = 0; i < Math.min(visibleWords, wordSegments.length); i++) {
108
+ totalVisibleGlyphs += wordSegments[i].glyphCount;
109
+ }
110
+ const visibleOpsRaw = sliceGlyphOps(ops, totalVisibleGlyphs);
111
+
112
+ const visibleOps =
113
+ progress >= DECORATION_DONE_THRESHOLD
114
+ ? visibleOpsRaw
115
+ : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
116
+
117
+ if (progress < 1 && totalVisibleGlyphs > 0) {
118
+ return addTypewriterCursor(visibleOps, totalVisibleGlyphs, fontSize, time);
119
+ }
120
+ return visibleOps;
121
+ } else {
122
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
123
+ const visibleGlyphs = Math.floor(progress * totalGlyphs);
124
+ if (visibleGlyphs === 0) return ops.filter((x) => x.op === "BeginFrame");
125
+
126
+ const visibleOpsRaw = sliceGlyphOps(ops, visibleGlyphs);
127
+
128
+ const visibleOps =
129
+ progress >= DECORATION_DONE_THRESHOLD
130
+ ? visibleOpsRaw
131
+ : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
132
+
133
+ if (progress < 1 && visibleGlyphs > 0) {
134
+ return addTypewriterCursor(visibleOps, visibleGlyphs, fontSize, time);
135
+ }
136
+ return visibleOps;
137
+ }
138
+ }
139
+
140
+ // ---------- ASCEND ----------
141
+ function applyAscendAnimation(
142
+ ops: DrawOp[],
143
+ lines: ShapedLine[],
144
+ progress: number,
145
+ direction: "left" | "right" | "up" | "down",
146
+ fontSize: number,
147
+ duration: number
148
+ ): DrawOp[] {
149
+ const wordSegments = getWordSegments(lines);
150
+ const totalWords = wordSegments.length;
151
+ if (totalWords === 0) return ops;
152
+
153
+ const result: DrawOp[] = [];
154
+ let glyphIndex = 0;
155
+
156
+ for (const op of ops) {
157
+ if (op.op === "BeginFrame") {
158
+ result.push(op);
159
+ break;
160
+ }
161
+ }
162
+
163
+ for (const op of ops) {
164
+ if (op.op === "FillPath" || op.op === "StrokePath") {
165
+ // which word owns this glyph
166
+ let wordIndex = -1,
167
+ acc = 0;
168
+ for (let i = 0; i < wordSegments.length; i++) {
169
+ const gcount = wordSegments[i].glyphCount;
170
+ if (glyphIndex >= acc && glyphIndex < acc + gcount) {
171
+ wordIndex = i;
172
+ break;
173
+ }
174
+ acc += gcount;
175
+ }
176
+ if (wordIndex >= 0) {
177
+ const startF = (wordIndex / Math.max(1, totalWords)) * (duration / duration);
178
+ const endF = Math.min(1, startF + 0.3);
179
+ if (progress >= endF) {
180
+ result.push(op);
181
+ } else if (progress > startF) {
182
+ const local = (progress - startF) / Math.max(1e-6, endF - startF);
183
+ const ease = easeOutCubic(Math.min(1, local));
184
+ const startOffset = direction === "up" ? fontSize * 0.4 : -fontSize * 0.4;
185
+ const animated: any = { ...op, y: op.y + startOffset * (1 - ease) };
186
+ if (op.op === "FillPath") {
187
+ if (animated.fill.kind === "solid")
188
+ animated.fill = { ...animated.fill, opacity: animated.fill.opacity * ease };
189
+ else animated.fill = { ...animated.fill, opacity: (animated.fill.opacity ?? 1) * ease };
190
+ } else {
191
+ animated.opacity = animated.opacity * ease;
192
+ }
193
+ result.push(animated);
194
+ }
195
+ }
196
+ if (isGlyphFill(op)) glyphIndex++;
197
+ } else if (op.op === "DecorationLine") {
198
+ if (progress >= DECORATION_DONE_THRESHOLD) {
199
+ result.push(op);
200
+ }
201
+ }
202
+ }
203
+ return result;
204
+ }
205
+
206
+ // ---------- SHIFT ----------
207
+ function applyShiftAnimation(
208
+ ops: DrawOp[],
209
+ lines: ShapedLine[],
210
+ progress: number,
211
+ direction: "left" | "right" | "up" | "down",
212
+ fontSize: number,
213
+ style: "character" | "word" | undefined,
214
+ duration: number
215
+ ): DrawOp[] {
216
+ const byWord = style === "word";
217
+ const startOffsets = {
218
+ left: { x: fontSize * 0.6, y: 0 },
219
+ right: { x: -fontSize * 0.6, y: 0 },
220
+ up: { x: 0, y: fontSize * 0.6 },
221
+ down: { x: 0, y: -fontSize * 0.6 },
222
+ };
223
+ const offset = startOffsets[direction];
224
+
225
+ const wordSegments = byWord ? getWordSegments(lines) : [];
226
+ const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
227
+ const totalUnits = byWord ? wordSegments.length : totalGlyphs;
228
+ if (totalUnits === 0) return ops;
229
+
230
+ const result: DrawOp[] = [];
231
+ for (const op of ops) {
232
+ if (op.op === "BeginFrame") {
233
+ result.push(op);
234
+ break;
235
+ }
236
+ }
237
+
238
+ const windowDuration = 0.3; // each unit animates for 0.3s of total duration
239
+ const overlapFactor = 0.7; // overlapping reveal
240
+ const staggerDelay = (duration * overlapFactor) / Math.max(1, totalUnits - 1);
241
+
242
+ const windowFor = (unitIdx: number) => {
243
+ const startTime = unitIdx * staggerDelay;
244
+ const startF = startTime / duration;
245
+ const endF = Math.min(1, (startTime + windowDuration) / duration);
246
+ return { startF, endF };
247
+ };
248
+
249
+ let glyphIndex = 0;
250
+ for (const op of ops) {
251
+ if (op.op !== "FillPath" && op.op !== "StrokePath") {
252
+ if (op.op === "DecorationLine" && progress > 0.8) result.push(op);
253
+ continue;
254
+ }
255
+
256
+ let unitIndex: number;
257
+ if (!byWord) {
258
+ unitIndex = glyphIndex;
259
+ } else {
260
+ let wordIndex = -1,
261
+ acc = 0;
262
+ for (let i = 0; i < wordSegments.length; i++) {
263
+ const gcount = wordSegments[i].glyphCount;
264
+ if (glyphIndex >= acc && glyphIndex < acc + gcount) {
265
+ wordIndex = i;
266
+ break;
267
+ }
268
+ acc += gcount;
269
+ }
270
+ unitIndex = Math.max(0, wordIndex);
271
+ }
272
+
273
+ const { startF, endF } = windowFor(unitIndex);
274
+
275
+ if (progress <= startF) {
276
+ const animated: any = { ...op, x: op.x + offset.x, y: op.y + offset.y };
277
+ if (op.op === "FillPath") {
278
+ if (animated.fill.kind === "solid") animated.fill = { ...animated.fill, opacity: 0 };
279
+ else animated.fill = { ...animated.fill, opacity: 0 };
280
+ } else {
281
+ animated.opacity = 0;
282
+ }
283
+ result.push(animated);
284
+ } else if (progress >= endF) {
285
+ result.push(op);
286
+ } else {
287
+ const local = (progress - startF) / Math.max(1e-6, endF - startF);
288
+ const ease = easeOutCubic(Math.min(1, local));
289
+ const dx = offset.x * (1 - ease);
290
+ const dy = offset.y * (1 - ease);
291
+ const animated: any = { ...op, x: op.x + dx, y: op.y + dy };
292
+
293
+ if (op.op === "FillPath") {
294
+ const targetOpacity =
295
+ animated.fill.kind === "solid" ? animated.fill.opacity : animated.fill.opacity ?? 1;
296
+ if (animated.fill.kind === "solid")
297
+ animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
298
+ else animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
299
+ } else {
300
+ animated.opacity = animated.opacity * ease;
301
+ }
302
+ result.push(animated);
303
+ }
304
+
305
+ if (isGlyphFill(op)) glyphIndex++;
306
+ }
307
+ return result;
308
+ }
309
+
310
+ // ---------- FADE / SLIDE / WAVE ----------
311
+ function applyFadeInAnimation(ops: DrawOp[], progress: number): DrawOp[] {
312
+ const alpha = easeOutQuad(progress);
313
+ const scale = 0.95 + 0.05 * alpha;
314
+ return scaleAndFade(ops, alpha, scale);
315
+ }
316
+ function applySlideInAnimation(
317
+ ops: DrawOp[],
318
+ progress: number,
319
+ direction: "left" | "right" | "up" | "down",
320
+ fontSize: number
321
+ ): DrawOp[] {
322
+ const easeProgress = easeOutCubic(progress);
323
+ const shift = shiftFor(1 - easeProgress, direction, fontSize * 2);
324
+ const alpha = easeOutQuad(progress);
325
+ return translateGlyphOps(ops, shift.dx, shift.dy, alpha);
326
+ }
327
+ function applyMovingLettersAnimation(
328
+ ops: DrawOp[],
329
+ progress: number,
330
+ direction: "left" | "right" | "up" | "down",
331
+ fontSize: number
332
+ ): DrawOp[] {
333
+ const amp = fontSize * 0.3;
334
+ return waveTransform(ops, direction, amp, progress);
335
+ }
336
+
337
+ // ---------- word segmentation / slicing ----------
338
+ function getWordSegments(lines: ShapedLine[]) {
339
+ const segments: any = [];
340
+ let totalGlyphIndex = 0;
341
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
342
+ const line = lines[lineIndex];
343
+ const words = segmentLineBySpaces(line);
344
+ for (const word of words) {
345
+ if (word.length > 0)
346
+ segments.push({ startGlyph: totalGlyphIndex, glyphCount: word.length, lineIndex });
347
+ totalGlyphIndex += word.length;
348
+ }
349
+ }
350
+ return segments;
351
+ }
352
+ function segmentLineBySpaces(line: ShapedLine): Glyph[][] {
353
+ const words: Glyph[][] = [];
354
+ let current: Glyph[] = [];
355
+ for (const g of line.glyphs) {
356
+ const isSpace = g.char === " " || g.char === "\t" || g.char === "\n";
357
+ if (isSpace) {
358
+ if (current.length) {
359
+ words.push([...current]);
360
+ current = [];
361
+ }
362
+ } else {
363
+ current.push(g);
364
+ }
365
+ }
366
+ if (current.length) words.push(current);
367
+ return words;
368
+ }
369
+
370
+ /** Include BOTH stroke+fill for the first `maxGlyphs` glyphs; nothing from later glyphs.
371
+ * Stroke is only included if its corresponding fill will be shown (so it never appears early). */
372
+ function sliceGlyphOps(ops: DrawOp[], maxGlyphs: number): DrawOp[] {
373
+ const result: DrawOp[] = [];
374
+ let glyphCount = 0;
375
+ let foundGlyphs = false;
376
+
377
+ for (const op of ops) {
378
+ if (op.op === "BeginFrame") {
379
+ result.push(op);
380
+ continue;
381
+ }
382
+
383
+ if (op.op === "FillPath" && !isShadowFill(op)) {
384
+ if (glyphCount < maxGlyphs) {
385
+ result.push(op);
386
+ foundGlyphs = true;
387
+ }
388
+ glyphCount++; // count only real glyph fills
389
+ continue;
390
+ }
391
+
392
+ if (op.op === "StrokePath") {
393
+ // Include stroke only if the upcoming fill is within range
394
+ if (glyphCount < maxGlyphs) result.push(op);
395
+ continue;
396
+ }
397
+
398
+ if (op.op === "FillPath" && isShadowFill(op)) {
399
+ if (glyphCount < maxGlyphs) result.push(op);
400
+ continue;
401
+ }
402
+
403
+ if (op.op === "DecorationLine" && foundGlyphs) {
404
+ result.push(op);
405
+ continue;
406
+ }
407
+ }
408
+
409
+ return result;
410
+ }
411
+
412
+ function addTypewriterCursor(
413
+ ops: DrawOp[],
414
+ glyphCount: number,
415
+ fontSize: number,
416
+ time: number
417
+ ): DrawOp[] {
418
+ const blinkRate = 2;
419
+ const cursorVisible = Math.floor(time * blinkRate * 2) % 2 === 0;
420
+ if (!cursorVisible || glyphCount === 0) return ops;
421
+
422
+ let last: DrawOp | null = null;
423
+ let count = 0;
424
+ for (const op of ops) {
425
+ if (op.op === "FillPath" && !isShadowFill(op)) {
426
+ count++;
427
+ if (count === glyphCount) {
428
+ last = op;
429
+ break;
430
+ }
431
+ }
432
+ }
433
+ if (last && last.op === "FillPath") {
434
+ const color = getTextColorFromOps(ops);
435
+ const cursorX = last.x + fontSize * 0.5;
436
+ const cursorY = last.y;
437
+
438
+ const cursorOp: DrawOp = {
439
+ op: "DecorationLine",
440
+ from: { x: cursorX, y: cursorY - fontSize * 0.7 },
441
+ to: { x: cursorX, y: cursorY + fontSize * 0.1 },
442
+ width: Math.max(2, fontSize / 25),
443
+ color,
444
+ opacity: 1,
445
+ };
446
+ return [...ops, cursorOp];
447
+ }
448
+ return ops;
449
+ }
450
+
451
+ // ---------- transforms ----------
452
+ function scaleAndFade(ops: DrawOp[], alpha: number, scale: number): DrawOp[] {
453
+ let cx = 0,
454
+ cy = 0,
455
+ n = 0;
456
+ ops.forEach((op) => {
457
+ if (op.op === "FillPath") {
458
+ cx += op.x;
459
+ cy += op.y;
460
+ n++;
461
+ }
462
+ });
463
+ if (n > 0) {
464
+ cx /= n;
465
+ cy /= n;
466
+ }
467
+
468
+ return ops.map((op) => {
469
+ if (op.op === "FillPath") {
470
+ const out: any = { ...op };
471
+ if (out.fill.kind === "solid") out.fill = { ...out.fill, opacity: out.fill.opacity * alpha };
472
+ else out.fill = { ...out.fill, opacity: (out.fill.opacity ?? 1) * alpha };
473
+ if (scale !== 1 && n > 0) {
474
+ const dx = op.x - cx,
475
+ dy = op.y - cy;
476
+ out.x = cx + dx * scale;
477
+ out.y = cy + dy * scale;
478
+ }
479
+ return out;
480
+ }
481
+ if (op.op === "StrokePath") {
482
+ const out: any = { ...op, opacity: op.opacity * alpha };
483
+ if (scale !== 1 && n > 0) {
484
+ const dx = op.x - cx,
485
+ dy = op.y - cy;
486
+ out.x = cx + dx * scale;
487
+ out.y = cy + dy * scale;
488
+ }
489
+ return out;
490
+ }
491
+ if (op.op === "DecorationLine") return { ...op, opacity: op.opacity * alpha };
492
+ return op;
493
+ });
494
+ }
495
+
496
+ function translateGlyphOps(ops: DrawOp[], dx: number, dy: number, alpha: number = 1): DrawOp[] {
497
+ return ops.map((op) => {
498
+ if (op.op === "FillPath") {
499
+ const out: any = { ...op, x: op.x + dx, y: op.y + dy };
500
+ if (alpha < 1) {
501
+ if (out.fill.kind === "solid")
502
+ out.fill = { ...out.fill, opacity: out.fill.opacity * alpha };
503
+ else out.fill = { ...out.fill, opacity: (out.fill.opacity ?? 1) * alpha };
504
+ }
505
+ return out;
506
+ }
507
+ if (op.op === "StrokePath")
508
+ return { ...op, x: op.x + dx, y: op.y + dy, opacity: op.opacity * alpha };
509
+ if (op.op === "DecorationLine") {
510
+ return {
511
+ ...op,
512
+ from: { x: op.from.x + dx, y: op.from.y + dy },
513
+ to: { x: op.to.x + dx, y: op.to.y + dy },
514
+ opacity: op.opacity * alpha,
515
+ };
516
+ }
517
+ return op;
518
+ });
519
+ }
520
+
521
+ function waveTransform(
522
+ ops: DrawOp[],
523
+ dir: "left" | "right" | "up" | "down",
524
+ amp: number,
525
+ p: number
526
+ ): DrawOp[] {
527
+ let glyphIndex = 0;
528
+ return ops.map((op) => {
529
+ if (op.op === "FillPath" || op.op === "StrokePath") {
530
+ const phase = Math.sin((glyphIndex / 5) * Math.PI + p * Math.PI * 4);
531
+ const dx = dir === "left" || dir === "right" ? phase * amp * (dir === "left" ? -1 : 1) : 0;
532
+ const dy = dir === "up" || dir === "down" ? phase * amp * (dir === "up" ? -1 : 1) : 0;
533
+ const waveAlpha = Math.min(1, p * 2);
534
+ if (op.op === "FillPath") {
535
+ if (!isShadowFill(op)) glyphIndex++;
536
+ const out: any = { ...op, x: op.x + dx, y: op.y + dy };
537
+ if (out.fill.kind === "solid")
538
+ out.fill = { ...out.fill, opacity: out.fill.opacity * waveAlpha };
539
+ else out.fill = { ...out.fill, opacity: (out.fill.opacity ?? 1) * waveAlpha };
540
+ return out;
541
+ }
542
+ return { ...op, x: op.x + dx, y: op.y + dy, opacity: op.opacity * waveAlpha };
543
+ }
544
+ return op;
545
+ });
546
+ }
547
+
548
+ // ---------- misc ----------
549
+ function shiftFor(progress: number, dir: "left" | "right" | "up" | "down", dist: number) {
550
+ const d = progress * dist;
551
+ switch (dir) {
552
+ case "left":
553
+ return { dx: -d, dy: 0 };
554
+ case "right":
555
+ return { dx: d, dy: 0 };
556
+ case "up":
557
+ return { dx: 0, dy: -d };
558
+ case "down":
559
+ return { dx: 0, dy: d };
560
+ }
561
+ }
562
+ function easeOutQuad(t: number) {
563
+ return t * (2 - t);
564
+ }
565
+ function easeOutCubic(t: number) {
566
+ return 1 - Math.pow(1 - t, 3);
567
+ }
568
+ function easeInOutQuad(t: number) {
569
+ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
570
+ }
@@ -0,0 +1,11 @@
1
+ import { RGBA } from "../types";
2
+
3
+ export function parseHex6(hex: string, alpha = 1): RGBA {
4
+ const m = /^#?([a-f0-9]{6})$/i.exec(hex);
5
+ if (!m) throw new Error(`Invalid color ${hex}`);
6
+ const n = parseInt(m[1], 16);
7
+ const r = (n >> 16) & 0xff;
8
+ const g = (n >> 8) & 0xff;
9
+ const b = n & 0xff;
10
+ return { r, g, b, a: alpha };
11
+ }
@@ -0,0 +1,9 @@
1
+ export function decorationGeometry(
2
+ kind: "underline" | "line-through",
3
+ p: { baselineY: number; fontSize: number; lineWidth: number; xStart: number }
4
+ ) {
5
+ const thickness = Math.max(1, Math.round(p.fontSize * 0.05));
6
+ let y = p.baselineY + Math.round(p.fontSize * 0.1);
7
+ if (kind === "line-through") y = p.baselineY - Math.round(p.fontSize * 0.3);
8
+ return { x1: p.xStart, x2: p.xStart + p.lineWidth, y, width: thickness };
9
+ }