mulmocast 2.12.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/assets/schemas/mulmo_script.json +434 -166
  2. package/lib/actions/avatar.d.ts +37 -0
  3. package/lib/actions/avatar.js +176 -0
  4. package/lib/actions/image_agents.js +9 -9
  5. package/lib/actions/index.d.ts +1 -0
  6. package/lib/actions/index.js +1 -0
  7. package/lib/actions/movie.d.ts +1 -0
  8. package/lib/actions/movie.js +40 -2
  9. package/lib/agents/combine_audio_files_agent.js +2 -2
  10. package/lib/cli/commands/movie/handler.js +2 -2
  11. package/lib/mcp/server.js +3 -3
  12. package/lib/methods/mulmo_beat.d.ts +1 -0
  13. package/lib/methods/mulmo_beat.js +3 -0
  14. package/lib/methods/mulmo_beat_node.d.ts +1 -0
  15. package/lib/methods/mulmo_presentation_style.d.ts +5 -1
  16. package/lib/methods/mulmo_presentation_style.js +8 -0
  17. package/lib/types/schema.d.ts +405 -0
  18. package/lib/types/schema.js +61 -0
  19. package/lib/types/type.d.ts +6 -1
  20. package/lib/utils/context.d.ts +158 -0
  21. package/lib/utils/image_plugins/index.js +2 -0
  22. package/lib/utils/image_plugins/remotion.d.ts +16 -0
  23. package/lib/utils/image_plugins/remotion.js +164 -0
  24. package/lib/utils/remotion/claude_prompt.d.ts +33 -0
  25. package/lib/utils/remotion/claude_prompt.js +111 -0
  26. package/lib/utils/remotion/claude_runner.d.ts +4 -0
  27. package/lib/utils/remotion/claude_runner.js +47 -0
  28. package/lib/utils/remotion/commit_files.d.ts +11 -0
  29. package/lib/utils/remotion/commit_files.js +37 -0
  30. package/lib/utils/remotion/packages.d.ts +5 -0
  31. package/lib/utils/remotion/packages.js +20 -0
  32. package/lib/utils/remotion/render.d.ts +19 -0
  33. package/lib/utils/remotion/render.js +63 -0
  34. package/lib/utils/remotion/system_prompt.d.ts +3 -0
  35. package/lib/utils/remotion/system_prompt.js +62 -0
  36. package/package.json +111 -23
  37. package/scripts/test/test_avatar.json +46 -0
  38. package/scripts/test/test_remotion.json +38 -0
