mulmocast 2.13.0 → 2.15.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.
@@ -125,7 +125,7 @@
125
125
  "properties": {
126
126
  "source": {
127
127
  "type": "string",
128
- "description": "AvatarScript avatar package (a folder with avatar.json, or a mesh-avatar-studio project), relative to the script"
128
+ "description": "AvatarScript avatar package (a folder with avatar.json, or a mesh-avatar-studio project): a path relative to the script, or an http(s) URL"
129
129
  },
130
130
  "position": {
131
131
  "type": "object",
@@ -226,7 +226,7 @@
226
226
  "properties": {
227
227
  "source": {
228
228
  "type": "string",
229
- "description": "AvatarScript avatar package (a folder with avatar.json, or a mesh-avatar-studio project), relative to the script"
229
+ "description": "AvatarScript avatar package (a folder with avatar.json, or a mesh-avatar-studio project): a path relative to the script, or an http(s) URL"
230
230
  },
231
231
  "position": {
232
232
  "type": "object",
@@ -13517,29 +13517,95 @@
13517
13517
  "additionalProperties": false
13518
13518
  },
13519
13519
  {
13520
- "type": "object",
13521
- "properties": {
13522
- "type": {
13523
- "type": "string",
13524
- "const": "remotion"
13525
- },
13526
- "prompt": {
13527
- "type": "string",
13528
- "minLength": 1,
13529
- "description": "What the scene shows. Claude Code (claude -p) writes a Remotion component from it."
13520
+ "anyOf": [
13521
+ {
13522
+ "type": "object",
13523
+ "properties": {
13524
+ "type": {
13525
+ "type": "string",
13526
+ "const": "remotion"
13527
+ },
13528
+ "fps": {
13529
+ "description": "Frame rate of the rendered video. Default: 30.",
13530
+ "type": "number",
13531
+ "minimum": 1,
13532
+ "maximum": 60
13533
+ },
13534
+ "prompt": {
13535
+ "type": "string",
13536
+ "minLength": 1,
13537
+ "description": "What the scene shows. Claude Code (claude -p) writes a Remotion component from it."
13538
+ }
13539
+ },
13540
+ "required": [
13541
+ "type",
13542
+ "prompt"
13543
+ ],
13544
+ "additionalProperties": false
13530
13545
  },
13531
- "fps": {
13532
- "description": "Frame rate of the rendered video. Default: 30.",
13533
- "type": "number",
13534
- "minimum": 1,
13535
- "maximum": 60
13546
+ {
13547
+ "type": "object",
13548
+ "properties": {
13549
+ "type": {
13550
+ "type": "string",
13551
+ "const": "remotion"
13552
+ },
13553
+ "fps": {
13554
+ "description": "Frame rate of the rendered video. Default: 30.",
13555
+ "type": "number",
13556
+ "minimum": 1,
13557
+ "maximum": 60
13558
+ },
13559
+ "code": {
13560
+ "oneOf": [
13561
+ {
13562
+ "type": "object",
13563
+ "properties": {
13564
+ "kind": {
13565
+ "type": "string",
13566
+ "const": "text"
13567
+ },
13568
+ "text": {
13569
+ "type": "string",
13570
+ "minLength": 1
13571
+ }
13572
+ },
13573
+ "required": [
13574
+ "kind",
13575
+ "text"
13576
+ ],
13577
+ "additionalProperties": false
13578
+ },
13579
+ {
13580
+ "type": "object",
13581
+ "properties": {
13582
+ "kind": {
13583
+ "type": "string",
13584
+ "const": "path"
13585
+ },
13586
+ "path": {
13587
+ "type": "string",
13588
+ "minLength": 1,
13589
+ "description": "Relative to the script"
13590
+ }
13591
+ },
13592
+ "required": [
13593
+ "kind",
13594
+ "path"
13595
+ ],
13596
+ "additionalProperties": false
13597
+ }
13598
+ ],
13599
+ "description": "A finished Remotion component (TSX that default-exports the scene). It is rendered as is, without claude -p."
13600
+ }
13601
+ },
13602
+ "required": [
13603
+ "type",
13604
+ "code"
13605
+ ],
13606
+ "additionalProperties": false
13536
13607
  }
13537
- },
13538
- "required": [
13539
- "type",
13540
- "prompt"
13541
- ],
13542
- "additionalProperties": false
13608
+ ]
13543
13609
  }
13544
13610
  ]
13545
13611
  },
@@ -19,7 +19,7 @@ export type AvatarPlacementPlan = {
19
19
  };
20
20
  export type AvatarTrackPlan = {
21
21
  speakerId: string;
22
- /** absolute path of the avatar package */
22
+ /** the avatar package: an absolute path, or an http(s) URL */
23
23
  source: string;
24
24
  segments: AvatarSegmentPlan[];
25
25
  /** where the avatar is shown, beat by beat; it is not shown outside these */
@@ -33,5 +33,6 @@ export type AvatarTrackPlan = {
33
33
  * avatarParams, then the defaults; during other speakers' beats the avatar stays where it was.
34
34
  */
35
35
  export declare const planAvatarTracks: (context: MulmoStudioContext) => AvatarTrackPlan[];
36
+ export declare const avatarUrlStamp: (source: string) => Promise<(string | null)[]>;
36
37
  /** The `avatar` action: renders the avatar tracks into studio.avatarTracks (none when no speaker has an avatar). */
37
38
  export declare const avatar: (context: MulmoStudioContext) => Promise<MulmoStudioContext>;
@@ -7,7 +7,7 @@ import path from "node:path";
7
7
  import { GraphAILogger } from "graphai";
8
8
  import { MulmoPresentationStyleMethods, MulmoStudioContextMethods } from "../methods/index.js";
9
9
  import { getFullPath, getOutputStudioFilePath } from "../utils/file.js";
10
- import { localizedText } from "../utils/utils.js";
10
+ import { isHttp, localizedText } from "../utils/utils.js";
11
11
  const DEFAULT_POSITION = { x: "84%", y: "100%", scale: "62%" };
12
12
  // Show the whole avatar image. A rig may crop the top of its image (a flat cut edge, hidden when the
13
13
  // avatar fills the frame); over a slide the cut would run through the head.
@@ -43,7 +43,7 @@ export const planAvatarTracks = (context) => {
43
43
  tracks.set(found.speakerId, {
44
44
  speakerId: found.speakerId,
45
45
  avatar: found.avatar,
46
- source: getFullPath(context.fileDirs.mulmoFileDirPath, found.avatar.source),
46
+ source: isHttp(found.avatar.source) ? found.avatar.source : getFullPath(context.fileDirs.mulmoFileDirPath, found.avatar.source),
47
47
  segments: [],
48
48
  placements: [],
49
49
  duration,
@@ -105,6 +105,62 @@ const folderStamp = (dir) => {
105
105
  .sort()
106
106
  .map((file) => [path.relative(dir, file), fileStamp(file)]);
107
107
  };
108
+ // An avatar at a URL: its small JSON files (avatar.json, the rig, layers.json, sprites.json) change
109
+ // whenever the avatar is edited or rebuilt, so they stand in for the files' stamps. They are fetched
110
+ // on every run; avatarscript caches the images.
111
+ // Redirects are followed only within the same origin, so a public host cannot send these requests
112
+ // to another one (an internal service, say). GitHub's github.com/…/raw/… links redirect to
113
+ // raw.githubusercontent.com: use the latter.
114
+ const MAX_REDIRECTS = 5;
115
+ const fetchSameOrigin = async (url) => {
116
+ let location = url;
117
+ for (let hops = 0; hops <= MAX_REDIRECTS; hops++) {
118
+ const response = await fetch(location, { redirect: "manual", signal: AbortSignal.timeout(60_000) });
119
+ const next = response.status >= 300 && response.status < 400 ? response.headers.get("location") : null;
120
+ if (!next)
121
+ return response;
122
+ const target = new URL(next, location);
123
+ if (target.origin !== url.origin)
124
+ throw new Error(`avatar: ${url.href} redirects to another host (${target.origin}); use the final URL`);
125
+ location = target;
126
+ }
127
+ throw new Error(`avatar: ${url.href}: too many redirects`);
128
+ };
129
+ const fetchText = async (url) => {
130
+ const response = await fetchSameOrigin(url);
131
+ if (response.status === 404)
132
+ return null;
133
+ if (!response.ok)
134
+ throw new Error(`avatar: ${url.href}: HTTP ${response.status}`);
135
+ return response.text();
136
+ };
137
+ export const avatarUrlStamp = async (source) => {
138
+ const url = new URL(source);
139
+ const folder = new URL(url);
140
+ folder.pathname = url.pathname.endsWith("/avatar.json") ? url.pathname.slice(0, -"avatar.json".length) : url.pathname.replace(/\/?$/, "/");
141
+ // the source's query (a version, a signature) goes with every file that has no query of its own
142
+ // like avatarscript, fetch only files inside the package's folder (no "../", no other host)
143
+ const at = (file, base) => {
144
+ const location = new URL(file, base);
145
+ if (location.origin !== folder.origin || !location.pathname.startsWith(folder.pathname)) {
146
+ throw new Error(`avatar: ${file} is outside the avatar package ${folder.href}`);
147
+ }
148
+ if (!location.search)
149
+ location.search = url.search;
150
+ return location;
151
+ };
152
+ const manifestText = await fetchText(at("avatar.json", folder));
153
+ const manifest = manifestText ? JSON.parse(manifestText) : undefined;
154
+ const root = at((manifest?.root ?? ".").replace(/\/?$/, "/"), folder);
155
+ // as avatarscript: a manifest names its files (sprites are optional); without one, a
156
+ // mesh-avatar-studio project's default paths
157
+ const assets = manifest ? (manifest.assets ?? {}) : { rig: "rig.json", layers: "built/layers.json", sprites: "built/sprites/sprites.json" };
158
+ const files = [assets.rig, assets.layers, assets.sprites];
159
+ // every location is checked before anything is fetched
160
+ const locations = files.map((file) => (file === undefined ? undefined : at(file, root)));
161
+ return [manifestText, ...(await Promise.all(locations.map((location) => (location ? fetchText(location) : null))))];
162
+ };
163
+ const sourceStamp = (source) => (isHttp(source) ? avatarUrlStamp(source) : folderStamp(source));
108
164
  const even = (n) => Math.max(2, Math.round(n / 2) * 2); // video codecs need even sizes
109
165
  /** Renders one track (or reuses it), returning where it goes on the canvas. */
110
166
  const renderAvatarTrack = async (plan, context, getAvatarScript) => {
@@ -112,7 +168,7 @@ const renderAvatarTrack = async (plan, context, getAvatarScript) => {
112
168
  // rendered once, at the largest size it is shown; smaller placements scale it down
113
169
  const height = even(Math.max(...plan.placements.map((p) => percent(p.position.scale, canvas.height))));
114
170
  const segments = plan.segments.map((segment) => ({ ...segment, audioStamp: fileStamp(segment.audio) }));
115
- const identity = { version: 4, segments, source: plan.source, sourceStamp: folderStamp(plan.source), duration: plan.duration, height, padTop: PAD_TOP };
171
+ const identity = { version: 4, segments, source: plan.source, sourceStamp: await sourceStamp(plan.source), duration: plan.duration, height, padTop: PAD_TOP };
116
172
  const hash = createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 12);
117
173
  const dir = MulmoStudioContextMethods.getImageProjectDirPath(context);
118
174
  const name = `avatar_${plan.speakerId.replace(/[^\w-]/g, "_")}_${hash}`;
@@ -0,0 +1,6 @@
1
+ export { REMOTION_COMPONENT_GUIDE } from "./utils/remotion/system_prompt.js";
2
+ export { REMOTION_PACKAGES, REMOTION_RENDER_PACKAGES, REMOTION_SCENE_PACKAGES, remotionInstallCommand } from "./utils/remotion/packages.js";
3
+ export { ensureRemotionPackages, missingInstalledRemotionPackages } from "./utils/remotion/render.js";
4
+ export { REMOTION_REVIEW_FRACTIONS } from "./utils/remotion/frames.js";
5
+ export { renderRemotionFrames } from "./utils/remotion/render_frames.js";
6
+ export type { RemotionFrame, RemotionFramesRequest } from "./utils/remotion/render_frames.js";
@@ -0,0 +1,6 @@
1
+ // Entry point for hosts whose own agent writes the remotion beat's component and passes it as `code`.
2
+ export { REMOTION_COMPONENT_GUIDE } from "./utils/remotion/system_prompt.js";
3
+ export { REMOTION_PACKAGES, REMOTION_RENDER_PACKAGES, REMOTION_SCENE_PACKAGES, remotionInstallCommand } from "./utils/remotion/packages.js";
4
+ export { ensureRemotionPackages, missingInstalledRemotionPackages } from "./utils/remotion/render.js";
5
+ export { REMOTION_REVIEW_FRACTIONS } from "./utils/remotion/frames.js";
6
+ export { renderRemotionFrames } from "./utils/remotion/render_frames.js";
@@ -0,0 +1 @@
1
+ export { REMOTION_COMPONENT_GUIDE } from "./utils/remotion/system_prompt.js";
@@ -0,0 +1,3 @@
1
+ // Entry point for code that only needs the remotion component guide — a tool description built into
2
+ // a browser bundle, for example. `mulmocast/remotion` also carries the renderer, which needs Node.
3
+ export { REMOTION_COMPONENT_GUIDE } from "./utils/remotion/system_prompt.js";
@@ -545,11 +545,28 @@ export declare const mulmoVisionMediaSchema: z.ZodObject<{
545
545
  style: z.ZodString;
546
546
  data: z.ZodRecord<z.ZodString, z.ZodAny>;
547
547
  }, z.core.$strict>;
548
- export declare const mulmoRemotionMediaSchema: z.ZodObject<{
549
- type: z.ZodLiteral<"remotion">;
548
+ export declare const remotionCodeSourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
549
+ kind: z.ZodLiteral<"text">;
550
+ text: z.ZodString;
551
+ }, z.core.$strict>, z.ZodObject<{
552
+ kind: z.ZodLiteral<"path">;
553
+ path: z.ZodString;
554
+ }, z.core.$strict>], "kind">;
555
+ export declare const mulmoRemotionMediaSchema: z.ZodUnion<readonly [z.ZodObject<{
550
556
  prompt: z.ZodString;
557
+ type: z.ZodLiteral<"remotion">;
551
558
  fps: z.ZodOptional<z.ZodNumber>;
552
- }, z.core.$strict>;
559
+ }, z.core.$strict>, z.ZodObject<{
560
+ code: z.ZodDiscriminatedUnion<[z.ZodObject<{
561
+ kind: z.ZodLiteral<"text">;
562
+ text: z.ZodString;
563
+ }, z.core.$strict>, z.ZodObject<{
564
+ kind: z.ZodLiteral<"path">;
565
+ path: z.ZodString;
566
+ }, z.core.$strict>], "kind">;
567
+ type: z.ZodLiteral<"remotion">;
568
+ fps: z.ZodOptional<z.ZodNumber>;
569
+ }, z.core.$strict>]>;
553
570
  export declare const mulmoRemotionParamsSchema: z.ZodObject<{
554
571
  brief: z.ZodOptional<z.ZodString>;
555
572
  }, z.core.$strict>;
@@ -4233,11 +4250,21 @@ export declare const mulmoImageAssetSchema: z.ZodUnion<readonly [z.ZodObject<{
4233
4250
  bgOpacity: z.ZodOptional<z.ZodNumber>;
4234
4251
  }, z.core.$strip>>;
4235
4252
  }, z.core.$strict>>>;
4253
+ }, z.core.$strict>, z.ZodUnion<readonly [z.ZodObject<{
4254
+ prompt: z.ZodString;
4255
+ type: z.ZodLiteral<"remotion">;
4256
+ fps: z.ZodOptional<z.ZodNumber>;
4236
4257
  }, z.core.$strict>, z.ZodObject<{
4258
+ code: z.ZodDiscriminatedUnion<[z.ZodObject<{
4259
+ kind: z.ZodLiteral<"text">;
4260
+ text: z.ZodString;
4261
+ }, z.core.$strict>, z.ZodObject<{
4262
+ kind: z.ZodLiteral<"path">;
4263
+ path: z.ZodString;
4264
+ }, z.core.$strict>], "kind">;
4237
4265
  type: z.ZodLiteral<"remotion">;
4238
- prompt: z.ZodString;
4239
4266
  fps: z.ZodOptional<z.ZodNumber>;
4240
- }, z.core.$strict>]>;
4267
+ }, z.core.$strict>]>]>;
4241
4268
  export declare const mulmoAudioAssetSchema: z.ZodUnion<readonly [z.ZodObject<{
4242
4269
  type: z.ZodLiteral<"audio">;
4243
4270
  source: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -8644,11 +8671,21 @@ export declare const mulmoBeatSchema: z.ZodObject<{
8644
8671
  bgOpacity: z.ZodOptional<z.ZodNumber>;
8645
8672
  }, z.core.$strip>>;
8646
8673
  }, z.core.$strict>>>;
8674
+ }, z.core.$strict>, z.ZodUnion<readonly [z.ZodObject<{
8675
+ prompt: z.ZodString;
8676
+ type: z.ZodLiteral<"remotion">;
8677
+ fps: z.ZodOptional<z.ZodNumber>;
8647
8678
  }, z.core.$strict>, z.ZodObject<{
8679
+ code: z.ZodDiscriminatedUnion<[z.ZodObject<{
8680
+ kind: z.ZodLiteral<"text">;
8681
+ text: z.ZodString;
8682
+ }, z.core.$strict>, z.ZodObject<{
8683
+ kind: z.ZodLiteral<"path">;
8684
+ path: z.ZodString;
8685
+ }, z.core.$strict>], "kind">;
8648
8686
  type: z.ZodLiteral<"remotion">;
8649
- prompt: z.ZodString;
8650
8687
  fps: z.ZodOptional<z.ZodNumber>;
8651
- }, z.core.$strict>]>>;
8688
+ }, z.core.$strict>]>]>>;
8652
8689
  audio: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
