mulmocast 2.12.1 → 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 +231 -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/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 +108 -20
  37. package/scripts/test/test_avatar.json +46 -0
  38. package/scripts/test/test_remotion.json +38 -0
@@ -119,6 +119,41 @@
119
119
  "apiVersion": {
120
120
  "type": "string"
121
121
  },
122
+ "avatar": {
123
+ "description": "Avatar shown over the beat visuals, speaking this speaker's lines",
124
+ "type": "object",
125
+ "properties": {
126
+ "source": {
127
+ "type": "string",
128
+ "description": "AvatarScript avatar package (a folder with avatar.json, or a mesh-avatar-studio project), relative to the script"
129
+ },
130
+ "position": {
131
+ "type": "object",
132
+ "properties": {
133
+ "x": {
134
+ "description": "Horizontal centre of the avatar, % of the canvas width. Default 84%",
135
+ "type": "string",
136
+ "pattern": "^-?\\d+(\\.\\d+)?%$"
137
+ },
138
+ "y": {
139
+ "description": "Bottom edge of the avatar, % of the canvas height. Default 100%",
140
+ "type": "string",
141
+ "pattern": "^-?\\d+(\\.\\d+)?%$"
142
+ },
143
+ "scale": {
144
+ "description": "Height of the avatar, % of the canvas height. Default 62%",
145
+ "type": "string",
146
+ "pattern": "^-?\\d+(\\.\\d+)?%$"
147
+ }
148
+ },
149
+ "additionalProperties": false
150
+ }
151
+ },
152
+ "required": [
153
+ "source"
154
+ ],
155
+ "additionalProperties": false
156
+ },
122
157
  "lang": {
123
158
  "type": "object",
124
159
  "propertyNames": {
@@ -184,6 +219,41 @@
184
219
  },
185
220
  "apiVersion": {
186
221
  "type": "string"
222
+ },
223
+ "avatar": {
224
+ "description": "Avatar shown over the beat visuals, speaking this speaker's lines",
225
+ "type": "object",
226
+ "properties": {
227
+ "source": {
228
+ "type": "string",
229
+ "description": "AvatarScript avatar package (a folder with avatar.json, or a mesh-avatar-studio project), relative to the script"
230
+ },
231
+ "position": {
232
+ "type": "object",
233
+ "properties": {
234
+ "x": {
235
+ "description": "Horizontal centre of the avatar, % of the canvas width. Default 84%",
236
+ "type": "string",
237
+ "pattern": "^-?\\d+(\\.\\d+)?%$"
238
+ },
239
+ "y": {
240
+ "description": "Bottom edge of the avatar, % of the canvas height. Default 100%",
241
+ "type": "string",
242
+ "pattern": "^-?\\d+(\\.\\d+)?%$"
243
+ },
244
+ "scale": {
245
+ "description": "Height of the avatar, % of the canvas height. Default 62%",
246
+ "type": "string",
247
+ "pattern": "^-?\\d+(\\.\\d+)?%$"
248
+ }
249
+ },
250
+ "additionalProperties": false
251
+ }
252
+ },
253
+ "required": [
254
+ "source"
255
+ ],
256
+ "additionalProperties": false
187
257
  }
188
258
  },
189
259
  "required": [
@@ -1860,6 +1930,69 @@
1860
1930
  },
1861
1931
  "additionalProperties": false
1862
1932
  },
1933
+ "avatarParams": {
1934
+ "type": "object",
1935
+ "properties": {
1936
+ "position": {
1937
+ "type": "object",
1938
+ "properties": {
1939
+ "x": {
1940
+ "description": "Horizontal centre of the avatar, % of the canvas width. Default 84%",
1941
+ "type": "string",
1942
+ "pattern": "^-?\\d+(\\.\\d+)?%$"
1943
+ },
1944
+ "y": {
1945
+ "description": "Bottom edge of the avatar, % of the canvas height. Default 100%",
1946
+ "type": "string",
1947
+ "pattern": "^-?\\d+(\\.\\d+)?%$"
1948
+ },
1949
+ "scale": {
1950
+ "description": "Height of the avatar, % of the canvas height. Default 62%",
1951
+ "type": "string",
1952
+ "pattern": "^-?\\d+(\\.\\d+)?%$"
1953
+ }
1954
+ },
1955
+ "additionalProperties": false
1956
+ },
1957
+ "emotion": {
1958
+ "description": "The avatar's expression during this beat",
1959
+ "type": "string",
1960
+ "enum": [
1961
+ "neutral",
1962
+ "happy",
1963
+ "sad",
1964
+ "angry",
1965
+ "surprised",
1966
+ "relaxed"
1967
+ ]
1968
+ },
1969
+ "motions": {
1970
+ "type": "array",
1971
+ "items": {
1972
+ "type": "object",
1973
+ "properties": {
1974
+ "motion": {
1975
+ "type": "string",
1976
+ "description": "Motion name, e.g. nod, tilt, think, giggle, surprise"
1977
+ },
1978
+ "at": {
1979
+ "description": "Words of the beat's text where the motion starts. Default: the start of the beat",
1980
+ "type": "string"
1981
+ }
1982
+ },
1983
+ "required": [
1984
+ "motion"
1985
+ ],
1986
+ "additionalProperties": false
1987
+ }
1988
+ },
1989
+ "hidden": {
1990
+ "description": "Hide the speaker's avatar during this beat",
1991
+ "type": "boolean"
1992
+ }
1993
+ },
1994
+ "additionalProperties": false
1995
+ },
1863
1996
  "htmlImageParams": {
1864
1997
  "type": "object",
1865
1998
  "properties": {
@@ -2207,6 +2340,16 @@
2207
2340
  ],
2208
2341
  "additionalProperties": false
2209
2342
  },