@@ -0,0 +1,37 @@
1
+ import type { MulmoAvatarPosition, MulmoStudioContext } from "../types/index.js";
2
+ export type AvatarSegmentPlan = {
3
+ beatIndex: number;
4
+ text: string;
5
+ audio: string;
6
+ /** absolute seconds on the final timeline */
7
+ start: number;
8
+ emotion?: "neutral" | "happy" | "sad" | "angry" | "surprised" | "relaxed";
9
+ motions?: {
10
+ motion: string;
11
+ at?: string;
12
+ }[];
13
+ };
14
+ /** Where the avatar is during one stretch of the video (absolute seconds, end exclusive). */
15
+ export type AvatarPlacementPlan = {
16
+ start: number;
17
+ end: number;
18
+ position: Required<MulmoAvatarPosition>;
19
+ };
20
+ export type AvatarTrackPlan = {
21
+ speakerId: string;
22
+ /** absolute path of the avatar package */
23
+ source: string;
24
+ segments: AvatarSegmentPlan[];
25
+ /** where the avatar is shown, beat by beat; it is not shown outside these */
26
+ placements: AvatarPlacementPlan[];
27
+ /** length of the whole video, seconds */
28
+ duration: number;
29
+ };
30
+ /**
31
+ * Which beats each avatar speaks, and where it stands, beat by beat. Pure: reads the studio,
32
+ * writes nothing. Position: the beat's avatarParams, then the speaker's avatar, then the script's
33
+ * avatarParams, then the defaults; during other speakers' beats the avatar stays where it was.
34
+ */
35
+ export declare const planAvatarTracks: (context: MulmoStudioContext) => AvatarTrackPlan[];
36
+ /** The `avatar` action: renders the avatar tracks into studio.avatarTracks (none when no speaker has an avatar). */
37
+ export declare const avatar: (context: MulmoStudioContext) => Promise<MulmoStudioContext>;
@@ -0,0 +1,176 @@
1
+ // Talking avatars (AvatarScript): for each speaker that has an avatar, one continuous, see-through
2
+ // avatar track for the whole video, speaking that speaker's beats. movie.ts overlays the tracks.
3
+ // See docs/plans/plan_avatar.md.
4
+ import { createHash } from "node:crypto";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { GraphAILogger } from "graphai";
8
+ import { MulmoPresentationStyleMethods, MulmoStudioContextMethods } from "../methods/index.js";
9
+ import { getFullPath, getOutputStudioFilePath } from "../utils/file.js";
10
+ import { localizedText } from "../utils/utils.js";
11
+ const DEFAULT_POSITION = { x: "84%", y: "100%", scale: "62%" };
12
+ // Show the whole avatar image. A rig may crop the top of its image (a flat cut edge, hidden when the
13
+ // avatar fills the frame); over a slide the cut would run through the head.
14
+ const PAD_TOP = 0;
15
+ const mergePosition = (...positions) => positions.reduce((acc, p) => ({ ...acc, ...Object.fromEntries(Object.entries(p ?? {}).filter(([, v]) => v !== undefined)) }), {
16
+ ...DEFAULT_POSITION,
17
+ });
18
+ const percent = (value, of) => (of * parseFloat(value)) / 100;
19
+ const samePosition = (a, b) => a.x === b.x && a.y === b.y && a.scale === b.scale;
20
+ /** Adds [start, end) at `position`, joining it to the previous stretch when nothing changes. */
21
+ const place = (track, start, end, position) => {
22
+ const last = track.placements.at(-1);
23
+ if (last && last.end === start && samePosition(last.position, position))
24
+ last.end = end;
25
+ else
26
+ track.placements.push({ start, end, position });
27
+ };
28
+ /**
29
+ * Which beats each avatar speaks, and where it stands, beat by beat. Pure: reads the studio,
30
+ * writes nothing. Position: the beat's avatarParams, then the speaker's avatar, then the script's
31
+ * avatarParams, then the defaults; during other speakers' beats the avatar stays where it was.
32
+ */
33
+ export const planAvatarTracks = (context) => {
34
+ const { script, beats: studioBeats } = context.studio;
35
+ const introPadding = MulmoStudioContextMethods.getIntroPadding(context);
36
+ const duration = studioBeats.reduce((total, _, index) => total + MulmoStudioContextMethods.getBeatDuration(context, index), 0);
37
+ const scriptPosition = context.presentationStyle.avatarParams?.position;
38
+ const tracks = new Map();
39
+ script.beats.forEach((beat) => {
40
+ const found = MulmoPresentationStyleMethods.getSpeakerAvatar(context.presentationStyle, beat, context.lang);
41
+ if (!found || tracks.has(found.speakerId))
42
+ return;
43
+ tracks.set(found.speakerId, {
44
+ speakerId: found.speakerId,
45
+ avatar: found.avatar,
46
+ source: getFullPath(context.fileDirs.mulmoFileDirPath, found.avatar.source),
47
+ segments: [],
48
+ placements: [],
49
+ duration,
50
+ current: mergePosition(scriptPosition, found.avatar.position),
51
+ });
52
+ });
53
+ // each beat's stretch of the video: the first one starts at 0 (its intro padding), the rest at their narration
54
+ const starts = studioBeats.map((studioBeat, index) => (index === 0 ? 0 : (studioBeat.startAt ?? 0) + introPadding));
55
+ script.beats.forEach((beat, index) => {
56
+ const studioBeat = studioBeats[index];
57
+ const windowEnd = starts[index + 1] ?? duration;
58
+ const speaker = MulmoPresentationStyleMethods.getSpeakerAvatar(context.presentationStyle, beat, context.lang)?.speakerId;
59
+ tracks.forEach((track) => {
60
+ const own = track.speakerId === speaker;
61
+ if (own)
62
+ track.current = mergePosition(scriptPosition, track.avatar.position, beat.avatarParams?.position);
63
+ if (!(own && beat.avatarParams?.hidden))
64
+ place(track, starts[index], windowEnd, track.current);
65
+ const text = localizedText(beat, context.multiLingual?.[index], context.lang, script.lang);
66
+ if (!own || beat.avatarParams?.hidden || !text || !studioBeat?.audioFile)
67
+ return;
68
+ track.segments.push({
69
+ beatIndex: index,
70
+ text,
71
+ audio: studioBeat.audioFile,
72
+ start: (studioBeat.startAt ?? 0) + introPadding,
73
+ emotion: beat.avatarParams?.emotion,
74
+ motions: beat.avatarParams?.motions,
75
+ });
76
+ });
77
+ });
78
+ return [...tracks.values()].map(({ avatar: __avatar, current: __current, ...track }) => track);
79
+ };
80
+ const INSTALL_HINT = "Avatars need the avatarscript and onnxruntime-node packages: npm install avatarscript onnxruntime-node";
81
+ // a variable, so TypeScript does not need onnxruntime-node installed (it is optional, ~290 MB)
82
+ const ONNX_RUNTIME = "onnxruntime-node";
83
+ const loadAvatarScript = () => import("avatarscript").catch((error) => {
84
+ throw new Error(INSTALL_HINT, { cause: error });
85
+ });
86
+ // AvatarScript times the speech by forced alignment, which runs on onnxruntime-node. Only rendering
87
+ // needs it.
88
+ const ensureOnnxRuntime = () => import(ONNX_RUNTIME).catch((error) => {
89
+ throw new Error(INSTALL_HINT, { cause: error });
90
+ });
91
+ // Size and modification time: what the cache key sees of a file, so that replacing it (new speech at
92
+ // the same path, an edited avatar) renders the track again.
93
+ const fileStamp = (file) => {
94
+ const stat = fs.statSync(file, { throwIfNoEntry: false });
95
+ return stat ? `${stat.size}:${stat.mtimeMs}` : null;
96
+ };
97
+ const folderStamp = (dir) => {
98
+ const stat = fs.statSync(dir, { throwIfNoEntry: false });
99
+ if (!stat?.isDirectory())
100
+ return fileStamp(dir);
101
+ return fs
102
+ .readdirSync(dir, { recursive: true, withFileTypes: true })
103
+ .filter((entry) => entry.isFile())
104
+ .map((entry) => path.join(entry.parentPath, entry.name))
105
+ .sort()
106
+ .map((file) => [path.relative(dir, file), fileStamp(file)]);
107
+ };
108
+ const even = (n) => Math.max(2, Math.round(n / 2) * 2); // video codecs need even sizes
109
+ /** Renders one track (or reuses it), returning where it goes on the canvas. */
110
+ const renderAvatarTrack = async (plan, context, getAvatarScript) => {
111
+ const canvas = MulmoPresentationStyleMethods.getCanvasSize(context.presentationStyle);
112
+ // rendered once, at the largest size it is shown; smaller placements scale it down
113
+ const height = even(Math.max(...plan.placements.map((p) => percent(p.position.scale, canvas.height))));
114
+ 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 };
116
+ const hash = createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 12);
117
+ const dir = MulmoStudioContextMethods.getImageProjectDirPath(context);
118
+ const name = `avatar_${plan.speakerId.replace(/[^\w-]/g, "_")}_${hash}`;
119
+ const file = path.resolve(dir, `${name}.webm`);
120
+ // the avatar's width / height, kept next to the track so that reusing it needs no avatarscript
121
+ const infoFile = path.resolve(dir, `${name}.json`);
122
+ const aspect = await (async () => {
123
+ if (!context.force && fs.existsSync(file) && fs.existsSync(infoFile)) {
124
+ GraphAILogger.info(`avatar: reusing ${file}`);
125
+ return JSON.parse(fs.readFileSync(infoFile, "utf-8")).aspect;
126
+ }
127
+ GraphAILogger.info(`avatar: rendering ${plan.speakerId} (${plan.segments.length} beats, ${plan.duration.toFixed(1)} s)`);
128
+ const avatarscript = await getAvatarScript();
129
+ await ensureOnnxRuntime();
130
+ const avatar = await avatarscript.loadAvatar(plan.source);
131
+ const avatarAspect = avatarscript.avatarAspect(avatar.rig, PAD_TOP);
132
+ fs.mkdirSync(dir, { recursive: true });
133
+ const { score, audio } = await avatarscript.compileTimeline(plan.segments, {
134
+ lang: context.lang,
135
+ duration: plan.duration,
136
+ view: { background: "transparent" },
137
+ });
138
+ const voice = path.resolve(dir, `${name}.wav`);
139
+ fs.writeFileSync(voice, avatarscript.toWav(audio));
140
+ await avatarscript.render({ avatar, score, audioPath: voice, audio: false, out: file, width: even(height * avatarAspect), height, padTop: PAD_TOP });
141
+ fs.writeFileSync(infoFile, JSON.stringify({ aspect: avatarAspect }));
142
+ return avatarAspect;
143
+ })();
144
+ const width = even(height * aspect);
145
+ const placements = plan.placements.map(({ start, end, position }) => {
146
+ const h = even(percent(position.scale, canvas.height));
147
+ const w = even(h * aspect);
148
+ return { start, end, x: Math.round(percent(position.x, canvas.width) - w / 2), y: Math.round(percent(position.y, canvas.height) - h), width: w, height: h };
149
+ });
150
+ return { speaker: plan.speakerId, file, width, height, placements };
151
+ };
152
+ /** The `avatar` action: renders the avatar tracks into studio.avatarTracks (none when no speaker has an avatar). */
153
+ export const avatar = async (context) => {
154
+ const allPlans = planAvatarTracks(context);
155
+ const plans = allPlans.filter((plan) => plan.segments.length > 0);
156
+ if (plans.length === 0) {
157
+ // no stale tracks from an earlier run; [] when there are avatars but nothing for them to say
158
+ if (allPlans.length > 0)
159
+ context.studio.avatarTracks = [];
160
+ else
161
+ delete context.studio.avatarTracks;
162
+ return context;
163
+ }
164
+ // loaded only when a track has to be rendered: a cached track needs neither avatarscript nor onnxruntime-node
165
+ const getAvatarScript = (() => {
166
+ const loaded = {};
167
+ return () => (loaded.module ??= loadAvatarScript());
168
+ })();
169
+ const tracks = [];
170
+ for (const plan of plans)
171
+ tracks.push(await renderAvatarTrack(plan, context, getAvatarScript));
172
+ context.studio.avatarTracks = tracks;
173
+ const outputStudioFilePath = getOutputStudioFilePath(MulmoStudioContextMethods.getOutDirPath(context), MulmoStudioContextMethods.getFileName(context));
174
+ fs.writeFileSync(outputStudioFilePath, JSON.stringify(context.studio, null, 2));
175
+ return context;
176
+ };
@@ -205,12 +205,12 @@ export const imagePreprocessAgent = async (namedInputs) => {
205
205
  const markdown = plugin.markdown ? plugin.markdown({ beat, context }) : undefined;
206
206
  const html = plugin.html ? await plugin.html({ beat, context }) : undefined;
207
207
  const isTypeMovie = beat.image.type === ImageMediaType.Movie;
208
- const isAnimatedHtml = MulmoBeatMethods.isAnimatedHtmlTailwind(beat);
209
- // animation and moviePrompt cannot be used together
210
- if (isAnimatedHtml && beat.moviePrompt) {
211
- throw new Error("html_tailwind animation and moviePrompt cannot be used together on the same beat. Use either animation or moviePrompt, not both.");
208
+ const isPluginVideo = MulmoBeatMethods.isPluginVideo(beat);
209
+ // the plugin's own video and moviePrompt cannot be used together
210
+ if (isPluginVideo && beat.moviePrompt) {
211
+ throw new Error(`${beat.image.type} video and moviePrompt cannot be used together on the same beat. Use one of them, not both.`);
212
212
  }
213
- if (isAnimatedHtml) {
213
+ if (isPluginVideo) {
214
214
  const animatedVideoPath = getBeatAnimatedVideoPath(context, index);
215
215
  // ImagePluginPreprocessAgentResponse
216
216
  // imageFromMovie is false: the plugin generates both the .mp4 video AND
@@ -218,7 +218,8 @@ export const imagePreprocessAgent = async (namedInputs) => {
218
218
  return {
219
219
  ...returnValue,
220
220
  imagePath, // static final-frame PNG (generated by the plugin)
221
- movieFile: animatedVideoPath, // .mp4 path for the pipeline
221
+ // Without a known duration the plugin writes only the PNG, so there is no .mp4 to point at.
222
+ movieFile: returnValue.beatDuration !== undefined ? animatedVideoPath : undefined,
222
223
  referenceImageForMovie: pluginPath,
223
224
  markdown,
224
225
  html,
@@ -275,9 +276,8 @@ export const imagePluginAgent = async (namedInputs) => {
275
276
  const { context, beat, index, imageRefs, movieRefs } = namedInputs;
276
277
  const { imagePath } = getBeatPngImagePath(context, index);
277
278
  const plugin = MulmoBeatMethods.getPlugin(beat);
278
- // For animated html_tailwind, use the .mp4 path so the plugin writes video there
279
- const isAnimatedHtml = MulmoBeatMethods.isAnimatedHtmlTailwind(beat);
280
- const effectiveImagePath = isAnimatedHtml ? getBeatAnimatedVideoPath(context, index) : imagePath;
279
+ // Plugins that write their own video (animated html_tailwind, remotion) get the .mp4 path
280
+ const effectiveImagePath = MulmoBeatMethods.isPluginVideo(beat) ? getBeatAnimatedVideoPath(context, index) : imagePath;
281
281
  try {
282
282
  MulmoStudioContextMethods.setBeatSessionState(context, "image", index, beat.id, true);
283
283
  const studioBeat = context.studio.beats[index];
@@ -1,6 +1,7 @@
1
1
  export * from "./audio.js";
2
2
  export * from "./images.js";
3
3
  export * from "./captions.js";
4
+ export * from "./avatar.js";
4
5
  export * from "./image_references.js";
5
6
  export * from "./image_agents.js";
6
7
  export * from "./movie.js";
@@ -1,6 +1,7 @@
1
1
  export * from "./audio.js";
2
2
  export * from "./images.js";
3
3
  export * from "./captions.js";
4
+ export * from "./avatar.js";
4
5
  export * from "./image_references.js";
5
6
  export * from "./image_agents.js";
6
7
  export * from "./movie.js";
@@ -9,6 +9,7 @@ export declare const getAudioPart: (inputIndex: number, duration: number, delay:
9
9
  audioId: string;
10
10
  audioPart: string;
11
11
  };
12
+ export declare const addAvatars: (ffmpegContext: FfmpegContext, videoId: string, context: MulmoStudioContext) => string;
12
13
  export declare const getOutOverlayCoords: (transitionType: string, d: number, t: number) => string;
13
14
  export declare const getInOverlayCoords: (transitionType: string, d: number, t: number) => string;
14
15
  export declare const isExplicitMixMode: (context: MulmoStudioContext) => boolean;
@@ -105,6 +105,30 @@ const getOutputOption = (audioId, videoId) => {
105
105
  "-b:a 128k", // Audio bitrate
106
106
  ];
107
107
  };
108
+ // Talking avatars (actions/avatar.ts): each track is a see-through video as long as the whole movie,
109
+ // overlaid after transitions so they do not move it. "-c:v libvpx-vp9" is needed to decode the alpha.
110
+ // A track is shown in stretches (placements); each stretch is trimmed out of the track, kept at its
111
+ // time, scaled to its size and placed where it says, so no filter runs outside its stretch.
112
+ export const addAvatars = (ffmpegContext, videoId, context) => {
113
+ return (context.studio.avatarTracks ?? []).reduce((accId, track, trackIndex) => {
114
+ if (track.placements.length === 0)
115
+ return accId;
116
+ const inputIndex = FfmpegContextAddInput(ffmpegContext, track.file, ["-c:v", "libvpx-vp9"]);
117
+ const sources = track.placements.length === 1 ? [`${inputIndex}:v`] : track.placements.map((_, index) => `avatar${trackIndex}_src${index}`);
118
+ const labels = sources.map((id) => "[" + id + "]").join("");
119
+ if (track.placements.length > 1)
120
+ ffmpegContext.filterComplex.push(`[${inputIndex}:v]split=${sources.length}${labels}`);
121
+ return track.placements.reduce((prevId, placement, index) => {
122
+ const partId = `avatar${trackIndex}_${index}s`;
123
+ const resized = placement.width !== track.width || placement.height !== track.height;
124
+ const scale = resized ? `,scale=${placement.width}:${placement.height}` : "";
125
+ ffmpegContext.filterComplex.push(`[${sources[index]}]trim=start=${placement.start}:end=${placement.end},setpts=PTS-STARTPTS+${placement.start}/TB${scale}[${partId}]`);
126
+ const outId = `avatar${trackIndex}_${index}`;
127
+ ffmpegContext.filterComplex.push(`[${prevId}][${partId}]overlay=x=${placement.x}:y=${placement.y}:format=auto:eof_action=pass:enable='gte(t,${placement.start})*lt(t,${placement.end})'[${outId}]`);
128
+ return outId;
129
+ }, accId);
130
+ }, videoId);
131
+ };
108
132
  const addCaptions = (ffmpegContext, concatVideoId, context, caption) => {
109
133
  const beatsWithCaptions = context.studio.beats.filter(({ captionFiles }) => captionFiles && captionFiles.length > 0);
110
134
  if (caption && beatsWithCaptions.length > 0) {
@@ -470,8 +494,13 @@ export const createVideo = async (audioArtifactFilePath, outputVideoPath, contex
470
494
  // Concatenate the trimmed images
471
495
  const concatVideoId = "concat_video";
472
496
  ffmpegContext.filterComplex.push(getConcatVideoFilter(concatVideoId, videoIdsForBeats));
473
- const captionedVideoId = addCaptions(ffmpegContext, concatVideoId, context, caption);
474
- const mixedVideoId = addTransitionEffects(ffmpegContext, captionedVideoId, context, transitionVideoIds, beatTimestamps, videoIdsForBeats);
497
+ // With talking avatars, captions go on last so that an avatar never covers them. Without, the order
498
+ // is unchanged: captions under the transitions.
499
+ const hasAvatars = (context.studio.avatarTracks ?? []).some((track) => track.placements.length > 0);
500
+ const captionedVideoId = hasAvatars ? concatVideoId : addCaptions(ffmpegContext, concatVideoId, context, caption);
501
+ const transitionedVideoId = addTransitionEffects(ffmpegContext, captionedVideoId, context, transitionVideoIds, beatTimestamps, videoIdsForBeats);
502
+ const avatarVideoId = addAvatars(ffmpegContext, transitionedVideoId, context);
503
+ const mixedVideoId = hasAvatars ? addCaptions(ffmpegContext, avatarVideoId, context, caption) : avatarVideoId;
475
504
  if (isTest) {
476
505
  return ffmpegContext.filterComplex;
477
506
  }
@@ -491,8 +520,17 @@ export const movieFilePath = (context) => {
491
520
  const caption = MulmoStudioContextMethods.getCaption(context);
492
521
  return getOutputVideoFilePath(outDirPath, fileName, context.lang, caption);
493
522
  };
523
+ // A speaker has an avatar but no track was rendered: the `avatar` action did not run before `movie`.
524
+ const warnIfAvatarsMissing = (context) => {
525
+ if (context.studio.avatarTracks)
526
+ return;
527
+ const speaksWithAvatar = context.studio.script.beats.some((beat) => MulmoPresentationStyleMethods.getSpeakerAvatar(context.presentationStyle, beat, context.lang));
528
+ if (speaksWithAvatar)
529
+ GraphAILogger.warn("movie: a speaker has an avatar, but no avatar track was rendered. Run the avatar action before movie.");
530
+ };
494
531
  export const movie = async (context) => {
495
532
  MulmoStudioContextMethods.setSessionState(context, "video", true);
533
+ warnIfAvatarsMissing(context);
496
534
  try {
497
535
  const audioArtifactFilePath = getAudioArtifactFilePath(context);
498
536
  const outputVideoPath = movieFilePath(context);
@@ -14,8 +14,8 @@ const getMovieDuration = async (context, beat) => {
14
14
  return { duration: duration / speed, hasAudio };
15
15
  }
16
16
  }
17
- // Animated html_tailwind beats with explicit duration act as movie-like for voice_over grouping
18
- if (MulmoBeatMethods.isAnimatedHtmlTailwind(beat) && beat.duration !== undefined) {
17
+ // Plugin-video beats (animated html_tailwind, remotion) with explicit duration act as movie-like for voice_over grouping
18
+ if (MulmoBeatMethods.isPluginVideo(beat) && beat.duration !== undefined) {
19
19
  return { duration: beat.duration, hasAudio: false };
20
20
  }
21
21
  return { duration: 0, hasAudio: false };
@@ -1,4 +1,4 @@
1
- import { audio, images, movie, captions } from "../../../actions/index.js";
1
+ import { audio, images, movie, captions, avatar } from "../../../actions/index.js";
2
2
  import { dumpUsageIfRequested, initializeContext, runTranslateIfNeeded, printUsageEstimate } from "../../helpers.js";
3
3
  export const handler = async (argv) => {
4
4
  const context = await initializeContext(argv);
@@ -10,6 +10,6 @@ export const handler = async (argv) => {
10
10
  return;
11
11
  }
12
12
  await runTranslateIfNeeded(context, true);
13
- await audio(context).then(images).then(captions).then(movie);
13
+ await audio(context).then(images).then(captions).then(avatar).then(movie);
14
14
  dumpUsageIfRequested(context);
15
15
  };
package/lib/mcp/server.js CHANGED
@@ -7,7 +7,7 @@ import fs from "fs";
7
7
  import path from "path";
8
8
  import { fileURLToPath } from "url";
9
9
  import { GraphAILogger } from "graphai";
10
- import { audio, images, movie, captions, pdf } from "../actions/index.js";
10
+ import { audio, images, movie, captions, avatar, pdf } from "../actions/index.js";
11
11
  import { initializeContext, runTranslateIfNeeded } from "../cli/helpers.js";
12
12
  import { outDirName } from "../types/const.js";
13
13
  import { resolveDirPath, mkdir, generateTimestampedFileName } from "../utils/file.js";
@@ -111,8 +111,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
111
111
  // Execute the requested command
112
112
  switch (cmd) {
113
113
  case "movie":
114
- // Generate movie (audio + images + captions + movie)
115
- await audio(context).then(images).then(captions).then(movie);
114
+ // Generate movie (audio + images + captions + avatar + movie)
115
+ await audio(context).then(images).then(captions).then(avatar).then(movie);
116
116
  return {
117
117
  content: [
118
118
  {
@@ -7,6 +7,7 @@ export declare const MulmoBeatMethods: {
7
7
  isAnimationEnabled: (animation: unknown) => animation is true | AnimationConfig;
8
8
  isAnimationObject: (animation: unknown) => animation is AnimationConfig;
9
9
  isAnimatedHtmlTailwind: (beat: MulmoBeat) => boolean;
10
+ isPluginVideo: (beat: MulmoBeat) => boolean;
10
11
  isMovieMode: (animation: unknown) => boolean;
11
12
  isVoiceOver: (beat?: MulmoBeat) => boolean;
12
13
  getPrevRenderedBeatIndex(beats: MulmoBeat[], index: number): number;
@@ -18,12 +18,15 @@ const isAnimatedHtmlTailwind = (beat) => {
18
18
  const animation = beat.image.animation;
19
19
  return isAnimationEnabled(animation);
20
20
  };
21
+ // Beats whose image plugin writes the beat's .mp4 itself, at the length the audio gives it.
22
+ const isPluginVideo = (beat) => isAnimatedHtmlTailwind(beat) || beat.image?.type === ImageMediaType.Remotion;
21
23
  // voice_over beats share the preceding beat's shot, and contribute no video segment of their own.
22
24
  const isVoiceOver = (beat) => beat?.image?.type === ImageMediaType.VoiceOver;
23
25
  export const MulmoBeatMethods = {
24
26
  isAnimationEnabled,
25
27
  isAnimationObject,
26
28
  isAnimatedHtmlTailwind,
29
+ isPluginVideo,
27
30
  isMovieMode,
28
31
  isVoiceOver,
29
32
  // Index of the closest preceding beat which is actually rendered as a video segment (-1 if none).
@@ -16,6 +16,7 @@ export declare const MulmoBeatMethods: {
16
16
  movie?: boolean;
17
17
  };
18
18
  isAnimatedHtmlTailwind: (beat: MulmoBeat) => boolean;
19
+ isPluginVideo: (beat: MulmoBeat) => boolean;
19
20
  isMovieMode: (animation: unknown) => boolean;
20
21
  isVoiceOver: (beat?: MulmoBeat) => boolean;
21
22
  getPrevRenderedBeatIndex(beats: MulmoBeat[], index: number): number;
@@ -4,7 +4,7 @@
4
4
  * Works in both Node.js and modern browsers.
5
5
  */
6
6
  import type { SlideTheme } from "@mulmocast/deck";
7
- import { MulmoCanvasDimension, MulmoBeat, Text2SpeechProvider, Text2ImageAgentInfo, Text2HtmlAgentInfo, BeatMediaType, MulmoPresentationStyle, SpeakerData, Text2ImageProvider, MulmoStudioContext, MulmoTransition, MulmoFillOption } from "../types/index.js";
7
+ import { MulmoCanvasDimension, MulmoBeat, Text2SpeechProvider, Text2ImageAgentInfo, Text2HtmlAgentInfo, BeatMediaType, MulmoPresentationStyle, SpeakerData, MulmoSpeakerAvatar, Text2ImageProvider, MulmoStudioContext, MulmoTransition, MulmoFillOption } from "../types/index.js";
8
8
  export declare const MulmoPresentationStyleMethods: {
9
9
  getCanvasSize(presentationStyle: MulmoPresentationStyle): MulmoCanvasDimension;
10
10
  getAllSpeechProviders(presentationStyle: MulmoPresentationStyle): Set<Text2SpeechProvider>;
@@ -28,6 +28,10 @@ export declare const MulmoPresentationStyleMethods: {
28
28
  getResolvedSlideTheme(presentationStyle: MulmoPresentationStyle, beat: MulmoBeat): SlideTheme;
29
29
  getDefaultSpeaker(presentationStyle: MulmoPresentationStyle): string;
30
30
  getSpeakerData(presentationStyle: MulmoPresentationStyle, beat: MulmoBeat, lang: string | undefined): SpeakerData;
31
+ getSpeakerAvatar(presentationStyle: MulmoPresentationStyle, beat: MulmoBeat, lang: string | undefined): {
32
+ speakerId: string;
33
+ avatar: MulmoSpeakerAvatar;
34
+ } | null;
31
35
  getSpeaker(context: MulmoStudioContext, beat: MulmoBeat, targetLang: string | undefined): SpeakerData;
32
36
  getMovieTransition(context: MulmoStudioContext, beat: MulmoBeat): MulmoTransition | null;
33
37
  getNeedFirstFrame(context: MulmoStudioContext): boolean[];
@@ -92,6 +92,14 @@ export const MulmoPresentationStyleMethods = {
92
92
  }
93
93
  return speaker;
94
94
  },
95
+ // The speaker of a beat and the avatar it speaks with, if any. A language-specific speaker
96
+ // replaces the whole speaker, so its avatar falls back to the base speaker's.
97
+ getSpeakerAvatar(presentationStyle, beat, lang) {
98
+ const speakerId = beat?.speaker ?? MulmoPresentationStyleMethods.getDefaultSpeaker(presentationStyle);
99
+ const speaker = presentationStyle.speechParams?.speakers?.[speakerId];
100
+ const avatar = (lang ? speaker?.lang?.[lang]?.avatar : undefined) ?? speaker?.avatar;
101
+ return avatar ? { speakerId, avatar } : null;
102
+ },
95
103
  getSpeaker(context, beat, targetLang) {
96
104
  // Normally, lang is determined by the context, but lang may be specified when using the API.
97
105
  const lang = targetLang ?? context.lang ?? context?.studio?.script?.lang;