mulmocast 2.12.1 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/schemas/mulmo_script.json +297 -0
- package/lib/actions/avatar.d.ts +37 -0
- package/lib/actions/avatar.js +176 -0
- package/lib/actions/image_agents.js +9 -9
- package/lib/actions/index.d.ts +1 -0
- package/lib/actions/index.js +1 -0
- package/lib/actions/movie.d.ts +1 -0
- package/lib/actions/movie.js +40 -2
- package/lib/agents/combine_audio_files_agent.js +2 -2
- package/lib/cli/commands/movie/handler.js +2 -2
- package/lib/index.remotion.d.ts +3 -0
- package/lib/index.remotion.js +4 -0
- package/lib/mcp/server.js +3 -3
- package/lib/methods/mulmo_beat.d.ts +1 -0
- package/lib/methods/mulmo_beat.js +3 -0
- package/lib/methods/mulmo_beat_node.d.ts +1 -0
- package/lib/methods/mulmo_presentation_style.d.ts +5 -1
- package/lib/methods/mulmo_presentation_style.js +8 -0
- package/lib/types/schema.d.ts +466 -4
- package/lib/types/schema.js +77 -0
- package/lib/types/type.d.ts +7 -1
- package/lib/utils/context.d.ts +178 -0
- package/lib/utils/image_plugins/index.js +2 -0
- package/lib/utils/image_plugins/remotion.d.ts +17 -0
- package/lib/utils/image_plugins/remotion.js +202 -0
- package/lib/utils/remotion/claude_prompt.d.ts +33 -0
- package/lib/utils/remotion/claude_prompt.js +111 -0
- package/lib/utils/remotion/claude_runner.d.ts +4 -0
- package/lib/utils/remotion/claude_runner.js +47 -0
- package/lib/utils/remotion/commit_files.d.ts +11 -0
- package/lib/utils/remotion/commit_files.js +37 -0
- package/lib/utils/remotion/packages.d.ts +5 -0
- package/lib/utils/remotion/packages.js +20 -0
- package/lib/utils/remotion/render.d.ts +20 -0
- package/lib/utils/remotion/render.js +65 -0
- package/lib/utils/remotion/system_prompt.d.ts +4 -0
- package/lib/utils/remotion/system_prompt.js +66 -0
- package/package.json +112 -20
- package/scripts/test/test_avatar.json +46 -0
- package/scripts/test/test_remotion.json +38 -0
package/lib/actions/movie.js
CHANGED
|
@@ -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
|
-
|
|
474
|
-
|
|
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
|
-
//
|
|
18
|
-
if (MulmoBeatMethods.
|
|
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;
|