mulmocast 2.7.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { assert, GraphAILogger } from "graphai";
2
2
  import { silent60secPath, isFile } from "../utils/file.js";
3
3
  import { FfmpegContextInit, FfmpegContextGenerateOutput, FfmpegContextInputFormattedAudio, ffmpegGetMediaDuration, } from "../utils/ffmpeg_utils.js";
4
4
  import { MulmoMediaSourceMethods } from "../methods/mulmo_media_source.js";
5
- import { MulmoBeatMethods } from "../methods/index.js";
5
+ import { MulmoBeatMethods, MulmoPresentationStyleMethods } from "../methods/index.js";
6
6
  import { userAssert } from "../utils/utils.js";
7
7
  import { getAudioInputIdsError } from "../utils/error_cause.js";
8
8
  const getMovieDuration = async (context, beat) => {
@@ -126,7 +126,22 @@ const getVoiceOverGroup = (context, index) => {
126
126
  };
127
127
  const getSpillOverGroup = (context, mediaDurations, index) => {
128
128
  const group = [index];
129
- for (let i = index + 1; i < context.studio.beats.length && !mediaDurations[i].hasMedia; i++) {
129
+ for (let i = index + 1; i < context.studio.beats.length; i++) {
130
+ const media = mediaDurations[i];
131
+ // An empty-text beat with only a moviePrompt (no generated audio requested) continues
132
+ // the preceding narration (spill-over). Generated movies don't exist yet at this point,
133
+ // so movieDuration === 0; imported movies and animated html_tailwind beats have
134
+ // movieDuration > 0 and keep their own timeline, ending the group as before.
135
+ const beat = context.studio.script.beats[i];
136
+ const isSilentContinuation = !beat.text?.trim() &&
137
+ media.audioDuration === 0 &&
138
+ media.movieDuration === 0 &&
139
+ Boolean(beat.moviePrompt) &&
140
+ !beat.soundEffectPrompt &&
141
+ !MulmoPresentationStyleMethods.generatedMovieHasAudio(context.presentationStyle, beat);
142
+ if (media.hasMedia && !isSilentContinuation) {
143
+ break;
144
+ }
130
145
  group.push(i);
131
146
  }
132
147
  return group;
@@ -81,11 +81,14 @@ async function generateMovie(model, apiKey, prompt, imagePath, lastFrameImagePat
81
81
  }
82
82
  // Add generate_audio if the model supports it
83
83
  const audio = provider2MovieAgent.replicate.modelParams[model].audio;
84
+ if (audio.mode === AUDIO_MODE_OPTIONAL) {
85
+ // Always send the flag: several models (e.g. seedance-2.0's generate_audio, p-video's
86
+ // save_audio) default it to true, which silently embeds generated audio.
87
+ // Silent unless explicitly requested.
88
+ input[audio.param] = generateAudio ?? false;
89
+ }
84
90
  if (generateAudio !== undefined) {
85
- if (audio.mode === AUDIO_MODE_OPTIONAL) {
86
- input[audio.param] = generateAudio;
87
- }
88
- else if (audio.mode === AUDIO_MODE_NEVER && generateAudio === true) {
91
+ if (audio.mode === AUDIO_MODE_NEVER && generateAudio === true) {
89
92
  throw new Error(`Model ${model} does not support audio generation`, {
90
93
  cause: agentGenerationError("movieReplicateAgent", imageAction, unsupportedModelTarget),
91
94
  });
@@ -1,5 +1,5 @@
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_beat.js";
4
+ export * from "./mulmo_beat_node.js";
5
5
  export * from "./mulmo_script.js";
@@ -1,5 +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_beat.js";
4
+ // Node-only MulmoBeatMethods (includes getPlugin). The browser-safe subset lives in
5
+ // ./mulmo_beat.js and is re-exported by ../index.common.js for mulmocast/browser.
6
+ export * from "./mulmo_beat_node.js";
5
7
  export * from "./mulmo_script.js";
@@ -9,13 +9,6 @@ export declare const MulmoBeatMethods: {
9
9
  isAnimatedHtmlTailwind: (beat: MulmoBeat) => boolean;
10
10
  isMovieMode: (animation: unknown) => boolean;
11
11
  getHtmlPrompt(beat: MulmoBeat): string | undefined;
12
- getPlugin(beat: MulmoBeat): {
13
- imageType: string;
14
- process: (params: import("../types/type.js").ImageProcessorParams) => Promise<string | undefined> | void;
15
- path: (params: import("../types/type.js").ImageProcessorParams) => string | undefined;
16
- markdown?: (params: import("../types/type.js").ImageProcessorParams) => string | undefined;
17
- html?: (params: import("../types/type.js").ImageProcessorParams) => Promise<string | undefined>;
18
- };
19
12
  getImageReferenceForImageGenerator(beat: MulmoBeat, imageRefs: Record<string, string>): string[];
20
13
  };
21
14
  export {};
@@ -1,4 +1,3 @@
1
- import { findImagePlugin } from "../utils/image_plugins/index.js";
2
1
  /** Type guard: checks if animation value is an object config like { fps: 30 } */
3
2
  const isAnimationObject = (animation) => {
4
3
  return typeof animation === "object" && animation !== null && !Array.isArray(animation);
@@ -29,13 +28,6 @@ export const MulmoBeatMethods = {
29
28
  }
30
29
  return beat?.htmlPrompt?.prompt;
31
30
  },
32
- getPlugin(beat) {
33
- const plugin = findImagePlugin(beat?.image?.type);
34
- if (!plugin) {
35
- throw new Error(`invalid beat image type: ${beat.image}`); // TODO: cause
36
- }
37
- return plugin;
38
- },
39
31
  getImageReferenceForImageGenerator(beat, imageRefs) {
40
32
  const imageNames = beat.imageNames ?? Object.keys(imageRefs); // use all images if imageNames is not specified
41
33
  const sources = imageNames.map((name) => imageRefs[name]);
@@ -0,0 +1,22 @@
1
+ import { MulmoBeat } from "../types/index.js";
2
+ export declare const MulmoBeatMethods: {
3
+ getPlugin(beat: MulmoBeat): {
4
+ imageType: string;
5
+ process: (params: import("../types/type.js").ImageProcessorParams) => Promise<string | undefined> | void;
6
+ path: (params: import("../types/type.js").ImageProcessorParams) => string | undefined;
7
+ markdown?: (params: import("../types/type.js").ImageProcessorParams) => string | undefined;
8
+ html?: (params: import("../types/type.js").ImageProcessorParams) => Promise<string | undefined>;
9
+ };
10
+ isAnimationEnabled: (animation: unknown) => animation is true | {
11
+ fps?: number;
12
+ movie?: boolean;
13
+ };
14
+ isAnimationObject: (animation: unknown) => animation is {
15
+ fps?: number;
16
+ movie?: boolean;
17
+ };
18
+ isAnimatedHtmlTailwind: (beat: MulmoBeat) => boolean;
19
+ isMovieMode: (animation: unknown) => boolean;
20
+ getHtmlPrompt(beat: MulmoBeat): string | undefined;
21
+ getImageReferenceForImageGenerator(beat: MulmoBeat, imageRefs: Record<string, string>): string[];
22
+ };
@@ -0,0 +1,10 @@
1
+ import { MulmoBeatMethods as MulmoBeatMethodsBrowser } from "./mulmo_beat.js";
2
+ import { getBeatPlugin } from "../utils/image_plugins/index.js";
3
+ // Node-only extension of MulmoBeatMethods. Kept out of methods/mulmo_beat.ts so
4
+ // browser bundles (mulmocast/browser) do not pull in image_plugins → mulmocast-vision → puppeteer.
5
+ export const MulmoBeatMethods = {
6
+ ...MulmoBeatMethodsBrowser,
7
+ getPlugin(beat) {
8
+ return getBeatPlugin(beat);
9
+ },
10
+ };
@@ -204,6 +204,7 @@ export declare const MulmoPresentationStyleMethods: {
204
204
  };
205
205
  keyName: string;
206
206
  };
207
+ generatedMovieHasAudio(presentationStyle: MulmoPresentationStyle, beat: MulmoBeat): boolean;
207
208
  getSoundEffectAgentInfo(presentationStyle: MulmoPresentationStyle, beat: MulmoBeat): {
208
209
  agentName: string;
209
210
  defaultModel: import("../types/provider2agent.js").ReplicateModel;
@@ -7,7 +7,7 @@ import { isNull } from "graphai";
7
7
  import { userAssert } from "../utils/utils.js";
8
8
  import { slideThemes } from "../data/slideThemes.js";
9
9
  import { text2ImageProviderSchema, text2HtmlImageProviderSchema, text2MovieProviderSchema, text2SpeechProviderSchema, mulmoCanvasDimensionSchema, mulmoTransitionSchema, } from "../types/schema.js";
10
- import { provider2ImageAgent, provider2MovieAgent, provider2LLMAgent, provider2TTSAgent, provider2SoundEffectAgent, provider2LipSyncAgent, defaultProviders, } from "../types/provider2agent.js";
10
+ import { provider2ImageAgent, provider2MovieAgent, provider2LLMAgent, provider2TTSAgent, provider2SoundEffectAgent, provider2LipSyncAgent, defaultProviders, getModelAudio, AUDIO_MODE_ALWAYS, } from "../types/provider2agent.js";
11
11
  const defaultTextSlideStyles = [
12
12
  '*,*::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}}',
13
13
  "body { margin: 60px; margin-top: 40px; color:#333; background-color:#fff; font-size: 30px; font-family: Arial, sans-serif; box-sizing: border-box; height: 100vh }",
@@ -131,6 +131,17 @@ export const MulmoPresentationStyleMethods = {
131
131
  keyName: agentInfo.keyName,
132
132
  };
133
133
  },
134
+ // True when the movie generated for this beat will carry an audio track:
135
+ // the model always embeds audio, or audio generation is explicitly requested.
136
+ generatedMovieHasAudio(presentationStyle, beat) {
137
+ const movieParams = { ...presentationStyle.movieParams, ...beat.movieParams };
138
+ if (movieParams?.generateAudio === true) {
139
+ return true;
140
+ }
141
+ const provider = text2MovieProviderSchema.parse(movieParams?.provider ?? defaultProviders.text2movie);
142
+ const model = movieParams?.model ?? provider2MovieAgent[provider].defaultModel;
143
+ return getModelAudio(provider, model)?.mode === AUDIO_MODE_ALWAYS;
144
+ },
134
145
  getSoundEffectAgentInfo(presentationStyle, beat) {
135
146
  const soundEffectProvider = (beat.soundEffectParams?.provider ??
136
147
  presentationStyle.soundEffectParams?.provider ??
@@ -88,7 +88,7 @@ export type ReplicateModel = `${string}/${string}`;
88
88
  export declare const AUDIO_MODE_NEVER: "never";
89
89
  export declare const AUDIO_MODE_ALWAYS: "always";
90
90
  export declare const AUDIO_MODE_OPTIONAL: "optional";
91
- type MovieAudioSpec = {
91
+ export type MovieAudioSpec = {
92
92
  mode: typeof AUDIO_MODE_NEVER;
93
93
  } | {
94
94
  mode: typeof AUDIO_MODE_ALWAYS;
@@ -213,6 +213,7 @@ export declare const defaultProviders: {
213
213
  export declare const llm: (keyof typeof provider2LLMAgent)[];
214
214
  export type LLM = keyof typeof provider2LLMAgent;
215
215
  export declare const htmlLLMProvider: string[];
216
+ export declare const getModelAudio: (provider: keyof typeof provider2MovieAgent, model: string) => MovieAudioSpec | undefined;
216
217
  export declare const getModelDuration: (provider: keyof typeof provider2MovieAgent, model: string, movieDuration?: number) => number | undefined;
217
218
  export type ModelPricing = {
218
219
  unit: "tokens" | "chars" | "seconds" | "images";
@@ -144,10 +144,12 @@ export const provider2MovieAgent = {
144
144
  "minimax/hailuo-2.3",
145
145
  "minimax/hailuo-2.3-fast",
146
146
  "pixverse/pixverse-v5",
147
+ "prunaai/p-video",
148
+ "xai/grok-imagine-video-1.5",
147
149
  ],
148
150
  modelParams: {
149
151
  "bytedance/seedance-1-lite": {
150
- durations: [5, 10],
152
+ durations: [4, 5, 6, 7, 8, 9, 10, 11, 12],
151
153
  start_image: "image",
152
154
  last_image: "last_frame_image",
153
155
  audio: { mode: AUDIO_MODE_NEVER },
@@ -259,10 +261,13 @@ export const provider2MovieAgent = {
259
261
  price_per_sec: 0.12,
260
262
  },
261
263
  "wan-video/wan-2.2-i2v-fast": {
264
+ // No duration input: length is num_frames (default 81) / frames_per_second (default 16) ≈ 5s.
262
265
  durations: [5],
263
266
  start_image: "image",
267
+ start_image_required: true,
268
+ last_image: "last_image",
264
269
  audio: { mode: AUDIO_MODE_NEVER },
265
- price_per_sec: 0.012,
270
+ price_per_sec: 0.01, // $0.05 per 81-frame 480p video; up to $0.145 at 720p
266
271
  },
267
272
  "wan-video/wan-2.2-t2v-fast": {
268
273
  durations: [5],
@@ -295,7 +300,7 @@ export const provider2MovieAgent = {
295
300
  last_image: "end_image",
296
301
  reference_images_param: "reference_images",
297
302
  audio: { mode: AUDIO_MODE_OPTIONAL, param: "generate_audio" },
298
- price_per_sec: 0.3,
303
+ price_per_sec: 0.28, // 'pro' (1080p, default); 'standard' $0.168, '4k' $0.42
299
304
  },
300
305
  "kwaivgi/kling-v3-video": {
301
306
  durations: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
@@ -335,6 +340,20 @@ export const provider2MovieAgent = {
335
340
  audio: { mode: AUDIO_MODE_NEVER },
336
341
  price_per_sec: 0.12,
337
342
  },
343
+ "prunaai/p-video": {
344
+ durations: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
345
+ start_image: "image",
346
+ last_image: "last_frame_image",
347
+ audio: { mode: AUDIO_MODE_OPTIONAL, param: "save_audio" },
348
+ price_per_sec: 0.02, // 720p, draft mode off ($0.04 at 1080p)
349
+ },
350
+ "xai/grok-imagine-video-1.5": {
351
+ durations: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
352
+ start_image: "image",
353
+ start_image_required: true,
354
+ audio: { mode: AUDIO_MODE_ALWAYS },
355
+ price_per_sec: 0.08,
356
+ },
338
357
  },
339
358
  },
340
359
  google: {
@@ -508,6 +527,10 @@ export const defaultProviders = {
508
527
  };
509
528
  export const llm = Object.keys(provider2LLMAgent);
510
529
  export const htmlLLMProvider = ["openai", "anthropic", "mock"];
530
+ export const getModelAudio = (provider, model) => {
531
+ const modelParams = provider2MovieAgent[provider]?.modelParams;
532
+ return modelParams?.[model]?.audio;
533
+ };
511
534
  export const getModelDuration = (provider, model, movieDuration) => {
512
535
  const modelParams = provider2MovieAgent[provider]?.modelParams;
513
536
  const { durations } = modelParams[model];
@@ -423,8 +423,16 @@ export const audioParamsSchema = z
423
423
  .object({
424
424
  padding: z.number().optional().default(0.3).describe("Padding between beats"), // seconds
425
425
  introPadding: z.number().optional().default(1.0).describe("Padding at the beginning of the audio"), // seconds
426
- closingPadding: z.number().optional().default(0.8).describe("Padding before the last beat"), // seconds
427
- outroPadding: z.number().optional().default(1.0).describe("Padding at the end of the audio"), // seconds
426
+ closingPadding: z
427
+ .number()
428
+ .optional()
429
+ .default(0.8)
430
+ .describe("Padding before the last beat (typically the closing credit). For padding after the last beat, use outroPadding"), // seconds
431
+ outroPadding: z
432
+ .number()
433
+ .optional()
434
+ .default(1.0)
435
+ .describe("Padding after the last beat, at the very end of the audio where the BGM fades out (distinct from closingPadding, which comes before the last beat)"), // seconds
428
436
  bgm: mediaSourceSchema.optional(),
429
437
  bgmVolume: z.number().optional().default(0.2).describe("Volume of the background music"),
430
438
  audioVolume: z.number().optional().default(1.0).describe("Volume of the audio"),
@@ -1,4 +1,4 @@
1
- import { ImageProcessorParams } from "../../types/index.js";
1
+ import { ImageProcessorParams, MulmoBeat } from "../../types/index.js";
2
2
  export declare const findImagePlugin: (imageType?: string) => {
3
3
  imageType: string;
4
4
  process: (params: ImageProcessorParams) => Promise<string | undefined> | void;
@@ -6,3 +6,10 @@ export declare const findImagePlugin: (imageType?: string) => {
6
6
  markdown?: (params: ImageProcessorParams) => string | undefined;
7
7
  html?: (params: ImageProcessorParams) => Promise<string | undefined>;
8
8
  } | undefined;
9
+ export declare const getBeatPlugin: (beat: MulmoBeat) => {
10
+ imageType: string;
11
+ process: (params: ImageProcessorParams) => Promise<string | undefined> | void;
12
+ path: (params: ImageProcessorParams) => string | undefined;
13
+ markdown?: (params: ImageProcessorParams) => string | undefined;
14
+ html?: (params: ImageProcessorParams) => Promise<string | undefined>;
15
+ };
@@ -25,3 +25,10 @@ const imagePlugins = [
25
25
  export const findImagePlugin = (imageType) => {
26
26
  return imagePlugins.find((plugin) => plugin.imageType === imageType);
27
27
  };
28
+ export const getBeatPlugin = (beat) => {
29
+ const plugin = findImagePlugin(beat?.image?.type);
30
+ if (!plugin) {
31
+ throw new Error(`invalid beat image type: ${beat.image}`);
32
+ }
33
+ return plugin;
34
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmocast",
3
- "version": "2.7.1",
3
+ "version": "2.8.0",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "lib/index.node.js",