mulmocast 2.17.0 → 2.18.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.
Files changed (43) hide show
  1. package/README.md +110 -38
  2. package/lib/actions/captions.js +1 -1
  3. package/lib/actions/image_agents.d.ts +1 -0
  4. package/lib/actions/image_agents.js +2 -2
  5. package/lib/actions/images.d.ts +2 -0
  6. package/lib/actions/images.js +1 -0
  7. package/lib/actions/pdf.js +23 -2
  8. package/lib/agents/movie_genai_agent.d.ts +24 -0
  9. package/lib/agents/movie_genai_agent.js +56 -43
  10. package/lib/cli/commands/bundle/builder.d.ts +2 -0
  11. package/lib/cli/commands/html/builder.d.ts +2 -0
  12. package/lib/cli/commands/markdown/builder.d.ts +2 -0
  13. package/lib/cli/commands/viewer/builder.d.ts +2 -0
  14. package/lib/cli/common.d.ts +2 -0
  15. package/lib/cli/common.js +5 -0
  16. package/lib/cli/helpers.js +3 -1
  17. package/lib/methods/mulmo_studio_context.d.ts +4 -0
  18. package/lib/methods/mulmo_studio_context.js +7 -0
  19. package/lib/types/provider2agent.d.ts +1 -4
  20. package/lib/types/provider2agent.js +27 -24
  21. package/lib/types/type.d.ts +2 -0
  22. package/lib/utils/estimate_usage.js +6 -5
  23. package/lib/utils/gemini_omni_video.d.ts +49 -0
  24. package/lib/utils/gemini_omni_video.js +36 -0
  25. package/lib/utils/html_render.d.ts +6 -5
  26. package/lib/utils/html_render.js +37 -30
  27. package/lib/utils/image_plugins/chart.js +2 -1
  28. package/lib/utils/image_plugins/html_tailwind.js +5 -4
  29. package/lib/utils/image_plugins/markdown.js +2 -1
  30. package/lib/utils/image_plugins/mermaid.js +2 -1
  31. package/lib/utils/image_plugins/slide.js +2 -1
  32. package/lib/utils/image_plugins/text_slide.js +2 -1
  33. package/lib/utils/image_plugins/vision.js +7 -0
  34. package/lib/utils/render_network_guard.d.ts +2 -0
  35. package/lib/utils/render_network_guard.js +36 -0
  36. package/lib/utils/render_network_policy.d.ts +15 -0
  37. package/lib/utils/render_network_policy.js +85 -0
  38. package/package.json +1 -1
  39. package/scripts/test/test_genai.json +10 -9
  40. package/scripts/test/test_genai_movie.json +1 -1
  41. package/scripts/test/test_image_references.json +3 -2
  42. package/scripts/test/test_movie_references.json +2 -1
  43. package/scripts/test/test_veo31_lite.json +3 -3
