animot-presenter 0.5.7 → 0.5.10

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
@@ -223,12 +223,14 @@ export interface DecorationsConfig {
223
223
  angle?: number;
224
224
  speedMs?: number;
225
225
  widthPct?: number;
226
+ randomness?: number;
226
227
  };
227
228
  gradientShift?: {
228
229
  enabled: boolean;
229
230
  colors?: string[];
230
231
  speedMs?: number;
231
232
  angle?: number;
233
+ direction?: 'forward' | 'reverse' | 'snake' | 'chase';
232
234
  };
233
235
  rgbSplit?: {
234
236
  enabled: boolean;
@@ -59,8 +59,17 @@ export function parallaxOffset(camera, depth, worldWidth, worldHeight) {
59
59
  const cy = camera.y + camera.height / 2;
60
60
  const wx = worldWidth / 2;
61
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;
62
+ // Parallax magnitude is `depth × camera-offset-from-world-center × factor`.
63
+ // The earlier 0.4 factor blew up on large worlds (6000×4000) because the
64
+ // camera-offset term scales with world size — depth=0.7 gave ±840px shifts,
65
+ // destroying composition.
66
+ //
67
+ // Fix: scale the factor by the CAMERA WIDTH ratio to a 1920-baseline so the
68
+ // effect feels the same regardless of world size. A 1920-wide camera gets
69
+ // the original feel; a 3200-wide camera (zoomed out) gets a smaller offset
70
+ // per unit-of-world-distance so the layout doesn't fly apart.
71
+ const baseFactor = depth * 0.15;
72
+ const widthScale = Math.min(1, 1920 / Math.max(1, camera.width));
73
+ const factor = baseFactor * widthScale;
65
74
  return { x: -(cx - wx) * factor, y: -(cy - wy) * factor };
66
75
  }
@@ -61,19 +61,21 @@ export function decorations(node, params) {
61
61
  if (!anyEnabled)
62
62
  return;
63
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).
64
+ // Shimmer: original implementationa 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).
68
70
  if (cfg.shimmer?.enabled) {
69
71
  if (getComputedStyle(node).position === 'static') {
70
72
  node.style.position = 'relative';
71
73
  }
72
- shimmerEl = document.createElement('div');
73
- shimmerEl.className = 'animot-shimmer';
74
74
  const angle = cfg.shimmer.angle ?? 110;
75
75
  const widthPct = cfg.shimmer.widthPct ?? 25;
76
76
  const color = cfg.shimmer.color ?? 'rgba(255, 255, 255, 0.4)';
77
+ shimmerEl = document.createElement('div');
78
+ shimmerEl.className = 'animot-shimmer';
77
79
  Object.assign(shimmerEl.style, {
78
80
  position: 'absolute',
79
81
  inset: '0',
@@ -82,7 +84,12 @@ export function decorations(node, params) {
82
84
  borderRadius: 'inherit',
83
85
  background: `linear-gradient(${angle}deg, transparent 0%, transparent ${50 - widthPct / 2}%, ${color} 50%, transparent ${50 + widthPct / 2}%, transparent 100%)`,
84
86
  backgroundSize: '300% 300%',
85
- backgroundPosition: '-100% -100%'
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'
86
93
  });
87
94
  node.appendChild(shimmerEl);
88
95
  }
@@ -90,8 +97,23 @@ export function decorations(node, params) {
90
97
  if (cfg.gradientShift?.enabled) {
91
98
  const colors = cfg.gradientShift.colors ?? ['#7c3aed', '#06b6d4', '#ec4899', '#7c3aed'];
92
99
  const angle = cfg.gradientShift.angle ?? 135;
93
- node.style.backgroundImage = `linear-gradient(${angle}deg, ${colors.join(', ')})`;
94
- node.style.backgroundSize = '300% 300%';
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
+ }
95
117
  }
96
118
  const start = performance.now();
97
119
  function tick(now) {
@@ -106,20 +128,109 @@ export function decorations(node, params) {
106
128
  const spread = 2 + wave * 8 * intensity;
107
129
  node.style.boxShadow = `0 0 ${blur}px ${spread}px ${cfg.glow.color}`;
108
130
  }
109
- // SHIMMER — sweep the gradient overlay diagonally across the element.
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.
110
140
  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}%`;
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
+ }
116
185
  }
117
- // GRADIENT SHIFT — animate background-position on the multi-stop gradient.
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.
118
196
  if (cfg.gradientShift?.enabled) {
119
197
  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%`;
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
+ }
123
234
  }
124
235
  // RGB SPLIT — chromatic aberration via stacked drop-shadows.
