mulmocast 2.16.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 (51) 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/image_references.d.ts +2 -0
  6. package/lib/actions/image_references.js +3 -1
  7. package/lib/actions/images.d.ts +2 -0
  8. package/lib/actions/images.js +1 -0
  9. package/lib/actions/pdf.js +23 -2
  10. package/lib/agents/image_openai_agent.js +4 -3
  11. package/lib/agents/movie_genai_agent.d.ts +24 -0
  12. package/lib/agents/movie_genai_agent.js +56 -43
  13. package/lib/agents/tts_gemini_agent.d.ts +56 -0
  14. package/lib/agents/tts_gemini_agent.js +43 -38
  15. package/lib/cli/commands/bundle/builder.d.ts +2 -0
  16. package/lib/cli/commands/html/builder.d.ts +2 -0
  17. package/lib/cli/commands/markdown/builder.d.ts +2 -0
  18. package/lib/cli/commands/viewer/builder.d.ts +2 -0
  19. package/lib/cli/common.d.ts +2 -0
  20. package/lib/cli/common.js +5 -0
  21. package/lib/cli/helpers.js +3 -1
  22. package/lib/methods/mulmo_studio_context.d.ts +4 -0
  23. package/lib/methods/mulmo_studio_context.js +7 -0
  24. package/lib/types/provider2agent.d.ts +3 -4
  25. package/lib/types/provider2agent.js +38 -26
  26. package/lib/types/schema.d.ts +1 -1
  27. package/lib/types/type.d.ts +2 -0
  28. package/lib/utils/estimate_usage.js +12 -11
  29. package/lib/utils/gemini_omni_video.d.ts +49 -0
  30. package/lib/utils/gemini_omni_video.js +36 -0
  31. package/lib/utils/gemini_tts.d.ts +45 -0
  32. package/lib/utils/gemini_tts.js +35 -0
  33. package/lib/utils/html_render.d.ts +6 -5
  34. package/lib/utils/html_render.js +37 -30
  35. package/lib/utils/image_plugins/chart.js +2 -1
  36. package/lib/utils/image_plugins/html_tailwind.js +5 -4
  37. package/lib/utils/image_plugins/markdown.js +2 -1
  38. package/lib/utils/image_plugins/mermaid.js +2 -1
  39. package/lib/utils/image_plugins/slide.js +2 -1
  40. package/lib/utils/image_plugins/text_slide.js +2 -1
  41. package/lib/utils/image_plugins/vision.js +7 -0
  42. package/lib/utils/render_network_guard.d.ts +2 -0
  43. package/lib/utils/render_network_guard.js +36 -0
  44. package/lib/utils/render_network_policy.d.ts +15 -0
  45. package/lib/utils/render_network_policy.js +85 -0
  46. package/package.json +2 -2
  47. package/scripts/test/test_genai.json +10 -9
  48. package/scripts/test/test_genai_movie.json +1 -1
  49. package/scripts/test/test_image_references.json +3 -2
  50. package/scripts/test/test_movie_references.json +2 -1
  51. package/scripts/test/test_veo31_lite.json +3 -3
@@ -4,10 +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, 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
+ import { geminiTtsInputText } from "./gemini_tts.js";
12
+ import { geminiOmniTotalSec, isGeminiOmniVideoModel } from "./gemini_omni_video.js";
11
13
  import { imagePrompt, htmlImageSystemPrompt, translateSystemPrompt, translatePrompts } from "./prompt.js";
12
14
  const MILLION = 1_000_000;
13
15
  const LATIN_CHARS_PER_TOKEN = 4;
@@ -92,15 +94,14 @@ const buildOpenAITts = ({ speaker, text, textIsFinal, beatIndex, lang }) => {
92
94
  return { process: "tts", beatIndex, lang, provider: "openai", model, inputChars: metric(text.length, textIsFinal), ...tokenMetrics };
93
95
  };
