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.
@@ -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>;
@@ -44,13 +44,21 @@ const getSplitTexts = (text, texts, textSplit) => {
44
44
  }
45
45
  return [text];
46
46
  };
47
- // Calculate timing ratios based on text length
48
- const calculateTimingRatios = (splitTexts) => {
49
- const totalLength = splitTexts.reduce((sum, t) => sum + t.length, 0);
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 splitTexts.map((t) => t.length / totalLength);
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);
@@ -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);
@@ -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
- if (MulmoBeatMethods.isAnimationObject(animation))
38
- return { fps: animation.fps ?? DEFAULT_ANIMATION_FPS };
39
- 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 };
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 htmlData = resolveRelativeImagePaths(rawHtmlData, context.fileDirs.mulmoFileDirPath);
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
- // Create frames directory next to the video file
71
- const framesDir = videoPath.replace(/\.[^/.]+$/, "_frames");
72
- fs.mkdirSync(framesDir, { recursive: true });
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
- finally {
78
- 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
+ }
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 htmlData = resolveRelativeImagePaths(rawHtmlData, context.fileDirs.mulmoFileDirPath);
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.7",
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.42.0",
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.3",
111
+ "marked": "^17.0.4",
112
112
  "mulmocast-vision": "^1.0.8",
113
113
  "ora": "^9.3.0",
114
- "puppeteer": "^24.37.5",
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.0",
132
- "globals": "^17.3.0",
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.56.1"
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
+ }