@@ -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, veoExtensionCount, VEO_EXTENSION_MODEL, VEO_SEGMENT_SEC, } from "../types/provider2agent.js";
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 createVeo31Payload = (model, prompt, aspectRatio, source) => ({
82
- model,
83
- prompt,
84
- config: {
85
- aspectRatio,
86
- resolution: "720p",
87
- numberOfVideos: 1,
88
- },
89
- ...source,
90
- });
91
- const generateExtendedVideo = async (ai, model, prompt, aspectRatio, imagePath, requestedDuration, movieFile, isVertexAI) => {
92
- const extensionsNeeded = veoExtensionCount(requestedDuration);
93
- GraphAILogger.info(`Veo 3.1 video extension: ${extensionsNeeded} extensions needed for ${requestedDuration}s target`);
94
- const generateIteration = async (iteration, accumulatedDuration, previousVideo) => {
95
- const isInitial = iteration === 0;
96
- const remainingDuration = requestedDuration - accumulatedDuration;
97
- const extensionDuration = isInitial ? VEO_SEGMENT_SEC : (getModelDuration("google", model, remainingDuration) ?? VEO_SEGMENT_SEC);
98
- const getSource = () => {
99
- if (isInitial)
100
- return imagePath ? { image: loadImageAsBase64(imagePath) } : undefined;
101
- return previousVideo?.uri ? { video: { uri: previousVideo.uri } } : undefined;
102
- };
103
- const payload = createVeo31Payload(model, prompt, aspectRatio, getSource());
104
- GraphAILogger.info(isInitial ? "Generating initial 8s video..." : `Extending video: iteration ${iteration}/${extensionsNeeded} (+${extensionDuration}s)...`);
105
- const operation = await ai.models.generateVideos(payload);
106
- const response = await pollUntilDone(ai, operation);
107
- const video = getVideoFromResponse(response, iteration);
108
- const totalDuration = accumulatedDuration + extensionDuration;
109
- GraphAILogger.info(`Video ${isInitial ? "generated" : "extended"}: ~${totalDuration}s total`);
110
- return { video, duration: totalDuration };
111
- };
112
- const result = await Array.from({ length: extensionsNeeded + 1 }).reduce(async (prev, _, index) => {
113
- const { video, duration } = await prev;
114
- return generateIteration(index, duration, video);
115
- }, Promise.resolve({ video: undefined, duration: 0 }));
116
- if (!result.video) {
117
- throw new Error("Failed to generate extended video", {
118
- cause: agentInvalidResponseError("movieGenAIAgent", imageAction, movieFileTarget),
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
- return downloadVideo(ai, result.video, movieFile, isVertexAI, model);
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
- // Veo 3.1: Video extension mode for videos longer than 8s
212
- if (model === VEO_EXTENSION_MODEL && requestedDuration > VEO_SEGMENT_SEC && params.canvasSize) {
213
- return generateExtendedVideo(ai, model, prompt, aspectRatio, imagePath, requestedDuration, movieFile, isVertexAI);
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);
@@ -11,6 +11,8 @@ export declare const builder: (yargs: Argv) => Argv<{
11
11
  g: boolean;
12
12
  } & {
13
13
  backup: boolean;
14
+ } & {
15
+ "strict-network": boolean;
14
16
  } & {
15
17
  p: string | undefined;
16
18
  } & {
@@ -11,6 +11,8 @@ export declare const builder: (yargs: Argv) => Argv<{
11
11
  g: boolean;
12
12
  } & {
13
13
  backup: boolean;
14
+ } & {
15
+ "strict-network": boolean;
14
16
  } & {
15
17
  p: string | undefined;
16
18
  } & {
@@ -11,6 +11,8 @@ export declare const builder: (yargs: Argv) => Argv<{
11
11
  g: boolean;
12
12
  } & {
13
13
  backup: boolean;
14
+ } & {
15
+ "strict-network": boolean;
14
16
  } & {
15
17
  p: string | undefined;
16
18
  } & {
@@ -11,6 +11,8 @@ export declare const builder: (yargs: Argv) => Argv<{
11
11
  g: boolean;
12
12
  } & {
13
13
  backup: boolean;
14
+ } & {
15
+ "strict-network": boolean;
14
16
  } & {
15
17
  p: string | undefined;
16
18
  } & {
@@ -16,6 +16,8 @@ export declare const commonOptions: (yargs: Argv) => Argv<{
16
16
  g: boolean;
17
17
  } & {
18
18
  backup: boolean;
19
+ } & {
20
+ "strict-network": boolean;
19
21
  } & {
20
22
  p: string | undefined;
21
23
  } & {
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",
@@ -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
- return await initializeContextFromFiles(files, raiseError, Boolean(argv.f), Boolean(argv.backup), argv.c, argv.l);
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
  };
@@ -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;
@@ -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
  },
@@ -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
- // veo-2.0 and veo-3.0 were shut down on 2026-06-30; the Gemini API now lists only the Veo 3.1 previews.
387
- defaultModel: "veo-3.1-generate-preview",
388
- models: ["veo-3.1-generate-preview", "veo-3.1-lite-generate-preview", "veo-3.1-generate-001"],
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
- "veo-3.1-lite-generate-preview": {
392
- durations: [4, 6, 8],
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, where the preview IDs are retired.
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
- // The default model is shared by the Gemini API and Vertex AI, but Vertex retired the Veo 3.1 preview IDs.
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
- // Veo per second of generated video (720p). veo-2.0-generate-001 and veo-3.0-generate-001
660
- // were shut down on 2026-06-30, so they intentionally have no price.
661
- "veo-3.1-generate-preview": { unit: "seconds", perSecUSD: 0.4, asOf: "2026-07-03" },
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" },
@@ -77,6 +77,7 @@ export type MulmoStudioContext = {
77
77
  presentationStyle: MulmoPresentationStyle;
78
78
  multiLingual: MulmoStudioMultiLingualArray;
79
79
  usageCollector?: UsageCollectorAPI;
80
+ strictNetwork?: boolean;
80
81
  };
81
82
  export type ScriptingParams = {
82
83
  urls: string[];
@@ -162,6 +163,7 @@ export type InitOptions = {
162
163
  c?: string;
163
164
  p?: string;
164
165
  g?: boolean;
166
+ strictNetwork?: boolean;
165
167
  };
166
168
  export type PublicAPIArgs = {
167
169
  settings?: Record<string, string>;
@@ -4,11 +4,12 @@
4
4
  // assume every asset is generated fresh.
5
5
  import { Tiktoken } from "js-tiktoken/lite";
6
6
  import o200k_base from "js-tiktoken/ranks/o200k_base";
7
- import { provider2TTSAgent, provider2MovieAgent, gptImageOutputTokens, isGptImage25Model, getModelDuration, defaultMovieModel, veoExtendedSeconds, VEO_EXTENSION_MODEL, VEO_SEGMENT_SEC, getModelPricing, defaultProviders, } from "../types/provider2agent.js";
7
+ import { provider2TTSAgent, provider2MovieAgent, gptImageOutputTokens, isGptImage25Model, getModelDuration, defaultMovieModel, getModelPricing, defaultProviders, } from "../types/provider2agent.js";
8
8
  import { text2SpeechProviderSchema, text2MovieProviderSchema } from "../types/schema.js";
9
9
  import { MulmoPresentationStyleMethods } from "../methods/mulmo_presentation_style.js";
10
10
  import { MulmoBeatMethods } from "../methods/mulmo_beat.js";
11
11
  import { geminiTtsInputText } from "./gemini_tts.js";
12
+ import { geminiOmniTotalSec, isGeminiOmniVideoModel } from "./gemini_omni_video.js";
12
13
  import { imagePrompt, htmlImageSystemPrompt, translateSystemPrompt, translatePrompts } from "./prompt.js";
13
14
  const MILLION = 1_000_000;
14
15
  const LATIN_CHARS_PER_TOKEN = 4;
@@ -217,8 +218,8 @@ const beatDurationMetric = (beat) => {
217
218
  }
218
219
  return estimated(beat.text ? estimateSpeechSec(beat.text) : DEFAULT_MOVIE_DURATION_SEC);
219
220
  };
220
- // The movie agent extends these clips past one segment instead of capping them, and Google bills every generated second.
221
- const isVeoExtension = (provider, model, duration) => provider === "google" && model === VEO_EXTENSION_MODEL && duration.value > VEO_SEGMENT_SEC;
221
+ // Gemini Omni beats past one segment are extended rather than capped, and Google bills every generated second.
222
+ const isGeminiOmniMovie = (provider, model) => provider === "google" && isGeminiOmniVideoModel(model);
222
223
  const snapMovieDuration = (provider, model, duration) => {
223
224
  if (!isKeyOf(provider2MovieAgent[provider].modelParams, model)) {
224
225
  return duration;
@@ -234,8 +235,8 @@ const estimateMovieGeneration = (style, beat, beatIndex, refKey) => {
234
235
  }
235
236
  const model = info.movieParams?.model ?? defaultMovieModel(provider, info.movieParams);
236
237
  const duration = beat ? beatDurationMetric(beat) : estimated(DEFAULT_MOVIE_DURATION_SEC);
237
- const predictSec = isVeoExtension(provider, model, duration)
238
- ? metric(veoExtendedSeconds(duration.value), duration.precision === "exact")
238
+ const predictSec = isGeminiOmniMovie(provider, model)
239
+ ? metric(geminiOmniTotalSec(duration.value), duration.precision === "exact")
239
240
  : snapMovieDuration(provider, model, duration);
240
241
  return attachCost({ process: refKey === undefined ? "movie" : "movieReference", beatIndex, refKey, provider, model, predictSec });
241
242
  };
@@ -0,0 +1,49 @@
1
+ export declare const GEMINI_OMNI_MIN_SEGMENT_SEC = 3;
2
+ export declare const GEMINI_OMNI_MAX_SEGMENT_SEC = 10;
3
+ export declare const GEMINI_OMNI_MAX_TOTAL_SEC = 40;
4
+ export declare const GEMINI_OMNI_RESOLUTION = "720p";
5
+ export declare const isGeminiOmniVideoModel: (model: string) => boolean;
6
+ export declare const geminiOmniTotalSec: (requestedSec: number) => number;
7
+ export declare const geminiOmniSegments: (requestedSec: number) => number[];
8
+ export declare const geminiOmniAspectRatio: (canvasSize: {
9
+ width: number;
10
+ height: number;
11
+ }) => string;
12
+ export type GeminiOmniImage = {
13
+ data: string;
14
+ mime_type: string;
15
+ };
16
+ export type GeminiOmniVideoRequestInput = {
17
+ model: string;
18
+ prompt: string;
19
+ aspectRatio: string;
20
+ durationSec: number;
21
+ frames?: GeminiOmniImage[];
22
+ previousInteractionId?: string;
23
+ };
24
+ export declare const geminiOmniExtensionPrompt: (prompt: string) => string;
25
+ export declare const buildGeminiOmniVideoRequest: ({ model, prompt, aspectRatio, durationSec, frames, previousInteractionId }: GeminiOmniVideoRequestInput) => {
26
+ response_format: {
27
+ type: "video";
28
+ aspect_ratio: string;
29
+ resolution: string;
30
+ duration: string;
31
+ };
32
+ previous_interaction_id?: string | undefined;
33
+ model: string;
34
+ input: string | ({
35
+ data: string;
36
+ mime_type: string;
37
+ type: "image";
38
+ } | {
39
+ type: "text";
40
+ text: string;
41
+ })[];
42
+ };
43
+ export type GeminiOmniVideoRequest = ReturnType<typeof buildGeminiOmniVideoRequest>;
44
+ type InteractionVideo = {
45
+ data?: string;
46
+ mime_type?: string;
47
+ };
48
+ export declare const videoFromInteraction: (video: InteractionVideo | undefined) => Buffer;
49
+ export {};
@@ -0,0 +1,36 @@
1
+ import { getAspectRatio } from "./utils.js";
2
+ // Gemini Omni video goes through the Interactions API: one call makes 3 to 10 whole seconds, and a call that
3
+ // names the previous interaction extends that video, up to 40 seconds in all.
4
+ export const GEMINI_OMNI_MIN_SEGMENT_SEC = 3;
5
+ export const GEMINI_OMNI_MAX_SEGMENT_SEC = 10;
6
+ export const GEMINI_OMNI_MAX_TOTAL_SEC = 40;
7
+ export const GEMINI_OMNI_RESOLUTION = "720p";
8
+ const GEMINI_OMNI_ASPECT_RATIOS = ["16:9", "9:16"];
9
+ export const isGeminiOmniVideoModel = (model) => model.startsWith("gemini-omni-");
10
+ export const geminiOmniTotalSec = (requestedSec) => {
11
+ if (!Number.isFinite(requestedSec))
12
+ throw new RangeError(`Invalid video duration: ${requestedSec}`);
13
+ return Math.min(Math.max(Math.ceil(requestedSec), GEMINI_OMNI_MIN_SEGMENT_SEC), GEMINI_OMNI_MAX_TOTAL_SEC);
14
+ };
15
+ // Split evenly so no segment falls under the 3-second minimum (11s is 6 + 5, not 10 + 1).
16
+ export const geminiOmniSegments = (requestedSec) => {
17
+ const totalSec = geminiOmniTotalSec(requestedSec);
18
+ const count = Math.ceil(totalSec / GEMINI_OMNI_MAX_SEGMENT_SEC);
19
+ const baseSec = Math.floor(totalSec / count);
20
+ return Array.from({ length: count }, (_, index) => baseSec + (index < totalSec % count ? 1 : 0));
21
+ };
22
+ export const geminiOmniAspectRatio = (canvasSize) => getAspectRatio(canvasSize, GEMINI_OMNI_ASPECT_RATIOS);
23
+ export const geminiOmniExtensionPrompt = (prompt) => `The scene continues: ${prompt}`;
24
+ export const buildGeminiOmniVideoRequest = ({ model, prompt, aspectRatio, durationSec, frames = [], previousInteractionId }) => ({
25
+ model,
26
+ input: frames.length > 0 ? [...frames.map((frame) => ({ type: "image", ...frame })), { type: "text", text: prompt }] : prompt,
27
+ ...(previousInteractionId ? { previous_interaction_id: previousInteractionId } : {}),
28
+ response_format: { type: "video", aspect_ratio: aspectRatio, resolution: GEMINI_OMNI_RESOLUTION, duration: `${durationSec}s` },
29
+ });
30
+ export const videoFromInteraction = (video) => {
31
+ if (!video?.data)
32
+ throw new Error("No video data returned");
33
+ if (video.mime_type !== "video/mp4")
34
+ throw new Error(`Gemini Omni returned ${video.mime_type ?? "video of an unknown type"}, expected video/mp4`);
35
+ return Buffer.from(video.data, "base64");
36
+ };
@@ -1,4 +1,5 @@
1
- export declare const renderHTMLToImage: (html: string, outputPath: string, width: number, height: number, isMermaid?: boolean, omitBackground?: boolean) => Promise<void>;
1
+ import { type RenderNetworkOptions } from "./render_network_policy.js";
2
+ export declare const renderHTMLToImage: (html: string, outputPath: string, width: number, height: number, isMermaid?: boolean, omitBackground?: boolean, network?: RenderNetworkOptions) => Promise<void>;
2
3
  /**
3
4
  * Render an animated HTML page frame-by-frame using Puppeteer.
4
5
  *
@@ -9,18 +10,18 @@ export declare const renderHTMLToImage: (html: string, outputPath: string, width
9
10
  *
10
11
  * The user-defined render() function may be sync or async.
11
12
  */
12
- export declare const renderHTMLToFrames: (html: string, outputDir: string, width: number, height: number, totalFrames: number, fps: number) => Promise<string[]>;
13
+ export declare const renderHTMLToFrames: (html: string, outputDir: string, width: number, height: number, totalFrames: number, fps: number, network?: RenderNetworkOptions) => Promise<string[]>;
13
14
  /**
14
15
  * Record an animated HTML page as video using Puppeteer's screencast API.
15
16
  * The animation plays in real-time via requestAnimationFrame, and
16
17
  * page.screencast() captures frames directly to an mp4 file.
17
18
  */
18
- export declare const renderHTMLToVideo: (html: string, videoPath: string, width: number, height: number, totalFrames: number, fps: number) => Promise<void>;
19
+ export declare const renderHTMLToVideo: (html: string, videoPath: string, width: number, height: number, totalFrames: number, fps: number, network?: RenderNetworkOptions) => Promise<void>;
19
20
  /**
20
21
  * Render the final frame of an animated HTML page as a static image.
21
22
  * Loads the animated HTML, calls window.renderFinal() to set all animations
22
23
  * to their end state, then takes a screenshot. Used for PDF/thumbnail generation.
23
24
  */
24
- export declare const renderHTMLToFinalFrame: (html: string, outputPath: string, width: number, height: number) => Promise<void>;
25
- export declare const renderMarkdownToImage: (markdown: string, style: string, outputPath: string, width: number, height: number) => Promise<void>;
25
+ export declare const renderHTMLToFinalFrame: (html: string, outputPath: string, width: number, height: number, network?: RenderNetworkOptions) => Promise<void>;
26
+ export declare const renderMarkdownToImage: (markdown: string, style: string, outputPath: string, width: number, height: number, network?: RenderNetworkOptions) => Promise<void>;
26
27
  export declare const interpolate: (template: string, data: Record<string, string>) => string;