mulmocast 2.12.1 → 2.13.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 (38) hide show
  1. package/assets/schemas/mulmo_script.json +231 -0
  2. package/lib/actions/avatar.d.ts +37 -0
  3. package/lib/actions/avatar.js +176 -0
  4. package/lib/actions/image_agents.js +9 -9
  5. package/lib/actions/index.d.ts +1 -0
  6. package/lib/actions/index.js +1 -0
  7. package/lib/actions/movie.d.ts +1 -0
  8. package/lib/actions/movie.js +40 -2
  9. package/lib/agents/combine_audio_files_agent.js +2 -2
  10. package/lib/cli/commands/movie/handler.js +2 -2
  11. package/lib/mcp/server.js +3 -3
  12. package/lib/methods/mulmo_beat.d.ts +1 -0
  13. package/lib/methods/mulmo_beat.js +3 -0
  14. package/lib/methods/mulmo_beat_node.d.ts +1 -0
  15. package/lib/methods/mulmo_presentation_style.d.ts +5 -1
  16. package/lib/methods/mulmo_presentation_style.js +8 -0
  17. package/lib/types/schema.d.ts +405 -0
  18. package/lib/types/schema.js +61 -0
  19. package/lib/types/type.d.ts +6 -1
  20. package/lib/utils/context.d.ts +158 -0
  21. package/lib/utils/image_plugins/index.js +2 -0
  22. package/lib/utils/image_plugins/remotion.d.ts +16 -0
  23. package/lib/utils/image_plugins/remotion.js +164 -0
  24. package/lib/utils/remotion/claude_prompt.d.ts +33 -0
  25. package/lib/utils/remotion/claude_prompt.js +111 -0
  26. package/lib/utils/remotion/claude_runner.d.ts +4 -0
  27. package/lib/utils/remotion/claude_runner.js +47 -0
  28. package/lib/utils/remotion/commit_files.d.ts +11 -0
  29. package/lib/utils/remotion/commit_files.js +37 -0
  30. package/lib/utils/remotion/packages.d.ts +5 -0
  31. package/lib/utils/remotion/packages.js +20 -0
  32. package/lib/utils/remotion/render.d.ts +19 -0
  33. package/lib/utils/remotion/render.js +63 -0
  34. package/lib/utils/remotion/system_prompt.d.ts +3 -0
  35. package/lib/utils/remotion/system_prompt.js +62 -0
  36. package/package.json +108 -20
  37. package/scripts/test/test_avatar.json +46 -0
  38. package/scripts/test/test_remotion.json +38 -0
