mulmocast 2.12.1 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/schemas/mulmo_script.json +297 -0
- package/lib/actions/avatar.d.ts +37 -0
- package/lib/actions/avatar.js +176 -0
- package/lib/actions/image_agents.js +9 -9
- package/lib/actions/index.d.ts +1 -0
- package/lib/actions/index.js +1 -0
- package/lib/actions/movie.d.ts +1 -0
- package/lib/actions/movie.js +40 -2
- package/lib/agents/combine_audio_files_agent.js +2 -2
- package/lib/cli/commands/movie/handler.js +2 -2
- package/lib/index.remotion.d.ts +3 -0
- package/lib/index.remotion.js +4 -0
- package/lib/mcp/server.js +3 -3
- package/lib/methods/mulmo_beat.d.ts +1 -0
- package/lib/methods/mulmo_beat.js +3 -0
- package/lib/methods/mulmo_beat_node.d.ts +1 -0
- package/lib/methods/mulmo_presentation_style.d.ts +5 -1
- package/lib/methods/mulmo_presentation_style.js +8 -0
- package/lib/types/schema.d.ts +466 -4
- package/lib/types/schema.js +77 -0
- package/lib/types/type.d.ts +7 -1
- package/lib/utils/context.d.ts +178 -0
- package/lib/utils/image_plugins/index.js +2 -0
- package/lib/utils/image_plugins/remotion.d.ts +17 -0
- package/lib/utils/image_plugins/remotion.js +202 -0
- package/lib/utils/remotion/claude_prompt.d.ts +33 -0
- package/lib/utils/remotion/claude_prompt.js +111 -0
- package/lib/utils/remotion/claude_runner.d.ts +4 -0
- package/lib/utils/remotion/claude_runner.js +47 -0
- package/lib/utils/remotion/commit_files.d.ts +11 -0
- package/lib/utils/remotion/commit_files.js +37 -0
- package/lib/utils/remotion/packages.d.ts +5 -0
- package/lib/utils/remotion/packages.js +20 -0
- package/lib/utils/remotion/render.d.ts +20 -0
- package/lib/utils/remotion/render.js +65 -0
- package/lib/utils/remotion/system_prompt.d.ts +4 -0
- package/lib/utils/remotion/system_prompt.js +66 -0
- package/package.json +112 -20
- package/scripts/test/test_avatar.json +46 -0
- package/scripts/test/test_remotion.json +38 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import nodePath from "node:path";
|
|
3
|
+
import { GraphAILogger } from "graphai";
|
|
4
|
+
import { ImageMediaType } from "../../types/index.js";
|
|
5
|
+
import { localizedText } from "../utils.js";
|
|
6
|
+
import { getFullPath } from "../file.js";
|
|
7
|
+
import { REMOTION_COMPONENT_FILE, buildRepairPrompt, buildReviewPrompt, buildScenePrompt, remotionCacheKey, } from "../remotion/claude_prompt.js";
|
|
8
|
+
import { reviewComponentWithClaude, writeComponentWithClaude } from "../remotion/claude_runner.js";
|
|
9
|
+
import { ensureRemotionPackages, renderRemotionScene } from "../remotion/render.js";
|
|
10
|
+
import { commitFiles } from "../remotion/commit_files.js";
|
|
11
|
+
import { parrotingImagePath } from "./utils.js";
|
|
12
|
+
export const imageType = ImageMediaType.Remotion;
|
|
13
|
+
const DEFAULT_FPS = 30;
|
|
14
|
+
const MAX_REPAIR_ATTEMPTS = 2;
|
|
15
|
+
// Without audio the length is unknown; any length gives the same final frame, since the system
|
|
16
|
+
// prompt makes every animation finish before the end.
|
|
17
|
+
const STILL_ONLY_DURATION_SEC = 10;
|
|
18
|
+
const REVIEW_FRACTIONS = [0.3, 0.6, 0.95];
|
|
19
|
+
export const remotionWorkDir = (imagePath) => imagePath.replace(/(_animated)?\.(mp4|png)$/, "_remotion");
|
|
20
|
+
export const remotionStillPath = (imagePath) => imagePath.replace(/_animated\.mp4$/, ".png");
|
|
21
|
+
const loadOrWriteComponent = async (job, force, deps) => {
|
|
22
|
+
if (!force && fs.existsSync(job.codePath)) {
|
|
23
|
+
return { code: fs.readFileSync(job.codePath, "utf8"), isNew: false };
|
|
24
|
+
}
|
|
25
|
+
const code = await deps.writeComponent(buildScenePrompt(job.spec), job.workDir);
|
|
26
|
+
fs.writeFileSync(job.codePath, code);
|
|
27
|
+
return { code, isNew: true };
|
|
28
|
+
};
|
|
29
|
+
// Resolves to the code that rendered, which is a repaired version when the given one failed.
|
|
30
|
+
const renderWithRepair = async (job, code, attemptsLeft, deps, reviewStills = []) => {
|
|
31
|
+
fs.writeFileSync(nodePath.join(job.workDir, REMOTION_COMPONENT_FILE), code);
|
|
32
|
+
try {
|
|
33
|
+
const extraStills = reviewStills.map(({ frame, file }) => ({ frame, path: file }));
|
|
34
|
+
await deps.renderScene({ workDir: job.workDir, props: job.props, videoPath: job.videoPath, stillPath: job.stillPath, extraStills });
|
|
35
|
+
return code;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (attemptsLeft <= 0) {
|
|
39
|
+
throw new Error(`remotion beat failed to render after repairs (code: ${job.codePath})`, { cause: error });
|
|
40
|
+
}
|
|
41
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
42
|
+
GraphAILogger.info(`remotion: render failed, asking claude -p to repair (${attemptsLeft} left): ${message.slice(0, 200)}`);
|
|
43
|
+
const repaired = await deps.writeComponent(buildRepairPrompt(job.spec, code, message), job.workDir);
|
|
44
|
+
fs.writeFileSync(job.codePath, repaired);
|
|
45
|
+
return await renderWithRepair(job, repaired, attemptsLeft - 1, deps, reviewStills);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const reviewStillsFor = (job) => REVIEW_FRACTIONS.map((fraction, index) => ({
|
|
49
|
+
fraction,
|
|
50
|
+
frame: Math.min(job.props.durationInFrames - 1, Math.floor(job.props.durationInFrames * fraction)),
|
|
51
|
+
file: nodePath.join(job.workDir, `review_${index}.png`),
|
|
52
|
+
}));
|
|
53
|
+
const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
|
|
54
|
+
// null when the review itself failed; undefined when the reviewer approved the scene as it is.
|
|
55
|
+
const askForReview = async (job, renderedCode, reviewStills, deps) => {
|
|
56
|
+
try {
|
|
57
|
+
return await deps.reviewComponent(buildReviewPrompt(job.spec, renderedCode, reviewStills), job.workDir);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
GraphAILogger.info(`remotion: visual review skipped: ${errorMessage(error)}`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const nodeFileOps = {
|
|
65
|
+
exists: (file) => fs.existsSync(file),
|
|
66
|
+
rename: (from, to) => fs.renameSync(from, to),
|
|
67
|
+
remove: (file) => fs.rmSync(file, { force: true }),
|
|
68
|
+
};
|
|
69
|
+
export const reviewCandidatePath = (filePath) => filePath.replace(/(\.[^./]+)$/, ".review$1");
|
|
70
|
+
// The reviewed version renders to side files that replace the first version's output only once it
|
|
71
|
+
// succeeded, so a failure leaves the scene that already rendered — files and cached code — untouched.
|
|
72
|
+
const renderReviewedVersion = async (job, improved, deps) => {
|
|
73
|
+
const candidate = {
|
|
74
|
+
...job,
|
|
75
|
+
codePath: reviewCandidatePath(job.codePath),
|
|
76
|
+
videoPath: job.videoPath && reviewCandidatePath(job.videoPath),
|
|
77
|
+
stillPath: reviewCandidatePath(job.stillPath),
|
|
78
|
+
};
|
|
79
|
+
try {
|
|
80
|
+
const renderedImprovement = await renderWithRepair(candidate, improved, MAX_REPAIR_ATTEMPTS, deps);
|
|
81
|
+
fs.writeFileSync(candidate.codePath, renderedImprovement);
|
|
82
|
+
const moves = [
|
|
83
|
+
...(job.videoPath && candidate.videoPath ? [{ from: candidate.videoPath, to: job.videoPath }] : []),
|
|
84
|
+
{ from: candidate.stillPath, to: job.stillPath },
|
|
85
|
+
{ from: candidate.codePath, to: job.codePath },
|
|
86
|
+
];
|
|
87
|
+
commitFiles(moves, nodeFileOps);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
GraphAILogger.info(`remotion: the reviewed version was not applied, keeping the first one: ${errorMessage(error)}`);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
[candidate.codePath, candidate.videoPath, candidate.stillPath].forEach((file) => file && fs.rmSync(file, { force: true }));
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const reviewAndImprove = async (job, renderedCode, reviewStills, deps) => {
|
|
97
|
+
const improved = await askForReview(job, renderedCode, reviewStills, deps);
|
|
98
|
+
if (improved === null)
|
|
99
|
+
return;
|
|
100
|
+
if (improved === undefined) {
|
|
101
|
+
GraphAILogger.info(`remotion: visual review approved ${job.codePath}`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
GraphAILogger.info(`remotion: visual review improved ${job.codePath}`);
|
|
105
|
+
await renderReviewedVersion(job, improved, deps);
|
|
106
|
+
};
|
|
107
|
+
const toFrameCount = (durationSec, fps) => {
|
|
108
|
+
const frames = Math.floor(durationSec * fps);
|
|
109
|
+
if (frames <= 0) {
|
|
110
|
+
throw new Error(`remotion: frame count is ${frames} (duration=${durationSec}, fps=${fps}). Increase duration or fps.`);
|
|
111
|
+
}
|
|
112
|
+
return frames;
|
|
113
|
+
};
|
|
114
|
+
// Lets a scene number itself and pace its place in the video; unknown when the beat is not in the script.
|
|
115
|
+
const scenePosition = (beats, index) => (index < 0 ? undefined : { index, count: beats.length });
|
|
116
|
+
// The words actually spoken over the scene: the translation when the video is rendered in another language.
|
|
117
|
+
const spokenNarration = (context, beat, index) => (index < 0 ? beat.text : localizedText(beat, context.multiLingual?.[index], context.lang, context.studio.script.lang)) || undefined;
|
|
118
|
+
const buildSceneOutput = (params, fps) => {
|
|
119
|
+
const { beat, imagePath, canvasSize } = params;
|
|
120
|
+
const duration = params.beatDuration ?? beat.duration;
|
|
121
|
+
return {
|
|
122
|
+
workDir: remotionWorkDir(imagePath),
|
|
123
|
+
props: { durationInFrames: toFrameCount(duration ?? STILL_ONLY_DURATION_SEC, fps), fps, width: canvasSize.width, height: canvasSize.height },
|
|
124
|
+
videoPath: duration !== undefined ? imagePath : undefined,
|
|
125
|
+
stillPath: remotionStillPath(imagePath),
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
const buildJob = (params, prompt, fps) => {
|
|
129
|
+
const { beat, context, canvasSize } = params;
|
|
130
|
+
const index = context.studio.script.beats.indexOf(beat);
|
|
131
|
+
const spec = {
|
|
132
|
+
prompt,
|
|
133
|
+
narration: spokenNarration(context, beat, index),
|
|
134
|
+
brief: context.presentationStyle.remotionParams?.brief || undefined,
|
|
135
|
+
position: scenePosition(context.studio.script.beats, index),
|
|
136
|
+
fps,
|
|
137
|
+
width: canvasSize.width,
|
|
138
|
+
height: canvasSize.height,
|
|
139
|
+
};
|
|
140
|
+
const output = buildSceneOutput(params, fps);
|
|
141
|
+
return { ...output, spec, codePath: nodePath.join(output.workDir, `${remotionCacheKey(spec)}.tsx`) };
|
|
142
|
+
};
|
|
143
|
+
// Named in errors, so whoever wrote the component (usually the host's agent) knows what to fix.
|
|
144
|
+
export const remotionCodeLocation = (code, context, beatIndex) => {
|
|
145
|
+
if (code.kind === "path")
|
|
146
|
+
return getFullPath(context.fileDirs.mulmoFileDirPath, code.path);
|
|
147
|
+
return beatIndex < 0 ? "inline code" : `inline code of beat ${beatIndex + 1}`;
|
|
148
|
+
};
|
|
149
|
+
const readGivenComponent = (code, location) => {
|
|
150
|
+
if (code.kind === "text")
|
|
151
|
+
return code.text;
|
|
152
|
+
try {
|
|
153
|
+
return fs.readFileSync(location, "utf8");
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
throw new Error(`remotion: cannot read the component file ${location}`, { cause: error });
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
// A given component is the caller's to fix, so a failed render stops here instead of asking claude -p to repair it.
|
|
160
|
+
const renderGivenComponent = async (params, code, fps, deps) => {
|
|
161
|
+
const output = buildSceneOutput(params, fps);
|
|
162
|
+
const location = remotionCodeLocation(code, params.context, params.context.studio.script.beats.indexOf(params.beat));
|
|
163
|
+
deps.ensurePackages();
|
|
164
|
+
const source = readGivenComponent(code, location);
|
|
165
|
+
fs.mkdirSync(output.workDir, { recursive: true });
|
|
166
|
+
fs.writeFileSync(nodePath.join(output.workDir, REMOTION_COMPONENT_FILE), source);
|
|
167
|
+
try {
|
|
168
|
+
await deps.renderScene({ workDir: output.workDir, props: output.props, videoPath: output.videoPath, stillPath: output.stillPath });
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
throw new Error(`remotion beat failed to render the given component (code: ${location}): ${errorMessage(error)}`, { cause: error });
|
|
172
|
+
}
|
|
173
|
+
return output.videoPath ?? output.stillPath;
|
|
174
|
+
};
|
|
175
|
+
export const createRemotionProcess = (deps) => async (params) => {
|
|
176
|
+
const { beat, context } = params;
|
|
177
|
+
if (!beat.image || beat.image.type !== imageType)
|
|
178
|
+
return;
|
|
179
|
+
const fps = beat.image.fps ?? DEFAULT_FPS;
|
|
180
|
+
if ("code" in beat.image)
|
|
181
|
+
return await renderGivenComponent(params, beat.image.code, fps, deps);
|
|
182
|
+
const job = buildJob(params, beat.image.prompt, fps);
|
|
183
|
+
deps.ensurePackages();
|
|
184
|
+
fs.mkdirSync(job.workDir, { recursive: true });
|
|
185
|
+
const { code, isNew } = await loadOrWriteComponent(job, context.force, deps);
|
|
186
|
+
if (isNew) {
|
|
187
|
+
const reviewStills = reviewStillsFor(job);
|
|
188
|
+
const renderedCode = await renderWithRepair(job, code, MAX_REPAIR_ATTEMPTS, deps, reviewStills);
|
|
189
|
+
await reviewAndImprove(job, renderedCode, reviewStills, deps);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
await renderWithRepair(job, code, MAX_REPAIR_ATTEMPTS, deps);
|
|
193
|
+
}
|
|
194
|
+
return job.videoPath ?? job.stillPath;
|
|
195
|
+
};
|
|
196
|
+
export const process = createRemotionProcess({
|
|
197
|
+
ensurePackages: ensureRemotionPackages,
|
|
198
|
+
writeComponent: writeComponentWithClaude,
|
|
199
|
+
reviewComponent: reviewComponentWithClaude,
|
|
200
|
+
renderScene: renderRemotionScene,
|
|
201
|
+
});
|
|
202
|
+
export const path = parrotingImagePath;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export declare const REMOTION_COMPONENT_FILE = "Generated.tsx";
|
|
2
|
+
export declare const REMOTION_ENTRY_FILE = "index.tsx";
|
|
3
|
+
export declare const REMOTION_COMPOSITION_ID = "Beat";
|
|
4
|
+
export type RemotionSceneSpec = {
|
|
5
|
+
prompt: string;
|
|
6
|
+
narration?: string;
|
|
7
|
+
brief?: string;
|
|
8
|
+
position?: {
|
|
9
|
+
index: number;
|
|
10
|
+
count: number;
|
|
11
|
+
};
|
|
12
|
+
fps: number;
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
};
|
|
16
|
+
export declare const buildClaudeArgs: (userPrompt: string, systemPrompt?: string, tools?: string) => string[];
|
|
17
|
+
export declare const buildScenePrompt: (spec: RemotionSceneSpec) => string;
|
|
18
|
+
export declare const buildRepairPrompt: (spec: RemotionSceneSpec, code: string, error: string) => string;
|
|
19
|
+
export type ReviewFrame = {
|
|
20
|
+
fraction: number;
|
|
21
|
+
file: string;
|
|
22
|
+
};
|
|
23
|
+
export declare const buildReviewPrompt: (spec: RemotionSceneSpec, code: string, frames: ReviewFrame[]) => string;
|
|
24
|
+
export declare const parseReviewReply: (replyText: string) => string | undefined;
|
|
25
|
+
export declare const remotionCacheKey: (spec: RemotionSceneSpec, systemPrompt?: string) => string;
|
|
26
|
+
export declare const extractTsx: (replyText: string) => string | undefined;
|
|
27
|
+
type ClaudeJsonReply = {
|
|
28
|
+
result: string;
|
|
29
|
+
is_error?: boolean;
|
|
30
|
+
};
|
|
31
|
+
export declare const parseClaudeReply: (stdout: string) => ClaudeJsonReply;
|
|
32
|
+
export declare const buildEntrySource: () => string;
|
|
33
|
+
export {};
|
|
@@ -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,20 @@
|
|
|
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 missingInstalledRemotionPackages: () => string[];
|
|
19
|
+
export declare const ensureRemotionPackages: () => void;
|
|
20
|
+
export declare const renderRemotionScene: SceneRenderer;
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
// Resolved from mulmocast's own location, which is where the renderer looks for them.
|
|
28
|
+
export const missingInstalledRemotionPackages = () => missingRemotionPackages(isPackageInstalled);
|
|
29
|
+
// Checked before asking claude -p for a component: a generated import of a missing optional peer
|
|
30
|
+
// would otherwise surface as an opaque bundling error after the generation was already paid for.
|
|
31
|
+
export const ensureRemotionPackages = () => {
|
|
32
|
+
const missing = missingInstalledRemotionPackages();
|
|
33
|
+
if (missing.length > 0) {
|
|
34
|
+
throw new Error(`The remotion beat needs packages that are not installed (${missing.join(", ")}). Install them with: ${remotionInstallCommand()}`);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
// The generated code sits in the output directory, which usually has no node_modules above it that
|
|
38
|
+
// holds react/remotion — so resolve them from wherever remotion itself is installed.
|
|
39
|
+
const remotionNodeModulesDir = () => {
|
|
40
|
+
const remotionPackageJson = createRequire(import.meta.url).resolve("remotion/package.json");
|
|
41
|
+
return path.dirname(path.dirname(remotionPackageJson));
|
|
42
|
+
};
|
|
43
|
+
export const renderRemotionScene = async ({ workDir, props, videoPath, stillPath, extraStills = [] }) => {
|
|
44
|
+
const { bundle, renderer } = await loadRemotion();
|
|
45
|
+
const entryPoint = path.join(workDir, REMOTION_ENTRY_FILE);
|
|
46
|
+
fs.writeFileSync(entryPoint, buildEntrySource());
|
|
47
|
+
const nodeModulesDir = remotionNodeModulesDir();
|
|
48
|
+
const serveUrl = await bundle({
|
|
49
|
+
entryPoint,
|
|
50
|
+
outDir: path.join(workDir, "bundle"),
|
|
51
|
+
webpackOverride: (config) => ({
|
|
52
|
+
...config,
|
|
53
|
+
resolve: { ...config.resolve, modules: [...(config.resolve?.modules ?? ["node_modules"]), nodeModulesDir] },
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
const composition = await renderer.selectComposition({ serveUrl, id: REMOTION_COMPOSITION_ID, inputProps: props, chromiumOptions: CHROMIUM_OPTIONS });
|
|
57
|
+
if (videoPath) {
|
|
58
|
+
await renderer.renderMedia({ composition, serveUrl, codec: "h264", outputLocation: videoPath, inputProps: props, chromiumOptions: CHROMIUM_OPTIONS });
|
|
59
|
+
}
|
|
60
|
+
const stills = [...extraStills, { frame: composition.durationInFrames - 1, path: stillPath }];
|
|
61
|
+
await stills.reduce(async (previous, still) => {
|
|
62
|
+
await previous;
|
|
63
|
+
await renderer.renderStill({ composition, serveUrl, output: still.path, frame: still.frame, inputProps: props, chromiumOptions: CHROMIUM_OPTIONS });
|
|
64
|
+
}, Promise.resolve());
|
|
65
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
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 ROLE = `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
|
+
const REPLY_FORMAT = `- Reply with ONLY the TSX source in one \`\`\`tsx code block. No explanation before or after.`;
|
|
7
|
+
const COMPONENT_RULES = `- One self-contained file: no relative imports, since it is rendered on its own.`;
|
|
8
|
+
const CONTRACT_BODY = `- \`export default\` the scene component. It receives props \`{ durationInFrames: number; fps: number; width: number; height: number }\`; read them with \`useVideoConfig()\`.
|
|
9
|
+
- 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>".
|
|
10
|
+
- No network, no external URLs, no image/video/audio files, no web fonts. Draw everything with DOM, SVG, canvas or three.js.`;
|
|
11
|
+
const OUTPUT_CONTRACT = [ROLE, "", "# Output contract", REPLY_FORMAT, CONTRACT_BODY].join("\n");
|
|
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
|
+
// The same contract for a host's agent that writes the component file itself and passes it as \`code\`.
|
|
55
|
+
// The system prompt stays byte-identical, since it is part of every cached component's key.
|
|
56
|
+
const COMPONENT_CONTRACT = ["# Component contract", COMPONENT_RULES, CONTRACT_BODY].join("\n");
|
|
57
|
+
export const REMOTION_COMPONENT_GUIDE = [COMPONENT_CONTRACT, TIMING_RULES, DESIGN_SYSTEM, MOTION_LANGUAGE, TOOLKIT, SELF_CHECK].join("\n\n");
|
|
58
|
+
export const REMOTION_REVIEW_MARKER = "LGTM";
|
|
59
|
+
export const REMOTION_REVIEW_SYSTEM_PROMPT = [
|
|
60
|
+
REMOTION_SYSTEM_PROMPT,
|
|
61
|
+
`# Your task now: visual review
|
|
62
|
+
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.
|
|
63
|
+
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.
|
|
64
|
+
- If the scene is ready to ship, reply with exactly ${REMOTION_REVIEW_MARKER} and nothing else.
|
|
65
|
+
- Otherwise reply with the whole improved component in one \`\`\`tsx code block, fixing every problem you found while keeping what works.`,
|
|
66
|
+
].join("\n\n");
|