mulmocast 2.4.8 → 2.4.9
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/assets/html/tailwind_animated.html +34 -0
- package/lib/methods/mulmo_beat.d.ts +2 -0
- package/lib/methods/mulmo_beat.js +5 -0
- package/lib/types/schema.d.ts +6 -0
- package/lib/types/schema.js +1 -0
- package/lib/utils/browser_pool.d.ts +5 -0
- package/lib/utils/browser_pool.js +39 -0
- package/lib/utils/context.d.ts +2 -0
- package/lib/utils/html_render.d.ts +6 -0
- package/lib/utils/html_render.js +33 -0
- package/lib/utils/image_plugins/html_tailwind.js +18 -12
- package/package.json +5 -5
|
@@ -254,6 +254,40 @@
|
|
|
254
254
|
result.catch(console.error);
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Play animation in real-time using requestAnimationFrame.
|
|
260
|
+
* Returns a Promise that resolves when all frames have been rendered.
|
|
261
|
+
* Called by Puppeteer's page.evaluate() during screencast recording.
|
|
262
|
+
*/
|
|
263
|
+
window.playAnimation = function() {
|
|
264
|
+
return new Promise(function(resolve) {
|
|
265
|
+
var mulmo = window.__MULMO;
|
|
266
|
+
var fps = mulmo.fps;
|
|
267
|
+
var totalFrames = mulmo.totalFrames;
|
|
268
|
+
var frameDuration = 1000 / fps;
|
|
269
|
+
var startTime = null;
|
|
270
|
+
|
|
271
|
+
function tick(timestamp) {
|
|
272
|
+
if (startTime === null) startTime = timestamp;
|
|
273
|
+
var elapsed = timestamp - startTime;
|
|
274
|
+
var frame = Math.min(Math.floor(elapsed / frameDuration), totalFrames - 1);
|
|
275
|
+
|
|
276
|
+
mulmo.frame = frame;
|
|
277
|
+
if (typeof render === 'function') {
|
|
278
|
+
render(frame, totalFrames, fps);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (frame < totalFrames - 1) {
|
|
282
|
+
requestAnimationFrame(tick);
|
|
283
|
+
} else {
|
|
284
|
+
resolve();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
requestAnimationFrame(tick);
|
|
289
|
+
});
|
|
290
|
+
};
|
|
257
291
|
</script>
|
|
258
292
|
</body>
|
|
259
293
|
</html>
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { MulmoBeat } from "../types/index.js";
|
|
2
2
|
type AnimationConfig = {
|
|
3
3
|
fps?: number;
|
|
4
|
+
movie?: boolean;
|
|
4
5
|
};
|
|
5
6
|
export declare const MulmoBeatMethods: {
|
|
6
7
|
isAnimationEnabled: (animation: unknown) => animation is true | AnimationConfig;
|
|
7
8
|
isAnimationObject: (animation: unknown) => animation is AnimationConfig;
|
|
8
9
|
isAnimatedHtmlTailwind: (beat: MulmoBeat) => boolean;
|
|
10
|
+
isMovieMode: (animation: unknown) => boolean;
|
|
9
11
|
getHtmlPrompt(beat: MulmoBeat): string | undefined;
|
|
10
12
|
getPlugin(beat: MulmoBeat): {
|
|
11
13
|
imageType: string;
|
|
@@ -7,6 +7,10 @@ const isAnimationObject = (animation) => {
|
|
|
7
7
|
const isAnimationEnabled = (animation) => {
|
|
8
8
|
return animation === true || isAnimationObject(animation);
|
|
9
9
|
};
|
|
10
|
+
/** Check if movie mode (CDP screencast) is enabled */
|
|
11
|
+
const isMovieMode = (animation) => {
|
|
12
|
+
return isAnimationObject(animation) && animation.movie === true;
|
|
13
|
+
};
|
|
10
14
|
/** Check if a beat has html_tailwind animation enabled */
|
|
11
15
|
const isAnimatedHtmlTailwind = (beat) => {
|
|
12
16
|
if (!beat.image || beat.image.type !== "html_tailwind")
|
|
@@ -18,6 +22,7 @@ export const MulmoBeatMethods = {
|
|
|
18
22
|
isAnimationEnabled,
|
|
19
23
|
isAnimationObject,
|
|
20
24
|
isAnimatedHtmlTailwind,
|
|
25
|
+
isMovieMode,
|
|
21
26
|
getHtmlPrompt(beat) {
|
|
22
27
|
if (beat?.htmlPrompt?.data) {
|
|
23
28
|
return beat.htmlPrompt.prompt + "\n\n[data]\n" + JSON.stringify(beat.htmlPrompt.data, null, 2);
|
package/lib/types/schema.d.ts
CHANGED
|
@@ -374,6 +374,7 @@ export declare const mulmoMermaidMediaSchema: z.ZodObject<{
|
|
|
374
374
|
}, z.core.$strict>;
|
|
375
375
|
export declare const htmlTailwindAnimationSchema: z.ZodUnion<readonly [z.ZodLiteral<true>, z.ZodObject<{
|
|
376
376
|
fps: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
377
|
+
movie: z.ZodOptional<z.ZodBoolean>;
|
|
377
378
|
}, z.core.$strip>]>;
|
|
378
379
|
export declare const mulmoHtmlTailwindMediaSchema: z.ZodObject<{
|
|
379
380
|
type: z.ZodLiteral<"html_tailwind">;
|
|
@@ -381,6 +382,7 @@ export declare const mulmoHtmlTailwindMediaSchema: z.ZodObject<{
|
|
|
381
382
|
script: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
382
383
|
animation: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<true>, z.ZodObject<{
|
|
383
384
|
fps: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
385
|
+
movie: z.ZodOptional<z.ZodBoolean>;
|
|
384
386
|
}, z.core.$strip>]>>;
|
|
385
387
|
}, z.core.$strict>;
|
|
386
388
|
export declare const mulmoBeatReferenceMediaSchema: z.ZodObject<{
|
|
@@ -553,6 +555,7 @@ export declare const mulmoImageAssetSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
|
553
555
|
script: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
554
556
|
animation: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<true>, z.ZodObject<{
|
|
555
557
|
fps: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
558
|
+
movie: z.ZodOptional<z.ZodBoolean>;
|
|
556
559
|
}, z.core.$strip>]>>;
|
|
557
560
|
}, z.core.$strict>, z.ZodObject<{
|
|
558
561
|
type: z.ZodLiteral<"beat">;
|
|
@@ -3606,6 +3609,7 @@ export declare const mulmoBeatSchema: z.ZodObject<{
|
|
|
3606
3609
|
script: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
3607
3610
|
animation: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<true>, z.ZodObject<{
|
|
3608
3611
|
fps: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
3612
|
+
movie: z.ZodOptional<z.ZodBoolean>;
|
|
3609
3613
|
}, z.core.$strip>]>>;
|
|
3610
3614
|
}, z.core.$strict>, z.ZodObject<{
|
|
3611
3615
|
type: z.ZodLiteral<"beat">;
|
|
@@ -7404,6 +7408,7 @@ export declare const mulmoScriptSchema: z.ZodObject<{
|
|
|
7404
7408
|
script: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
7405
7409
|
animation: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<true>, z.ZodObject<{
|
|
7406
7410
|
fps: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
7411
|
+
movie: z.ZodOptional<z.ZodBoolean>;
|
|
7407
7412
|
}, z.core.$strip>]>>;
|
|
7408
7413
|
}, z.core.$strict>, z.ZodObject<{
|
|
7409
7414
|
type: z.ZodLiteral<"beat">;
|
|
@@ -10821,6 +10826,7 @@ export declare const mulmoStudioSchema: z.ZodObject<{
|
|
|
10821
10826
|
script: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
10822
10827
|
animation: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<true>, z.ZodObject<{
|
|
10823
10828
|
fps: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
10829
|
+
movie: z.ZodOptional<z.ZodBoolean>;
|
|
10824
10830
|
}, z.core.$strip>]>>;
|
|
10825
10831
|
}, z.core.$strict>, z.ZodObject<{
|
|
10826
10832
|
type: z.ZodLiteral<"beat">;
|
package/lib/types/schema.js
CHANGED
|
@@ -198,6 +198,7 @@ export const htmlTailwindAnimationSchema = z.union([
|
|
|
198
198
|
z.literal(true),
|
|
199
199
|
z.object({
|
|
200
200
|
fps: z.number().min(1).max(60).optional().default(30),
|
|
201
|
+
movie: z.boolean().optional().describe("Use CDP screencast for real-time recording (experimental, faster). Default: false (frame-by-frame screenshot)."),
|
|
201
202
|
}),
|
|
202
203
|
]);
|
|
203
204
|
export const mulmoHtmlTailwindMediaSchema = z
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import puppeteer from "puppeteer";
|
|
2
|
+
/** Get a shared browser instance. Launches one if none exists. */
|
|
3
|
+
export declare const getBrowser: () => Promise<puppeteer.Browser>;
|
|
4
|
+
/** Close the shared browser instance. Call at the end of processing. */
|
|
5
|
+
export declare const closeBrowser: () => Promise<void>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import puppeteer from "puppeteer";
|
|
2
|
+
const isCI = process.env.CI === "true";
|
|
3
|
+
const launchArgs = isCI ? ["--no-sandbox", "--allow-file-access-from-files"] : ["--allow-file-access-from-files"];
|
|
4
|
+
let browserInstance = null;
|
|
5
|
+
let launchPromise = null;
|
|
6
|
+
const launchBrowser = async () => {
|
|
7
|
+
const browser = await puppeteer.launch({ args: launchArgs });
|
|
8
|
+
browser.on("disconnected", () => {
|
|
9
|
+
browserInstance = null;
|
|
10
|
+
launchPromise = null;
|
|
11
|
+
});
|
|
12
|
+
return browser;
|
|
13
|
+
};
|
|
14
|
+
/** Get a shared browser instance. Launches one if none exists. */
|
|
15
|
+
export const getBrowser = async () => {
|
|
16
|
+
if (browserInstance?.connected) {
|
|
17
|
+
return browserInstance;
|
|
18
|
+
}
|
|
19
|
+
// Prevent multiple concurrent launches
|
|
20
|
+
if (!launchPromise) {
|
|
21
|
+
launchPromise = launchBrowser().then((browser) => {
|
|
22
|
+
browserInstance = browser;
|
|
23
|
+
launchPromise = null;
|
|
24
|
+
return browser;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return launchPromise;
|
|
28
|
+
};
|
|
29
|
+
/** Close the shared browser instance. Call at the end of processing. */
|
|
30
|
+
export const closeBrowser = async () => {
|
|
31
|
+
if (launchPromise) {
|
|
32
|
+
await launchPromise;
|
|
33
|
+
}
|
|
34
|
+
if (browserInstance?.connected) {
|
|
35
|
+
await browserInstance.close();
|
|
36
|
+
}
|
|
37
|
+
browserInstance = null;
|
|
38
|
+
launchPromise = null;
|
|
39
|
+
};
|
package/lib/utils/context.d.ts
CHANGED
|
@@ -1725,6 +1725,7 @@ export declare const createStudioData: (_mulmoScript: MulmoScript, fileName: str
|
|
|
1725
1725
|
script?: string | string[] | undefined;
|
|
1726
1726
|
animation?: true | {
|
|
1727
1727
|
fps: number;
|
|
1728
|
+
movie?: boolean | undefined;
|
|
1728
1729
|
} | undefined;
|
|
1729
1730
|
} | {
|
|
1730
1731
|
type: "beat";
|
|
@@ -3819,6 +3820,7 @@ export declare const initializeContextFromFiles: (files: FileObject, raiseError:
|
|
|
3819
3820
|
script?: string | string[] | undefined;
|
|
3820
3821
|
animation?: true | {
|
|
3821
3822
|
fps: number;
|
|
3823
|
+
movie?: boolean | undefined;
|
|
3822
3824
|
} | undefined;
|
|
3823
3825
|
} | {
|
|
3824
3826
|
type: "beat";
|
|
@@ -10,5 +10,11 @@ export declare const renderHTMLToImage: (html: string, outputPath: string, width
|
|
|
10
10
|
* The user-defined render() function may be sync or async.
|
|
11
11
|
*/
|
|
12
12
|
export declare const renderHTMLToFrames: (html: string, outputDir: string, width: number, height: number, totalFrames: number, fps: number) => Promise<string[]>;
|
|
13
|
+
/**
|
|
14
|
+
* Record an animated HTML page as video using Puppeteer's screencast API.
|
|
15
|
+
* The animation plays in real-time via requestAnimationFrame, and
|
|
16
|
+
* page.screencast() captures frames directly to an mp4 file.
|
|
17
|
+
*/
|
|
18
|
+
export declare const renderHTMLToVideo: (html: string, videoPath: string, width: number, height: number, totalFrames: number, fps: number) => Promise<void>;
|
|
13
19
|
export declare const renderMarkdownToImage: (markdown: string, style: string, outputPath: string, width: number, height: number) => Promise<void>;
|
|
14
20
|
export declare const interpolate: (template: string, data: Record<string, string>) => string;
|
package/lib/utils/html_render.js
CHANGED
|
@@ -135,6 +135,39 @@ export const renderHTMLToFrames = async (html, outputDir, width, height, totalFr
|
|
|
135
135
|
await browser.close();
|
|
136
136
|
}
|
|
137
137
|
};
|
|
138
|
+
/**
|
|
139
|
+
* Record an animated HTML page as video using Puppeteer's screencast API.
|
|
140
|
+
* The animation plays in real-time via requestAnimationFrame, and
|
|
141
|
+
* page.screencast() captures frames directly to an mp4 file.
|
|
142
|
+
*/
|
|
143
|
+
export const renderHTMLToVideo = async (html, videoPath, width, height, totalFrames, fps) => {
|
|
144
|
+
const duration_ms = (totalFrames / fps) * 1000;
|
|
145
|
+
const browser = await puppeteer.launch({
|
|
146
|
+
args: isCI ? ["--no-sandbox", "--allow-file-access-from-files"] : ["--allow-file-access-from-files"],
|
|
147
|
+
});
|
|
148
|
+
try {
|
|
149
|
+
const page = await browser.newPage();
|
|
150
|
+
await loadHtmlIntoPage(page, html, 30000);
|
|
151
|
+
await page.setViewport({ width, height });
|
|
152
|
+
await page.addStyleTag({ content: "html{height:100%;margin:0;padding:0;overflow:hidden}" });
|
|
153
|
+
await scaleContentToFit(page, width, height);
|
|
154
|
+
const recorder = await page.screencast({
|
|
155
|
+
path: videoPath,
|
|
156
|
+
format: "mp4",
|
|
157
|
+
fps,
|
|
158
|
+
});
|
|
159
|
+
// Play animation in real-time and wait for completion
|
|
160
|
+
await page.evaluate(() => {
|
|
161
|
+
return window.playAnimation();
|
|
162
|
+
});
|
|
163
|
+
// Small buffer to ensure the last frame is captured
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(duration_ms * 0.1, 500)));
|
|
165
|
+
await recorder.stop();
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
await browser.close();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
138
171
|
export const renderMarkdownToImage = async (markdown, style, outputPath, width, height) => {
|
|
139
172
|
const header = `<head><style>${style}</style></head>`;
|
|
140
173
|
const body = await marked(markdown);
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import nodePath from "node:path";
|
|
3
3
|
import { MulmoBeatMethods } from "../../methods/mulmo_beat.js";
|
|
4
4
|
import { getHTMLFile } from "../file.js";
|
|
5
|
-
import { renderHTMLToImage, interpolate, renderHTMLToFrames } from "../html_render.js";
|
|
5
|
+
import { renderHTMLToImage, interpolate, renderHTMLToFrames, renderHTMLToVideo } from "../html_render.js";
|
|
6
6
|
import { framesToVideo } from "../ffmpeg_utils.js";
|
|
7
7
|
import { parrotingImagePath } from "./utils.js";
|
|
8
8
|
export const imageType = "html_tailwind";
|
|
@@ -47,9 +47,9 @@ const getAnimationConfig = (params) => {
|
|
|
47
47
|
const animation = beat.image.animation;
|
|
48
48
|
if (!MulmoBeatMethods.isAnimationEnabled(animation))
|
|
49
49
|
return null;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return { fps
|
|
50
|
+
const fps = MulmoBeatMethods.isAnimationObject(animation) ? (animation.fps ?? DEFAULT_ANIMATION_FPS) : DEFAULT_ANIMATION_FPS;
|
|
51
|
+
const movie = MulmoBeatMethods.isMovieMode(animation);
|
|
52
|
+
return { fps, movie };
|
|
53
53
|
};
|
|
54
54
|
const processHtmlTailwindAnimated = async (params) => {
|
|
55
55
|
const { beat, imagePath, canvasSize, context } = params;
|
|
@@ -81,15 +81,21 @@ const processHtmlTailwindAnimated = async (params) => {
|
|
|
81
81
|
const htmlData = resolveRelativeImagePaths(resolvedRefs, context.fileDirs.mulmoFileDirPath);
|
|
82
82
|
// imagePath is set to the .mp4 path by imagePluginAgent for animated beats
|
|
83
83
|
const videoPath = imagePath;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
await renderHTMLToFrames(htmlData, framesDir, canvasSize.width, canvasSize.height, totalFrames, fps);
|
|
89
|
-
await framesToVideo(framesDir, videoPath, fps, canvasSize.width, canvasSize.height);
|
|
84
|
+
if (animConfig.movie) {
|
|
85
|
+
// CDP screencast: real-time recording (experimental, faster)
|
|
86
|
+
await renderHTMLToVideo(htmlData, videoPath, canvasSize.width, canvasSize.height, totalFrames, fps);
|
|
90
87
|
}
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
else {
|
|
89
|
+
// Frame-by-frame screenshot (deterministic, slower)
|
|
90
|
+
const framesDir = videoPath.replace(/\.[^/.]+$/, "_frames");
|
|
91
|
+
fs.mkdirSync(framesDir, { recursive: true });
|
|
92
|
+
try {
|
|
93
|
+
await renderHTMLToFrames(htmlData, framesDir, canvasSize.width, canvasSize.height, totalFrames, fps);
|
|
94
|
+
await framesToVideo(framesDir, videoPath, fps, canvasSize.width, canvasSize.height);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
fs.rmSync(framesDir, { recursive: true, force: true });
|
|
98
|
+
}
|
|
93
99
|
}
|
|
94
100
|
return videoPath;
|
|
95
101
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mulmocast",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.9",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.node.js",
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
"marked": "^17.0.4",
|
|
112
112
|
"mulmocast-vision": "^1.0.8",
|
|
113
113
|
"ora": "^9.3.0",
|
|
114
|
-
"puppeteer": "^24.
|
|
114
|
+
"puppeteer": "^24.39.0",
|
|
115
115
|
"replicate": "^1.4.0",
|
|
116
116
|
"yaml": "^2.8.2",
|
|
117
117
|
"yargs": "^18.0.0",
|
|
@@ -125,15 +125,15 @@
|
|
|
125
125
|
"@types/jsdom": "^28.0.0",
|
|
126
126
|
"@types/yargs": "^17.0.35",
|
|
127
127
|
"cross-env": "^10.1.0",
|
|
128
|
-
"eslint": "^10.0.
|
|
128
|
+
"eslint": "^10.0.3",
|
|
129
129
|
"eslint-config-prettier": "^10.1.8",
|
|
130
130
|
"eslint-plugin-prettier": "^5.5.5",
|
|
131
|
-
"eslint-plugin-sonarjs": "^4.0.
|
|
131
|
+
"eslint-plugin-sonarjs": "^4.0.2",
|
|
132
132
|
"globals": "^17.4.0",
|
|
133
133
|
"prettier": "^3.8.1",
|
|
134
134
|
"tsx": "^4.21.0",
|
|
135
135
|
"typescript": "6.0.0-beta",
|
|
136
|
-
"typescript-eslint": "^8.
|
|
136
|
+
"typescript-eslint": "^8.57.0"
|
|
137
137
|
},
|
|
138
138
|
"engines": {
|
|
139
139
|
"node": ">=22.0.0"
|