mulmocast 2.17.0 → 2.19.0
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/README.md +110 -38
- package/assets/schemas/mulmo_script.json +100 -0
- package/lib/actions/audio.js +4 -1
- package/lib/actions/avatar.js +4 -1
- package/lib/actions/captions.js +1 -1
- package/lib/actions/image_agents.d.ts +1 -0
- package/lib/actions/image_agents.js +2 -2
- package/lib/actions/images.d.ts +2 -0
- package/lib/actions/images.js +1 -0
- package/lib/actions/pdf.js +23 -2
- package/lib/agents/add_bgm_agent.d.ts +13 -1
- package/lib/agents/add_bgm_agent.js +77 -4
- package/lib/agents/movie_genai_agent.d.ts +24 -0
- package/lib/agents/movie_genai_agent.js +56 -43
- package/lib/cli/bin.js +0 -0
- package/lib/cli/commands/bundle/builder.d.ts +2 -0
- package/lib/cli/commands/html/builder.d.ts +2 -0
- package/lib/cli/commands/markdown/builder.d.ts +2 -0
- package/lib/cli/commands/viewer/builder.d.ts +2 -0
- package/lib/cli/common.d.ts +2 -0
- package/lib/cli/common.js +5 -0
- package/lib/cli/helpers.js +3 -1
- package/lib/mcp/server.js +0 -0
- package/lib/methods/mulmo_studio_context.d.ts +6 -0
- package/lib/methods/mulmo_studio_context.js +26 -0
- package/lib/types/provider2agent.d.ts +1 -4
- package/lib/types/provider2agent.js +27 -24
- package/lib/types/schema.d.ts +64 -0
- package/lib/types/schema.js +42 -0
- package/lib/types/type.d.ts +4 -1
- package/lib/utils/context.d.ts +32 -0
- package/lib/utils/estimate_usage.js +6 -5
- package/lib/utils/gemini_omni_video.d.ts +49 -0
- package/lib/utils/gemini_omni_video.js +36 -0
- package/lib/utils/html_render.d.ts +6 -5
- package/lib/utils/html_render.js +37 -30
- package/lib/utils/image_plugins/chart.js +2 -1
- package/lib/utils/image_plugins/html_tailwind.js +5 -4
- package/lib/utils/image_plugins/markdown.js +2 -1
- package/lib/utils/image_plugins/mermaid.js +2 -1
- package/lib/utils/image_plugins/slide.js +2 -1
- package/lib/utils/image_plugins/text_slide.js +2 -1
- package/lib/utils/image_plugins/vision.js +7 -0
- package/lib/utils/jinglescript.d.ts +12 -0
- package/lib/utils/jinglescript.js +63 -0
- package/lib/utils/render_network_guard.d.ts +2 -0
- package/lib/utils/render_network_guard.js +36 -0
- package/lib/utils/render_network_policy.d.ts +15 -0
- package/lib/utils/render_network_policy.js +85 -0
- package/package.json +5 -4
- package/scripts/test/test_genai.json +10 -9
- package/scripts/test/test_genai_movie.json +1 -1
- package/scripts/test/test_image_references.json +3 -2
- package/scripts/test/test_jinglescript_sound_effects.json +101 -0
- package/scripts/test/test_movie_references.json +2 -1
- package/scripts/test/test_sound_effects.json +64 -0
- package/scripts/test/test_sound_effects_animation.json +441 -0
- package/scripts/test/test_veo31_lite.json +3 -3
- package/scripts/test/test_captions_2.json~ +0 -278
- package/scripts/test/test_en_portrait.json~ +0 -31
- package/scripts/test/test_slide_image_ref_gemini_en.json~ +0 -289
- package/scripts/test/test_vertexai.json~ +0 -21
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import type { AgentFunctionInfo } from "graphai";
|
|
2
2
|
import { MulmoStudioContext } from "../types/index.js";
|
|
3
|
+
import { FfmpegContext } from "../utils/ffmpeg_utils.js";
|
|
3
4
|
export declare const resolveAddBgmMixParams: (audioParams: MulmoStudioContext["presentationStyle"]["audioParams"]) => {
|
|
4
5
|
useExplicitMix: boolean;
|
|
5
6
|
voiceVolume: number;
|
|
6
7
|
};
|
|
7
|
-
export declare const resolveAddBgmFilterConfig: (useExplicitMix: boolean) => {
|
|
8
|
+
export declare const resolveAddBgmFilterConfig: (useExplicitMix: boolean, mixedInputId?: string) => {
|
|
8
9
|
amixNormalize: string;
|
|
9
10
|
mixedOutputId: string;
|
|
10
11
|
limiterFilter: string | undefined;
|
|
11
12
|
};
|
|
13
|
+
export type SoundEffectPlacement = {
|
|
14
|
+
file: string;
|
|
15
|
+
startAt: number;
|
|
16
|
+
volume: number;
|
|
17
|
+
duration?: number;
|
|
18
|
+
loop: boolean;
|
|
19
|
+
};
|
|
20
|
+
export declare const getSoundEffectPlacements: (context: MulmoStudioContext) => SoundEffectPlacement[];
|
|
21
|
+
export declare const soundEffectGain: (useExplicitMix: boolean) => 0.5 | 1;
|
|
22
|
+
export declare const buildSoundEffectFilters: (inputIds: number[], placements: SoundEffectPlacement[], gain: number, outputId: string) => string[];
|
|
23
|
+
export declare const addSoundEffects: (ffmpegContext: FfmpegContext, context: MulmoStudioContext) => "mixed" | "mixed_sfx";
|
|
12
24
|
declare const addBGMAgentInfo: AgentFunctionInfo;
|
|
13
25
|
export default addBGMAgentInfo;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { GraphAILogger } from "graphai";
|
|
2
2
|
import { FfmpegContextAddInput, FfmpegContextInit, FfmpegContextGenerateOutput, ffmpegGetMediaDuration } from "../utils/ffmpeg_utils.js";
|
|
3
3
|
import { MulmoStudioContextMethods } from "../methods/mulmo_studio_context.js";
|
|
4
|
+
import { MulmoMediaSourceMethods } from "../methods/mulmo_media_source.js";
|
|
4
5
|
import { isFile } from "../utils/file.js";
|
|
6
|
+
import { userAssert } from "../utils/utils.js";
|
|
7
|
+
import { getJingleFilePath } from "../utils/jinglescript.js";
|
|
5
8
|
import { agentGenerationError, agentFileNotExistError, audioAction, audioFileTarget } from "../utils/error_cause.js";
|
|
6
9
|
export const resolveAddBgmMixParams = (audioParams) => {
|
|
7
10
|
const useExplicitMix = audioParams.ttsVolume !== undefined;
|
|
@@ -11,14 +14,82 @@ export const resolveAddBgmMixParams = (audioParams) => {
|
|
|
11
14
|
voiceVolume: audioParams.audioVolume * ttsVolume,
|
|
12
15
|
};
|
|
13
16
|
};
|
|
14
|
-
export const resolveAddBgmFilterConfig = (useExplicitMix) => {
|
|
17
|
+
export const resolveAddBgmFilterConfig = (useExplicitMix, mixedInputId = "mixed") => {
|
|
15
18
|
const amixNormalize = useExplicitMix ? ":normalize=0" : "";
|
|
16
19
|
return {
|
|
17
20
|
amixNormalize,
|
|
18
|
-
mixedOutputId: useExplicitMix ? "mixed_limited" :
|
|
19
|
-
limiterFilter: useExplicitMix ?
|
|
21
|
+
mixedOutputId: useExplicitMix ? "mixed_limited" : mixedInputId,
|
|
22
|
+
limiterFilter: useExplicitMix ? `[${mixedInputId}]alimiter=limit=0.95:attack=5:release=50[mixed_limited]` : undefined,
|
|
20
23
|
};
|
|
21
24
|
};
|
|
25
|
+
// Absolute placement of every beat's sound effects on the final audio timeline. startAt counts from
|
|
26
|
+
// when the beat appears on screen, the same clock as its html animation (for the first beat, that is
|
|
27
|
+
// introPadding before its narration starts).
|
|
28
|
+
// The context must come from combineAudioFilesAgent, which sets studio.beats[].startAt.
|
|
29
|
+
export const getSoundEffectPlacements = (context) => {
|
|
30
|
+
return context.studio.script.beats.flatMap((beat, index) => (beat.soundEffects ?? []).map((soundEffect, seIndex) => {
|
|
31
|
+
const beatStartAt = context.studio.beats[index]?.startAt;
|
|
32
|
+
userAssert(beatStartAt !== undefined, `soundEffects: startAt of beat ${index} is not computed yet`);
|
|
33
|
+
const { source } = soundEffect;
|
|
34
|
+
const file = source.kind === "jinglescript" ? getJingleFilePath(source.score, context) : MulmoMediaSourceMethods.resolve(source, context);
|
|
35
|
+
userAssert(!!file, `soundEffects: unsupported source at beat ${index}, effect ${seIndex} (use url or path)`);
|
|
36
|
+
const startAt = soundEffect.startAt ?? 0;
|
|
37
|
+
const loop = soundEffect.loop ?? false;
|
|
38
|
+
// A looping effect without duration plays until the beat leaves the screen.
|
|
39
|
+
const duration = soundEffect.duration ?? (loop ? MulmoStudioContextMethods.getBeatScreenDuration(context, index) - startAt : undefined);
|
|
40
|
+
userAssert(duration === undefined || duration > 0, `soundEffects: beat ${index}, effect ${seIndex} starts after the end of the beat`);
|
|
41
|
+
return {
|
|
42
|
+
file,
|
|
43
|
+
startAt: MulmoStudioContextMethods.getBeatScreenStartAt(context, index) + startAt,
|
|
44
|
+
volume: soundEffect.volume ?? 1.0,
|
|
45
|
+
...(duration !== undefined ? { duration } : {}),
|
|
46
|
+
loop,
|
|
47
|
+
};
|
|
48
|
+
}));
|
|
49
|
+
};
|
|
50
|
+
// In legacy mode, amix (normalize=1) halves the voice and the music. Sound effects are mixed in
|
|
51
|
+
// afterwards with normalize=0, so apply the same factor to keep volume 1.0 at the gain of the narration at its
|
|
52
|
+
// default volume. audioVolume / ttsVolume deliberately do not scale sound effects.
|
|
53
|
+
export const soundEffectGain = (useExplicitMix) => (useExplicitMix ? 1.0 : 0.5);
|
|
54
|
+
const soundEffectFadeOut = 0.05; // seconds, avoids a click where the effect is cut
|
|
55
|
+
const getSoundEffectTrim = (duration) => {
|
|
56
|
+
const fade = Math.min(soundEffectFadeOut, duration);
|
|
57
|
+
return `atrim=duration=${duration}, afade=t=out:st=${duration - fade}:d=${fade}, `;
|
|
58
|
+
};
|
|
59
|
+
export const buildSoundEffectFilters = (inputIds, placements, gain, outputId) => {
|
|
60
|
+
const ids = placements.map((_, index) => `se_${index}`);
|
|
61
|
+
const filters = placements.map((placement, index) => {
|
|
62
|
+
const delayMs = Math.round(placement.startAt * 1000);
|
|
63
|
+
const trim = placement.duration !== undefined ? getSoundEffectTrim(placement.duration) : "";
|
|
64
|
+
return `[${inputIds[index]}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo, ${trim}volume=${placement.volume * gain}, adelay=${delayMs}|${delayMs}[${ids[index]}]`;
|
|
65
|
+
});
|
|
66
|
+
if (ids.length === 1) {
|
|
67
|
+
return [...filters, `[${ids[0]}]anull[${outputId}]`];
|
|
68
|
+
}
|
|
69
|
+
const inputs = ids.map((id) => "[" + id + "]").join("");
|
|
70
|
+
return [...filters, `${inputs}amix=inputs=${ids.length}:duration=longest:normalize=0[${outputId}]`];
|
|
71
|
+
};
|
|
72
|
+
// Adds the beats' sound effects to ffmpegContext and mixes them onto [mixed].
|
|
73
|
+
// Returns the id of the resulting stream ("mixed" when there are none).
|
|
74
|
+
export const addSoundEffects = (ffmpegContext, context) => {
|
|
75
|
+
const { useExplicitMix } = resolveAddBgmMixParams(context.presentationStyle.audioParams);
|
|
76
|
+
const soundEffects = getSoundEffectPlacements(context);
|
|
77
|
+
if (soundEffects.length === 0) {
|
|
78
|
+
return "mixed";
|
|
79
|
+
}
|
|
80
|
+
soundEffects.forEach(({ file }) => {
|
|
81
|
+
if (!/^https?:\/\//.test(file) && !isFile(file)) {
|
|
82
|
+
throw new Error(`AddBGMAgent soundEffect file not exist: ${file}`, {
|
|
83
|
+
cause: agentFileNotExistError("addBGMAgent", audioAction, audioFileTarget, file),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
GraphAILogger.log("soundEffects:", soundEffects);
|
|
88
|
+
const inputIds = soundEffects.map(({ file, loop }) => FfmpegContextAddInput(ffmpegContext, file, loop ? ["-stream_loop", "-1"] : undefined));
|
|
89
|
+
ffmpegContext.filterComplex.push(...buildSoundEffectFilters(inputIds, soundEffects, soundEffectGain(useExplicitMix), "sfx"));
|
|
90
|
+
ffmpegContext.filterComplex.push("[mixed][sfx]amix=inputs=2:duration=first:normalize=0[mixed_sfx]");
|
|
91
|
+
return "mixed_sfx";
|
|
92
|
+
};
|
|
22
93
|
const addBGMAgent = async ({ namedInputs, params, }) => {
|
|
23
94
|
const { voiceFile, outputFile, context } = namedInputs;
|
|
24
95
|
const { musicFile } = params;
|
|
@@ -44,8 +115,10 @@ const addBGMAgent = async ({ namedInputs, params, }) => {
|
|
|
44
115
|
const { useExplicitMix, voiceVolume } = resolveAddBgmMixParams(audioParams);
|
|
45
116
|
ffmpegContext.filterComplex.push(`[${musicInputIndex}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo, volume=${audioParams.bgmVolume}[music]`);
|
|
46
117
|
ffmpegContext.filterComplex.push(`[${voiceInputIndex}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo, volume=${voiceVolume}, adelay=${introPadding * 1000}|${introPadding * 1000}[voice]`);
|
|
47
|
-
const { amixNormalize
|
|
118
|
+
const { amixNormalize } = resolveAddBgmFilterConfig(useExplicitMix);
|
|
48
119
|
ffmpegContext.filterComplex.push(`[music][voice]amix=inputs=2:duration=longest${amixNormalize}[mixed]`);
|
|
120
|
+
const mixedInputId = addSoundEffects(ffmpegContext, context);
|
|
121
|
+
const { mixedOutputId, limiterFilter } = resolveAddBgmFilterConfig(useExplicitMix, mixedInputId);
|
|
49
122
|
if (limiterFilter) {
|
|
50
123
|
ffmpegContext.filterComplex.push(limiterFilter);
|
|
51
124
|
}
|
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
import type { AgentFunction, AgentFunctionInfo } from "graphai";
|
|
2
2
|
import type { AgentBufferResult, GenAIImageAgentConfig, GoogleMovieAgentParams, MovieAgentInputs } from "../types/agent.js";
|
|
3
|
+
import { type GeminiOmniImage, type GeminiOmniVideoRequest } from "../utils/gemini_omni_video.js";
|
|
4
|
+
type GeminiOmniInteraction = {
|
|
5
|
+
id?: string;
|
|
6
|
+
output_video?: {
|
|
7
|
+
data?: string;
|
|
8
|
+
mime_type?: string;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
export type GeminiOmniVideoClient = {
|
|
12
|
+
interactions: {
|
|
13
|
+
create: (params: GeminiOmniVideoRequest, options?: {
|
|
14
|
+
timeout?: number;
|
|
15
|
+
}) => Promise<GeminiOmniInteraction>;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
export type GeminiOmniVideoInput = {
|
|
19
|
+
model: string;
|
|
20
|
+
prompt: string;
|
|
21
|
+
aspectRatio: string;
|
|
22
|
+
requestedSec: number;
|
|
23
|
+
firstFrame?: GeminiOmniImage;
|
|
24
|
+
lastFrame?: GeminiOmniImage;
|
|
25
|
+
};
|
|
26
|
+
export declare const generateGeminiOmniVideo: (ai: GeminiOmniVideoClient, input: GeminiOmniVideoInput) => Promise<Buffer>;
|
|
3
27
|
export declare const movieGenAIAgent: AgentFunction<GoogleMovieAgentParams, AgentBufferResult, MovieAgentInputs, GenAIImageAgentConfig>;
|
|
4
28
|
declare const movieGenAIAgentInfo: AgentFunctionInfo;
|
|
5
29
|
export default movieGenAIAgentInfo;
|
|
@@ -5,11 +5,14 @@ import { apiKeyMissingError, agentGenerationError, agentInvalidResponseError, im
|
|
|
5
5
|
import { getAspectRatio } from "../utils/utils.js";
|
|
6
6
|
import { ffmpegGetMediaDuration } from "../utils/ffmpeg_utils.js";
|
|
7
7
|
import { ASPECT_RATIOS } from "../types/const.js";
|
|
8
|
-
import { getModelDuration, provider2MovieAgent, AUDIO_MODE_NEVER, AUDIO_MODE_ALWAYS, defaultMovieModel,
|
|
8
|
+
import { getModelDuration, provider2MovieAgent, AUDIO_MODE_NEVER, AUDIO_MODE_ALWAYS, defaultMovieModel, unsupportedGoogleMovieModelMessage, } from "../types/provider2agent.js";
|
|
9
|
+
import { GEMINI_OMNI_MAX_TOTAL_SEC, buildGeminiOmniVideoRequest, geminiOmniAspectRatio, geminiOmniExtensionPrompt, geminiOmniSegments, isGeminiOmniVideoModel, videoFromInteraction, } from "../utils/gemini_omni_video.js";
|
|
9
10
|
// Per-request timeout so a stalled GenAI video API call rejects instead of hanging.
|
|
10
11
|
const GENAI_REQUEST_TIMEOUT_MS = 120_000;
|
|
11
12
|
// Wall-clock cap on the long-running video operation poll loop (Veo runs minutes).
|
|
12
13
|
const VIDEO_POLL_TIMEOUT_MS = 1_200_000;
|
|
14
|
+
// A Gemini Omni call answers synchronously with the whole video (10s at 720p took about 45s).
|
|
15
|
+
const GEMINI_OMNI_REQUEST_TIMEOUT_MS = 600_000;
|
|
13
16
|
const pollUntilDone = async (ai, operation) => {
|
|
14
17
|
const response = { operation };
|
|
15
18
|
const deadline = Date.now() + VIDEO_POLL_TIMEOUT_MS;
|
|
@@ -78,47 +81,53 @@ const downloadVideo = async (ai, video, movieFile, isVertexAI, model) => {
|
|
|
78
81
|
const usage = predictSec !== undefined ? { provider: "google", model, predictSec } : undefined;
|
|
79
82
|
return { saved: movieFile, usage };
|
|
80
83
|
};
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
84
|
+
const isOmniImage = (frame) => frame !== undefined;
|
|
85
|
+
const nextOmniRequest = (input, durationSec, previous, frames) => {
|
|
86
|
+
if (!previous)
|
|
87
|
+
return buildGeminiOmniVideoRequest({ ...input, durationSec, frames });
|
|
88
|
+
if (!previous.id)
|
|
89
|
+
throw new Error("Gemini Omni returned no interaction id to extend");
|
|
90
|
+
return buildGeminiOmniVideoRequest({ ...input, prompt: geminiOmniExtensionPrompt(input.prompt), durationSec, previousInteractionId: previous.id });
|
|
91
|
+
};
|
|
92
|
+
// Each call after the first extends the previous interaction's video and answers with the whole video so far.
|
|
93
|
+
export const generateGeminiOmniVideo = async (ai, input) => {
|
|
94
|
+
const segments = geminiOmniSegments(input.requestedSec);
|
|
95
|
+
const frames = [input.firstFrame, segments.length === 1 ? input.lastFrame : undefined].filter(isOmniImage);
|
|
96
|
+
const last = await segments.reduce(async (previousPromise, durationSec) => {
|
|
97
|
+
const request = nextOmniRequest(input, durationSec, await previousPromise, frames);
|
|
98
|
+
return ai.interactions.create(request, { timeout: GEMINI_OMNI_REQUEST_TIMEOUT_MS });
|
|
99
|
+
}, Promise.resolve(undefined));
|
|
100
|
+
return videoFromInteraction(last?.output_video);
|
|
101
|
+
};
|
|
102
|
+
const omniImage = (imagePath) => {
|
|
103
|
+
const { imageBytes, mimeType } = loadImageAsBase64(imagePath);
|
|
104
|
+
return { data: imageBytes, mime_type: mimeType };
|
|
105
|
+
};
|
|
106
|
+
const omniFrames = (input, { imagePath, lastFrameImagePath, referenceImages }) => {
|
|
107
|
+
if (referenceImages && referenceImages.length > 0) {
|
|
108
|
+
GraphAILogger.warn(`movieGenAIAgent: model ${input.model} does not support referenceImages — ignoring`);
|
|
109
|
+
}
|
|
110
|
+
if (lastFrameImagePath && !imagePath) {
|
|
111
|
+
GraphAILogger.warn(`movieGenAIAgent: lastFrame requires a first frame image (imagePrompt or firstFrameImageName) — ignoring lastFrameImageName`);
|
|
112
|
+
}
|
|
113
|
+
else if (lastFrameImagePath && geminiOmniSegments(input.requestedSec).length > 1) {
|
|
114
|
+
GraphAILogger.warn(`movieGenAIAgent: lastFrame applies to a single ${input.model} segment — ignoring lastFrameImageName for a ${input.requestedSec}s beat`);
|
|
115
|
+
}
|
|
116
|
+
const lastFrame = lastFrameImagePath && imagePath ? omniImage(lastFrameImagePath) : undefined;
|
|
117
|
+
return { firstFrame: imagePath ? omniImage(imagePath) : undefined, lastFrame };
|
|
118
|
+
};
|
|
119
|
+
const generateOmniVideoFile = async (ai, isVertexAI, input, sources, movieFile) => {
|
|
120
|
+
if (isVertexAI) {
|
|
121
|
+
throw new Error(`Model ${input.model} is supported on the Gemini API only; use veo-3.1-generate-001 with Vertex AI.`, {
|
|
122
|
+
cause: agentGenerationError("movieGenAIAgent", imageAction, unsupportedModelTarget),
|
|
119
123
|
});
|
|
120
124
|
}
|
|
121
|
-
|
|
125
|
+
if (input.requestedSec > GEMINI_OMNI_MAX_TOTAL_SEC) {
|
|
126
|
+
GraphAILogger.warn(`movieGenAIAgent: ${input.model} makes at most ${GEMINI_OMNI_MAX_TOTAL_SEC}s — the ${input.requestedSec}s beat gets a shorter video`);
|
|
127
|
+
}
|
|
128
|
+
writeFileSync(movieFile, await generateGeminiOmniVideo(ai, { ...input, ...omniFrames(input, sources) }));
|
|
129
|
+
const predictSec = await probeDurationSec(movieFile);
|
|
130
|
+
return { saved: movieFile, usage: predictSec !== undefined ? { provider: "google", model: input.model, predictSec } : undefined };
|
|
122
131
|
};
|
|
123
132
|
const generateStandardVideo = async (ai, model, prompt, aspectRatio, imagePath, lastFrameImagePath, referenceImages, duration, movieFile, isVertexAI) => {
|
|
124
133
|
const capabilities = provider2MovieAgent.google.modelParams[model];
|
|
@@ -173,6 +182,10 @@ export const movieGenAIAgent = async ({ namedInputs, params, config, }) => {
|
|
|
173
182
|
const model = params.model ?? defaultMovieModel("google", params);
|
|
174
183
|
const apiKey = config?.apiKey;
|
|
175
184
|
try {
|
|
185
|
+
const unsupportedMessage = unsupportedGoogleMovieModelMessage(model);
|
|
186
|
+
if (unsupportedMessage) {
|
|
187
|
+
throw new Error(unsupportedMessage, { cause: agentGenerationError("movieGenAIAgent", imageAction, unsupportedModelTarget) });
|
|
188
|
+
}
|
|
176
189
|
const requestedDuration = params.duration ?? 8;
|
|
177
190
|
const duration = getModelDuration("google", model, requestedDuration);
|
|
178
191
|
if (duration === undefined) {
|
|
@@ -208,9 +221,9 @@ export const movieGenAIAgent = async ({ namedInputs, params, config, }) => {
|
|
|
208
221
|
}
|
|
209
222
|
return new GoogleGenAI({ apiKey, httpOptions: { timeout: GENAI_REQUEST_TIMEOUT_MS } });
|
|
210
223
|
})();
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return
|
|
224
|
+
if (isGeminiOmniVideoModel(model)) {
|
|
225
|
+
const omniInput = { model, prompt, aspectRatio: geminiOmniAspectRatio(params.canvasSize), requestedSec: requestedDuration };
|
|
226
|
+
return generateOmniVideoFile(ai, isVertexAI, omniInput, { imagePath, lastFrameImagePath, referenceImages }, movieFile);
|
|
214
227
|
}
|
|
215
228
|
// Standard mode
|
|
216
229
|
return generateStandardVideo(ai, model, prompt, aspectRatio, imagePath, lastFrameImagePath, referenceImages, duration, movieFile, isVertexAI);
|
package/lib/cli/bin.js
CHANGED
|
File without changes
|
package/lib/cli/common.d.ts
CHANGED
package/lib/cli/common.js
CHANGED
|
@@ -49,6 +49,11 @@ export const commonOptions = (yargs) => {
|
|
|
49
49
|
describe: "create backup media file",
|
|
50
50
|
type: "boolean",
|
|
51
51
|
default: false,
|
|
52
|
+
})
|
|
53
|
+
.option("strict-network", {
|
|
54
|
+
describe: "Let HTML renderers reach only local files and the CDNs mulmocast's templates use (also MULMO_STRICT_NETWORK=1)",
|
|
55
|
+
type: "boolean",
|
|
56
|
+
default: false,
|
|
52
57
|
})
|
|
53
58
|
.option("p", {
|
|
54
59
|
alias: "presentationStyle",
|
package/lib/cli/helpers.js
CHANGED
|
@@ -10,6 +10,7 @@ import { translate } from "../actions/translate.js";
|
|
|
10
10
|
import { initializeContextFromFiles } from "../utils/context.js";
|
|
11
11
|
import { estimateUsage, actionEstimateProcesses } from "../utils/estimate_usage.js";
|
|
12
12
|
import { formatUsageEstimates } from "../utils/estimate_usage_format.js";
|
|
13
|
+
import { strictNetworkFromEnv } from "../utils/render_network_policy.js";
|
|
13
14
|
export const runTranslateIfNeeded = async (context, includeCaption = false) => {
|
|
14
15
|
if (MulmoStudioContextMethods.needTranslate(context, includeCaption)) {
|
|
15
16
|
GraphAILogger.log("run translate");
|
|
@@ -151,5 +152,6 @@ export const initializeContext = async (argv, raiseError = false) => {
|
|
|
151
152
|
grouped: Boolean(argv.g),
|
|
152
153
|
});
|
|
153
154
|
setGraphAILogger(Boolean(argv.v), { files });
|
|
154
|
-
|
|
155
|
+
const context = await initializeContextFromFiles(files, raiseError, Boolean(argv.f), Boolean(argv.backup), argv.c, argv.l);
|
|
156
|
+
return context && { ...context, strictNetwork: Boolean(argv.strictNetwork) || strictNetworkFromEnv(process.env) };
|
|
155
157
|
};
|
package/lib/mcp/server.js
CHANGED
|
File without changes
|
|
@@ -12,6 +12,10 @@ export declare const MulmoStudioContextMethods: {
|
|
|
12
12
|
getImageDirPath(context: MulmoStudioContext): string;
|
|
13
13
|
getImageProjectDirPath(context: MulmoStudioContext): string;
|
|
14
14
|
getOutDirPath(context: MulmoStudioContext): string;
|
|
15
|
+
getRenderNetworkOptions(context: MulmoStudioContext): {
|
|
16
|
+
strictNetwork: boolean;
|
|
17
|
+
allowedFileRoots: string[];
|
|
18
|
+
};
|
|
15
19
|
getFileName(context: MulmoStudioContext): string;
|
|
16
20
|
getCaption(context: MulmoStudioContext): string | undefined;
|
|
17
21
|
setSessionState(context: MulmoStudioContext, sessionType: SessionType, value: boolean, result?: boolean): void;
|
|
@@ -19,6 +23,8 @@ export declare const MulmoStudioContextMethods: {
|
|
|
19
23
|
needTranslate(context: MulmoStudioContext, includeCaption?: boolean): boolean | "" | undefined;
|
|
20
24
|
getIntroPadding(context: MulmoStudioContext): number;
|
|
21
25
|
getExtraPadding(context: MulmoStudioContext, index: number): number;
|
|
26
|
+
getBeatScreenStartAt(context: MulmoStudioContext, index: number): number;
|
|
27
|
+
getBeatScreenDuration(context: MulmoStudioContext, index: number): number;
|
|
22
28
|
getBeatDuration(context: MulmoStudioContext, index: number): number;
|
|
23
29
|
getVoiceOverGroupDuration(context: MulmoStudioContext, index: number): number;
|
|
24
30
|
isMovieBeat(context: MulmoStudioContext, studioBeat: MulmoStudioBeat, beat: MulmoBeat): boolean;
|
|
@@ -53,6 +53,13 @@ export const MulmoStudioContextMethods = {
|
|
|
53
53
|
getOutDirPath(context) {
|
|
54
54
|
return context.fileDirs.outDirPath;
|
|
55
55
|
},
|
|
56
|
+
// In strict network mode a renderer may read files only from the script's folder and mulmocast's output folders.
|
|
57
|
+
// A script loaded by URL has no local folder: its mulmoFileDirPath is the parent of the base directory.
|
|
58
|
+
getRenderNetworkOptions(context) {
|
|
59
|
+
const { mulmoFileDirPath, outDirPath, imageDirPath, audioDirPath, isHttpPath } = context.fileDirs;
|
|
60
|
+
const scriptRoots = isHttpPath ? [] : [mulmoFileDirPath];
|
|
61
|
+
return { strictNetwork: Boolean(context.strictNetwork), allowedFileRoots: [...scriptRoots, outDirPath, imageDirPath, audioDirPath] };
|
|
62
|
+
},
|
|
56
63
|
getFileName(context) {
|
|
57
64
|
return context.studio.filename;
|
|
58
65
|
},
|
|
@@ -103,6 +110,25 @@ export const MulmoStudioContextMethods = {
|
|
|
103
110
|
}
|
|
104
111
|
return 0;
|
|
105
112
|
},
|
|
113
|
+
// When this beat appears on screen, in seconds from the start of the movie. The first beat's segment
|
|
114
|
+
// starts at 0 and absorbs the intro padding; every later beat starts when its narration does.
|
|
115
|
+
getBeatScreenStartAt(context, index) {
|
|
116
|
+
if (index === 0) {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
return MulmoStudioContextMethods.getIntroPadding(context) + (context.studio.beats[index]?.startAt ?? 0);
|
|
120
|
+
},
|
|
121
|
+
// How long this beat stays on screen from getBeatScreenStartAt: its whole segment, including the
|
|
122
|
+
// voice_over beats that share its shot. A voice_over beat itself is counted for its own slice only.
|
|
123
|
+
// The audio ends outroPadding after the last beat; getExtraPadding gives a single-beat script only
|
|
124
|
+
// the intro padding, so its outro is added here.
|
|
125
|
+
getBeatScreenDuration(context, index) {
|
|
126
|
+
if (MulmoBeatMethods.isVoiceOver(context.studio.script.beats[index])) {
|
|
127
|
+
return MulmoStudioContextMethods.getBeatDuration(context, index);
|
|
128
|
+
}
|
|
129
|
+
const singleBeatOutro = context.studio.beats.length === 1 ? context.presentationStyle.audioParams.outroPadding : 0;
|
|
130
|
+
return MulmoStudioContextMethods.getSegmentDuration(context, index) + singleBeatOutro;
|
|
131
|
+
},
|
|
106
132
|
// The duration this beat occupies on the audio timeline.
|
|
107
133
|
getBeatDuration(context, index) {
|
|
108
134
|
return (context.studio.beats[index]?.duration ?? 0) + MulmoStudioContextMethods.getExtraPadding(context, index);
|
|
@@ -233,13 +233,10 @@ export declare const ProviderName: {
|
|
|
233
233
|
export declare const isImageProvider: (provider: string | undefined) => provider is keyof typeof provider2ImageAgent;
|
|
234
234
|
export declare const getMaxImageReferenceImages: (provider: string | undefined, model: string) => number | undefined;
|
|
235
235
|
export declare const getModelAudio: (provider: keyof typeof provider2MovieAgent, model: string) => MovieAudioSpec | undefined;
|
|
236
|
+
export declare const unsupportedGoogleMovieModelMessage: (model: string) => string | null;
|
|
236
237
|
export declare const defaultMovieModel: (provider: keyof typeof provider2MovieAgent, movieParams?: {
|
|
237
238
|
vertexai_project?: string;
|
|
238
239
|
}) => string;
|
|
239
|
-
export declare const VEO_EXTENSION_MODEL = "veo-3.1-generate-preview";
|
|
240
|
-
export declare const VEO_SEGMENT_SEC = 8;
|
|
241
|
-
export declare const veoExtensionCount: (requestedSec: number) => number;
|
|
242
|
-
export declare const veoExtendedSeconds: (requestedSec: number) => number;
|
|
243
240
|
export declare const getModelDuration: (provider: keyof typeof provider2MovieAgent, model: string, movieDuration?: number) => number | undefined;
|
|
244
241
|
export type ModelPricing = {
|
|
245
242
|
unit: "tokens" | "chars" | "seconds" | "images";
|
|
@@ -383,20 +383,21 @@ export const provider2MovieAgent = {
|
|
|
383
383
|
},
|
|
384
384
|
google: {
|
|
385
385
|
agentName: "movieGenAIAgent",
|
|
386
|
-
//
|
|
387
|
-
defaultModel: "
|
|
388
|
-
models: ["
|
|
386
|
+
// The Veo 3.1 previews shut down on the Gemini API on 2026-10-22 (removed here ahead of that); Gemini Omni is their successor.
|
|
387
|
+
defaultModel: "gemini-omni-1.1-flash",
|
|
388
|
+
models: ["gemini-omni-1.1-flash", "veo-3.1-generate-001"],
|
|
389
389
|
keyName: "GEMINI_API_KEY",
|
|
390
390
|
modelParams: {
|
|
391
|
-
|
|
392
|
-
|
|
391
|
+
// Gemini API only. Longer beats are extended, up to 40 seconds (see gemini_omni_video.ts).
|
|
392
|
+
"gemini-omni-1.1-flash": {
|
|
393
|
+
durations: [3, 4, 5, 6, 7, 8, 9, 10],
|
|
393
394
|
supportsDuration: true,
|
|
394
395
|
supportsLastFrame: true,
|
|
395
396
|
supportsReferenceImages: false,
|
|
396
397
|
supportsPersonGeneration: false,
|
|
397
398
|
audio: { mode: AUDIO_MODE_ALWAYS },
|
|
398
399
|
},
|
|
399
|
-
// Vertex AI only: the GA name of Veo 3.1 there
|
|
400
|
+
// Vertex AI only: the GA name of Veo 3.1 there.
|
|
400
401
|
"veo-3.1-generate-001": {
|
|
401
402
|
durations: [4, 6, 8],
|
|
402
403
|
supportsDuration: true,
|
|
@@ -405,14 +406,6 @@ export const provider2MovieAgent = {
|
|
|
405
406
|
supportsPersonGeneration: false,
|
|
406
407
|
audio: { mode: AUDIO_MODE_ALWAYS },
|
|
407
408
|
},
|
|
408
|
-
"veo-3.1-generate-preview": {
|
|
409
|
-
durations: [4, 6, 8],
|
|
410
|
-
supportsDuration: true,
|
|
411
|
-
supportsLastFrame: true,
|
|
412
|
-
supportsReferenceImages: true,
|
|
413
|
-
supportsPersonGeneration: false,
|
|
414
|
-
audio: { mode: AUDIO_MODE_ALWAYS },
|
|
415
|
-
},
|
|
416
409
|
},
|
|
417
410
|
},
|
|
418
411
|
mock: {
|
|
@@ -585,15 +578,26 @@ export const getModelAudio = (provider, model) => {
|
|
|
585
578
|
return modelParams?.[model]?.audio;
|
|
586
579
|
};
|
|
587
580
|
const GOOGLE_VERTEX_DEFAULT_MOVIE_MODEL = "veo-3.1-generate-001";
|
|
588
|
-
|
|
581
|
+
const removedGoogleMovieModelHint = "Its Gemini API shutdown date is 2026-10-22, so mulmocast no longer offers it. Use 'gemini-omni-1.1-flash' on the Gemini API, or 'veo-3.1-generate-001' with movieParams.vertexai_project on Vertex AI (needed for referenceImages).";
|
|
582
|
+
const removedGoogleMovieModelHints = {
|
|
583
|
+
"veo-3.1-generate-preview": removedGoogleMovieModelHint,
|
|
584
|
+
"veo-3.1-lite-generate-preview": removedGoogleMovieModelHint,
|
|
585
|
+
};
|
|
586
|
+
// An unknown model fails here with a hint, not with a TypeError in the duration lookup.
|
|
587
|
+
export const unsupportedGoogleMovieModelMessage = (model) => {
|
|
588
|
+
if (Object.hasOwn(provider2MovieAgent.google.modelParams, model))
|
|
589
|
+
return null;
|
|
590
|
+
const hint = Object.hasOwn(removedGoogleMovieModelHints, model)
|
|
591
|
+
? removedGoogleMovieModelHints[model]
|
|
592
|
+
: `Supported models: ${provider2MovieAgent.google.models.join(", ")}.`;
|
|
593
|
+
return `Google movie model "${model}" is not supported. ${hint}`;
|
|
594
|
+
};
|
|
595
|
+
// Gemini Omni is a Gemini API model here, so Vertex AI keeps Veo 3.1 as its default.
|
|
589
596
|
export const defaultMovieModel = (provider, movieParams) => provider === "google" && movieParams?.vertexai_project ? GOOGLE_VERTEX_DEFAULT_MOVIE_MODEL : provider2MovieAgent[provider].defaultModel;
|
|
590
|
-
// Veo 3.1 (preview) builds clips longer than one segment by extending an 8-second first segment 8 seconds at a time.
|
|
591
|
-
export const VEO_EXTENSION_MODEL = "veo-3.1-generate-preview";
|
|
592
|
-
export const VEO_SEGMENT_SEC = 8;
|
|
593
|
-
export const veoExtensionCount = (requestedSec) => Math.ceil((requestedSec - VEO_SEGMENT_SEC) / VEO_SEGMENT_SEC);
|
|
594
|
-
export const veoExtendedSeconds = (requestedSec) => VEO_SEGMENT_SEC * (1 + veoExtensionCount(requestedSec));
|
|
595
597
|
export const getModelDuration = (provider, model, movieDuration) => {
|
|
596
598
|
const modelParams = provider2MovieAgent[provider]?.modelParams;
|
|
599
|
+
if (!modelParams || !Object.hasOwn(modelParams, model))
|
|
600
|
+
return undefined;
|
|
597
601
|
const { durations } = modelParams[model];
|
|
598
602
|
if (durations && movieDuration) {
|
|
599
603
|
const largerDurations = durations.filter((d) => d >= movieDuration);
|
|
@@ -656,12 +660,11 @@ export const modelPricing = {
|
|
|
656
660
|
"gemini-2.5-flash-image": { unit: "images", inputPerMTokensUSD: 0.3, perImageUSD: 0.039, asOf: "2026-07-03" },
|
|
657
661
|
// $0.0336 per 1K-resolution image
|
|
658
662
|
"gemini-3.1-flash-lite-image": { unit: "images", inputPerMTokensUSD: 0.25, perImageUSD: 0.0336, asOf: "2026-10-07" },
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
"
|
|
663
|
+
// Video per generated second at 720p. Shut-down models (veo-2.0, veo-3.0, the Veo 3.1 previews) intentionally have no price.
|
|
664
|
+
// Gemini Omni bills video output tokens at $17.50/1M, 5,792 tokens per second of 720p video.
|
|
665
|
+
"gemini-omni-1.1-flash": { unit: "seconds", perSecUSD: 0.1014, asOf: "2026-10-07" },
|
|
662
666
|
// Vertex AI: https://cloud.google.com/vertex-ai/generative-ai/pricing (video + audio, 720p/1080p)
|
|
663
667
|
"veo-3.1-generate-001": { unit: "seconds", perSecUSD: 0.4, asOf: "2026-10-07" },
|
|
664
|
-
"veo-3.1-lite-generate-preview": { unit: "seconds", perSecUSD: 0.05, asOf: "2026-07-03" },
|
|
665
668
|
// Google Cloud Text-to-Speech per 1M characters, keyed by voice tier.
|
|
666
669
|
// https://cloud.google.com/text-to-speech/pricing
|
|
667
670
|
"tts-standard": { unit: "chars", perMCharsUSD: 4, asOf: "2026-07-03" },
|