mulmocast 2.15.0 → 2.17.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.
@@ -0,0 +1,48 @@
1
+ import { WEBRTC_REMOVAL_SCRIPT, isAllowedSceneRequest } from "./network_policy.js";
2
+ const isRecord = (value) => typeof value === "object" && value !== null;
3
+ const isPausedRequest = (value) => isRecord(value) && typeof value.requestId === "string" && isRecord(value.request) && typeof value.request.url === "string";
4
+ const isAttachedTarget = (value) => isRecord(value) && typeof value.sessionId === "string" && isRecord(value.targetInfo) && typeof value.targetInfo.type === "string";
5
+ // Remotion types only the CDP commands it uses itself; Fetch and Runtime.runIfWaitingForDebugger
6
+ // are sent through the same send(), which resolves on the reply and rejects on a CDP error.
7
+ const sendCommand = async (channel, method, params = {}) => {
8
+ const send = Reflect.get(channel, "send");
9
+ if (typeof send !== "function")
10
+ throw new Error(`remotion: cannot send ${method}: the browser connection has no send()`);
11
+ await send.call(channel, method, params);
12
+ };
13
+ const FRAME_TARGET_TYPES = ["page", "iframe"];
14
+ const answerRequest = async (connection, paused, serverPort, onBlocked) => {
15
+ if (isAllowedSceneRequest({ url: paused.request.url, isDocument: paused.resourceType === "Document" }, serverPort)) {
16
+ await sendCommand(connection, "Fetch.continueRequest", { requestId: paused.requestId });
17
+ return;
18
+ }
19
+ onBlocked(paused.request.url);
20
+ await sendCommand(connection, "Fetch.failRequest", { requestId: paused.requestId, errorReason: "BlockedByClient" });
21
+ };
22
+ // A target that could not be guarded stays paused, so its render fails instead of running unguarded.
23
+ const guardTarget = async (browser, attached) => {
24
+ const session = browser.connection.session(attached.sessionId);
25
+ if (!session)
26
+ throw new Error(`remotion: no CDP session for a new ${attached.targetInfo.type}`);
27
+ if (FRAME_TARGET_TYPES.includes(attached.targetInfo.type)) {
28
+ await session.send("Page.enable");
29
+ await session.send("Page.addScriptToEvaluateOnNewDocument", { source: WEBRTC_REMOVAL_SCRIPT });
30
+ }
31
+ await sendCommand(session, "Runtime.runIfWaitingForDebugger");
32
+ };
33
+ export const guardSceneNetwork = async (browser, serverPort, onBlocked) => {
34
+ const guard = { failures: [] };
35
+ const fail = (error) => guard.failures.push(error instanceof Error ? error.message : String(error));
36
+ const connection = browser.connection;
37
+ connection.on("Fetch.requestPaused", (event) => {
38
+ if (isPausedRequest(event))
39
+ answerRequest(connection, event, serverPort, onBlocked).catch(fail);
40
+ });
41
+ connection.on("Target.attachedToTarget", (event) => {
42
+ if (isAttachedTarget(event))
43
+ guardTarget(browser, event).catch(fail);
44
+ });
45
+ await sendCommand(connection, "Fetch.enable", { patterns: [{ urlPattern: "*" }] });
46
+ await connection.send("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
47
+ return guard;
48
+ };
@@ -0,0 +1,10 @@
1
+ export type SceneRequest = {
2
+ url: string;
3
+ isDocument: boolean;
4
+ };
5
+ export declare const isAllowedSceneRequest: ({ url, isDocument }: SceneRequest, serverPort: number) => boolean;
6
+ export declare const isAllowedMediaDownload: (src: string) => boolean;
7
+ export declare const REMOTION_CONTENT_SECURITY_POLICY = "connect-src 'self' data: blob:";
8
+ export declare const withContentSecurityPolicy: (html: string) => string;
9
+ export declare const WEBRTC_GLOBALS: readonly ["RTCPeerConnection", "webkitRTCPeerConnection", "RTCDataChannel", "RTCIceCandidate", "RTCSessionDescription"];
10
+ export declare const WEBRTC_REMOVAL_SCRIPT: string;
@@ -0,0 +1,51 @@
1
+ // What a remotion scene's browser may reach: only the bundle server started for that render, and
2
+ // in-memory URLs. Kept free of I/O so the rules can be tested without a browser.
3
+ const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
4
+ const IN_MEMORY_SCHEMES = ["data:", "blob:"];
5
+ const parseUrl = (url) => {
6
+ try {
7
+ return new URL(url);
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ };
13
+ // Remotion's /proxy makes the bundle server download its `src` from Node, on the scene's behalf.
14
+ const REMOTION_PROXY_PATH = "/proxy";
15
+ // The only page that carries the CSP; any other HTML the server returns (a directory listing, a 404)
16
+ // would run without it, so no other path may be loaded as a document.
17
+ const SCENE_DOCUMENT_PATHS = ["/", "/index.html"];
18
+ // Another port on localhost is another service, so only the render's own port counts as local.
19
+ const isBundleServer = (parsed, serverPort) => parsed.protocol === "http:" && LOOPBACK_HOSTS.includes(parsed.hostname) && parsed.port === String(serverPort);
20
+ export const isAllowedSceneRequest = ({ url, isDocument }, serverPort) => {
21
+ const parsed = parseUrl(url);
22
+ if (!parsed)
23
+ return false;
24
+ if (IN_MEMORY_SCHEMES.includes(parsed.protocol))
25
+ return true;
26
+ if (!isBundleServer(parsed, serverPort) || parsed.pathname.startsWith(REMOTION_PROXY_PATH))
27
+ return false;
28
+ return !isDocument || SCENE_DOCUMENT_PATHS.includes(parsed.pathname);
29
+ };
30
+ // Remotion downloads <Audio> / <Video> sources from Node while rendering, outside the browser;
31
+ // a scene uses no media files, so only inline data survives.
32
+ export const isAllowedMediaDownload = (src) => parseUrl(src)?.protocol === "data:";
33
+ // The browser-level request block does not see WebSocket or EventSource connections; this does.
34
+ export const REMOTION_CONTENT_SECURITY_POLICY = "connect-src 'self' data: blob:";
35
+ const HEAD_TAG = /<head(\s[^>]*)?>/i;
36
+ export const withContentSecurityPolicy = (html) => {
37
+ const match = HEAD_TAG.exec(html);
38
+ if (!match)
39
+ throw new Error("remotion: the bundle's index.html has no <head>, so the network policy cannot be applied");
40
+ const meta = `<meta http-equiv="Content-Security-Policy" content="${REMOTION_CONTENT_SECURITY_POLICY}">`;
41
+ const insertAt = match.index + match[0].length;
42
+ return [html.slice(0, insertAt), meta, html.slice(insertAt)].join("");
43
+ };
44
+ export const WEBRTC_GLOBALS = ["RTCPeerConnection", "webkitRTCPeerConnection", "RTCDataChannel", "RTCIceCandidate", "RTCSessionDescription"];
45
+ // Run before any page script in every frame: WebRTC opens UDP connections that neither the request
46
+ // block nor CSP covers. Non-configurable, so scene code cannot put the constructors back.
47
+ export const WEBRTC_REMOVAL_SCRIPT = [
48
+ `for (const name of ${JSON.stringify(WEBRTC_GLOBALS)}) {`,
49
+ " try { Object.defineProperty(globalThis, name, { value: undefined, writable: false, configurable: false }); } catch (_) {}",
50
+ "}",
51
+ ].join("\n");
@@ -1,10 +1,15 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import net from "node:net";
3
4
  import { createRequire } from "node:module";
5
+ import { GraphAILogger } from "graphai";
4
6
  import { REMOTION_COMPOSITION_ID, REMOTION_ENTRY_FILE, buildEntrySource } from "./claude_prompt.js";
5
7
  import { missingRemotionPackages, remotionInstallCommand } from "./packages.js";
8
+ import { isAllowedMediaDownload, withContentSecurityPolicy } from "./network_policy.js";
9
+ import { guardSceneNetwork } from "./network_guard.js";
6
10
  // WebGL (three.js scenes) needs the ANGLE backend; the default one cannot create a context headless.
7
11
  const CHROMIUM_OPTIONS = { gl: "angle" };
12
+ const MAX_LOGGED_URL_LENGTH = 200;
8
13
  const loadRemotion = async () => {
9
14
  try {
10
15
  const [bundler, renderer] = await Promise.all([import("@remotion/bundler"), import("@remotion/renderer")]);
@@ -40,8 +45,7 @@ const remotionNodeModulesDir = () => {
40
45
  const remotionPackageJson = createRequire(import.meta.url).resolve("remotion/package.json");
41
46
  return path.dirname(path.dirname(remotionPackageJson));
42
47
  };
43
- export const renderRemotionScene = async ({ workDir, props, videoPath, stillPath, extraStills = [] }) => {
44
- const { bundle, renderer } = await loadRemotion();
48
+ const bundleScene = async (bundle, workDir) => {
45
49
  const entryPoint = path.join(workDir, REMOTION_ENTRY_FILE);
46
50
  fs.writeFileSync(entryPoint, buildEntrySource());
47
51
  const nodeModulesDir = remotionNodeModulesDir();
@@ -53,13 +57,53 @@ export const renderRemotionScene = async ({ workDir, props, videoPath, stillPath
53
57
  resolve: { ...config.resolve, modules: [...(config.resolve?.modules ?? ["node_modules"]), nodeModulesDir] },
54
58
  }),
55
59
  });
56
- const composition = await renderer.selectComposition({ serveUrl, id: REMOTION_COMPOSITION_ID, inputProps: props, chromiumOptions: CHROMIUM_OPTIONS });
60
+ const indexHtml = path.join(serveUrl, "index.html");
61
+ fs.writeFileSync(indexHtml, withContentSecurityPolicy(fs.readFileSync(indexHtml, "utf8")));
62
+ return serveUrl;
63
+ };
64
+ const findFreePort = () => new Promise((resolve, reject) => {
65
+ const server = net.createServer();
66
+ server.once("error", reject);
67
+ server.listen(0, "127.0.0.1", () => {
68
+ const address = server.address();
69
+ const port = typeof address === "object" && address ? address.port : 0;
70
+ server.close(() => (port > 0 ? resolve(port) : reject(new Error("remotion: could not pick a port for the bundle server"))));
71
+ });
72
+ });
73
+ const logBlockedRequest = (url) => GraphAILogger.info(`remotion: blocked a network request from the scene: ${url}`);
74
+ // Throwing here stops remotion before it fetches, and fails the render.
75
+ const rejectMediaDownload = (src) => {
76
+ if (!isAllowedMediaDownload(src))
77
+ throw new Error(`remotion: a scene may not load media files (${src.slice(0, MAX_LOGGED_URL_LENGTH)})`);
78
+ return undefined;
79
+ };
80
+ // The bundle server listens on the port given here, the only local origin the guard lets the scene reach.
81
+ const renderOutputs = async (renderer, browser, serveUrl, port, outputs) => {
82
+ const { props, videoPath, stillPath, extraStills = [] } = outputs;
83
+ const shared = { serveUrl, inputProps: props, chromiumOptions: CHROMIUM_OPTIONS, puppeteerInstance: browser, port };
84
+ const downloadGuard = { onDownload: rejectMediaDownload };
85
+ const composition = await renderer.selectComposition({ ...shared, id: REMOTION_COMPOSITION_ID });
57
86
  if (videoPath) {
58
- await renderer.renderMedia({ composition, serveUrl, codec: "h264", outputLocation: videoPath, inputProps: props, chromiumOptions: CHROMIUM_OPTIONS });
87
+ await renderer.renderMedia({ ...shared, ...downloadGuard, composition, codec: "h264", outputLocation: videoPath });
59
88
  }
60
89
  const stills = stillPath ? [...extraStills, { frame: composition.durationInFrames - 1, path: stillPath }] : extraStills;
61
90
  await stills.reduce(async (previous, still) => {
62
91
  await previous;
63
- await renderer.renderStill({ composition, serveUrl, output: still.path, frame: still.frame, inputProps: props, chromiumOptions: CHROMIUM_OPTIONS });
92
+ await renderer.renderStill({ ...shared, ...downloadGuard, composition, output: still.path, frame: still.frame });
64
93
  }, Promise.resolve());
65
94
  };
95
+ export const renderRemotionScene = async ({ workDir, ...outputs }) => {
96
+ const { bundle, renderer } = await loadRemotion();
97
+ const serveUrl = await bundleScene(bundle, workDir);
98
+ const port = await findFreePort();
99
+ const browser = await renderer.openBrowser("chrome", { chromiumOptions: CHROMIUM_OPTIONS });
100
+ try {
101
+ const guard = await guardSceneNetwork(browser, port, logBlockedRequest);
102
+ await renderOutputs(renderer, browser, serveUrl, port, outputs);
103
+ if (guard.failures.length > 0)
104
+ throw new Error(`remotion: the network guard failed: ${guard.failures.join("; ")}`);
105
+ }
106
+ finally {
107
+ await browser.close({ silent: true });
108
+ }
109
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmocast",
3
- "version": "2.15.0",
3
+ "version": "2.17.0",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "lib/index.node.js",
@@ -99,7 +99,7 @@
99
99
  "homepage": "https://github.com/receptron/mulmocast-cli#readme",
100
100
  "dependencies": {
101
101
  "@google-cloud/text-to-speech": "^6.4.1",
102
- "@google/genai": "^2.17.1",
102
+ "@google/genai": "^2.24.0",
103
103
  "@graphai/anthropic_agent": "^2.1.0",
104
104
  "@graphai/browserless_agent": "^2.0.4",
105
105
  "@graphai/gemini_agent": "^2.1.0",
@@ -13,7 +13,7 @@
13
13
  "title": "[TITLE: Brief, engaging title for the topic]",
14
14
  "htmlImageParams": {
15
15
  "provider": "anthropic",
16
- "model": "claude-sonnet-4-5-20250929"
16
+ "model": "claude-sonnet-4-6"
17
17
  },
18
18
  "lang": "en",
19
19
  "beats": [
@@ -5,7 +5,7 @@
5
5
  },
6
6
  "htmlImageParams": {
7
7
  "provider": "anthropic",
8
- "model": "claude-sonnet-4-5-20250929"
8
+ "model": "claude-sonnet-4-6"
9
9
  },
10
10
  "title": "Sample Title",
11
11
  "references": [
@@ -59,7 +59,7 @@
59
59
  "text": "movie generated by veo3",
60
60
  "moviePrompt": "a woman is walking through a busy Tokyo street at night, she is wearing dark sunglasses",
61
61
  "movieParams": {
62
- "model": "veo-3.0-generate-001"
62
+ "model": "veo-3.1-lite-generate-preview"
63
63
  }
64
64
  },
65
65
  {
@@ -80,7 +80,7 @@
80
80
  },
81
81
  "moviePrompt": "a woman is walking through a busy Tokyo street at night, she is wearing dark sunglasses",
82
82
  "movieParams": {
83
- "model": "veo-3.0-generate-001"
83
+ "model": "veo-3.1-lite-generate-preview"
84
84
  }
85
85
  }
86
86
  ]
@@ -7,7 +7,7 @@
7
7
  "title": "Testing OpenAI's new Image Generation",
8
8
  "description": "Hello",
9
9
  "imageParams": {
10
- "model": "gpt-image-1",
10
+ "model": "gpt-image-2.5-sunburst",
11
11
  "style": "<style>Japanese animation with soft watercolor backgrounds, characters with simple rounded faces, large expressive eyes, small nose and mouth, soft jawlines, minimalist facial features, pastel color palette, detailed natural environments, whimsical magical elements, hand-drawn aesthetic, gentle lighting, flowing movement in hair and clothing, nostalgic countryside scenery with fantasy elements."
12
12
  },
13
13
  "speechParams": {
@@ -96,12 +96,12 @@
96
96
  }
97
97
  },
98
98
  {
99
- "id": "kling-v1.6-pro",
100
- "text": "kwaivgi/kling-v1.6-pro with gpt4-image",
99
+ "id": "kling-v2.1-start-image",
100
+ "text": "kwaivgi/kling-v2.1 with gpt4-image",
101
101
  "imagePrompt": "a woman is walking through a busy Tokyo street at night, she is wearing dark sunglasses",
102
102
  "moviePrompt": "a woman is walking through a busy Tokyo street at night, she is wearing dark sunglasses",
103
103
  "movieParams": {
104
- "model": "kwaivgi/kling-v1.6-pro"
104
+ "model": "kwaivgi/kling-v2.1"
105
105
  }
106
106
  },
107
107
  {
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "movieParams": {
26
26
  "provider": "google",
27
- "model": "veo-2.0-generate-001",
27
+ "model": "veo-3.1-generate-001",
28
28
  "vertexai_project": "YOUR_PROJECT_ID",
29
29
  "vertexai_location": "us-central1"
30
30
  },
@@ -76,7 +76,7 @@
76
76
  "text": "TTS: Gemini 2.5 Pro TTS Kore. Movie: Veo 3 override.",
77
77
  "moviePrompt": "Ocean waves crashing on a beach at sunset",
78
78
  "movieParams": {
79
- "model": "veo-3.0-generate-001",
79
+ "model": "veo-3.1-generate-001",
80
80
  "vertexai_project": "YOUR_PROJECT_ID",
81
81
  "vertexai_location": "us-central1"
82
82
  }
@@ -101,7 +101,7 @@
101
101
  },
102
102
  "moviePrompt": "The robot turns its head and waves at the camera",
103
103
  "movieParams": {
104
- "model": "veo-3.0-generate-001",
104
+ "model": "veo-3.1-generate-001",
105
105
  "vertexai_project": "YOUR_PROJECT_ID",
106
106
  "vertexai_location": "us-central1"
107
107
  },