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.
@@ -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);
@@ -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">;
@@ -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
+ };
@@ -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;
@@ -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
- if (MulmoBeatMethods.isAnimationObject(animation))
51
- return { fps: animation.fps ?? DEFAULT_ANIMATION_FPS };
52
- return { fps: DEFAULT_ANIMATION_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
- // Create frames directory next to the video file
85
- const framesDir = videoPath.replace(/\.[^/.]+$/, "_frames");
86
- fs.mkdirSync(framesDir, { recursive: true });
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
- finally {
92
- fs.rmSync(framesDir, { recursive: true, force: true });
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.8",
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.38.0",
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.2",
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.1",
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.56.1"
136
+ "typescript-eslint": "^8.57.0"
137
137
  },
138
138
  "engines": {
139
139
  "node": ">=22.0.0"