animot-presenter 0.5.6 → 0.5.9

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.
package/dist/types.d.ts CHANGED
@@ -71,6 +71,8 @@ export interface BaseElement {
71
71
  backfaceVisibility?: 'visible' | 'hidden';
72
72
  animationConfig?: ElementAnimationConfig;
73
73
  floatingAnimation?: FloatingAnimationConfig;
74
+ decorations?: DecorationsConfig;
75
+ depth?: number;
74
76
  motionPathId?: string;
75
77
  motionPathConfig?: MotionPathConfig;
76
78
  blur?: number;
@@ -201,6 +203,57 @@ export interface ImageElement extends BaseElement {
201
203
  borderRadius?: number;
202
204
  };
203
205
  }
206
+ export interface CameraViewport {
207
+ x: number;
208
+ y: number;
209
+ width: number;
210
+ height: number;
211
+ rotation?: number;
212
+ }
213
+ export interface DecorationsConfig {
214
+ glow?: {
215
+ enabled: boolean;
216
+ color: string;
217
+ intensity?: number;
218
+ speedMs?: number;
219
+ };
220
+ shimmer?: {
221
+ enabled: boolean;
222
+ color?: string;
223
+ angle?: number;
224
+ speedMs?: number;
225
+ widthPct?: number;
226
+ randomness?: number;
227
+ };
228
+ gradientShift?: {
229
+ enabled: boolean;
230
+ colors?: string[];
231
+ speedMs?: number;
232
+ angle?: number;
233
+ direction?: 'forward' | 'reverse' | 'snake' | 'chase';
234
+ };
235
+ rgbSplit?: {
236
+ enabled: boolean;
237
+ offset?: number;
238
+ speedMs?: number;
239
+ };
240
+ }
241
+ export interface VideoElement extends BaseElement {
242
+ type: 'video';
243
+ src: string;
244
+ posterImage?: string;
245
+ startTime?: number;
246
+ endTime?: number;
247
+ volume: number;
248
+ muted: boolean;
249
+ autoplay: boolean;
250
+ loop: boolean;
251
+ playbackRate: number;
252
+ objectFit: 'cover' | 'contain' | 'fill';
253
+ borderRadius: number;
254
+ opacity: number;
255
+ showControls?: boolean;
256
+ }
204
257
  export type StrokeStyle = 'solid' | 'dashed' | 'dotted';