2343
+ "remotionParams": {
2344
+ "type": "object",
2345
+ "properties": {
2346
+ "brief": {
2347
+ "description": "Whole-video art direction shared by every remotion beat (palette, type, mood), so all scenes look like one film.",
2348
+ "type": "string"
2349
+ }
2350
+ },
2351
+ "additionalProperties": false
2352
+ },
2210
2353
  "captionParams": {
2211
2354
  "type": "object",
2212
2355
  "properties": {
@@ -13372,6 +13515,31 @@
13372
13515
  "slide"
13373
13516
  ],
13374
13517
  "additionalProperties": false
13518
+ },
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."
13530
+ },
13531
+ "fps": {
13532
+ "description": "Frame rate of the rendered video. Default: 30.",
13533
+ "type": "number",
13534
+ "minimum": 1,
13535
+ "maximum": 60
13536
+ }
13537
+ },
13538
+ "required": [
13539
+ "type",
13540
+ "prompt"
13541
+ ],
13542
+ "additionalProperties": false
13375
13543
  }
13376
13544
  ]
13377
13545
  },
@@ -14686,6 +14854,69 @@
14686
14854
  },
14687
14855
  "additionalProperties": false
14688
14856
  },
14857
+ "avatarParams": {
14858
+ "type": "object",
14859
+ "properties": {
14860
+ "position": {
14861
+ "type": "object",
14862
+ "properties": {
14863
+ "x": {
14864
+ "description": "Horizontal centre of the avatar, % of the canvas width. Default 84%",
14865
+ "type": "string",
14866
+ "pattern": "^-?\\d+(\\.\\d+)?%$"
14867
+ },
14868
+ "y": {
14869
+ "description": "Bottom edge of the avatar, % of the canvas height. Default 100%",
14870
+ "type": "string",
14871
+ "pattern": "^-?\\d+(\\.\\d+)?%$"
14872
+ },
14873
+ "scale": {
14874
+ "description": "Height of the avatar, % of the canvas height. Default 62%",
14875
+ "type": "string",
14876
+ "pattern": "^-?\\d+(\\.\\d+)?%$"
14877
+ }
14878
+ },
14879
+ "additionalProperties": false
14880
+ },
14881
+ "emotion": {
14882
+ "description": "The avatar's expression during this beat",
14883
+ "type": "string",
14884
+ "enum": [
14885
+ "neutral",
14886
+ "happy",
14887
+ "sad",
14888
+ "angry",
14889
+ "surprised",
14890
+ "relaxed"
14891
+ ]
14892
+ },
14893
+ "motions": {
14894
+ "type": "array",
14895
+ "items": {
14896
+ "type": "object",
14897
+ "properties": {
14898
+ "motion": {
14899
+ "type": "string",
14900
+ "description": "Motion name, e.g. nod, tilt, think, giggle, surprise"
14901
+ },
14902
+ "at": {
14903
+ "description": "Words of the beat's text where the motion starts. Default: the start of the beat",
14904
+ "type": "string"
14905
+ }
14906
+ },
14907
+ "required": [
14908
+ "motion"
14909
+ ],
14910
+ "additionalProperties": false
14911
+ }
14912
+ },
14913
+ "hidden": {
14914
+ "description": "Hide the speaker's avatar during this beat",
14915
+ "type": "boolean"
14916
+ }
14917
+ },
14918
+ "additionalProperties": false
14919
+ },
14689
14920
  "htmlImageParams": {
14690
14921
  "type": "object",
14691
14922
  "properties": {
@@ -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
  };