mulmocast 2.8.1 → 2.9.1
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 +20 -0
- package/lib/actions/image_agents.js +83 -15
- 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/actions/movie.d.ts +2 -9
- package/lib/actions/movie.js +149 -147
- package/lib/methods/index.d.ts +1 -0
- package/lib/methods/index.js +1 -0
- package/lib/methods/mulmo_beat.d.ts +3 -0
- package/lib/methods/mulmo_beat.js +17 -0
- package/lib/methods/mulmo_beat_node.d.ts +3 -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 +5 -1
- package/lib/methods/mulmo_presentation_style.js +43 -1
- package/lib/methods/mulmo_studio_context.d.ts +6 -1
- package/lib/methods/mulmo_studio_context.js +36 -1
- package/lib/types/provider2agent.d.ts +15 -0
- package/lib/types/provider2agent.js +31 -0
- package/lib/types/schema.d.ts +192 -0
- package/lib/types/schema.js +42 -12
- package/lib/types/type.d.ts +2 -1
- package/lib/utils/context.d.ts +70 -0
- package/lib/utils/error_cause.d.ts +12 -0
- package/lib/utils/error_cause.js +15 -0
- package/package.json +9 -9
- package/scripts/test/test_voice_over_transition_no_audio.json +71 -0
package/lib/actions/movie.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { GraphAILogger, assert } from "graphai";
|
|
2
|
-
import {
|
|
3
|
-
import { MulmoPresentationStyleMethods } from "../methods/index.js";
|
|
2
|
+
import { MulmoPresentationStyleMethods, MulmoBeatMethods } from "../methods/index.js";
|
|
4
3
|
import { getAudioArtifactFilePath, getOutputVideoFilePath, writingMessage, isFile } from "../utils/file.js";
|
|
5
4
|
import { createVideoFileError, createVideoSourceError } from "../utils/error_cause.js";
|
|
6
5
|
import { FfmpegContextAddInput, FfmpegContextInit, FfmpegContextPushFormattedAudio, FfmpegContextGenerateOutput, } from "../utils/ffmpeg_utils.js";
|
|
@@ -9,7 +8,14 @@ import { convertVideoFilterToFFmpeg } from "../utils/video_filter.js";
|
|
|
9
8
|
// const isMac = process.platform === "darwin";
|
|
10
9
|
const videoCodec = "libx264"; // "h264_videotoolbox" (macOS only) is too noisy
|
|
11
10
|
const VIDEO_FPS = 30;
|
|
12
|
-
|
|
11
|
+
// videoIdsForBeats holds undefined for voice_over beats; walk back to the previous rendered beat's videoId.
|
|
12
|
+
const getPrevVideoId = (videoIdsForBeats, index) => {
|
|
13
|
+
const offset = videoIdsForBeats
|
|
14
|
+
.slice(0, Math.max(index, 0))
|
|
15
|
+
.reverse()
|
|
16
|
+
.findIndex((videoId) => videoId !== undefined);
|
|
17
|
+
return offset < 0 ? undefined : videoIdsForBeats[index - 1 - offset];
|
|
18
|
+
};
|
|
13
19
|
export const getVideoPart = (inputIndex, isMovie, duration, canvasInfo, fillOption, speed, filters, frameCount) => {
|
|
14
20
|
const videoId = `v${inputIndex}`;
|
|
15
21
|
const videoFilters = [];
|
|
@@ -149,109 +155,104 @@ export const getInOverlayCoords = (transitionType, d, t) => {
|
|
|
149
155
|
}
|
|
150
156
|
throw new Error(`Unknown transition type: ${transitionType}`);
|
|
151
157
|
};
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
158
|
+
// Each of these returns the id of the video to keep building on, or undefined when the transition
|
|
159
|
+
// cannot be applied (in which case the caller keeps the untouched video).
|
|
160
|
+
const addFadeTransition = ({ ffmpegContext, prevVideoId, transitionVideoId, outputVideoId, startAt, duration }) => {
|
|
161
|
+
// Fade out the previous beat's last frame
|
|
162
|
+
const processedVideoId = `${transitionVideoId}_f`;
|
|
163
|
+
ffmpegContext.filterComplex.push(`[${transitionVideoId}]format=yuva420p,fade=t=out:d=${duration}:alpha=1,setpts=PTS-STARTPTS+${startAt}/TB[${processedVideoId}]`);
|
|
164
|
+
ffmpegContext.filterComplex.push(`[${prevVideoId}][${processedVideoId}]overlay=enable='between(t,${startAt},${startAt + duration})'[${outputVideoId}]`);
|
|
165
|
+
return outputVideoId;
|
|
166
|
+
};
|
|
167
|
+
const addSlideoutTransition = ({ ffmpegContext, transitionType, prevVideoId, transitionVideoId, outputVideoId, startAt, duration }) => {
|
|
168
|
+
// Slideout: previous beat's last frame slides out
|
|
169
|
+
const processedVideoId = `${transitionVideoId}_f`;
|
|
170
|
+
ffmpegContext.filterComplex.push(`[${transitionVideoId}]format=yuva420p,setpts=PTS-STARTPTS+${startAt}/TB[${processedVideoId}]`);
|
|
171
|
+
ffmpegContext.filterComplex.push(`[${prevVideoId}][${processedVideoId}]overlay=${getOutOverlayCoords(transitionType, duration, startAt)}:enable='between(t,${startAt},${startAt + duration})'[${outputVideoId}]`);
|
|
172
|
+
return outputVideoId;
|
|
173
|
+
};
|
|
174
|
+
const addSlideinTransition = ({ ffmpegContext, transitionType, prevVideoId, nextVideoId, prevLastFrame, outputVideoId, startAt, duration, }) => {
|
|
175
|
+
// Slidein: this beat's first frame slides in over the previous beat's last frame
|
|
176
|
+
if (!nextVideoId || !prevLastFrame) {
|
|
177
|
+
return undefined; // Cannot apply slidein without the first frame or the previous shot
|
|
178
|
+
}
|
|
179
|
+
// Prepare background (last frame of previous beat)
|
|
180
|
+
const backgroundVideoId = `${prevLastFrame}_bg`;
|
|
181
|
+
ffmpegContext.filterComplex.push(`[${prevLastFrame}]format=yuva420p,setpts=PTS-STARTPTS+${startAt}/TB[${backgroundVideoId}]`);
|
|
182
|
+
// Prepare sliding frame (first frame of this beat)
|
|
183
|
+
const slideinFrameId = `${nextVideoId}_f`;
|
|
184
|
+
ffmpegContext.filterComplex.push(`[${nextVideoId}]format=yuva420p,setpts=PTS-STARTPTS+${startAt}/TB[${slideinFrameId}]`);
|
|
185
|
+
// First overlay: put background on top of concat video
|
|
186
|
+
const bgOutputId = `${prevLastFrame}_bg_o`;
|
|
187
|
+
ffmpegContext.filterComplex.push(`[${prevVideoId}][${backgroundVideoId}]overlay=enable='between(t,${startAt},${startAt + duration})'[${bgOutputId}]`);
|
|
188
|
+
// Second overlay: slide in the new frame on top of background
|
|
189
|
+
ffmpegContext.filterComplex.push(`[${bgOutputId}][${slideinFrameId}]overlay=${getInOverlayCoords(transitionType, duration, startAt)}:enable='between(t,${startAt},${startAt + duration})'[${outputVideoId}]`);
|
|
190
|
+
return outputVideoId;
|
|
191
|
+
};
|
|
192
|
+
const addWipeTransition = ({ ffmpegContext, transitionType, prevVideoId, transitionVideoId, nextVideoId, outputVideoId, startAt, duration, }) => {
|
|
193
|
+
// Wipe transition: use xfade filter between previous beat's last frame and this beat's first frame
|
|
194
|
+
if (!nextVideoId) {
|
|
195
|
+
return undefined; // Cannot apply wipe without first frame
|
|
196
|
+
}
|
|
197
|
+
// For wipe transitions, use offset=0 so transition starts immediately and completes within duration
|
|
198
|
+
// This ensures 0-100% wipe happens exactly during the transition duration
|
|
199
|
+
const xfadeOffset = 0;
|
|
200
|
+
// Apply xfade with offset=0 for complete 0-100% transition
|
|
201
|
+
// Both input videos should be at least duration seconds long (ensured by static frame generation)
|
|
202
|
+
const xfadeOutputId = `${transitionVideoId}_xfade`;
|
|
203
|
+
ffmpegContext.filterComplex.push(`[${transitionVideoId}]format=yuv420p[${transitionVideoId}_fmt]`);
|
|
204
|
+
ffmpegContext.filterComplex.push(`[${nextVideoId}]format=yuv420p[${nextVideoId}_fmt]`);
|
|
205
|
+
ffmpegContext.filterComplex.push(`[${transitionVideoId}_fmt][${nextVideoId}_fmt]xfade=transition=${transitionType}:duration=${duration}:offset=${xfadeOffset}[${xfadeOutputId}]`);
|
|
206
|
+
// Set PTS for overlay timing
|
|
207
|
+
const xfadeTimedId = `${xfadeOutputId}_t`;
|
|
208
|
+
ffmpegContext.filterComplex.push(`[${xfadeOutputId}]setpts=PTS-STARTPTS+${startAt}/TB[${xfadeTimedId}]`);
|
|
209
|
+
// Overlay the xfade result on the concat video
|
|
210
|
+
ffmpegContext.filterComplex.push(`[${prevVideoId}][${xfadeTimedId}]overlay=enable='between(t,${startAt},${startAt + duration})'[${outputVideoId}]`);
|
|
211
|
+
return outputVideoId;
|
|
212
|
+
};
|
|
213
|
+
const addTransitionEffect = (transitionContext) => {
|
|
214
|
+
const { transitionType } = transitionContext;
|
|
215
|
+
if (transitionType === "fade") {
|
|
216
|
+
return addFadeTransition(transitionContext);
|
|
217
|
+
}
|
|
218
|
+
if (transitionType.startsWith("slideout_")) {
|
|
219
|
+
return addSlideoutTransition(transitionContext);
|
|
220
|
+
}
|
|
221
|
+
if (transitionType.startsWith("slidein_")) {
|
|
222
|
+
return addSlideinTransition(transitionContext);
|
|
155
223
|
}
|
|
224
|
+
if (transitionType.startsWith("wipe")) {
|
|
225
|
+
return addWipeTransition(transitionContext);
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`Unknown transition type: ${transitionType}`);
|
|
228
|
+
};
|
|
229
|
+
const addTransitionEffects = (ffmpegContext, captionedVideoId, context, transitionVideoIds, beatTimestamps, videoIdsForBeats) => {
|
|
156
230
|
return transitionVideoIds.reduce((prevVideoId, { videoId: transitionVideoId, nextVideoId, beatIndex }) => {
|
|
157
231
|
const beat = context.studio.script.beats[beatIndex];
|
|
158
232
|
const transition = MulmoPresentationStyleMethods.getMovieTransition(context, beat);
|
|
159
233
|
if (!transition) {
|
|
160
234
|
return prevVideoId; // Skip if no transition is defined
|
|
161
235
|
}
|
|
162
|
-
// Transition happens at the start of this beat
|
|
163
|
-
const startAt = beatTimestamps[beatIndex] - 0.05; // 0.05 is to avoid flickering
|
|
164
236
|
// Limit transition duration to be no longer than either beat's duration
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (!nextVideoId) {
|
|
183
|
-
// Cannot apply slidein without first frame
|
|
184
|
-
return prevVideoId;
|
|
185
|
-
}
|
|
186
|
-
// Get previous beat's last frame for background
|
|
187
|
-
const prevVideoSourceId = videoIdsForBeats[beatIndex - 1];
|
|
188
|
-
// Both movie and image beats now have _last
|
|
189
|
-
const prevLastFrame = `${prevVideoSourceId}_last`;
|
|
190
|
-
// Prepare background (last frame of previous beat)
|
|
191
|
-
const backgroundVideoId = `${prevLastFrame}_bg`;
|
|
192
|
-
ffmpegContext.filterComplex.push(`[${prevLastFrame}]format=yuva420p,setpts=PTS-STARTPTS+${startAt}/TB[${backgroundVideoId}]`);
|
|
193
|
-
// Prepare sliding frame (first frame of this beat)
|
|
194
|
-
const slideinFrameId = `${nextVideoId}_f`;
|
|
195
|
-
ffmpegContext.filterComplex.push(`[${nextVideoId}]format=yuva420p,setpts=PTS-STARTPTS+${startAt}/TB[${slideinFrameId}]`);
|
|
196
|
-
// First overlay: put background on top of concat video
|
|
197
|
-
const bgOutputId = `${prevLastFrame}_bg_o`;
|
|
198
|
-
ffmpegContext.filterComplex.push(`[${prevVideoId}][${backgroundVideoId}]overlay=enable='between(t,${startAt},${startAt + duration})'[${bgOutputId}]`);
|
|
199
|
-
// Second overlay: slide in the new frame on top of background
|
|
200
|
-
ffmpegContext.filterComplex.push(`[${bgOutputId}][${slideinFrameId}]overlay=${getInOverlayCoords(transition.type, duration, startAt)}:enable='between(t,${startAt},${startAt + duration})'[${outputVideoId}]`);
|
|
201
|
-
}
|
|
202
|
-
else if (transition.type.startsWith("wipe")) {
|
|
203
|
-
// Wipe transition: use xfade filter between previous beat's last frame and this beat's first frame
|
|
204
|
-
if (!nextVideoId) {
|
|
205
|
-
// Cannot apply wipe without first frame
|
|
206
|
-
return prevVideoId;
|
|
207
|
-
}
|
|
208
|
-
// For wipe transitions, use offset=0 so transition starts immediately and completes within duration
|
|
209
|
-
// This ensures 0-100% wipe happens exactly during the transition duration
|
|
210
|
-
const xfadeOffset = 0;
|
|
211
|
-
// Apply xfade with offset=0 for complete 0-100% transition
|
|
212
|
-
// Both input videos should be at least duration seconds long (ensured by static frame generation)
|
|
213
|
-
const xfadeOutputId = `${transitionVideoId}_xfade`;
|
|
214
|
-
ffmpegContext.filterComplex.push(`[${transitionVideoId}]format=yuv420p[${transitionVideoId}_fmt]`);
|
|
215
|
-
ffmpegContext.filterComplex.push(`[${nextVideoId}]format=yuv420p[${nextVideoId}_fmt]`);
|
|
216
|
-
ffmpegContext.filterComplex.push(`[${transitionVideoId}_fmt][${nextVideoId}_fmt]xfade=transition=${transition.type}:duration=${duration}:offset=${xfadeOffset}[${xfadeOutputId}]`);
|
|
217
|
-
// Set PTS for overlay timing
|
|
218
|
-
const xfadeTimedId = `${xfadeOutputId}_t`;
|
|
219
|
-
ffmpegContext.filterComplex.push(`[${xfadeOutputId}]setpts=PTS-STARTPTS+${startAt}/TB[${xfadeTimedId}]`);
|
|
220
|
-
// Overlay the xfade result on the concat video
|
|
221
|
-
ffmpegContext.filterComplex.push(`[${prevVideoId}][${xfadeTimedId}]overlay=enable='between(t,${startAt},${startAt + duration})'[${outputVideoId}]`);
|
|
222
|
-
}
|
|
223
|
-
else {
|
|
224
|
-
throw new Error(`Unknown transition type: ${transition.type}`);
|
|
225
|
-
}
|
|
226
|
-
return outputVideoId;
|
|
237
|
+
const prevBeatIndex = MulmoBeatMethods.getPrevRenderedBeatIndex(context.studio.script.beats, beatIndex);
|
|
238
|
+
const duration = getClampedTransitionDuration(transition.duration, MulmoStudioContextMethods.getSegmentDuration(context, prevBeatIndex), MulmoStudioContextMethods.getSegmentDuration(context, beatIndex));
|
|
239
|
+
// The last frame of the previous rendered beat (voice_over beats have no video of their own).
|
|
240
|
+
// Both movie and image beats now have _last.
|
|
241
|
+
const prevVideoSourceId = getPrevVideoId(videoIdsForBeats, beatIndex);
|
|
242
|
+
return (addTransitionEffect({
|
|
243
|
+
ffmpegContext,
|
|
244
|
+
transitionType: transition.type,
|
|
245
|
+
prevVideoId,
|
|
246
|
+
transitionVideoId,
|
|
247
|
+
nextVideoId,
|
|
248
|
+
prevLastFrame: prevVideoSourceId ? `${prevVideoSourceId}_last` : undefined,
|
|
249
|
+
outputVideoId: `trans_${beatIndex}_o`,
|
|
250
|
+
// Transition happens at the start of this beat. 0.05 is to avoid flickering.
|
|
251
|
+
startAt: beatTimestamps[beatIndex] - 0.05,
|
|
252
|
+
duration,
|
|
253
|
+
}) ?? prevVideoId);
|
|
227
254
|
}, captionedVideoId);
|
|
228
255
|
};
|
|
229
|
-
export const getNeedFirstFrame = (context) => {
|
|
230
|
-
return context.studio.script.beats.map((beat, index) => {
|
|
231
|
-
if (index === 0)
|
|
232
|
-
return false; // First beat cannot have transition
|
|
233
|
-
const transition = MulmoPresentationStyleMethods.getMovieTransition(context, beat);
|
|
234
|
-
return (transition?.type.startsWith("slidein_") || transition?.type.startsWith("wipe")) ?? false;
|
|
235
|
-
});
|
|
236
|
-
};
|
|
237
|
-
export const getNeedLastFrame = (context) => {
|
|
238
|
-
return context.studio.script.beats.map((beat, index) => {
|
|
239
|
-
if (index === context.studio.script.beats.length - 1)
|
|
240
|
-
return false; // Last beat doesn't need _last
|
|
241
|
-
const nextTransition = MulmoPresentationStyleMethods.getMovieTransition(context, context.studio.script.beats[index + 1]);
|
|
242
|
-
return nextTransition !== null; // Any transition on next beat requires this beat's last frame
|
|
243
|
-
});
|
|
244
|
-
};
|
|
245
|
-
export const resolveMovieVolume = (beat, context) => {
|
|
246
|
-
const baseMovieVolume = beat.audioParams?.movieVolume ?? context.presentationStyle.audioParams.movieVolume ?? 1.0;
|
|
247
|
-
const ducking = context.presentationStyle.audioParams.ducking;
|
|
248
|
-
const hasSpeech = !!beat.text && !context.presentationStyle.audioParams.suppressSpeech;
|
|
249
|
-
if (ducking && hasSpeech) {
|
|
250
|
-
const ratio = ducking.ratio ?? DEFAULT_DUCKING_RATIO;
|
|
251
|
-
return baseMovieVolume * ratio;
|
|
252
|
-
}
|
|
253
|
-
return baseMovieVolume;
|
|
254
|
-
};
|
|
255
256
|
export const isExplicitMixMode = (context) => {
|
|
256
257
|
const audioParams = context.presentationStyle.audioParams;
|
|
257
258
|
const duckingRequested = audioParams.ducking !== undefined;
|
|
@@ -285,40 +286,27 @@ export const mixAudiosFromMovieBeats = (ffmpegContext, artifactAudioId, audioIds
|
|
|
285
286
|
}
|
|
286
287
|
return artifactAudioId;
|
|
287
288
|
};
|
|
288
|
-
export const getExtraPadding = (context, index) => {
|
|
289
|
-
// We need to consider only intro and outro padding because the other paddings were already added to the beat.duration
|
|
290
|
-
if (index === 0) {
|
|
291
|
-
return MulmoStudioContextMethods.getIntroPadding(context);
|
|
292
|
-
}
|
|
293
|
-
else if (index === context.studio.beats.length - 1) {
|
|
294
|
-
return context.presentationStyle.audioParams.outroPadding;
|
|
295
|
-
}
|
|
296
|
-
return 0;
|
|
297
|
-
};
|
|
298
|
-
export const getFillOption = (context, beat) => {
|
|
299
|
-
// Get fillOption from merged imageParams (global + beat-specific)
|
|
300
|
-
const globalFillOption = context.presentationStyle.movieParams?.fillOption;
|
|
301
|
-
const beatFillOption = beat.movieParams?.fillOption;
|
|
302
|
-
const defaultFillOption = mulmoFillOptionSchema.parse({}); // let the schema infer the default value
|
|
303
|
-
return { ...defaultFillOption, ...globalFillOption, ...beatFillOption };
|
|
304
|
-
};
|
|
305
289
|
export const getTransitionVideoId = (transition, videoIdsForBeats, index) => {
|
|
290
|
+
// Every transition needs the previous *rendered* beat (voice_over beats have no video of their own).
|
|
291
|
+
const prevVideoSourceId = getPrevVideoId(videoIdsForBeats, index);
|
|
292
|
+
const currentVideoId = videoIdsForBeats[index];
|
|
293
|
+
if (!prevVideoSourceId || !currentVideoId) {
|
|
294
|
+
return null; // Skip the transition instead of referencing a filtergraph pad which does not exist.
|
|
295
|
+
}
|
|
306
296
|
if (transition.type === "fade" || transition.type.startsWith("slideout_")) {
|
|
307
|
-
// Use previous beat's last frame.
|
|
308
|
-
const prevVideoSourceId = videoIdsForBeats[index - 1];
|
|
297
|
+
// Use previous beat's last frame.
|
|
309
298
|
// Both movie and image beats now have _last
|
|
310
299
|
const frameId = `${prevVideoSourceId}_last`;
|
|
311
300
|
return { videoId: frameId, nextVideoId: undefined, beatIndex: index };
|
|
312
301
|
}
|
|
313
302
|
if (transition.type.startsWith("wipe")) {
|
|
314
303
|
// Wipe needs both previous beat's last frame and this beat's first frame
|
|
315
|
-
const prevVideoSourceId = videoIdsForBeats[index - 1];
|
|
316
304
|
const prevLastFrame = `${prevVideoSourceId}_last`;
|
|
317
|
-
const nextFirstFrame = `${
|
|
305
|
+
const nextFirstFrame = `${currentVideoId}_first`;
|
|
318
306
|
return { videoId: prevLastFrame, nextVideoId: nextFirstFrame, beatIndex: index };
|
|
319
307
|
}
|
|
320
308
|
// Use this beat's first frame. slidein_ case
|
|
321
|
-
return { videoId: "", nextVideoId: `${
|
|
309
|
+
return { videoId: "", nextVideoId: `${currentVideoId}_first`, beatIndex: index };
|
|
322
310
|
};
|
|
323
311
|
export const getConcatVideoFilter = (concatVideoId, videoIdsForBeats) => {
|
|
324
312
|
const videoIds = videoIdsForBeats.filter((id) => id !== undefined); // filter out voice-over beats
|
|
@@ -336,14 +324,17 @@ export const getTransitionFrameDurations = (context, index) => {
|
|
|
336
324
|
const getTransitionDuration = (transition, prevBeatIndex, currentBeatIndex) => {
|
|
337
325
|
if (!transition || prevBeatIndex < 0 || currentBeatIndex >= beats.length)
|
|
338
326
|
return 0;
|
|
339
|
-
const prevBeatDuration =
|
|
340
|
-
const currentBeatDuration =
|
|
327
|
+
const prevBeatDuration = MulmoStudioContextMethods.getSegmentDuration(context, prevBeatIndex);
|
|
328
|
+
const currentBeatDuration = MulmoStudioContextMethods.getSegmentDuration(context, currentBeatIndex);
|
|
341
329
|
return getClampedTransitionDuration(transition.duration, prevBeatDuration, currentBeatDuration);
|
|
342
330
|
};
|
|
331
|
+
// Transitions are resolved against the neighboring *rendered* beats (voice_over beats have no video).
|
|
332
|
+
const prevIndex = MulmoBeatMethods.getPrevRenderedBeatIndex(scriptBeats, index);
|
|
333
|
+
const nextIndex = MulmoBeatMethods.getNextRenderedBeatIndex(scriptBeats, index);
|
|
343
334
|
const currentTransition = MulmoPresentationStyleMethods.getMovieTransition(context, scriptBeats[index]);
|
|
344
|
-
const firstDuration =
|
|
345
|
-
const nextTransition =
|
|
346
|
-
const lastDuration = getTransitionDuration(nextTransition, index,
|
|
335
|
+
const firstDuration = prevIndex >= 0 ? getTransitionDuration(currentTransition, prevIndex, index) : 0;
|
|
336
|
+
const nextTransition = nextIndex >= 0 ? MulmoPresentationStyleMethods.getMovieTransition(context, scriptBeats[nextIndex]) : null;
|
|
337
|
+
const lastDuration = nextIndex >= 0 ? getTransitionDuration(nextTransition, index, nextIndex) : 0;
|
|
347
338
|
return {
|
|
348
339
|
firstDuration: Math.max(firstDuration, minFrame),
|
|
349
340
|
lastDuration: Math.max(lastDuration, minFrame),
|
|
@@ -388,12 +379,26 @@ export const addSplitAndExtractFrames = (ffmpegContext, videoId, firstDuration,
|
|
|
388
379
|
const findMissingIndex = (context) => {
|
|
389
380
|
return context.studio.beats.findIndex((studioBeat, index) => {
|
|
390
381
|
const beat = context.studio.script.beats[index];
|
|
391
|
-
if (beat
|
|
382
|
+
if (MulmoBeatMethods.isVoiceOver(beat)) {
|
|
392
383
|
return false; // Voice-over does not have either imageFile or movieFile.
|
|
393
384
|
}
|
|
394
385
|
return !studioBeat.imageFile && !studioBeat.movieFile;
|
|
395
386
|
});
|
|
396
387
|
};
|
|
388
|
+
// Extract the stills the transitions around this beat consume, and return the transition this beat
|
|
389
|
+
// starts (null when it has none, or when there is no previous shot to transition from).
|
|
390
|
+
const addTransitionAssets = (ffmpegContext, context, index, videoIdsForBeats, { isMovie, canvasInfo, needFirst, needLast }) => {
|
|
391
|
+
const videoId = videoIdsForBeats[index];
|
|
392
|
+
if (needFirst || needLast) {
|
|
393
|
+
const { firstDuration, lastDuration } = getTransitionFrameDurations(context, index);
|
|
394
|
+
addSplitAndExtractFrames(ffmpegContext, videoId, firstDuration, lastDuration, isMovie, needFirst, needLast, canvasInfo);
|
|
395
|
+
}
|
|
396
|
+
const transition = MulmoPresentationStyleMethods.getMovieTransition(context, context.studio.script.beats[index]);
|
|
397
|
+
if (!transition || index === 0) {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
return getTransitionVideoId(transition, videoIdsForBeats, index);
|
|
401
|
+
};
|
|
397
402
|
export const createVideo = async (audioArtifactFilePath, outputVideoPath, context, isTest = false) => {
|
|
398
403
|
const caption = MulmoStudioContextMethods.getCaption(context);
|
|
399
404
|
const start = performance.now();
|
|
@@ -410,20 +415,22 @@ export const createVideo = async (audioArtifactFilePath, outputVideoPath, contex
|
|
|
410
415
|
const transitionVideoIds = [];
|
|
411
416
|
const beatTimestamps = [];
|
|
412
417
|
// Check which beats need _first (for slidein transition on this beat)
|
|
413
|
-
const needsFirstFrame = getNeedFirstFrame(context);
|
|
418
|
+
const needsFirstFrame = MulmoPresentationStyleMethods.getNeedFirstFrame(context);
|
|
414
419
|
// Check which beats need _last (for any transition on next beat - they all need previous beat's last frame)
|
|
415
|
-
const needsLastFrame = getNeedLastFrame(context);
|
|
420
|
+
const needsLastFrame = MulmoPresentationStyleMethods.getNeedLastFrame(context);
|
|
416
421
|
let cumulativeFrames = 0;
|
|
417
422
|
context.studio.beats.reduce((timestamp, studioBeat, index) => {
|
|
418
423
|
const beat = context.studio.script.beats[index];
|
|
419
|
-
if (beat
|
|
424
|
+
if (MulmoBeatMethods.isVoiceOver(beat)) {
|
|
420
425
|
videoIdsForBeats.push(undefined);
|
|
421
426
|
beatTimestamps.push(timestamp);
|
|
422
|
-
|
|
427
|
+
// Skip voice-over beats: their video is covered by the owner beat's segment, but the
|
|
428
|
+
// audio timeline still advances, so keep the timestamp in sync with the audio artifact.
|
|
429
|
+
return timestamp + MulmoStudioContextMethods.getBeatDuration(context, index);
|
|
423
430
|
}
|
|
424
431
|
const sourceFile = isTest ? "/test/dummy.mp4" : validateBeatSource(studioBeat, index);
|
|
425
|
-
|
|
426
|
-
const duration =
|
|
432
|
+
const voiceOverDuration = MulmoStudioContextMethods.getVoiceOverGroupDuration(context, index);
|
|
433
|
+
const duration = MulmoStudioContextMethods.getSegmentDuration(context, index);
|
|
427
434
|
// Use cumulative frame tracking to prevent audio-video drift from frame quantization.
|
|
428
435
|
// trim=duration=X rounds up to the next frame boundary (~0.03s per beat at 30fps),
|
|
429
436
|
// causing cumulative drift. Instead, compute exact frame counts per beat.
|
|
@@ -431,29 +438,23 @@ export const createVideo = async (audioArtifactFilePath, outputVideoPath, contex
|
|
|
431
438
|
const frameCount = targetEndFrame - cumulativeFrames;
|
|
432
439
|
cumulativeFrames = targetEndFrame;
|
|
433
440
|
const inputIndex = FfmpegContextAddInput(ffmpegContext, sourceFile);
|
|
434
|
-
const isMovie =
|
|
435
|
-
studioBeat.movieFile ||
|
|
436
|
-
MulmoPresentationStyleMethods.getImageType(context.presentationStyle, beat) === "movie");
|
|
441
|
+
const isMovie = MulmoStudioContextMethods.isMovieBeat(context, studioBeat, beat);
|
|
437
442
|
const speed = beat.movieParams?.speed ?? 1.0;
|
|
438
443
|
const filters = beat.movieParams?.filters;
|
|
439
|
-
const { videoId, videoPart } = getVideoPart(inputIndex, isMovie, duration, canvasInfo, getFillOption(context, beat), speed, filters, frameCount);
|
|
444
|
+
const { videoId, videoPart } = getVideoPart(inputIndex, isMovie, duration, canvasInfo, MulmoPresentationStyleMethods.getFillOption(context, beat), speed, filters, frameCount);
|
|
440
445
|
ffmpegContext.filterComplex.push(videoPart);
|
|
441
|
-
// for transition
|
|
442
|
-
const needFirst = needsFirstFrame[index]; // This beat has slidein
|
|
443
|
-
const needLast = needsLastFrame[index]; // Next beat has transition
|
|
444
446
|
videoIdsForBeats.push(videoId);
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if (
|
|
452
|
-
const transitionVideoId = getTransitionVideoId(transition, videoIdsForBeats, index);
|
|
447
|
+
const transitionVideoId = addTransitionAssets(ffmpegContext, context, index, videoIdsForBeats, {
|
|
448
|
+
isMovie,
|
|
449
|
+
canvasInfo,
|
|
450
|
+
needFirst: needsFirstFrame[index], // This beat has slidein
|
|
451
|
+
needLast: needsLastFrame[index], // Next beat has transition
|
|
452
|
+
});
|
|
453
|
+
if (transitionVideoId) {
|
|
453
454
|
transitionVideoIds.push(transitionVideoId);
|
|
454
455
|
}
|
|
455
456
|
// NOTE: We don't support audio if the speed is not 1.0.
|
|
456
|
-
const movieVolume =
|
|
457
|
+
const movieVolume = MulmoPresentationStyleMethods.getMovieVolume(context, beat);
|
|
457
458
|
if (studioBeat.hasMovieAudio && movieVolume > 0.0 && speed === 1.0) {
|
|
458
459
|
// TODO: Handle a special case where it has lipSyncFile AND hasMovieAudio is on (the source file has an audio, such as sound effect).
|
|
459
460
|
const { audioId, audioPart } = getAudioPart(inputIndex, duration, timestamp, movieVolume);
|
|
@@ -461,7 +462,8 @@ export const createVideo = async (audioArtifactFilePath, outputVideoPath, contex
|
|
|
461
462
|
ffmpegContext.filterComplex.push(audioPart);
|
|
462
463
|
}
|
|
463
464
|
beatTimestamps.push(timestamp);
|
|
464
|
-
|
|
465
|
+
// The trailing voice_over beats advance the timestamp themselves.
|
|
466
|
+
return timestamp + duration - voiceOverDuration;
|
|
465
467
|
}, 0);
|
|
466
468
|
assert(videoIdsForBeats.length === context.studio.beats.length, "videoIds.length !== studio.beats.length");
|
|
467
469
|
assert(beatTimestamps.length === context.studio.beats.length, "beatTimestamps.length !== studio.beats.length");
|
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";
|
|
@@ -8,6 +8,9 @@ export declare const MulmoBeatMethods: {
|
|
|
8
8
|
isAnimationObject: (animation: unknown) => animation is AnimationConfig;
|
|
9
9
|
isAnimatedHtmlTailwind: (beat: MulmoBeat) => boolean;
|
|
10
10
|
isMovieMode: (animation: unknown) => boolean;
|
|
11
|
+
isVoiceOver: (beat?: MulmoBeat) => boolean;
|
|
12
|
+
getPrevRenderedBeatIndex(beats: MulmoBeat[], index: number): number;
|
|
13
|
+
getNextRenderedBeatIndex(beats: MulmoBeat[], index: number): number;
|
|
11
14
|
getHtmlPrompt(beat: MulmoBeat): string | undefined;
|
|
12
15
|
getImageReferenceForImageGenerator(beat: MulmoBeat, imageRefs: Record<string, string>): string[];
|
|
13
16
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ImageMediaType } from "../types/index.js";
|
|
1
2
|
/** Type guard: checks if animation value is an object config like { fps: 30 } */
|
|
2
3
|
const isAnimationObject = (animation) => {
|
|
3
4
|
return typeof animation === "object" && animation !== null && !Array.isArray(animation);
|
|
@@ -17,11 +18,27 @@ const isAnimatedHtmlTailwind = (beat) => {
|
|
|
17
18
|
const animation = beat.image.animation;
|
|
18
19
|
return isAnimationEnabled(animation);
|
|
19
20
|
};
|
|
21
|
+
// voice_over beats share the preceding beat's shot, and contribute no video segment of their own.
|
|
22
|
+
const isVoiceOver = (beat) => beat?.image?.type === ImageMediaType.VoiceOver;
|
|
20
23
|
export const MulmoBeatMethods = {
|
|
21
24
|
isAnimationEnabled,
|
|
22
25
|
isAnimationObject,
|
|
23
26
|
isAnimatedHtmlTailwind,
|
|
24
27
|
isMovieMode,
|
|
28
|
+
isVoiceOver,
|
|
29
|
+
// Index of the closest preceding beat which is actually rendered as a video segment (-1 if none).
|
|
30
|
+
getPrevRenderedBeatIndex(beats, index) {
|
|
31
|
+
const offset = beats
|
|
32
|
+
.slice(0, Math.max(index, 0))
|
|
33
|
+
.reverse()
|
|
34
|
+
.findIndex((beat) => !isVoiceOver(beat));
|
|
35
|
+
return offset < 0 ? -1 : index - 1 - offset;
|
|
36
|
+
},
|
|
37
|
+
// Index of the closest following beat which is actually rendered as a video segment (-1 if none).
|
|
38
|
+
getNextRenderedBeatIndex(beats, index) {
|
|
39
|
+
const offset = beats.slice(index + 1).findIndex((beat) => !isVoiceOver(beat));
|
|
40
|
+
return offset < 0 ? -1 : index + 1 + offset;
|
|
41
|
+
},
|
|
25
42
|
getHtmlPrompt(beat) {
|
|
26
43
|
if (beat?.htmlPrompt?.data) {
|
|
27
44
|
return beat.htmlPrompt.prompt + "\n\n[data]\n" + JSON.stringify(beat.htmlPrompt.data, null, 2);
|
|
@@ -17,6 +17,9 @@ export declare const MulmoBeatMethods: {
|
|
|
17
17
|
};
|
|
18
18
|
isAnimatedHtmlTailwind: (beat: MulmoBeat) => boolean;
|
|
19
19
|
isMovieMode: (animation: unknown) => boolean;
|
|
20
|
+
isVoiceOver: (beat?: MulmoBeat) => boolean;
|
|
21
|
+
getPrevRenderedBeatIndex(beats: MulmoBeat[], index: number): number;
|
|
22
|
+
getNextRenderedBeatIndex(beats: MulmoBeat[], index: number): number;
|
|
20
23
|
getHtmlPrompt(beat: MulmoBeat): string | undefined;
|
|
21
24
|
getImageReferenceForImageGenerator(beat: MulmoBeat, imageRefs: Record<string, string>): string[];
|
|
22
25
|
};
|
|
@@ -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
|
+
};
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Works in both Node.js and modern browsers.
|
|
5
5
|
*/
|
|
6
6
|
import type { SlideTheme } from "@mulmocast/deck";
|
|
7
|
-
import { MulmoCanvasDimension, MulmoBeat, Text2SpeechProvider, Text2ImageAgentInfo, Text2HtmlAgentInfo, BeatMediaType, MulmoPresentationStyle, SpeakerData, Text2ImageProvider, MulmoStudioContext, MulmoTransition } from "../types/index.js";
|
|
7
|
+
import { MulmoCanvasDimension, MulmoBeat, Text2SpeechProvider, Text2ImageAgentInfo, Text2HtmlAgentInfo, BeatMediaType, MulmoPresentationStyle, SpeakerData, Text2ImageProvider, MulmoStudioContext, MulmoTransition, MulmoFillOption } from "../types/index.js";
|
|
8
8
|
export declare const MulmoPresentationStyleMethods: {
|
|
9
9
|
getCanvasSize(presentationStyle: MulmoPresentationStyle): MulmoCanvasDimension;
|
|
10
10
|
getAllSpeechProviders(presentationStyle: MulmoPresentationStyle): Set<Text2SpeechProvider>;
|
|
@@ -30,6 +30,10 @@ export declare const MulmoPresentationStyleMethods: {
|
|
|
30
30
|
getSpeakerData(presentationStyle: MulmoPresentationStyle, beat: MulmoBeat, lang: string | undefined): SpeakerData;
|
|
31
31
|
getSpeaker(context: MulmoStudioContext, beat: MulmoBeat, targetLang: string | undefined): SpeakerData;
|
|
32
32
|
getMovieTransition(context: MulmoStudioContext, beat: MulmoBeat): MulmoTransition | null;
|
|
33
|
+
getNeedFirstFrame(context: MulmoStudioContext): boolean[];
|
|
34
|
+
getNeedLastFrame(context: MulmoStudioContext): boolean[];
|
|
35
|
+
getFillOption(context: MulmoStudioContext, beat: MulmoBeat): MulmoFillOption;
|
|
36
|
+
getMovieVolume(context: MulmoStudioContext, beat: MulmoBeat): number;
|
|
33
37
|
getText2ImageProvider(provider: Text2ImageProvider | undefined): Text2ImageProvider;
|
|
34
38
|
getImageAgentInfo(presentationStyle: MulmoPresentationStyle, beat?: MulmoBeat): Text2ImageAgentInfo;
|
|
35
39
|
getMovieAgentInfo(presentationStyle: MulmoPresentationStyle, beat?: MulmoBeat): {
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import { isNull } from "graphai";
|
|
7
7
|
import { userAssert } from "../utils/utils.js";
|
|
8
8
|
import { slideThemes } from "../data/slideThemes.js";
|
|
9
|
-
import { text2ImageProviderSchema, text2HtmlImageProviderSchema, text2MovieProviderSchema, text2SpeechProviderSchema, mulmoCanvasDimensionSchema, mulmoTransitionSchema, } from "../types/schema.js";
|
|
9
|
+
import { text2ImageProviderSchema, text2HtmlImageProviderSchema, text2MovieProviderSchema, text2SpeechProviderSchema, mulmoCanvasDimensionSchema, mulmoTransitionSchema, mulmoFillOptionSchema, } from "../types/schema.js";
|
|
10
|
+
import { MulmoBeatMethods } from "./mulmo_beat.js";
|
|
10
11
|
import { provider2ImageAgent, provider2MovieAgent, provider2LLMAgent, provider2TTSAgent, provider2SoundEffectAgent, provider2LipSyncAgent, defaultProviders, getModelAudio, AUDIO_MODE_ALWAYS, } from "../types/provider2agent.js";
|
|
11
12
|
const defaultTextSlideStyles = [
|
|
12
13
|
'*,*::before,*::after{box-sizing:border-box}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin:0}ul[role="list"],ol[role="list"]{list-style:none}html:focus-within{scroll-behavior:smooth}body{min-height:100vh;text-rendering:optimizeSpeed;line-height:1.5}a:not([class]){text-decoration-skip-ink:auto}img,picture{max-width:100%;display:block}input,button,textarea,select{font:inherit}@media(prefers-reduced-motion:reduce){html:focus-within{scroll-behavior:auto}*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important;scroll-behavior:auto !important}}',
|
|
@@ -22,6 +23,13 @@ const defaultTextSlideStyles = [
|
|
|
22
23
|
"td, th { padding: 8px }",
|
|
23
24
|
"tr:nth-child(even) { background-color: #eee }",
|
|
24
25
|
];
|
|
26
|
+
const DEFAULT_DUCKING_RATIO = 0.3;
|
|
27
|
+
// Both frame requirements answer the same question for each *rendered* beat (voice_over beats have
|
|
28
|
+
// no video segment, so they never supply a frame): does a transition need a still from this beat?
|
|
29
|
+
const mapRenderedBeats = (context, needsFrame) => {
|
|
30
|
+
const beats = context.studio.script.beats;
|
|
31
|
+
return beats.map((beat, index) => (MulmoBeatMethods.isVoiceOver(beat) ? false : needsFrame(beats, index)));
|
|
32
|
+
};
|
|
25
33
|
export const MulmoPresentationStyleMethods = {
|
|
26
34
|
getCanvasSize(presentationStyle) {
|
|
27
35
|
return mulmoCanvasDimensionSchema.parse(presentationStyle.canvasSize);
|
|
@@ -95,6 +103,40 @@ export const MulmoPresentationStyleMethods = {
|
|
|
95
103
|
return null;
|
|
96
104
|
return mulmoTransitionSchema.parse(transitionData);
|
|
97
105
|
},
|
|
106
|
+
getNeedFirstFrame(context) {
|
|
107
|
+
// This beat slides/wipes in over the previous shot, so it supplies its own first frame.
|
|
108
|
+
return mapRenderedBeats(context, (beats, index) => {
|
|
109
|
+
if (MulmoBeatMethods.getPrevRenderedBeatIndex(beats, index) < 0)
|
|
110
|
+
return false; // No previous shot: the transition is skipped
|
|
111
|
+
const transition = MulmoPresentationStyleMethods.getMovieTransition(context, beats[index]);
|
|
112
|
+
return (transition?.type.startsWith("slidein_") || transition?.type.startsWith("wipe")) ?? false;
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
getNeedLastFrame(context) {
|
|
116
|
+
// Any transition consumes the last frame of the previous rendered beat, skipping voice_over beats.
|
|
117
|
+
return mapRenderedBeats(context, (beats, index) => {
|
|
118
|
+
const nextIndex = MulmoBeatMethods.getNextRenderedBeatIndex(beats, index);
|
|
119
|
+
if (nextIndex < 0)
|
|
120
|
+
return false; // Last rendered beat doesn't need _last
|
|
121
|
+
return MulmoPresentationStyleMethods.getMovieTransition(context, beats[nextIndex]) !== null;
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
getFillOption(context, beat) {
|
|
125
|
+
const globalFillOption = context.presentationStyle.movieParams?.fillOption;
|
|
126
|
+
const beatFillOption = beat.movieParams?.fillOption;
|
|
127
|
+
const defaultFillOption = mulmoFillOptionSchema.parse({}); // let the schema infer the default value
|
|
128
|
+
return { ...defaultFillOption, ...globalFillOption, ...beatFillOption };
|
|
129
|
+
},
|
|
130
|
+
getMovieVolume(context, beat) {
|
|
131
|
+
const baseMovieVolume = beat.audioParams?.movieVolume ?? context.presentationStyle.audioParams.movieVolume ?? 1.0;
|
|
132
|
+
const ducking = context.presentationStyle.audioParams.ducking;
|
|
133
|
+
const hasSpeech = !!beat.text && !context.presentationStyle.audioParams.suppressSpeech;
|
|
134
|
+
if (ducking && hasSpeech) {
|
|
135
|
+
const ratio = ducking.ratio ?? DEFAULT_DUCKING_RATIO;
|
|
136
|
+
return baseMovieVolume * ratio;
|
|
137
|
+
}
|
|
138
|
+
return baseMovieVolume;
|
|
139
|
+
},
|
|
98
140
|
/* NOTE: This method is not used.
|
|
99
141
|
getTTSModel(context: MulmoStudioContext, beat: MulmoBeat): string | undefined {
|
|
100
142
|
const speaker = MulmoPresentationStyleMethods.getSpeaker(context, beat);
|