mulmocast 2.4.7 → 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/actions/captions.d.ts +2 -0
- package/lib/actions/captions.js +12 -4
- 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.d.ts +10 -0
- package/lib/utils/image_plugins/html_tailwind.js +38 -17
- package/package.json +8 -8
- package/scripts/test/test_image_ref_scheme.json +84 -0
|
@@ -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,4 +1,6 @@
|
|
|
1
1
|
import { MulmoStudioContext, PublicAPIArgs } from "../types/index.js";
|
|
2
2
|
import type { GraphData } from "graphai";
|
|
3
|
+
export declare const stripHtmlTags: (text: string) => string;
|
|
4
|
+
export declare const calculateTimingRatios: (splitTexts: string[]) => number[];
|
|
3
5
|
export declare const caption_graph_data: GraphData;
|
|
4
6
|
export declare const captions: (context: MulmoStudioContext, args?: PublicAPIArgs) => Promise<MulmoStudioContext>;
|
package/lib/actions/captions.js
CHANGED
|
@@ -44,13 +44,21 @@ const getSplitTexts = (text, texts, textSplit) => {
|
|
|
44
44
|
}
|
|
45
45
|
return [text];
|
|
46
46
|
};
|
|
47
|
-
//
|
|
48
|
-
const
|
|
49
|
-
|
|
47
|
+
// HTML tags commonly used in caption texts
|
|
48
|
+
const CAPTION_HTML_TAGS = "span|br|b|i|em|strong|u|s|div|p|a|sub|sup|mark";
|
|
49
|
+
const captionTagRegex = new RegExp(`</?(?:${CAPTION_HTML_TAGS})(?:\\s[^>]*)?\\/?>`, "gi");
|
|
50
|
+
// Strip known HTML tags to get plain text length (for timing calculation, not sanitization)
|
|
51
|
+
export const stripHtmlTags = (text) => {
|
|
52
|
+
return text.replace(captionTagRegex, "");
|
|
53
|
+
};
|
|
54
|
+
// Calculate timing ratios based on text length (HTML tags excluded)
|
|
55
|
+
export const calculateTimingRatios = (splitTexts) => {
|
|
56
|
+
const plainTexts = splitTexts.map(stripHtmlTags);
|
|
57
|
+
const totalLength = plainTexts.reduce((sum, t) => sum + t.length, 0);
|
|
50
58
|
if (totalLength === 0) {
|
|
51
59
|
return splitTexts.map(() => 1 / splitTexts.length);
|
|
52
60
|
}
|
|
53
|
-
return
|
|
61
|
+
return plainTexts.map((t) => t.length / totalLength);
|
|
54
62
|
};
|
|
55
63
|
// Convert ratios to cumulative ratios: [0.3, 0.5, 0.2] -> [0, 0.3, 0.8, 1.0]
|
|
56
64
|
const calculateCumulativeRatios = (ratios) => {
|
|
@@ -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);
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { ImageProcessorParams } from "../../types/index.js";
|
|
2
2
|
export declare const imageType = "html_tailwind";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve image:name references to file:// absolute paths using imageRefs.
|
|
5
|
+
* e.g., src="image:bg_office" → src="file:///abs/path/to/bg_office.png"
|
|
6
|
+
*/
|
|
7
|
+
export declare const resolveImageRefs: (html: string, imageRefs: Record<string, string>) => string;
|
|
8
|
+
/**
|
|
9
|
+
* Resolve relative paths in src attributes to file:// absolute paths.
|
|
10
|
+
* Paths starting with http://, https://, file://, data:, image:, or / are left unchanged.
|
|
11
|
+
*/
|
|
12
|
+
export declare const resolveRelativeImagePaths: (html: string, baseDirPath: string) => string;
|
|
3
13
|
export declare const process: (params: ImageProcessorParams) => Promise<string | undefined>;
|
|
4
14
|
export declare const path: (params: ImageProcessorParams) => string;
|
|
5
15
|
export declare const html: (params: ImageProcessorParams) => Promise<string | undefined>;
|
|
@@ -2,16 +2,29 @@ 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";
|
|
9
|
+
/**
|
|
10
|
+
* Resolve image:name references to file:// absolute paths using imageRefs.
|
|
11
|
+
* e.g., src="image:bg_office" → src="file:///abs/path/to/bg_office.png"
|
|
12
|
+
*/
|
|
13
|
+
export const resolveImageRefs = (html, imageRefs) => {
|
|
14
|
+
return html.replace(/(\bsrc\s*=\s*)(["'])image:([^"']+)\2/gi, (match, prefix, quote, name) => {
|
|
15
|
+
const resolvedPath = imageRefs[name];
|
|
16
|
+
if (!resolvedPath) {
|
|
17
|
+
return match;
|
|
18
|
+
}
|
|
19
|
+
return `${prefix}${quote}file://${resolvedPath}${quote}`;
|
|
20
|
+
});
|
|
21
|
+
};
|
|
9
22
|
/**
|
|
10
23
|
* Resolve relative paths in src attributes to file:// absolute paths.
|
|
11
|
-
* Paths starting with http://, https://, file://, data:, or / are left unchanged.
|
|
24
|
+
* Paths starting with http://, https://, file://, data:, image:, or / are left unchanged.
|
|
12
25
|
*/
|
|
13
|
-
const resolveRelativeImagePaths = (html, baseDirPath) => {
|
|
14
|
-
return html.replace(/(\bsrc\s*=\s*)(["'])((?!https?:\/\/|file:\/\/|data:|\/)[^"']+)\2/gi, (_, prefix, quote, relativePath) => {
|
|
26
|
+
export const resolveRelativeImagePaths = (html, baseDirPath) => {
|
|
27
|
+
return html.replace(/(\bsrc\s*=\s*)(["'])((?!https?:\/\/|file:\/\/|data:|image:|\/)[^"']+)\2/gi, (_, prefix, quote, relativePath) => {
|
|
15
28
|
const absolutePath = nodePath.resolve(baseDirPath, relativePath);
|
|
16
29
|
return `${prefix}${quote}file://${absolutePath}${quote}`;
|
|
17
30
|
});
|
|
@@ -34,9 +47,9 @@ const getAnimationConfig = (params) => {
|
|
|
34
47
|
const animation = beat.image.animation;
|
|
35
48
|
if (!MulmoBeatMethods.isAnimationEnabled(animation))
|
|
36
49
|
return null;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
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 };
|
|
40
53
|
};
|
|
41
54
|
const processHtmlTailwindAnimated = async (params) => {
|
|
42
55
|
const { beat, imagePath, canvasSize, context } = params;
|
|
@@ -64,18 +77,25 @@ const processHtmlTailwindAnimated = async (params) => {
|
|
|
64
77
|
fps: String(fps),
|
|
65
78
|
custom_style: "",
|
|
66
79
|
});
|
|
67
|
-
const
|
|
80
|
+
const resolvedRefs = resolveImageRefs(rawHtmlData, params.imageRefs ?? {});
|
|
81
|
+
const htmlData = resolveRelativeImagePaths(resolvedRefs, context.fileDirs.mulmoFileDirPath);
|
|
68
82
|
// imagePath is set to the .mp4 path by imagePluginAgent for animated beats
|
|
69
83
|
const videoPath = imagePath;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
await renderHTMLToFrames(htmlData, framesDir, canvasSize.width, canvasSize.height, totalFrames, fps);
|
|
75
|
-
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);
|
|
76
87
|
}
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
}
|
|
79
99
|
}
|
|
80
100
|
return videoPath;
|
|
81
101
|
};
|
|
@@ -90,7 +110,8 @@ const processHtmlTailwindStatic = async (params) => {
|
|
|
90
110
|
html_body: html,
|
|
91
111
|
user_script: buildUserScript(script),
|
|
92
112
|
});
|
|
93
|
-
const
|
|
113
|
+
const resolvedRefs = resolveImageRefs(rawHtmlData, params.imageRefs ?? {});
|
|
114
|
+
const htmlData = resolveRelativeImagePaths(resolvedRefs, context.fileDirs.mulmoFileDirPath);
|
|
94
115
|
await renderHTMLToImage(htmlData, imagePath, canvasSize.width, canvasSize.height);
|
|
95
116
|
return imagePath;
|
|
96
117
|
};
|
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",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"homepage": "https://github.com/receptron/mulmocast-cli#readme",
|
|
88
88
|
"dependencies": {
|
|
89
89
|
"@google-cloud/text-to-speech": "^6.4.0",
|
|
90
|
-
"@google/genai": "^1.
|
|
90
|
+
"@google/genai": "^1.44.0",
|
|
91
91
|
"@graphai/anthropic_agent": "^2.0.12",
|
|
92
92
|
"@graphai/browserless_agent": "^2.0.2",
|
|
93
93
|
"@graphai/gemini_agent": "^2.0.5",
|
|
@@ -108,10 +108,10 @@
|
|
|
108
108
|
"fluent-ffmpeg": "^2.1.3",
|
|
109
109
|
"graphai": "^2.0.16",
|
|
110
110
|
"jsdom": "^28.1.0",
|
|
111
|
-
"marked": "^17.0.
|
|
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.
|
|
132
|
-
"globals": "^17.
|
|
131
|
+
"eslint-plugin-sonarjs": "^4.0.2",
|
|
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"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$mulmocast": { "version": "1.1" },
|
|
3
|
+
"lang": "ja",
|
|
4
|
+
"canvasSize": { "width": 1080, "height": 1920 },
|
|
5
|
+
"title": "image: スキームテスト",
|
|
6
|
+
"speechParams": {
|
|
7
|
+
"speakers": {
|
|
8
|
+
"Presenter": { "voiceId": "shimmer" }
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"audioParams": {
|
|
12
|
+
"bgm": {
|
|
13
|
+
"kind": "url",
|
|
14
|
+
"url": "https://github.com/receptron/mulmocast-media/raw/refs/heads/main/bgms/theme001.mp3"
|
|
15
|
+
},
|
|
16
|
+
"bgmVolume": 0.12
|
|
17
|
+
},
|
|
18
|
+
"imageParams": {
|
|
19
|
+
"provider": "google",
|
|
20
|
+
"model": "gemini-3.1-flash-image-preview",
|
|
21
|
+
"images": {
|
|
22
|
+
"bg_office": {
|
|
23
|
+
"type": "imagePrompt",
|
|
24
|
+
"prompt": "Empty modern tech office with abandoned desks and computer screens still glowing, dramatic morning sunlight, photorealistic, vertical composition 9:16"
|
|
25
|
+
},
|
|
26
|
+
"bg_city": {
|
|
27
|
+
"type": "imagePrompt",
|
|
28
|
+
"prompt": "Futuristic city skyline at sunset with neon lights reflecting on glass buildings, vibrant colors, photorealistic, vertical composition 9:16"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"beats": [
|
|
33
|
+
{
|
|
34
|
+
"text": "image:スキームのテストです。背景画像がimageParamsで生成した画像に置き換わります。",
|
|
35
|
+
"speaker": "Presenter",
|
|
36
|
+
"image": {
|
|
37
|
+
"type": "html_tailwind",
|
|
38
|
+
"html": [
|
|
39
|
+
"<div class='h-full w-full overflow-hidden relative bg-black'>",
|
|
40
|
+
" <div id='wrap' style='position:absolute;inset:0;overflow:hidden'>",
|
|
41
|
+
" <img src='image:bg_office' style='width:100%;height:100%;object-fit:cover;filter:brightness(0.7)' />",
|
|
42
|
+
" </div>",
|
|
43
|
+
" <div style='position:absolute;top:50%;left:40px;right:40px;transform:translateY(-50%);text-align:center'>",
|
|
44
|
+
" <div style='display:inline-block;background:rgba(239,68,68,0.85);padding:12px 32px;border-radius:12px'>",
|
|
45
|
+
" <span style='color:white;font-size:80px;font-weight:900'>image: test</span>",
|
|
46
|
+
" </div>",
|
|
47
|
+
" <div style='color:white;font-size:48px;font-weight:900;margin-top:20px;text-shadow:0 4px 16px rgba(0,0,0,0.9)'>bg_office を参照</div>",
|
|
48
|
+
" </div>",
|
|
49
|
+
"</div>"
|
|
50
|
+
],
|
|
51
|
+
"script": [
|
|
52
|
+
"const animation = new MulmoAnimation();",
|
|
53
|
+
"animation.animate('#wrap', { scale: [1.0, 1.15] }, { start: 0, end: 'auto', easing: 'linear' });"
|
|
54
|
+
],
|
|
55
|
+
"animation": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"text": "二枚目の背景です。別のimageRefsキーを参照しています。",
|
|
60
|
+
"speaker": "Presenter",
|
|
61
|
+
"image": {
|
|
62
|
+
"type": "html_tailwind",
|
|
63
|
+
"html": [
|
|
64
|
+
"<div class='h-full w-full overflow-hidden relative bg-black'>",
|
|
65
|
+
" <div id='wrap' style='position:absolute;inset:0;overflow:hidden'>",
|
|
66
|
+
" <img src='image:bg_city' style='width:100%;height:100%;object-fit:cover;filter:brightness(0.6)' />",
|
|
67
|
+
" </div>",
|
|
68
|
+
" <div style='position:absolute;top:50%;left:40px;right:40px;transform:translateY(-50%);text-align:center'>",
|
|
69
|
+
" <div style='display:inline-block;background:rgba(59,130,246,0.85);padding:12px 32px;border-radius:12px'>",
|
|
70
|
+
" <span style='color:white;font-size:80px;font-weight:900'>image: test</span>",
|
|
71
|
+
" </div>",
|
|
72
|
+
" <div style='color:white;font-size:48px;font-weight:900;margin-top:20px;text-shadow:0 4px 16px rgba(0,0,0,0.9)'>bg_city を参照</div>",
|
|
73
|
+
" </div>",
|
|
74
|
+
"</div>"
|
|
75
|
+
],
|
|
76
|
+
"script": [
|
|
77
|
+
"const animation = new MulmoAnimation();",
|
|
78
|
+
"animation.animate('#wrap', { translateX: [0, -30] }, { start: 0, end: 'auto', easing: 'linear' });"
|
|
79
|
+
],
|
|
80
|
+
"animation": true
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
}
|