animot-presenter 0.5.22 → 0.5.24

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.
@@ -29,6 +29,42 @@
29
29
 
30
30
  type TweenValue = ReturnType<typeof tween<number>>;
31
31
 
32
+ // Svelte's underlying Tween.set() leaks: when retargeted before completion,
33
+ // the previous rAF task is aborted but its promise is NEVER fulfilled
34
+ // (see svelte/src/internal/client/loop.js — abort() only deletes the task,
35
+ // doesn't call fulfill). The presenter calls .to() on 15+ tween properties
36
+ // per element per slide transition, so a deck running in a loop accumulates
37
+ // hundreds of thousands of orphaned promises plus the async-function
38
+ // Contexts of any awaiter that's stuck on them. wrapTween() patches each
39
+ // tween instance so:
40
+ // 1) .to() on a value the tween is already at is a no-op (Promise.resolve)
41
+ // 2) Each .to() resolves the previous wrapper promise immediately on
42
+ // retarget, so any await chain unwinds and releases its Context even
43
+ // if the underlying Svelte promise is left dangling.
44
+ function wrapTween<TV extends { current: unknown; to: (v: never, o?: unknown) => Promise<void> }>(tv: TV): TV {
45
+ const origTo = tv.to.bind(tv);
46
+ let prevResolve: (() => void) | null = null;
47
+ (tv as unknown as { to: (v: unknown, o?: unknown) => Promise<void> }).to = (value, options) => {
48
+ if (tv.current === value) {
49
+ if (prevResolve) { const r = prevResolve; prevResolve = null; r(); }
50
+ return Promise.resolve();
51
+ }
52
+ if (prevResolve) { const r = prevResolve; prevResolve = null; r(); }
53
+ const inner = origTo(value as never, options);
54
+ return new Promise<void>((resolve) => {
55
+ prevResolve = resolve;
56
+ const done = () => {
57
+ if (prevResolve === resolve) { prevResolve = null; resolve(); }
58
+ };
59
+ inner.then(done, done);
60
+ });
61
+ };
62
+ return tv;
63
+ }
64
+ function mkTween<T>(value: T, options?: Parameters<typeof tween<T>>[1]): ReturnType<typeof tween<T>> {
65
+ return wrapTween(tween(value, options)) as ReturnType<typeof tween<T>>;
66
+ }
67
+
32
68
  interface AnimatedElement {
33
69
  x: TweenValue; y: TweenValue; width: TweenValue; height: TweenValue;
34
70
  rotation: TweenValue; skewX: TweenValue; skewY: TweenValue;
@@ -49,34 +85,76 @@
49
85
 
50
86
  interface ShapeMorphState { fromType: string; toType: string; }
51
87
 
88
+ // Race a promise against an AbortSignal so awaits unwind the instant a
89
+ // loop is cancelled — otherwise tween.to() / setTimeout promises keep
90
+ // pending and pin their async-function Context to the heap. Long-running
91
+ // loops without this leak millions of closure contexts (see commit notes).
92
+ function abortable<T>(p: Promise<T>, signal: AbortSignal): Promise<T> {
93
+ if (signal.aborted) return Promise.reject(new DOMException('aborted', 'AbortError'));
94
+ return new Promise<T>((resolve, reject) => {
95
+ const onAbort = () => reject(new DOMException('aborted', 'AbortError'));
96
+ signal.addEventListener('abort', onAbort, { once: true });
97
+ p.then(
98
+ (v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
99
+ (e) => { signal.removeEventListener('abort', onAbort); reject(e); }
100
+ );
101
+ });
102
+ }
103
+ function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
104
+ if (signal.aborted) return Promise.reject(new DOMException('aborted', 'AbortError'));
105
+ return new Promise<void>((resolve, reject) => {
106
+ const id = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms);
107
+ const onAbort = () => { clearTimeout(id); reject(new DOMException('aborted', 'AbortError')); };
108
+ signal.addEventListener('abort', onAbort, { once: true });
109
+ });
110
+ }
111
+ function isAbortError(e: unknown): boolean {
112
+ return !!(e && typeof e === 'object' && (e as { name?: string }).name === 'AbortError');
113
+ }
114
+
52
115
  // Active motion path loop cancellation tokens
53
116
  let motionPathLoopAbort: AbortController | null = null;
