mulmocast 2.9.0 → 2.9.2
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/movie.d.ts +2 -9
- package/lib/actions/movie.js +149 -147
- package/lib/agents/image_google_agent.d.ts +5 -0
- package/lib/agents/image_google_agent.js +86 -0
- package/lib/agents/movie_google_agent.d.ts +9 -0
- package/lib/agents/movie_google_agent.js +122 -0
- package/lib/agents/mulmo_prompts_agent.d.ts +7 -0
- package/lib/agents/mulmo_prompts_agent.js +37 -0
- package/lib/agents/prompts_data.d.ts +15 -0
- package/lib/agents/prompts_data.js +16 -0
- package/lib/agents/tts_nijivoice_agent.d.ts +5 -0
- package/lib/agents/tts_nijivoice_agent.js +69 -0
- package/lib/agents/validate_mulmo_script_agent.d.ts +17 -0
- package/lib/agents/validate_mulmo_script_agent.js +34 -0
- package/lib/cli/args.d.ts +15 -0
- package/lib/cli/args.js +62 -0
- package/lib/cli/bin.js +0 -0
- package/lib/cli/cli.d.ts +17 -0
- package/lib/cli/cli.js +117 -0
- package/lib/cli/run.d.ts +2 -0
- package/lib/cli/run.js +3 -0
- package/lib/cli/tool-args.d.ts +16 -0
- package/lib/cli/tool-args.js +64 -0
- package/lib/cli/tool-cli.d.ts +2 -0
- package/lib/cli/tool-cli.js +32 -0
- package/lib/cli/tool_commands/prompt/builder.d.ts +4 -0
- package/lib/cli/tool_commands/prompt/builder.js +11 -0
- package/lib/cli/tool_commands/prompt/handler.d.ts +4 -0
- package/lib/cli/tool_commands/prompt/handler.js +14 -0
- package/lib/cli/tool_commands/prompt/index.d.ts +4 -0
- package/lib/cli/tool_commands/prompt/index.js +4 -0
- package/lib/cli/tool_commands/schema/builder.d.ts +2 -0
- package/lib/cli/tool_commands/schema/builder.js +3 -0
- package/lib/cli/tool_commands/schema/handler.d.ts +2 -0
- package/lib/cli/tool_commands/schema/handler.js +12 -0
- package/lib/cli/tool_commands/schema/index.d.ts +4 -0
- package/lib/cli/tool_commands/schema/index.js +4 -0
- package/lib/cli/tool_commands/scripting/builder.d.ts +20 -0
- package/lib/cli/tool_commands/scripting/builder.js +63 -0
- package/lib/cli/tool_commands/scripting/handler.d.ts +12 -0
- package/lib/cli/tool_commands/scripting/handler.js +36 -0
- package/lib/cli/tool_commands/scripting/index.d.ts +4 -0
- package/lib/cli/tool_commands/scripting/index.js +4 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +8 -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_presentation_style.d.ts +5 -1
- package/lib/methods/mulmo_presentation_style.js +43 -1
- package/lib/methods/mulmo_script_template.d.ts +4 -0
- package/lib/methods/mulmo_script_template.js +6 -0
- package/lib/methods/mulmo_studio.d.ts +8 -0
- package/lib/methods/mulmo_studio.js +25 -0
- package/lib/methods/mulmo_studio_context.d.ts +6 -1
- package/lib/methods/mulmo_studio_context.js +36 -1
- package/lib/slide/blocks.d.ts +13 -0
- package/lib/slide/blocks.js +251 -0
- package/lib/slide/index.d.ts +6 -0
- package/lib/slide/index.js +7 -0
- package/lib/slide/layouts/big_quote.d.ts +2 -0
- package/lib/slide/layouts/big_quote.js +19 -0
- package/lib/slide/layouts/columns.d.ts +2 -0
- package/lib/slide/layouts/columns.js +53 -0
- package/lib/slide/layouts/comparison.d.ts +2 -0
- package/lib/slide/layouts/comparison.js +25 -0
- package/lib/slide/layouts/funnel.d.ts +2 -0
- package/lib/slide/layouts/funnel.js +25 -0
- package/lib/slide/layouts/grid.d.ts +2 -0
- package/lib/slide/layouts/grid.js +38 -0
- package/lib/slide/layouts/index.d.ts +3 -0
- package/lib/slide/layouts/index.js +46 -0
- package/lib/slide/layouts/matrix.d.ts +2 -0
- package/lib/slide/layouts/matrix.js +53 -0
- package/lib/slide/layouts/split.d.ts +2 -0
- package/lib/slide/layouts/split.js +51 -0
- package/lib/slide/layouts/stats.d.ts +2 -0
- package/lib/slide/layouts/stats.js +23 -0
- package/lib/slide/layouts/table.d.ts +2 -0
- package/lib/slide/layouts/table.js +10 -0
- package/lib/slide/layouts/timeline.d.ts +2 -0
- package/lib/slide/layouts/timeline.js +27 -0
- package/lib/slide/layouts/title.d.ts +2 -0
- package/lib/slide/layouts/title.js +19 -0
- package/lib/slide/layouts/waterfall.d.ts +2 -0
- package/lib/slide/layouts/waterfall.js +63 -0
- package/lib/slide/render.d.ts +17 -0
- package/lib/slide/render.js +101 -0
- package/lib/slide/schema.d.ts +9309 -0
- package/lib/slide/schema.js +434 -0
- package/lib/slide/utils.d.ts +76 -0
- package/lib/slide/utils.js +243 -0
- package/lib/tools/prompt.d.ts +1 -0
- package/lib/tools/prompt.js +18 -0
- package/lib/tools/seed.d.ts +3 -0
- package/lib/tools/seed.js +211 -0
- package/lib/tools/seed_from_url.d.ts +3 -0
- package/lib/tools/seed_from_url.js +188 -0
- package/lib/types/slide.d.ts +9309 -0
- package/lib/types/slide.js +434 -0
- package/lib/utils/assets.d.ts +18 -0
- package/lib/utils/assets.js +101 -0
- package/lib/utils/cli.d.ts +1 -0
- package/lib/utils/cli.js +15 -0
- package/lib/utils/const.d.ts +12 -0
- package/lib/utils/const.js +12 -0
- package/lib/utils/filters.js +11 -2
- package/lib/utils/image_preprocess.d.ts +14 -0
- package/lib/utils/image_preprocess.js +52 -0
- package/lib/utils/markdown.d.ts +3 -0
- package/lib/utils/markdown.js +42 -0
- package/lib/utils/pdf.d.ts +9 -0
- package/lib/utils/pdf.js +77 -0
- package/lib/utils/preprocess.d.ts +343 -0
- package/lib/utils/preprocess.js +58 -0
- package/lib/utils/provider2agent.d.ts +141 -0
- package/lib/utils/provider2agent.js +267 -0
- package/lib/utils/text_hash.d.ts +1 -0
- package/lib/utils/text_hash.js +4 -0
- package/package.json +8 -8
- package/scripts/test/test_voice_over_transition_no_audio.json +71 -0
- package/lib/utils/sdk_timeout.d.ts +0 -3
- package/lib/utils/sdk_timeout.js +0 -7
- package/scripts/test/test_captions_2.json~ +0 -278
- package/scripts/test/test_en_portrait.json~ +0 -31
- package/scripts/test/test_slide_image_ref_gemini_en.json~ +0 -289
- package/scripts/test/test_vertexai.json~ +0 -21
package/lib/actions/movie.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MulmoStudioContext,
|
|
1
|
+
import { MulmoStudioContext, MulmoTransition, MulmoCanvasDimension, MulmoFillOption, MulmoVideoFilter } from "../types/index.js";
|
|
2
2
|
import { FfmpegContext } from "../utils/ffmpeg_utils.js";
|
|
3
3
|
type VideoId = string | undefined;
|
|
4
4
|
export declare const getVideoPart: (inputIndex: number, isMovie: boolean, duration: number, canvasInfo: MulmoCanvasDimension, fillOption: MulmoFillOption, speed: number, filters?: MulmoVideoFilter[], frameCount?: number) => {
|
|
@@ -11,15 +11,8 @@ export declare const getAudioPart: (inputIndex: number, duration: number, delay:
|
|
|
11
11
|
};
|
|
12
12
|
export declare const getOutOverlayCoords: (transitionType: string, d: number, t: number) => string;
|
|
13
13
|
export declare const getInOverlayCoords: (transitionType: string, d: number, t: number) => string;
|
|
14
|
-
export declare const getNeedFirstFrame: (context: MulmoStudioContext) => boolean[];
|
|
15
|
-
export declare const getNeedLastFrame: (context: MulmoStudioContext) => boolean[];
|
|
16
|
-
export declare const resolveMovieVolume: (beat: MulmoBeat, context: MulmoStudioContext) => number;
|
|
17
14
|
export declare const isExplicitMixMode: (context: MulmoStudioContext) => boolean;
|
|
18
15
|
export declare const mixAudiosFromMovieBeats: (ffmpegContext: FfmpegContext, artifactAudioId: string, audioIdsFromMovieBeats: string[], context: MulmoStudioContext) => string;
|
|
19
|
-
export declare const getExtraPadding: (context: MulmoStudioContext, index: number) => number;
|
|
20
|
-
export declare const getFillOption: (context: MulmoStudioContext, beat: MulmoBeat) => {
|
|
21
|
-
style: "aspectFit" | "aspectFill";
|
|
22
|
-
};
|
|
23
16
|
export declare const getTransitionVideoId: (transition: MulmoTransition, videoIdsForBeats: VideoId[], index: number) => {
|
|
24
17
|
videoId: string;
|
|
25
18
|
nextVideoId: undefined;
|
|
@@ -28,7 +21,7 @@ export declare const getTransitionVideoId: (transition: MulmoTransition, videoId
|
|
|
28
21
|
videoId: string;
|
|
29
22
|
nextVideoId: string;
|
|
30
23
|
beatIndex: number;
|
|
31
|
-
};
|
|
24
|
+
} | null;
|
|
32
25
|
export declare const getConcatVideoFilter: (concatVideoId: string, videoIdsForBeats: VideoId[]) => string;
|
|
33
26
|
export declare const getTransitionFrameDurations: (context: MulmoStudioContext, index: number) => {
|
|
34
27
|
firstDuration: number;
|
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");
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AgentFunction, AgentFunctionInfo } from "graphai";
|
|
2
|
+
import type { AgentBufferResult, ImageAgentInputs, ImageAgentParams, GoogleImageAgentConfig } from "../types/agent.js";
|
|
3
|
+
export declare const imageGoogleAgent: AgentFunction<ImageAgentParams, AgentBufferResult, ImageAgentInputs, GoogleImageAgentConfig>;
|
|
4
|
+
declare const imageGoogleAgentInfo: AgentFunctionInfo;
|
|
5
|
+
export default imageGoogleAgentInfo;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { GraphAILogger } from "graphai";
|
|
2
|
+
import { getAspectRatio } from "./movie_google_agent.js";
|
|
3
|
+
import { provider2ImageAgent } from "../utils/provider2agent.js";
|
|
4
|
+
async function generateImage(projectId, model, token, prompt, aspectRatio) {
|
|
5
|
+
const GOOGLE_IMAGEN_ENDPOINT = `https://us-central1-aiplatform.googleapis.com/v1/projects/${projectId}/locations/us-central1/publishers/google/models/${model}:predict`;
|
|
6
|
+
try {
|
|
7
|
+
// Prepare the payload for the API request
|
|
8
|
+
const payload = {
|
|
9
|
+
instances: [
|
|
10
|
+
{
|
|
11
|
+
prompt,
|
|
12
|
+
},
|
|
13
|
+
],
|
|
14
|
+
parameters: {
|
|
15
|
+
sampleCount: 1,
|
|
16
|
+
aspectRatio,
|
|
17
|
+
safetySetting: "block_only_high",
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
// Make the API call using fetch
|
|
21
|
+
const response = await fetch(GOOGLE_IMAGEN_ENDPOINT, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: {
|
|
24
|
+
Authorization: `Bearer ${token}`,
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify(payload),
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
throw new Error(`Error: ${response.status} - ${response.statusText}`);
|
|
31
|
+
}
|
|
32
|
+
const responseData = await response.json();
|
|
33
|
+
// Parse and return the generated image URL or data
|
|
34
|
+
const predictions = responseData.predictions;
|
|
35
|
+
if (predictions && predictions.length > 0) {
|
|
36
|
+
const base64Image = predictions[0].bytesBase64Encoded;
|
|
37
|
+
if (base64Image) {
|
|
38
|
+
return Buffer.from(base64Image, "base64"); // Decode the base64 image to a buffer
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
throw new Error("No base64-encoded image data returned from the API.");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
// console.log(response);
|
|
46
|
+
GraphAILogger.info("No predictions returned from the API.", responseData, prompt);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
GraphAILogger.info("Error generating image:", error);
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export const imageGoogleAgent = async ({ namedInputs, params, config, }) => {
|
|
56
|
+
const { prompt } = namedInputs;
|
|
57
|
+
const aspectRatio = getAspectRatio(params.canvasSize);
|
|
58
|
+
const model = params.model ?? provider2ImageAgent["google"].defaultModel;
|
|
59
|
+
const projectId = config?.projectId;
|
|
60
|
+
const token = config?.token;
|
|
61
|
+
try {
|
|
62
|
+
const buffer = await generateImage(projectId, model, token, prompt, aspectRatio);
|
|
63
|
+
if (buffer) {
|
|
64
|
+
return { buffer };
|
|
65
|
+
}
|
|
66
|
+
throw new Error("ERROR: geneateImage returned undefined");
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
GraphAILogger.info("Failed to generate image:", error);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const imageGoogleAgentInfo = {
|
|
74
|
+
name: "imageGoogleAgent",
|
|
75
|
+
agent: imageGoogleAgent,
|
|
76
|
+
mock: imageGoogleAgent,
|
|
77
|
+
samples: [],
|
|
78
|
+
description: "Google Image agent",
|
|
79
|
+
category: ["image"],
|
|
80
|
+
author: "Receptron Team",
|
|
81
|
+
repository: "https://github.com/receptron/mulmocast-cli/",
|
|
82
|
+
// source: "https://github.com/receptron/mulmocast-cli/blob/main/src/agents/image_google_agent.ts",
|
|
83
|
+
license: "MIT",
|
|
84
|
+
environmentVariables: [],
|
|
85
|
+
};
|
|
86
|
+
export default imageGoogleAgentInfo;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AgentFunction, AgentFunctionInfo } from "graphai";
|
|
2
|
+
import type { AgentBufferResult, GoogleImageAgentConfig, GoogleMovieAgentParams, MovieAgentInputs } from "../types/agent.js";
|
|
3
|
+
export declare const getAspectRatio: (canvasSize: {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}) => string;
|
|
7
|
+
export declare const movieGoogleAgent: AgentFunction<GoogleMovieAgentParams, AgentBufferResult, MovieAgentInputs, GoogleImageAgentConfig>;
|
|
8
|
+
declare const movieGoogleAgentInfo: AgentFunctionInfo;
|
|
9
|
+
export default movieGoogleAgentInfo;
|