mulmocast 2.9.0 → 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.
@@ -1,4 +1,4 @@
1
- import { MulmoStudioContext, MulmoBeat, MulmoTransition, MulmoCanvasDimension, MulmoFillOption, MulmoVideoFilter } from "../types/index.js";
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;
@@ -1,6 +1,5 @@
1
1
  import { GraphAILogger, assert } from "graphai";
2
- import { mulmoFillOptionSchema, } from "../types/index.js";
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
- const DEFAULT_DUCKING_RATIO = 0.3;
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
- const addTransitionEffects = (ffmpegContext, captionedVideoId, context, transitionVideoIds, beatTimestamps, videoIdsForBeats) => {
153
- if (transitionVideoIds.length === 0) {
154
- return captionedVideoId;
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 prevBeatDuration = context.studio.beats[beatIndex - 1].duration ?? 1;
166
- const currentBeatDuration = context.studio.beats[beatIndex].duration ?? 1;
167
- const duration = getClampedTransitionDuration(transition.duration, prevBeatDuration, currentBeatDuration);
168
- const outputVideoId = `trans_${beatIndex}_o`;
169
- const processedVideoId = `${transitionVideoId}_f`;
170
- if (transition.type === "fade") {
171
- // Fade out the previous beat's last frame
172
- ffmpegContext.filterComplex.push(`[${transitionVideoId}]format=yuva420p,fade=t=out:d=${duration}:alpha=1,setpts=PTS-STARTPTS+${startAt}/TB[${processedVideoId}]`);
173
- ffmpegContext.filterComplex.push(`[${prevVideoId}][${processedVideoId}]overlay=enable='between(t,${startAt},${startAt + duration})'[${outputVideoId}]`);
174
- }
175
- else if (transition.type.startsWith("slideout_")) {
176
- // Slideout: previous beat's last frame slides out
177
- ffmpegContext.filterComplex.push(`[${transitionVideoId}]format=yuva420p,setpts=PTS-STARTPTS+${startAt}/TB[${processedVideoId}]`);
178
- ffmpegContext.filterComplex.push(`[${prevVideoId}][${processedVideoId}]overlay=${getOutOverlayCoords(transition.type, duration, startAt)}:enable='between(t,${startAt},${startAt + duration})'[${outputVideoId}]`);
179
- }
180
- else if (transition.type.startsWith("slidein_")) {
181
- // Slidein: this beat's first frame slides in over the previous beat's last frame
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. TODO: support voice-over
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 = `${videoIdsForBeats[index]}_first`;
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: `${videoIdsForBeats[index]}_first`, beatIndex: index };
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 = beats[prevBeatIndex].duration ?? 1;
340
- const currentBeatDuration = beats[currentBeatIndex].duration ?? 1;
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 = index > 0 ? getTransitionDuration(currentTransition, index - 1, index) : 0;
345
- const nextTransition = index < scriptBeats.length - 1 ? MulmoPresentationStyleMethods.getMovieTransition(context, scriptBeats[index + 1]) : null;
346
- const lastDuration = getTransitionDuration(nextTransition, index, index + 1);
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.image?.type === "voice_over") {
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.image?.type === "voice_over") {
424
+ if (MulmoBeatMethods.isVoiceOver(beat)) {
420
425
  videoIdsForBeats.push(undefined);
421
426
  beatTimestamps.push(timestamp);
422
- return timestamp; // Skip voice-over beats.
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
- // The movie duration is bigger in case of voice-over.
426
- const duration = Math.max(studioBeat.duration + getExtraPadding(context, index), studioBeat.movieDuration ?? 0);
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 = !!(studioBeat.lipSyncFile ||
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
- if (needFirst || needLast) {
446
- const { firstDuration, lastDuration } = getTransitionFrameDurations(context, index);
447
- addSplitAndExtractFrames(ffmpegContext, videoId, firstDuration, lastDuration, isMovie, needFirst, needLast, canvasInfo);
448
- }
449
- // Record transition info if this beat has a transition
450
- const transition = MulmoPresentationStyleMethods.getMovieTransition(context, beat);
451
- if (transition && index > 0) {
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 = resolveMovieVolume(beat, context);
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
- return timestamp + duration;
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");
@@ -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
  };
@@ -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);
@@ -3,7 +3,7 @@
3
3
  * (No Node.js built-ins like fs, path, dotenv, etc.)
4
4
  * Works in both Node.js and modern browsers.
5
5
  */
6
- import { BeatSessionType, MulmoStudioContext, SessionProgressCallback, SessionType, MulmoBeat, SpeechOptions } from "../types/index.js";
6
+ import { BeatSessionType, MulmoStudioContext, SessionProgressCallback, SessionType, MulmoBeat, MulmoStudioBeat, SpeechOptions } from "../types/index.js";
7
7
  import { provider2TTSAgent } from "../types/provider2agent.js";
8
8
  export declare const addSessionProgressCallback: (cb: SessionProgressCallback) => void;
9
9
  export declare const removeSessionProgressCallback: (cb: SessionProgressCallback) => void;
@@ -18,6 +18,11 @@ export declare const MulmoStudioContextMethods: {
18
18
  setBeatSessionState(context: MulmoStudioContext, sessionType: BeatSessionType | undefined, index: number, id: string | undefined, value: boolean): void;
19
19
  needTranslate(context: MulmoStudioContext, includeCaption?: boolean): boolean | "" | undefined;
20
20
  getIntroPadding(context: MulmoStudioContext): number;
21
+ getExtraPadding(context: MulmoStudioContext, index: number): number;
22
+ getBeatDuration(context: MulmoStudioContext, index: number): number;
23
+ getVoiceOverGroupDuration(context: MulmoStudioContext, index: number): number;
24
+ isMovieBeat(context: MulmoStudioContext, studioBeat: MulmoStudioBeat, beat: MulmoBeat): boolean;
25
+ getSegmentDuration(context: MulmoStudioContext, index: number): number;
21
26
  getAudioParam(context: MulmoStudioContext, beat: MulmoBeat, lang?: string): {
22
27
  provider: keyof typeof provider2TTSAgent;
23
28
  voiceId: string;
@@ -3,10 +3,11 @@
3
3
  * (No Node.js built-ins like fs, path, dotenv, etc.)
4
4
  * Works in both Node.js and modern browsers.
5
5
  */
6
- import { text2SpeechProviderSchema, } from "../types/index.js";
6
+ import { ImageMediaType, text2SpeechProviderSchema, } from "../types/index.js";
7
7
  import { beatId } from "../utils/utils.js";
8
8
  import { GraphAILogger } from "graphai";
9
9
  import { MulmoPresentationStyleMethods } from "./mulmo_presentation_style.js";
10
+ import { MulmoBeatMethods } from "./mulmo_beat.js";
10
11
  const sessionProgressCallbacks = new Set();
11
12
  export const addSessionProgressCallback = (cb) => {
12
13
  sessionProgressCallbacks.add(cb);
@@ -92,6 +93,40 @@ export const MulmoStudioContextMethods = {
92
93
  }
93
94
  return context.presentationStyle.audioParams.introPadding;
94
95
  },
96
+ // Intro/outro padding is the only padding not already folded into studio.beats[].duration.
97
+ getExtraPadding(context, index) {
98
+ if (index === 0) {
99
+ return MulmoStudioContextMethods.getIntroPadding(context);
100
+ }
101
+ else if (index === context.studio.beats.length - 1) {
102
+ return context.presentationStyle.audioParams.outroPadding;
103
+ }
104
+ return 0;
105
+ },
106
+ // The duration this beat occupies on the audio timeline.
107
+ getBeatDuration(context, index) {
108
+ return (context.studio.beats[index]?.duration ?? 0) + MulmoStudioContextMethods.getExtraPadding(context, index);
109
+ },
110
+ // The total duration of the voice_over beats which follow (and share the shot of) this beat.
111
+ getVoiceOverGroupDuration(context, index) {
112
+ const trailingBeats = context.studio.script.beats.slice(index + 1);
113
+ const groupEnd = trailingBeats.findIndex((beat) => !MulmoBeatMethods.isVoiceOver(beat));
114
+ const groupSize = groupEnd < 0 ? trailingBeats.length : groupEnd;
115
+ return trailingBeats.slice(0, groupSize).reduce((total, _, offset) => total + MulmoStudioContextMethods.getBeatDuration(context, index + 1 + offset), 0);
116
+ },
117
+ // Whether this beat's source is a movie (as opposed to a still which has to be looped).
118
+ isMovieBeat(context, studioBeat, beat) {
119
+ return !!(studioBeat.lipSyncFile ||
120
+ studioBeat.movieFile ||
121
+ MulmoPresentationStyleMethods.getImageType(context.presentationStyle, beat) === ImageMediaType.Movie);
122
+ },
123
+ // How long this beat's video segment is: its own duration plus the trailing voice_over beats which
124
+ // share its shot (they have no video segment of their own), never shorter than its movie. This is
125
+ // the single rule for a segment's length -- createVideo builds from it and transitions clamp against it.
126
+ getSegmentDuration(context, index) {
127
+ const ownDuration = MulmoStudioContextMethods.getBeatDuration(context, index) + MulmoStudioContextMethods.getVoiceOverGroupDuration(context, index);
128
+ return Math.max(ownDuration, context.studio.beats[index]?.movieDuration ?? 0);
129
+ },
95
130
  getAudioParam(context, beat, lang) {
96
131
  const speaker = MulmoPresentationStyleMethods.getSpeaker(context, beat, lang);
97
132
  const speechOptions = { ...speaker.speechOptions, ...beat.speechOptions };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmocast",
3
- "version": "2.9.0",
3
+ "version": "2.9.1",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "lib/index.node.js",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "resolutions": {
27
27
  "minimatch": "^10.2.5",
28
- "tar": "7.5.20",
28
+ "tar": "7.5.22",
29
29
  "yauzl": "^3.4.0"
30
30
  },
31
31
  "bin": {
@@ -89,7 +89,7 @@
89
89
  "homepage": "https://github.com/receptron/mulmocast-cli#readme",
90
90
  "dependencies": {
91
91
  "@google-cloud/text-to-speech": "^6.4.1",
92
- "@google/genai": "^2.12.0",
92
+ "@google/genai": "^2.13.0",
93
93
  "@graphai/anthropic_agent": "^2.0.12",
94
94
  "@graphai/browserless_agent": "^2.0.3",
95
95
  "@graphai/gemini_agent": "^2.0.5",
@@ -107,12 +107,12 @@
107
107
  "@mulmocast/deck": "^1.1.0",
108
108
  "@tavily/core": "^0.7.6",
109
109
  "archiver": "^8.0.0",
110
- "clipboardy": "^5.3.1",
110
+ "clipboardy": "^5.3.2",
111
111
  "dotenv": "^17.4.2",
112
112
  "graphai": "^2.0.18",
113
113
  "js-tiktoken": "^1.0.21",
114
114
  "jsdom": "^29.1.1",
115
- "marked": "^18.0.6",
115
+ "marked": "^18.0.7",
116
116
  "mulmocast-vision": "^1.0.12",
117
117
  "ora": "^9.4.1",
118
118
  "puppeteer": "^25.3.0",
@@ -128,16 +128,16 @@
128
128
  "@types/jsdom": "^28.0.3",
129
129
  "@types/yargs": "^17.0.35",
130
130
  "cross-env": "^10.1.0",
131
- "eslint": "^10.7.0",
131
+ "eslint": "^10.8.0",
132
132
  "eslint-config-prettier": "^10.1.8",
133
133
  "eslint-plugin-import": "^2.32.0",
134
134
  "eslint-plugin-prettier": "^5.5.6",
135
135
  "eslint-plugin-sonarjs": "^4.2.0",
136
136
  "globals": "^17.7.0",
137
- "prettier": "^3.9.5",
137
+ "prettier": "^3.9.6",
138
138
  "tsx": "^4.23.1",
139
139
  "typescript": "6.0.3",
140
- "typescript-eslint": "^8.64.0"
140
+ "typescript-eslint": "^8.65.0"
141
141
  },
142
142
  "engines": {
143
143
  "node": ">=22.0.0"
@@ -0,0 +1,71 @@
1
+ {
2
+ "$mulmocast": {
3
+ "version": "1.1"
4
+ },
5
+ "lang": "en",
6
+ "title": "VoiceOver Transition Test",
7
+ "movieParams": {
8
+ "transition": {
9
+ "type": "fade",
10
+ "duration": 1.0
11
+ }
12
+ },
13
+ "beats": [
14
+ {
15
+ "speaker": "Presenter",
16
+ "text": "",
17
+ "duration": 3.0,
18
+ "image": {
19
+ "type": "image",
20
+ "source": {
21
+ "kind": "path",
22
+ "path": "../../assets/images/mulmocast_credit.png"
23
+ }
24
+ }
25
+ },
26
+ {
27
+ "speaker": "Presenter",
28
+ "text": "",
29
+ "duration": 2.0,
30
+ "image": {
31
+ "type": "voice_over"
32
+ }
33
+ },
34
+ {
35
+ "speaker": "Presenter",
36
+ "text": "",
37
+ "duration": 3.0,
38
+ "image": {
39
+ "type": "textSlide",
40
+ "slide": {
41
+ "title": "Fade after a voice_over"
42
+ }
43
+ }
44
+ },
45
+ {
46
+ "speaker": "Presenter",
47
+ "text": "",
48
+ "duration": 2.0,
49
+ "image": {
50
+ "type": "voice_over"
51
+ }
52
+ },
53
+ {
54
+ "speaker": "Presenter",
55
+ "text": "",
56
+ "duration": 3.0,
57
+ "movieParams": {
58
+ "transition": {
59
+ "type": "wipeleft",
60
+ "duration": 1.0
61
+ }
62
+ },
63
+ "image": {
64
+ "type": "textSlide",
65
+ "slide": {
66
+ "title": "Wipe after a voice_over"
67
+ }
68
+ }
69
+ }
70
+ ]
71
+ }