125
236
  if (cfg.rgbSplit?.enabled) {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Unified wrapper around YouTube IFrame Player API and Vimeo Player.js so the
3
+ * rest of the app can drive embedded videos with the same play/pause/seek/
4
+ * volume calls used for `<video>` elements. The provider libraries are lazy-
5
+ * loaded on first use so a project with no embeds doesn't pay the network cost.
6
+ *
7
+ * What this enables:
8
+ * • Auto-pause embeds when the user navigates away from a slide.
9
+ * • Reset embed `currentTime` to startTime on slide enter (otherwise YouTube
10
+ * persists position across iframe rebuilds).
11
+ * • Render OUR own play/pause/scrub controls on top of the iframe (we hide
12
+ * YouTube's chrome via `controls=0` in the embed URL).
13
+ *
14
+ * What this does NOT enable: server-side export capture. YouTube/Vimeo render
15
+ * frames inside their player; Puppeteer can't screenshot cross-origin iframe
16
+ * content, so exports of slides containing embeds still fall back to the
17
+ * provider-supplied thumbnail (handled in /present's render path).
18
+ */
19
+ export declare function loadYouTubeAPI(): Promise<typeof window['YT']>;
20
+ export declare function loadVimeoAPI(): Promise<any>;
21
+ export interface UnifiedPlayer {
22
+ play(): Promise<void> | void;
23
+ pause(): Promise<void> | void;
24
+ seekTo(seconds: number): Promise<void> | void;
25
+ getCurrentTime(): Promise<number>;
26
+ getDuration(): Promise<number>;
27
+ setVolume(v: number): void;
28
+ setMuted(m: boolean): void;
29
+ setPlaybackRate(r: number): void;
30
+ destroy(): void;
31
+ /** Subscribe to playing-state changes. Call returned fn to unsubscribe. */
32
+ onStateChange(cb: (state: 'playing' | 'paused' | 'ended' | 'buffering' | 'cued' | 'unstarted') => void): () => void;
33
+ }
34
+ interface CreateOptions {
35
+ provider: 'youtube' | 'vimeo';
36
+ /** The actual <iframe> element. We attach the player to it. */
37
+ iframe: HTMLIFrameElement;
38
+ /** Provider video ID (used for the YouTube ctor; Vimeo binds straight to iframe). */
39
+ videoId?: string;
40
+ startTime?: number;
41
+ muted?: boolean;
42
+ }
43
+ export declare function createEmbedPlayer(opts: CreateOptions): Promise<UnifiedPlayer>;
44
+ declare global {
45
+ interface Window {
46
+ YT: any;
47
+ onYouTubeIframeAPIReady?: () => void;
48
+ }
49
+ }
50
+ export {};
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Unified wrapper around YouTube IFrame Player API and Vimeo Player.js so the
3
+ * rest of the app can drive embedded videos with the same play/pause/seek/
4
+ * volume calls used for `<video>` elements. The provider libraries are lazy-
5
+ * loaded on first use so a project with no embeds doesn't pay the network cost.
6
+ *
7
+ * What this enables:
8
+ * • Auto-pause embeds when the user navigates away from a slide.
9
+ * • Reset embed `currentTime` to startTime on slide enter (otherwise YouTube
10
+ * persists position across iframe rebuilds).
11
+ * • Render OUR own play/pause/scrub controls on top of the iframe (we hide
12
+ * YouTube's chrome via `controls=0` in the embed URL).
13
+ *
14
+ * What this does NOT enable: server-side export capture. YouTube/Vimeo render
15
+ * frames inside their player; Puppeteer can't screenshot cross-origin iframe
16
+ * content, so exports of slides containing embeds still fall back to the
17
+ * provider-supplied thumbnail (handled in /present's render path).
18
+ */
19
+ let ytApiPromise = null;
20
+ export function loadYouTubeAPI() {
21
+ if (typeof window === 'undefined')
22
+ return Promise.reject(new Error('SSR'));
23
+ if (window.YT && window.YT.Player)
24
+ return Promise.resolve(window.YT);
25
+ if (ytApiPromise)
26
+ return ytApiPromise;
27
+ ytApiPromise = new Promise((resolve, reject) => {
28
+ // The IFrame API expects a global callback. Chain any pre-existing one
29
+ // so we play nicely with other libraries on the page.
30
+ const prev = window.onYouTubeIframeAPIReady;
31
+ window.onYouTubeIframeAPIReady = () => {
32
+ if (typeof prev === 'function') {
33
+ try {
34
+ prev();
35
+ }
36
+ catch { }
37
+ }
38
+ resolve(window.YT);
39
+ };
40
+ const tag = document.createElement('script');
41
+ tag.src = 'https://www.youtube.com/iframe_api';
42
+ tag.async = true;
43
+ tag.onerror = () => reject(new Error('Failed to load YouTube IFrame API'));
44
+ document.head.appendChild(tag);
45
+ });
46
+ return ytApiPromise;
47
+ }
48
+ let vimeoApiPromise = null;
49
+ export function loadVimeoAPI() {
50
+ if (typeof window === 'undefined')
51
+ return Promise.reject(new Error('SSR'));
52
+ const w = window;
53
+ if (w.Vimeo && w.Vimeo.Player)
54
+ return Promise.resolve(w.Vimeo);
55
+ if (vimeoApiPromise)
56
+ return vimeoApiPromise;
57
+ vimeoApiPromise = new Promise((resolve, reject) => {
58
+ const tag = document.createElement('script');
59
+ tag.src = 'https://player.vimeo.com/api/player.js';
60
+ tag.async = true;
61
+ tag.onload = () => resolve(window.Vimeo);
62
+ tag.onerror = () => reject(new Error('Failed to load Vimeo Player API'));
63
+ document.head.appendChild(tag);
64
+ });
65
+ return vimeoApiPromise;
66
+ }
67
+ export async function createEmbedPlayer(opts) {
68
+ if (opts.provider === 'youtube')
69
+ return createYouTubePlayer(opts);
70
+ return createVimeoPlayer(opts);
71
+ }
72
+ async function createYouTubePlayer(opts) {
73
+ const YT = await loadYouTubeAPI();
74
+ let stateCbs = [];
75
+ const player = await new Promise((resolve) => {
76
+ const p = new YT.Player(opts.iframe, {
77
+ events: {
78
+ onReady: () => resolve(p),
79
+ onStateChange: (e) => {
80
+ const map = {
81
+ [-1]: 'unstarted',
82
+ [0]: 'ended',
83
+ [1]: 'playing',
84
+ [2]: 'paused',
85
+ [3]: 'buffering',
86
+ [5]: 'cued'
87
+ };
88
+ const state = map[e.data] ?? 'unstarted';
89
+ for (const cb of stateCbs)
90
+ try {
91
+ cb(state);
92
+ }
93
+ catch { }
94
+ }
95
+ }
96
+ });
97
+ });
98
+ if (opts.muted)
99
+ player.mute();
100
+ if (opts.startTime)
101
+ try {
102
+ player.seekTo(opts.startTime, true);
103
+ }
104
+ catch { }
105
+ return {
106
+ play: () => player.playVideo(),
107
+ pause: () => player.pauseVideo(),
108
+ seekTo: (s) => player.seekTo(s, true),
109
+ getCurrentTime: async () => Number(player.getCurrentTime()) || 0,
110
+ getDuration: async () => Number(player.getDuration()) || 0,
111
+ setVolume: (v) => player.setVolume(Math.max(0, Math.min(100, v * 100))),
112
+ setMuted: (m) => (m ? player.mute() : player.unMute()),
113
+ setPlaybackRate: (r) => player.setPlaybackRate(r),
114
+ destroy: () => { try {
115
+ player.destroy();
116
+ }
117
+ catch { } },
118
+ onStateChange: (cb) => { stateCbs.push(cb); return () => { stateCbs = stateCbs.filter((f) => f !== cb); }; }
119
+ };
120
+ }
121
+ async function createVimeoPlayer(opts) {
122
+ const Vimeo = await loadVimeoAPI();
123
+ const player = new Vimeo.Player(opts.iframe);
124
+ let stateCbs = [];
125
+ await player.ready();
126
+ if (opts.muted)
127
+ await player.setMuted(true);
128
+ if (opts.startTime)
129
+ try {
130
+ await player.setCurrentTime(opts.startTime);
131
+ }
132
+ catch { }
133
+ player.on('play', () => stateCbs.forEach((cb) => cb('playing')));
134
+ player.on('pause', () => stateCbs.forEach((cb) => cb('paused')));
135
+ player.on('ended', () => stateCbs.forEach((cb) => cb('ended')));
136
+ player.on('bufferstart', () => stateCbs.forEach((cb) => cb('buffering')));
137
+ return {
138
+ play: () => player.play(),
139
+ pause: () => player.pause(),
140
+ seekTo: (s) => player.setCurrentTime(s),
141
+ getCurrentTime: () => player.getCurrentTime(),
142
+ getDuration: () => player.getDuration(),
143
+ setVolume: (v) => player.setVolume(Math.max(0, Math.min(1, v))),
144
+ setMuted: (m) => player.setMuted(m),
145
+ setPlaybackRate: (r) => player.setPlaybackRate(r),
146
+ destroy: () => { try {
147
+ player.destroy();
148
+ }
149
+ catch { } },
150
+ onStateChange: (cb) => { stateCbs.push(cb); return () => { stateCbs = stateCbs.filter((f) => f !== cb); }; }
151
+ };
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "animot-presenter",
3
- "version": "0.5.7",
3
+ "version": "0.5.10",
4
4
  "description": "Embed animated presentations anywhere. Works with vanilla JS, React, Vue, Angular, Svelte, and any frontend framework. Morphing animations, code highlighting, charts, particles, and more.",
5
5
  "type": "module",
6
6
  "svelte": "./dist/index.js",