mulmocast 2.8.0 → 2.8.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.
@@ -54,6 +54,7 @@ type ImagePluginPreprocessAgentResponse = ImagePreprocessAgentResponseBase & {
54
54
  markdown: string;
55
55
  html: string;
56
56
  };
57
+ export declare const conformFrameImageToCanvas: (context: MulmoStudioContext, imageName: string, imagePath: string, fillColor: string) => Promise<string>;
57
58
  type ImagePreprocessAgentResponse = ImagePreprocessAgentResponseBase | ImageHtmlPreprocessAgentResponse | ImagePluginPreprocessAgentResponse | ImageOnlyMoviePreprocessAgentResponse | ImageGenearalPreprocessAgentResponse;
58
59
  export declare const imagePreprocessAgent: (namedInputs: {
59
60
  context: MulmoStudioContext;
@@ -1,6 +1,9 @@
1
+ import fs from "fs";
2
+ import { createHash } from "crypto";
1
3
  import { GraphAILogger } from "graphai";
2
4
  import { MulmoPresentationStyleMethods, MulmoStudioContextMethods, MulmoBeatMethods, MulmoMediaSourceMethods } from "../methods/index.js";
3
- import { getBeatPngImagePath, getBeatMoviePaths, getBeatAnimatedVideoPath, getAudioFilePath, getGroupedAudioFilePath } from "../utils/file.js";
5
+ import { getBeatPngImagePath, getBeatMoviePaths, getBeatAnimatedVideoPath, getAudioFilePath, getGroupedAudioFilePath, getReferenceImagePath, } from "../utils/file.js";
6
+ import { ffmpegGetImageDimensions, padImageToCanvas } from "../utils/ffmpeg_utils.js";
4
7
  import { imagePrompt, htmlImageSystemPrompt } from "../utils/prompt.js";
5
8
  import { renderHTMLToImage } from "../utils/html_render.js";
6
9
  import { beatId } from "../utils/utils.js";
@@ -11,6 +14,63 @@ const htmlStyle = (context, beat) => {
11
14
  textSlideStyle: MulmoPresentationStyleMethods.getTextSlideStyle(context.presentationStyle, beat),
12
15
  };
13
16
  };
17
+ // Image-to-video models expect first/last frame images that match the video canvas.
18
+ // Generated reference images come back at the provider's fixed sizes (e.g. gpt-image emits
19
+ // 1536x1024), so aspect-fit pad them to the canvas before movie generation.
20
+ const conformingInFlight = new Map();
21
+ // Aspect-matching sources never get a destPath, so without this memo every beat
22
+ // (and every rerun) would ffprobe them again. Keyed by path + mtime.
23
+ const imageDimensionsCache = new Map();
24
+ const getImageDimensionsCached = async (imagePath, mtimeMs) => {
25
+ const key = `${imagePath}:${mtimeMs}`;
26
+ const cached = imageDimensionsCache.get(key);
27
+ if (cached) {
28
+ return cached;
29
+ }
30
+ const dimensions = await ffmpegGetImageDimensions(imagePath);
31
+ imageDimensionsCache.set(key, dimensions);
32
+ return dimensions;
33
+ };
34
+ export const conformFrameImageToCanvas = async (context, imageName, imagePath, fillColor) => {
35
+ if (!fs.existsSync(imagePath)) {
36
+ return imagePath; // mock agents / dry runs
37
+ }
38
+ const canvasSize = MulmoPresentationStyleMethods.getCanvasSize(context.presentationStyle);
39
+ // The cache key includes the source path and fill color: the same ref name can resolve
40
+ // to different sources in different beats, and a color change must invalidate the cache.
41
+ const digest = createHash("sha256").update(`${imagePath}|${fillColor}`).digest("hex").slice(0, 8);
42
+ const destPath = getReferenceImagePath(context, `${imageName}_fit_${canvasSize.width}x${canvasSize.height}_${digest}`, "png");
43
+ const inFlight = conformingInFlight.get(destPath);
44
+ if (inFlight) {
45
+ return inFlight;
46
+ }
47
+ const promise = (async () => {
48
+ const sourceMtimeMs = fs.statSync(imagePath).mtimeMs;
49
+ if (fs.existsSync(destPath) && fs.statSync(destPath).mtimeMs >= sourceMtimeMs) {
50
+ return destPath;
51
+ }
52
+ const { width, height } = await getImageDimensionsCached(imagePath, sourceMtimeMs);
53
+ if (Math.abs(width / height - canvasSize.width / canvasSize.height) < 0.01) {
54
+ return imagePath;
55
+ }
56
+ GraphAILogger.info(`conformFrameImageToCanvas: padding ${imageName} (${width}x${height}) to ${canvasSize.width}x${canvasSize.height}`);
57
+ try {
58
+ await padImageToCanvas(imagePath, destPath, canvasSize.width, canvasSize.height, fillColor);
59
+ }
60
+ catch (error) {
61
+ fs.rmSync(destPath, { force: true }); // don't let a partial file poison the mtime cache
62
+ throw error;
63
+ }
64
+ return destPath;
65
+ })();
66
+ conformingInFlight.set(destPath, promise);
67
+ try {
68
+ return await promise;
69
+ }
70
+ finally {
71
+ conformingInFlight.delete(destPath);
72
+ }
73
+ };
14
74
  export const imagePreprocessAgent = async (namedInputs) => {
15
75
  const { context, beat, index, imageRefs } = namedInputs;
16
76
  const studioBeat = context.studio.beats[index];
@@ -66,21 +126,24 @@ export const imagePreprocessAgent = async (namedInputs) => {
66
126
  }
67
127
  }
68
128
  returnValue.movieAgentInfo = MulmoPresentationStyleMethods.getMovieAgentInfo(context.presentationStyle, beat);
69
- // Resolve movie reference images from imageRefs
70
- const movieParams = beat.movieParams ?? context.presentationStyle.movieParams;
71
- if (movieParams?.firstFrameImageName && imageRefs) {
129
+ // Resolve movie reference images from imageRefs.
130
+ // Shallow-merge like getMovieAgentInfo: beat-level fields override style-level fields
131
+ // individually, so a beat setting only lastFrameImageName keeps the global frameFillColor.
132
+ const movieParams = { ...context.presentationStyle.movieParams, ...beat.movieParams };
133
+ const frameFillColor = movieParams.frameFillColor ?? "black";
134
+ if (movieParams.firstFrameImageName && imageRefs) {
72
135
  const firstFramePath = imageRefs[movieParams.firstFrameImageName];
73
136
  if (firstFramePath) {
74
- returnValue.firstFrameImagePath = firstFramePath;
137
+ returnValue.firstFrameImagePath = await conformFrameImageToCanvas(context, movieParams.firstFrameImageName, firstFramePath, frameFillColor);
75
138
  }
76
139
  }
77
- if (movieParams?.lastFrameImageName && imageRefs) {
140
+ if (movieParams.lastFrameImageName && imageRefs) {
78
141
  const lastFramePath = imageRefs[movieParams.lastFrameImageName];
79
142
  if (lastFramePath) {
80
- returnValue.lastFrameImagePath = lastFramePath;
143
+ returnValue.lastFrameImagePath = await conformFrameImageToCanvas(context, movieParams.lastFrameImageName, lastFramePath, frameFillColor);
81
144
  }
82
145
  }
83
- if (movieParams?.referenceImages && imageRefs) {
146
+ if (movieParams.referenceImages && imageRefs) {
84
147
  returnValue.movieReferenceImages = movieParams.referenceImages
85
148
  .map((ref) => {
86
149
  const refPath = imageRefs[ref.imageName];
@@ -194,6 +194,7 @@ export declare const MulmoPresentationStyleMethods: {
194
194
  vertexai_location?: string | undefined;
195
195
  firstFrameImageName?: string | undefined;
196
196
  lastFrameImageName?: string | undefined;
197
+ frameFillColor?: string | undefined;
197
198
  referenceImages?: {
198
199
  imageName: string;
199
200
  referenceType: "ASSET" | "STYLE";
@@ -4757,6 +4757,7 @@ export declare const mulmoMovieParamsSchema: z.ZodObject<{
4757
4757
  vertexai_location: z.ZodOptional<z.ZodString>;
4758
4758
  firstFrameImageName: z.ZodOptional<z.ZodString>;
4759
4759
  lastFrameImageName: z.ZodOptional<z.ZodString>;
4760
+ frameFillColor: z.ZodOptional<z.ZodString>;
4760
4761
  referenceImages: z.ZodOptional<z.ZodArray<z.ZodObject<{
4761
4762
  imageName: z.ZodString;
4762
4763
  referenceType: z.ZodEnum<{
@@ -8719,6 +8720,7 @@ export declare const mulmoBeatSchema: z.ZodObject<{
8719
8720
  vertexai_location: z.ZodOptional<z.ZodString>;
8720
8721
  firstFrameImageName: z.ZodOptional<z.ZodString>;
8721
8722
  lastFrameImageName: z.ZodOptional<z.ZodString>;
8723
+ frameFillColor: z.ZodOptional<z.ZodString>;
8722
8724
  referenceImages: z.ZodOptional<z.ZodArray<z.ZodObject<{
8723
8725
  imageName: z.ZodString;
8724
8726
  referenceType: z.ZodEnum<{
@@ -9209,6 +9211,7 @@ export declare const mulmoPresentationStyleSchema: z.ZodObject<{
9209
9211
  vertexai_location: z.ZodOptional<z.ZodString>;
9210
9212
  firstFrameImageName: z.ZodOptional<z.ZodString>;
9211
9213
  lastFrameImageName: z.ZodOptional<z.ZodString>;
9214
+ frameFillColor: z.ZodOptional<z.ZodString>;
9212
9215
  referenceImages: z.ZodOptional<z.ZodArray<z.ZodObject<{
9213
9216
  imageName: z.ZodString;
9214
9217
  referenceType: z.ZodEnum<{
@@ -9721,6 +9724,7 @@ export declare const mulmoScriptSchema: z.ZodObject<{
9721
9724
  vertexai_location: z.ZodOptional<z.ZodString>;
9722
9725
  firstFrameImageName: z.ZodOptional<z.ZodString>;
9723
9726
  lastFrameImageName: z.ZodOptional<z.ZodString>;
9727
+ frameFillColor: z.ZodOptional<z.ZodString>;
9724
9728
  referenceImages: z.ZodOptional<z.ZodArray<z.ZodObject<{
9725
9729
  imageName: z.ZodString;
9726
9730
  referenceType: z.ZodEnum<{
@@ -13829,6 +13833,7 @@ export declare const mulmoScriptSchema: z.ZodObject<{
13829
13833
  vertexai_location: z.ZodOptional<z.ZodString>;
13830
13834
  firstFrameImageName: z.ZodOptional<z.ZodString>;
13831
13835
  lastFrameImageName: z.ZodOptional<z.ZodString>;
13836
+ frameFillColor: z.ZodOptional<z.ZodString>;
13832
13837
  referenceImages: z.ZodOptional<z.ZodArray<z.ZodObject<{
13833
13838
  imageName: z.ZodString;
13834
13839
  referenceType: z.ZodEnum<{
@@ -14395,6 +14400,7 @@ export declare const mulmoStudioSchema: z.ZodObject<{
14395
14400
  vertexai_location: z.ZodOptional<z.ZodString>;
14396
14401
  firstFrameImageName: z.ZodOptional<z.ZodString>;
14397
14402
  lastFrameImageName: z.ZodOptional<z.ZodString>;
14403
+ frameFillColor: z.ZodOptional<z.ZodString>;
14398
14404
  referenceImages: z.ZodOptional<z.ZodArray<z.ZodObject<{
14399
14405
  imageName: z.ZodString;
14400
14406
  referenceType: z.ZodEnum<{
@@ -18503,6 +18509,7 @@ export declare const mulmoStudioSchema: z.ZodObject<{
18503
18509
  vertexai_location: z.ZodOptional<z.ZodString>;
18504
18510
  firstFrameImageName: z.ZodOptional<z.ZodString>;
18505
18511
  lastFrameImageName: z.ZodOptional<z.ZodString>;
18512
+ frameFillColor: z.ZodOptional<z.ZodString>;
18506
18513
  referenceImages: z.ZodOptional<z.ZodArray<z.ZodObject<{
18507
18514
  imageName: z.ZodString;
18508
18515
  referenceType: z.ZodEnum<{
@@ -19004,6 +19011,7 @@ export declare const mulmoPromptTemplateSchema: z.ZodObject<{
19004
19011
  vertexai_location: z.ZodOptional<z.ZodString>;
19005
19012
  firstFrameImageName: z.ZodOptional<z.ZodString>;
19006
19013
  lastFrameImageName: z.ZodOptional<z.ZodString>;
19014
+ frameFillColor: z.ZodOptional<z.ZodString>;
19007
19015
  referenceImages: z.ZodOptional<z.ZodArray<z.ZodObject<{
19008
19016
  imageName: z.ZodString;
19009
19017
  referenceType: z.ZodEnum<{
@@ -19510,6 +19518,7 @@ export declare const mulmoPromptTemplateFileSchema: z.ZodObject<{
19510
19518
  vertexai_location: z.ZodOptional<z.ZodString>;
19511
19519
  firstFrameImageName: z.ZodOptional<z.ZodString>;
19512
19520
  lastFrameImageName: z.ZodOptional<z.ZodString>;
19521
+ frameFillColor: z.ZodOptional<z.ZodString>;
19513
19522
  referenceImages: z.ZodOptional<z.ZodArray<z.ZodObject<{
19514
19523
  imageName: z.ZodString;
19515
19524
  referenceType: z.ZodEnum<{
@@ -502,6 +502,11 @@ export const mulmoMovieParamsSchema = z.object({
502
502
  vertexai_location: z.string().optional(), // Vertex AI location (default: us-central1)
503
503
  firstFrameImageName: imageIdSchema.optional().describe("Reference an imageRefs key for the first frame (image-to-video input)"),
504
504
  lastFrameImageName: imageIdSchema.optional().describe("Reference an imageRefs key for the last frame (image-to-video interpolation)"),
505
+ frameFillColor: z
506
+ .string()
507
+ .regex(/^(#|0x)?[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$|^[a-zA-Z]+$/, "must be a hex color ('#RRGGBB[AA]', '0x' or no prefix) or a color name")
508
+ .optional()
509
+ .describe("Fill color used when padding firstFrame/lastFrame reference images to the canvas aspect ratio (e.g. '#F7F6F4'). Default: black"),
505
510
  referenceImages: z
506
511
  .array(movieReferenceImageSchema)
507
512
  .optional()
@@ -280,6 +280,7 @@ export declare const createStudioData: (_mulmoScript: MulmoScript, fileName: str
280
280
  vertexai_location?: string | undefined;
281
281
  firstFrameImageName?: string | undefined;
282
282
  lastFrameImageName?: string | undefined;
283
+ frameFillColor?: string | undefined;
283
284
  referenceImages?: {
284
285
  imageName: string;
285
286
  referenceType: "ASSET" | "STYLE";
@@ -2315,6 +2316,7 @@ export declare const createStudioData: (_mulmoScript: MulmoScript, fileName: str
2315
2316
  vertexai_location?: string | undefined;
2316
2317
  firstFrameImageName?: string | undefined;
2317
2318
  lastFrameImageName?: string | undefined;
2319
+ frameFillColor?: string | undefined;
2318
2320
  referenceImages?: {
2319
2321
  imageName: string;
2320
2322
  referenceType: "ASSET" | "STYLE";
@@ -2816,6 +2818,7 @@ export declare const initializeContextFromFiles: (files: FileObject, raiseError:
2816
2818
  vertexai_location?: string | undefined;
2817
2819
  firstFrameImageName?: string | undefined;
2818
2820
  lastFrameImageName?: string | undefined;
2821
+ frameFillColor?: string | undefined;
2819
2822
  referenceImages?: {
2820
2823
  imageName: string;
2821
2824
  referenceType: "ASSET" | "STYLE";
@@ -4851,6 +4854,7 @@ export declare const initializeContextFromFiles: (files: FileObject, raiseError:
4851
4854
  vertexai_location?: string | undefined;
4852
4855
  firstFrameImageName?: string | undefined;
4853
4856
  lastFrameImageName?: string | undefined;
4857
+ frameFillColor?: string | undefined;
4854
4858
  referenceImages?: {
4855
4859
  imageName: string;
4856
4860
  referenceType: "ASSET" | "STYLE";
@@ -5359,6 +5363,7 @@ export declare const initializeContextFromFiles: (files: FileObject, raiseError:
5359
5363
  vertexai_location?: string | undefined;
5360
5364
  firstFrameImageName?: string | undefined;
5361
5365
  lastFrameImageName?: string | undefined;
5366
+ frameFillColor?: string | undefined;
5362
5367
  referenceImages?: {
5363
5368
  imageName: string;
5364
5369
  referenceType: "ASSET" | "STYLE";
@@ -26,6 +26,11 @@ export declare const ffmpegGetMediaDuration: (filePath: string) => Promise<{
26
26
  duration: number;
27
27
  hasAudio: boolean;
28
28
  }>;
29
+ export declare const ffmpegGetImageDimensions: (filePath: string) => Promise<{
30
+ width: number;
31
+ height: number;
32
+ }>;
33
+ export declare const padImageToCanvas: (srcPath: string, destPath: string, width: number, height: number, fillColor: string) => Promise<void>;
29
34
  export declare const extractImageFromMovie: (movieFile: string, imagePath: string, useLastFrame?: boolean) => Promise<object>;
30
35
  export declare const trimMusic: (inputFile: string, startTime: number, duration: number) => Promise<Buffer>;
31
36
  export declare const createSilentAudio: (filePath: string, durationSec: number) => Promise<void>;
@@ -117,6 +117,48 @@ export const ffmpegGetMediaDuration = (filePath) => {
117
117
  });
118
118
  });
119
119
  };
120
+ export const ffmpegGetImageDimensions = (filePath) => {
121
+ return new Promise((resolve, reject) => {
122
+ ffmpeg.ffprobe(filePath, (err, metadata) => {
123
+ if (err) {
124
+ GraphAILogger.error("Error while getting image dimensions:", err);
125
+ reject(err);
126
+ return;
127
+ }
128
+ const stream = metadata.streams?.find((s) => s.codec_type === "video" && s.width && s.height);
129
+ if (!stream?.width || !stream?.height) {
130
+ reject(new Error(`ffmpegGetImageDimensions: no video stream found: ${filePath}`));
131
+ return;
132
+ }
133
+ resolve({ width: stream.width, height: stream.height });
134
+ });
135
+ });
136
+ };
137
+ // Aspect-fit the image into width x height, filling the extra space with fillColor (centered).
138
+ export const padImageToCanvas = (srcPath, destPath, width, height, fillColor) => {
139
+ // Only allow hex colors and color names: fillColor is interpolated into the
140
+ // filtergraph, where ',' and ':' would allow injecting arbitrary filters.
141
+ if (!/^(#|0x)?[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(fillColor) && !/^[a-zA-Z]+$/.test(fillColor)) {
142
+ return Promise.reject(new Error(`padImageToCanvas: invalid fill color: ${fillColor}`));
143
+ }
144
+ // ffmpeg color syntax: hex needs a 0x prefix ('#RRGGBB' and bare 'RRGGBB' both normalize)
145
+ const color = /^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(fillColor) ? `0x${fillColor}` : fillColor.replace(/^#/, "0x");
146
+ return new Promise((resolve, reject) => {
147
+ ffmpeg(srcPath)
148
+ .outputOptions([
149
+ "-frames:v",
150
+ "1",
151
+ "-update",
152
+ "1",
153
+ "-vf",
154
+ `scale=w=${width}:h=${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=${color}`,
155
+ ])
156
+ .output(destPath)
157
+ .on("end", () => resolve())
158
+ .on("error", (err) => reject(err))
159
+ .run();
160
+ });
161
+ };
120
162
  export const extractImageFromMovie = (movieFile, imagePath, useLastFrame = false) => {
121
163
  return new Promise((resolve, reject) => {
122
164
  const command = ffmpeg(movieFile);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmocast",
3
- "version": "2.8.0",
3
+ "version": "2.8.1",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "lib/index.node.js",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "resolutions": {
27
27
  "minimatch": "^10.2.5",
28
- "tar": "7.5.19",
28
+ "tar": "7.5.20",
29
29
  "yauzl": "^3.4.0"
30
30
  },
31
31
  "bin": {
@@ -112,8 +112,8 @@
112
112
  "graphai": "^2.0.18",
113
113
  "js-tiktoken": "^1.0.21",
114
114
  "jsdom": "^29.1.1",
115
- "marked": "^18.0.5",
116
- "mulmocast-vision": "^1.0.11",
115
+ "marked": "^18.0.6",
116
+ "mulmocast-vision": "^1.0.12",
117
117
  "ora": "^9.4.1",
118
118
  "puppeteer": "^25.3.0",
119
119
  "replicate": "^1.4.0",
@@ -132,12 +132,12 @@
132
132
  "eslint-config-prettier": "^10.1.8",
133
133
  "eslint-plugin-import": "^2.32.0",
134
134
  "eslint-plugin-prettier": "^5.5.6",
135
- "eslint-plugin-sonarjs": "^4.1.0",
135
+ "eslint-plugin-sonarjs": "^4.2.0",
136
136
  "globals": "^17.7.0",
137
137
  "prettier": "^3.9.5",
138
- "tsx": "^4.23.0",
138
+ "tsx": "^4.23.1",
139
139
  "typescript": "6.0.3",
140
- "typescript-eslint": "^8.63.0"
140
+ "typescript-eslint": "^8.64.0"
141
141
  },
142
142
  "engines": {
143
143
  "node": ">=22.0.0"