animot-presenter 0.5.6 → 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/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,55 @@ 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
+ };
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
+ }
204
255
  export type StrokeStyle = 'solid' | 'dashed' | 'dotted';
205
256
  export interface ShapeElement extends BaseElement {
206
257
  type: 'shape';
@@ -293,7 +344,7 @@ export interface MotionPathElement extends BaseElement {
293
344
  pathWidth: number;
294
345
  showInPresentation: boolean;
295
346
  }
296
- 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;
297
348
  export type ParticleShape = 'circle' | 'square' | 'star' | 'triangle';
298
349
  export interface ParticlesConfig {
299
350
  enabled: boolean;
@@ -356,6 +407,7 @@ export interface Slide {
356
407
  canvas: SlideCanvas;
357
408
  transition: TransitionConfig;
358
409
  duration: number;
410
+ camera?: CameraViewport;
359
411
  }
360
412
  export interface ProjectSettings {
361
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,30 @@
1
+ /**
2
+ * Parse a video URL into an embed descriptor. Supports YouTube and Vimeo.
3
+ * Returns null for URLs we don't recognize (the caller should treat those as
4
+ * raw `<video src>` files). Used by VideoElement renderers to switch between
5
+ * `<video>` and `<iframe>` rendering.
6
+ *
7
+ * Important: embed URLs cannot be captured by the server-side export pipeline
8
+ * (cross-origin iframe content isn't accessible to Puppeteer's `__tick` virtual
9
+ * clock and YouTube's player ignores any external time signal). We surface
10
+ * this as a warning in the properties panel.
11
+ */
12
+ export interface VideoEmbed {
13
+ provider: 'youtube' | 'vimeo';
14
+ /** ID of the video on the provider (`dQw4w9WgXcQ` for YouTube, `123456` for Vimeo). */
15
+ id: string;
16
+ /** Fully-formed src for an `<iframe>`. */
17
+ embedSrc: string;
18
+ /** A poster image we can probe for from the provider. */
19
+ thumbnail?: string;
20
+ }
21
+ export interface ParseEmbedOptions {
22
+ autoplay?: boolean;
23
+ loop?: boolean;
24
+ muted?: boolean;
25
+ startTime?: number;
26
+ endTime?: number;
27
+ showControls?: boolean;
28
+ }
29
+ export declare function parseEmbedUrl(url: string, opts?: ParseEmbedOptions): VideoEmbed | null;
30
+ export declare function isEmbedUrl(url: string): boolean;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Parse a video URL into an embed descriptor. Supports YouTube and Vimeo.
3
+ * Returns null for URLs we don't recognize (the caller should treat those as
4
+ * raw `<video src>` files). Used by VideoElement renderers to switch between
5
+ * `<video>` and `<iframe>` rendering.
6
+ *
7
+ * Important: embed URLs cannot be captured by the server-side export pipeline
8
+ * (cross-origin iframe content isn't accessible to Puppeteer's `__tick` virtual
9
+ * clock and YouTube's player ignores any external time signal). We surface
10
+ * this as a warning in the properties panel.
11
+ */
12
+ const YOUTUBE_RE = /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{6,16})/;
13
+ const VIMEO_RE = /vimeo\.com\/(?:video\/)?(\d{6,12})/;
14
+ export function parseEmbedUrl(url, opts = {}) {
15
+ if (!url)
16
+ return null;
17
+ if (url.startsWith('data:'))
18
+ return null;
19
+ const ytMatch = url.match(YOUTUBE_RE);
20
+ if (ytMatch) {
21
+ const id = ytMatch[1];
22
+ // YouTube's embed parameters: muted+autoplay = playsinline-on-mobile-friendly,
23
+ // loop requires both `loop=1` AND `playlist=<same id>` (quirk of YouTube's API).
24
+ const params = [];
25
+ if (opts.autoplay)
26
+ params.push('autoplay=1');
27
+ if (opts.muted || opts.autoplay)
28
+ params.push('mute=1'); // browsers block unmuted autoplay
29
+ if (opts.loop) {
30
+ params.push('loop=1');
31
+ params.push(`playlist=${id}`);
32
+ }
33
+ if (opts.startTime)
34
+ params.push(`start=${Math.round(opts.startTime)}`);
35
+ if (opts.endTime)
36
+ params.push(`end=${Math.round(opts.endTime)}`);
37
+ if (!opts.showControls)
38
+ params.push('controls=0');
39
+ params.push('rel=0', 'modestbranding=1', 'playsinline=1');
40
+ return {
41
+ provider: 'youtube',
42
+ id,
43
+ embedSrc: `https://www.youtube.com/embed/${id}?${params.join('&')}`,
44
+ thumbnail: `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`
45
+ };
46
+ }
47
+ const vimMatch = url.match(VIMEO_RE);
48
+ if (vimMatch) {
49
+ const id = vimMatch[1];
50
+ const params = [];
51
+ if (opts.autoplay)
52
+ params.push('autoplay=1');
53
+ if (opts.muted || opts.autoplay)
54
+ params.push('muted=1');
55
+ if (opts.loop)
56
+ params.push('loop=1');
57
+ if (!opts.showControls)
58
+ params.push('controls=0');
59
+ params.push('byline=0', 'portrait=0', 'title=0');
60
+ return {
61
+ provider: 'vimeo',
62
+ id,
63
+ embedSrc: `https://player.vimeo.com/video/${id}?${params.join('&')}`
64
+ };
65
+ }
66
+ return null;
67
+ }
68
+ export function isEmbedUrl(url) {
69
+ return parseEmbedUrl(url) !== null;
70
+ }