@mihirsarya/manim-scroll-runtime 0.1.2 → 0.2.1

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/player.d.ts CHANGED
@@ -11,11 +11,24 @@ export declare class ScrollPlayer {
11
11
  private isActive;
12
12
  private rafId;
13
13
  private observer?;
14
+ private lastProgress;
15
+ private lastFrameIndex;
16
+ private scrollHandler?;
17
+ private resizeHandler?;
18
+ private pendingDraw;
19
+ private pendingResize;
20
+ private isTransparent;
14
21
  constructor(options: ScrollAnimationOptions);
15
22
  init(): Promise<void>;
16
23
  destroy(): void;
17
24
  private selectMode;
25
+ private drawInitialFrame;
18
26
  private setupObserver;
27
+ /**
28
+ * Set up window resize handling for responsive scroll progress calculation.
29
+ * When the viewport height changes, scroll progress needs to be recalculated.
30
+ */
31
+ private setupResizeHandling;
19
32
  private start;
20
33
  private stop;
21
34
  private tick;
package/dist/player.js CHANGED
@@ -84,37 +84,75 @@ export class ScrollPlayer {
84
84
  this.mode = "video";
85
85
  this.isActive = false;
86
86
  this.rafId = null;
87
+ this.lastProgress = -1;
88
+ this.lastFrameIndex = -1;
89
+ this.pendingDraw = false;
90
+ this.pendingResize = false;
91
+ this.isTransparent = false;
87
92
  this.container = options.container;
88
93
  this.canvas = (_a = options.canvas) !== null && _a !== void 0 ? _a : document.createElement("canvas");
89
- this.ctx = this.canvas.getContext("2d");
94
+ // Use willReadFrequently: false for better performance with transparent compositing
95
+ this.ctx = this.canvas.getContext("2d", { alpha: true });
90
96
  if (!options.canvas) {
91
97
  this.container.appendChild(this.canvas);
92
98
  }
93
99
  }
94
100
  async init() {
95
- var _a, _b;
101
+ var _a, _b, _c;
96
102
  this.manifest = await loadManifest(this.options.manifestUrl);
97
103
  this.frameCache = new FrameCache(this.manifest.frames);
98
104
  this.mode = this.selectMode(this.manifest, this.options.mode);
105
+ this.isTransparent = (_a = this.manifest.transparent) !== null && _a !== void 0 ? _a : false;
99
106
  this.canvas.width = this.manifest.width;
100
107
  this.canvas.height = this.manifest.height;
108
+ // For inline mode, style the canvas to match text flow
109
+ if (this.manifest.inline) {
110
+ this.canvas.style.background = "transparent";
111
+ this.canvas.style.display = "inline-block";
112
+ this.canvas.style.verticalAlign = "middle";
113
+ // Height matches line height (set in em units for text scaling)
114
+ this.canvas.style.height = "1em";
115
+ // Width is calculated from aspect ratio
116
+ if (this.manifest.aspectRatio) {
117
+ this.canvas.style.width = `${this.manifest.aspectRatio}em`;
118
+ }
119
+ else {
120
+ this.canvas.style.width = "auto";
121
+ }
122
+ }
123
+ else if (this.isTransparent) {
124
+ // Non-inline transparent mode
125
+ this.canvas.style.background = "transparent";
126
+ }
101
127
  if (this.mode === "video" && this.manifest.video) {
102
128
  const video = document.createElement("video");
103
129
  video.src = this.manifest.video;
104
130
  video.muted = true;
105
131
  video.playsInline = true;
106
- await new Promise((resolve) => {
107
- video.addEventListener("loadedmetadata", () => resolve(), { once: true });
132
+ video.preload = "auto";
133
+ // Wait for enough data to be loaded to allow seeking
134
+ await new Promise((resolve, reject) => {
135
+ video.addEventListener("canplaythrough", () => resolve(), { once: true });
136
+ video.addEventListener("error", () => reject(new Error("Failed to load video")), { once: true });
137
+ // Fallback timeout
138
+ setTimeout(() => resolve(), 5000);
108
139
  });
109
140
  this.videoEl = video;
110
141
  }
111
142
  this.setupObserver();
112
- (_b = (_a = this.options).onReady) === null || _b === void 0 ? void 0 : _b.call(_a);
143
+ this.setupResizeHandling();
144
+ // Draw the first frame immediately so the canvas isn't blank
145
+ await this.drawInitialFrame();
146
+ (_c = (_b = this.options).onReady) === null || _c === void 0 ? void 0 : _c.call(_b);
113
147
  }
114
148
  destroy() {
115
149
  var _a;
116
150
  this.stop();
117
151
  (_a = this.observer) === null || _a === void 0 ? void 0 : _a.disconnect();
152
+ if (this.resizeHandler) {
153
+ window.removeEventListener("resize", this.resizeHandler);
154
+ this.resizeHandler = undefined;
155
+ }
118
156
  if (!this.options.canvas) {
119
157
  this.canvas.remove();
120
158
  }
@@ -128,6 +166,33 @@ export class ScrollPlayer {
128
166
  return "frames";
129
167
  return manifest.video ? "video" : "frames";
130
168
  }
169
+ async drawInitialFrame() {
170
+ if (!this.manifest || !this.frameCache)
171
+ return;
172
+ try {
173
+ // Clear canvas for transparent mode
174
+ if (this.isTransparent) {
175
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
176
+ }
177
+ if (this.mode === "video" && this.videoEl) {
178
+ // Seek to start and draw first frame
179
+ this.videoEl.currentTime = 0;
180
+ // Wait for seek to complete
181
+ await new Promise((resolve) => {
182
+ this.videoEl.addEventListener("seeked", () => resolve(), { once: true });
183
+ setTimeout(() => resolve(), 100); // Fallback
184
+ });
185
+ this.ctx.drawImage(this.videoEl, 0, 0, this.canvas.width, this.canvas.height);
186
+ }
187
+ else if (this.frameCache.length > 0) {
188
+ const frame = await this.frameCache.load(0);
189
+ this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height);
190
+ }
191
+ }
192
+ catch (e) {
193
+ console.warn("[manim-scroll] Failed to draw initial frame:", e);
194
+ }
195
+ }
131
196
  setupObserver() {
132
197
  this.observer = new IntersectionObserver((entries) => {
133
198
  for (const entry of entries) {
@@ -141,15 +206,43 @@ export class ScrollPlayer {
141
206
  }, { root: null, threshold: 0 });
142
207
  this.observer.observe(this.container);
143
208
  }
209
+ /**
210
+ * Set up window resize handling for responsive scroll progress calculation.
211
+ * When the viewport height changes, scroll progress needs to be recalculated.
212
+ */
213
+ setupResizeHandling() {
214
+ this.resizeHandler = () => {
215
+ if (!this.pendingResize) {
216
+ this.pendingResize = true;
217
+ requestAnimationFrame(() => {
218
+ this.pendingResize = false;
219
+ // Force recalculation of scroll progress by resetting last progress
220
+ this.lastProgress = -1;
221
+ if (this.isActive) {
222
+ this.tick();
223
+ }
224
+ });
225
+ }
226
+ };
227
+ window.addEventListener("resize", this.resizeHandler, { passive: true });
228
+ }
144
229
  start() {
145
230
  if (this.isActive)
146
231
  return;
147
232
  this.isActive = true;
148
- const loop = () => {
149
- this.tick();
150
- this.rafId = requestAnimationFrame(loop);
233
+ // Use scroll event listener with RAF throttling for efficiency
234
+ this.scrollHandler = () => {
235
+ if (!this.pendingDraw) {
236
+ this.pendingDraw = true;
237
+ this.rafId = requestAnimationFrame(() => {
238
+ this.pendingDraw = false;
239
+ this.tick();
240
+ });
241
+ }
151
242
  };
152
- this.rafId = requestAnimationFrame(loop);
243
+ window.addEventListener("scroll", this.scrollHandler, { passive: true });
244
+ // Initial tick
245
+ this.tick();
153
246
  }
154
247
  stop() {
155
248
  if (!this.isActive)
@@ -158,6 +251,10 @@ export class ScrollPlayer {
158
251
  if (this.rafId !== null) {
159
252
  cancelAnimationFrame(this.rafId);
160
253
  }
254
+ if (this.scrollHandler) {
255
+ window.removeEventListener("scroll", this.scrollHandler);
256
+ this.scrollHandler = undefined;
257
+ }
161
258
  }
162
259
  async tick() {
163
260
  var _a, _b;
@@ -165,14 +262,36 @@ export class ScrollPlayer {
165
262
  return;
166
263
  const rect = this.container.getBoundingClientRect();
167
264
  const progress = resolveScrollProgress(rect, window.innerHeight, this.options.scrollRange);
265
+ // Skip if progress hasn't changed significantly (threshold: 0.1%)
266
+ if (Math.abs(progress - this.lastProgress) < 0.001) {
267
+ return;
268
+ }
269
+ this.lastProgress = progress;
168
270
  (_b = (_a = this.options).onProgress) === null || _b === void 0 ? void 0 : _b.call(_a, progress);
169
271
  if (this.mode === "video" && this.videoEl) {
170
272
  const duration = this.videoEl.duration || 0;
171
- this.videoEl.currentTime = duration * progress;
172
- this.ctx.drawImage(this.videoEl, 0, 0, this.canvas.width, this.canvas.height);
273
+ const targetTime = duration * progress;
274
+ // Only seek if time changed by more than half a frame (assuming 30fps)
275
+ if (Math.abs(this.videoEl.currentTime - targetTime) > 0.016) {
276
+ this.videoEl.currentTime = targetTime;
277
+ // Clear before drawing for transparent mode
278
+ if (this.isTransparent) {
279
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
280
+ }
281
+ this.ctx.drawImage(this.videoEl, 0, 0, this.canvas.width, this.canvas.height);
282
+ }
173
283
  return;
174
284
  }
175
285
  const index = Math.round(progress * (this.frameCache.length - 1));
286
+ // Skip if same frame
287
+ if (index === this.lastFrameIndex) {
288
+ return;
289
+ }
290
+ this.lastFrameIndex = index;
291
+ // Clear before drawing for transparent mode
292
+ if (this.isTransparent) {
293
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
294
+ }
176
295
  const frame = await this.frameCache.load(index);
177
296
  this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height);
178
297
  }
package/dist/types.d.ts CHANGED
@@ -5,6 +5,12 @@ export type RenderManifest = {
5
5
  height: number;
6
6
  frames: string[];
7
7
  video: string | null;
8
+ /** Whether the animation was rendered with transparent background */
9
+ transparent?: boolean;
10
+ /** Whether this is an inline animation with tight bounds */
11
+ inline?: boolean;
12
+ /** Aspect ratio of the text content (for inline sizing) */
13
+ aspectRatio?: number | null;
8
14
  };
9
15
  /**
10
16
  * Legacy scroll range format (pixels).
@@ -36,3 +42,27 @@ export type ScrollAnimationOptions = {
36
42
  onReady?: () => void;
37
43
  onProgress?: (progress: number) => void;
38
44
  };
45
+ /**
46
+ * Options for native text animation (no pre-rendered assets).
47
+ * Replicates Manim's Write/DrawBorderThenFill animation in the browser.
48
+ */
49
+ export type NativeAnimationOptions = {
50
+ /** The container element to render the animation into */
51
+ container: HTMLElement;
52
+ /** The text to animate */
53
+ text: string;
54
+ /** Font size in pixels. If not specified, inherits from parent element. */
55
+ fontSize?: number;
56
+ /** Text color (hex or CSS color) */
57
+ color?: string;
58
+ /** URL to a font file (woff, woff2, ttf, otf) for opentype.js */
59
+ fontUrl?: string;
60
+ /** Stroke width for the drawing phase (default: 2, matches Manim's DrawBorderThenFill) */
61
+ strokeWidth?: number;
62
+ /** Scroll range configuration */
63
+ scrollRange?: ScrollRangeValue;
64
+ /** Called when animation is loaded and ready */
65
+ onReady?: () => void;
66
+ /** Called on scroll progress updates */
67
+ onProgress?: (progress: number) => void;
68
+ };
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "@mihirsarya/manim-scroll-runtime",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Scroll-driven playback runtime for pre-rendered Manim animations.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "files": [
8
8
  "dist"
9
9
  ],
10
+ "dependencies": {
11
+ "opentype.js": "^1.3.4"
12
+ },
10
13
  "devDependencies": {
14
+ "@types/opentype.js": "^1.3.8",
11
15
  "typescript": "^5.4.5"
12
16
  },
13
17
  "scripts": {