8653
8690
  type: z.ZodLiteral<"audio">;
8654
8691
  source: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -13901,11 +13938,21 @@ export declare const mulmoScriptSchema: z.ZodObject<{
13901
13938
  bgOpacity: z.ZodOptional<z.ZodNumber>;
13902
13939
  }, z.core.$strip>>;
13903
13940
  }, z.core.$strict>>>;
13941
+ }, z.core.$strict>, z.ZodUnion<readonly [z.ZodObject<{
13942
+ prompt: z.ZodString;
13943
+ type: z.ZodLiteral<"remotion">;
13944
+ fps: z.ZodOptional<z.ZodNumber>;
13904
13945
  }, z.core.$strict>, z.ZodObject<{
13946
+ code: z.ZodDiscriminatedUnion<[z.ZodObject<{
13947
+ kind: z.ZodLiteral<"text">;
13948
+ text: z.ZodString;
13949
+ }, z.core.$strict>, z.ZodObject<{
13950
+ kind: z.ZodLiteral<"path">;
13951
+ path: z.ZodString;
13952
+ }, z.core.$strict>], "kind">;
13905
13953
  type: z.ZodLiteral<"remotion">;
13906
- prompt: z.ZodString;
13907
13954
  fps: z.ZodOptional<z.ZodNumber>;
13908
- }, z.core.$strict>]>>;
13955
+ }, z.core.$strict>]>]>>;
13909
13956
  audio: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
