@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/README.md +231 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/loader.d.ts +3 -0
- package/dist/loader.js +33 -4
- package/dist/native-player.d.ts +65 -0
- package/dist/native-player.js +985 -0
- package/dist/player.d.ts +13 -0
- package/dist/player.js +130 -11
- package/dist/types.d.ts +30 -0
- package/package.json +5 -1
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
this.
|
|
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.
|
|
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
|
-
|
|
172
|
-
|
|
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
|
|
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": {
|