mulmocast 2.8.0 → 2.9.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/lib/actions/image_agents.d.ts +21 -0
- package/lib/actions/image_agents.js +149 -18
- package/lib/actions/image_references.d.ts +3 -0
- package/lib/actions/image_references.js +55 -33
- package/lib/actions/images.d.ts +30 -0
- package/lib/actions/images.js +16 -4
- package/lib/methods/index.d.ts +1 -0
- package/lib/methods/index.js +1 -0
- package/lib/methods/mulmo_image_prompt_media.d.ts +14 -0
- package/lib/methods/mulmo_image_prompt_media.js +45 -0
- package/lib/methods/mulmo_presentation_style.d.ts +1 -0
- package/lib/types/provider2agent.d.ts +15 -0
- package/lib/types/provider2agent.js +31 -0
- package/lib/types/schema.d.ts +201 -0
- package/lib/types/schema.js +47 -12
- package/lib/types/type.d.ts +2 -1
- package/lib/utils/context.d.ts +75 -0
- package/lib/utils/error_cause.d.ts +12 -0
- package/lib/utils/error_cause.js +15 -0
- package/lib/utils/ffmpeg_utils.d.ts +5 -0
- package/lib/utils/ffmpeg_utils.js +42 -0
- package/package.json +9 -9
|
@@ -24,6 +24,9 @@ type ImagePreprocessAgentReturnValue = {
|
|
|
24
24
|
};
|
|
25
25
|
firstFrameImagePath?: string;
|
|
26
26
|
lastFrameImagePath?: string;
|
|
27
|
+
firstFrameIsBeatImage?: boolean;
|
|
28
|
+
lastFrameIsBeatImage?: boolean;
|
|
29
|
+
frameFillColor?: string;
|
|
27
30
|
movieReferenceImages?: {
|
|
28
31
|
imagePath: string;
|
|
29
32
|
referenceType: "ASSET" | "STYLE";
|
|
@@ -54,6 +57,7 @@ type ImagePluginPreprocessAgentResponse = ImagePreprocessAgentResponseBase & {
|
|
|
54
57
|
markdown: string;
|
|
55
58
|
html: string;
|
|
56
59
|
};
|
|
60
|
+
export declare const conformFrameImageToCanvas: (context: MulmoStudioContext, imageName: string, imagePath: string, fillColor: string) => Promise<string>;
|
|
57
61
|
type ImagePreprocessAgentResponse = ImagePreprocessAgentResponseBase | ImageHtmlPreprocessAgentResponse | ImagePluginPreprocessAgentResponse | ImageOnlyMoviePreprocessAgentResponse | ImageGenearalPreprocessAgentResponse;
|
|
58
62
|
export declare const imagePreprocessAgent: (namedInputs: {
|
|
59
63
|
context: MulmoStudioContext;
|
|
@@ -61,6 +65,23 @@ export declare const imagePreprocessAgent: (namedInputs: {
|
|
|
61
65
|
index: number;
|
|
62
66
|
imageRefs?: Record<string, string>;
|
|
63
67
|
}) => Promise<ImagePreprocessAgentResponse>;
|
|
68
|
+
export type BeatFramePreprocessorInput = {
|
|
69
|
+
imagePath?: string;
|
|
70
|
+
lastFrameImagePath?: string;
|
|
71
|
+
firstFrameIsBeatImage?: boolean;
|
|
72
|
+
lastFrameIsBeatImage?: boolean;
|
|
73
|
+
frameFillColor?: string;
|
|
74
|
+
referenceImageForMovie?: string;
|
|
75
|
+
};
|
|
76
|
+
export declare const beatFrameResolverAgent: (namedInputs: {
|
|
77
|
+
context: MulmoStudioContext;
|
|
78
|
+
beat: MulmoBeat;
|
|
79
|
+
index: number;
|
|
80
|
+
preprocessor?: BeatFramePreprocessorInput;
|
|
81
|
+
}) => Promise<{
|
|
82
|
+
lastFrameImagePath: string | undefined;
|
|
83
|
+
referenceImageForMovie: string | undefined;
|
|
84
|
+
}>;
|
|
64
85
|
export declare const imagePluginAgent: (namedInputs: {
|
|
65
86
|
context: MulmoStudioContext;
|
|
66
87
|
beat: MulmoBeat;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { createHash } from "crypto";
|
|
1
3
|
import { GraphAILogger } from "graphai";
|
|
4
|
+
import { BEAT_IMAGE_SENTINEL, ImageMediaType, beatImageTypesWithoutOwnStill } from "../types/schema.js";
|
|
2
5
|
import { MulmoPresentationStyleMethods, MulmoStudioContextMethods, MulmoBeatMethods, MulmoMediaSourceMethods } from "../methods/index.js";
|
|
3
|
-
import { getBeatPngImagePath, getBeatMoviePaths, getBeatAnimatedVideoPath, getAudioFilePath, getGroupedAudioFilePath } from "../utils/file.js";
|
|
6
|
+
import { getBeatPngImagePath, getBeatMoviePaths, getBeatAnimatedVideoPath, getAudioFilePath, getGroupedAudioFilePath, getReferenceImagePath, } from "../utils/file.js";
|
|
7
|
+
import { ffmpegGetImageDimensions, padImageToCanvas } from "../utils/ffmpeg_utils.js";
|
|
8
|
+
import { beatImageSentinelError, bothFramesTarget, missingBeatImageTarget, unsupportedImageTypeTarget, missingFirstFrameTarget } from "../utils/error_cause.js";
|
|
4
9
|
import { imagePrompt, htmlImageSystemPrompt } from "../utils/prompt.js";
|
|
5
10
|
import { renderHTMLToImage } from "../utils/html_render.js";
|
|
6
11
|
import { beatId } from "../utils/utils.js";
|
|
@@ -11,6 +16,121 @@ const htmlStyle = (context, beat) => {
|
|
|
11
16
|
textSlideStyle: MulmoPresentationStyleMethods.getTextSlideStyle(context.presentationStyle, beat),
|
|
12
17
|
};
|
|
13
18
|
};
|
|
19
|
+
// Image-to-video models expect first/last frame images that match the video canvas.
|
|
20
|
+
// Generated reference images come back at the provider's fixed sizes (e.g. gpt-image emits
|
|
21
|
+
// 1536x1024), so aspect-fit pad them to the canvas before movie generation.
|
|
22
|
+
const conformingInFlight = new Map();
|
|
23
|
+
// Aspect-matching sources never get a destPath, so without this memo every beat
|
|
24
|
+
// (and every rerun) would ffprobe them again. Keyed by path + mtime.
|
|
25
|
+
const imageDimensionsCache = new Map();
|
|
26
|
+
const getImageDimensionsCached = async (imagePath, mtimeMs) => {
|
|
27
|
+
const key = `${imagePath}:${mtimeMs}`;
|
|
28
|
+
const cached = imageDimensionsCache.get(key);
|
|
29
|
+
if (cached) {
|
|
30
|
+
return cached;
|
|
31
|
+
}
|
|
32
|
+
const dimensions = await ffmpegGetImageDimensions(imagePath);
|
|
33
|
+
imageDimensionsCache.set(key, dimensions);
|
|
34
|
+
return dimensions;
|
|
35
|
+
};
|
|
36
|
+
export const conformFrameImageToCanvas = async (context, imageName, imagePath, fillColor) => {
|
|
37
|
+
if (!fs.existsSync(imagePath)) {
|
|
38
|
+
return imagePath; // mock agents / dry runs
|
|
39
|
+
}
|
|
40
|
+
const canvasSize = MulmoPresentationStyleMethods.getCanvasSize(context.presentationStyle);
|
|
41
|
+
// The cache key includes the source path and fill color: the same ref name can resolve
|
|
42
|
+
// to different sources in different beats, and a color change must invalidate the cache.
|
|
43
|
+
const digest = createHash("sha256").update(`${imagePath}|${fillColor}`).digest("hex").slice(0, 8);
|
|
44
|
+
const destPath = getReferenceImagePath(context, `${imageName}_fit_${canvasSize.width}x${canvasSize.height}_${digest}`, "png");
|
|
45
|
+
const inFlight = conformingInFlight.get(destPath);
|
|
46
|
+
if (inFlight) {
|
|
47
|
+
return inFlight;
|
|
48
|
+
}
|
|
49
|
+
const promise = (async () => {
|
|
50
|
+
const sourceMtimeMs = fs.statSync(imagePath).mtimeMs;
|
|
51
|
+
if (fs.existsSync(destPath) && fs.statSync(destPath).mtimeMs >= sourceMtimeMs) {
|
|
52
|
+
return destPath;
|
|
53
|
+
}
|
|
54
|
+
const { width, height } = await getImageDimensionsCached(imagePath, sourceMtimeMs);
|
|
55
|
+
if (Math.abs(width / height - canvasSize.width / canvasSize.height) < 0.01) {
|
|
56
|
+
return imagePath;
|
|
57
|
+
}
|
|
58
|
+
GraphAILogger.info(`conformFrameImageToCanvas: padding ${imageName} (${width}x${height}) to ${canvasSize.width}x${canvasSize.height}`);
|
|
59
|
+
try {
|
|
60
|
+
await padImageToCanvas(imagePath, destPath, canvasSize.width, canvasSize.height, fillColor);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
fs.rmSync(destPath, { force: true }); // don't let a partial file poison the mtime cache
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
return destPath;
|
|
67
|
+
})();
|
|
68
|
+
conformingInFlight.set(destPath, promise);
|
|
69
|
+
try {
|
|
70
|
+
return await promise;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
conformingInFlight.delete(destPath);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const validateBeatImageSentinel = (movieParams, beat, index) => {
|
|
77
|
+
if (movieParams.firstFrameImageName === BEAT_IMAGE_SENTINEL && movieParams.lastFrameImageName === BEAT_IMAGE_SENTINEL) {
|
|
78
|
+
throw new Error(`${BEAT_IMAGE_SENTINEL} cannot be used for both firstFrameImageName and lastFrameImageName. beat: ${index}`, {
|
|
79
|
+
cause: beatImageSentinelError(index, bothFramesTarget),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (!beat.imagePrompt && !beat.image) {
|
|
83
|
+
throw new Error(`${BEAT_IMAGE_SENTINEL} requires the beat to have an imagePrompt or image. beat: ${index}`, {
|
|
84
|
+
cause: beatImageSentinelError(index, missingBeatImageTarget),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (beat.image && beatImageTypesWithoutOwnStill.includes(beat.image.type)) {
|
|
88
|
+
throw new Error(`${BEAT_IMAGE_SENTINEL} cannot be used with image type "${beat.image.type}" (the beat image is not its own still). beat: ${index}`, {
|
|
89
|
+
cause: beatImageSentinelError(index, unsupportedImageTypeTarget),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// The implicit first frame (the raw beat image) is not conformed to the canvas, so pairing
|
|
93
|
+
// it with a conformed $beatImage last frame would feed mismatched frame sizes to strict
|
|
94
|
+
// image-to-video models (e.g. wan-2.2-i2v-fast). Require an explicit, conformed first frame.
|
|
95
|
+
if (movieParams.lastFrameImageName === BEAT_IMAGE_SENTINEL && !movieParams.firstFrameImageName) {
|
|
96
|
+
throw new Error(`${BEAT_IMAGE_SENTINEL} as lastFrameImageName requires firstFrameImageName. beat: ${index}`, {
|
|
97
|
+
cause: beatImageSentinelError(index, missingFirstFrameTarget),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
// Resolve firstFrameImageName/lastFrameImageName. Named refs conform to the canvas here;
|
|
102
|
+
// the $beatImage sentinel only sets flags (the beat image does not exist yet at preprocess
|
|
103
|
+
// time) and is resolved by beatFrameResolverAgent after image generation.
|
|
104
|
+
const applyFrameImageParams = async (returnValue, context, beat, index, movieParams, imageRefs) => {
|
|
105
|
+
const frameFillColor = movieParams.frameFillColor ?? "black";
|
|
106
|
+
// A style-level $beatImage merges into every beat; beats that generate no movie
|
|
107
|
+
// (no moviePrompt) ignore the sentinel instead of failing validation.
|
|
108
|
+
const sentinelActive = Boolean(beat.moviePrompt);
|
|
109
|
+
const firstFrameImageName = !sentinelActive && movieParams.firstFrameImageName === BEAT_IMAGE_SENTINEL ? undefined : movieParams.firstFrameImageName;
|
|
110
|
+
const lastFrameImageName = !sentinelActive && movieParams.lastFrameImageName === BEAT_IMAGE_SENTINEL ? undefined : movieParams.lastFrameImageName;
|
|
111
|
+
if (firstFrameImageName === BEAT_IMAGE_SENTINEL || lastFrameImageName === BEAT_IMAGE_SENTINEL) {
|
|
112
|
+
validateBeatImageSentinel(movieParams, beat, index);
|
|
113
|
+
returnValue.frameFillColor = frameFillColor;
|
|
114
|
+
}
|
|
115
|
+
if (firstFrameImageName === BEAT_IMAGE_SENTINEL) {
|
|
116
|
+
returnValue.firstFrameIsBeatImage = true;
|
|
117
|
+
}
|
|
118
|
+
else if (firstFrameImageName && imageRefs) {
|
|
119
|
+
const firstFramePath = imageRefs[firstFrameImageName];
|
|
120
|
+
if (firstFramePath) {
|
|
121
|
+
returnValue.firstFrameImagePath = await conformFrameImageToCanvas(context, firstFrameImageName, firstFramePath, frameFillColor);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (lastFrameImageName === BEAT_IMAGE_SENTINEL) {
|
|
125
|
+
returnValue.lastFrameIsBeatImage = true;
|
|
126
|
+
}
|
|
127
|
+
else if (lastFrameImageName && imageRefs) {
|
|
128
|
+
const lastFramePath = imageRefs[lastFrameImageName];
|
|
129
|
+
if (lastFramePath) {
|
|
130
|
+
returnValue.lastFrameImagePath = await conformFrameImageToCanvas(context, lastFrameImageName, lastFramePath, frameFillColor);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
14
134
|
export const imagePreprocessAgent = async (namedInputs) => {
|
|
15
135
|
const { context, beat, index, imageRefs } = namedInputs;
|
|
16
136
|
const studioBeat = context.studio.beats[index];
|
|
@@ -28,7 +148,7 @@ export const imagePreprocessAgent = async (namedInputs) => {
|
|
|
28
148
|
movieFile: beat.moviePrompt ? moviePaths.movieFile : undefined,
|
|
29
149
|
beatDuration: beat.duration ?? studioBeat?.duration,
|
|
30
150
|
};
|
|
31
|
-
const isMovie = Boolean(beat.moviePrompt || beat?.image?.type ===
|
|
151
|
+
const isMovie = Boolean(beat.moviePrompt || beat?.image?.type === ImageMediaType.Movie);
|
|
32
152
|
if (beat.soundEffectPrompt) {
|
|
33
153
|
if (isMovie) {
|
|
34
154
|
returnValue.soundEffectAgentInfo = MulmoPresentationStyleMethods.getSoundEffectAgentInfo(context.presentationStyle, beat);
|
|
@@ -66,21 +186,12 @@ export const imagePreprocessAgent = async (namedInputs) => {
|
|
|
66
186
|
}
|
|
67
187
|
}
|
|
68
188
|
returnValue.movieAgentInfo = MulmoPresentationStyleMethods.getMovieAgentInfo(context.presentationStyle, beat);
|
|
69
|
-
// Resolve movie reference images from imageRefs
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (movieParams?.lastFrameImageName && imageRefs) {
|
|
78
|
-
const lastFramePath = imageRefs[movieParams.lastFrameImageName];
|
|
79
|
-
if (lastFramePath) {
|
|
80
|
-
returnValue.lastFrameImagePath = lastFramePath;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
if (movieParams?.referenceImages && imageRefs) {
|
|
189
|
+
// Resolve movie reference images from imageRefs.
|
|
190
|
+
// Shallow-merge like getMovieAgentInfo: beat-level fields override style-level fields
|
|
191
|
+
// individually, so a beat setting only lastFrameImageName keeps the global frameFillColor.
|
|
192
|
+
const movieParams = { ...context.presentationStyle.movieParams, ...beat.movieParams };
|
|
193
|
+
await applyFrameImageParams(returnValue, context, beat, index, movieParams, imageRefs);
|
|
194
|
+
if (movieParams.referenceImages && imageRefs) {
|
|
84
195
|
returnValue.movieReferenceImages = movieParams.referenceImages
|
|
85
196
|
.map((ref) => {
|
|
86
197
|
const refPath = imageRefs[ref.imageName];
|
|
@@ -93,7 +204,7 @@ export const imagePreprocessAgent = async (namedInputs) => {
|
|
|
93
204
|
const pluginPath = plugin.path({ beat, context, imagePath, ...htmlStyle(context, beat) });
|
|
94
205
|
const markdown = plugin.markdown ? plugin.markdown({ beat, context, imagePath, ...htmlStyle(context, beat) }) : undefined;
|
|
95
206
|
const html = plugin.html ? await plugin.html({ beat, context, imagePath, ...htmlStyle(context, beat) }) : undefined;
|
|
96
|
-
const isTypeMovie = beat.image.type ===
|
|
207
|
+
const isTypeMovie = beat.image.type === ImageMediaType.Movie;
|
|
97
208
|
const isAnimatedHtml = MulmoBeatMethods.isAnimatedHtmlTailwind(beat);
|
|
98
209
|
// animation and moviePrompt cannot be used together
|
|
99
210
|
if (isAnimatedHtml && beat.moviePrompt) {
|
|
@@ -140,6 +251,26 @@ export const imagePreprocessAgent = async (namedInputs) => {
|
|
|
140
251
|
const movieFirstFramePath = returnValue.firstFrameImagePath ?? imagePath;
|
|
141
252
|
return { ...returnValue, imagePath, referenceImageForMovie: movieFirstFramePath, imageAgentInfo, prompt, referenceImages };
|
|
142
253
|
};
|
|
254
|
+
// Resolves $beatImage frame references after the beat's image exists. imagePreprocessAgent runs
|
|
255
|
+
// before image generation, so it can only flag the sentinel; this agent runs after
|
|
256
|
+
// imageGenerator/imagePlugin and conforms the now-existing beat image to the canvas.
|
|
257
|
+
// movieGenerator consumes only referenceImageForMovie (the movie's first frame / i2v input)
|
|
258
|
+
// and lastFrameImagePath.
|
|
259
|
+
export const beatFrameResolverAgent = async (namedInputs) => {
|
|
260
|
+
const { context, beat, index, preprocessor } = namedInputs;
|
|
261
|
+
if (preprocessor?.imagePath && (preprocessor.firstFrameIsBeatImage || preprocessor.lastFrameIsBeatImage)) {
|
|
262
|
+
const name = `${beatId(beat.id, index)}_beatImage`;
|
|
263
|
+
const conformed = await conformFrameImageToCanvas(context, name, preprocessor.imagePath, preprocessor.frameFillColor ?? "black");
|
|
264
|
+
return {
|
|
265
|
+
lastFrameImagePath: preprocessor.lastFrameIsBeatImage ? conformed : preprocessor.lastFrameImagePath,
|
|
266
|
+
referenceImageForMovie: preprocessor.firstFrameIsBeatImage ? conformed : preprocessor.referenceImageForMovie,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
lastFrameImagePath: preprocessor?.lastFrameImagePath,
|
|
271
|
+
referenceImageForMovie: preprocessor?.referenceImageForMovie,
|
|
272
|
+
};
|
|
273
|
+
};
|
|
143
274
|
export const imagePluginAgent = async (namedInputs) => {
|
|
144
275
|
const { context, beat, index, imageRefs, movieRefs } = namedInputs;
|
|
145
276
|
const { imagePath } = getBeatPngImagePath(context, index);
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { MulmoStudioContext, MulmoBeat, MulmoImagePromptMedia } from "../types/index.js";
|
|
2
|
+
import type { ResolvedImageReference } from "../methods/mulmo_image_prompt_media.js";
|
|
3
|
+
export type { ResolvedImageReference } from "../methods/mulmo_image_prompt_media.js";
|
|
2
4
|
export declare const generateReferenceImage: (inputs: {
|
|
3
5
|
context: MulmoStudioContext;
|
|
4
6
|
key: string;
|
|
5
7
|
index: number;
|
|
6
8
|
image: MulmoImagePromptMedia;
|
|
7
9
|
referenceImagePath?: string;
|
|
10
|
+
references?: ResolvedImageReference[];
|
|
8
11
|
force?: boolean;
|
|
9
12
|
}) => Promise<string>;
|
|
10
13
|
export type MediaRefs = {
|
|
@@ -1,10 +1,36 @@
|
|
|
1
|
-
import { GraphAI, GraphAILogger } from "graphai";
|
|
1
|
+
import { GraphAI, GraphAILogger, graphDataLatestVersion } from "graphai";
|
|
2
2
|
import { getReferenceImagePath } from "../utils/file.js";
|
|
3
3
|
import { imageGraphOption } from "./graph_option.js";
|
|
4
4
|
import { createUsageCallback } from "../utils/usage_callback.js";
|
|
5
|
-
import { MulmoPresentationStyleMethods, MulmoMediaSourceMethods } from "../methods/index.js";
|
|
5
|
+
import { MulmoPresentationStyleMethods, MulmoMediaSourceMethods, MulmoImagePromptMediaMethods } from "../methods/index.js";
|
|
6
|
+
import { ImageMediaType, } from "../types/index.js";
|
|
6
7
|
import { imageOpenaiAgent, mediaMockAgent, imageGenAIAgent, imageReplicateAgent, movieGenAIAgent, movieReplicateAgent } from "../agents/index.js";
|
|
7
8
|
import { agentGenerationError, imageReferenceAction, imageFileTarget, movieFileTarget } from "../utils/error_cause.js";
|
|
9
|
+
// Resolve the normalized reference list to local file paths (with labels), in reference order.
|
|
10
|
+
// imageKey is the imageRefs key of the image being generated (used for warnings and url
|
|
11
|
+
// download file naming). A named reference that is missing from imageRefs warns and is
|
|
12
|
+
// skipped (existing behavior).
|
|
13
|
+
const resolveImageReferences = async (context, imageKey, image, imageRefs) => {
|
|
14
|
+
const imageReferences = MulmoImagePromptMediaMethods.getReferences(image);
|
|
15
|
+
const resolved = await Promise.all(imageReferences.map(async (ref, i) => {
|
|
16
|
+
if (ref.name !== undefined) {
|
|
17
|
+
const path = imageRefs?.[ref.name];
|
|
18
|
+
if (!path) {
|
|
19
|
+
GraphAILogger.warn(`imagePrompt "${imageKey}": reference "${ref.name}" not found in imageRefs — generating without this reference`);
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return { path, label: ref.label };
|
|
23
|
+
}
|
|
24
|
+
if (ref.source !== undefined) {
|
|
25
|
+
// The legacy referenceImage keeps the bare imageKey so url downloads reuse their historical cache file name.
|
|
26
|
+
const sourceKey = ref.legacy ? imageKey : `${imageKey}_ref${i}`;
|
|
27
|
+
return { path: await MulmoMediaSourceMethods.imageReference(ref.source, context, sourceKey), label: ref.label };
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}));
|
|
31
|
+
const references = resolved.filter((ref) => ref !== undefined);
|
|
32
|
+
return references.length > 0 ? references : undefined;
|
|
33
|
+
};
|
|
8
34
|
// public api
|
|
9
35
|
// Application may call this function directly to generate reference image.
|
|
10
36
|
export const generateReferenceImage = async (inputs) => {
|
|
@@ -12,11 +38,13 @@ export const generateReferenceImage = async (inputs) => {
|
|
|
12
38
|
const imagePath = getReferenceImagePath(context, key, "png");
|
|
13
39
|
// generate image
|
|
14
40
|
const imageAgentInfo = MulmoPresentationStyleMethods.getImageAgentInfo(context.presentationStyle);
|
|
15
|
-
const
|
|
41
|
+
const references = MulmoImagePromptMediaMethods.limitReferences(inputs.references ?? (referenceImagePath ? [{ path: referenceImagePath }] : undefined), imageAgentInfo, key);
|
|
42
|
+
const preamble = MulmoImagePromptMediaMethods.buildReferencePreamble((references ?? []).map((ref) => ref.label));
|
|
43
|
+
const prompt = `${preamble}${image.prompt}\n${imageAgentInfo.imageParams.style || ""}`;
|
|
16
44
|
GraphAILogger.info(`Generating reference image for ${key}: ${prompt}`);
|
|
17
|
-
const referenceImages =
|
|
45
|
+
const referenceImages = references ? references.map((ref) => ref.path) : undefined;
|
|
18
46
|
const image_graph_data = {
|
|
19
|
-
version:
|
|
47
|
+
version: graphDataLatestVersion,
|
|
20
48
|
nodes: {
|
|
21
49
|
imageGenerator: {
|
|
22
50
|
agent: imageAgentInfo.agent,
|
|
@@ -62,33 +90,30 @@ export const getMediaRefs = async (context) => {
|
|
|
62
90
|
}
|
|
63
91
|
const imageRefs = {};
|
|
64
92
|
const movieRefs = {};
|
|
65
|
-
// Stage 1: resolve non-referencing entries (image, imagePrompt without
|
|
93
|
+
// Stage 1: resolve non-referencing entries (image, imagePrompt without named references, movie)
|
|
66
94
|
await Promise.all(Object.keys(images)
|
|
67
95
|
.sort()
|
|
68
96
|
.map(async (key, index) => {
|
|
69
97
|
const image = images[key];
|
|
70
|
-
if (image.type ===
|
|
71
|
-
const
|
|
72
|
-
imageRefs[key] = await generateReferenceImage({ context, key, index, image,
|
|
98
|
+
if (image.type === ImageMediaType.ImagePrompt && !MulmoImagePromptMediaMethods.hasNamedReference(image)) {
|
|
99
|
+
const references = await resolveImageReferences(context, key, image);
|
|
100
|
+
imageRefs[key] = await generateReferenceImage({ context, key, index, image, references, force: false });
|
|
73
101
|
}
|
|
74
|
-
else if (image.type ===
|
|
102
|
+
else if (image.type === ImageMediaType.Image) {
|
|
75
103
|
imageRefs[key] = await MulmoMediaSourceMethods.imageReference(image.source, context, key);
|
|
76
104
|
}
|
|
77
|
-
else if (image.type ===
|
|
105
|
+
else if (image.type === ImageMediaType.Movie) {
|
|
78
106
|
movieRefs[key] = await resolveMovieReference(image, context, key);
|
|
79
107
|
}
|
|
80
108
|
}));
|
|
81
|
-
// Stage 2: resolve imagePrompt with
|
|
109
|
+
// Stage 2: resolve imagePrompt with named references (depends on Stage 1 results)
|
|
82
110
|
await Promise.all(Object.keys(images)
|
|
83
111
|
.sort()
|
|
84
112
|
.map(async (key, index) => {
|
|
85
113
|
const image = images[key];
|
|
86
|
-
if (image.type ===
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
GraphAILogger.warn(`imagePrompt "${key}": referenceImageName "${image.referenceImageName}" not found in imageRefs — generating without reference`);
|
|
90
|
-
}
|
|
91
|
-
imageRefs[key] = await generateReferenceImage({ context, key, index, image, referenceImagePath: refPath, force: false });
|
|
114
|
+
if (image.type === ImageMediaType.ImagePrompt && MulmoImagePromptMediaMethods.hasNamedReference(image)) {
|
|
115
|
+
const references = await resolveImageReferences(context, key, image, imageRefs);
|
|
116
|
+
imageRefs[key] = await generateReferenceImage({ context, key, index, image, references, force: false });
|
|
92
117
|
}
|
|
93
118
|
}));
|
|
94
119
|
return { imageRefs, movieRefs };
|
|
@@ -102,7 +127,7 @@ const generateReferenceMovie = async (inputs) => {
|
|
|
102
127
|
const movieAgentInfo = MulmoPresentationStyleMethods.getMovieAgentInfo(context.presentationStyle);
|
|
103
128
|
GraphAILogger.info(`Generating reference movie for ${key}: ${moviePrompt.prompt}`);
|
|
104
129
|
const movie_graph_data = {
|
|
105
|
-
version:
|
|
130
|
+
version: graphDataLatestVersion,
|
|
106
131
|
nodes: {
|
|
107
132
|
movieGenerator: {
|
|
108
133
|
agent: movieAgentInfo.agent,
|
|
@@ -145,45 +170,42 @@ const generateReferenceMovie = async (inputs) => {
|
|
|
145
170
|
const resolveLocalRefs = async (context, images, beatIndex, globalImageRefs) => {
|
|
146
171
|
const localImageRefs = {};
|
|
147
172
|
const localMovieRefs = {};
|
|
148
|
-
// Stage 1: image, imagePrompt (without
|
|
173
|
+
// Stage 1: image, imagePrompt (without named references), movie (parallel)
|
|
149
174
|
await Promise.all(Object.keys(images)
|
|
150
175
|
.sort()
|
|
151
176
|
.map(async (key, i) => {
|
|
152
177
|
const entry = images[key];
|
|
153
|
-
if (entry.type ===
|
|
154
|
-
const
|
|
178
|
+
if (entry.type === ImageMediaType.ImagePrompt && !MulmoImagePromptMediaMethods.hasNamedReference(entry)) {
|
|
179
|
+
const references = await resolveImageReferences(context, key, entry);
|
|
155
180
|
localImageRefs[key] = await generateReferenceImage({
|
|
156
181
|
context,
|
|
157
182
|
key,
|
|
158
183
|
index: beatIndex * 100 + i,
|
|
159
184
|
image: entry,
|
|
160
|
-
|
|
185
|
+
references,
|
|
161
186
|
});
|
|
162
187
|
}
|
|
163
|
-
else if (entry.type ===
|
|
188
|
+
else if (entry.type === ImageMediaType.Image) {
|
|
164
189
|
localImageRefs[key] = await MulmoMediaSourceMethods.imageReference(entry.source, context, key);
|
|
165
190
|
}
|
|
166
|
-
else if (entry.type ===
|
|
191
|
+
else if (entry.type === ImageMediaType.Movie) {
|
|
167
192
|
localMovieRefs[key] = await resolveMovieReference(entry, context, key);
|
|
168
193
|
}
|
|
169
194
|
}));
|
|
170
|
-
// Stage 2: imagePrompt with
|
|
195
|
+
// Stage 2: imagePrompt with named references (depends on Stage 1)
|
|
171
196
|
const combinedImageRefsForImagePrompt = { ...globalImageRefs, ...localImageRefs };
|
|
172
197
|
await Promise.all(Object.keys(images)
|
|
173
198
|
.sort()
|
|
174
199
|
.map(async (key, i) => {
|
|
175
200
|
const entry = images[key];
|
|
176
|
-
if (entry.type ===
|
|
177
|
-
const
|
|
178
|
-
if (!refPath) {
|
|
179
|
-
GraphAILogger.warn(`imagePrompt "${key}": referenceImageName "${entry.referenceImageName}" not found — generating without reference`);
|
|
180
|
-
}
|
|
201
|
+
if (entry.type === ImageMediaType.ImagePrompt && MulmoImagePromptMediaMethods.hasNamedReference(entry)) {
|
|
202
|
+
const references = await resolveImageReferences(context, key, entry, combinedImageRefsForImagePrompt);
|
|
181
203
|
localImageRefs[key] = await generateReferenceImage({
|
|
182
204
|
context,
|
|
183
205
|
key,
|
|
184
206
|
index: beatIndex * 100 + i,
|
|
185
207
|
image: entry,
|
|
186
|
-
|
|
208
|
+
references,
|
|
187
209
|
});
|
|
188
210
|
}
|
|
189
211
|
}));
|
|
@@ -193,7 +215,7 @@ const resolveLocalRefs = async (context, images, beatIndex, globalImageRefs) =>
|
|
|
193
215
|
.sort()
|
|
194
216
|
.map(async (key, i) => {
|
|
195
217
|
const entry = images[key];
|
|
196
|
-
if (entry.type ===
|
|
218
|
+
if (entry.type === ImageMediaType.MoviePrompt) {
|
|
197
219
|
const refImagePath = entry.imageName ? combinedImageRefs[entry.imageName] : undefined;
|
|
198
220
|
localMovieRefs[key] = await generateReferenceMovie({
|
|
199
221
|
context,
|
package/lib/actions/images.d.ts
CHANGED
|
@@ -72,6 +72,9 @@ export declare const beat_graph_data: {
|
|
|
72
72
|
};
|
|
73
73
|
firstFrameImagePath?: string;
|
|
74
74
|
lastFrameImagePath?: string;
|
|
75
|
+
firstFrameIsBeatImage?: boolean;
|
|
76
|
+
lastFrameIsBeatImage?: boolean;
|
|
77
|
+
frameFillColor?: string;
|
|
75
78
|
movieReferenceImages?: {
|
|
76
79
|
imagePath: string;
|
|
77
80
|
referenceType: "ASSET" | "STYLE";
|
|
@@ -103,6 +106,9 @@ export declare const beat_graph_data: {
|
|
|
103
106
|
};
|
|
104
107
|
firstFrameImagePath?: string;
|
|
105
108
|
lastFrameImagePath?: string;
|
|
109
|
+
firstFrameIsBeatImage?: boolean;
|
|
110
|
+
lastFrameIsBeatImage?: boolean;
|
|
111
|
+
frameFillColor?: string;
|
|
106
112
|
movieReferenceImages?: {
|
|
107
113
|
imagePath: string;
|
|
108
114
|
referenceType: "ASSET" | "STYLE";
|
|
@@ -145,6 +151,9 @@ export declare const beat_graph_data: {
|
|
|
145
151
|
};
|
|
146
152
|
firstFrameImagePath?: string;
|
|
147
153
|
lastFrameImagePath?: string;
|
|
154
|
+
firstFrameIsBeatImage?: boolean;
|
|
155
|
+
lastFrameIsBeatImage?: boolean;
|
|
156
|
+
frameFillColor?: string;
|
|
148
157
|
movieReferenceImages?: {
|
|
149
158
|
imagePath: string;
|
|
150
159
|
referenceType: "ASSET" | "STYLE";
|
|
@@ -179,6 +188,9 @@ export declare const beat_graph_data: {
|
|
|
179
188
|
};
|
|
180
189
|
firstFrameImagePath?: string;
|
|
181
190
|
lastFrameImagePath?: string;
|
|
191
|
+
firstFrameIsBeatImage?: boolean;
|
|
192
|
+
lastFrameIsBeatImage?: boolean;
|
|
193
|
+
frameFillColor?: string;
|
|
182
194
|
movieReferenceImages?: {
|
|
183
195
|
imagePath: string;
|
|
184
196
|
referenceType: "ASSET" | "STYLE";
|
|
@@ -298,6 +310,24 @@ export declare const beat_graph_data: {
|
|
|
298
310
|
};
|
|
299
311
|
defaultValue: {};
|
|
300
312
|
};
|
|
313
|
+
frameResolver: {
|
|
314
|
+
agent: (namedInputs: {
|
|
315
|
+
context: MulmoStudioContext;
|
|
316
|
+
beat: import("../types/type.js").MulmoBeat;
|
|
317
|
+
index: number;
|
|
318
|
+
preprocessor?: import("./image_agents.js").BeatFramePreprocessorInput;
|
|
319
|
+
}) => Promise<{
|
|
320
|
+
lastFrameImagePath: string | undefined;
|
|
321
|
+
referenceImageForMovie: string | undefined;
|
|
322
|
+
}>;
|
|
323
|
+
inputs: {
|
|
324
|
+
onComplete: string[];
|
|
325
|
+
context: string;
|
|
326
|
+
beat: string;
|
|
327
|
+
index: string;
|
|
328
|
+
preprocessor: string;
|
|
329
|
+
};
|
|
330
|
+
};
|
|
301
331
|
movieGenerator: {
|
|
302
332
|
if: string;
|
|
303
333
|
agent: string;
|
package/lib/actions/images.js
CHANGED
|
@@ -13,7 +13,7 @@ import { getOutputStudioFilePath, mkdir } from "../utils/file.js";
|
|
|
13
13
|
import { audioCheckerError } from "../utils/error_cause.js";
|
|
14
14
|
import { extractImageFromMovie, ffmpegGetMediaDuration, trimMusic } from "../utils/ffmpeg_utils.js";
|
|
15
15
|
import { getMediaRefs, resolveBeatLocalRefs } from "./image_references.js";
|
|
16
|
-
import { imagePreprocessAgent, imagePluginAgent, htmlImageGeneratorAgent } from "./image_agents.js";
|
|
16
|
+
import { imagePreprocessAgent, imagePluginAgent, htmlImageGeneratorAgent, beatFrameResolverAgent } from "./image_agents.js";
|
|
17
17
|
import { imageGraphOption } from "./graph_option.js";
|
|
18
18
|
import { createUsageCallback } from "../utils/usage_callback.js";
|
|
19
19
|
const vanillaAgents = vanilla.default ?? vanilla;
|
|
@@ -169,15 +169,27 @@ export const beat_graph_data = {
|
|
|
169
169
|
},
|
|
170
170
|
defaultValue: {},
|
|
171
171
|
},
|
|
172
|
+
// Resolves $beatImage frame references once the beat image exists (after imageGenerator/imagePlugin);
|
|
173
|
+
// passes the preprocessor's frame paths through unchanged for non-sentinel beats.
|
|
174
|
+
frameResolver: {
|
|
175
|
+
agent: beatFrameResolverAgent,
|
|
176
|
+
inputs: {
|
|
177
|
+
onComplete: [":imageGenerator", ":imagePlugin"],
|
|
178
|
+
context: ":context",
|
|
179
|
+
beat: ":beat",
|
|
180
|
+
index: ":__mapIndex",
|
|
181
|
+
preprocessor: ":preprocessor",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
172
184
|
movieGenerator: {
|
|
173
185
|
if: ":beat.moviePrompt",
|
|
174
186
|
agent: ":preprocessor.movieAgentInfo.agent",
|
|
175
187
|
inputs: {
|
|
176
188
|
media: "movie",
|
|
177
|
-
onComplete: [":
|
|
189
|
+
onComplete: [":frameResolver"], // frameResolver itself waits for imageGenerator/imagePlugin
|
|
178
190
|
prompt: ":beat.moviePrompt",
|
|
179
|
-
imagePath: ":
|
|
180
|
-
lastFrameImagePath: ":
|
|
191
|
+
imagePath: ":frameResolver.referenceImageForMovie",
|
|
192
|
+
lastFrameImagePath: ":frameResolver.lastFrameImagePath",
|
|
181
193
|
referenceImages: ":preprocessor.movieReferenceImages",
|
|
182
194
|
movieFile: ":preprocessor.movieFile", // for google genai agent
|
|
183
195
|
cache: {
|
package/lib/methods/index.d.ts
CHANGED
package/lib/methods/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from "./mulmo_presentation_style.js";
|
|
2
2
|
export * from "./mulmo_studio_context.js";
|
|
3
3
|
export * from "./mulmo_media_source.js";
|
|
4
|
+
export * from "./mulmo_image_prompt_media.js";
|
|
4
5
|
// Node-only MulmoBeatMethods (includes getPlugin). The browser-safe subset lives in
|
|
5
6
|
// ./mulmo_beat.js and is re-exported by ../index.common.js for mulmocast/browser.
|
|
6
7
|
export * from "./mulmo_beat_node.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { MulmoImagePromptMedia, MulmoImageReference, Text2ImageAgentInfo } from "../types/index.js";
|
|
2
|
+
export type MulmoNormalizedImageReference = MulmoImageReference & {
|
|
3
|
+
legacy?: boolean;
|
|
4
|
+
};
|
|
5
|
+
export type ResolvedImageReference = {
|
|
6
|
+
path: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare const MulmoImagePromptMediaMethods: {
|
|
10
|
+
getReferences(image: MulmoImagePromptMedia): MulmoNormalizedImageReference[];
|
|
11
|
+
hasNamedReference(image: MulmoImagePromptMedia): boolean;
|
|
12
|
+
buildReferencePreamble(labels: (string | undefined)[]): string;
|
|
13
|
+
limitReferences(references: ResolvedImageReference[] | undefined, imageAgentInfo: Text2ImageAgentInfo, imageKey: string): ResolvedImageReference[] | undefined;
|
|
14
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { GraphAILogger } from "graphai";
|
|
2
|
+
import { getMaxImageReferenceImages } from "../types/provider2agent.js";
|
|
3
|
+
export const MulmoImagePromptMediaMethods = {
|
|
4
|
+
// Normalized reference list: legacy single-ref fields first, then the references array.
|
|
5
|
+
// When both legacy fields are set, referenceImageName wins and referenceImage is ignored,
|
|
6
|
+
// matching the pre-references resolution behavior.
|
|
7
|
+
getReferences(image) {
|
|
8
|
+
const references = [];
|
|
9
|
+
if (image.referenceImageName) {
|
|
10
|
+
references.push({ name: image.referenceImageName, legacy: true });
|
|
11
|
+
}
|
|
12
|
+
else if (image.referenceImage) {
|
|
13
|
+
references.push({ source: image.referenceImage, legacy: true });
|
|
14
|
+
}
|
|
15
|
+
references.push(...(image.references ?? []));
|
|
16
|
+
return references;
|
|
17
|
+
},
|
|
18
|
+
// Entries with a named reference must resolve after stage 1 (which generates/loads the named images).
|
|
19
|
+
hasNamedReference(image) {
|
|
20
|
+
return this.getReferences(image).some((ref) => ref.name !== undefined);
|
|
21
|
+
},
|
|
22
|
+
// "Reference image 1 shows <label>. ... Use these exact designs.\n"
|
|
23
|
+
// Empty when no reference has a label, keeping label-free prompts byte-identical to before.
|
|
24
|
+
buildReferencePreamble(labels) {
|
|
25
|
+
const sentences = labels.map((label, index) => (label ? `Reference image ${index + 1} shows ${label}.` : undefined)).filter((s) => s !== undefined);
|
|
26
|
+
if (sentences.length === 0) {
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
return `${sentences.join(" ")} Use these exact designs.\n`;
|
|
30
|
+
},
|
|
31
|
+
// Truncate to the provider/model limit, keeping the first N deterministically.
|
|
32
|
+
// imageKey is the imageRefs key of the image being generated (used for the warning).
|
|
33
|
+
limitReferences(references, imageAgentInfo, imageKey) {
|
|
34
|
+
if (!references) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const { provider, model } = imageAgentInfo.imageParams;
|
|
38
|
+
const max = getMaxImageReferenceImages(provider, model ?? "");
|
|
39
|
+
if (max !== undefined && references.length > max) {
|
|
40
|
+
GraphAILogger.warn(`imagePrompt "${imageKey}": ${references.length} reference images exceed the ${provider} limit of ${max} — keeping the first ${max}`);
|
|
41
|
+
return references.slice(0, max);
|
|
42
|
+
}
|
|
43
|
+
return references;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -194,6 +194,7 @@ export declare const MulmoPresentationStyleMethods: {
|
|
|
194
194
|
vertexai_location?: string | undefined;
|
|
195
195
|
firstFrameImageName?: string | undefined;
|
|
196
196
|
lastFrameImageName?: string | undefined;
|
|
197
|
+
frameFillColor?: string | undefined;
|
|
197
198
|
referenceImages?: {
|
|
198
199
|
imageName: string;
|
|
199
200
|
referenceType: "ASSET" | "STYLE";
|