13910
13957
  type: z.ZodLiteral<"audio">;
13911
13958
  source: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -18682,11 +18729,21 @@ export declare const mulmoStudioSchema: z.ZodObject<{
18682
18729
  bgOpacity: z.ZodOptional<z.ZodNumber>;
18683
18730
  }, z.core.$strip>>;
18684
18731
  }, z.core.$strict>>>;
18732
+ }, z.core.$strict>, z.ZodUnion<readonly [z.ZodObject<{
18733
+ prompt: z.ZodString;
18734
+ type: z.ZodLiteral<"remotion">;
18735
+ fps: z.ZodOptional<z.ZodNumber>;
18685
18736
  }, z.core.$strict>, z.ZodObject<{
18737
+ code: z.ZodDiscriminatedUnion<[z.ZodObject<{
18738
+ kind: z.ZodLiteral<"text">;
18739
+ text: z.ZodString;
18740
+ }, z.core.$strict>, z.ZodObject<{
18741
+ kind: z.ZodLiteral<"path">;
18742
+ path: z.ZodString;
18743
+ }, z.core.$strict>], "kind">;
18686
18744
  type: z.ZodLiteral<"remotion">;
18687
- prompt: z.ZodString;
18688
18745
  fps: z.ZodOptional<z.ZodNumber>;
18689
- }, z.core.$strict>]>>;
18746
+ }, z.core.$strict>]>]>>;
18690
18747
  audio: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
18691
18748
  type: z.ZodLiteral<"audio">;
18692
18749
  source: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -46,7 +46,9 @@ export const avatarPositionSchema = z
46
46
  export const avatarEmotionSchema = z.enum(["neutral", "happy", "sad", "angry", "surprised", "relaxed"]);
47
47
  export const speakerAvatarSchema = z