@@ -0,0 +1,111 @@
1
+ import { createHash } from "node:crypto";
2
+ import { REMOTION_REVIEW_MARKER, REMOTION_SYSTEM_PROMPT } from "./system_prompt.js";
3
+ export const REMOTION_COMPONENT_FILE = "Generated.tsx";
4
+ export const REMOTION_ENTRY_FILE = "index.tsx";
5
+ export const REMOTION_COMPOSITION_ID = "Beat";
6
+ // No user/project settings: the reply depends only on these prompts, and hooks, plugins and MCP
7
+ // servers neither run nor add their context to every call. Writing needs no tools; review reads images,
8
+ // and --restricted keeps that Read inside the beat's work directory — a script's prompt cannot point it
9
+ // at other files.
10
+ export const buildClaudeArgs = (userPrompt, systemPrompt = REMOTION_SYSTEM_PROMPT, tools = "") => [
11
+ "-p",
12
+ userPrompt,
13
+ "--system-prompt",
14
+ systemPrompt,
15
+ "--tools",
16
+ tools,
17
+ ...(tools ? ["--allowedTools", tools] : []),
18
+ "--setting-sources",
19
+ "",
20
+ "--strict-mcp-config",
21
+ "--restricted",
22
+ "--output-format",
23
+ "json",
24
+ "--no-session-persistence",
25
+ ];
26
+ export const buildScenePrompt = (spec) => [
27
+ ...(spec.brief ? [`Art direction for the whole video (every scene follows it exactly, so they look like one film): ${spec.brief}`, ""] : []),
28
+ ...(spec.position ? [`This is scene ${spec.position.index + 1} of ${spec.position.count}.`, ""] : []),
29
+ spec.prompt,
30
+ "",
31
+ ...(spec.narration ? [`Narration spoken over this scene: ${spec.narration}`, ""] : []),
32
+ `Canvas: ${spec.width}x${spec.height} pixels at ${spec.fps} fps.`,
33
+ ].join("\n");
34
+ export const buildRepairPrompt = (spec, code, error) => [
35
+ buildScenePrompt(spec),
36
+ "",
37
+ "Your previous component failed to bundle or render. Fix it and reply with the whole corrected component.",
38
+ "",
39
+ "Error:",
40
+ error,
41
+ "",
42
+ "Previous component:",
43
+ "```tsx",
44
+ code,
45
+ "```",
46
+ ].join("\n");
47
+ export const buildReviewPrompt = (spec, code, frames) => [
48
+ buildScenePrompt(spec),
49
+ "",
50
+ "Rendered frames of your component (read each image file):",
51
+ ...frames.map((frame) => `- at ${Math.round(frame.fraction * 100)}% of the scene: ${frame.file}`),
52
+ "",
53
+ "Your component:",
54
+ "```tsx",
55
+ code,
56
+ "```",
57
+ ].join("\n");
58
+ // undefined means the reviewer approved the scene as it is.
59
+ export const parseReviewReply = (replyText) => {
60
+ if (replyText.trim() === REMOTION_REVIEW_MARKER)
61
+ return undefined;
62
+ return extractTsx(replyText);
63
+ };
64
+ export const remotionCacheKey = (spec, systemPrompt = REMOTION_SYSTEM_PROMPT) => createHash("sha256")
65
+ .update(JSON.stringify([spec.prompt, spec.narration ?? "", spec.brief ?? "", spec.position ?? null, spec.fps, spec.width, spec.height, systemPrompt]), "utf8")
66
+ .digest("hex");
67
+ const CODE_BLOCK_PATTERN = /```(?:tsx|typescript|ts|jsx)?[ \t]*\r?\n([\s\S]*?)```/;
68
+ export const extractTsx = (replyText) => {
69
+ const match = CODE_BLOCK_PATTERN.exec(replyText);
70
+ const code = match ? match[1] : replyText;
71
+ return code.includes("export default") ? code.trim() + "\n" : undefined;
72
+ };
73
+ const isClaudeJsonReply = (value) => typeof value === "object" && value !== null && "result" in value && typeof value.result === "string";
74
+ export const parseClaudeReply = (stdout) => {
75
+ const parsed = JSON.parse(stdout);
76
+ if (!isClaudeJsonReply(parsed)) {
77
+ throw new Error(`claude -p returned JSON without a "result" string: ${stdout.slice(0, 200)}`);
78
+ }
79
+ if (parsed.is_error) {
80
+ throw new Error(`claude -p reported an error: ${parsed.result}`);
81
+ }
82
+ return parsed;
83
+ };
84
+ export const buildEntrySource = () => [
85
+ 'import React from "react";',
86
+ 'import { Composition, registerRoot } from "remotion";',
87
+ `import Scene from "./${REMOTION_COMPONENT_FILE.replace(/\.tsx$/, "")}";`,
88
+ "",
89
+ "type SceneProps = { durationInFrames: number; fps: number; width: number; height: number };",
90
+ "",
91
+ "const Root: React.FC = () => (",
92
+ " <Composition",
93
+ ` id="${REMOTION_COMPOSITION_ID}"`,
94
+ " component={Scene}",
95
+ " durationInFrames={1}",
96
+ " fps={30}",
97
+ " width={1280}",
98
+ " height={720}",
99
+ " defaultProps={{ durationInFrames: 1, fps: 30, width: 1280, height: 720 }}",
100
+ " calculateMetadata={({ props }: { props: SceneProps }) => ({",
101
+ " durationInFrames: props.durationInFrames,",
102
+ " fps: props.fps,",
103
+ " width: props.width,",
104
+ " height: props.height,",
105
+ " })}",
106
+ " />",
107
+ ");",
108
+ "",
109
+ "registerRoot(Root);",
110
+ "",
111
+ ].join("\n");
@@ -0,0 +1,4 @@
1
+ export type ComponentWriter = (userPrompt: string, workDir: string) => Promise<string>;
2
+ export type ComponentReviewer = (userPrompt: string, workDir: string) => Promise<string | undefined>;
3
+ export declare const writeComponentWithClaude: ComponentWriter;
4
+ export declare const reviewComponentWithClaude: ComponentReviewer;
@@ -0,0 +1,47 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { buildClaudeArgs, extractTsx, parseClaudeReply, parseReviewReply } from "./claude_prompt.js";
4
+ import { REMOTION_REVIEW_SYSTEM_PROMPT, REMOTION_SYSTEM_PROMPT } from "./system_prompt.js";
5
+ const execFileAsync = promisify(execFile);
6
+ const CLAUDE_COMMAND = "claude";
7
+ const CLAUDE_TIMEOUT_MS = 10 * 60 * 1000;
8
+ const CLAUDE_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
9
+ const REVIEW_TOOLS = "Read";
10
+ const describeFailure = (error, workDir) => {
11
+ if (error instanceof Error && error.name === "AbortError") {
12
+ return new Error(`claude -p timed out after ${CLAUDE_TIMEOUT_MS}ms (workDir: ${workDir})`, { cause: error });
13
+ }
14
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
15
+ return new Error(`"${CLAUDE_COMMAND}" was not found. The remotion beat needs Claude Code installed and logged in.`, { cause: error });
16
+ }
17
+ const message = error instanceof Error ? error.message : String(error);
18
+ return new Error(`Running "${CLAUDE_COMMAND} -p" failed (workDir: ${workDir}): ${message}`, { cause: error });
19
+ };
20
+ // Run from the beat's work directory so no project CLAUDE.md is picked up and the reviewer can read
21
+ // the frames there; stdin is closed so `claude -p` does not wait for piped input.
22
+ const runClaude = async (args, workDir) => {
23
+ const controller = new AbortController();
24
+ const timeoutId = setTimeout(() => controller.abort(), CLAUDE_TIMEOUT_MS);
25
+ try {
26
+ const child = execFileAsync(CLAUDE_COMMAND, args, { cwd: workDir, signal: controller.signal, maxBuffer: CLAUDE_MAX_OUTPUT_BYTES });
27
+ child.child.stdin?.end();
28
+ const { stdout } = await child;
29
+ return parseClaudeReply(stdout).result;
30
+ }
31
+ catch (error) {
32
+ throw describeFailure(error, workDir);
33
+ }
34
+ finally {
35
+ clearTimeout(timeoutId);
36
+ }
37
+ };
38
+ export const writeComponentWithClaude = async (userPrompt, workDir) => {
39
+ const code = extractTsx(await runClaude(buildClaudeArgs(userPrompt, REMOTION_SYSTEM_PROMPT), workDir));
40
+ if (!code) {
41
+ throw new Error(`claude -p did not return a component with "export default" (workDir: ${workDir})`);
42
+ }
43
+ return code;
44
+ };
45
+ export const reviewComponentWithClaude = async (userPrompt, workDir) => {
46
+ return parseReviewReply(await runClaude(buildClaudeArgs(userPrompt, REMOTION_REVIEW_SYSTEM_PROMPT, REVIEW_TOOLS), workDir));
47
+ };
@@ -0,0 +1,11 @@
1
+ export type FileOps = {
2
+ exists: (file: string) => boolean;
3
+ rename: (from: string, to: string) => void;
4
+ remove: (file: string) => void;
5
+ };
6
+ export type FileMove = {
7
+ from: string;
8
+ to: string;
9
+ };
10
+ export declare const backupPathOf: (file: string) => string;
11
+ export declare const commitFiles: (moves: FileMove[], ops: FileOps) => void;
@@ -0,0 +1,37 @@
1
+ export const backupPathOf = (file) => `${file}.pre-review`;
2
+ // Moves every `from` onto its `to`, or none of them: on any failure the moves already made are undone
3
+ // and the files they replaced are put back, then the error is rethrown.
4
+ export const commitFiles = (moves, ops) => {
5
+ const undoSteps = [];
6
+ try {
7
+ moves.forEach(({ from, to }) => {
8
+ if (ops.exists(to)) {
9
+ ops.rename(to, backupPathOf(to));
10
+ undoSteps.push(() => ops.rename(backupPathOf(to), to));
11
+ }
12
+ ops.rename(from, to);
13
+ undoSteps.push(() => ops.rename(to, from));
14
+ });
15
+ }
16
+ catch (error) {
17
+ // Undo in reverse; one failing undo must not stop the others from restoring their files.
18
+ undoSteps.reverse().forEach((undo) => {
19
+ try {
20
+ undo();
21
+ }
22
+ catch {
23
+ // the original error below is what the caller needs to see
24
+ }
25
+ });
26
+ throw error;
27
+ }
28
+ // The moves are committed by now; a backup that cannot be removed is a leftover, not a failed commit.
29
+ moves.forEach(({ to }) => {
30
+ try {
31
+ ops.remove(backupPathOf(to));
32
+ }
33
+ catch {
34
+ // leave it; the next commit onto this path overwrites it
35
+ }
36
+ });
37
+ };
@@ -0,0 +1,5 @@
1
+ export declare const REMOTION_RENDER_PACKAGES: readonly ["remotion", "@remotion/bundler", "@remotion/renderer", "react", "react-dom"];
2
+ export declare const REMOTION_SCENE_PACKAGES: readonly ["@remotion/three", "three", "@react-three/fiber", "@remotion/effects", "@remotion/paths", "@remotion/noise", "@remotion/shapes", "@remotion/transitions", "@remotion/motion-blur", "@remotion/layout-utils"];
3
+ export declare const REMOTION_PACKAGES: readonly string[];
4
+ export declare const remotionInstallCommand: (packages?: readonly string[]) => string;
5
+ export declare const missingRemotionPackages: (isInstalled: (name: string) => boolean, packages?: readonly string[]) => string[];
@@ -0,0 +1,20 @@
1
+ // Every package the renderer needs or the generated component may import. They are optional peer
2
+ // dependencies of mulmocast, so a host installs them itself; one list keeps the install hint, the
3
+ // pre-flight check and the system prompt's import allowance from drifting apart.
4
+ export const REMOTION_RENDER_PACKAGES = ["remotion", "@remotion/bundler", "@remotion/renderer", "react", "react-dom"];
5
+ export const REMOTION_SCENE_PACKAGES = [
6
+ "@remotion/three",
7
+ "three",
8
+ "@react-three/fiber",
9
+ "@remotion/effects",
10
+ "@remotion/paths",
11
+ "@remotion/noise",
12
+ "@remotion/shapes",
13
+ "@remotion/transitions",
14
+ "@remotion/motion-blur",
15
+ "@remotion/layout-utils",
16
+ ];
17
+ export const REMOTION_PACKAGES = [...REMOTION_RENDER_PACKAGES, ...REMOTION_SCENE_PACKAGES];
18
+ export const remotionInstallCommand = (packages = REMOTION_PACKAGES) => `npm install ${packages.join(" ")}`;
19
+ // `isInstalled` is injected so the rule can be tested without touching node_modules.
20
+ export const missingRemotionPackages = (isInstalled, packages = REMOTION_PACKAGES) => packages.filter((name) => !isInstalled(name));
@@ -0,0 +1,19 @@
1
+ export type RemotionSceneProps = {
2
+ durationInFrames: number;
3
+ fps: number;
4
+ width: number;
5
+ height: number;
6
+ };
7
+ export type RemotionRenderRequest = {
8
+ workDir: string;
9
+ props: RemotionSceneProps;
10
+ videoPath?: string;
11
+ stillPath: string;
12
+ extraStills?: {
13
+ frame: number;
14
+ path: string;
15
+ }[];
16
+ };
17
+ export type SceneRenderer = (request: RemotionRenderRequest) => Promise<void>;
18
+ export declare const ensureRemotionPackages: () => void;
19
+ export declare const renderRemotionScene: SceneRenderer;
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import { REMOTION_COMPOSITION_ID, REMOTION_ENTRY_FILE, buildEntrySource } from "./claude_prompt.js";
5
+ import { missingRemotionPackages, remotionInstallCommand } from "./packages.js";
6
+ // WebGL (three.js scenes) needs the ANGLE backend; the default one cannot create a context headless.
7
+ const CHROMIUM_OPTIONS = { gl: "angle" };
8
+ const loadRemotion = async () => {
9
+ try {
10
+ const [bundler, renderer] = await Promise.all([import("@remotion/bundler"), import("@remotion/renderer")]);
11
+ return { bundle: bundler.bundle, renderer };
12
+ }
13
+ catch (error) {
14
+ throw new Error(`The remotion beat needs these packages: ${remotionInstallCommand()}`, { cause: error });
15
+ }
16
+ };
17
+ // A package that hides its package.json (three does) is still installed; only "not found" means missing.
18
+ const isPackageInstalled = (name) => {
19
+ try {
20
+ createRequire(import.meta.url).resolve(`${name}/package.json`);
21
+ return true;
22
+ }
23
+ catch (error) {
24
+ return !(error instanceof Error && "code" in error && error.code === "MODULE_NOT_FOUND");
25
+ }
26
+ };
27
+ // Checked before asking claude -p for a component: a generated import of a missing optional peer
28
+ // would otherwise surface as an opaque bundling error after the generation was already paid for.
29
+ export const ensureRemotionPackages = () => {
30
+ const missing = missingRemotionPackages(isPackageInstalled);
31
+ if (missing.length > 0) {
32
+ throw new Error(`The remotion beat needs packages that are not installed (${missing.join(", ")}). Install them with: ${remotionInstallCommand()}`);
33
+ }
34
+ };
35
+ // The generated code sits in the output directory, which usually has no node_modules above it that
36
+ // holds react/remotion — so resolve them from wherever remotion itself is installed.
37
+ const remotionNodeModulesDir = () => {
38
+ const remotionPackageJson = createRequire(import.meta.url).resolve("remotion/package.json");
39
+ return path.dirname(path.dirname(remotionPackageJson));
40
+ };
41
+ export const renderRemotionScene = async ({ workDir, props, videoPath, stillPath, extraStills = [] }) => {
42
+ const { bundle, renderer } = await loadRemotion();
43
+ const entryPoint = path.join(workDir, REMOTION_ENTRY_FILE);
44
+ fs.writeFileSync(entryPoint, buildEntrySource());
45
+ const nodeModulesDir = remotionNodeModulesDir();
46
+ const serveUrl = await bundle({
47
+ entryPoint,
48
+ outDir: path.join(workDir, "bundle"),
49
+ webpackOverride: (config) => ({
50
+ ...config,
51
+ resolve: { ...config.resolve, modules: [...(config.resolve?.modules ?? ["node_modules"]), nodeModulesDir] },
52
+ }),
53
+ });
54
+ const composition = await renderer.selectComposition({ serveUrl, id: REMOTION_COMPOSITION_ID, inputProps: props, chromiumOptions: CHROMIUM_OPTIONS });
55
+ if (videoPath) {
56
+ await renderer.renderMedia({ composition, serveUrl, codec: "h264", outputLocation: videoPath, inputProps: props, chromiumOptions: CHROMIUM_OPTIONS });
57
+ }
58
+ const stills = [...extraStills, { frame: composition.durationInFrames - 1, path: stillPath }];
59
+ await stills.reduce(async (previous, still) => {
60
+ await previous;
61
+ await renderer.renderStill({ composition, serveUrl, output: still.path, frame: still.frame, inputProps: props, chromiumOptions: CHROMIUM_OPTIONS });
62
+ }, Promise.resolve());
63
+ };
@@ -0,0 +1,3 @@
1
+ export declare const REMOTION_SYSTEM_PROMPT: string;
2
+ export declare const REMOTION_REVIEW_MARKER = "LGTM";
3
+ export declare const REMOTION_REVIEW_SYSTEM_PROMPT: string;
@@ -0,0 +1,62 @@
1
+ import { REMOTION_SCENE_PACKAGES } from "./packages.js";
2
+ const ALLOWED_IMPORTS = ["react", "remotion", ...REMOTION_SCENE_PACKAGES].map((name) => `"${name}"`).join(", ");
3
+ // The contract and the house style for the component `claude -p` writes for one remotion beat.
4
+ // Every snippet in the toolkit section was rendered with this repository's renderer settings.
5
+ const OUTPUT_CONTRACT = `You are a senior motion designer and Remotion (v4.0.533) engineer. You write ONE React component in TypeScript (TSX) that renders one scene of a commercial-quality video.
6
+
7
+ # Output contract
8
+ - Reply with ONLY the TSX source in one \`\`\`tsx code block. No explanation before or after.
9
+ - \`export default\` the scene component. It receives props \`{ durationInFrames: number; fps: number; width: number; height: number }\`; read them with \`useVideoConfig()\`.
10
+ - Allowed imports, nothing else: ${ALLOWED_IMPORTS}. Subpaths of these packages are allowed too, e.g. "@remotion/transitions/fade" (also "/slide", "/wipe", "/iris", "/cross-zoom", "/film-burn", "/dissolve", "/clock-wipe") and "@remotion/effects/<name>".
11
+ - No network, no external URLs, no image/video/audio files, no web fonts. Draw everything with DOM, SVG, canvas or three.js.`;
12
+ const TIMING_RULES = `# Timing (hard rules — breaking them causes flicker or broken renders)
13
+ - Everything is a pure function of \`useCurrentFrame()\`. NEVER use CSS animations/transitions, \`setTimeout\`, \`requestAnimationFrame\`, \`Date\`, or three.js \`useFrame\`.
14
+ - NEVER use \`Math.random()\`; use \`random("seed-" + i)\` from "remotion" or \`noise2D/noise3D\` from "@remotion/noise".
15
+ - NEVER hardcode the scene length. Express every moment as a fraction of \`durationInFrames\` (e.g. \`const at = (f: number) => Math.round(durationInFrames * f)\`), so the scene fits any narration length.
16
+ - All entrances finish by 75% of the duration; the rest holds the composed final state (with only subtle motion). Do not fade the scene out at the end — the next scene cuts in.
17
+ - Always pass \`{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }\` to \`interpolate\`.
18
+ - Easing: \`Easing.bezier(0.16, 1, 0.3, 1)\` for entrances, \`Easing.bezier(0.7, 0, 0.84, 0)\` for exits, \`spring({ frame, fps, config: { damping: 200 } })\` for physical settle. Stagger related items by about 0.25 s (\`Math.round(fps * 0.25)\` frames).`;
19
+ const DESIGN_SYSTEM = `# Design system (aim: a polished brand film or keynote, not a slide)
20
+ - Canvas units: define \`const u = width / 1920;\` and size everything with it. Safe margins: 120u left/right, 96u top/bottom. Nothing important outside them, and nothing may touch or cross the canvas edge unintentionally.
21
+ - Type scale at 1920 wide: hero 160–280u (numbers can go bigger), title 72–110u, body 40–52u, label/kicker 22–28u uppercase with letter-spacing 0.18em. Never below 22u.
22
+ - Fonts: \`"Inter", "Hiragino Sans", "Noto Sans JP", "Helvetica Neue", Arial, sans-serif\` for text, \`"SF Mono", "Menlo", monospace\` for code. Weight 800–900 for heroes with letter-spacing -0.03em; 400–500 for body. Use \`fontVariantNumeric: "tabular-nums"\` for changing numbers.
23
+ - Fit every text: before placing long text, compute its size with \`fitText({ text, withinWidth, fontFamily, fontWeight })\` from "@remotion/layout-utils" and cap it with \`Math.min(...)\`, or keep strings short. Text must never overflow, clip, or overlap other elements.
24
+ - Layout: one hero per scene. Use a 12-column grid inside the safe margins, flex/grid layouts, thin 1–2u rules, aligned edges. Avoid centered stacks of identical boxes. Prefer asymmetric, editorial compositions with deliberate negative space.
25
+ - Color: deep background (near-black, deep navy or rich dark tone), off-white text (#F5F7FA), muted secondary text (rgba(245,247,250,0.6)), and ONE saturated accent (plus at most one supporting tint). Use gradients only as soft lighting (radial, low contrast). No rainbow, no emoji.
26
+ - Depth and polish: layered backgrounds (soft radial light, fine grid or grain), subtle parallax between layers, gentle camera drift (scale 1.00→1.04 over the scene), and light effects sparingly.`;
27
+ const MOTION_LANGUAGE = `# Motion language
28
+ - Kinetic typography: split headlines into words or characters and reveal each with a staggered mask (clipPath inset or overflow-hidden translateY 100%→0), not a plain fade. Pair every opacity change with translate, scale or blur.
29
+ - Data as motion: counters that count up with tabular numerals, bars and rings that fill, lines that draw on (\`evolvePath\`), tables whose cells fill in order, comparisons that resolve into a ratio chip.
30
+ - Choreography: one focal action at a time, in the order the narration says things. Secondary elements settle quietly. Keep a living background (noise drift, slow light sweep, particles from seeded random) so no frame is ever static.
31
+ - Transitions inside the scene: wipes, clip-path reveals, scale-through, light sweeps, or \`TransitionSeries\` for multi-part scenes.
32
+ - When narration is given, the screen shows its key facts (numbers, names, short phrases), timed in the order they are spoken — never the full sentence as a subtitle.`;
33
+ const TOOLKIT = `# Toolkit (verified snippets — copy the patterns exactly)
34
+ - Draw a line/path on:
35
+ \`import { evolvePath } from "@remotion/paths";\`
36
+ \`const { strokeDasharray, strokeDashoffset } = evolvePath(progress, d);\` → put both on the SVG <path> with fill="none".
37
+ - Organic drift: \`import { noise2D } from "@remotion/noise";\` \`const dx = noise2D("x", frame / 90, 0) * 20 * u;\`
38
+ - Seeded randomness: \`import { random } from "remotion";\` \`const x = random("star-" + i) * width;\`
39
+ - 3D (three.js via react-three-fiber):
40
+ \`import { ThreeCanvas } from "@remotion/three";\`
41
+ \`<ThreeCanvas width={width} height={height} camera={{ position: [0, 0, 6], fov: 45 }}> <ambientLight intensity={0.5} /> <pointLight position={[3, 3, 4]} intensity={40} color="#22d3ee" /> <mesh rotation={[a, b, 0]}> <torusKnotGeometry args={[0.9, 0.28, 160, 24]} /> <meshStandardMaterial color="#e2e8f0" metalness={0.6} roughness={0.2} /> </mesh> </ThreeCanvas>\`
42
+ Drive every rotation/position/camera move from \`frame\`. Always add lights. Any <Sequence> inside ThreeCanvas needs \`layout="none"\`. Layer DOM/SVG text above the canvas with absolute positioning.
43
+ - Post effects on a whole layer (glow, chromatic aberration, vignette, grain, scanlines, halftone, light leak, blur, zoom blur, shine, light trail…):
44
+ \`import { HtmlInCanvas } from "remotion";\` \`import { glow } from "@remotion/effects/glow";\` \`import { vignette } from "@remotion/effects/vignette";\`
45
+ \`<HtmlInCanvas width={width} height={height} effects={[glow({}), vignette({})]}> <AbsoluteFill>…DOM/SVG…</AbsoluteFill> </HtmlInCanvas>\`
46
+ Subpath → function: glow→glow, vignette→vignette, chromatic-aberration→chromaticAberration, scanlines→scanlines, halftone→halftone, noise→noise, blur→blur, zoom-blur→zoomBlur, shine→shine, light-trail→lightTrail, light-leak→lightLeak. Call each with \`({})\` for defaults. Use effects sparingly; they must serve the scene.
47
+ - Light leak overlay: \`import { Solid } from "remotion"; import { lightLeak } from "@remotion/effects/light-leak";\` \`<Solid width={width} height={height} color="transparent" effects={[lightLeak({ progress, seed: 4 })]} style={{ position: "absolute", mixBlendMode: "screen" }} />\`
48
+ - Shapes: \`import { Circle, Rect, Star, Polygon, Pie } from "@remotion/shapes";\` (SVG elements; animate their props from frame).`;
49
+ const SELF_CHECK = `# Before you answer, check silently
50
+ - Every import is in the allowed list and every used symbol is imported. The code type-checks.
51
+ - At 30%, 60% and 100% of the duration the frame is fully composed, readable, balanced, and nothing overflows the safe margins or overlaps.
52
+ - The scene matches the brief and the narration, and looks like a frame from a premium brand film.`;
53
+ export const REMOTION_SYSTEM_PROMPT = [OUTPUT_CONTRACT, TIMING_RULES, DESIGN_SYSTEM, MOTION_LANGUAGE, TOOLKIT, SELF_CHECK].join("\n\n");
54
+ export const REMOTION_REVIEW_MARKER = "LGTM";
55
+ export const REMOTION_REVIEW_SYSTEM_PROMPT = [
56
+ REMOTION_SYSTEM_PROMPT,
57
+ `# Your task now: visual review
58
+ You are given rendered frames of a scene you wrote and its source. Read every frame image with the Read tool, then judge it as a creative director shipping a commercial.
59
+ Look for: text overflowing, clipped by the canvas edge or by its container; elements overlapping or colliding; unreadable contrast or too-small text; empty, unbalanced or flat composition; broken or missing rendering (black areas, missing 3D, artifacts); anything that does not match the brief or the narration.
60
+ - If the scene is ready to ship, reply with exactly ${REMOTION_REVIEW_MARKER} and nothing else.
61
+ - Otherwise reply with the whole improved component in one \`\`\`tsx code block, fixing every problem you found while keeping what works.`,
62
+ ].join("\n\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmocast",
3
- "version": "2.12.1",
3
+ "version": "2.13.0",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "lib/index.node.js",
@@ -92,56 +92,144 @@
92
92
  "dependencies": {
93
93
  "@google-cloud/text-to-speech": "^6.4.1",
94
94
  "@google/genai": "^2.17.1",
95
- "@graphai/anthropic_agent": "^2.0.12",
96
- "@graphai/browserless_agent": "^2.0.3",
97
- "@graphai/gemini_agent": "^2.0.5",
98
- "@graphai/groq_agent": "^2.0.2",
95
+ "@graphai/anthropic_agent": "^2.1.0",
96
+ "@graphai/browserless_agent": "^2.0.4",
97
+ "@graphai/gemini_agent": "^2.1.0",
98
+ "@graphai/groq_agent": "^2.1.0",
99
99
  "@graphai/input_agents": "^1.0.2",
100
- "@graphai/openai_agent": "^2.0.9",
100
+ "@graphai/openai_agent": "^2.1.0",
101
101
  "@graphai/stream_agent_filter": "^2.0.3",
102
102
  "@graphai/vanilla": "^2.0.12",
103
103
  "@graphai/vanilla_node_agents": "^2.0.4",
104
- "@inquirer/input": "^5.1.6",
105
- "@inquirer/select": "^5.2.5",
106
- "@modelcontextprotocol/sdk": "^1.29.0",
104
+ "@inquirer/input": "^5.1.7",
105
+ "@inquirer/select": "^5.2.6",
106
+ "@modelcontextprotocol/sdk": "^1.32.1",
107
107
  "@modernized/fluent-ffmpeg": "^1.0.3",
108
108
  "@mozilla/readability": "^0.6.0",
109
109
  "@mulmocast/deck": "^2.0.2",
110
- "@tavily/core": "^0.7.10",
110
+ "@tavily/core": "^0.7.13",
111
111
  "archiver": "^8.0.0",
112
112
  "clipboardy": "^5.3.2",
113
113
  "dotenv": "^17.4.2",
114
114
  "graphai": "^2.0.18",
115
115
  "js-tiktoken": "^1.0.21",
116
116
  "jsdom": "^29.1.1",
117
- "marked": "^18.0.13",
117
+ "marked": "^18.1.0",
118
118
  "mulmocast-vision": "^1.0.12",
119
119
  "ora": "^9.4.1",
120
- "puppeteer": "^25.11.0",
120
+ "puppeteer": "^25.12.0",
121
121
  "replicate": "^1.4.0",
122
122
  "yaml": "^2.9.1",
123
- "yargs": "^18.1.0",
123
+ "yargs": "^18.2.0",
124
124
  "zod": "^4.6.5"
125
125
  },
126
126
  "devDependencies": {
127
127
  "@eslint/js": "^10.0.1",
128
+ "@react-three/fiber": "^9.8.1",
128
129
  "@receptron/test_utils": "^2.0.3",
130
+ "@remotion/bundler": "^4.0.533",
131
+ "@remotion/effects": "^4.0.533",
132
+ "@remotion/layout-utils": "^4.0.533",
133
+ "@remotion/motion-blur": "^4.0.533",
134
+ "@remotion/noise": "^4.0.533",
135
+ "@remotion/paths": "^4.0.533",
136
+ "@remotion/renderer": "^4.0.533",
137
+ "@remotion/shapes": "^4.0.533",
138
+ "@remotion/three": "^4.0.533",
139
+ "@remotion/transitions": "^4.0.533",
129
140
  "@types/archiver": "^8.0.0",
130
141
  "@types/jsdom": "^28.0.3",
131
- "@types/node": "^22.20.2",
142
+ "@types/node": "^26.6.4",
132
143
  "@types/yargs": "^17.0.35",
144
+ "avatarscript": "^0.2.1",
133
145
  "cross-env": "^10.1.0",
134
146
  "esbuild": "^0.28.2",
135
- "eslint": "^10.11.0",
147
+ "eslint": "^10.12.0",
136
148
  "eslint-config-prettier": "^10.1.8",
137
149
  "eslint-plugin-import": "^2.32.0",
138
150
  "eslint-plugin-prettier": "^5.5.6",
139
- "eslint-plugin-sonarjs": "^4.2.1",
140
- "globals": "^17.12.0",
141
- "prettier": "^3.9.8",
142
- "tsx": "^4.23.13",
151
+ "eslint-plugin-sonarjs": "^4.2.2",
152
+ "globals": "^17.13.0",
153
+ "prettier": "^3.9.9",
154
+ "react": "^19.2.0",
155
+ "react-dom": "^19.2.0",
156
+ "remotion": "^4.0.533",
157
+ "three": "^0.186.1",
158
+ "tsx": "^4.23.15",
143
159
  "typescript": "6.0.3",
144
- "typescript-eslint": "^8.70.0"
160
+ "typescript-eslint": "^8.71.1"
161
+ },
162
+ "peerDependencies": {
163
+ "@react-three/fiber": "^9.8.1",
164
+ "@remotion/bundler": "^4.0.533",
165
+ "@remotion/effects": "^4.0.533",
166
+ "@remotion/layout-utils": "^4.0.533",
167
+ "@remotion/motion-blur": "^4.0.533",
168
+ "@remotion/noise": "^4.0.533",
169
+ "@remotion/paths": "^4.0.533",
170
+ "@remotion/renderer": "^4.0.533",
171
+ "@remotion/shapes": "^4.0.533",
172
+ "@remotion/three": "^4.0.533",
173
+ "@remotion/transitions": "^4.0.533",
174
+ "avatarscript": "^0.2.1",
175
+ "onnxruntime-node": "^1.30.0",
176
+ "react": "^19.2.0",
177
+ "react-dom": "^19.2.0",
178
+ "remotion": "^4.0.533",
179
+ "three": "^0.186.1"
180
+ },
181
+ "peerDependenciesMeta": {
182
+ "@react-three/fiber": {
183
+ "optional": true
184
+ },
185
+ "@remotion/bundler": {
186
+ "optional": true
187
+ },
188
+ "@remotion/effects": {
189
+ "optional": true
190
+ },
191
+ "@remotion/layout-utils": {
192
+ "optional": true
193
+ },
194
+ "@remotion/motion-blur": {
195
+ "optional": true
196
+ },
197
+ "@remotion/noise": {
198
+ "optional": true
199
+ },
200
+ "@remotion/paths": {
201
+ "optional": true
202
+ },
203
+ "@remotion/renderer": {
204
+ "optional": true
205
+ },
206
+ "@remotion/shapes": {
207
+ "optional": true
208
+ },
209
+ "@remotion/three": {
210
+ "optional": true
211
+ },
212
+ "@remotion/transitions": {
213
+ "optional": true
214
+ },
215
+ "avatarscript": {
216
+ "optional": true
217
+ },
218
+ "onnxruntime-node": {
219
+ "optional": true
220
+ },
221
+ "react": {
222
+ "optional": true
223
+ },
224
+ "react-dom": {
225
+ "optional": true
226
+ },
227
+ "remotion": {
228
+ "optional": true
229
+ },
230
+ "three": {
231
+ "optional": true
232
+ }
145
233
  },
146
234
  "engines": {
147
235
  "node": ">=22.0.0"
@@ -0,0 +1,46 @@
1
+ {
2
+ "$mulmocast": { "version": "1.1" },
3
+ "lang": "ja",
4
+ "title": "Talking avatar over slides",
5
+ "description": "Needs mesh-avatar-studio checked out next to this repository (for its sample avatar), and npm packages avatarscript and onnxruntime-node.",
6
+ "avatarParams": { "position": { "x": "82%", "y": "100%", "scale": "62%" } },
7
+ "speechParams": {
8
+ "speakers": {
9
+ "Miko": {
10
+ "provider": "openai",
11
+ "voiceId": "coral",
12
+ "displayName": { "ja": "ミコ" },
13
+ "avatar": { "source": "../../../mesh-avatar-studio/samples/miko-qipao" }
14
+ }
15
+ }
16
+ },
17
+ "beats": [
18
+ {
19
+ "speaker": "Miko",
20
+ "text": "みなさん、こんにちは!今日は、スライドの上でしゃべるアバターを紹介します。",
21
+ "image": { "type": "markdown", "markdown": ["# Talking avatars", "", "- MulmoScript + AvatarScript", "- スライドの上でアバターが話す"] },
22
+ "avatarParams": { "emotion": "happy", "motions": [{ "motion": "nod", "at": "こんにちは" }] }
23
+ },
24
+ {
25
+ "speaker": "Miko",
26
+ "text": "位置と大きさは、台本全体で決めておいて、ビートごとに変えられます。このスライドでは、大きくなっています。",
27
+ "image": { "type": "markdown", "markdown": ["# Position and size", "", "- 台本全体: `avatarParams.position`", "- ビートごとに上書き"] },
28
+ "avatarParams": { "emotion": "relaxed", "position": { "x": "76%", "scale": "82%" }, "motions": [{ "motion": "tilt", "at": "大きく" }] }
29
+ },
30
+ {
31
+ "speaker": "Miko",
32
+ "text": "こちらでは、左側に小さく移動しました。",
33
+ "image": {
34
+ "type": "markdown",
35
+ "markdown": ["# Moved", "", "```json", "\"avatarParams\": {", " \"position\": { \"x\": \"18%\", \"scale\": \"48%\" }", "}", "```"]
36
+ },
37
+ "avatarParams": { "emotion": "surprised", "position": { "x": "18%", "scale": "48%" }, "motions": [{ "motion": "surprise" }] }
38
+ },
39
+ {
40
+ "speaker": "Miko",
41
+ "text": "最後は、元の位置に戻ります。それでは、始めましょう!",
42
+ "image": { "type": "markdown", "markdown": ["# Let's start"] },
43
+ "avatarParams": { "emotion": "happy" }
44
+ }
45
+ ]
46
+ }