54
117
  function cancelMotionPathLoops() {
55
118
  if (motionPathLoopAbort) { motionPathLoopAbort.abort(); motionPathLoopAbort = null; }
56
119
  }
57
120
 
58
- // Keyframe schedule loop independent from motion-path / morph engines.
59
- // Plain (non-reactive) state: no $state for the abort controller because
60
- // the keyframe loop only retargets existing tweens and doesn't need to
61
- // drive any reactive UI of its own. Adding reactive state here proved to
62
- // crowd out the existing tween reactivity in 0.5.20.
121
+ // Keyframe schedule loop. Plain (non-reactive) module-scope state adding
122
+ // $state for keyframe overrides in 0.5.20 broke reactivity for the existing
123
+ // tween-driven render. This implementation only retargets existing tweens
124
+ // via setTimeout; nothing here is reactive, nothing is read from render.
63
125
  let keyframeLoopAbort: AbortController | null = null;
126
+ // Scheduled-but-not-yet-fired keyframe timeouts. We track these so cancel
127
+ // also clears them — otherwise their closures (which capture `signal`,
128
+ // `animated`, etc.) survive until natural firing time and pin a Context.
129
+ let keyframeTimeouts: ReturnType<typeof setTimeout>[] = [];
130
+ // Per-element overrides for keyframe-driven props that aren't tweened
131
+ // (backgroundColor, text color). The schedule writes these at each
132
+ // keyframe boundary; liveProps reads them at render time.
133
+ let keyframeOverrides = $state<Map<string, Record<string, any>>>(new Map());
64
134
  function cancelKeyframeLoops() {
65
135
  if (keyframeLoopAbort) { keyframeLoopAbort.abort(); keyframeLoopAbort = null; }
66
- }
67
- function easingForTween(name: string | undefined): string {
68
- switch (name) {
69
- case 'linear': case 'ease-in': case 'ease-out': case 'ease-in-out': return name;
70
- case 'spring': return 'ease-out';
71
- case 'ease': default: return 'ease-out';
136
+ if (keyframeTimeouts.length) {
137
+ for (const id of keyframeTimeouts) clearTimeout(id);
138
+ keyframeTimeouts = [];
72
139
  }
73
140
  }
141
+ function setKeyframeOverride(elementId: string, prop: string, value: any) {
142
+ const cur = keyframeOverrides.get(elementId) ?? {};
143
+ keyframeOverrides.set(elementId, { ...cur, [prop]: value });
144
+ keyframeOverrides = new Map(keyframeOverrides);
145
+ }
146
+ // Tweens take an easing FUNCTION (t→number), not a CSS keyword. Returning
147
+ // a string here was the cause of "r is not a function" thrown deep in the
148
+ // tween animation loop — `r(t)` crashed because `r` was the literal string
149
+ // passed in. Reuse the engine's `getEasingFn` so keyframe easing matches
150
+ // the slide-morph engine's vocabulary.
151
+ function easingForTween(name: string | undefined): (t: number) => number {
152
+ return getEasingFn(name ?? 'ease-out');
153
+ }
74
154
  function animateKeyframes(slide: Slide) {
75
155
  cancelKeyframeLoops();
76
- // Bail if nothing on the slide actually has keyframes — avoids
77
- // allocating an AbortController + iterating elements for the common
78
- // case (no keyframes anywhere).
79
156
  const hasAnyKeyframes = slide.canvas.elements.some((el) => el.keyframes && el.keyframes.length > 0);
157
+ if (keyframeOverrides.size > 0) keyframeOverrides = new Map();
80
158
  if (!hasAnyKeyframes) return;
81
159
  keyframeLoopAbort = new AbortController();
82
160
  const signal = keyframeLoopAbort.signal;
@@ -86,6 +164,7 @@
86
164
  if (!animated) continue;
87
165
  const sorted = [...element.keyframes].sort((a, b) => a.time - b.time);
88
166
  const first = sorted[0];
167
+ // Snap tweens to KF1 instantly so the slide-displayed pose IS the start.
89
168
  if (first.position) { animated.x?.to(first.position.x, { duration: 0 }); animated.y?.to(first.position.y, { duration: 0 }); }
90
169
  if (first.size) { animated.width?.to(first.size.width, { duration: 0 }); animated.height?.to(first.size.height, { duration: 0 }); }
91
170
  if (first.rotation !== undefined) animated.rotation?.to(first.rotation, { duration: 0 });
@@ -104,8 +183,10 @@
104
183
  if (first.contrast !== undefined) animated.contrast?.to(first.contrast, { duration: 0 });
105
184
  if (first.saturate !== undefined) animated.saturate?.to(first.saturate, { duration: 0 });
106
185
  if (first.grayscale !== undefined) animated.grayscale?.to(first.grayscale, { duration: 0 });
186
+ if (first.backgroundColor !== undefined) setKeyframeOverride(element.id, 'backgroundColor', first.backgroundColor);
187
+ if (first.color !== undefined) setKeyframeOverride(element.id, 'color', first.color);
107
188
  for (const kf of sorted.slice(1)) {
108
- setTimeout(() => {
189
+ const tid = setTimeout(() => {
109
190
  if (signal.aborted) return;
110
191
  const easing = easingForTween(kf.easing);
111
192
  const idx = sorted.indexOf(kf);
@@ -129,7 +210,10 @@
129
210
  if (kf.contrast !== undefined) animated.contrast?.to(kf.contrast, { duration: span, easing });
130
211
  if (kf.saturate !== undefined) animated.saturate?.to(kf.saturate, { duration: span, easing });
131
212
  if (kf.grayscale !== undefined) animated.grayscale?.to(kf.grayscale, { duration: span, easing });
213
+ if (kf.backgroundColor !== undefined) setKeyframeOverride(element.id, 'backgroundColor', kf.backgroundColor);
214
+ if (kf.color !== undefined) setKeyframeOverride(element.id, 'color', kf.color);
132
215
  }, Math.max(0, kf.time));
216
+ keyframeTimeouts.push(tid);
133
217
  }
134
218
  }
135
219
  }
@@ -347,6 +431,35 @@
347
431
  return slide?.canvas.elements.find(el => el.id === elementId);
348
432
  }
349
433
 
434
+ /**
435
+ * Overlay tween-driven values + non-tweened keyframe overrides onto the
436
+ * element used in render. Gated: returns the element unchanged when it
437
+ * has no keyframes AND no active override — that's the 99% case and
438
+ * keeps the existing slide-morph render pipeline allocation-free.
439
+ */
440
+ function liveProps<T extends CanvasElement>(element: T): T {
441
+ const hasKeyframes = !!element.keyframes && element.keyframes.length > 0;
442
+ const overrides = keyframeOverrides.get(element.id);
443
+ if (!hasKeyframes && !overrides) return element;
444
+ const a = animatedElements.get(element.id) as any;
445
+ const e = element as any;
446
+ const out: any = { ...element };
447
+ if (a && hasKeyframes) {
448
+ if (a.borderRadius && e.borderRadius !== undefined) out.borderRadius = a.borderRadius.current;
449
+ if (a.fontSize && e.fontSize !== undefined) out.fontSize = a.fontSize.current;
450
+ if (a.fillColor && e.fillColor !== undefined) out.fillColor = a.fillColor.current;
451
+ if (a.strokeColor && e.strokeColor !== undefined) out.strokeColor = a.strokeColor.current;
452
+ if (a.strokeWidth && e.strokeWidth !== undefined) out.strokeWidth = a.strokeWidth.current;
453
+ if (a.blur && e.blur !== undefined) out.blur = a.blur.current;
454
+ if (a.brightness && e.brightness !== undefined) out.brightness = a.brightness.current;
455
+ if (a.contrast && e.contrast !== undefined) out.contrast = a.contrast.current;
456
+ if (a.saturate && e.saturate !== undefined) out.saturate = a.saturate.current;
457
+ if (a.grayscale && e.grayscale !== undefined) out.grayscale = a.grayscale.current;
458
+ }
459
+ if (overrides) Object.assign(out, overrides);
460
+ return out as T;
461
+ }
462
+
350
463
  // Typewriter
351
464
  function startTypewriterAnimation(elementId: string, fullText: string, speed: number) {
352
465
  const existing = typewriterIntervals.get(elementId);
@@ -487,36 +600,71 @@
487
600
  for (const element of slide.canvas.elements) {
488
601
  if (!animatedElements.has(element.id)) {
489
602
  const inCurrent = getElementInSlide(currentSlide, element.id);
490
- const startOpacity = inCurrent ? ((inCurrent as any).opacity ?? 1) : 0;
603
+ let startOpacity = inCurrent ? ((inCurrent as any).opacity ?? 1) : 0;
491
604
  const br = (element as any).borderRadius ?? 0;
492
605
  const isShape = element.type === 'shape';
493
606
  const shapeEl = isShape ? element as ShapeElement : null;
494
607
  const isText = element.type === 'text';
495
608
  const textEl = isText ? element as TextElement : null;
609
+
610
+ // If the element has keyframes, seed each tween with the FIRST
611
+ // keyframe's value instead of the persisted (last-captured)
612
+ // state. The persisted state is whatever was on the canvas
613
+ // at the moment the user captured their final keyframe; using
614
+ // it as the tween's starting value would cause the slide to
615
+ // flash at the "wrong" pose before the keyframe schedule
616
+ // snaps it to KF1. Doing it here also avoids depending on a
617
+ // duration:0 snap that the tween library may not apply
618
+ // synchronously.
619
+ const sortedKfs = element.keyframes && element.keyframes.length > 0
620
+ ? [...element.keyframes].sort((a, b) => a.time - b.time)
621
+ : null;
622
+ const k0 = sortedKfs ? sortedKfs[0] : null;
623
+ const seedX = k0?.position ? k0.position.x : element.position.x;
624
+ const seedY = k0?.position ? k0.position.y : element.position.y;
625
+ const seedW = k0?.size ? k0.size.width : element.size.width;
626
+ const seedH = k0?.size ? k0.size.height : element.size.height;
627
+ const seedRot = k0?.rotation !== undefined ? k0.rotation : element.rotation;
628
+ const seedSkewX = k0?.skewX !== undefined ? k0.skewX : (element.skewX ?? 0);
629
+ const seedSkewY = k0?.skewY !== undefined ? k0.skewY : (element.skewY ?? 0);
630
+ const seedTiltX = k0?.tiltX !== undefined ? k0.tiltX : (element.tiltX ?? 0);
631
+ const seedTiltY = k0?.tiltY !== undefined ? k0.tiltY : (element.tiltY ?? 0);
632
+ if (k0?.opacity !== undefined) startOpacity = k0.opacity;
633
+ const seedBR = k0?.borderRadius !== undefined ? k0.borderRadius : br;
634
+ const seedFontSize = k0?.fontSize !== undefined ? k0.fontSize : (textEl ? textEl.fontSize : 0);
635
+ const seedFill = k0?.fillColor !== undefined ? k0.fillColor : (shapeEl ? shapeEl.fillColor : '');
636
+ const seedStroke = k0?.strokeColor !== undefined ? k0.strokeColor : (shapeEl ? shapeEl.strokeColor : '');
637
+ const seedStrokeW = k0?.strokeWidth !== undefined ? k0.strokeWidth : (shapeEl ? shapeEl.strokeWidth : 0);
638
+ const seedBlur = k0?.blur !== undefined ? k0.blur : (element.blur ?? 0);
639
+ const seedBright = k0?.brightness !== undefined ? k0.brightness : (element.brightness ?? 100);
640
+ const seedContrast = k0?.contrast !== undefined ? k0.contrast : (element.contrast ?? 100);
641
+ const seedSat = k0?.saturate !== undefined ? k0.saturate : (element.saturate ?? 100);
642
+ const seedGray = k0?.grayscale !== undefined ? k0.grayscale : (element.grayscale ?? 0);
643
+
496
644
  animatedElements.set(element.id, {
497
- x: tween(element.position.x, { duration: 500 }),
498
- y: tween(element.position.y, { duration: 500 }),
499
- width: tween(element.size.width, { duration: 500 }),
500
- height: tween(element.size.height, { duration: 500 }),
501
- rotation: tween(element.rotation, { duration: 500 }),
502
- skewX: tween(element.skewX ?? 0, { duration: 500 }),
503
- skewY: tween(element.skewY ?? 0, { duration: 500 }),
504
- tiltX: tween(element.tiltX ?? 0, { duration: 500 }),
505
- tiltY: tween(element.tiltY ?? 0, { duration: 500 }),
506
- perspective: tween(element.perspective ?? 1000, { duration: 500 }),
507
- opacity: tween(startOpacity, { duration: 300 }),
508
- borderRadius: tween(br, { duration: 500 }),
509
- fontSize: textEl ? tween(textEl.fontSize, { duration: 500 }) : null,
510
- fillColor: shapeEl ? tween(shapeEl.fillColor, { duration: 500 }) : null,
511
- strokeColor: shapeEl ? tween(shapeEl.strokeColor, { duration: 500 }) : null,
512
- strokeWidth: shapeEl ? tween(shapeEl.strokeWidth, { duration: 500 }) : null,
513
- shapeMorph: shapeEl ? tween(1, { duration: 500 }) : null,
514
- motionPathProgress: element.motionPathConfig ? tween(0, { duration: 500 }) : null,
515
- blur: tween(element.blur ?? 0, { duration: 500 }),
516
- brightness: tween(element.brightness ?? 100, { duration: 500 }),
517
- contrast: tween(element.contrast ?? 100, { duration: 500 }),
518
- saturate: tween(element.saturate ?? 100, { duration: 500 }),
519
- grayscale: tween(element.grayscale ?? 0, { duration: 500 })
645
+ x: mkTween(seedX, { duration: 500 }),
646
+ y: mkTween(seedY, { duration: 500 }),
647
+ width: mkTween(seedW, { duration: 500 }),
648
+ height: mkTween(seedH, { duration: 500 }),
649
+ rotation: mkTween(seedRot, { duration: 500 }),
650
+ skewX: mkTween(seedSkewX, { duration: 500 }),
651
+ skewY: mkTween(seedSkewY, { duration: 500 }),
652
+ tiltX: mkTween(seedTiltX, { duration: 500 }),
653
+ tiltY: mkTween(seedTiltY, { duration: 500 }),
654
+ perspective: mkTween(element.perspective ?? 1000, { duration: 500 }),
655
+ opacity: mkTween(startOpacity, { duration: 300 }),
656
+ borderRadius: mkTween(seedBR, { duration: 500 }),
657
+ fontSize: textEl ? mkTween(seedFontSize, { duration: 500 }) : null,
658
+ fillColor: shapeEl ? mkTween(seedFill, { duration: 500 }) : null,
659
+ strokeColor: shapeEl ? mkTween(seedStroke, { duration: 500 }) : null,
660
+ strokeWidth: shapeEl ? mkTween(seedStrokeW, { duration: 500 }) : null,
661
+ shapeMorph: shapeEl ? mkTween(1, { duration: 500 }) : null,
662
+ motionPathProgress: element.motionPathConfig ? mkTween(0, { duration: 500 }) : null,
663
+ blur: mkTween(seedBlur, { duration: 500 }),
664
+ brightness: mkTween(seedBright, { duration: 500 }),
665
+ contrast: mkTween(seedContrast, { duration: 500 }),
666
+ saturate: mkTween(seedSat, { duration: 500 }),
667
+ grayscale: mkTween(seedGray, { duration: 500 })
520
668
  });
521
669
  const currentSlideEl = getElementInSlide(currentSlide, element.id);
522
670
  elementContent.set(element.id, JSON.parse(JSON.stringify(currentSlideEl || element)));
@@ -555,16 +703,20 @@
555
703
  if (shouldLoop) {
556
704
  const laps = element.motionPathConfig.laps ?? 0;
557
705
  (async () => {
558
- let lap = 0;
559
- while (!signal.aborted && (laps === 0 || lap < laps)) {
560
- await animated.motionPathProgress!.to(0, { duration: 0 });
561
- await animated.motionPathProgress!.to(1, { duration, easing });
562
- lap++;
563
- if (!signal.aborted && (laps === 0 || lap < laps)) await new Promise(r => setTimeout(r, 50));
706
+ try {
707
+ let lap = 0;
708
+ while (!signal.aborted && (laps === 0 || lap < laps)) {
709
+ await abortable(animated.motionPathProgress!.to(0, { duration: 0 }), signal);
710
+ await abortable(animated.motionPathProgress!.to(1, { duration, easing }), signal);
711
+ lap++;
712
+ if (!signal.aborted && (laps === 0 || lap < laps)) await abortableSleep(50, signal);
713
+ }
714
+ } catch (e) {
715
+ if (!isAbortError(e)) throw e;
564
716
  }
565
717
  })();
566
718
  } else {
567
- animated.motionPathProgress.to(1, { duration, easing });
719
+ animated.motionPathProgress.to(1, { duration, easing }).catch(() => {});
568
720
  }
569
721
  }
570
722
  }
@@ -1384,7 +1536,7 @@
1384
1536
  use:decorations={{ config: element.decorations, slideDuration: currentSlide?.duration, shape: element.type === 'shape' ? { type: (element as any).shapeType, borderRadius: (element as any).borderRadius } : undefined, key: `${currentSlideIndex}-${JSON.stringify(element.decorations ?? null)}-${element.type === 'shape' ? (element as any).shapeType + ':' + ((element as any).borderRadius ?? 0) : ''}` }}
1385
1537
  >
1386
1538
  {#if element.type === 'code'}
1387
- {@const codeEl = element as CodeElement}
1539
+ {@const codeEl = liveProps(element) as CodeElement}
1388
1540
  {@const morphState = codeMorphState.get(codeEl.id)}
1389
1541
  <div class="animot-code-block" class:transparent-bg={codeEl.transparentBackground} style:font-size="{codeEl.fontSize}px" style:font-weight={codeEl.fontWeight || 400} style:padding="{codeEl.padding}px" style:border-radius="{animated.borderRadius.current}px" style:background={codeEl.bgColor ?? '#0d1117'}>
1390
1542
  {#if codeEl.showHeader}
@@ -1441,7 +1593,7 @@
1441
1593
  </div>
1442
1594
  </div>
1443
1595
  {:else if element.type === 'text'}
1444
- {@const textEl = element as TextElement}
1596
+ {@const textEl = liveProps(element) as TextElement}
1445
1597
  {@const animFontSize = animated.fontSize?.current ?? textEl.fontSize}
1446
1598
  {@const typewriterState = textTypewriterState.get(element.id)}
1447
1599
  {@const displayText = typewriterState?.isAnimating ? typewriterState.fullText.slice(0, typewriterState.displayedChars) : textEl.content}
@@ -1472,7 +1624,7 @@
1472
1624
  {#if !isActionTextMode}{displayText}{#if typewriterState?.isAnimating}<span class="animot-typewriter-cursor">|</span>{/if}{/if}
1473
1625
  </div>
1474
1626
  {:else if element.type === 'arrow'}
1475
- {@const arrowEl = element as ArrowElement}
1627
+ {@const arrowEl = liveProps(element) as ArrowElement}
1476
1628
  {@const cp = arrowEl.controlPoints || []}
1477
1629
  {@const pathD = cp.length === 0 ? `M ${arrowEl.startPoint.x} ${arrowEl.startPoint.y} L ${arrowEl.endPoint.x} ${arrowEl.endPoint.y}` : cp.length === 1 ? `M ${arrowEl.startPoint.x} ${arrowEl.startPoint.y} Q ${cp[0].x} ${cp[0].y} ${arrowEl.endPoint.x} ${arrowEl.endPoint.y}` : cp.length === 2 ? `M ${arrowEl.startPoint.x} ${arrowEl.startPoint.y} C ${cp[0].x} ${cp[0].y} ${cp[1].x} ${cp[1].y} ${arrowEl.endPoint.x} ${arrowEl.endPoint.y}` : buildCatmullRomPath(arrowEl.startPoint, cp, arrowEl.endPoint)}
1478
1630
  {@const lastCp = cp.length > 0 ? cp[cp.length - 1] : arrowEl.startPoint}
@@ -1495,11 +1647,11 @@
1495
1647
  {/if}
1496
1648
  </svg>
1497
1649
  {:else if element.type === 'image'}
1498
- {@const imgEl = element as ImageElement}
1650
+ {@const imgEl = liveProps(element) as ImageElement}
1499
1651
  {@const clipPath = imgEl.clipMask?.enabled ? (imgEl.clipMask.shapeType === 'circle' ? 'circle(50% at 50% 50%)' : imgEl.clipMask.shapeType === 'ellipse' ? 'ellipse(50% 50% at 50% 50%)' : imgEl.clipMask.shapeType === 'triangle' ? 'polygon(50% 0%, 0% 100%, 100% 100%)' : imgEl.clipMask.shapeType === 'star' ? 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)' : imgEl.clipMask.shapeType === 'hexagon' ? 'polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%)' : (imgEl.clipMask.borderRadius ?? 0) > 0 ? `inset(0 round ${imgEl.clipMask.borderRadius}px)` : 'none') : 'none'}
1500
1652
  <img class="animot-image-element" src={imgEl.src} alt="" style:object-fit={imgEl.objectFit} style:border-radius="{imgEl.clipMask?.enabled ? 0 : imgEl.borderRadius}px" style:clip-path={clipPath} style:background-color={imgEl.backgroundColor ?? 'transparent'} />
1501
1653
  {:else if element.type === 'video'}
1502
- {@const videoEl = element as VideoElement}
1654
+ {@const videoEl = liveProps(element) as VideoElement}
1503
1655
  {@const videoEmbed = parseEmbedUrl(videoEl.src)}
1504
1656
  {#if videoEmbed}
1505
1657
  <div class="animot-video-element animot-embed-wrap" style:border-radius="{videoEl.borderRadius}px" style:opacity={videoEl.opacity}>
@@ -1509,7 +1661,7 @@
1509
1661
  <video class="animot-video-element" src={videoEl.src} poster={videoEl.posterImage} autoplay={videoEl.autoplay} loop={videoEl.loop} muted={videoEl.muted} controls={!!videoEl.showControls} playsinline preload="auto" style:object-fit={videoEl.objectFit} style:border-radius="{videoEl.borderRadius}px" style:opacity={videoEl.opacity}></video>
1510
1662
  {/if}
1511
1663
  {:else if element.type === 'shape'}
1512
- {@const shapeEl = element as ShapeElement}
1664
+ {@const shapeEl = liveProps(element) as ShapeElement}
1513
1665
  {@const animFill = animated.fillColor?.current ?? shapeEl.fillColor}
1514
1666
  {@const animStroke = animated.strokeColor?.current ?? shapeEl.strokeColor}
1515
1667
  {@const animStrokeWidth = animated.strokeWidth?.current ?? shapeEl.strokeWidth}
@@ -1517,15 +1669,15 @@
1517
1669
  {@const morphProgress = animated.shapeMorph?.current ?? 1}
1518
1670
  {@const effectiveShapeType = mState ? (morphProgress >= 1 ? mState.toType : (morphProgress <= 0 ? mState.fromType : null)) : shapeEl.shapeType}
1519
1671
  {@const isMorphing = mState && morphProgress > 0 && morphProgress < 1}
1520
- <svg class="animot-shape-element" viewBox="0 0 {animated.width.current} {animated.height.current}" fill-opacity={shapeEl.fillOpacity ?? 1} stroke-opacity={shapeEl.strokeOpacity ?? 1} style:filter={shapeEl.boxShadow?.enabled ? `drop-shadow(${shapeEl.boxShadow.offsetX}px ${shapeEl.boxShadow.offsetY}px ${shapeEl.boxShadow.blur}px ${shapeEl.boxShadow.color})` : 'none'}>
1672
+ <svg class="animot-shape-element" viewBox="0 0 {Math.max(0, animated.width.current)} {Math.max(0, animated.height.current)}" fill-opacity={shapeEl.fillOpacity ?? 1} stroke-opacity={shapeEl.strokeOpacity ?? 1} style:filter={shapeEl.boxShadow?.enabled ? `drop-shadow(${shapeEl.boxShadow.offsetX}px ${shapeEl.boxShadow.offsetY}px ${shapeEl.boxShadow.blur}px ${shapeEl.boxShadow.color})` : 'none'}>
1521
1673
  {#if isMorphing}
1522
- {@const w = animated.width.current}
1523
- {@const h = animated.height.current}
1674
+ {@const w = Math.max(0, animated.width.current)}
1675
+ {@const h = Math.max(0, animated.height.current)}
1524
1676
  {@const sw = animStrokeWidth}
1525
1677
  <g style:opacity={1 - morphProgress}>{@html renderShape(mState!.fromType, w, h, animated.borderRadius.current, animFill, animStroke, sw, shapeEl.strokeStyle, shapeEl.strokeDashGap)}</g>
1526
1678
  <g style:opacity={morphProgress}>{@html renderShape(mState!.toType, w, h, animated.borderRadius.current, animFill, animStroke, sw, shapeEl.strokeStyle, shapeEl.strokeDashGap)}</g>
1527
1679
  {:else}
1528
- {@html renderShape(effectiveShapeType ?? shapeEl.shapeType, animated.width.current, animated.height.current, animated.borderRadius.current, animFill, animStroke, animStrokeWidth, shapeEl.strokeStyle, shapeEl.strokeDashGap)}
1680
+ {@html renderShape(effectiveShapeType ?? shapeEl.shapeType, Math.max(0, animated.width.current), Math.max(0, animated.height.current), animated.borderRadius.current, animFill, animStroke, animStrokeWidth, shapeEl.strokeStyle, shapeEl.strokeDashGap)}
1529
1681
  {/if}
1530
1682
  </svg>
1531
1683
  {:else if element.type === 'counter'}
@@ -1574,7 +1726,7 @@
1574
1726
  {:else if element.type === 'motionPath'}
1575
1727
  {@const mpEl = element as MotionPathElement}
1576
1728
  {#if mpEl.showInPresentation}
1577
- <svg width="100%" height="100%" viewBox="0 0 {animated.width.current} {animated.height.current}" style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible;">
1729
+ <svg width="100%" height="100%" viewBox="0 0 {Math.max(0, animated.width.current)} {Math.max(0, animated.height.current)}" style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible;">
1578
1730
  <path d={buildPresenterPathD(mpEl.points, mpEl.closed)} stroke={mpEl.pathColor} stroke-width={mpEl.pathWidth} fill="none" stroke-dasharray="8 4" />
1579
1731
  </svg>
1580
1732
  {/if}
@@ -1643,6 +1795,8 @@
1643
1795
  }
1644
1796
 
1645
1797
  function renderShape(type: string, w: number, h: number, br: number, fill: string, stroke: string, sw: number, strokeStyle?: string, strokeDashGap?: number): string {
1798
+ const nn = (v: number) => (v > 0 ? v : 0);
1799
+ w = nn(w); h = nn(h); br = nn(br); sw = nn(sw);
1646
1800
  let dashAttr = '';
1647
1801
  if (strokeStyle && strokeStyle !== 'solid') {
1648
1802
  const s = sw || 1;
@@ -1656,17 +1810,17 @@
1656
1810
  return `<polygon points="${pts}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr} stroke-linejoin="round"/>`;
1657
1811
  };
1658
1812
  switch (type) {
1659
- case 'rectangle': return `<rect x="${sw/2}" y="${sw/2}" width="${w-sw}" height="${h-sw}" rx="${br}" ry="${br}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1660
- case 'circle': return `<circle cx="${w/2}" cy="${h/2}" r="${Math.min(w,h)/2-sw/2}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1661
- case 'ellipse': return `<ellipse cx="${w/2}" cy="${h/2}" rx="${w/2-sw/2}" ry="${h/2-sw/2}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1813
+ case 'rectangle': return `<rect x="${sw/2}" y="${sw/2}" width="${nn(w-sw)}" height="${nn(h-sw)}" rx="${nn(Math.min(br, (w-sw)/2, (h-sw)/2))}" ry="${nn(Math.min(br, (w-sw)/2, (h-sw)/2))}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1814
+ case 'circle': return `<circle cx="${w/2}" cy="${h/2}" r="${nn(Math.min(w,h)/2-sw/2)}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1815
+ case 'ellipse': return `<ellipse cx="${w/2}" cy="${h/2}" rx="${nn(w/2-sw/2)}" ry="${nn(h/2-sw/2)}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1662
1816
  case 'triangle': return polyOrPath(`${w/2},${sw/2} ${sw/2},${h-sw/2} ${w-sw/2},${h-sw/2}`);
1663
1817
  case 'star': {
1664
- const cx = w/2, cy = h/2, outerR = Math.min(w,h)/2-sw/2, innerR = outerR*0.4;
1818
+ const cx = w/2, cy = h/2, outerR = nn(Math.min(w,h)/2-sw/2), innerR = outerR*0.4;
1665
1819
  const pts = Array.from({length:10},(_,i)=>{const a=(i*Math.PI/5)-Math.PI/2;const r=i%2===0?outerR:innerR;return`${cx+r*Math.cos(a)},${cy+r*Math.sin(a)}`;}).join(' ');
1666
1820
  return polyOrPath(pts);
1667
1821
  }
1668
1822
  case 'hexagon': {
1669
- const cx = w/2, cy = h/2, r = Math.min(w,h)/2-sw/2;
1823
+ const cx = w/2, cy = h/2, r = nn(Math.min(w,h)/2-sw/2);
1670
1824
  const pts = Array.from({length:6},(_,i)=>{const a=(i*Math.PI/3)-Math.PI/2;return`${cx+r*Math.cos(a)},${cy+r*Math.sin(a)}`;}).join(' ');
1671
1825
  return polyOrPath(pts);
1672
1826
  }