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.
@@ -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 === "movie");
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
- const movieParams = beat.movieParams ?? context.presentationStyle.movieParams;
71
- if (movieParams?.firstFrameImageName && imageRefs) {
72
- const firstFramePath = imageRefs[movieParams.firstFrameImageName];
73
- if (firstFramePath) {
74
- returnValue.firstFrameImagePath = firstFramePath;
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 === "movie";
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 prompt = `${image.prompt}\n${imageAgentInfo.imageParams.style || ""}`;
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 = referenceImagePath ? [referenceImagePath] : undefined;
45
+ const referenceImages = references ? references.map((ref) => ref.path) : undefined;
18
46
  const image_graph_data = {
19
- version: 0.5,
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 referenceImageName, movie)
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 === "imagePrompt" && !image.referenceImageName) {
71
- const refPath = image.referenceImage ? await MulmoMediaSourceMethods.imageReference(image.referenceImage, context, key) : undefined;
72
- imageRefs[key] = await generateReferenceImage({ context, key, index, image, referenceImagePath: refPath, force: false });
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 === "image") {
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 === "movie") {
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 referenceImageName (depends on Stage 1 results)
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 === "imagePrompt" && image.referenceImageName) {
87
- const refPath = imageRefs[image.referenceImageName];
88
- if (!refPath) {
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: 0.5,
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 referenceImageName), movie (parallel)
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 === "imagePrompt" && !entry.referenceImageName) {
154
- const refPath = entry.referenceImage ? await MulmoMediaSourceMethods.imageReference(entry.referenceImage, context, key) : undefined;
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
- referenceImagePath: refPath,
185
+ references,
161
186
  });
162
187
  }
163
- else if (entry.type === "image") {
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 === "movie") {
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 referenceImageName (depends on Stage 1)
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 === "imagePrompt" && entry.referenceImageName) {
177
- const refPath = combinedImageRefsForImagePrompt[entry.referenceImageName];
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
- referenceImagePath: refPath,
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 === "moviePrompt") {
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,
@@ -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;
@@ -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: [":imageGenerator", ":imagePlugin"], // to wait for imageGenerator to finish
189
+ onComplete: [":frameResolver"], // frameResolver itself waits for imageGenerator/imagePlugin
178
190
  prompt: ":beat.moviePrompt",
179
- imagePath: ":preprocessor.referenceImageForMovie",
180
- lastFrameImagePath: ":preprocessor.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: {
@@ -1,5 +1,6 @@
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
  export * from "./mulmo_beat_node.js";
5
6
  export * from "./mulmo_script.js";
@@ -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";