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.
Files changed (40) hide show
  1. package/assets/schemas/mulmo_script.json +297 -0
  2. package/lib/actions/avatar.d.ts +37 -0
  3. package/lib/actions/avatar.js +176 -0
  4. package/lib/actions/image_agents.js +9 -9
  5. package/lib/actions/index.d.ts +1 -0
  6. package/lib/actions/index.js +1 -0
  7. package/lib/actions/movie.d.ts +1 -0
  8. package/lib/actions/movie.js +40 -2
  9. package/lib/agents/combine_audio_files_agent.js +2 -2
  10. package/lib/cli/commands/movie/handler.js +2 -2
  11. package/lib/index.remotion.d.ts +3 -0
  12. package/lib/index.remotion.js +4 -0
  13. package/lib/mcp/server.js +3 -3
  14. package/lib/methods/mulmo_beat.d.ts +1 -0
  15. package/lib/methods/mulmo_beat.js +3 -0
  16. package/lib/methods/mulmo_beat_node.d.ts +1 -0
  17. package/lib/methods/mulmo_presentation_style.d.ts +5 -1
  18. package/lib/methods/mulmo_presentation_style.js +8 -0
  19. package/lib/types/schema.d.ts +466 -4
  20. package/lib/types/schema.js +77 -0
  21. package/lib/types/type.d.ts +7 -1
  22. package/lib/utils/context.d.ts +178 -0
  23. package/lib/utils/image_plugins/index.js +2 -0
  24. package/lib/utils/image_plugins/remotion.d.ts +17 -0
  25. package/lib/utils/image_plugins/remotion.js +202 -0
  26. package/lib/utils/remotion/claude_prompt.d.ts +33 -0
  27. package/lib/utils/remotion/claude_prompt.js +111 -0
  28. package/lib/utils/remotion/claude_runner.d.ts +4 -0
  29. package/lib/utils/remotion/claude_runner.js +47 -0
  30. package/lib/utils/remotion/commit_files.d.ts +11 -0
  31. package/lib/utils/remotion/commit_files.js +37 -0
  32. package/lib/utils/remotion/packages.d.ts +5 -0
  33. package/lib/utils/remotion/packages.js +20 -0
  34. package/lib/utils/remotion/render.d.ts +20 -0
  35. package/lib/utils/remotion/render.js +65 -0
  36. package/lib/utils/remotion/system_prompt.d.ts +4 -0
  37. package/lib/utils/remotion/system_prompt.js +66 -0
  38. package/package.json +112 -20
  39. package/scripts/test/test_avatar.json +46 -0
  40. package/scripts/test/test_remotion.json +38 -0
@@ -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
  };
@@ -0,0 +1,3 @@
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";
@@ -0,0 +1,4 @@
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";
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;