@mihirsarya/manim-scroll-runtime 0.1.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/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/loader.d.ts +10 -0
- package/dist/loader.js +54 -0
- package/dist/loader.test.d.ts +1 -0
- package/dist/loader.test.js +185 -0
- package/dist/player.d.ts +22 -0
- package/dist/player.js +179 -0
- package/dist/player.test.d.ts +1 -0
- package/dist/player.test.js +210 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.js +1 -0
- package/package.json +16 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ScrollAnimationOptions } from "./types";
|
|
2
|
+
export type { RenderManifest, ScrollAnimationOptions, ScrollRange, ScrollRangePreset, ScrollRangeValue, } from "./types";
|
|
3
|
+
export declare function registerScrollAnimation(options: ScrollAnimationOptions): Promise<() => void>;
|
package/dist/index.js
ADDED
package/dist/loader.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RenderManifest } from "./types";
|
|
2
|
+
export declare function loadManifest(url: string): Promise<RenderManifest>;
|
|
3
|
+
export declare class FrameCache {
|
|
4
|
+
private readonly frameUrls;
|
|
5
|
+
private frames;
|
|
6
|
+
private loading;
|
|
7
|
+
constructor(frameUrls: string[]);
|
|
8
|
+
get length(): number;
|
|
9
|
+
load(index: number): Promise<HTMLImageElement>;
|
|
10
|
+
}
|
package/dist/loader.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
function resolveAssetUrl(asset, manifestUrl) {
|
|
2
|
+
try {
|
|
3
|
+
return new URL(asset, manifestUrl).toString();
|
|
4
|
+
}
|
|
5
|
+
catch {
|
|
6
|
+
return asset;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export async function loadManifest(url) {
|
|
10
|
+
const response = await fetch(url);
|
|
11
|
+
if (!response.ok) {
|
|
12
|
+
throw new Error(`Failed to load manifest: ${response.status}`);
|
|
13
|
+
}
|
|
14
|
+
const manifest = (await response.json());
|
|
15
|
+
return {
|
|
16
|
+
...manifest,
|
|
17
|
+
frames: manifest.frames.map((frame) => resolveAssetUrl(frame, url)),
|
|
18
|
+
video: manifest.video ? resolveAssetUrl(manifest.video, url) : null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export class FrameCache {
|
|
22
|
+
constructor(frameUrls) {
|
|
23
|
+
this.frameUrls = frameUrls;
|
|
24
|
+
this.frames = new Map();
|
|
25
|
+
this.loading = new Map();
|
|
26
|
+
}
|
|
27
|
+
get length() {
|
|
28
|
+
return this.frameUrls.length;
|
|
29
|
+
}
|
|
30
|
+
async load(index) {
|
|
31
|
+
if (this.frames.has(index)) {
|
|
32
|
+
return this.frames.get(index);
|
|
33
|
+
}
|
|
34
|
+
if (this.loading.has(index)) {
|
|
35
|
+
return this.loading.get(index);
|
|
36
|
+
}
|
|
37
|
+
const url = this.frameUrls[index];
|
|
38
|
+
const promise = new Promise((resolve, reject) => {
|
|
39
|
+
const img = new Image();
|
|
40
|
+
img.onload = () => {
|
|
41
|
+
this.frames.set(index, img);
|
|
42
|
+
this.loading.delete(index);
|
|
43
|
+
resolve(img);
|
|
44
|
+
};
|
|
45
|
+
img.onerror = () => {
|
|
46
|
+
this.loading.delete(index);
|
|
47
|
+
reject(new Error(`Failed to load frame: ${url}`));
|
|
48
|
+
};
|
|
49
|
+
img.src = url;
|
|
50
|
+
});
|
|
51
|
+
this.loading.set(index, promise);
|
|
52
|
+
return promise;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
// Test the FrameCache class logic
|
|
3
|
+
describe("FrameCache", () => {
|
|
4
|
+
// Simulate the FrameCache class for testing
|
|
5
|
+
class TestFrameCache {
|
|
6
|
+
constructor(frameUrls) {
|
|
7
|
+
this.frameUrls = frameUrls;
|
|
8
|
+
this.frames = new Map();
|
|
9
|
+
this.loading = new Map();
|
|
10
|
+
}
|
|
11
|
+
get length() {
|
|
12
|
+
return this.frameUrls.length;
|
|
13
|
+
}
|
|
14
|
+
async load(index) {
|
|
15
|
+
if (this.frames.has(index)) {
|
|
16
|
+
return this.frames.get(index);
|
|
17
|
+
}
|
|
18
|
+
if (this.loading.has(index)) {
|
|
19
|
+
return this.loading.get(index);
|
|
20
|
+
}
|
|
21
|
+
const url = this.frameUrls[index];
|
|
22
|
+
const frame = { url };
|
|
23
|
+
const promise = Promise.resolve(frame).then((f) => {
|
|
24
|
+
this.frames.set(index, f);
|
|
25
|
+
this.loading.delete(index);
|
|
26
|
+
return f;
|
|
27
|
+
});
|
|
28
|
+
this.loading.set(index, promise);
|
|
29
|
+
return promise;
|
|
30
|
+
}
|
|
31
|
+
// Helper for testing
|
|
32
|
+
isCached(index) {
|
|
33
|
+
return this.frames.has(index);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
it("should return correct length", () => {
|
|
37
|
+
const cache = new TestFrameCache(["frame1.png", "frame2.png", "frame3.png"]);
|
|
38
|
+
expect(cache.length).toBe(3);
|
|
39
|
+
});
|
|
40
|
+
it("should return 0 for empty array", () => {
|
|
41
|
+
const cache = new TestFrameCache([]);
|
|
42
|
+
expect(cache.length).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
it("should load and cache frames", async () => {
|
|
45
|
+
const cache = new TestFrameCache(["frame1.png", "frame2.png"]);
|
|
46
|
+
expect(cache.isCached(0)).toBe(false);
|
|
47
|
+
const frame = await cache.load(0);
|
|
48
|
+
expect(frame.url).toBe("frame1.png");
|
|
49
|
+
expect(cache.isCached(0)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
it("should return cached frames on subsequent loads", async () => {
|
|
52
|
+
const cache = new TestFrameCache(["frame1.png"]);
|
|
53
|
+
const frame1 = await cache.load(0);
|
|
54
|
+
const frame2 = await cache.load(0);
|
|
55
|
+
expect(frame1).toBe(frame2);
|
|
56
|
+
});
|
|
57
|
+
it("should deduplicate concurrent loads", async () => {
|
|
58
|
+
let loadCount = 0;
|
|
59
|
+
class TrackingCache {
|
|
60
|
+
constructor(frameUrls) {
|
|
61
|
+
this.frameUrls = frameUrls;
|
|
62
|
+
this.frames = new Map();
|
|
63
|
+
this.loading = new Map();
|
|
64
|
+
}
|
|
65
|
+
async load(index) {
|
|
66
|
+
if (this.frames.has(index)) {
|
|
67
|
+
return this.frames.get(index);
|
|
68
|
+
}
|
|
69
|
+
if (this.loading.has(index)) {
|
|
70
|
+
return this.loading.get(index);
|
|
71
|
+
}
|
|
72
|
+
loadCount++;
|
|
73
|
+
const url = this.frameUrls[index];
|
|
74
|
+
const frame = { url };
|
|
75
|
+
const promise = new Promise((resolve) => {
|
|
76
|
+
setTimeout(() => {
|
|
77
|
+
this.frames.set(index, frame);
|
|
78
|
+
this.loading.delete(index);
|
|
79
|
+
resolve(frame);
|
|
80
|
+
}, 10);
|
|
81
|
+
});
|
|
82
|
+
this.loading.set(index, promise);
|
|
83
|
+
return promise;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const cache = new TrackingCache(["frame1.png"]);
|
|
87
|
+
// Start multiple concurrent loads
|
|
88
|
+
const [frame1, frame2, frame3] = await Promise.all([
|
|
89
|
+
cache.load(0),
|
|
90
|
+
cache.load(0),
|
|
91
|
+
cache.load(0),
|
|
92
|
+
]);
|
|
93
|
+
// Should only have loaded once
|
|
94
|
+
expect(loadCount).toBe(1);
|
|
95
|
+
// All should be the same frame
|
|
96
|
+
expect(frame1.url).toBe("frame1.png");
|
|
97
|
+
expect(frame2.url).toBe("frame1.png");
|
|
98
|
+
expect(frame3.url).toBe("frame1.png");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe("resolveAssetUrl", () => {
|
|
102
|
+
// Clone of the resolveAssetUrl function
|
|
103
|
+
function resolveAssetUrl(asset, manifestUrl) {
|
|
104
|
+
try {
|
|
105
|
+
return new URL(asset, manifestUrl).toString();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return asset;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
it("should resolve relative URLs", () => {
|
|
112
|
+
const url = resolveAssetUrl("frames/0001.png", "https://example.com/assets/scene/manifest.json");
|
|
113
|
+
expect(url).toBe("https://example.com/assets/scene/frames/0001.png");
|
|
114
|
+
});
|
|
115
|
+
it("should resolve URLs with parent traversal", () => {
|
|
116
|
+
const url = resolveAssetUrl("../shared/frame.png", "https://example.com/assets/scene/manifest.json");
|
|
117
|
+
expect(url).toBe("https://example.com/assets/shared/frame.png");
|
|
118
|
+
});
|
|
119
|
+
it("should preserve absolute URLs", () => {
|
|
120
|
+
const url = resolveAssetUrl("https://cdn.example.com/frame.png", "https://example.com/assets/manifest.json");
|
|
121
|
+
expect(url).toBe("https://cdn.example.com/frame.png");
|
|
122
|
+
});
|
|
123
|
+
it("should handle file:// URLs", () => {
|
|
124
|
+
const url = resolveAssetUrl("frames/0001.png", "file:///Users/test/project/manifest.json");
|
|
125
|
+
expect(url).toBe("file:///Users/test/project/frames/0001.png");
|
|
126
|
+
});
|
|
127
|
+
it("should return original asset for invalid URLs", () => {
|
|
128
|
+
const url = resolveAssetUrl("frame.png", "not-a-valid-url");
|
|
129
|
+
expect(url).toBe("frame.png");
|
|
130
|
+
});
|
|
131
|
+
it("should handle root-relative paths", () => {
|
|
132
|
+
const url = resolveAssetUrl("/assets/frame.png", "https://example.com/manifest.json");
|
|
133
|
+
expect(url).toBe("https://example.com/assets/frame.png");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
describe("loadManifest", () => {
|
|
137
|
+
// Test the manifest loading logic
|
|
138
|
+
it("should handle valid manifest structure", () => {
|
|
139
|
+
const rawManifest = {
|
|
140
|
+
scene: "TextScene",
|
|
141
|
+
fps: 30,
|
|
142
|
+
width: 1920,
|
|
143
|
+
height: 1080,
|
|
144
|
+
frames: ["frames/0001.png", "frames/0002.png"],
|
|
145
|
+
video: "video.mp4",
|
|
146
|
+
};
|
|
147
|
+
// Simulate processing
|
|
148
|
+
const manifestUrl = "https://example.com/assets/manifest.json";
|
|
149
|
+
function resolveAssetUrl(asset, url) {
|
|
150
|
+
try {
|
|
151
|
+
return new URL(asset, url).toString();
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return asset;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const processed = {
|
|
158
|
+
...rawManifest,
|
|
159
|
+
frames: rawManifest.frames.map((frame) => resolveAssetUrl(frame, manifestUrl)),
|
|
160
|
+
video: rawManifest.video ? resolveAssetUrl(rawManifest.video, manifestUrl) : null,
|
|
161
|
+
};
|
|
162
|
+
expect(processed.scene).toBe("TextScene");
|
|
163
|
+
expect(processed.fps).toBe(30);
|
|
164
|
+
expect(processed.width).toBe(1920);
|
|
165
|
+
expect(processed.height).toBe(1080);
|
|
166
|
+
expect(processed.frames).toHaveLength(2);
|
|
167
|
+
expect(processed.frames[0]).toBe("https://example.com/assets/frames/0001.png");
|
|
168
|
+
expect(processed.video).toBe("https://example.com/assets/video.mp4");
|
|
169
|
+
});
|
|
170
|
+
it("should handle manifest without video", () => {
|
|
171
|
+
const rawManifest = {
|
|
172
|
+
scene: "CustomScene",
|
|
173
|
+
fps: 60,
|
|
174
|
+
width: 1280,
|
|
175
|
+
height: 720,
|
|
176
|
+
frames: ["frame.png"],
|
|
177
|
+
video: null,
|
|
178
|
+
};
|
|
179
|
+
const processed = {
|
|
180
|
+
...rawManifest,
|
|
181
|
+
video: rawManifest.video ? rawManifest.video : null,
|
|
182
|
+
};
|
|
183
|
+
expect(processed.video).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
});
|
package/dist/player.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ScrollAnimationOptions } from "./types";
|
|
2
|
+
export declare class ScrollPlayer {
|
|
3
|
+
private readonly options;
|
|
4
|
+
private readonly container;
|
|
5
|
+
private readonly canvas;
|
|
6
|
+
private readonly ctx;
|
|
7
|
+
private manifest?;
|
|
8
|
+
private frameCache?;
|
|
9
|
+
private videoEl?;
|
|
10
|
+
private mode;
|
|
11
|
+
private isActive;
|
|
12
|
+
private rafId;
|
|
13
|
+
private observer?;
|
|
14
|
+
constructor(options: ScrollAnimationOptions);
|
|
15
|
+
init(): Promise<void>;
|
|
16
|
+
destroy(): void;
|
|
17
|
+
private selectMode;
|
|
18
|
+
private setupObserver;
|
|
19
|
+
private start;
|
|
20
|
+
private stop;
|
|
21
|
+
private tick;
|
|
22
|
+
}
|
package/dist/player.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { FrameCache, loadManifest } from "./loader";
|
|
2
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
3
|
+
/**
|
|
4
|
+
* Parse a relative unit string (e.g., "100vh", "-50%") to pixels.
|
|
5
|
+
* - "vh" units are relative to viewport height
|
|
6
|
+
* - "%" units are relative to element height
|
|
7
|
+
* - Numbers without units are treated as pixels
|
|
8
|
+
*/
|
|
9
|
+
function parseRelativeUnit(value, viewportHeight, elementHeight) {
|
|
10
|
+
if (typeof value === "number") {
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
const trimmed = value.trim();
|
|
14
|
+
// Handle viewport height units
|
|
15
|
+
if (trimmed.endsWith("vh")) {
|
|
16
|
+
const num = parseFloat(trimmed.slice(0, -2));
|
|
17
|
+
return (num / 100) * viewportHeight;
|
|
18
|
+
}
|
|
19
|
+
// Handle percentage (relative to element height)
|
|
20
|
+
if (trimmed.endsWith("%")) {
|
|
21
|
+
const num = parseFloat(trimmed.slice(0, -1));
|
|
22
|
+
return (num / 100) * elementHeight;
|
|
23
|
+
}
|
|
24
|
+
// Handle pixel units or plain numbers
|
|
25
|
+
if (trimmed.endsWith("px")) {
|
|
26
|
+
return parseFloat(trimmed.slice(0, -2));
|
|
27
|
+
}
|
|
28
|
+
return parseFloat(trimmed);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a ScrollRangeValue to a normalized { start, end } object in pixels.
|
|
32
|
+
* Supports presets, tuple format, and legacy object format.
|
|
33
|
+
*/
|
|
34
|
+
function resolveScrollRange(range, viewportHeight, elementHeight, documentHeight) {
|
|
35
|
+
var _a, _b;
|
|
36
|
+
// Default: viewport preset behavior
|
|
37
|
+
if (range === undefined || range === "viewport") {
|
|
38
|
+
return {
|
|
39
|
+
start: viewportHeight,
|
|
40
|
+
end: -elementHeight,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// Element preset: animation tied to element's scroll position
|
|
44
|
+
if (range === "element") {
|
|
45
|
+
return {
|
|
46
|
+
start: viewportHeight * 0.8,
|
|
47
|
+
end: viewportHeight * 0.2 - elementHeight,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// Full preset: spans entire document scroll
|
|
51
|
+
if (range === "full") {
|
|
52
|
+
return {
|
|
53
|
+
start: documentHeight - viewportHeight,
|
|
54
|
+
end: 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Tuple format: [start, end] with relative units
|
|
58
|
+
if (Array.isArray(range)) {
|
|
59
|
+
const [startVal, endVal] = range;
|
|
60
|
+
return {
|
|
61
|
+
start: parseRelativeUnit(startVal, viewportHeight, elementHeight),
|
|
62
|
+
end: parseRelativeUnit(endVal, viewportHeight, elementHeight),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// Legacy object format
|
|
66
|
+
return {
|
|
67
|
+
start: (_a = range.start) !== null && _a !== void 0 ? _a : viewportHeight,
|
|
68
|
+
end: (_b = range.end) !== null && _b !== void 0 ? _b : -elementHeight,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function resolveScrollProgress(rect, viewportHeight, range) {
|
|
72
|
+
var _a, _b;
|
|
73
|
+
const documentHeight = document.documentElement.scrollHeight;
|
|
74
|
+
const resolved = resolveScrollRange(range, viewportHeight, rect.height, documentHeight);
|
|
75
|
+
const start = (_a = resolved.start) !== null && _a !== void 0 ? _a : viewportHeight;
|
|
76
|
+
const end = (_b = resolved.end) !== null && _b !== void 0 ? _b : -rect.height;
|
|
77
|
+
const progress = (start - rect.top) / (start - end);
|
|
78
|
+
return clamp(progress, 0, 1);
|
|
79
|
+
}
|
|
80
|
+
export class ScrollPlayer {
|
|
81
|
+
constructor(options) {
|
|
82
|
+
var _a;
|
|
83
|
+
this.options = options;
|
|
84
|
+
this.mode = "video";
|
|
85
|
+
this.isActive = false;
|
|
86
|
+
this.rafId = null;
|
|
87
|
+
this.container = options.container;
|
|
88
|
+
this.canvas = (_a = options.canvas) !== null && _a !== void 0 ? _a : document.createElement("canvas");
|
|
89
|
+
this.ctx = this.canvas.getContext("2d");
|
|
90
|
+
if (!options.canvas) {
|
|
91
|
+
this.container.appendChild(this.canvas);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async init() {
|
|
95
|
+
var _a, _b;
|
|
96
|
+
this.manifest = await loadManifest(this.options.manifestUrl);
|
|
97
|
+
this.frameCache = new FrameCache(this.manifest.frames);
|
|
98
|
+
this.mode = this.selectMode(this.manifest, this.options.mode);
|
|
99
|
+
this.canvas.width = this.manifest.width;
|
|
100
|
+
this.canvas.height = this.manifest.height;
|
|
101
|
+
if (this.mode === "video" && this.manifest.video) {
|
|
102
|
+
const video = document.createElement("video");
|
|
103
|
+
video.src = this.manifest.video;
|
|
104
|
+
video.muted = true;
|
|
105
|
+
video.playsInline = true;
|
|
106
|
+
await new Promise((resolve) => {
|
|
107
|
+
video.addEventListener("loadedmetadata", () => resolve(), { once: true });
|
|
108
|
+
});
|
|
109
|
+
this.videoEl = video;
|
|
110
|
+
}
|
|
111
|
+
this.setupObserver();
|
|
112
|
+
(_b = (_a = this.options).onReady) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
113
|
+
}
|
|
114
|
+
destroy() {
|
|
115
|
+
var _a;
|
|
116
|
+
this.stop();
|
|
117
|
+
(_a = this.observer) === null || _a === void 0 ? void 0 : _a.disconnect();
|
|
118
|
+
if (!this.options.canvas) {
|
|
119
|
+
this.canvas.remove();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
selectMode(manifest, mode) {
|
|
123
|
+
if (mode === "frames")
|
|
124
|
+
return "frames";
|
|
125
|
+
if (mode === "video" && manifest.video)
|
|
126
|
+
return "video";
|
|
127
|
+
if (mode === "video" && !manifest.video)
|
|
128
|
+
return "frames";
|
|
129
|
+
return manifest.video ? "video" : "frames";
|
|
130
|
+
}
|
|
131
|
+
setupObserver() {
|
|
132
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (entry.isIntersecting) {
|
|
135
|
+
this.start();
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
this.stop();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}, { root: null, threshold: 0 });
|
|
142
|
+
this.observer.observe(this.container);
|
|
143
|
+
}
|
|
144
|
+
start() {
|
|
145
|
+
if (this.isActive)
|
|
146
|
+
return;
|
|
147
|
+
this.isActive = true;
|
|
148
|
+
const loop = () => {
|
|
149
|
+
this.tick();
|
|
150
|
+
this.rafId = requestAnimationFrame(loop);
|
|
151
|
+
};
|
|
152
|
+
this.rafId = requestAnimationFrame(loop);
|
|
153
|
+
}
|
|
154
|
+
stop() {
|
|
155
|
+
if (!this.isActive)
|
|
156
|
+
return;
|
|
157
|
+
this.isActive = false;
|
|
158
|
+
if (this.rafId !== null) {
|
|
159
|
+
cancelAnimationFrame(this.rafId);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async tick() {
|
|
163
|
+
var _a, _b;
|
|
164
|
+
if (!this.manifest || !this.frameCache)
|
|
165
|
+
return;
|
|
166
|
+
const rect = this.container.getBoundingClientRect();
|
|
167
|
+
const progress = resolveScrollProgress(rect, window.innerHeight, this.options.scrollRange);
|
|
168
|
+
(_b = (_a = this.options).onProgress) === null || _b === void 0 ? void 0 : _b.call(_a, progress);
|
|
169
|
+
if (this.mode === "video" && this.videoEl) {
|
|
170
|
+
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);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const index = Math.round(progress * (this.frameCache.length - 1));
|
|
176
|
+
const frame = await this.frameCache.load(index);
|
|
177
|
+
this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
// We need to test the internal functions, so we'll extract them for testing
|
|
3
|
+
// The player module doesn't export these, so we'll create unit tests for the logic
|
|
4
|
+
/**
|
|
5
|
+
* Clone of parseRelativeUnit for testing
|
|
6
|
+
*/
|
|
7
|
+
function parseRelativeUnit(value, viewportHeight, elementHeight) {
|
|
8
|
+
if (typeof value === "number") {
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
if (trimmed.endsWith("vh")) {
|
|
13
|
+
const num = parseFloat(trimmed.slice(0, -2));
|
|
14
|
+
return (num / 100) * viewportHeight;
|
|
15
|
+
}
|
|
16
|
+
if (trimmed.endsWith("%")) {
|
|
17
|
+
const num = parseFloat(trimmed.slice(0, -1));
|
|
18
|
+
return (num / 100) * elementHeight;
|
|
19
|
+
}
|
|
20
|
+
if (trimmed.endsWith("px")) {
|
|
21
|
+
return parseFloat(trimmed.slice(0, -2));
|
|
22
|
+
}
|
|
23
|
+
return parseFloat(trimmed);
|
|
24
|
+
}
|
|
25
|
+
function resolveScrollRange(range, viewportHeight, elementHeight, documentHeight) {
|
|
26
|
+
var _a, _b;
|
|
27
|
+
if (range === undefined || range === "viewport") {
|
|
28
|
+
return {
|
|
29
|
+
start: viewportHeight,
|
|
30
|
+
end: -elementHeight,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (range === "element") {
|
|
34
|
+
return {
|
|
35
|
+
start: viewportHeight * 0.8,
|
|
36
|
+
end: viewportHeight * 0.2 - elementHeight,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (range === "full") {
|
|
40
|
+
return {
|
|
41
|
+
start: documentHeight - viewportHeight,
|
|
42
|
+
end: 0,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (Array.isArray(range)) {
|
|
46
|
+
const [startVal, endVal] = range;
|
|
47
|
+
return {
|
|
48
|
+
start: parseRelativeUnit(startVal, viewportHeight, elementHeight),
|
|
49
|
+
end: parseRelativeUnit(endVal, viewportHeight, elementHeight),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
start: (_a = range.start) !== null && _a !== void 0 ? _a : viewportHeight,
|
|
54
|
+
end: (_b = range.end) !== null && _b !== void 0 ? _b : -elementHeight,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
58
|
+
function resolveScrollProgress(rectTop, rectHeight, viewportHeight, documentHeight, range) {
|
|
59
|
+
var _a, _b;
|
|
60
|
+
const resolved = resolveScrollRange(range, viewportHeight, rectHeight, documentHeight);
|
|
61
|
+
const start = (_a = resolved.start) !== null && _a !== void 0 ? _a : viewportHeight;
|
|
62
|
+
const end = (_b = resolved.end) !== null && _b !== void 0 ? _b : -rectHeight;
|
|
63
|
+
const progress = (start - rectTop) / (start - end);
|
|
64
|
+
return clamp(progress, 0, 1);
|
|
65
|
+
}
|
|
66
|
+
describe("parseRelativeUnit", () => {
|
|
67
|
+
const viewportHeight = 1000;
|
|
68
|
+
const elementHeight = 500;
|
|
69
|
+
it("should return number values directly", () => {
|
|
70
|
+
expect(parseRelativeUnit(100, viewportHeight, elementHeight)).toBe(100);
|
|
71
|
+
expect(parseRelativeUnit(-50, viewportHeight, elementHeight)).toBe(-50);
|
|
72
|
+
expect(parseRelativeUnit(0, viewportHeight, elementHeight)).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
it("should parse viewport height units (vh)", () => {
|
|
75
|
+
expect(parseRelativeUnit("100vh", viewportHeight, elementHeight)).toBe(1000);
|
|
76
|
+
expect(parseRelativeUnit("50vh", viewportHeight, elementHeight)).toBe(500);
|
|
77
|
+
expect(parseRelativeUnit("0vh", viewportHeight, elementHeight)).toBe(0);
|
|
78
|
+
expect(parseRelativeUnit("-25vh", viewportHeight, elementHeight)).toBe(-250);
|
|
79
|
+
});
|
|
80
|
+
it("should parse percentage units (relative to element height)", () => {
|
|
81
|
+
expect(parseRelativeUnit("100%", viewportHeight, elementHeight)).toBe(500);
|
|
82
|
+
expect(parseRelativeUnit("50%", viewportHeight, elementHeight)).toBe(250);
|
|
83
|
+
expect(parseRelativeUnit("-50%", viewportHeight, elementHeight)).toBe(-250);
|
|
84
|
+
});
|
|
85
|
+
it("should parse pixel units", () => {
|
|
86
|
+
expect(parseRelativeUnit("100px", viewportHeight, elementHeight)).toBe(100);
|
|
87
|
+
expect(parseRelativeUnit("-200px", viewportHeight, elementHeight)).toBe(-200);
|
|
88
|
+
});
|
|
89
|
+
it("should parse plain numbers as strings", () => {
|
|
90
|
+
expect(parseRelativeUnit("100", viewportHeight, elementHeight)).toBe(100);
|
|
91
|
+
expect(parseRelativeUnit("-50", viewportHeight, elementHeight)).toBe(-50);
|
|
92
|
+
});
|
|
93
|
+
it("should handle whitespace", () => {
|
|
94
|
+
expect(parseRelativeUnit(" 100vh ", viewportHeight, elementHeight)).toBe(1000);
|
|
95
|
+
expect(parseRelativeUnit(" 50% ", viewportHeight, elementHeight)).toBe(250);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe("resolveScrollRange", () => {
|
|
99
|
+
const viewportHeight = 1000;
|
|
100
|
+
const elementHeight = 500;
|
|
101
|
+
const documentHeight = 3000;
|
|
102
|
+
it("should handle undefined (defaults to viewport preset)", () => {
|
|
103
|
+
const result = resolveScrollRange(undefined, viewportHeight, elementHeight, documentHeight);
|
|
104
|
+
expect(result.start).toBe(1000);
|
|
105
|
+
expect(result.end).toBe(-500);
|
|
106
|
+
});
|
|
107
|
+
it("should handle viewport preset", () => {
|
|
108
|
+
const result = resolveScrollRange("viewport", viewportHeight, elementHeight, documentHeight);
|
|
109
|
+
expect(result.start).toBe(1000);
|
|
110
|
+
expect(result.end).toBe(-500);
|
|
111
|
+
});
|
|
112
|
+
it("should handle element preset", () => {
|
|
113
|
+
const result = resolveScrollRange("element", viewportHeight, elementHeight, documentHeight);
|
|
114
|
+
expect(result.start).toBe(800); // viewportHeight * 0.8
|
|
115
|
+
expect(result.end).toBe(-300); // viewportHeight * 0.2 - elementHeight
|
|
116
|
+
});
|
|
117
|
+
it("should handle full preset", () => {
|
|
118
|
+
const result = resolveScrollRange("full", viewportHeight, elementHeight, documentHeight);
|
|
119
|
+
expect(result.start).toBe(2000); // documentHeight - viewportHeight
|
|
120
|
+
expect(result.end).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
it("should handle tuple format with relative units", () => {
|
|
123
|
+
const result = resolveScrollRange(["100vh", "-50%"], viewportHeight, elementHeight, documentHeight);
|
|
124
|
+
expect(result.start).toBe(1000);
|
|
125
|
+
expect(result.end).toBe(-250);
|
|
126
|
+
});
|
|
127
|
+
it("should handle tuple format with numbers", () => {
|
|
128
|
+
const result = resolveScrollRange([800, -400], viewportHeight, elementHeight, documentHeight);
|
|
129
|
+
expect(result.start).toBe(800);
|
|
130
|
+
expect(result.end).toBe(-400);
|
|
131
|
+
});
|
|
132
|
+
it("should handle tuple format with mixed values", () => {
|
|
133
|
+
const result = resolveScrollRange(["80vh", -200], viewportHeight, elementHeight, documentHeight);
|
|
134
|
+
expect(result.start).toBe(800);
|
|
135
|
+
expect(result.end).toBe(-200);
|
|
136
|
+
});
|
|
137
|
+
it("should handle legacy object format", () => {
|
|
138
|
+
const result = resolveScrollRange({ start: 600, end: -300 }, viewportHeight, elementHeight, documentHeight);
|
|
139
|
+
expect(result.start).toBe(600);
|
|
140
|
+
expect(result.end).toBe(-300);
|
|
141
|
+
});
|
|
142
|
+
it("should handle partial legacy object format", () => {
|
|
143
|
+
const result = resolveScrollRange({ start: 600 }, viewportHeight, elementHeight, documentHeight);
|
|
144
|
+
expect(result.start).toBe(600);
|
|
145
|
+
expect(result.end).toBe(-500); // defaults to -elementHeight
|
|
146
|
+
});
|
|
147
|
+
it("should handle empty legacy object format", () => {
|
|
148
|
+
const result = resolveScrollRange({}, viewportHeight, elementHeight, documentHeight);
|
|
149
|
+
expect(result.start).toBe(1000); // defaults to viewportHeight
|
|
150
|
+
expect(result.end).toBe(-500); // defaults to -elementHeight
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe("resolveScrollProgress", () => {
|
|
154
|
+
const viewportHeight = 1000;
|
|
155
|
+
const elementHeight = 500;
|
|
156
|
+
const documentHeight = 3000;
|
|
157
|
+
it("should return 0 when element is below viewport", () => {
|
|
158
|
+
// Element top is at viewport height (just entering from bottom)
|
|
159
|
+
const progress = resolveScrollProgress(viewportHeight, // rectTop at bottom of viewport
|
|
160
|
+
elementHeight, viewportHeight, documentHeight, "viewport");
|
|
161
|
+
expect(progress).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
it("should return 1 when element is above viewport", () => {
|
|
164
|
+
// Element top is at -elementHeight (fully exited top)
|
|
165
|
+
const progress = resolveScrollProgress(-elementHeight, elementHeight, viewportHeight, documentHeight, "viewport");
|
|
166
|
+
expect(progress).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
it("should return 0.5 when element is halfway through", () => {
|
|
169
|
+
// Halfway between start (1000) and end (-500) is 250
|
|
170
|
+
const progress = resolveScrollProgress(250, elementHeight, viewportHeight, documentHeight, "viewport");
|
|
171
|
+
expect(progress).toBe(0.5);
|
|
172
|
+
});
|
|
173
|
+
it("should clamp progress to 0-1 range", () => {
|
|
174
|
+
// Way below viewport
|
|
175
|
+
const progressBelow = resolveScrollProgress(2000, // far below
|
|
176
|
+
elementHeight, viewportHeight, documentHeight, "viewport");
|
|
177
|
+
expect(progressBelow).toBe(0);
|
|
178
|
+
// Way above viewport
|
|
179
|
+
const progressAbove = resolveScrollProgress(-2000, // far above
|
|
180
|
+
elementHeight, viewportHeight, documentHeight, "viewport");
|
|
181
|
+
expect(progressAbove).toBe(1);
|
|
182
|
+
});
|
|
183
|
+
it("should work with different scroll range presets", () => {
|
|
184
|
+
// Test with element preset
|
|
185
|
+
const progressElement = resolveScrollProgress(viewportHeight * 0.5, // middle
|
|
186
|
+
elementHeight, viewportHeight, documentHeight, "element");
|
|
187
|
+
expect(progressElement).toBeGreaterThan(0);
|
|
188
|
+
expect(progressElement).toBeLessThan(1);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
describe("clamp", () => {
|
|
192
|
+
it("should clamp values within range", () => {
|
|
193
|
+
expect(clamp(0.5, 0, 1)).toBe(0.5);
|
|
194
|
+
expect(clamp(0, 0, 1)).toBe(0);
|
|
195
|
+
expect(clamp(1, 0, 1)).toBe(1);
|
|
196
|
+
});
|
|
197
|
+
it("should clamp values below minimum", () => {
|
|
198
|
+
expect(clamp(-1, 0, 1)).toBe(0);
|
|
199
|
+
expect(clamp(-100, 0, 1)).toBe(0);
|
|
200
|
+
});
|
|
201
|
+
it("should clamp values above maximum", () => {
|
|
202
|
+
expect(clamp(2, 0, 1)).toBe(1);
|
|
203
|
+
expect(clamp(100, 0, 1)).toBe(1);
|
|
204
|
+
});
|
|
205
|
+
it("should work with different ranges", () => {
|
|
206
|
+
expect(clamp(50, 0, 100)).toBe(50);
|
|
207
|
+
expect(clamp(-10, 0, 100)).toBe(0);
|
|
208
|
+
expect(clamp(150, 0, 100)).toBe(100);
|
|
209
|
+
});
|
|
210
|
+
});
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type RenderManifest = {
|
|
2
|
+
scene: string;
|
|
3
|
+
fps: number;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
frames: string[];
|
|
7
|
+
video: string | null;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Legacy scroll range format (pixels).
|
|
11
|
+
*/
|
|
12
|
+
export type ScrollRange = {
|
|
13
|
+
start?: number;
|
|
14
|
+
end?: number;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Preset scroll range behaviors.
|
|
18
|
+
* - "viewport": Animation plays as element crosses the viewport (most common)
|
|
19
|
+
* - "element": Animation tied to element's own scroll position
|
|
20
|
+
* - "full": Animation spans entire document scroll
|
|
21
|
+
*/
|
|
22
|
+
export type ScrollRangePreset = "viewport" | "element" | "full";
|
|
23
|
+
/**
|
|
24
|
+
* Flexible scroll range value that supports:
|
|
25
|
+
* - Presets: "viewport" | "element" | "full"
|
|
26
|
+
* - Tuple with relative units: ["100vh", "-50%"] or [100, -200]
|
|
27
|
+
* - Legacy object format: { start?: number, end?: number }
|
|
28
|
+
*/
|
|
29
|
+
export type ScrollRangeValue = ScrollRangePreset | [start: string | number, end: string | number] | ScrollRange;
|
|
30
|
+
export type ScrollAnimationOptions = {
|
|
31
|
+
container: HTMLElement;
|
|
32
|
+
manifestUrl: string;
|
|
33
|
+
mode?: "auto" | "frames" | "video";
|
|
34
|
+
canvas?: HTMLCanvasElement;
|
|
35
|
+
scrollRange?: ScrollRangeValue;
|
|
36
|
+
onReady?: () => void;
|
|
37
|
+
onProgress?: (progress: number) => void;
|
|
38
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mihirsarya/manim-scroll-runtime",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Scroll-driven playback runtime for pre-rendered Manim animations.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"typescript": "^5.4.5"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc -p tsconfig.json"
|
|
15
|
+
}
|
|
16
|
+
}
|