mulmocast 2.7.2 → 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];
@@ -2,7 +2,7 @@ import { assert, GraphAILogger } from "graphai";
2
2
  import { silent60secPath, isFile } from "../utils/file.js";
3
3
  import { FfmpegContextInit, FfmpegContextGenerateOutput, FfmpegContextInputFormattedAudio, ffmpegGetMediaDuration, } from "../utils/ffmpeg_utils.js";
4
4
  import { MulmoMediaSourceMethods } from "../methods/mulmo_media_source.js";
5
- import { MulmoBeatMethods } from "../methods/index.js";
5
+ import { MulmoBeatMethods, MulmoPresentationStyleMethods } from "../methods/index.js";
6
6
  import { userAssert } from "../utils/utils.js";
7
7
  import { getAudioInputIdsError } from "../utils/error_cause.js";
8
8
  const getMovieDuration = async (context, beat) => {
@@ -126,7 +126,22 @@ const getVoiceOverGroup = (context, index) => {
126
126
  };
127
127
  const getSpillOverGroup = (context, mediaDurations, index) => {
128
128
  const group = [index];
129
- for (let i = index + 1; i < context.studio.beats.length && !mediaDurations[i].hasMedia; i++) {
129
+ for (let i = index + 1; i < context.studio.beats.length; i++) {
130
+ const media = mediaDurations[i];
131
+ // An empty-text beat with only a moviePrompt (no generated audio requested) continues
132
+ // the preceding narration (spill-over). Generated movies don't exist yet at this point,
133
+ // so movieDuration === 0; imported movies and animated html_tailwind beats have
134
+ // movieDuration > 0 and keep their own timeline, ending the group as before.
135
+ const beat = context.studio.script.beats[i];
136
+ const isSilentContinuation = !beat.text?.trim() &&
137
+ media.audioDuration === 0 &&
138
+ media.movieDuration === 0 &&
139
+ Boolean(beat.moviePrompt) &&
140
+ !beat.soundEffectPrompt &&
141
+ !MulmoPresentationStyleMethods.generatedMovieHasAudio(context.presentationStyle, beat);
142
+ if (media.hasMedia && !isSilentContinuation) {
143
+ break;
144
+ }
130
145
  group.push(i);
131
146
  }
132
147
  return group;
@@ -81,11 +81,14 @@ async function generateMovie(model, apiKey, prompt, imagePath, lastFrameImagePat
81
81
  }
82
82
  // Add generate_audio if the model supports it
83
83
  const audio = provider2MovieAgent.replicate.modelParams[model].audio;
84
+ if (audio.mode === AUDIO_MODE_OPTIONAL) {
85
+ // Always send the flag: several models (e.g. seedance-2.0's generate_audio, p-video's
86
+ // save_audio) default it to true, which silently embeds generated audio.
87
+ // Silent unless explicitly requested.
88
+ input[audio.param] = generateAudio ?? false;
89
+ }
84
90
  if (generateAudio !== undefined) {
85
- if (audio.mode === AUDIO_MODE_OPTIONAL) {
86
- input[audio.param] = generateAudio;
87
- }
88
- else if (audio.mode === AUDIO_MODE_NEVER && generateAudio === true) {
91
+ if (audio.mode === AUDIO_MODE_NEVER && generateAudio === true) {
89
92
  throw new Error(`Model ${model} does not support audio generation`, {
90
93
  cause: agentGenerationError("movieReplicateAgent", imageAction, unsupportedModelTarget),
91
94
  });
@@ -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";
@@ -204,6 +205,7 @@ export declare const MulmoPresentationStyleMethods: {
204
205
  };
205
206
  keyName: string;
206
207
  };
208
+ generatedMovieHasAudio(presentationStyle: MulmoPresentationStyle, beat: MulmoBeat): boolean;
207
209
  getSoundEffectAgentInfo(presentationStyle: MulmoPresentationStyle, beat: MulmoBeat): {
208
210
  agentName: string;
209
211
  defaultModel: import("../types/provider2agent.js").ReplicateModel;
@@ -7,7 +7,7 @@ import { isNull } from "graphai";
7
7
  import { userAssert } from "../utils/utils.js";
8
8
  import { slideThemes } from "../data/slideThemes.js";
9
9
  import { text2ImageProviderSchema, text2HtmlImageProviderSchema, text2MovieProviderSchema, text2SpeechProviderSchema, mulmoCanvasDimensionSchema, mulmoTransitionSchema, } from "../types/schema.js";
10
- import { provider2ImageAgent, provider2MovieAgent, provider2LLMAgent, provider2TTSAgent, provider2SoundEffectAgent, provider2LipSyncAgent, defaultProviders, } from "../types/provider2agent.js";
10
+ import { provider2ImageAgent, provider2MovieAgent, provider2LLMAgent, provider2TTSAgent, provider2SoundEffectAgent, provider2LipSyncAgent, defaultProviders, getModelAudio, AUDIO_MODE_ALWAYS, } from "../types/provider2agent.js";
11
11
  const defaultTextSlideStyles = [
12
12
  '*,*::before,*::after{box-sizing:border-box}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin:0}ul[role="list"],ol[role="list"]{list-style:none}html:focus-within{scroll-behavior:smooth}body{min-height:100vh;text-rendering:optimizeSpeed;line-height:1.5}a:not([class]){text-decoration-skip-ink:auto}img,picture{max-width:100%;display:block}input,button,textarea,select{font:inherit}@media(prefers-reduced-motion:reduce){html:focus-within{scroll-behavior:auto}*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important;scroll-behavior:auto !important}}',
13
13
  "body { margin: 60px; margin-top: 40px; color:#333; background-color:#fff; font-size: 30px; font-family: Arial, sans-serif; box-sizing: border-box; height: 100vh }",
@@ -131,6 +131,17 @@ export const MulmoPresentationStyleMethods = {
131
131
  keyName: agentInfo.keyName,
132
132
  };
133
133
  },
134
+ // True when the movie generated for this beat will carry an audio track:
135
+ // the model always embeds audio, or audio generation is explicitly requested.
136
+ generatedMovieHasAudio(presentationStyle, beat) {
137
+ const movieParams = { ...presentationStyle.movieParams, ...beat.movieParams };
138
+ if (movieParams?.generateAudio === true) {
139
+ return true;
140
+ }
141
+ const provider = text2MovieProviderSchema.parse(movieParams?.provider ?? defaultProviders.text2movie);
142
+ const model = movieParams?.model ?? provider2MovieAgent[provider].defaultModel;
143
+ return getModelAudio(provider, model)?.mode === AUDIO_MODE_ALWAYS;
144
+ },
134
145
  getSoundEffectAgentInfo(presentationStyle, beat) {
135
146
  const soundEffectProvider = (beat.soundEffectParams?.provider ??
136
147
  presentationStyle.soundEffectParams?.provider ??
@@ -88,7 +88,7 @@ export type ReplicateModel = `${string}/${string}`;
88
88
  export declare const AUDIO_MODE_NEVER: "never";
89
89
  export declare const AUDIO_MODE_ALWAYS: "always";
90
90
  export declare const AUDIO_MODE_OPTIONAL: "optional";
91
- type MovieAudioSpec = {
91
+ export type MovieAudioSpec = {
92
92
  mode: typeof AUDIO_MODE_NEVER;
93
93
  } | {
94
94
  mode: typeof AUDIO_MODE_ALWAYS;
@@ -213,6 +213,7 @@ export declare const defaultProviders: {
213
213
  export declare const llm: (keyof typeof provider2LLMAgent)[];
214
214
  export type LLM = keyof typeof provider2LLMAgent;
215
215
  export declare const htmlLLMProvider: string[];
216
+ export declare const getModelAudio: (provider: keyof typeof provider2MovieAgent, model: string) => MovieAudioSpec | undefined;
216
217
  export declare const getModelDuration: (provider: keyof typeof provider2MovieAgent, model: string, movieDuration?: number) => number | undefined;
217
218
  export type ModelPricing = {
218
219
  unit: "tokens" | "chars" | "seconds" | "images";
@@ -144,10 +144,12 @@ export const provider2MovieAgent = {
144
144
  "minimax/hailuo-2.3",
145
145
  "minimax/hailuo-2.3-fast",
146
146
  "pixverse/pixverse-v5",
147
+ "prunaai/p-video",
148
+ "xai/grok-imagine-video-1.5",
147
149
  ],
148
150
  modelParams: {
149
151
  "bytedance/seedance-1-lite": {
150
- durations: [5, 10],
152
+ durations: [4, 5, 6, 7, 8, 9, 10, 11, 12],
151
153
  start_image: "image",
152
154
  last_image: "last_frame_image",
153
155
  audio: { mode: AUDIO_MODE_NEVER },
@@ -259,10 +261,13 @@ export const provider2MovieAgent = {
259
261
  price_per_sec: 0.12,
260
262
  },
261
263
  "wan-video/wan-2.2-i2v-fast": {
264
+ // No duration input: length is num_frames (default 81) / frames_per_second (default 16) ≈ 5s.
262
265
  durations: [5],
263
266
  start_image: "image",
267
+ start_image_required: true,
268
+ last_image: "last_image",
264
269
  audio: { mode: AUDIO_MODE_NEVER },
265
- price_per_sec: 0.012,
270
+ price_per_sec: 0.01, // $0.05 per 81-frame 480p video; up to $0.145 at 720p
266
271
  },
267
272
  "wan-video/wan-2.2-t2v-fast": {
268
273
  durations: [5],
@@ -295,7 +300,7 @@ export const provider2MovieAgent = {
295
300
  last_image: "end_image",
296
301
  reference_images_param: "reference_images",
297
302
  audio: { mode: AUDIO_MODE_OPTIONAL, param: "generate_audio" },
298
- price_per_sec: 0.3,
303
+ price_per_sec: 0.28, // 'pro' (1080p, default); 'standard' $0.168, '4k' $0.42
299
304
  },
300
305
  "kwaivgi/kling-v3-video": {
301
306
  durations: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
@@ -335,6 +340,20 @@ export const provider2MovieAgent = {
335
340
  audio: { mode: AUDIO_MODE_NEVER },
336
341
  price_per_sec: 0.12,
337
342
  },
343
+ "prunaai/p-video": {
344
+ durations: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
345
+ start_image: "image",
346
+ last_image: "last_frame_image",
347
+ audio: { mode: AUDIO_MODE_OPTIONAL, param: "save_audio" },
348
+ price_per_sec: 0.02, // 720p, draft mode off ($0.04 at 1080p)
349
+ },
350
+ "xai/grok-imagine-video-1.5": {
351
+ durations: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
352
+ start_image: "image",
353
+ start_image_required: true,
354
+ audio: { mode: AUDIO_MODE_ALWAYS },
355
+ price_per_sec: 0.08,
356
+ },
338
357
  },
339
358
  },
340
359
  google: {
@@ -508,6 +527,10 @@ export const defaultProviders = {
508
527
  };
509
528
  export const llm = Object.keys(provider2LLMAgent);
510
529
  export const htmlLLMProvider = ["openai", "anthropic", "mock"];
530
+ export const getModelAudio = (provider, model) => {
531
+ const modelParams = provider2MovieAgent[provider]?.modelParams;
532
+ return modelParams?.[model]?.audio;
533
+ };
511
534
  export const getModelDuration = (provider, model, movieDuration) => {
512
535
  const modelParams = provider2MovieAgent[provider]?.modelParams;
513
536
  const { durations } = modelParams[model];
@@ -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.7.2",
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"