48
48
  .object({
49
- source: z.string().describe("AvatarScript avatar package (a folder with avatar.json, or a mesh-avatar-studio project), relative to the script"),
49
+ source: z
50
+ .string()
51
+ .describe("AvatarScript avatar package (a folder with avatar.json, or a mesh-avatar-studio project): a path relative to the script, or an http(s) URL"),
50
52
  position: avatarPositionSchema.optional(),
51
53
  })
52
54
  .strict();
@@ -359,13 +361,29 @@ export const mulmoVisionMediaSchema = z
359
361
  data: z.record(z.string(), z.any()),
360
362
  })
361
363
  .strict();
362
- export const mulmoRemotionMediaSchema = z
363
- .object({
364
+ export const remotionCodeSourceSchema = z.discriminatedUnion("kind", [
365
+ z.object({ kind: z.literal("text"), text: z.string().min(1) }).strict(),
366
+ z.object({ kind: z.literal("path"), path: z.string().min(1).describe("Relative to the script") }).strict(),
367
+ ]);
368
+ const remotionMediaShape = {
364
369
  type: z.literal(ImageMediaType.Remotion),
365
- prompt: z.string().min(1).describe("What the scene shows. Claude Code (claude -p) writes a Remotion component from it."),
366
370
  fps: z.number().min(1).max(60).optional().describe("Frame rate of the rendered video. Default: 30."),
367
- })
368
- .strict();
371
+ };
372
+ // Two strict shapes rather than one object with a refine, so the bundled JSON schema states "prompt or code, not both" too.
373
+ export const mulmoRemotionMediaSchema = z.union([
374
+ z
375
+ .object({
376
+ ...remotionMediaShape,
377
+ prompt: z.string().min(1).describe("What the scene shows. Claude Code (claude -p) writes a Remotion component from it."),
378
+ })
379
+ .strict(),
380
+ z
381
+ .object({
382
+ ...remotionMediaShape,
383
+ code: remotionCodeSourceSchema.describe("A finished Remotion component (TSX that default-exports the scene). It is rendered as is, without claude -p."),
384
+ })
385
+ .strict(),
386
+ ]);
369
387
  export const mulmoRemotionParamsSchema = z
