@mihirsarya/manim-scroll-next 0.1.5 → 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 +265 -0
- package/dist/extractor.js +27 -2
- package/dist/extractor.test.js +32 -0
- package/dist/index.d.ts +13 -2
- package/dist/index.js +44 -6
- package/package.json +6 -7
- package/render/cli.py +47 -2
- package/render/templates/text_scene.py +39 -1
package/README.md
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# @mihirsarya/manim-scroll-next
|
|
2
|
+
|
|
3
|
+
Next.js plugin for build-time Manim scroll animation rendering. Automatically scans your source files, extracts `<ManimScroll>` components, and renders animations with smart caching.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @mihirsarya/manim-scroll-next
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or use the unified package (recommended):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @mihirsarya/manim-scroll
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Next.js 13+
|
|
20
|
+
- Python 3.8+ with [Manim](https://www.manim.community/) installed
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
### 1. Configure Next.js
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
// next.config.js
|
|
28
|
+
const { withManimScroll } = require("@mihirsarya/manim-scroll-next");
|
|
29
|
+
// Or: const { withManimScroll } = require("@mihirsarya/manim-scroll/next");
|
|
30
|
+
|
|
31
|
+
module.exports = withManimScroll({
|
|
32
|
+
manimScroll: {
|
|
33
|
+
pythonPath: "python3",
|
|
34
|
+
quality: "h",
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 2. Use ManimScroll Components
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
// app/page.tsx
|
|
43
|
+
import { ManimScroll } from "@mihirsarya/manim-scroll-react";
|
|
44
|
+
// Or: import { ManimScroll } from "@mihirsarya/manim-scroll";
|
|
45
|
+
|
|
46
|
+
export default function Home() {
|
|
47
|
+
return (
|
|
48
|
+
<ManimScroll
|
|
49
|
+
scene="TextScene"
|
|
50
|
+
fontSize={72}
|
|
51
|
+
color="#ffffff"
|
|
52
|
+
>
|
|
53
|
+
Welcome to my site
|
|
54
|
+
</ManimScroll>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 3. Build
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
next build
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The plugin:
|
|
66
|
+
1. Scans your source files for `<ManimScroll>` components
|
|
67
|
+
2. Extracts props and children text
|
|
68
|
+
3. Computes a hash for each unique animation
|
|
69
|
+
4. Renders only new/changed animations (cached by hash)
|
|
70
|
+
5. Outputs assets to `public/manim-assets/`
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
### Combined Config (Recommended)
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
module.exports = withManimScroll({
|
|
78
|
+
// Next.js config
|
|
79
|
+
reactStrictMode: true,
|
|
80
|
+
|
|
81
|
+
// Manim Scroll config
|
|
82
|
+
manimScroll: {
|
|
83
|
+
pythonPath: "python3",
|
|
84
|
+
quality: "h",
|
|
85
|
+
fps: 30,
|
|
86
|
+
resolution: "1920x1080",
|
|
87
|
+
verbose: true,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Separate Configs
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
const nextConfig = {
|
|
96
|
+
reactStrictMode: true,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
module.exports = withManimScroll(nextConfig, {
|
|
100
|
+
pythonPath: "python3",
|
|
101
|
+
quality: "h",
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Options
|
|
106
|
+
|
|
107
|
+
| Option | Type | Default | Description |
|
|
108
|
+
|--------|------|---------|-------------|
|
|
109
|
+
| `pythonPath` | `string` | `"python3"` | Path to Python executable |
|
|
110
|
+
| `cliPath` | `string` | Built-in | Path to render CLI script |
|
|
111
|
+
| `templatesDir` | `string` | Built-in | Path to scene templates |
|
|
112
|
+
| `quality` | `string` | `"h"` | Manim quality preset (`l`, `m`, `h`, `k`) |
|
|
113
|
+
| `fps` | `number` | `30` | Frames per second |
|
|
114
|
+
| `resolution` | `string` | `"1920x1080"` | Output resolution (`WIDTHxHEIGHT`) |
|
|
115
|
+
| `format` | `string` | `"both"` | Output format (`frames`, `video`, `both`) |
|
|
116
|
+
| `concurrency` | `number` | CPU count - 1 | Max parallel renders |
|
|
117
|
+
| `include` | `string[]` | `["**/*.tsx", "**/*.jsx"]` | Glob patterns to scan |
|
|
118
|
+
| `exclude` | `string[]` | `["node_modules/**", ".next/**"]` | Glob patterns to exclude |
|
|
119
|
+
| `cleanOrphans` | `boolean` | `true` | Remove unused cached assets |
|
|
120
|
+
| `verbose` | `boolean` | `false` | Enable verbose logging |
|
|
121
|
+
|
|
122
|
+
## Exports
|
|
123
|
+
|
|
124
|
+
### Main Export
|
|
125
|
+
|
|
126
|
+
- **`withManimScroll(config, manimConfig?)`** - Next.js config wrapper
|
|
127
|
+
|
|
128
|
+
### Types
|
|
129
|
+
|
|
130
|
+
- `ManimScrollConfig` - Plugin configuration type
|
|
131
|
+
- `NextConfigWithManimScroll` - Extended Next.js config type
|
|
132
|
+
|
|
133
|
+
### Utilities (Advanced)
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import {
|
|
137
|
+
extractAnimations,
|
|
138
|
+
renderAnimations,
|
|
139
|
+
computePropsHash,
|
|
140
|
+
isCached,
|
|
141
|
+
getCacheEntry,
|
|
142
|
+
getAnimationsToRender,
|
|
143
|
+
writeCacheManifest,
|
|
144
|
+
readCacheManifest,
|
|
145
|
+
cleanOrphanedCache,
|
|
146
|
+
processManimScroll,
|
|
147
|
+
} from "@mihirsarya/manim-scroll-next";
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## How It Works
|
|
151
|
+
|
|
152
|
+
### 1. Extraction
|
|
153
|
+
|
|
154
|
+
The plugin uses Babel to parse your source files and extract `<ManimScroll>` component usages:
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
<ManimScroll scene="TextScene" fontSize={72} color="#fff">
|
|
158
|
+
Hello World
|
|
159
|
+
</ManimScroll>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Becomes:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
{
|
|
166
|
+
id: "app/page.tsx:ManimScroll:1",
|
|
167
|
+
scene: "TextScene",
|
|
168
|
+
props: { text: "Hello World", fontSize: 72, color: "#fff" }
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### 2. Caching
|
|
173
|
+
|
|
174
|
+
Each animation is hashed based on `scene + props`. The cache manifest at `public/manim-assets/cache-manifest.json` maps hashes to rendered asset directories:
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
{
|
|
178
|
+
"version": 1,
|
|
179
|
+
"animations": {
|
|
180
|
+
"abc123...": "/manim-assets/abc123.../manifest.json"
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### 3. Rendering
|
|
186
|
+
|
|
187
|
+
New animations are rendered using the bundled Python CLI:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
python render/cli.py \
|
|
191
|
+
--scene-file render/templates/text_scene.py \
|
|
192
|
+
--scene-name TextScene \
|
|
193
|
+
--props '{"text": "Hello World", "fontSize": 72}' \
|
|
194
|
+
--output-dir public/manim-assets/abc123...
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### 4. Asset Structure
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
public/manim-assets/
|
|
201
|
+
├── cache-manifest.json
|
|
202
|
+
├── abc123.../
|
|
203
|
+
│ ├── manifest.json
|
|
204
|
+
│ ├── media/
|
|
205
|
+
│ │ ├── videos/...
|
|
206
|
+
│ │ └── images/...
|
|
207
|
+
└── def456.../
|
|
208
|
+
└── ...
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Development Mode
|
|
212
|
+
|
|
213
|
+
In dev mode (`next dev`), the plugin:
|
|
214
|
+
- Creates an empty cache manifest immediately to prevent 404s
|
|
215
|
+
- Processes animations asynchronously in the background
|
|
216
|
+
- Retries manifest resolution if animations are still rendering
|
|
217
|
+
|
|
218
|
+
## Custom Build Scripts
|
|
219
|
+
|
|
220
|
+
For advanced workflows, use the exported utilities:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { extractAnimations, renderAnimations } from "@mihirsarya/manim-scroll-next";
|
|
224
|
+
|
|
225
|
+
async function customBuild() {
|
|
226
|
+
const animations = await extractAnimations({
|
|
227
|
+
rootDir: process.cwd(),
|
|
228
|
+
include: ["src/**/*.tsx"],
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const results = await renderAnimations(animations, "./public", {
|
|
232
|
+
pythonPath: "python3",
|
|
233
|
+
quality: "h",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
console.log(`Rendered ${results.filter(r => r.success).length} animations`);
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Troubleshooting
|
|
241
|
+
|
|
242
|
+
### Manim not found
|
|
243
|
+
|
|
244
|
+
Ensure Manim is installed and accessible:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
python3 -c "import manim; print(manim.__version__)"
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Animations not updating
|
|
251
|
+
|
|
252
|
+
The cache is based on props hash. To force re-render:
|
|
253
|
+
|
|
254
|
+
1. Delete `public/manim-assets/`
|
|
255
|
+
2. Or change a prop value
|
|
256
|
+
|
|
257
|
+
### Slow builds
|
|
258
|
+
|
|
259
|
+
- Reduce `quality` (use `"l"` or `"m"` for development)
|
|
260
|
+
- Increase `concurrency` if you have more CPU cores
|
|
261
|
+
- Use `format: "frames"` to skip video encoding
|
|
262
|
+
|
|
263
|
+
## License
|
|
264
|
+
|
|
265
|
+
MIT
|
package/dist/extractor.js
CHANGED
|
@@ -189,8 +189,25 @@ function isManimScrollComponent(node) {
|
|
|
189
189
|
}
|
|
190
190
|
return false;
|
|
191
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Props that should NOT be included in the animation hash.
|
|
194
|
+
* These are display/scroll-related props, not animation-specific props.
|
|
195
|
+
* This list must stay in sync with what ManimScroll.tsx excludes from animationProps.
|
|
196
|
+
*/
|
|
197
|
+
const EXCLUDED_PROPS = new Set([
|
|
198
|
+
"manifestUrl",
|
|
199
|
+
"mode",
|
|
200
|
+
"scrollRange",
|
|
201
|
+
"onReady",
|
|
202
|
+
"onProgress",
|
|
203
|
+
"canvas",
|
|
204
|
+
"className",
|
|
205
|
+
"style",
|
|
206
|
+
"children", // children is handled separately as "text"
|
|
207
|
+
]);
|
|
192
208
|
/**
|
|
193
209
|
* Extract ManimScroll component data from a JSX element.
|
|
210
|
+
* Returns null if the component uses native mode (no pre-rendering needed).
|
|
194
211
|
*/
|
|
195
212
|
function extractManimScroll(jsxElement, filePath) {
|
|
196
213
|
var _a, _b;
|
|
@@ -200,6 +217,7 @@ function extractManimScroll(jsxElement, filePath) {
|
|
|
200
217
|
}
|
|
201
218
|
const props = {};
|
|
202
219
|
let scene = "TextScene";
|
|
220
|
+
let mode;
|
|
203
221
|
// Extract attributes
|
|
204
222
|
for (const attr of openingElement.attributes) {
|
|
205
223
|
if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name)) {
|
|
@@ -208,8 +226,11 @@ function extractManimScroll(jsxElement, filePath) {
|
|
|
208
226
|
if (name === "scene" && typeof value === "string") {
|
|
209
227
|
scene = value;
|
|
210
228
|
}
|
|
211
|
-
else if (name === "
|
|
212
|
-
|
|
229
|
+
else if (name === "mode" && typeof value === "string") {
|
|
230
|
+
mode = value;
|
|
231
|
+
}
|
|
232
|
+
else if (EXCLUDED_PROPS.has(name)) {
|
|
233
|
+
// Skip display/scroll-related props - only include animation-specific props
|
|
213
234
|
continue;
|
|
214
235
|
}
|
|
215
236
|
else if (value !== undefined) {
|
|
@@ -217,6 +238,10 @@ function extractManimScroll(jsxElement, filePath) {
|
|
|
217
238
|
}
|
|
218
239
|
}
|
|
219
240
|
}
|
|
241
|
+
// Skip native mode components - they render in the browser without pre-rendering
|
|
242
|
+
if (mode === "native") {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
220
245
|
// Extract children as text prop
|
|
221
246
|
const childrenText = extractChildrenText(jsxElement.children);
|
|
222
247
|
if (childrenText) {
|
package/dist/extractor.test.js
CHANGED
|
@@ -237,6 +237,38 @@ vitest_1.vi.mock("glob", () => ({
|
|
|
237
237
|
(0, vitest_1.expect)(result).toHaveLength(1);
|
|
238
238
|
(0, vitest_1.expect)(result[0].props.data).toEqual({ "123": "value" });
|
|
239
239
|
});
|
|
240
|
+
(0, vitest_1.it)("should exclude display/scroll-related props from animation props", () => {
|
|
241
|
+
vitest_1.vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
242
|
+
vitest_1.vi.mocked(fs.readFileSync).mockReturnValue(`
|
|
243
|
+
<ManimScroll
|
|
244
|
+
fontSize={72}
|
|
245
|
+
color="#ffffff"
|
|
246
|
+
scrollRange="viewport"
|
|
247
|
+
style={{ width: "100%", height: "100%" }}
|
|
248
|
+
className="animation-container"
|
|
249
|
+
mode="frames"
|
|
250
|
+
onReady={() => {}}
|
|
251
|
+
onProgress={(p) => console.log(p)}
|
|
252
|
+
canvas={{ width: 1920, height: 1080 }}
|
|
253
|
+
>
|
|
254
|
+
Hello World
|
|
255
|
+
</ManimScroll>
|
|
256
|
+
`);
|
|
257
|
+
const result = (0, extractor_1.extractAnimationsFromFile)("/app/page.tsx");
|
|
258
|
+
(0, vitest_1.expect)(result).toHaveLength(1);
|
|
259
|
+
// Animation props should be included
|
|
260
|
+
(0, vitest_1.expect)(result[0].props.fontSize).toBe(72);
|
|
261
|
+
(0, vitest_1.expect)(result[0].props.color).toBe("#ffffff");
|
|
262
|
+
(0, vitest_1.expect)(result[0].props.text).toBe("Hello World");
|
|
263
|
+
// Display/scroll props should be excluded
|
|
264
|
+
(0, vitest_1.expect)(result[0].props.scrollRange).toBeUndefined();
|
|
265
|
+
(0, vitest_1.expect)(result[0].props.style).toBeUndefined();
|
|
266
|
+
(0, vitest_1.expect)(result[0].props.className).toBeUndefined();
|
|
267
|
+
(0, vitest_1.expect)(result[0].props.mode).toBeUndefined();
|
|
268
|
+
(0, vitest_1.expect)(result[0].props.onReady).toBeUndefined();
|
|
269
|
+
(0, vitest_1.expect)(result[0].props.onProgress).toBeUndefined();
|
|
270
|
+
(0, vitest_1.expect)(result[0].props.canvas).toBeUndefined();
|
|
271
|
+
});
|
|
240
272
|
});
|
|
241
273
|
(0, vitest_1.describe)("extractChildrenText (via extractor)", () => {
|
|
242
274
|
(0, vitest_1.beforeEach)(() => {
|
package/dist/index.d.ts
CHANGED
|
@@ -35,9 +35,11 @@ declare function processManimScroll(projectDir: string, config: ManimScrollConfi
|
|
|
35
35
|
/**
|
|
36
36
|
* Wrap a Next.js config with ManimScroll build-time processing.
|
|
37
37
|
*
|
|
38
|
+
* Supports two calling patterns:
|
|
39
|
+
*
|
|
38
40
|
* @example
|
|
39
41
|
* ```js
|
|
40
|
-
* //
|
|
42
|
+
* // Pattern 1: Combined config (recommended)
|
|
41
43
|
* const { withManimScroll } = require("@mihirsarya/manim-scroll-next");
|
|
42
44
|
*
|
|
43
45
|
* module.exports = withManimScroll({
|
|
@@ -47,8 +49,17 @@ declare function processManimScroll(projectDir: string, config: ManimScrollConfi
|
|
|
47
49
|
* },
|
|
48
50
|
* });
|
|
49
51
|
* ```
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```js
|
|
55
|
+
* // Pattern 2: Separate configs
|
|
56
|
+
* module.exports = withManimScroll(nextConfig, {
|
|
57
|
+
* pythonPath: "python3",
|
|
58
|
+
* quality: "h",
|
|
59
|
+
* });
|
|
60
|
+
* ```
|
|
50
61
|
*/
|
|
51
|
-
export declare function withManimScroll(nextConfig?: NextConfigWithManimScroll): NextConfig;
|
|
62
|
+
export declare function withManimScroll(nextConfig?: NextConfigWithManimScroll, manimScrollConfig?: ManimScrollConfig): NextConfig;
|
|
52
63
|
export { extractAnimations, type ExtractedAnimation } from "./extractor";
|
|
53
64
|
export { computePropsHash, isCached, getCacheEntry, getAnimationsToRender, writeCacheManifest, readCacheManifest, cleanOrphanedCache, } from "./cache";
|
|
54
65
|
export { renderAnimations, type RenderResult, type RenderOptions } from "./renderer";
|
package/dist/index.js
CHANGED
|
@@ -36,10 +36,25 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.renderAnimations = exports.cleanOrphanedCache = exports.readCacheManifest = exports.writeCacheManifest = exports.getAnimationsToRender = exports.getCacheEntry = exports.isCached = exports.computePropsHash = exports.extractAnimations = void 0;
|
|
37
37
|
exports.withManimScroll = withManimScroll;
|
|
38
38
|
exports.processManimScroll = processManimScroll;
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
39
40
|
const path = __importStar(require("path"));
|
|
40
41
|
const extractor_1 = require("./extractor");
|
|
41
42
|
const cache_1 = require("./cache");
|
|
42
43
|
const renderer_1 = require("./renderer");
|
|
44
|
+
/**
|
|
45
|
+
* Ensure a cache manifest file exists (preserving existing entries).
|
|
46
|
+
* This is called synchronously so the file exists before the page loads.
|
|
47
|
+
*/
|
|
48
|
+
function ensureCacheManifestExists(publicDir) {
|
|
49
|
+
const manifestPath = path.join(publicDir, "manim-assets", "cache-manifest.json");
|
|
50
|
+
// If manifest already exists, leave it alone
|
|
51
|
+
if (fs.existsSync(manifestPath)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// Create the directory and an empty manifest
|
|
55
|
+
(0, cache_1.ensureAssetDir)(publicDir);
|
|
56
|
+
fs.writeFileSync(manifestPath, JSON.stringify({ version: 1, animations: {} }, null, 2));
|
|
57
|
+
}
|
|
43
58
|
let hasProcessed = false;
|
|
44
59
|
/**
|
|
45
60
|
* Process ManimScroll components: extract, cache, and render.
|
|
@@ -74,6 +89,11 @@ async function processManimScroll(projectDir, config) {
|
|
|
74
89
|
console.log(` - ${animation.id} (hash: ${hash})`);
|
|
75
90
|
}
|
|
76
91
|
}
|
|
92
|
+
// Write the cache manifest IMMEDIATELY after extraction, before rendering.
|
|
93
|
+
// This ensures the manifest is available when the page loads, even if
|
|
94
|
+
// rendering is still in progress. The runtime will gracefully handle
|
|
95
|
+
// missing animation files.
|
|
96
|
+
(0, cache_1.writeCacheManifest)(animations, publicDir);
|
|
77
97
|
// Determine which need rendering
|
|
78
98
|
const { cached, toRender } = (0, cache_1.getAnimationsToRender)(animations, publicDir);
|
|
79
99
|
if (verbose && cached.length > 0) {
|
|
@@ -101,8 +121,6 @@ async function processManimScroll(projectDir, config) {
|
|
|
101
121
|
else if (verbose) {
|
|
102
122
|
console.log("[manim-scroll] All animations are cached, skipping render.");
|
|
103
123
|
}
|
|
104
|
-
// Write the cache manifest for runtime lookup
|
|
105
|
-
(0, cache_1.writeCacheManifest)(animations, publicDir);
|
|
106
124
|
// Clean up orphaned cache entries
|
|
107
125
|
if (config.cleanOrphans !== false) {
|
|
108
126
|
(0, cache_1.cleanOrphanedCache)(animations, publicDir);
|
|
@@ -112,9 +130,11 @@ async function processManimScroll(projectDir, config) {
|
|
|
112
130
|
/**
|
|
113
131
|
* Wrap a Next.js config with ManimScroll build-time processing.
|
|
114
132
|
*
|
|
133
|
+
* Supports two calling patterns:
|
|
134
|
+
*
|
|
115
135
|
* @example
|
|
116
136
|
* ```js
|
|
117
|
-
* //
|
|
137
|
+
* // Pattern 1: Combined config (recommended)
|
|
118
138
|
* const { withManimScroll } = require("@mihirsarya/manim-scroll-next");
|
|
119
139
|
*
|
|
120
140
|
* module.exports = withManimScroll({
|
|
@@ -124,10 +144,22 @@ async function processManimScroll(projectDir, config) {
|
|
|
124
144
|
* },
|
|
125
145
|
* });
|
|
126
146
|
* ```
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* ```js
|
|
150
|
+
* // Pattern 2: Separate configs
|
|
151
|
+
* module.exports = withManimScroll(nextConfig, {
|
|
152
|
+
* pythonPath: "python3",
|
|
153
|
+
* quality: "h",
|
|
154
|
+
* });
|
|
155
|
+
* ```
|
|
127
156
|
*/
|
|
128
|
-
function withManimScroll(nextConfig = {}) {
|
|
157
|
+
function withManimScroll(nextConfig = {}, manimScrollConfig) {
|
|
129
158
|
var _a;
|
|
130
|
-
|
|
159
|
+
// Support both calling patterns:
|
|
160
|
+
// 1. withManimScroll({ manimScroll: {...} })
|
|
161
|
+
// 2. withManimScroll(nextConfig, { pythonPath: ... })
|
|
162
|
+
const manimConfig = (_a = manimScrollConfig !== null && manimScrollConfig !== void 0 ? manimScrollConfig : nextConfig.manimScroll) !== null && _a !== void 0 ? _a : {};
|
|
131
163
|
// Remove manimScroll from the config passed to Next.js
|
|
132
164
|
const { manimScroll: _, ...restConfig } = nextConfig;
|
|
133
165
|
return {
|
|
@@ -153,7 +185,13 @@ function withManimScroll(nextConfig = {}) {
|
|
|
153
185
|
}
|
|
154
186
|
// Run in dev mode on first build
|
|
155
187
|
if (context.dev && context.isServer) {
|
|
156
|
-
|
|
188
|
+
const projectDir = context.dir;
|
|
189
|
+
const publicDir = path.join(projectDir, "public");
|
|
190
|
+
// Ensure a cache manifest exists SYNCHRONOUSLY before the page loads.
|
|
191
|
+
// This prevents 404 errors while async processing is running.
|
|
192
|
+
// The manifest will be updated with actual animations once extraction completes.
|
|
193
|
+
ensureCacheManifestExists(publicDir);
|
|
194
|
+
processManimScroll(projectDir, manimConfig).catch((error) => {
|
|
157
195
|
console.error("[manim-scroll] Error during dev processing:", error);
|
|
158
196
|
});
|
|
159
197
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mihirsarya/manim-scroll-next",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Next.js plugin for build-time Manim scroll animation rendering.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -8,11 +8,6 @@
|
|
|
8
8
|
"dist",
|
|
9
9
|
"render"
|
|
10
10
|
],
|
|
11
|
-
"scripts": {
|
|
12
|
-
"prebuild": "rm -rf render && cp -r ../render .",
|
|
13
|
-
"build": "tsc -p tsconfig.json",
|
|
14
|
-
"prepublishOnly": "rm -rf render && cp -r ../render . && npm run build"
|
|
15
|
-
},
|
|
16
11
|
"peerDependencies": {
|
|
17
12
|
"next": ">=13.0.0"
|
|
18
13
|
},
|
|
@@ -26,5 +21,9 @@
|
|
|
26
21
|
"@types/babel__traverse": "^7.20.5",
|
|
27
22
|
"@types/node": "^20.11.0",
|
|
28
23
|
"typescript": "^5.4.5"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"prebuild": "rm -rf render && cp -r ../render .",
|
|
27
|
+
"build": "tsc -p tsconfig.json"
|
|
29
28
|
}
|
|
30
|
-
}
|
|
29
|
+
}
|
package/render/cli.py
CHANGED
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import subprocess
|
|
8
8
|
import sys
|
|
9
|
-
from dataclasses import dataclass, asdict
|
|
9
|
+
from dataclasses import dataclass, asdict, field
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import List, Optional
|
|
12
12
|
|
|
@@ -19,6 +19,9 @@ class RenderManifest:
|
|
|
19
19
|
height: int
|
|
20
20
|
frames: List[str]
|
|
21
21
|
video: Optional[str]
|
|
22
|
+
transparent: bool = False
|
|
23
|
+
inline: bool = False
|
|
24
|
+
aspectRatio: Optional[float] = None
|
|
22
25
|
|
|
23
26
|
|
|
24
27
|
def _parse_resolution(value: str) -> tuple[int, int]:
|
|
@@ -51,6 +54,16 @@ def _run_manim(cmd: List[str], env: dict[str, str]) -> None:
|
|
|
51
54
|
raise RuntimeError("Manim render failed. Check the command and logs.")
|
|
52
55
|
|
|
53
56
|
|
|
57
|
+
def _load_props_file(props_path: Optional[str]) -> dict:
|
|
58
|
+
"""Load props from a JSON file."""
|
|
59
|
+
if not props_path:
|
|
60
|
+
return {}
|
|
61
|
+
path = Path(props_path)
|
|
62
|
+
if not path.exists():
|
|
63
|
+
return {}
|
|
64
|
+
return json.loads(path.read_text())
|
|
65
|
+
|
|
66
|
+
|
|
54
67
|
def main() -> int:
|
|
55
68
|
parser = argparse.ArgumentParser(description="Render Manim scenes for scroll playback.")
|
|
56
69
|
parser.add_argument("--scene-file", required=True, help="Path to Manim scene file.")
|
|
@@ -65,11 +78,20 @@ def main() -> int:
|
|
|
65
78
|
"--props",
|
|
66
79
|
help="Path to JSON props for the scene (exposed as MANIM_SCROLL_PROPS).",
|
|
67
80
|
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--transparent",
|
|
83
|
+
action="store_true",
|
|
84
|
+
help="Render with transparent background (for inline mode).",
|
|
85
|
+
)
|
|
68
86
|
|
|
69
87
|
args = parser.parse_args()
|
|
70
88
|
output_dir = Path(args.output_dir).resolve()
|
|
71
89
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
72
90
|
|
|
91
|
+
# Load props to check for inline mode
|
|
92
|
+
props = _load_props_file(args.props)
|
|
93
|
+
inline = props.get("inline", False) or args.transparent
|
|
94
|
+
|
|
73
95
|
width, height = args.resolution
|
|
74
96
|
base_cmd = [
|
|
75
97
|
"manim",
|
|
@@ -84,18 +106,38 @@ def main() -> int:
|
|
|
84
106
|
args.scene_name,
|
|
85
107
|
]
|
|
86
108
|
|
|
109
|
+
# Add transparent flag for inline mode
|
|
110
|
+
if inline or args.transparent:
|
|
111
|
+
base_cmd.insert(1, "--transparent")
|
|
112
|
+
|
|
87
113
|
env = os.environ.copy()
|
|
88
114
|
if args.props:
|
|
89
115
|
props_path = Path(args.props).resolve()
|
|
90
116
|
env["MANIM_SCROLL_PROPS"] = str(props_path)
|
|
91
117
|
|
|
118
|
+
# Set up bounds output file for inline mode
|
|
119
|
+
bounds_path = output_dir / "bounds.json"
|
|
120
|
+
env["MANIM_SCROLL_BOUNDS_OUT"] = str(bounds_path)
|
|
121
|
+
|
|
92
122
|
if args.format in ("frames", "both"):
|
|
93
123
|
_run_manim(base_cmd + ["--write_all", "--format", "png"], env)
|
|
94
124
|
|
|
95
125
|
if args.format in ("video", "both"):
|
|
96
|
-
|
|
126
|
+
# For transparent video, use webm which supports alpha
|
|
127
|
+
video_format = "webm" if (inline or args.transparent) else args.video_format
|
|
128
|
+
_run_manim(base_cmd + ["--format", video_format], env)
|
|
97
129
|
|
|
98
130
|
frames, video = _collect_assets(output_dir)
|
|
131
|
+
|
|
132
|
+
# For inline mode, try to read bounds info to get aspect ratio
|
|
133
|
+
aspect_ratio = None
|
|
134
|
+
if bounds_path.exists():
|
|
135
|
+
try:
|
|
136
|
+
bounds_data = json.loads(bounds_path.read_text())
|
|
137
|
+
aspect_ratio = bounds_data.get("aspectRatio")
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
99
141
|
manifest = RenderManifest(
|
|
100
142
|
scene=args.scene_name,
|
|
101
143
|
fps=args.fps,
|
|
@@ -103,6 +145,9 @@ def main() -> int:
|
|
|
103
145
|
height=height,
|
|
104
146
|
frames=frames,
|
|
105
147
|
video=video,
|
|
148
|
+
transparent=inline or args.transparent,
|
|
149
|
+
inline=inline,
|
|
150
|
+
aspectRatio=aspect_ratio,
|
|
106
151
|
)
|
|
107
152
|
manifest_path = output_dir / "manifest.json"
|
|
108
153
|
manifest_path.write_text(json.dumps(asdict(manifest), indent=2))
|
|
@@ -4,7 +4,7 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
-
from manim import ORIGIN, Scene, Text, Write
|
|
7
|
+
from manim import ORIGIN, Scene, Text, Write, config
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def _load_props() -> dict:
|
|
@@ -18,6 +18,21 @@ def _load_props() -> dict:
|
|
|
18
18
|
return json.load(handle)
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
def _write_bounds_info(width: float, height: float, aspect_ratio: float, props: dict) -> None:
|
|
22
|
+
"""Write text bounds info to a file for the manifest generator."""
|
|
23
|
+
bounds_path = os.environ.get("MANIM_SCROLL_BOUNDS_OUT")
|
|
24
|
+
if not bounds_path:
|
|
25
|
+
return
|
|
26
|
+
bounds = {
|
|
27
|
+
"textWidth": width,
|
|
28
|
+
"textHeight": height,
|
|
29
|
+
"aspectRatio": aspect_ratio,
|
|
30
|
+
"inline": props.get("inline", False),
|
|
31
|
+
"padding": props.get("padding", 0),
|
|
32
|
+
}
|
|
33
|
+
Path(bounds_path).write_text(json.dumps(bounds))
|
|
34
|
+
|
|
35
|
+
|
|
21
36
|
class TextScene(Scene):
|
|
22
37
|
def construct(self) -> None:
|
|
23
38
|
props = _load_props()
|
|
@@ -25,12 +40,35 @@ class TextScene(Scene):
|
|
|
25
40
|
font_size = props.get("fontSize", props.get("font_size", 64))
|
|
26
41
|
color = props.get("color", "#FFFFFF")
|
|
27
42
|
font = props.get("font")
|
|
43
|
+
inline = props.get("inline", False)
|
|
44
|
+
padding = props.get("padding", 0.1) # Small padding in Manim units
|
|
28
45
|
|
|
29
46
|
if font:
|
|
30
47
|
text_mob = Text(text_value, font_size=font_size, color=color, font=font)
|
|
31
48
|
else:
|
|
32
49
|
text_mob = Text(text_value, font_size=font_size, color=color)
|
|
33
50
|
|
|
51
|
+
if inline:
|
|
52
|
+
# For inline mode, adjust the camera frame to fit the text tightly
|
|
53
|
+
# This makes the text fill the entire frame
|
|
54
|
+
text_width = text_mob.width
|
|
55
|
+
text_height = text_mob.height
|
|
56
|
+
|
|
57
|
+
# Add minimal padding
|
|
58
|
+
padded_width = text_width + padding * 2
|
|
59
|
+
padded_height = text_height + padding * 2
|
|
60
|
+
|
|
61
|
+
# Calculate aspect ratio
|
|
62
|
+
aspect_ratio = padded_width / padded_height if padded_height > 0 else 1
|
|
63
|
+
|
|
64
|
+
# Adjust the Manim frame to match text bounds
|
|
65
|
+
# This sets the "virtual" frame size in Manim units
|
|
66
|
+
config.frame_width = padded_width
|
|
67
|
+
config.frame_height = padded_height
|
|
68
|
+
|
|
69
|
+
# Write bounds info for the manifest
|
|
70
|
+
_write_bounds_info(text_width, text_height, aspect_ratio, props)
|
|
71
|
+
|
|
34
72
|
text_mob.move_to(ORIGIN)
|
|
35
73
|
self.play(Write(text_mob))
|
|
36
74
|
self.wait(0.5)
|