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
|
@@ -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,97 @@
|
|
|
13372
13515
|
"slide"
|
|
13373
13516
|
],
|
|
13374
13517
|
"additionalProperties": false
|
|
13518
|
+
},
|
|
13519
|
+
{
|
|
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
|
|
13545
|
+
},
|
|
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
|
|
13607
|
+
}
|
|
13608
|
+
]
|
|
13375
13609
|
}
|
|
13376
13610
|
]
|
|
13377
13611
|
},
|
|
@@ -14686,6 +14920,69 @@
|
|
|
14686
14920
|
},
|
|
14687
14921
|
"additionalProperties": false
|
|
14688
14922
|
},
|
|
14923
|
+
"avatarParams": {
|
|
14924
|
+
"type": "object",
|
|
14925
|
+
"properties": {
|
|
14926
|
+
"position": {
|
|
14927
|
+
"type": "object",
|
|
14928
|
+
"properties": {
|
|
14929
|
+
"x": {
|
|
14930
|
+
"description": "Horizontal centre of the avatar, % of the canvas width. Default 84%",
|
|
14931
|
+
"type": "string",
|
|
14932
|
+
"pattern": "^-?\\d+(\\.\\d+)?%$"
|
|
14933
|
+
},
|
|
14934
|
+
"y": {
|
|
14935
|
+
"description": "Bottom edge of the avatar, % of the canvas height. Default 100%",
|
|
14936
|
+
"type": "string",
|
|
14937
|
+
"pattern": "^-?\\d+(\\.\\d+)?%$"
|
|
14938
|
+
},
|
|
14939
|
+
"scale": {
|
|
14940
|
+
"description": "Height of the avatar, % of the canvas height. Default 62%",
|
|
14941
|
+
"type": "string",
|
|
14942
|
+
"pattern": "^-?\\d+(\\.\\d+)?%$"
|
|
14943
|
+
}
|
|
14944
|
+
},
|
|
14945
|
+
"additionalProperties": false
|
|
14946
|
+
},
|
|
14947
|
+
"emotion": {
|
|
14948
|
+
"description": "The avatar's expression during this beat",
|
|
14949
|
+
"type": "string",
|
|
14950
|
+
"enum": [
|
|
14951
|
+
"neutral",
|
|
14952
|
+
"happy",
|
|
14953
|
+
"sad",
|
|
14954
|
+
"angry",
|
|
14955
|
+
"surprised",
|
|
14956
|
+
"relaxed"
|
|
14957
|
+
]
|
|
14958
|
+
},
|
|
14959
|
+
"motions": {
|
|
14960
|
+
"type": "array",
|
|
14961
|
+
"items": {
|
|
14962
|
+
"type": "object",
|
|
14963
|
+
"properties": {
|
|
14964
|
+
"motion": {
|
|
14965
|
+
"type": "string",
|
|
14966
|
+
"description": "Motion name, e.g. nod, tilt, think, giggle, surprise"
|
|
14967
|
+
},
|
|
14968
|
+
"at": {
|
|
14969
|
+
"description": "Words of the beat's text where the motion starts. Default: the start of the beat",
|
|
14970
|
+
"type": "string"
|
|
14971
|
+
}
|
|
14972
|
+
},
|
|
14973
|
+
"required": [
|
|
14974
|
+
"motion"
|
|
14975
|
+
],
|
|
14976
|
+
"additionalProperties": false
|
|
14977
|
+
}
|
|
14978
|
+
},
|
|
14979
|
+
"hidden": {
|
|
14980
|
+
"description": "Hide the speaker's avatar during this beat",
|
|
14981
|
+
"type": "boolean"
|
|
14982
|
+
}
|
|
14983
|
+
},
|
|
14984
|
+
"additionalProperties": false
|
|
14985
|
+
},
|
|
14689
14986
|
"htmlImageParams": {
|
|
14690
14987
|
"type": "object",
|
|
14691
14988
|
"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
|
|
209
|
-
//
|
|
210
|
-
if (
|
|
211
|
-
throw new Error(
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
279
|
-
const
|
|
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];
|
package/lib/actions/index.d.ts
CHANGED
package/lib/actions/index.js
CHANGED
package/lib/actions/movie.d.ts
CHANGED
|
@@ -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;
|