205
258
  export interface ShapeElement extends BaseElement {
206
259
  type: 'shape';
@@ -293,7 +346,7 @@ export interface MotionPathElement extends BaseElement {
293
346
  pathWidth: number;
294
347
  showInPresentation: boolean;
295
348
  }
296
- export type CanvasElement = CodeElement | TextElement | ArrowElement | ImageElement | ShapeElement | CounterElement | ChartElement | IconElement | SvgElement | MotionPathElement;
349
+ export type CanvasElement = CodeElement | TextElement | ArrowElement | ImageElement | VideoElement | ShapeElement | CounterElement | ChartElement | IconElement | SvgElement | MotionPathElement;
297
350
  export type ParticleShape = 'circle' | 'square' | 'star' | 'triangle';
298
351
  export interface ParticlesConfig {
299
352
  enabled: boolean;
@@ -356,6 +409,7 @@ export interface Slide {
356
409
  canvas: SlideCanvas;
357
410
  transition: TransitionConfig;
358
411
  duration: number;
412
+ camera?: CameraViewport;
359
413
  }
360
414
  export interface ProjectSettings {
361
415
  defaultCanvasWidth: number;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Decode an animated image (GIF / APNG / animated WebP / animated AVIF) into
3
+ * a list of frames with cumulative timing using the browser's ImageDecoder
4
+ * API. Used by the server-side export pipeline so that animated <img> elements
5
+ * can be rendered frame-accurately under our virtual clock — native <img>
6
+ * playback ignores virtual time, so without this every captured frame would
7
+ * show the GIF stuck at frame 0.
8
+ *
9
+ * Live `/present` keeps using <img> directly; only the export pipeline opts
10
+ * into the canvas-from-decoded-frames path.
11
+ */
12
+ export interface DecodedFrame {
13
+ bitmap: ImageBitmap;
14
+ /** Frame display duration in ms. */
15
+ durationMs: number;
16
+ }
17
+ export interface DecodedAnimatedImage {
18
+ frames: DecodedFrame[];
19
+ totalMs: number;
20
+ width: number;
21
+ height: number;
22
+ }
23
+ /** True if the URL/data-URL looks like an animated container we can decode.
24
+ * We don't try to detect "animated PNG" vs "static PNG" upfront — the decoder
25
+ * call below will return one frame for static images and that's fine. */
26
+ export declare function isLikelyAnimated(src: string): boolean;
27
+ /** Decode every frame of an animated image. Falls back to a single-frame
28
+ * result for static images. Returns null when ImageDecoder is unavailable
29
+ * (e.g. older browsers) so callers can degrade gracefully. */
30
+ export declare function decodeAnimatedImage(src: string): Promise<DecodedAnimatedImage | null>;
31
+ /** Pick the frame that should be visible at `elapsedMs` for a looping animation
32
+ * of total `totalMs`. Returns the index. */
33
+ export declare function frameIndexAt(decoded: DecodedAnimatedImage, elapsedMs: number): number;
34
+ /** Draw the frame visible at `elapsedMs` into the given canvas. */
35
+ export declare function drawFrameAt(canvas: HTMLCanvasElement, decoded: DecodedAnimatedImage, elapsedMs: number): void;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Decode an animated image (GIF / APNG / animated WebP / animated AVIF) into
3
+ * a list of frames with cumulative timing using the browser's ImageDecoder
4
+ * API. Used by the server-side export pipeline so that animated <img> elements
5
+ * can be rendered frame-accurately under our virtual clock — native <img>
6
+ * playback ignores virtual time, so without this every captured frame would
7
+ * show the GIF stuck at frame 0.
8
+ *
9
+ * Live `/present` keeps using <img> directly; only the export pipeline opts
10
+ * into the canvas-from-decoded-frames path.
11
+ */
12
+ /** True if the URL/data-URL looks like an animated container we can decode.
13
+ * We don't try to detect "animated PNG" vs "static PNG" upfront — the decoder
14
+ * call below will return one frame for static images and that's fine. */
15
+ export function isLikelyAnimated(src) {
16
+ if (!src)
17
+ return false;
18
+ // Data URL — sniff the MIME.
19
+ if (src.startsWith('data:')) {
20
+ const mime = src.slice(5).split(';')[0].toLowerCase();
21
+ return mime === 'image/gif' || mime === 'image/apng' || mime === 'image/webp' || mime === 'image/avif' || mime === 'image/png';
22
+ }
23
+ // Otherwise look at the path extension.
24
+ const lower = src.toLowerCase().split('?')[0];
25
+ return lower.endsWith('.gif') || lower.endsWith('.apng') || lower.endsWith('.webp') || lower.endsWith('.avif');
26
+ }
27
+ async function srcToBlob(src) {
28
+ const res = await fetch(src);
29
+ return await res.blob();
30
+ }
31
+ /** Decode every frame of an animated image. Falls back to a single-frame
32
+ * result for static images. Returns null when ImageDecoder is unavailable
33
+ * (e.g. older browsers) so callers can degrade gracefully. */
34
+ export async function decodeAnimatedImage(src) {
35
+ if (typeof globalThis.ImageDecoder === 'undefined')
36
+ return null;
37
+ try {
38
+ const blob = await srcToBlob(src);
39
+ const decoder = new globalThis.ImageDecoder({ data: blob.stream(), type: blob.type || 'image/gif' });
40
+ await decoder.tracks.ready;
41
+ await decoder.completed;
42
+ const frameCount = decoder.tracks.selectedTrack?.frameCount ?? 1;
43
+ const frames = [];
44
+ let totalMs = 0;
45
+ let width = 0;
46
+ let height = 0;
47
+ for (let i = 0; i < frameCount; i++) {
48
+ const result = await decoder.decode({ frameIndex: i });
49
+ const vf = result.image;
50
+ width = vf.displayWidth || vf.codedWidth || width;
51
+ height = vf.displayHeight || vf.codedHeight || height;
52
+ // duration is in microseconds when present; some decoders return null
53
+ // for the last frame — fall back to a sensible 100ms (~10fps) so we
54
+ // don't divide by zero when seeking.
55
+ const durMs = vf.duration != null ? vf.duration / 1000 : 100;
56
+ const bitmap = await createImageBitmap(vf);
57
+ vf.close?.();
58
+ frames.push({ bitmap, durationMs: durMs });
59
+ totalMs += durMs;
60
+ }
61
+ decoder.close?.();
62
+ return { frames, totalMs, width, height };
63
+ }
64
+ catch (err) {
65
+ console.warn('Failed to decode animated image:', err);
66
+ return null;
67
+ }
68
+ }
69
+ /** Pick the frame that should be visible at `elapsedMs` for a looping animation
70
+ * of total `totalMs`. Returns the index. */
71
+ export function frameIndexAt(decoded, elapsedMs) {
72
+ if (decoded.totalMs <= 0 || decoded.frames.length === 0)
73
+ return 0;
74
+ const t = ((elapsedMs % decoded.totalMs) + decoded.totalMs) % decoded.totalMs;
75
+ let acc = 0;
76
+ for (let i = 0; i < decoded.frames.length; i++) {
77
+ acc += decoded.frames[i].durationMs;
78
+ if (t < acc)
79
+ return i;
80
+ }
81
+ return decoded.frames.length - 1;
82
+ }
83
+ /** Draw the frame visible at `elapsedMs` into the given canvas. */
84
+ export function drawFrameAt(canvas, decoded, elapsedMs) {
85
+ const idx = frameIndexAt(decoded, elapsedMs);
86
+ const frame = decoded.frames[idx];
87
+ if (!frame)
88
+ return;
89
+ // Resize the canvas backing-store to the natural frame size only once;
90
+ // the wrapping CSS scales it to the element box.
91
+ if (canvas.width !== decoded.width)
92
+ canvas.width = decoded.width;
93
+ if (canvas.height !== decoded.height)
94
+ canvas.height = decoded.height;
95
+ const ctx = canvas.getContext('2d');
96
+ if (!ctx)
97
+ return;
98
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
99
+ ctx.drawImage(frame.bitmap, 0, 0);
100
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Cinema-mode camera math.
3
+ *
4
+ * Concept: in cinema mode every slide shares one large "world canvas" (defined
5
+ * by Project.settings.worldWidth/Height). Each slide has a `camera` viewport —
6
+ * a rect in world coordinates that we want to fill the visible canvas. The
7
+ * transition between two slides = animating the camera (pan + zoom + roll).
8
+ *
9
+ * To render: we wrap all elements in a transform that maps the camera rect to
10
+ * the visible canvas dimensions. Element positions stay in world coordinates;
11
+ * only the wrapper transforms.
12
+ *
13
+ * Parallax: each element can declare a `depth` in [-1, +1]. Elements behind
14
+ * the camera (negative depth) appear to move less; elements in front move
15
+ * more. Implemented as an additional translate proportional to camera offset.
16
+ */
17
+ import type { CameraViewport } from '../types';
18
+ export interface CameraTransformInput {
19
+ camera: CameraViewport;
20
+ /** Output viewport — typically the slide canvas dims. */
21
+ viewportWidth: number;
22
+ viewportHeight: number;
23
+ }
24
+ /** Build the CSS transform that maps a camera rect over the world to fill the
25
+ * given output viewport. Returned as a single transform string ready for
26
+ * `style:transform="..."`. */
27
+ export declare function cameraTransform({ camera, viewportWidth, viewportHeight }: CameraTransformInput): string;
28
+ /** Lerp between two camera viewports for transitions. */
29
+ export declare function lerpCamera(a: CameraViewport, b: CameraViewport, t: number): CameraViewport;
30
+ /** Default camera = full world (no zoom, no pan). Used when a slide is in
31
+ * cinema mode but its camera is unset. */
32
+ export declare function defaultCamera(worldWidth: number, worldHeight: number): CameraViewport;
33
+ /** Per-element parallax offset. Elements with depth = 0 move 1:1 with the
34
+ * camera; depth > 0 moves more (closer); depth < 0 moves less (farther). The
35
+ * factor maps depth → multiplier such that depth=1 doubles motion, depth=-1
36
+ * halves it. We compute the offset from the *delta between the camera's
37
+ * current position and a neutral world center* — gives a plausible parallax
38
+ * without needing a separate reference camera per element.
39
+ */
40
+ export declare function parallaxOffset(camera: CameraViewport, depth: number, worldWidth: number, worldHeight: number): {
41
+ x: number;
42
+ y: number;
43
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Cinema-mode camera math.
3
+ *
4
+ * Concept: in cinema mode every slide shares one large "world canvas" (defined
5
+ * by Project.settings.worldWidth/Height). Each slide has a `camera` viewport —
6
+ * a rect in world coordinates that we want to fill the visible canvas. The
7
+ * transition between two slides = animating the camera (pan + zoom + roll).
8
+ *
9
+ * To render: we wrap all elements in a transform that maps the camera rect to
10
+ * the visible canvas dimensions. Element positions stay in world coordinates;
11
+ * only the wrapper transforms.
12
+ *
13
+ * Parallax: each element can declare a `depth` in [-1, +1]. Elements behind
14
+ * the camera (negative depth) appear to move less; elements in front move
15
+ * more. Implemented as an additional translate proportional to camera offset.
16
+ */
17
+ /** Build the CSS transform that maps a camera rect over the world to fill the
18
+ * given output viewport. Returned as a single transform string ready for
19
+ * `style:transform="..."`. */
20
+ export function cameraTransform({ camera, viewportWidth, viewportHeight }) {
21
+ const sx = viewportWidth / Math.max(1, camera.width);
22
+ const sy = viewportHeight / Math.max(1, camera.height);
23
+ // Uniform scale so circles stay circles. We pick the smaller axis so the
24
+ // camera rect fully fits the viewport (letterboxes the rest); use Math.max
25
+ // instead if "cover" semantics are wanted.
26
+ const s = Math.min(sx, sy);
27
+ const rotation = camera.rotation ?? 0;
28
+ // Order: translate world → camera origin, then scale, then rotate. Reads
29
+ // right-to-left in CSS so we write it scale → translate.
30
+ return `scale(${s}) rotate(${-rotation}deg) translate(${-camera.x}px, ${-camera.y}px)`;
31
+ }
32
+ /** Lerp between two camera viewports for transitions. */
33
+ export function lerpCamera(a, b, t) {
34
+ const lerp = (x, y) => x + (y - x) * t;
35
+ return {
36
+ x: lerp(a.x, b.x),
37
+ y: lerp(a.y, b.y),
38
+ width: lerp(a.width, b.width),
39
+ height: lerp(a.height, b.height),
40
+ rotation: lerp(a.rotation ?? 0, b.rotation ?? 0)
41
+ };
42
+ }
43
+ /** Default camera = full world (no zoom, no pan). Used when a slide is in
44
+ * cinema mode but its camera is unset. */
45
+ export function defaultCamera(worldWidth, worldHeight) {
46
+ return { x: 0, y: 0, width: worldWidth, height: worldHeight, rotation: 0 };
47
+ }
48
+ /** Per-element parallax offset. Elements with depth = 0 move 1:1 with the
49
+ * camera; depth > 0 moves more (closer); depth < 0 moves less (farther). The
50
+ * factor maps depth → multiplier such that depth=1 doubles motion, depth=-1
51
+ * halves it. We compute the offset from the *delta between the camera's
52
+ * current position and a neutral world center* — gives a plausible parallax
53
+ * without needing a separate reference camera per element.
54
+ */
55
+ export function parallaxOffset(camera, depth, worldWidth, worldHeight) {
56
+ if (!depth)
57
+ return { x: 0, y: 0 };
58
+ const cx = camera.x + camera.width / 2;
59
+ const cy = camera.y + camera.height / 2;
60
+ const wx = worldWidth / 2;
61
+ const wy = worldHeight / 2;
62
+ // Tame the multiplier — full ±1 doubling is too much in practice; use 0.4
63
+ // so depth = +1 means 40% extra parallax, depth = -1 means 40% damped.
64
+ const factor = depth * 0.4;
65
+ return { x: -(cx - wx) * factor, y: -(cy - wy) * factor };
66
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Svelte action: applies ambient decorative animations to an element. These
3
+ * sit on top of the morph/transition system — they're continuous loops driven
4
+ * by the same virtual-clock restart registry (__svgAnimRestart) so they reset
5
+ * cleanly on slide enter and align in Flow-mode GIF exports.
6
+ *
7
+ * Effects:
8
+ * • glow — pulsing colored box-shadow halo
9
+ * • shimmer — diagonal highlight stripe sweeping across the element
10
+ * • gradientShift — animates background-position on a multi-color gradient
11
+ * • rgbSplit — chromatic-aberration-style R/B channel offset (drop-shadow)
12
+ */
13
+ import type { DecorationsConfig } from '../types';
14
+ export interface DecorationsParams {
15
+ config?: DecorationsConfig;
16
+ /** Slide loop duration; when present, each effect's cycle is rounded so an
17
+ * integer number fits in slide_duration — guarantees seamless GIF loop. */
18
+ slideDuration?: number;
19
+ /** Bumped by the host when ANY decoration prop changes. Without it the
20
+ * action would silently keep running with stale params. */
21
+ key?: unknown;
22
+ }
23
+ export declare function decorations(node: HTMLElement, params: DecorationsParams): {
24
+ update(p: DecorationsParams): void;
25
+ destroy(): void;
26
+ };
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Svelte action: applies ambient decorative animations to an element. These
3
+ * sit on top of the morph/transition system — they're continuous loops driven
4
+ * by the same virtual-clock restart registry (__svgAnimRestart) so they reset
5
+ * cleanly on slide enter and align in Flow-mode GIF exports.
6
+ *
7
+ * Effects:
8
+ * • glow — pulsing colored box-shadow halo
9
+ * • shimmer — diagonal highlight stripe sweeping across the element
10
+ * • gradientShift — animates background-position on a multi-color gradient
11
+ * • rgbSplit — chromatic-aberration-style R/B channel offset (drop-shadow)
12
+ */
13
+ function effectiveCycle(requested, slideDuration) {
14
+ if (slideDuration && slideDuration > 0) {
15
+ return slideDuration / Math.max(1, Math.round(slideDuration / requested));
16
+ }
17
+ return requested;
18
+ }
19
+ export function decorations(node, params) {
20
+ let raf = 0;
21
+ let shimmerEl = null;
22
+ let originalBoxShadow = '';
23
+ let originalFilter = '';
24
+ let originalBackgroundImage = '';
25
+ let originalBackgroundSize = '';
26
+ let originalBackgroundPosition = '';
27
+ let savedOriginal = false;
28
+ function saveOriginalStyles() {
29
+ if (savedOriginal)
30
+ return;
31
+ originalBoxShadow = node.style.boxShadow;
32
+ originalFilter = node.style.filter;
33
+ originalBackgroundImage = node.style.backgroundImage;
34
+ originalBackgroundSize = node.style.backgroundSize;
35
+ originalBackgroundPosition = node.style.backgroundPosition;
36
+ savedOriginal = true;
37
+ }
38
+ function restoreOriginalStyles() {
39
+ node.style.boxShadow = originalBoxShadow;
40
+ node.style.filter = originalFilter;
41
+ node.style.backgroundImage = originalBackgroundImage;
42
+ node.style.backgroundSize = originalBackgroundSize;
43
+ node.style.backgroundPosition = originalBackgroundPosition;
44
+ if (shimmerEl) {
45
+ shimmerEl.remove();
46
+ shimmerEl = null;
47
+ }
48
+ }
49
+ function reset() {
50
+ if (raf)
51
+ cancelAnimationFrame(raf);
52
+ raf = 0;
53
+ restoreOriginalStyles();
54
+ }
55
+ function run() {
56
+ reset();
57
+ const cfg = params.config;
58
+ if (!cfg)
59
+ return;
60
+ const anyEnabled = cfg.glow?.enabled || cfg.shimmer?.enabled || cfg.gradientShift?.enabled || cfg.rgbSplit?.enabled;
61
+ if (!anyEnabled)
62
+ return;
63
+ saveOriginalStyles();
64
+ // Shimmer: original implementation — a gradient overlay sized 300% of
65
+ // the host with the stripe at 50%. Animating backgroundPosition slides
66
+ // the visible window across the gradient. User confirmed this looked
67
+ // right on the first cycle; the only issue was a perceived gap at the
68
+ // loop boundary. Fix below is in the tick loop (we time-shift `phase`
69
+ // so the empty/offscreen portion lands at the wrap, hiding the jump).
70
+ if (cfg.shimmer?.enabled) {
71
+ if (getComputedStyle(node).position === 'static') {
72
+ node.style.position = 'relative';
73
+ }
74
+ const angle = cfg.shimmer.angle ?? 110;
75
+ const widthPct = cfg.shimmer.widthPct ?? 25;
76
+ const color = cfg.shimmer.color ?? 'rgba(255, 255, 255, 0.4)';
77
+ shimmerEl = document.createElement('div');
78
+ shimmerEl.className = 'animot-shimmer';
79
+ Object.assign(shimmerEl.style, {
80
+ position: 'absolute',
81
+ inset: '0',
82
+ pointerEvents: 'none',
83
+ overflow: 'hidden',
84
+ borderRadius: 'inherit',
85
+ background: `linear-gradient(${angle}deg, transparent 0%, transparent ${50 - widthPct / 2}%, ${color} 50%, transparent ${50 + widthPct / 2}%, transparent 100%)`,
86
+ backgroundSize: '300% 300%',
87
+ // Default `repeat` would tile the gradient — when the bg slides past
88
+ // an edge, the next tile's stripe enters from the opposite side
89
+ // (the "two squares touching corners" artifact). One stripe per cycle.
90
+ backgroundRepeat: 'no-repeat',
91
+ backgroundPosition: '-100% -100%',
92
+ willChange: 'background-position'
93
+ });
94
+ node.appendChild(shimmerEl);
95
+ }
96
+ // Initial styles for the static parts of each effect.
97
+ if (cfg.gradientShift?.enabled) {
98
+ const colors = cfg.gradientShift.colors ?? ['#7c3aed', '#06b6d4', '#ec4899', '#7c3aed'];
99
+ const angle = cfg.gradientShift.angle ?? 135;
100
+ const direction = cfg.gradientShift.direction ?? 'forward';
101
+ if (direction === 'chase') {
102
+ // Conic gradient: each color is a slice of the pie. Repeat the first
103
+ // color at the end so the seam where it wraps doesn't show. The tick
104
+ // loop animates the `from` angle every frame.
105
+ const stops = [...colors, colors[0]].join(', ');
106
+ node.style.backgroundImage = `conic-gradient(from 0deg at 50% 50%, ${stops})`;
107
+ node.style.backgroundSize = '100% 100%';
108
+ node.style.backgroundPosition = '0 0';
109
+ }
110
+ else {
111
+ node.style.backgroundImage = `linear-gradient(${angle}deg, ${colors.join(', ')})`;
112
+ // Bigger backgroundSize means more "headroom" for the position to
113
+ // sweep before the gradient repeats. 400% lets snake mode swing back
114
+ // and forth without ever showing a tile seam, even at 45° angles.
115
+ node.style.backgroundSize = '400% 400%';
116
+ }
117
+ }
118
+ const start = performance.now();
119
+ function tick(now) {
120
+ const elapsed = now - start;
121
+ // GLOW — pulsing box-shadow.
122
+ if (cfg.glow?.enabled) {
123
+ const cycle = effectiveCycle(cfg.glow.speedMs ?? 2400, params.slideDuration);
124
+ const phase = (elapsed % cycle) / cycle; // 0–1
125
+ const wave = (Math.sin(phase * Math.PI * 2) + 1) / 2; // 0–1
126
+ const intensity = cfg.glow.intensity ?? 0.6;
127
+ const blur = 12 + wave * 36 * intensity;
128
+ const spread = 2 + wave * 8 * intensity;
129
+ node.style.boxShadow = `0 0 ${blur}px ${spread}px ${cfg.glow.color}`;
130
+ }
131
+ // SHIMMER — sweep the stretched gradient ALONG its own gradient
132
+ // direction (derived from `angle`) so the stripe glides perpendicular
133
+ // to itself for any angle.
134
+ //
135
+ // Optional `randomness` (0–1) jitters each cycle's duration and
136
+ // inserts a random pause between sweeps so the effect feels alive
137
+ // instead of metronomic. Per-cycle state (start time, duration,
138
+ // pause length) is stashed on the wrapper so we don't burn a closure
139
+ // rebuild every frame.
140
+ if (cfg.shimmer?.enabled && shimmerEl) {
141
+ const baseSpeed = cfg.shimmer.speedMs ?? 3000;
142
+ const baseCycle = effectiveCycle(baseSpeed, params.slideDuration);
143
+ const randomness = Math.max(0, Math.min(1, cfg.shimmer.randomness ?? 0));
144
+ const w = shimmerEl;
145
+ if (typeof w.__shimCycleStart !== 'number') {
146
+ w.__shimCycleStart = elapsed;
147
+ w.__shimCycleDur = baseCycle;
148
+ w.__shimCyclePause = 0;
149
+ w.__shimSeed = 1;
150
+ }
151
+ let local = elapsed - w.__shimCycleStart;
152
+ const total = w.__shimCycleDur + w.__shimCyclePause;
153
+ if (local >= total) {
154
+ // Roll a new cycle. Hash-based pseudo-random keeps the sequence
155
+ // deterministic for a given seed — same shimmer plays back the
156
+ // same way on /present reload, useful when reviewing.
157
+ w.__shimSeed = (w.__shimSeed * 1103515245 + 12345) & 0x7fffffff;
158
+ const r1 = (w.__shimSeed % 1000) / 1000;
159
+ const r2 = ((w.__shimSeed >> 8) % 1000) / 1000;
160
+ // Duration multiplier: at randomness=1 it's 0.5×–2× base; at
161
+ // randomness=0 it's exactly base.
162
+ const jitter = (r1 * 2 - 1) * randomness; // -randomness..+randomness
163
+ const durMul = jitter >= 0 ? 1 + jitter : 1 / (1 - jitter * 0.5);
164
+ w.__shimCycleDur = Math.max(300, baseCycle * durMul);
165
+ // Pause between sweeps: 0 to randomness × baseCycle.
166
+ w.__shimCyclePause = baseCycle * randomness * r2;
167
+ w.__shimCycleStart = elapsed;
168
+ local = 0;
169
+ }
170
+ const angle = cfg.shimmer.angle ?? 110;
171
+ const rad = angle * Math.PI / 180;
172
+ const dirX = Math.sin(rad);
173
+ const dirY = -Math.cos(rad);
174
+ if (local > w.__shimCycleDur) {
175
+ // Pause window — park the stripe far offscreen so nothing renders.
176
+ shimmerEl.style.backgroundPosition = `-300% -300%`;
177
+ }
178
+ else {
179
+ const phase = local / w.__shimCycleDur;
180
+ const offset = (phase - 0.5) * 300;
181
+ const posX = 50 + offset * dirX;
182
+ const posY = 50 + offset * dirY;
183
+ shimmerEl.style.backgroundPosition = `${posX}% ${posY}%`;
184
+ }
185
+ }
186
+ // GRADIENT SHIFT — direction modes:
187
+ // • forward — phase ramps 0 → 1, position increases monotonically
188
+ // • reverse — phase ramps 1 → 0
189
+ // • snake — phase oscillates via sin so the gradient slithers
190
+ // back and forth along its own angle
191
+ // • chase — colors rotate around the element via a conic gradient
192
+ // (with 4 colors this is the "each color shifts to the
193
+ // next side" pinwheel)
194
+ // Sweep direction (for non-chase modes) is derived from `angle` so
195
+ // the gradient flows perpendicular to its own bands.
196
+ if (cfg.gradientShift?.enabled) {
197
+ const cycle = effectiveCycle(cfg.gradientShift.speedMs ?? 6000, params.slideDuration);
198
+ const direction = cfg.gradientShift.direction ?? 'forward';
199
+ if (direction === 'chase') {
200
+ // Rebuild the conic gradient each frame with a rotating `from`
201
+ // angle. Cheap to render — modern browsers compose conic
202
+ // gradients on the GPU. Color list is closed (first color
203
+ // repeated at end) so the seam where 360° wraps to 0° is
204
+ // invisible.
205
+ const colors = cfg.gradientShift.colors ?? ['#7c3aed', '#06b6d4', '#ec4899', '#7c3aed'];
206
+ const stops = [...colors, colors[0]].join(', ');
207
+ const rotateDeg = ((elapsed % cycle) / cycle) * 360;
208
+ node.style.backgroundImage = `conic-gradient(from ${rotateDeg}deg at 50% 50%, ${stops})`;
209
+ }
210
+ else {
211
+ const angle = cfg.gradientShift.angle ?? 135;
212
+ const rad = angle * Math.PI / 180;
213
+ const dirX = Math.sin(rad);
214
+ const dirY = -Math.cos(rad);
215
+ let phase;
216
+ if (direction === 'snake') {
217
+ phase = (Math.sin((elapsed / cycle) * Math.PI * 2) + 1) / 2;
218
+ }
219
+ else if (direction === 'reverse') {
220
+ phase = 1 - ((elapsed % cycle) / cycle);
221
+ }
222
+ else {
223
+ phase = (elapsed % cycle) / cycle;
224
+ }
225
+ // Sweep range = 200% of host (centered at 50%). With
226
+ // backgroundSize 400%, position values from -50% to 250% all
227
+ // keep the gradient fully covering the element across all angles.
228
+ const range = 200;
229
+ const offset = (phase - 0.5) * range;
230
+ const posX = 50 + offset * dirX;
231
+ const posY = 50 + offset * dirY;
232
+ node.style.backgroundPosition = `${posX}% ${posY}%`;
233
+ }
234
+ }
235
+ // RGB SPLIT — chromatic aberration via stacked drop-shadows.
236
+ if (cfg.rgbSplit?.enabled) {
237
+ const offset = cfg.rgbSplit.offset ?? 3;
238
+ let dx = offset;
239
+ if ((cfg.rgbSplit.speedMs ?? 0) > 0) {
240
+ const cycle = effectiveCycle(cfg.rgbSplit.speedMs, params.slideDuration);
241
+ const phase = (elapsed % cycle) / cycle;
242
+ dx = offset * Math.sin(phase * Math.PI * 2);
243
+ }
244
+ node.style.filter = `${originalFilter} drop-shadow(${dx}px 0 0 rgba(255, 0, 64, 0.7)) drop-shadow(${-dx}px 0 0 rgba(0, 200, 255, 0.7))`;
245
+ }
246
+ raf = requestAnimationFrame(tick);
247
+ }
248
+ raf = requestAnimationFrame(tick);
249
+ }
250
+ queueMicrotask(run);
251
+ if (typeof window !== 'undefined') {
252
+ const reg = (window.__svgAnimRestart ||= []);
253
+ reg.push(run);
254
+ node.__decorationsRestart = run;
255
+ }
256
+ let lastKey = params.key;
257
+ return {
258
+ update(p) {
259
+ const keyChanged = p.key !== lastKey;
260
+ params = p;
261
+ if (keyChanged) {
262
+ lastKey = p.key;
263
+ queueMicrotask(run);
264
+ }
265
+ },
266
+ destroy() {
267
+ reset();
268
+ if (typeof window !== 'undefined') {
269
+ const reg = window.__svgAnimRestart;
270
+ const fn = node.__decorationsRestart;
271
+ if (reg && fn) {
272
+ const idx = reg.indexOf(fn);
273
+ if (idx >= 0)
274
+ reg.splice(idx, 1);
275
+ }
276
+ }
277
+ }
278
+ };
279
+ }