animot-presenter 0.5.5 → 0.5.7
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 +41 -4
- package/dist/cdn/animot-presenter.css +1 -1
- package/dist/cdn/animot-presenter.esm.js +5228 -4846
- package/dist/cdn/animot-presenter.min.js +10 -10
- package/dist/engine/utils.d.ts +11 -2
- package/dist/engine/utils.js +16 -4
- package/dist/types.d.ts +65 -3
- 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 +168 -0
- package/dist/utils/text-animate.d.ts +47 -0
- package/dist/utils/text-animate.js +348 -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/engine/utils.d.ts
CHANGED
|
@@ -15,13 +15,22 @@ export declare function computeFloatSpeed(cfg: {
|
|
|
15
15
|
speed: number;
|
|
16
16
|
speedRandomness?: number;
|
|
17
17
|
}, seed: string): number;
|
|
18
|
-
|
|
18
|
+
interface GradientStop {
|
|
19
|
+
color: string;
|
|
20
|
+
position: number;
|
|
21
|
+
}
|
|
22
|
+
interface BgInput {
|
|
19
23
|
type: string;
|
|
20
24
|
color?: string;
|
|
21
25
|
gradient?: {
|
|
22
26
|
type: string;
|
|
23
27
|
angle?: number;
|
|
24
28
|
colors: string[];
|
|
29
|
+
stops?: GradientStop[];
|
|
30
|
+
radialShape?: 'circle' | 'ellipse';
|
|
31
|
+
radialPosition?: string;
|
|
25
32
|
};
|
|
26
33
|
image?: string;
|
|
27
|
-
}
|
|
34
|
+
}
|
|
35
|
+
export declare function getBackgroundStyle(bg: BgInput): string;
|
|
36
|
+
export {};
|
package/dist/engine/utils.js
CHANGED
|
@@ -73,16 +73,28 @@ export function computeFloatSpeed(cfg, seed) {
|
|
|
73
73
|
return cfg.speed;
|
|
74
74
|
return cfg.speed * (1 - r + r * hashFraction(seed, 2));
|
|
75
75
|
}
|
|
76
|
+
function buildStops(colors, stops) {
|
|
77
|
+
if (stops && stops.length > 0) {
|
|
78
|
+
const sorted = [...stops].sort((a, b) => a.position - b.position);
|
|
79
|
+
return sorted.map((s) => `${s.color} ${s.position}%`).join(', ');
|
|
80
|
+
}
|
|
81
|
+
if (colors.length <= 1)
|
|
82
|
+
return colors[0] ?? '#000';
|
|
83
|
+
return colors.map((c, i) => `${c} ${(i / (colors.length - 1)) * 100}%`).join(', ');
|
|
84
|
+
}
|
|
76
85
|
export function getBackgroundStyle(bg) {
|
|
77
86
|
if (bg.type === 'transparent')
|
|
78
87
|
return 'background: transparent';
|
|
79
88
|
if (bg.type === 'solid')
|
|
80
89
|
return `background-color: ${bg.color ?? 'transparent'}`;
|
|
81
90
|
if (bg.type === 'gradient' && bg.gradient) {
|
|
82
|
-
const { type, angle = 135, colors } = bg.gradient;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
91
|
+
const { type, angle = 135, colors, stops, radialShape = 'circle', radialPosition = 'center' } = bg.gradient;
|
|
92
|
+
const stopList = buildStops(colors, stops);
|
|
93
|
+
if (type === 'conic')
|
|
94
|
+
return `background: conic-gradient(from ${angle}deg at ${radialPosition}, ${stopList})`;
|
|
95
|
+
if (type === 'radial')
|
|
96
|
+
return `background: radial-gradient(${radialShape} at ${radialPosition}, ${stopList})`;
|
|
97
|
+
return `background: linear-gradient(${angle}deg, ${stopList})`;
|
|
86
98
|
}
|
|
87
99
|
if (bg.type === 'image' && bg.image)
|
|
88
100
|
return `background-image: url(${bg.image}); background-size: cover; background-position: center`;
|
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;
|
|
@@ -107,10 +109,13 @@ export interface CodeElement extends BaseElement {
|
|
|
107
109
|
headerRadius?: number;
|
|
108
110
|
tabRadius?: number;
|
|
109
111
|
}
|
|
110
|
-
export type TextAnimationMode = 'instant' | 'typewriter' | 'fade-words';
|
|
112
|
+
export type TextAnimationMode = 'instant' | 'typewriter' | 'fade-words' | 'fade-letters' | 'handwriting' | 'bounce-in';
|
|
111
113
|
export interface TextAnimationConfig {
|
|
112
114
|
mode: TextAnimationMode;
|
|
113
115
|
typewriterSpeed: number;
|
|
116
|
+
duration?: number;
|
|
117
|
+
stagger?: number;
|
|
118
|
+
loop?: boolean;
|
|
114
119
|
}
|
|
115
120
|
export interface TextElement extends BaseElement {
|
|
116
121
|
type: 'text';
|
|
@@ -198,6 +203,55 @@ export interface ImageElement extends BaseElement {
|
|
|
198
203
|
borderRadius?: number;
|
|
199
204
|
};
|
|
200
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
|
+
};
|
|
227
|
+
gradientShift?: {
|
|
228
|
+
enabled: boolean;
|
|
229
|
+
colors?: string[];
|
|
230
|
+
speedMs?: number;
|
|
231
|
+
angle?: number;
|
|
232
|
+
};
|
|
233
|
+
rgbSplit?: {
|
|
234
|
+
enabled: boolean;
|
|
235
|
+
offset?: number;
|
|
236
|
+
speedMs?: number;
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
export interface VideoElement extends BaseElement {
|
|
240
|
+
type: 'video';
|
|
241
|
+
src: string;
|
|
242
|
+
posterImage?: string;
|
|
243
|
+
startTime?: number;
|
|
244
|
+
endTime?: number;
|
|
245
|
+
volume: number;
|
|
246
|
+
muted: boolean;
|
|
247
|
+
autoplay: boolean;
|
|
248
|
+
loop: boolean;
|
|
249
|
+
playbackRate: number;
|
|
250
|
+
objectFit: 'cover' | 'contain' | 'fill';
|
|
251
|
+
borderRadius: number;
|
|
252
|
+
opacity: number;
|
|
253
|
+
showControls?: boolean;
|
|
254
|
+
}
|
|
201
255
|
export type StrokeStyle = 'solid' | 'dashed' | 'dotted';
|
|
202
256
|
export interface ShapeElement extends BaseElement {
|
|
203
257
|
type: 'shape';
|
|
@@ -290,7 +344,7 @@ export interface MotionPathElement extends BaseElement {
|
|
|
290
344
|
pathWidth: number;
|
|
291
345
|
showInPresentation: boolean;
|
|
292
346
|
}
|
|
293
|
-
export type CanvasElement = CodeElement | TextElement | ArrowElement | ImageElement | ShapeElement | CounterElement | ChartElement | IconElement | SvgElement | MotionPathElement;
|
|
347
|
+
export type CanvasElement = CodeElement | TextElement | ArrowElement | ImageElement | VideoElement | ShapeElement | CounterElement | ChartElement | IconElement | SvgElement | MotionPathElement;
|
|
294
348
|
export type ParticleShape = 'circle' | 'square' | 'star' | 'triangle';
|
|
295
349
|
export interface ParticlesConfig {
|
|
296
350
|
enabled: boolean;
|
|
@@ -316,13 +370,20 @@ export interface ConfettiConfig {
|
|
|
316
370
|
startVelocity: number;
|
|
317
371
|
scalar: number;
|
|
318
372
|
}
|
|
373
|
+
export interface GradientStop {
|
|
374
|
+
color: string;
|
|
375
|
+
position: number;
|
|
376
|
+
}
|
|
319
377
|
export interface CanvasBackground {
|
|
320
378
|
type: 'solid' | 'gradient' | 'image' | 'transparent';
|
|
321
379
|
color?: string;
|
|
322
380
|
gradient?: {
|
|
323
|
-
type: 'linear' | 'radial';
|
|
381
|
+
type: 'linear' | 'radial' | 'conic';
|
|
324
382
|
angle?: number;
|
|
325
383
|
colors: string[];
|
|
384
|
+
stops?: GradientStop[];
|
|
385
|
+
radialShape?: 'circle' | 'ellipse';
|
|
386
|
+
radialPosition?: string;
|
|
326
387
|
};
|
|
327
388
|
image?: string;
|
|
328
389
|
particles?: ParticlesConfig;
|
|
@@ -346,6 +407,7 @@ export interface Slide {
|
|
|
346
407
|
canvas: SlideCanvas;
|
|
347
408
|
transition: TransitionConfig;
|
|
348
409
|
duration: number;
|
|
410
|
+
camera?: CameraViewport;
|
|
349
411
|
}
|
|
350
412
|
export interface ProjectSettings {
|
|
351
413
|
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,168 @@
|
|
|
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 needs a positioned overlay — we inject it as a child. The
|
|
65
|
+
// element must already be `position: relative` (or non-static) to clip
|
|
66
|
+
// the overlay correctly; we coerce it here without mutating user state
|
|
67
|
+
// permanently (restoreOriginalStyles puts back any inline style).
|
|
68
|
+
if (cfg.shimmer?.enabled) {
|
|
69
|
+
if (getComputedStyle(node).position === 'static') {
|
|
70
|
+
node.style.position = 'relative';
|
|
71
|
+
}
|
|
72
|
+
shimmerEl = document.createElement('div');
|
|
73
|
+
shimmerEl.className = 'animot-shimmer';
|
|
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
|
+
Object.assign(shimmerEl.style, {
|
|
78
|
+
position: 'absolute',
|
|
79
|
+
inset: '0',
|
|
80
|
+
pointerEvents: 'none',
|
|
81
|
+
overflow: 'hidden',
|
|
82
|
+
borderRadius: 'inherit',
|
|
83
|
+
background: `linear-gradient(${angle}deg, transparent 0%, transparent ${50 - widthPct / 2}%, ${color} 50%, transparent ${50 + widthPct / 2}%, transparent 100%)`,
|
|
84
|
+
backgroundSize: '300% 300%',
|
|
85
|
+
backgroundPosition: '-100% -100%'
|
|
86
|
+
});
|
|
87
|
+
node.appendChild(shimmerEl);
|
|
88
|
+
}
|
|
89
|
+
// Initial styles for the static parts of each effect.
|
|
90
|
+
if (cfg.gradientShift?.enabled) {
|
|
91
|
+
const colors = cfg.gradientShift.colors ?? ['#7c3aed', '#06b6d4', '#ec4899', '#7c3aed'];
|
|
92
|
+
const angle = cfg.gradientShift.angle ?? 135;
|
|
93
|
+
node.style.backgroundImage = `linear-gradient(${angle}deg, ${colors.join(', ')})`;
|
|
94
|
+
node.style.backgroundSize = '300% 300%';
|
|
95
|
+
}
|
|
96
|
+
const start = performance.now();
|
|
97
|
+
function tick(now) {
|
|
98
|
+
const elapsed = now - start;
|
|
99
|
+
// GLOW — pulsing box-shadow.
|
|
100
|
+
if (cfg.glow?.enabled) {
|
|
101
|
+
const cycle = effectiveCycle(cfg.glow.speedMs ?? 2400, params.slideDuration);
|
|
102
|
+
const phase = (elapsed % cycle) / cycle; // 0–1
|
|
103
|
+
const wave = (Math.sin(phase * Math.PI * 2) + 1) / 2; // 0–1
|
|
104
|
+
const intensity = cfg.glow.intensity ?? 0.6;
|
|
105
|
+
const blur = 12 + wave * 36 * intensity;
|
|
106
|
+
const spread = 2 + wave * 8 * intensity;
|
|
107
|
+
node.style.boxShadow = `0 0 ${blur}px ${spread}px ${cfg.glow.color}`;
|
|
108
|
+
}
|
|
109
|
+
// SHIMMER — sweep the gradient overlay diagonally across the element.
|
|
110
|
+
if (cfg.shimmer?.enabled && shimmerEl) {
|
|
111
|
+
const cycle = effectiveCycle(cfg.shimmer.speedMs ?? 3000, params.slideDuration);
|
|
112
|
+
const phase = (elapsed % cycle) / cycle;
|
|
113
|
+
// Sweep -100% → 200% so the highlight enters from the top-left, leaves bottom-right.
|
|
114
|
+
const pos = -100 + phase * 300;
|
|
115
|
+
shimmerEl.style.backgroundPosition = `${pos}% ${pos}%`;
|
|
116
|
+
}
|
|
117
|
+
// GRADIENT SHIFT — animate background-position on the multi-stop gradient.
|
|
118
|
+
if (cfg.gradientShift?.enabled) {
|
|
119
|
+
const cycle = effectiveCycle(cfg.gradientShift.speedMs ?? 6000, params.slideDuration);
|
|
120
|
+
const phase = (elapsed % cycle) / cycle;
|
|
121
|
+
const pos = phase * 300; // 0% → 300% creates a smooth loop with backgroundSize 300%
|
|
122
|
+
node.style.backgroundPosition = `${pos}% 50%`;
|
|
123
|
+
}
|
|
124
|
+
// RGB SPLIT — chromatic aberration via stacked drop-shadows.
|
|
125
|
+
if (cfg.rgbSplit?.enabled) {
|
|
126
|
+
const offset = cfg.rgbSplit.offset ?? 3;
|
|
127
|
+
let dx = offset;
|
|
128
|
+
if ((cfg.rgbSplit.speedMs ?? 0) > 0) {
|
|
129
|
+
const cycle = effectiveCycle(cfg.rgbSplit.speedMs, params.slideDuration);
|
|
130
|
+
const phase = (elapsed % cycle) / cycle;
|
|
131
|
+
dx = offset * Math.sin(phase * Math.PI * 2);
|
|
132
|
+
}
|
|
133
|
+
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))`;
|
|
134
|
+
}
|
|
135
|
+
raf = requestAnimationFrame(tick);
|
|
136
|
+
}
|
|
137
|
+
raf = requestAnimationFrame(tick);
|
|
138
|
+
}
|
|
139
|
+
queueMicrotask(run);
|
|
140
|
+
if (typeof window !== 'undefined') {
|
|
141
|
+
const reg = (window.__svgAnimRestart ||= []);
|
|
142
|
+
reg.push(run);
|
|
143
|
+
node.__decorationsRestart = run;
|
|
144
|
+
}
|
|
145
|
+
let lastKey = params.key;
|
|
146
|
+
return {
|
|
147
|
+
update(p) {
|
|
148
|
+
const keyChanged = p.key !== lastKey;
|
|
149
|
+
params = p;
|
|
150
|
+
if (keyChanged) {
|
|
151
|
+
lastKey = p.key;
|
|
152
|
+
queueMicrotask(run);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
destroy() {
|
|
156
|
+
reset();
|
|
157
|
+
if (typeof window !== 'undefined') {
|
|
158
|
+
const reg = window.__svgAnimRestart;
|
|
159
|
+
const fn = node.__decorationsRestart;
|
|
160
|
+
if (reg && fn) {
|
|
161
|
+
const idx = reg.indexOf(fn);
|
|
162
|
+
if (idx >= 0)
|
|
163
|
+
reg.splice(idx, 1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte action: animates a text element's content via JS RAF.
|
|
3
|
+
*
|
|
4
|
+
* Implemented modes:
|
|
5
|
+
* • fade-letters — wraps each character in a <span> and fades them in with stagger
|
|
6
|
+
* • bounce-in — same wrapping, scale+translate pop per character
|
|
7
|
+
* • handwriting — re-renders the text inside an <svg><text> with stroke + transparent
|
|
8
|
+
* fill and animates `stroke-dashoffset` so the text appears to be
|
|
9
|
+
* drawn left-to-right (works best with cursive/script fonts)
|
|
10
|
+
*
|
|
11
|
+
* Other modes (instant, typewriter, fade-words) are handled elsewhere by the
|
|
12
|
+
* /present render path — this action no-ops for those.
|
|
13
|
+
*
|
|
14
|
+
* The action registers a restart fn into `window.__svgAnimRestart` so the
|
|
15
|
+
* server-side video export pipeline can reset all animations under the
|
|
16
|
+
* virtual clock at the start of the slide hold (same pattern as FlowMarkers
|
|
17
|
+
* and arrowClipDraw).
|
|
18
|
+
*/
|
|
19
|
+
export type TextAnimateMode = 'instant' | 'typewriter' | 'fade-words' | 'fade-letters' | 'handwriting' | 'bounce-in';
|
|
20
|
+
export interface TextAnimateParams {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
mode: TextAnimateMode;
|
|
23
|
+
content: string;
|
|
24
|
+
/** Total duration in ms for handwriting; for stagger modes it's the per-letter
|
|
25
|
+
* delay budget — actual total = stagger * letterCount + perLetterDuration. */
|
|
26
|
+
duration: number;
|
|
27
|
+
stagger?: number;
|
|
28
|
+
loop?: boolean;
|
|
29
|
+
/** Cosmetic: applied to the inner SVG text in handwriting mode. */
|
|
30
|
+
color?: string;
|
|
31
|
+
fontSize?: number;
|
|
32
|
+
fontFamily?: string;
|
|
33
|
+
fontWeight?: number | string;
|
|
34
|
+
fontStyle?: string;
|
|
35
|
+
textAlign?: 'left' | 'center' | 'right';
|
|
36
|
+
/** Slide loop duration; when present the effective duration aligns to a
|
|
37
|
+
* cycle that fits an integer number of times into slide_duration so GIF
|
|
38
|
+
* loops are seamless in Flow mode. */
|
|
39
|
+
slideDuration?: number;
|
|
40
|
+
/** Bumped by the host component when ANY meaningful prop changes — without
|
|
41
|
+
* this the action would silently keep running with stale values. */
|
|
42
|
+
key?: unknown;
|
|
43
|
+
}
|
|
44
|
+
export declare function textAnimate(node: HTMLElement, params: TextAnimateParams): {
|
|
45
|
+
update(p: TextAnimateParams): void;
|
|
46
|
+
destroy(): void;
|
|
47
|
+
};
|