370
388
  .object({
371
389
  brief: z.string().optional().describe("Whole-video art direction shared by every remotion beat (palette, type, mood), so all scenes look like one film."),
@@ -1,6 +1,6 @@
1
1
  import { type CallbackFunction } from "graphai";
2
2
  import type { UsageCollectorAPI } from "./usage.js";
3
- import { langSchema, localizedTextSchema, mulmoBeatSchema, mulmoScriptSchema, mulmoStudioSchema, mulmoStudioBeatSchema, mulmoStoryboardSchema, mulmoStoryboardSceneSchema, mulmoStudioMultiLingualSchema, mulmoStudioMultiLingualArraySchema, mulmoStudioMultiLingualDataSchema, mulmoStudioMultiLingualFileSchema, speakerDictionarySchema, speakerSchema, mulmoSpeechParamsSchema, mulmoImageParamsSchema, mulmoImageParamsImagesValueSchema, mulmoImageParamsImagesSchema, mulmoFillOptionSchema, mulmoTransitionSchema, mulmoVideoFilterSchema, mulmoMovieParamsSchema, mulmoSoundEffectParamsSchema, mulmoLipSyncParamsSchema, textSlideParamsSchema, speechOptionsSchema, speakerDataSchema, mulmoCanvasDimensionSchema, mulmoPromptTemplateSchema, mulmoPromptTemplateFileSchema, text2ImageProviderSchema, text2HtmlImageProviderSchema, text2MovieProviderSchema, text2SpeechProviderSchema, mulmoPresentationStyleSchema, multiLingualTextsSchema, mulmoImageAssetSchema, mulmoMermaidMediaSchema, mulmoRemotionMediaSchema, mulmoTextSlideMediaSchema, mulmoMarkdownMediaSchema, mulmoImageMediaSchema, mulmoChartMediaSchema, mediaSourceSchema, mediaSourceMermaidSchema, backgroundImageSchema, backgroundImageSourceSchema, mulmoSessionStateSchema, mulmoOpenAIImageModelSchema, mulmoGoogleImageModelSchema, mulmoGoogleMovieModelSchema, mulmoReplicateMovieModelSchema, mulmoImagePromptMediaSchema, mulmoImageReferenceSchema, mulmoMovieMediaSchema, mulmoMoviePromptMediaSchema, markdownLayoutSchema, row2Schema, grid2x2Schema, mulmoAvatarParamsSchema, avatarPositionSchema, speakerAvatarSchema, mulmoAvatarTrackSchema } from "./schema.js";
3
+ import { langSchema, localizedTextSchema, mulmoBeatSchema, mulmoScriptSchema, mulmoStudioSchema, mulmoStudioBeatSchema, mulmoStoryboardSchema, mulmoStoryboardSceneSchema, mulmoStudioMultiLingualSchema, mulmoStudioMultiLingualArraySchema, mulmoStudioMultiLingualDataSchema, mulmoStudioMultiLingualFileSchema, speakerDictionarySchema, speakerSchema, mulmoSpeechParamsSchema, mulmoImageParamsSchema, mulmoImageParamsImagesValueSchema, mulmoImageParamsImagesSchema, mulmoFillOptionSchema, mulmoTransitionSchema, mulmoVideoFilterSchema, mulmoMovieParamsSchema, mulmoSoundEffectParamsSchema, mulmoLipSyncParamsSchema, textSlideParamsSchema, speechOptionsSchema, speakerDataSchema, mulmoCanvasDimensionSchema, mulmoPromptTemplateSchema, mulmoPromptTemplateFileSchema, text2ImageProviderSchema, text2HtmlImageProviderSchema, text2MovieProviderSchema, text2SpeechProviderSchema, mulmoPresentationStyleSchema, multiLingualTextsSchema, mulmoImageAssetSchema, mulmoMermaidMediaSchema, mulmoRemotionMediaSchema, remotionCodeSourceSchema, mulmoTextSlideMediaSchema, mulmoMarkdownMediaSchema, mulmoImageMediaSchema, mulmoChartMediaSchema, mediaSourceSchema, mediaSourceMermaidSchema, backgroundImageSchema, backgroundImageSourceSchema, mulmoSessionStateSchema, mulmoOpenAIImageModelSchema, mulmoGoogleImageModelSchema, mulmoGoogleMovieModelSchema, mulmoReplicateMovieModelSchema, mulmoImagePromptMediaSchema, mulmoImageReferenceSchema, mulmoMovieMediaSchema, mulmoMoviePromptMediaSchema, markdownLayoutSchema, row2Schema, grid2x2Schema, mulmoAvatarParamsSchema, avatarPositionSchema, speakerAvatarSchema, mulmoAvatarTrackSchema } from "./schema.js";
4
4
  import { pdf_modes, pdf_sizes, storyToScriptGenerateMode } from "./const.js";
5
5
  import type { LLM } from "./provider2agent.js";
6
6
  import { z } from "zod";
@@ -66,6 +66,7 @@ export type MulmoMovieMedia = z.infer<typeof mulmoMovieMediaSchema>;
66
66
  export type MulmoChartMedia = z.infer<typeof mulmoChartMediaSchema>;
67
67
  export type MulmoMermaidMedia = z.infer<typeof mulmoMermaidMediaSchema>;
68
68
  export type MulmoRemotionMedia = z.infer<typeof mulmoRemotionMediaSchema>;
69
+ export type MulmoRemotionCodeSource = z.infer<typeof remotionCodeSourceSchema>;
69
70
  export type MulmoSessionState = z.infer<typeof mulmoSessionStateSchema>;
70
71
  export type MulmoStudioContext = {
71
72
  fileDirs: FileObject;
@@ -549,8 +549,18 @@ export declare const createStudioData: (_mulmoScript: MulmoScript, fileName: str
549
549
  style: string;
550
550
  data: Record<string, any>;
551
551
  } | {
552
- type: "remotion";
553
552
  prompt: string;
553
+ type: "remotion";
554
+ fps?: number | undefined;
555
+ } | {
556
+ code: {
557
+ kind: "text";
558
+ text: string;
559
+ } | {
560
+ kind: "path";
561
+ path: string;
562
+ };
563
+ type: "remotion";
554
564
  fps?: number | undefined;
555
565
  } | {
556
566
  type: "slide";
@@ -3178,8 +3188,18 @@ export declare const initializeContextFromFiles: (files: FileObject, raiseError:
3178
3188
  style: string;
3179
3189
  data: Record<string, any>;
3180
3190
  } | {
3181
- type: "remotion";
3182
3191
  prompt: string;
3192
+ type: "remotion";
3193
+ fps?: number | undefined;
3194
+ } | {
3195
+ code: {
3196
+ kind: "text";
3197
+ text: string;
3198
+ } | {
3199
+ kind: "path";
3200
+ path: string;
3201
+ };
3202
+ type: "remotion";
3183
3203
  fps?: number | undefined;
3184
3204
  } | {
3185
3205
  type: "slide";
@@ -1,4 +1,4 @@
1
- import { ImageProcessorParams } from "../../types/index.js";
1
+ import { ImageProcessorParams, MulmoRemotionCodeSource, MulmoStudioContext } from "../../types/index.js";
2
2
  import { ComponentReviewer, ComponentWriter } from "../remotion/claude_runner.js";
3
3
  import { SceneRenderer } from "../remotion/render.js";
4
4
  export declare const imageType: "remotion";
@@ -11,6 +11,7 @@ export type RemotionDeps = {
11
11
  export declare const remotionWorkDir: (imagePath: string) => string;
12
12
  export declare const remotionStillPath: (imagePath: string) => string;
13
13
  export declare const reviewCandidatePath: (filePath: string) => string;
14
+ export declare const remotionCodeLocation: (code: MulmoRemotionCodeSource, context: MulmoStudioContext, beatIndex: number) => string;
14
15
  export declare const createRemotionProcess: (deps: RemotionDeps) => (params: ImageProcessorParams) => Promise<string | undefined>;
15
16
  export declare const process: (params: ImageProcessorParams) => Promise<string | undefined>;
16
17
  export declare const path: (params: import("../../types/type.js").BeatPathParams) => string;
@@ -3,10 +3,12 @@ import nodePath from "node:path";
3
3
  import { GraphAILogger } from "graphai";
4
4
  import { ImageMediaType } from "../../types/index.js";
5
5
  import { localizedText } from "../utils.js";
6
+ import { getFullPath } from "../file.js";
6
7
  import { REMOTION_COMPONENT_FILE, buildRepairPrompt, buildReviewPrompt, buildScenePrompt, remotionCacheKey, } from "../remotion/claude_prompt.js";
7
8
  import { reviewComponentWithClaude, writeComponentWithClaude } from "../remotion/claude_runner.js";
8
9
  import { ensureRemotionPackages, renderRemotionScene } from "../remotion/render.js";
9
10
  import { commitFiles } from "../remotion/commit_files.js";
11
+ import { REMOTION_REVIEW_FRACTIONS, frameAtFraction, toFrameCount } from "../remotion/frames.js";
10
12
  import { parrotingImagePath } from "./utils.js";
11
13
  export const imageType = ImageMediaType.Remotion;
12
14
  const DEFAULT_FPS = 30;
@@ -14,7 +16,6 @@ const MAX_REPAIR_ATTEMPTS = 2;
14
16
  // Without audio the length is unknown; any length gives the same final frame, since the system
15
17
  // prompt makes every animation finish before the end.
16
18
  const STILL_ONLY_DURATION_SEC = 10;
17
- const REVIEW_FRACTIONS = [0.3, 0.6, 0.95];
18
19
  export const remotionWorkDir = (imagePath) => imagePath.replace(/(_animated)?\.(mp4|png)$/, "_remotion");
19
20
  export const remotionStillPath = (imagePath) => imagePath.replace(/_animated\.mp4$/, ".png");
20
21
  const loadOrWriteComponent = async (job, force, deps) => {
@@ -44,9 +45,9 @@ const renderWithRepair = async (job, code, attemptsLeft, deps, reviewStills = []
44
45
  return await renderWithRepair(job, repaired, attemptsLeft - 1, deps, reviewStills);
45
46
  }
46
47
  };
47
- const reviewStillsFor = (job) => REVIEW_FRACTIONS.map((fraction, index) => ({
48
+ const reviewStillsFor = (job) => REMOTION_REVIEW_FRACTIONS.map((fraction, index) => ({
48
49
  fraction,
49
- frame: Math.min(job.props.durationInFrames - 1, Math.floor(job.props.durationInFrames * fraction)),
50
+ frame: frameAtFraction(job.props.durationInFrames, fraction),
50
51
  file: nodePath.join(job.workDir, `review_${index}.png`),
51
52
  }));
52
53
  const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
@@ -103,20 +104,22 @@ const reviewAndImprove = async (job, renderedCode, reviewStills, deps) => {
103
104
  GraphAILogger.info(`remotion: visual review improved ${job.codePath}`);
104
105
  await renderReviewedVersion(job, improved, deps);
105
106
  };
106
- const toFrameCount = (durationSec, fps) => {
107
- const frames = Math.floor(durationSec * fps);
108
- if (frames <= 0) {
109
- throw new Error(`remotion: frame count is ${frames} (duration=${durationSec}, fps=${fps}). Increase duration or fps.`);
110
- }
111
- return frames;
112
- };
113
107
  // Lets a scene number itself and pace its place in the video; unknown when the beat is not in the script.
114
108
  const scenePosition = (beats, index) => (index < 0 ? undefined : { index, count: beats.length });
115
109
  // The words actually spoken over the scene: the translation when the video is rendered in another language.
116
110
  const spokenNarration = (context, beat, index) => (index < 0 ? beat.text : localizedText(beat, context.multiLingual?.[index], context.lang, context.studio.script.lang)) || undefined;
117
- const buildJob = (params, prompt, fps) => {
118
- const { beat, context, imagePath, canvasSize } = params;
111
+ const buildSceneOutput = (params, fps) => {
112
+ const { beat, imagePath, canvasSize } = params;
119
113
  const duration = params.beatDuration ?? beat.duration;
114
+ return {
115
+ workDir: remotionWorkDir(imagePath),
116
+ props: { durationInFrames: toFrameCount(duration ?? STILL_ONLY_DURATION_SEC, fps), fps, width: canvasSize.width, height: canvasSize.height },
117
+ videoPath: duration !== undefined ? imagePath : undefined,
118
+ stillPath: remotionStillPath(imagePath),
119
+ };
120
+ };
121
+ const buildJob = (params, prompt, fps) => {
122
+ const { beat, context, canvasSize } = params;
120
123
  const index = context.studio.script.beats.indexOf(beat);
121
124
  const spec = {
122
125
  prompt,
@@ -127,21 +130,49 @@ const buildJob = (params, prompt, fps) => {
127
130
  width: canvasSize.width,
128
131
  height: canvasSize.height,
129
132
  };
130
- const workDir = remotionWorkDir(imagePath);
131
- return {
132
- spec,
133
- workDir,
134
- codePath: nodePath.join(workDir, `${remotionCacheKey(spec)}.tsx`),
135
- props: { durationInFrames: toFrameCount(duration ?? STILL_ONLY_DURATION_SEC, fps), fps, width: spec.width, height: spec.height },
136
- videoPath: duration !== undefined ? imagePath : undefined,
137
- stillPath: remotionStillPath(imagePath),
138
- };
133
+ const output = buildSceneOutput(params, fps);
134
+ return { ...output, spec, codePath: nodePath.join(output.workDir, `${remotionCacheKey(spec)}.tsx`) };
135
+ };
136
+ // Named in errors, so whoever wrote the component (usually the host's agent) knows what to fix.
137
+ export const remotionCodeLocation = (code, context, beatIndex) => {
138
+ if (code.kind === "path")
139
+ return getFullPath(context.fileDirs.mulmoFileDirPath, code.path);
140
+ return beatIndex < 0 ? "inline code" : `inline code of beat ${beatIndex + 1}`;
141
+ };
142
+ const readGivenComponent = (code, location) => {
143
+ if (code.kind === "text")
144
+ return code.text;
145
+ try {
146
+ return fs.readFileSync(location, "utf8");
147
+ }
148
+ catch (error) {
149
+ throw new Error(`remotion: cannot read the component file ${location}`, { cause: error });
150
+ }
151
+ };
152
+ // A given component is the caller's to fix, so a failed render stops here instead of asking claude -p to repair it.
153
+ const renderGivenComponent = async (params, code, fps, deps) => {
154
+ const output = buildSceneOutput(params, fps);
155
+ const location = remotionCodeLocation(code, params.context, params.context.studio.script.beats.indexOf(params.beat));
156
+ deps.ensurePackages();
157
+ const source = readGivenComponent(code, location);
158
+ fs.mkdirSync(output.workDir, { recursive: true });
159
+ fs.writeFileSync(nodePath.join(output.workDir, REMOTION_COMPONENT_FILE), source);
160
+ try {
161
+ await deps.renderScene({ workDir: output.workDir, props: output.props, videoPath: output.videoPath, stillPath: output.stillPath });
162
+ }
163
+ catch (error) {
164
+ throw new Error(`remotion beat failed to render the given component (code: ${location}): ${errorMessage(error)}`, { cause: error });
165
+ }
166
+ return output.videoPath ?? output.stillPath;
139
167
  };
140
168
  export const createRemotionProcess = (deps) => async (params) => {
141
169
  const { beat, context } = params;
142
170
  if (!beat.image || beat.image.type !== imageType)
143
171
  return;
144
- const job = buildJob(params, beat.image.prompt, beat.image.fps ?? DEFAULT_FPS);
172
+ const fps = beat.image.fps ?? DEFAULT_FPS;
173
+ if ("code" in beat.image)
174
+ return await renderGivenComponent(params, beat.image.code, fps, deps);
175
+ const job = buildJob(params, beat.image.prompt, fps);
145
176
  deps.ensurePackages();
146
177
  fs.mkdirSync(job.workDir, { recursive: true });
147
178
  const { code, isNew } = await loadOrWriteComponent(job, context.force, deps);
@@ -0,0 +1,3 @@
1
+ export declare const REMOTION_REVIEW_FRACTIONS: readonly number[];
2
+ export declare const toFrameCount: (durationSec: number, fps: number) => number;
3
+ export declare const frameAtFraction: (durationInFrames: number, fraction: number) => number;
@@ -0,0 +1,11 @@
1
+ // The same moments the visual review looks at; a host reviewing its own component gets these by default.
2
+ export const REMOTION_REVIEW_FRACTIONS = [0.3, 0.6, 0.95];
3
+ export const toFrameCount = (durationSec, fps) => {
4
+ const frames = Math.floor(durationSec * fps);
5
+ if (!Number.isFinite(frames) || frames <= 0) {
6
+ throw new Error(`remotion: frame count is ${frames} (duration=${durationSec}, fps=${fps}). Increase duration or fps.`);
7
+ }
8
+ return frames;
9
+ };
10
+ // Clamped to the last frame, so a fraction of 1 is the final frame rather than one past it.
11
+ export const frameAtFraction = (durationInFrames, fraction) => Math.min(durationInFrames - 1, Math.floor(durationInFrames * fraction));
@@ -8,12 +8,13 @@ export type RemotionRenderRequest = {
8
8
  workDir: string;
9
9
  props: RemotionSceneProps;
10
10
  videoPath?: string;
11
- stillPath: string;
11
+ stillPath?: string;
12
12
  extraStills?: {
13
13
  frame: number;
14
14
  path: string;
15
15
  }[];
16
16
  };
17
17
  export type SceneRenderer = (request: RemotionRenderRequest) => Promise<void>;
18
+ export declare const missingInstalledRemotionPackages: () => string[];
18
19
  export declare const ensureRemotionPackages: () => void;
19
20
  export declare const renderRemotionScene: SceneRenderer;
@@ -24,10 +24,12 @@ const isPackageInstalled = (name) => {
24
24
  return !(error instanceof Error && "code" in error && error.code === "MODULE_NOT_FOUND");
25
25
  }
26
26
  };
27
+ // Resolved from mulmocast's own location, which is where the renderer looks for them.
28
+ export const missingInstalledRemotionPackages = () => missingRemotionPackages(isPackageInstalled);
27
29
  // Checked before asking claude -p for a component: a generated import of a missing optional peer
28
30
  // would otherwise surface as an opaque bundling error after the generation was already paid for.
29
31
  export const ensureRemotionPackages = () => {
30
- const missing = missingRemotionPackages(isPackageInstalled);
32
+ const missing = missingInstalledRemotionPackages();
31
33
  if (missing.length > 0) {
32
34
  throw new Error(`The remotion beat needs packages that are not installed (${missing.join(", ")}). Install them with: ${remotionInstallCommand()}`);
33
35
  }
@@ -55,7 +57,7 @@ export const renderRemotionScene = async ({ workDir, props, videoPath, stillPath
55
57
  if (videoPath) {
56
58
  await renderer.renderMedia({ composition, serveUrl, codec: "h264", outputLocation: videoPath, inputProps: props, chromiumOptions: CHROMIUM_OPTIONS });
57
59
  }
58
- const stills = [...extraStills, { frame: composition.durationInFrames - 1, path: stillPath }];
60
+ const stills = stillPath ? [...extraStills, { frame: composition.durationInFrames - 1, path: stillPath }] : extraStills;
59
61
  await stills.reduce(async (previous, still) => {
60
62
  await previous;
61
63
  await renderer.renderStill({ composition, serveUrl, output: still.path, frame: still.frame, inputProps: props, chromiumOptions: CHROMIUM_OPTIONS });
@@ -0,0 +1,23 @@
1
+ import { SceneRenderer } from "./render.js";
2
+ export type RemotionFramesRequest = {
3
+ code: string;
4
+ durationSec: number;
5
+ outDir: string;
6
+ fps?: number;
7
+ width?: number;
8
+ height?: number;
9
+ fractions?: readonly number[];
10
+ };
11
+ export type RemotionFrame = {
12
+ fraction: number;
13
+ frame: number;
14
+ path: string;
15
+ };
16
+ export type RemotionFramesDeps = {
17
+ ensurePackages: () => void;
18
+ renderScene: SceneRenderer;
19
+ makeWorkDir: () => string;
20
+ };
21
+ export declare const framePlan: (durationInFrames: number, fractions: readonly number[], outDir: string) => RemotionFrame[];
22
+ export declare const createRemotionFrameRenderer: (deps: RemotionFramesDeps) => (request: RemotionFramesRequest) => Promise<RemotionFrame[]>;
23
+ export declare const renderRemotionFrames: (request: RemotionFramesRequest) => Promise<RemotionFrame[]>;
@@ -0,0 +1,61 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { REMOTION_COMPONENT_FILE } from "./claude_prompt.js";
5
+ import { REMOTION_REVIEW_FRACTIONS, frameAtFraction, toFrameCount } from "./frames.js";
6
+ import { ensureRemotionPackages, renderRemotionScene } from "./render.js";
7
+ const DEFAULT_FPS = 30;
8
+ const DEFAULT_WIDTH = 1920;
9
+ const DEFAULT_HEIGHT = 1080;
10
+ const isValidFraction = (fraction) => Number.isFinite(fraction) && fraction >= 0 && fraction <= 1;
11
+ const checkFractions = (fractions) => {
12
+ if (fractions.length === 0)
13
+ throw new Error("remotion: fractions is empty");
14
+ const invalid = fractions.filter((fraction) => !isValidFraction(fraction));
15
+ if (invalid.length > 0)
16
+ throw new Error(`remotion: fractions must be between 0 and 1 (got ${invalid.join(", ")})`);
17
+ };
18
+ // Named by percent so a reader can tell which moment each file is; two fractions on one percent keep their order.
19
+ export const framePlan = (durationInFrames, fractions, outDir) => fractions.map((fraction, index) => {
20
+ const percent = String(Math.round(fraction * 100)).padStart(3, "0");
21
+ return { fraction, frame: frameAtFraction(durationInFrames, fraction), path: path.join(outDir, `frame_${index}_${percent}.png`) };
22
+ });
23
+ const isPositiveInteger = (value) => Number.isInteger(value) && value > 0;
24
+ const checkCanvas = (width, height) => {
25
+ if (!isPositiveInteger(width) || !isPositiveInteger(height))
26
+ throw new Error(`remotion: width and height must be positive integers (got ${width}x${height})`);
27
+ };
28
+ const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
29
+ export const createRemotionFrameRenderer = (deps) => async (request) => {
30
+ const fps = request.fps ?? DEFAULT_FPS;
31
+ const props = {
32
+ durationInFrames: toFrameCount(request.durationSec, fps),
33
+ fps,
34
+ width: request.width ?? DEFAULT_WIDTH,
35
+ height: request.height ?? DEFAULT_HEIGHT,
36
+ };
37
+ const fractions = request.fractions ?? REMOTION_REVIEW_FRACTIONS;
38
+ checkFractions(fractions);
39
+ checkCanvas(props.width, props.height);
40
+ deps.ensurePackages();
41
+ const frames = framePlan(props.durationInFrames, fractions, request.outDir);
42
+ fs.mkdirSync(request.outDir, { recursive: true });
43
+ const workDir = deps.makeWorkDir();
44
+ try {
45
+ fs.writeFileSync(path.join(workDir, REMOTION_COMPONENT_FILE), request.code);
46
+ await deps.renderScene({ workDir, props, extraStills: frames.map(({ frame, path: file }) => ({ frame, path: file })) });
47
+ return frames;
48
+ }
49
+ catch (error) {
50
+ throw new Error(`remotion: failed to render the component's frames: ${errorMessage(error)}`, { cause: error });
51
+ }
52
+ finally {
53
+ fs.rmSync(workDir, { recursive: true, force: true });
54
+ }
55
+ };
56
+ // The bundle and entry files go to a temporary directory, so outDir ends up holding only the PNGs.
57
+ export const renderRemotionFrames = createRemotionFrameRenderer({
58
+ ensurePackages: ensureRemotionPackages,
59
+ renderScene: renderRemotionScene,
60
+ makeWorkDir: () => fs.mkdtempSync(path.join(os.tmpdir(), "mulmocast-remotion-frames-")),
61
+ });
@@ -1,3 +1,4 @@
1
1
  export declare const REMOTION_SYSTEM_PROMPT: string;
2
+ export declare const REMOTION_COMPONENT_GUIDE: string;
2
3
  export declare const REMOTION_REVIEW_MARKER = "LGTM";
3
4
  export declare const REMOTION_REVIEW_SYSTEM_PROMPT: string;
@@ -2,13 +2,13 @@ import { REMOTION_SCENE_PACKAGES } from "./packages.js";
2
2
  const ALLOWED_IMPORTS = ["react", "remotion", ...REMOTION_SCENE_PACKAGES].map((name) => `"${name}"`).join(", ");
3
3
  // The contract and the house style for the component `claude -p` writes for one remotion beat.
4
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()\`.
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()\`.
10
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>".
11
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
12
  const TIMING_RULES = `# Timing (hard rules — breaking them causes flicker or broken renders)
13
13
  - Everything is a pure function of \`useCurrentFrame()\`. NEVER use CSS animations/transitions, \`setTimeout\`, \`requestAnimationFrame\`, \`Date\`, or three.js \`useFrame\`.
14
14
  - NEVER use \`Math.random()\`; use \`random("seed-" + i)\` from "remotion" or \`noise2D/noise3D\` from "@remotion/noise".
@@ -51,6 +51,10 @@ const SELF_CHECK = `# Before you answer, check silently
51
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
52
  - The scene matches the brief and the narration, and looks like a frame from a premium brand film.`;
53
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");
54
58
  export const REMOTION_REVIEW_MARKER = "LGTM";
55
59
  export const REMOTION_REVIEW_SYSTEM_PROMPT = [
56
60
  REMOTION_SYSTEM_PROMPT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmocast",
3
- "version": "2.13.0",
3
+ "version": "2.15.0",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "lib/index.node.js",
@@ -16,6 +16,14 @@
16
16
  "./data": {
17
17
  "default": "./lib/data/index.js"
18
18
  },
19
+ "./remotion": {
20
+ "types": "./lib/index.remotion.d.ts",
21
+ "default": "./lib/index.remotion.js"
22
+ },
23
+ "./remotion/guide": {
24
+ "types": "./lib/index.remotion_guide.d.ts",
25
+ "default": "./lib/index.remotion_guide.js"
26
+ },
19
27
  "./assets/*": "./assets/*",
20
28
  "./scripts/*": "./scripts/*",
21
29
  "./tools/complete_script": {
@@ -141,7 +149,7 @@
141
149
  "@types/jsdom": "^28.0.3",
142
150
  "@types/node": "^26.6.4",
143
151
  "@types/yargs": "^17.0.35",
144
- "avatarscript": "^0.2.1",
152
+ "avatarscript": "^0.3.2",
145
153
  "cross-env": "^10.1.0",
146
154
  "esbuild": "^0.28.2",
147
155
  "eslint": "^10.12.0",
@@ -171,7 +179,7 @@
171
179
  "@remotion/shapes": "^4.0.533",
172
180
  "@remotion/three": "^4.0.533",
173
181
  "@remotion/transitions": "^4.0.533",
174
- "avatarscript": "^0.2.1",
182
+ "avatarscript": "^0.3.2",
175
183
  "onnxruntime-node": "^1.30.0",
176
184
  "react": "^19.2.0",
177
185
  "react-dom": "^19.2.0",
@@ -1,46 +1,120 @@
1
1
  {
2
- "$mulmocast": { "version": "1.1" },
2
+ "$mulmocast": {
3
+ "version": "1.1"
4
+ },
3
5
  "lang": "ja",
4
6
  "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
+ "description": "Loads the sample avatar ani from mulmocast-media by URL. Needs the npm packages avatarscript and onnxruntime-node.",
8
+ "avatarParams": {
9
+ "position": {
10
+ "x": "82%",
11
+ "y": "100%",
12
+ "scale": "62%"
13
+ }
14
+ },
7
15
  "speechParams": {
8
16
  "speakers": {
9
- "Miko": {
10
- "provider": "openai",
11
- "voiceId": "coral",
12
- "displayName": { "ja": "ミコ" },
13
- "avatar": { "source": "../../../mesh-avatar-studio/samples/miko-qipao" }
17
+ "Ani": {
18
+ "provider": "gemini",
19
+ "voiceId": "Leda",
20
+ "speechOptions": {
21
+ "instruction": "Speak in a slightly high-pitched, curt tone with sudden flustered shifts—like a tsundere anime girl."
22
+ },
23
+ "displayName": {
24
+ "ja": "アニ"
25
+ },
26
+ "avatar": {
27
+ "source": "https://raw.githubusercontent.com/receptron/mulmocast-media/main/avatars/ani"
28
+ }
14
29
  }
15
30
  }
16
31
  },
17
32
  "beats": [
18
33
  {
19
- "speaker": "Miko",
20
- "text": "みなさん、こんにちは!今日は、スライドの上でしゃべるアバターを紹介します。",
21
- "image": { "type": "markdown", "markdown": ["# Talking avatars", "", "- MulmoScript + AvatarScript", "- スライドの上でアバターが話す"] },
22
- "avatarParams": { "emotion": "happy", "motions": [{ "motion": "nod", "at": "こんにちは" }] }
34
+ "speaker": "Ani",
35
+ "text": "べ、別にあなたのために説明するわけじゃないんだからね!今日は、スライドの上でしゃべるアバターを紹介してあげる。",
36
+ "image": {
37
+ "type": "markdown",
38
+ "markdown": ["# Talking avatars", "", "- MulmoScript + AvatarScript", "- スライドの上でアバターが話す"]
39
+ },
40
+ "avatarParams": {
41
+ "emotion": "happy",
42
+ "motions": [
43
+ {
44
+ "motion": "nod",
45
+ "at": "紹介"
46
+ }
47
+ ]
48
+ }
49
+ },
50
+ {
51
+ "speaker": "Ani",
52
+ "text": "このアバターはね、メッシュ・アバター・スタジオで、たった一枚のイラストから作ったの。それを、マルモキャストの上で動かしてるのよ。",
53
+ "image": {
54
+ "type": "markdown",
55
+ "markdown": ["# How it's made", "", "- mesh-avatar-studio: 1枚のイラスト → メッシュアバター", "- MulmoCast + AvatarScript: 台本 → 音声 → 口パク動画"]
56
+ },
57
+ "avatarParams": {
58
+ "emotion": "relaxed",
59
+ "motions": [
60
+ {
61
+ "motion": "nod",
62
+ "at": "マルモキャスト"
63
+ }
64
+ ]
65
+ }
23
66
  },
24
67
  {
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": "大きく" }] }
68
+ "speaker": "Ani",
69
+ "text": "位置と大きさは、台本全体で決めておいて、ビートごとに変えられるの。ほら、大きくなったでしょ。ちゃんと見なさいよね!",
70
+ "image": {
71
+ "type": "markdown",
72
+ "markdown": ["# Position and size", "", "- 台本全体: `avatarParams.position`", "- ビートごとに上書き"]
73
+ },
74
+ "avatarParams": {
75
+ "emotion": "relaxed",
76
+ "position": {
77
+ "x": "76%",
78
+ "scale": "82%"
79
+ },
80
+ "motions": [
81
+ {
82
+ "motion": "tilt",
83
+ "at": "大きく"
84
+ }
85
+ ]
86
+ }
29
87
  },
30
88
  {
31
- "speaker": "Miko",
32
- "text": "こちらでは、左側に小さく移動しました。",
89
+ "speaker": "Ani",
90
+ "text": "ちょ、ちょっと!今度は左に小さくされちゃったんだけど!",
33
91
  "image": {
34
92
  "type": "markdown",
35
93
  "markdown": ["# Moved", "", "```json", "\"avatarParams\": {", " \"position\": { \"x\": \"18%\", \"scale\": \"48%\" }", "}", "```"]
36
94
  },
37
- "avatarParams": { "emotion": "surprised", "position": { "x": "18%", "scale": "48%" }, "motions": [{ "motion": "surprise" }] }
95
+ "avatarParams": {
96
+ "emotion": "surprised",
97
+ "position": {
98
+ "x": "18%",
99
+ "scale": "48%"
100
+ },
101
+ "motions": [
102
+ {
103
+ "motion": "surprise"
104
+ }
105
+ ]
106
+ }
38
107
  },
39
108
  {
40
- "speaker": "Miko",
41
- "text": "最後は、元の位置に戻ります。それでは、始めましょう!",
42
- "image": { "type": "markdown", "markdown": ["# Let's start"] },
43
- "avatarParams": { "emotion": "happy" }
109
+ "speaker": "Ani",
110
+ "text": "最後は元の位置に戻るわ。べ、別に、こんなことが簡単にできるって、自慢してるわけじゃないんだからね!",
111
+ "image": {
112
+ "type": "markdown",
113
+ "markdown": ["# 簡単…でしょ?"]
114
+ },
115
+ "avatarParams": {
116
+ "emotion": "angry"
117
+ }
44
118
  }
45
119
  ]
46
120
  }