94
96
  const buildGeminiTts = ({ speaker, speechOptions, text, beatIndex, lang }) => {
95
- // The gemini TTS agent wraps the transcript in a "Director's Notes" prompt when an instruction is set.
96
- const prompt = speechOptions?.instruction ? `### DIRECTOR'S NOTES\n${speechOptions.instruction}\n\n#### TRANSCRIPT\n${text}` : text;
97
+ const model = speaker.model ?? provider2TTSAgent.gemini.defaultModel;
97
98
  return {
98
99
  process: "tts",
99
100
  beatIndex,
100
101
  lang,
101
102
  provider: "gemini",
102
- model: speaker.model ?? provider2TTSAgent.gemini.defaultModel,
103
- inputTokens: estimated(countHeuristicTokens(prompt)),
103
+ model,
104
+ inputTokens: estimated(countHeuristicTokens(geminiTtsInputText(model, text, speechOptions?.instruction))),
104
105
  outputTokens: estimated(estimateSpeechSec(text) * GEMINI_TTS_AUDIO_TOKENS_PER_SEC),
105
106
  };
106
107
  };
@@ -162,8 +163,8 @@ const gptImageSize = (canvasSize) => {
162
163
  const gptImageOutputMetric = (model, canvasSize, quality) => {
163
164
  const table = gptImageOutputTokens[gptImageSize(canvasSize)];
164
165
  const knownQuality = quality === "low" || quality === "medium" || quality === "high" ? quality : undefined;
165
- // Unspecified quality means API "auto"; assume "high" as the conservative upper bound.
166
- const tokens = table[knownQuality ?? "high"];
166
+ // Anything else renders at "high" (the upper bound), except an explicit "auto" on 2.5, which renders at "low".
167
+ const tokens = table[knownQuality ?? (quality === "auto" && isGptImage25Model(model) ? "low" : "high")];
167
168
  return metric(tokens, GPT_IMAGE_FIXED_TOKEN_MODELS.includes(model) && knownQuality !== undefined);
168
169
  };
169
170
  const buildImageRecord = ({ process, provider, model, prompt, canvasSize, quality, beatIndex, refKey }) => {
@@ -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
+ };
@@ -0,0 +1,45 @@
1
+ export declare const GEMINI_INTERACTIONS_TTS_MODELS: ReadonlySet<string>;
2
+ export declare const GEMINI_TTS_SAMPLE_RATE = 24000;
3
+ export declare const geminiDirectorsNotesPrompt: (text: string, instructions?: string) => string;
4
+ export declare const geminiTtsInputText: (model: string, text: string, instructions?: string) => string;
5
+ export declare const usesGeminiInteractionsTts: (model: string) => boolean;
6
+ export type GeminiInteractionsTtsInput = {
7
+ model: string;
8
+ text: string;
9
+ voice: string;
10
+ instructions?: string;
11
+ };
12
+ export declare const buildGeminiInteractionsTtsRequest: ({ model, text, voice, instructions }: GeminiInteractionsTtsInput) => {
13
+ model: string;
14
+ input: {
15
+ type: "user_input";
16
+ content: {
17
+ annotations?: {
18
+ type: "speech_metadata";
19
+ style: string;
20
+ }[] | undefined;
21
+ type: "text";
22
+ text: string;
23
+ }[];
24
+ }[];
25
+ response_format: {
26
+ type: "audio";
27
+ mime_type: string;
28
+ sample_rate: number;
29
+ };
30
+ generation_config: {
31
+ speech_config: {
32
+ voice: string;
33
+ }[];
34
+ };
35
+ };
36
+ type InteractionAudio = {
37
+ data?: string;
38
+ mime_type?: string;
39
+ sample_rate?: number;
40
+ };
41
+ export declare const pcmFromInteractionAudio: (audio: InteractionAudio | undefined) => {
42
+ rawPcm: Buffer;
43
+ sampleRate: number;
44
+ };
45
+ export {};
@@ -0,0 +1,35 @@
1
+ // Gemini 3.8 TTS takes the Interactions API: the text is read verbatim, so speaker direction goes in a
2
+ // speech_metadata annotation instead of the prompt, and raw PCM must be asked for (the unary default is WAV).
3
+ export const GEMINI_INTERACTIONS_TTS_MODELS = new Set(["gemini-3.8-flash-tts", "gemini-3.8-flash-lite-tts"]);
4
+ export const GEMINI_TTS_SAMPLE_RATE = 24000;
5
+ // 2.5 TTS has no field for direction, so it goes in the prompt: https://ai.google.dev/gemini-api/docs/speech-generation#controllable
6
+ export const geminiDirectorsNotesPrompt = (text, instructions) => instructions ? ["### DIRECTOR'S NOTES", instructions, "", "#### TRANSCRIPT", text].join("\n") : text;
7
+ // The text the model is given for one line: 3.8 gets the transcript plus the style annotation, 2.5 the wrapped prompt.
8
+ export const geminiTtsInputText = (model, text, instructions) => usesGeminiInteractionsTts(model) ? [text, instructions].filter(Boolean).join("\n") : geminiDirectorsNotesPrompt(text, instructions);
9
+ export const usesGeminiInteractionsTts = (model) => GEMINI_INTERACTIONS_TTS_MODELS.has(model);
10
+ export const buildGeminiInteractionsTtsRequest = ({ model, text, voice, instructions }) => ({
11
+ model,
12
+ input: [
13
+ {
14
+ type: "user_input",
15
+ content: [{ type: "text", text, ...(instructions ? { annotations: [{ type: "speech_metadata", style: instructions }] } : {}) }],
16
+ },
17
+ ],
18
+ response_format: { type: "audio", mime_type: "audio/l16", sample_rate: GEMINI_TTS_SAMPLE_RATE },
19
+ generation_config: { speech_config: [{ voice }] },
20
+ });
21
+ const sampleRateOf = (audio) => {
22
+ if (typeof audio.sample_rate === "number" && audio.sample_rate > 0)
23
+ return audio.sample_rate;
24
+ const match = audio.mime_type?.match(/rate=(\d+)/);
25
+ return match ? Number(match[1]) : GEMINI_TTS_SAMPLE_RATE;
26
+ };
27
+ // Anything but headerless 16-bit PCM would be fed to the PCM encoder as noise, so it is an error, not a guess.
28
+ export const pcmFromInteractionAudio = (audio) => {
29
+ if (!audio?.data)
30
+ throw new Error("No audio data returned");
31
+ if (!audio.mime_type?.toLowerCase().startsWith("audio/l16")) {
32
+ throw new Error(`Gemini TTS returned ${audio.mime_type ?? "audio of an unknown type"}, expected audio/l16`);
33
+ }
34
+ return { rawPcm: Buffer.from(audio.data, "base64"), sampleRate: sampleRateOf(audio) };
35
+ };
@@ -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;
@@ -4,6 +4,8 @@ import nodePath from "node:path";
4
4
  import crypto from "node:crypto";
5
5
  import { marked } from "marked";
6
6
  import puppeteer from "puppeteer";
7
+ import { guardRenderPage } from "./render_network_guard.js";
8
+ import { strictNetworkLaunchArgs, withRenderContentSecurityPolicy } from "./render_network_policy.js";
7
9
  import { GraphAILogger } from "graphai";
8
10
  const isCI = process.env.CI === "true";
9
11
  const VIDEO_LOAD_TIMEOUT_MS = 15000;
@@ -83,15 +85,16 @@ const resolveWaitUntil = (html) => {
83
85
  * and navigate via page.goto (setContent uses about:blank origin
84
86
  * which blocks file:// loading).
85
87
  */
86
- const loadHtmlIntoPage = async (page, html, timeout_ms) => {
88
+ const renderTempPagePath = () => nodePath.join(os.tmpdir(), `mulmocast_render_${crypto.randomUUID()}.html`);
89
+ const loadHtmlIntoPage = async (page, html, timeout_ms, forcedFilePath) => {
87
90
  const waitUntil = resolveWaitUntil(html);
88
91
  const hasFileUrls = /file:\/\//.test(html);
89
92
  const hasExternalScripts = /script src=["']https?:\/\//.test(html);
90
93
  // puppeteer's setContent does not accept "networkidle0" (no navigation, so network-idle states aren't meaningful in types).
91
94
  // Route any HTML that needs network-idle semantics through the page.goto path to preserve the original wait behavior.
92
95
  const needsNetworkIdle = waitUntil === "networkidle0";
93
- if (hasFileUrls || hasExternalScripts || needsNetworkIdle) {
94
- const tmpFile = nodePath.join(os.tmpdir(), `mulmocast_render_${crypto.randomUUID()}.html`);
96
+ if (forcedFilePath || hasFileUrls || hasExternalScripts || needsNetworkIdle) {
97
+ const tmpFile = forcedFilePath ?? renderTempPagePath();
95
98
  fs.writeFileSync(tmpFile, html);
96
99
  try {
97
100
  await page.goto(`file://${tmpFile}`, { waitUntil, timeout: timeout_ms });
@@ -109,14 +112,28 @@ const loadHtmlIntoPage = async (page, html, timeout_ms) => {
109
112
  await page.setContent(html, { waitUntil, timeout: timeout_ms });
110
113
  }
111
114
  };
112
- export const renderHTMLToImage = async (html, outputPath, width, height, isMermaid = false, omitBackground = false) => {
115
+ const RENDER_LOAD_TIMEOUT_MS = 30000;
116
+ const renderLaunchArgs = ({ strictNetwork = false }) => [
117
+ ...(isCI ? ["--no-sandbox"] : []),
118
+ "--allow-file-access-from-files",
119
+ ...(strictNetwork ? strictNetworkLaunchArgs() : []),
120
+ ];
121
+ // Strict mode always loads through a file: the guard's WebRTC removal never reaches a setContent document.
122
+ const loadGuardedPage = async (page, html, allowedFileRoots) => {
123
+ const pagePath = renderTempPagePath();
124
+ await guardRenderPage(page, [pagePath, ...allowedFileRoots]);
125
+ await loadHtmlIntoPage(page, withRenderContentSecurityPolicy(html), RENDER_LOAD_TIMEOUT_MS, pagePath);
126
+ };
127
+ const openRenderPage = async (browser, html, { strictNetwork = false, allowedFileRoots = [] }) => {
128
+ const page = await browser.newPage();
129
+ await (strictNetwork ? loadGuardedPage(page, html, allowedFileRoots) : loadHtmlIntoPage(page, html, RENDER_LOAD_TIMEOUT_MS));
130
+ return page;
131
+ };
132
+ export const renderHTMLToImage = async (html, outputPath, width, height, isMermaid = false, omitBackground = false, network = {}) => {
113
133
  // Use Puppeteer to render HTML to an image
114
- const browser = await puppeteer.launch({
115
- args: isCI ? ["--no-sandbox", "--allow-file-access-from-files"] : ["--allow-file-access-from-files"],
116
- });
134
+ const browser = await puppeteer.launch({ args: renderLaunchArgs(network) });
117
135
  try {
118
- const page = await browser.newPage();
119
- await loadHtmlIntoPage(page, html, 30000);
136
+ const page = await openRenderPage(browser, html, network);
120
137
  // Adjust page settings if needed (like width, height, etc.)
121
138
  await page.setViewport({ width, height });
122
139
  // height:100% ensures background fills viewport; only reset html, let body styles come from custom CSS
@@ -158,14 +175,10 @@ export const renderHTMLToImage = async (html, outputPath, width, height, isMerma
158
175
  *
159
176
  * The user-defined render() function may be sync or async.
160
177
  */
161
- export const renderHTMLToFrames = async (html, outputDir, width, height, totalFrames, fps) => {
162
- const browser = await puppeteer.launch({
163
- args: isCI ? ["--no-sandbox", "--allow-file-access-from-files"] : ["--allow-file-access-from-files"],
164
- });
178
+ export const renderHTMLToFrames = async (html, outputDir, width, height, totalFrames, fps, network = {}) => {
179
+ const browser = await puppeteer.launch({ args: renderLaunchArgs(network) });
165
180
  try {
166
- const page = await browser.newPage();
167
- // Wait for Tailwind CSS CDN to load
168
- await loadHtmlIntoPage(page, html, 30000);
181
+ const page = await openRenderPage(browser, html, network);
169
182
  await page.setViewport({ width, height });
170
183
  await page.addStyleTag({ content: "html{height:100%;margin:0;padding:0;overflow:hidden}" });
171
184
  // Scale content to fit viewport (same logic as renderHTMLToImage)
@@ -199,14 +212,11 @@ export const renderHTMLToFrames = async (html, outputDir, width, height, totalFr
199
212
  * The animation plays in real-time via requestAnimationFrame, and
200
213
  * page.screencast() captures frames directly to an mp4 file.
201
214
  */
202
- export const renderHTMLToVideo = async (html, videoPath, width, height, totalFrames, fps) => {
215
+ export const renderHTMLToVideo = async (html, videoPath, width, height, totalFrames, fps, network = {}) => {
203
216
  const duration_ms = (totalFrames / fps) * 1000;
204
- const browser = await puppeteer.launch({
205
- args: isCI ? ["--no-sandbox", "--allow-file-access-from-files"] : ["--allow-file-access-from-files"],
206
- });
217
+ const browser = await puppeteer.launch({ args: renderLaunchArgs(network) });
207
218
  try {
208
- const page = await browser.newPage();
209
- await loadHtmlIntoPage(page, html, 30000);
219
+ const page = await openRenderPage(browser, html, network);
210
220
  await page.setViewport({ width, height });
211
221
  await page.addStyleTag({ content: "html{height:100%;margin:0;padding:0;overflow:hidden}" });
212
222
  await scaleContentToFit(page, width, height);
@@ -242,13 +252,10 @@ export const renderHTMLToVideo = async (html, videoPath, width, height, totalFra
242
252
  * Loads the animated HTML, calls window.renderFinal() to set all animations
243
253
  * to their end state, then takes a screenshot. Used for PDF/thumbnail generation.
244
254
  */
245
- export const renderHTMLToFinalFrame = async (html, outputPath, width, height) => {
246
- const browser = await puppeteer.launch({
247
- args: isCI ? ["--no-sandbox", "--allow-file-access-from-files"] : ["--allow-file-access-from-files"],
248
- });
255
+ export const renderHTMLToFinalFrame = async (html, outputPath, width, height, network = {}) => {
256
+ const browser = await puppeteer.launch({ args: renderLaunchArgs(network) });
249
257
  try {
250
- const page = await browser.newPage();
251
- await loadHtmlIntoPage(page, html, 30000);
258
+ const page = await openRenderPage(browser, html, network);
252
259
  await page.setViewport({ width, height });
253
260
  await page.addStyleTag({ content: "html{height:100%;margin:0;padding:0;overflow:hidden}" });
254
261
  await scaleContentToFit(page, width, height);
@@ -266,11 +273,11 @@ export const renderHTMLToFinalFrame = async (html, outputPath, width, height) =>
266
273
  await browser.close();
267
274
  }
268
275
  };
269
- export const renderMarkdownToImage = async (markdown, style, outputPath, width, height) => {
276
+ export const renderMarkdownToImage = async (markdown, style, outputPath, width, height, network = {}) => {
270
277
  const header = `<head><style>${style}</style></head>`;
271
278
  const body = await marked(markdown);
272
279
  const html = `<html>${header}<body>${body}</body></html>`;
273
- await renderHTMLToImage(html, outputPath, width, height);
280
+ await renderHTMLToImage(html, outputPath, width, height, false, false, network);
274
281
  };
275
282
  export const interpolate = (template, data) => {
276
283
  return template.replace(/\$\{(.*?)\}/g, (_, key) => data[key.trim()] ?? "");
@@ -3,6 +3,7 @@ import { renderHTMLToImage, interpolate } from "../html_render.js";
3
3
  import { parrotingImagePath, generateUniqueId } from "./utils.js";
4
4
  import { resolveCombinedStyle } from "./bg_image_util.js";
5
5
  import { chartHtml, escapedChartTemplateValues, resolveChartPlugins, stringifyChartData } from "./chart_html.js";
6
+ import { MulmoStudioContextMethods } from "../../methods/mulmo_studio_context.js";
6
7
  export const imageType = "chart";
7
8
  const processChart = async (params) => {
8
9
  const { beat, imagePath, canvasSize } = params;
@@ -19,7 +20,7 @@ const processChart = async (params) => {
19
20
  chart_width: chart_width.toString(),
20
21
  chart_plugins: resolveChartPlugins(chartType),
21
22
  });
22
- await renderHTMLToImage(htmlData, imagePath, canvasSize.width, canvasSize.height);
23
+ await renderHTMLToImage(htmlData, imagePath, canvasSize.width, canvasSize.height, false, false, MulmoStudioContextMethods.getRenderNetworkOptions(params.context));
23
24
  return imagePath;
24
25
  };
25
26
  const dumpHtml = async (params) => {
@@ -7,6 +7,7 @@ import { framesToVideo } from "../ffmpeg_utils.js";
7
7
  import { parrotingImagePath } from "./utils.js";
8
8
  import { swipeElementsToHtml, swipeElementsToScript } from "../swipe_to_html.js";
9
9
  import { htmlTailwindMarkup } from "./html_tailwind_markup.js";
10
+ import { MulmoStudioContextMethods } from "../../methods/mulmo_studio_context.js";
10
11
  export const imageType = "html_tailwind";
11
12
  /**
12
13
  * Resolve image:name references to file:// absolute paths using imageRefs.
@@ -178,13 +179,13 @@ const processHtmlTailwindAnimated = async (params) => {
178
179
  // imagePath is set to the .mp4 path by imagePluginAgent for animated beats
179
180
  const videoPath = imagePath;
180
181
  if (animConfig.movie) {
181
- await renderHTMLToVideo(htmlData, videoPath, canvasSize.width, canvasSize.height, totalFrames, fps);
182
+ await renderHTMLToVideo(htmlData, videoPath, canvasSize.width, canvasSize.height, totalFrames, fps, MulmoStudioContextMethods.getRenderNetworkOptions(params.context));
182
183
  }
183
184
  else {
184
185
  const framesDir = videoPath.replace(/\.[^/.]+$/, "_frames");
185
186
  fs.mkdirSync(framesDir, { recursive: true });
186
187
  try {
187
- await renderHTMLToFrames(htmlData, framesDir, canvasSize.width, canvasSize.height, totalFrames, fps);
188
+ await renderHTMLToFrames(htmlData, framesDir, canvasSize.width, canvasSize.height, totalFrames, fps, MulmoStudioContextMethods.getRenderNetworkOptions(params.context));
188
189
  await framesToVideo(framesDir, videoPath, fps, canvasSize.width, canvasSize.height);
189
190
  }
190
191
  finally {
@@ -197,7 +198,7 @@ const processHtmlTailwindAnimated = async (params) => {
197
198
  // even when exact duration is unknown (e.g., PDF generation without audio).
198
199
  const finalFramePath = imagePath.replace(/_animated\.mp4$/, ".png");
199
200
  const finalHtml = buildAnimatedHtml(params, FINAL_FRAME_TOTAL, fps);
200
- await renderHTMLToFinalFrame(finalHtml, finalFramePath, canvasSize.width, canvasSize.height);
201
+ await renderHTMLToFinalFrame(finalHtml, finalFramePath, canvasSize.width, canvasSize.height, MulmoStudioContextMethods.getRenderNetworkOptions(params.context));
201
202
  // Return video path when video was generated, otherwise return the static PNG path
202
203
  return duration !== undefined ? imagePath : finalFramePath;
203
204
  };
@@ -216,7 +217,7 @@ const processHtmlTailwindStatic = async (params) => {
216
217
  const resolvedAllRefs = resolveMovieRefs(resolvedImageRefs, params.movieRefs ?? {});
217
218
  const resolvedImages = resolveRelativeImagePaths(resolvedAllRefs, context.fileDirs.mulmoFileDirPath);
218
219
  const htmlData = resolveRelativeModelPathsInScript(resolvedImages, context.fileDirs.mulmoFileDirPath);
219
- await renderHTMLToImage(htmlData, imagePath, canvasSize.width, canvasSize.height);
220
+ await renderHTMLToImage(htmlData, imagePath, canvasSize.width, canvasSize.height, false, false, MulmoStudioContextMethods.getRenderNetworkOptions(params.context));
220
221
  return imagePath;
221
222
  };
222
223
  const processHtmlTailwind = async (params) => {
@@ -5,6 +5,7 @@ import { resolveCombinedStyle } from "./bg_image_util.js";
5
5
  import { renderMarkdownContent, renderMarkdownLayout, layoutToMarkdown, toMarkdownString } from "./markdown_layout.js";
6
6
  import { resolveImageRefs, resolveMovieRefs } from "./html_tailwind.js";
7
7
  import { isObject } from "graphai";
8
+ import { MulmoStudioContextMethods } from "../../methods/mulmo_studio_context.js";
8
9
  /**
9
10
  * This path renders once to a PNG, so a random element id is fine. The browser path needs
10
11
  * a stable one and supplies its own — that generator is the only thing the two callers of
@@ -77,7 +78,7 @@ const processMarkdown = async (params) => {
77
78
  const { html: rawHtml, hasMermaid } = await generateHtml(params);
78
79
  const resolvedImages = resolveImageRefs(rawHtml, params.imageRefs ?? {});
79
80
  const html = resolveMovieRefs(resolvedImages, params.movieRefs ?? {});
80
- await renderHTMLToImage(html, imagePath, canvasSize.width, canvasSize.height, hasMermaid);
81
+ await renderHTMLToImage(html, imagePath, canvasSize.width, canvasSize.height, hasMermaid, false, MulmoStudioContextMethods.getRenderNetworkOptions(params.context));
81
82
  return imagePath;
82
83
  };
83
84
  const dumpHtml = async (params) => {
@@ -5,6 +5,7 @@ import { parrotingImagePath, generateUniqueId } from "./utils.js";
5
5
  import { escapedMermaidTemplateValues, mermaidHtml } from "./mermaid_html.js";
6
6
  import { resolveCombinedStyle } from "./bg_image_util.js";
7
7
  import { resolveImageRefs, resolveMovieRefs, resolveRelativeImagePaths } from "./html_tailwind.js";
8
+ import { MulmoStudioContextMethods } from "../../methods/mulmo_studio_context.js";
8
9
  export const imageType = "mermaid";
9
10
  /** Mermaid markup for this path. The markup itself is shared with the browser path. */
10
11
  export const generateMermaidHtml = (code, title) => mermaidHtml(code, generateUniqueId("mermaid"), title);
@@ -23,7 +24,7 @@ const processMermaid = async (params) => {
23
24
  const resolvedImageRefs = resolveImageRefs(rawHtml, params.imageRefs ?? {});
24
25
  const resolvedAllRefs = resolveMovieRefs(resolvedImageRefs, params.movieRefs ?? {});
25
26
  const htmlData = resolveRelativeImagePaths(resolvedAllRefs, context.fileDirs.mulmoFileDirPath);
26
- await renderHTMLToImage(htmlData, imagePath, canvasSize.width, canvasSize.height, true);
27
+ await renderHTMLToImage(htmlData, imagePath, canvasSize.width, canvasSize.height, true, false, MulmoStudioContextMethods.getRenderNetworkOptions(context));
27
28
  }
28
29
  return imagePath;
29
30
  };
@@ -7,6 +7,7 @@ import { pathToDataUrl } from "../../methods/mulmo_media_source.js";
7
7
  import { MulmoMediaSourceMethods } from "../../methods/mulmo_media_source.js";
8
8
  import { MulmoPresentationStyleMethods } from "../../methods/mulmo_presentation_style.js";
9
9
  import { imageAction, imageFileTarget, unknownMediaType } from "../error_cause.js";
10
+ import { MulmoStudioContextMethods } from "../../methods/mulmo_studio_context.js";
10
11
  export const imageType = "slide";
11
12
  const slideImageRefError = (refKey) => ({
12
13
  type: unknownMediaType,
@@ -167,7 +168,7 @@ const processSlide = async (params) => {
167
168
  const reference = beat.image.reference;
168
169
  const resolvedBranding = await resolveAndConvertBranding(params);
169
170
  const html = generateSlideHTML(theme, slide, reference, resolvedBranding);
170
- await renderHTMLToImage(html, imagePath, canvasSize.width, canvasSize.height);
171
+ await renderHTMLToImage(html, imagePath, canvasSize.width, canvasSize.height, false, false, MulmoStudioContextMethods.getRenderNetworkOptions(params.context));
171
172
  return imagePath;
172
173
  };
173
174
  /**
@@ -2,6 +2,7 @@ import { renderMarkdownToImage } from "../html_render.js";
2
2
  import { parrotingImagePath } from "./utils.js";
3
3
  import { resolveCombinedStyle } from "./bg_image_util.js";
4
4
  import { marked } from "marked";
5
+ import { MulmoStudioContextMethods } from "../../methods/mulmo_studio_context.js";
5
6
  export const imageType = "textSlide";
6
7
  const processTextSlide = async (params) => {
7
8
  const { beat, imagePath, canvasSize } = params;
@@ -17,7 +18,7 @@ const processTextSlide = async (params) => {
17
18
  const marginTop = slide.subtitle ? canvasSize.height * 0.4 : canvasSize.height * 0.45;
18
19
  return `body {margin-top: ${marginTop}px;}`;
19
20
  })();
20
- await renderMarkdownToImage(markdown, combinedStyle + topMargin, imagePath, canvasSize.width, canvasSize.height);
21
+ await renderMarkdownToImage(markdown, combinedStyle + topMargin, imagePath, canvasSize.width, canvasSize.height, MulmoStudioContextMethods.getRenderNetworkOptions(params.context));
21
22
  return imagePath;
22
23
  };
23
24
  const dumpMarkdown = (params) => {
@@ -1,12 +1,19 @@
1
1
  import { parrotingImagePath } from "./utils.js";
2
2
  import { htmlPlugin, templateNameTofunctionName } from "mulmocast-vision";
3
3
  import { resolve as resolvePath } from "path";
4
+ import { agentGenerationError, imageAction, unsupportedImageTypeTarget } from "../error_cause.js";
4
5
  export const imageType = "vision";
5
6
  const processVision = async (params) => {
6
7
  const { beat, imagePath, context } = params;
7
8
  const rootDir = context.fileDirs.nodeModuleRootPath ? resolvePath(context.fileDirs.nodeModuleRootPath, "mulmocast-vision") : undefined;
8
9
  if (!beat?.image || beat.image.type !== imageType)
9
10
  return;
11
+ // mulmocast-vision launches its own browser, which the strict network guard cannot reach.
12
+ if (context.strictNetwork) {
13
+ throw new Error("vision beats cannot be rendered with --strict-network: mulmocast-vision renders in its own browser, outside the network guard", {
14
+ cause: agentGenerationError("visionPlugin", imageAction, unsupportedImageTypeTarget),
15
+ });
16
+ }
10
17
  const handler = new htmlPlugin({ rootDir });
11
18
  await handler.callNamedFunction(templateNameTofunctionName(beat.image.style), beat.image.data, {
12
19
  functionName: beat.image.style,
@@ -0,0 +1,2 @@
1
+ import type { Page } from "puppeteer";
2
+ export declare const guardRenderPage: (page: Page, allowedFileRoots: readonly string[]) => Promise<void>;
@@ -0,0 +1,36 @@
1
+ import fs from "node:fs";
2
+ import { GraphAILogger } from "graphai";
3
+ import { WEBRTC_REMOVAL_SCRIPT } from "./remotion/network_policy.js";
4
+ import { POPUP_BLOCK_SCRIPT, isAllowedRenderRequest, realRoot } from "./render_network_policy.js";
5
+ const fileRealPath = (filePath) => {
6
+ try {
7
+ return fs.realpathSync.native(filePath);
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ };
13
+ // Must run before the page loads anything. The WebRTC removal only reaches documents created after this call,
14
+ // which is why strict mode always navigates to a file instead of using setContent.
15
+ export const guardRenderPage = async (page, allowedFileRoots) => {
16
+ const realRoots = allowedFileRoots.map((root) => realRoot(root, fileRealPath));
17
+ const reported = new Set();
18
+ await page.setRequestInterception(true);
19
+ page.on("request", (request) => {
20
+ if (request.isInterceptResolutionHandled())
21
+ return;
22
+ const url = request.url();
23
+ // The page can close while a request is still pending; that rejection is not a failure of the render.
24
+ if (isAllowedRenderRequest(url, realRoots, fileRealPath)) {
25
+ request.continue().catch(() => undefined);
26
+ return;
27
+ }
28
+ if (!reported.has(url)) {
29
+ reported.add(url);
30
+ GraphAILogger.info(`strict network: blocked ${url}`);
31
+ }
32
+ request.abort("blockedbyclient").catch(() => undefined);
33
+ });
34
+ await page.evaluateOnNewDocument(WEBRTC_REMOVAL_SCRIPT);
35
+ await page.evaluateOnNewDocument(POPUP_BLOCK_SCRIPT);
36
+ };
@@ -0,0 +1,15 @@
1
+ export declare const RENDER_CDN_HOSTS: string[];
2
+ export declare const RENDER_JSDELIVR_PATHS: string[];
3
+ export type RenderNetworkOptions = {
4
+ strictNetwork?: boolean;
5
+ allowedFileRoots?: readonly string[];
6
+ };
7
+ export declare const STRICT_NETWORK_ENV = "MULMO_STRICT_NETWORK";
8
+ export declare const strictNetworkFromEnv: (env: Record<string, string | undefined>) => boolean;
9
+ export type RealPath = (filePath: string) => string | undefined;
10
+ export declare const isAllowedRenderRequest: (url: string, realRoots?: readonly string[], realPath?: RealPath) => boolean;
11
+ export declare const realRoot: (root: string, realPath: RealPath) => string;
12
+ export declare const strictNetworkLaunchArgs: () => string[];
13
+ export declare const POPUP_BLOCK_SCRIPT = "Object.defineProperty(globalThis, \"open\", { value: () => null, writable: false, configurable: false });";
14
+ export declare const RENDER_CONTENT_SECURITY_POLICY = "connect-src 'self' data: blob:";
15
+ export declare const withRenderContentSecurityPolicy: (html: string) => string;