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.
- package/lib/actions/image_agents.d.ts +1 -0
- package/lib/actions/image_agents.js +71 -8
- package/lib/agents/combine_audio_files_agent.js +17 -2
- package/lib/agents/movie_replicate_agent.js +7 -4
- package/lib/methods/mulmo_presentation_style.d.ts +2 -0
- package/lib/methods/mulmo_presentation_style.js +12 -1
- package/lib/types/provider2agent.d.ts +2 -1
- package/lib/types/provider2agent.js +26 -3
- package/lib/types/schema.d.ts +9 -0
- package/lib/types/schema.js +5 -0
- package/lib/utils/context.d.ts +5 -0
- package/lib/utils/ffmpeg_utils.d.ts +5 -0
- package/lib/utils/ffmpeg_utils.js +42 -0
- package/package.json +7 -7
|
@@ -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
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
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
|
|
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 ===
|
|
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.
|
|
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.
|
|
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];
|
package/lib/types/schema.d.ts
CHANGED
|
@@ -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<{
|
package/lib/types/schema.js
CHANGED
|
@@ -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()
|
package/lib/utils/context.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
116
|
-
"mulmocast-vision": "^1.0.
|
|
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.
|
|
135
|
+
"eslint-plugin-sonarjs": "^4.2.0",
|
|
136
136
|
"globals": "^17.7.0",
|
|
137
137
|
"prettier": "^3.9.5",
|
|
138
|
-
"tsx": "^4.23.
|
|
138
|
+
"tsx": "^4.23.1",
|
|
139
139
|
"typescript": "6.0.3",
|
|
140
|
-
"typescript-eslint": "^8.
|
|
140
|
+
"typescript-eslint": "^8.64.0"
|
|
141
141
|
},
|
|
142
142
|
"engines": {
|
|
143
143
|
"node": ">=22.0.0"
|