@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.
@@ -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
@@ -0,0 +1,6 @@
1
+ import { ScrollPlayer } from "./player";
2
+ export async function registerScrollAnimation(options) {
3
+ const player = new ScrollPlayer(options);
4
+ await player.init();
5
+ return () => player.destroy();
6
+ }
@@ -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
+ });
@@ -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
+ });
@@ -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
+ }