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/AnimotPresenter.svelte +40 -3
- package/dist/EmbedPlayer.svelte +239 -0
- package/dist/EmbedPlayer.svelte.d.ts +17 -0
- package/dist/cdn/animot-presenter.css +1 -1
- package/dist/cdn/animot-presenter.esm.js +5380 -4900
- package/dist/cdn/animot-presenter.min.js +10 -10
- package/dist/types.d.ts +55 -1
- package/dist/utils/animated-image.d.ts +35 -0
- package/dist/utils/animated-image.js +100 -0
- package/dist/utils/camera.d.ts +43 -0
- package/dist/utils/camera.js +66 -0
- package/dist/utils/decorations.d.ts +26 -0
- package/dist/utils/decorations.js +279 -0
- package/dist/utils/embed-players.d.ts +50 -0
- package/dist/utils/embed-players.js +152 -0
- package/dist/utils/video-embed.d.ts +30 -0
- package/dist/utils/video-embed.js +70 -0
- package/package.json +84 -84
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
|
+
}
|