mulmocast 2.14.0 → 2.16.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.
Files changed (38) hide show
  1. package/README.md +1 -7
  2. package/assets/schemas/mulmo_script.json +2 -2
  3. package/lib/actions/avatar.d.ts +2 -1
  4. package/lib/actions/avatar.js +59 -3
  5. package/lib/actions/image_references.d.ts +23 -1
  6. package/lib/actions/image_references.js +16 -9
  7. package/lib/agents/image_genai_agent.js +3 -3
  8. package/lib/agents/movie_genai_agent.js +5 -7
  9. package/lib/data/scriptTemplates.js +2 -2
  10. package/lib/data/templateDataSet.js +1 -1
  11. package/lib/index.remotion.d.ts +3 -0
  12. package/lib/index.remotion.js +2 -0
  13. package/lib/index.remotion_guide.d.ts +1 -0
  14. package/lib/index.remotion_guide.js +3 -0
  15. package/lib/methods/mulmo_presentation_style.js +3 -3
  16. package/lib/types/provider2agent.d.ts +25 -16
  17. package/lib/types/provider2agent.js +99 -63
  18. package/lib/types/schema.js +3 -1
  19. package/lib/utils/estimate_usage.js +7 -3
  20. package/lib/utils/image_plugins/remotion.js +3 -10
  21. package/lib/utils/remotion/frames.d.ts +3 -0
  22. package/lib/utils/remotion/frames.js +11 -0
  23. package/lib/utils/remotion/network_guard.d.ts +5 -0
  24. package/lib/utils/remotion/network_guard.js +48 -0
  25. package/lib/utils/remotion/network_policy.d.ts +10 -0
  26. package/lib/utils/remotion/network_policy.js +51 -0
  27. package/lib/utils/remotion/render.d.ts +1 -1
  28. package/lib/utils/remotion/render.js +50 -6
  29. package/lib/utils/remotion/render_frames.d.ts +23 -0
  30. package/lib/utils/remotion/render_frames.js +61 -0
  31. package/package.json +7 -3
  32. package/scripts/templates/html.json +1 -1
  33. package/scripts/templates/presentation.json +1 -1
  34. package/scripts/test/test_avatar.json +97 -23
  35. package/scripts/test/test_genai.json +2 -2
  36. package/scripts/test/test_gpt_image.json +1 -1
  37. package/scripts/test/test_replicate.json +3 -3
  38. package/scripts/test/test_vertexai.json +3 -3
@@ -44,13 +44,13 @@ export const provider2TTSAgent = {
44
44
  models: ["mock-model"],
45
45
  },
46
46
  };
47
- export const gptImages = ["gpt-image-2", "gpt-image-1.5", "gpt-image-1", "gpt-image-1-mini"];
48
- const supportedOpenAIImageReplacementHint = "Use 'gpt-image-1' or another supported model.";
47
+ export const gptImages = ["gpt-image-2.5-sunburst", "gpt-image-2.5-flare", "gpt-image-2", "gpt-image-1.5", "gpt-image-1", "gpt-image-1-mini"];
48
+ const supportedOpenAIImageReplacementHint = "Use 'gpt-image-2.5-sunburst' or another supported model.";
49
49
  export const deprecatedOpenAIImageModelHints = {
50
50
  "dall-e-2": supportedOpenAIImageReplacementHint,
51
51
  "dall-e-3": supportedOpenAIImageReplacementHint,
52
52
  };
53
- const supportedGoogleImageReplacementHint = "Use 'gemini-2.5-flash-image' or 'gemini-3-pro-image-preview' instead.";
53
+ const supportedGoogleImageReplacementHint = "Use 'gemini-3.1-flash-lite-image' or 'gemini-3-pro-image' instead.";
54
54
  export const deprecatedGoogleImageModelHints = {
55
55
  "imagen-3.0-generate-002": supportedGoogleImageReplacementHint,
56
56
  "imagen-4.0-generate-001": supportedGoogleImageReplacementHint,
@@ -59,11 +59,21 @@ export const deprecatedGoogleImageModelHints = {
59
59
  "imagen-4.0-generate-preview-06-06": supportedGoogleImageReplacementHint,
60
60
  "imagen-4.0-ultra-generate-preview-06-06": supportedGoogleImageReplacementHint,
61
61
  };
62
+ // Gemini image models are called through generateContent; anything else is treated as an Imagen model (generateImages).
63
+ export const isGeminiImageModel = (model) => model.startsWith("gemini-");
62
64
  // Google image models that on Vertex AI are only published under location "global"
63
65
  // (regional endpoints like us-central1 return 404 NOT_FOUND).
64
66
  // See https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-pro-image
65
67
  // and https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-1-flash-image
66
- export const vertexAIGlobalOnlyImageModels = new Set(["gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"]);
68
+ export const vertexAIGlobalOnlyImageModels = new Set([
69
+ "gemini-3.1-flash-lite-image",
70
+ "gemini-3-pro-image",
71
+ "gemini-3-pro-image-preview",
72
+ "gemini-3.1-flash-image-preview",
73
+ ]);
74
+ const VERTEX_DEFAULT_IMAGE_LOCATION = "us-central1";
75
+ // A global-only model has no regional endpoint, so an omitted location means "global" for it.
76
+ export const vertexImageLocation = (model, location) => location ?? (vertexAIGlobalOnlyImageModels.has(model) ? "global" : VERTEX_DEFAULT_IMAGE_LOCATION);
67
77
  // Per-model reference image limits (image_input array). Only verified entries; unlisted models are not truncated.
68
78
  const replicateImageModelParams = {
69
79
  "bytedance/seedream-4": { maxReferenceImages: 10 },
@@ -71,7 +81,9 @@ const replicateImageModelParams = {
71
81
  export const provider2ImageAgent = {
72
82
  openai: {
73
83
  agentName: "imageOpenaiAgent",
74
- defaultModel: "gpt-image-1",
84
+ // gpt-image-1 shuts down on 2026-10-23; OpenAI names gpt-image-2.5-sunburst or -flare as its successor.
85
+ // https://developers.openai.com/api/docs/deprecations
86
+ defaultModel: "gpt-image-2.5-sunburst",
75
87
  models: [...gptImages],
76
88
  keyName: "OPENAI_API_KEY",
77
89
  baseURLKeyName: "OPENAI_BASE_URL",
@@ -79,8 +91,16 @@ export const provider2ImageAgent = {
79
91
  },
80
92
  google: {
81
93
  agentName: "imageGenAIAgent",
82
- defaultModel: "gemini-2.5-flash-image",
83
- models: ["gemini-2.5-flash-image", "gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview"],
94
+ // gemini-2.5-flash-image is deprecated; Google names gemini-3.1-flash-lite-image as its successor.
95
+ defaultModel: "gemini-3.1-flash-lite-image",
96
+ models: [
97
+ "gemini-3.1-flash-lite-image",
98
+ "gemini-3.1-flash-image",
99
+ "gemini-3-pro-image",
100
+ "gemini-2.5-flash-image",
101
+ "gemini-3.1-flash-image-preview",
102
+ "gemini-3-pro-image-preview",
103
+ ],
84
104
  keyName: "GEMINI_API_KEY",
85
105
  },
86
106
  replicate: {
@@ -126,7 +146,6 @@ export const provider2MovieAgent = {
126
146
  "bytedance/seedance-1-pro",
127
147
  "bytedance/seedance-2.0",
128
148
  "bytedance/seedance-2.0-fast",
129
- "kwaivgi/kling-v1.6-pro",
130
149
  "kwaivgi/kling-v2.1",
131
150
  "kwaivgi/kling-v2.1-master",
132
151
  "google/veo-2",
@@ -173,20 +192,14 @@ export const provider2MovieAgent = {
173
192
  start_image: "image",
174
193
  last_image: "last_frame_image",
175
194
  audio: { mode: AUDIO_MODE_OPTIONAL, param: "generate_audio" },
176
- price_per_sec: 0.29,
195
+ price_per_sec: 0.18,
177
196
  },
178
197
  "bytedance/seedance-2.0-fast": {
179
198
  durations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
180
199
  start_image: "image",
181
200
  last_image: "last_frame_image",
182
201
  audio: { mode: AUDIO_MODE_OPTIONAL, param: "generate_audio" },
183
- price_per_sec: 0.22,
184
- },
185
- "kwaivgi/kling-v1.6-pro": {
186
- durations: [5, 10],
187
- start_image: "start_image",
188
- audio: { mode: AUDIO_MODE_NEVER },
189
- price_per_sec: 0.095,
202
+ price_per_sec: 0.15,
190
203
  },
191
204
  "kwaivgi/kling-v2.1": {
192
205
  durations: [5, 10],
@@ -210,7 +223,7 @@ export const provider2MovieAgent = {
210
223
  durations: [8],
211
224
  start_image: "image",
212
225
  audio: { mode: AUDIO_MODE_OPTIONAL, param: "generate_audio" },
213
- price_per_sec: 0.75,
226
+ price_per_sec: 0.2,
214
227
  },
215
228
  "google/veo-3.1": {
216
229
  durations: [4, 6, 8],
@@ -218,14 +231,14 @@ export const provider2MovieAgent = {
218
231
  last_image: "last_frame_image",
219
232
  reference_images_param: "reference_images",
220
233
  audio: { mode: AUDIO_MODE_OPTIONAL, param: "generate_audio" },
221
- price_per_sec: 0.75,
234
+ price_per_sec: 0.2,
222
235
  },
223
236
  "google/veo-3.1-fast": {
224
237
  durations: [4, 6, 8],
225
238
  start_image: "image",
226
239
  last_image: "last_frame_image",
227
240
  audio: { mode: AUDIO_MODE_OPTIONAL, param: "generate_audio" },
228
- price_per_sec: 0.4,
241
+ price_per_sec: 0.1,
229
242
  },
230
243
  "google/veo-3.1-lite": {
231
244
  durations: [4, 6, 8],
@@ -238,13 +251,13 @@ export const provider2MovieAgent = {
238
251
  durations: [8],
239
252
  start_image: "image",
240
253
  audio: { mode: AUDIO_MODE_OPTIONAL, param: "generate_audio" },
241
- price_per_sec: 0.4,
254
+ price_per_sec: 0.1,
242
255
  },
243
256
  "minimax/video-01": {
244
257
  durations: [6],
245
258
  start_image: "first_frame_image",
246
259
  audio: { mode: AUDIO_MODE_NEVER },
247
- price_per_sec: 0.5,
260
+ price_per_sec: 0.083,
248
261
  },
249
262
  "minimax/hailuo-02": {
250
263
  durations: [6], // NOTE: 10 for only 720p
@@ -264,7 +277,7 @@ export const provider2MovieAgent = {
264
277
  start_image: "image",
265
278
  last_image: "last_frame_image",
266
279
  audio: { mode: AUDIO_MODE_OPTIONAL, param: "sound_effect_switch" },
267
- price_per_sec: 0.12,
280
+ price_per_sec: 0.06,
268
281
  },
269
282
  "wan-video/wan-2.2-i2v-fast": {
270
283
  // No duration input: length is num_frames (default 81) / frames_per_second (default 16) ≈ 5s.
@@ -279,26 +292,26 @@ export const provider2MovieAgent = {
279
292
  durations: [5],
280
293
  start_image: undefined,
281
294
  audio: { mode: AUDIO_MODE_NEVER },
282
- price_per_sec: 0.012,
295
+ price_per_sec: 0.01,
283
296
  },
284
297
  "xai/grok-imagine-video": {
285
298
  durations: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
286
299
  start_image: "image",
287
300
  audio: { mode: AUDIO_MODE_NEVER },
288
- price_per_sec: 0.08,
301
+ price_per_sec: 0.05,
289
302
  },
290
303
  "xai/grok-imagine-r2v": {
291
304
  durations: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
292
305
  start_image: undefined,
293
306
  reference_images_param: "reference_images",
294
307
  audio: { mode: AUDIO_MODE_NEVER },
295
- price_per_sec: 0.08,
308
+ price_per_sec: 0.05,
296
309
  },
297
310
  "runwayml/gen-4.5": {
298
311
  durations: [5, 10],
299
312
  start_image: "image",
300
313
  audio: { mode: AUDIO_MODE_NEVER },
301
- price_per_sec: 0.25,
314
+ price_per_sec: 0.12,
302
315
  },
303
316
  "kwaivgi/kling-v3-omni-video": {
304
317
  durations: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
@@ -306,7 +319,7 @@ export const provider2MovieAgent = {
306
319
  last_image: "end_image",
307
320
  reference_images_param: "reference_images",
308
321
  audio: { mode: AUDIO_MODE_OPTIONAL, param: "generate_audio" },
309
- price_per_sec: 0.28, // 'pro' (1080p, default); 'standard' $0.168, '4k' $0.42
322
+ price_per_sec: 0.224,
310
323
  },
311
324
  "kwaivgi/kling-v3-video": {
312
325
  durations: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
@@ -314,7 +327,7 @@ export const provider2MovieAgent = {
314
327
  last_image: "end_image",
315
328
  reference_images_param: "reference_images",
316
329
  audio: { mode: AUDIO_MODE_OPTIONAL, param: "generate_audio" },
317
- price_per_sec: 0.3,
330
+ price_per_sec: 0.224,
318
331
  },
319
332
  // TODO: price_per_sec for the models below is a coarse approximation.
320
333
  // Actual Replicate pricing varies by resolution / duration / quality and
@@ -324,27 +337,27 @@ export const provider2MovieAgent = {
324
337
  durations: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
325
338
  start_image: "image",
326
339
  audio: { mode: AUDIO_MODE_NEVER },
327
- price_per_sec: 0.05,
340
+ price_per_sec: 0.14,
328
341
  },
329
342
  "minimax/hailuo-2.3": {
330
343
  durations: [6, 10],
331
344
  start_image: "first_frame_image",
332
345
  audio: { mode: AUDIO_MODE_NEVER },
333
- price_per_sec: 0.1,
346
+ price_per_sec: 0.047,
334
347
  },
335
348
  "minimax/hailuo-2.3-fast": {
336
349
  durations: [6, 10],
337
350
  start_image: "first_frame_image",
338
351
  start_image_required: true,
339
352
  audio: { mode: AUDIO_MODE_NEVER },
340
- price_per_sec: 0.06,
353
+ price_per_sec: 0.032,
341
354
  },
342
355
  "pixverse/pixverse-v5": {
343
356
  durations: [5, 8],
344
357
  start_image: "image",
345
358
  last_image: "last_frame_image",
346
359
  audio: { mode: AUDIO_MODE_NEVER },
347
- price_per_sec: 0.12,
360
+ price_per_sec: 0.06,
348
361
  },
349
362
  "prunaai/p-video": {
350
363
  durations: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
@@ -364,8 +377,9 @@ export const provider2MovieAgent = {
364
377
  },
365
378
  google: {
366
379
  agentName: "movieGenAIAgent",
367
- defaultModel: "veo-2.0-generate-001",
368
- models: ["veo-2.0-generate-001", "veo-3.0-generate-001", "veo-3.1-generate-preview", "veo-3.1-lite-generate-preview"],
380
+ // veo-2.0 and veo-3.0 were shut down on 2026-06-30; the Gemini API now lists only the Veo 3.1 previews.
381
+ defaultModel: "veo-3.1-generate-preview",
382
+ models: ["veo-3.1-generate-preview", "veo-3.1-lite-generate-preview", "veo-3.1-generate-001"],
369
383
  keyName: "GEMINI_API_KEY",
370
384
  modelParams: {
371
385
  "veo-3.1-lite-generate-preview": {
@@ -376,7 +390,8 @@ export const provider2MovieAgent = {
376
390
  supportsPersonGeneration: false,
377
391
  audio: { mode: AUDIO_MODE_ALWAYS },
378
392
  },
379
- "veo-3.1-generate-preview": {
393
+ // Vertex AI only: the GA name of Veo 3.1 there, where the preview IDs are retired.
394
+ "veo-3.1-generate-001": {
380
395
  durations: [4, 6, 8],
381
396
  supportsDuration: true,
382
397
  supportsLastFrame: true,
@@ -384,22 +399,14 @@ export const provider2MovieAgent = {
384
399
  supportsPersonGeneration: false,
385
400
  audio: { mode: AUDIO_MODE_ALWAYS },
386
401
  },
387
- "veo-3.0-generate-001": {
388
- durations: [8],
389
- supportsDuration: false, // Veo 3.0 always generates 8s
390
- supportsLastFrame: false,
391
- supportsReferenceImages: false,
402
+ "veo-3.1-generate-preview": {
403
+ durations: [4, 6, 8],
404
+ supportsDuration: true,
405
+ supportsLastFrame: true,
406
+ supportsReferenceImages: true,
392
407
  supportsPersonGeneration: false,
393
408
  audio: { mode: AUDIO_MODE_ALWAYS },
394
409
  },
395
- "veo-2.0-generate-001": {
396
- durations: [5, 6, 8],
397
- supportsDuration: true,
398
- supportsLastFrame: false, // Vertex AI only
399
- supportsReferenceImages: false,
400
- supportsPersonGeneration: true,
401
- audio: { mode: AUDIO_MODE_NEVER },
402
- },
403
410
  },
404
411
  },
405
412
  mock: {
@@ -471,12 +478,16 @@ export const provider2LipSyncAgent = {
471
478
  export const provider2LLMAgent = {
472
479
  openai: {
473
480
  agentName: "openAIAgent",
474
- defaultModel: "gpt-5",
481
+ // gpt-5 (gpt-5-2025-08-07) shuts down on 2026-12-11; OpenAI names gpt-5.6-sol as its successor.
482
+ defaultModel: "gpt-5.6-sol",
475
483
  keyName: "OPENAI_API_KEY",
476
484
  baseURLKeyName: "OPENAI_BASE_URL",
477
485
  apiVersionKeyName: "OPENAI_API_VERSION",
478
486
  max_tokens: 8192,
479
487
  models: [
488
+ "gpt-5.6-sol",
489
+ "gpt-5.6-terra",
490
+ "gpt-5.6-luna",
480
491
  "gpt-5",
481
492
  "gpt-5-nano",
482
493
  "gpt-5-mini",
@@ -494,26 +505,31 @@ export const provider2LLMAgent = {
494
505
  },
495
506
  anthropic: {
496
507
  agentName: "anthropicAgent",
497
- defaultModel: "claude-sonnet-4-5-20250929",
508
+ // claude-sonnet-4-5 retires on 2026-11-30. Its named successor, claude-sonnet-5-5 (like claude-opus-4-8),
509
+ // rejects the temperature @graphai/anthropic_agent always sends, so the newest model that answers is used.
510
+ defaultModel: "claude-sonnet-4-6",
498
511
  max_tokens: 8192,
499
- models: ["claude-opus-4-1-20250805", "claude-opus-4-20250514", "claude-sonnet-4-20250514", "claude-sonnet-4-5-20250929", "claude-haiku-4-5-20251001"],
512
+ models: ["claude-sonnet-4-6", "claude-sonnet-4-5-20250929", "claude-haiku-4-5-20251001"],
500
513
  keyName: "ANTHROPIC_API_KEY",
501
514
  apiKeyNameOverride: "ANTHROPIC_API_TOKEN",
502
515
  // GraphAI is currently using ANTHROPIC_API_KEY, but the official name is ANTHROPIC_API_TOKEN.
503
516
  },
504
517
  gemini: {
505
518
  agentName: "geminiAgent",
506
- defaultModel: "gemini-2.5-flash",
519
+ // Google limits the 2.5 models to users who have used them before and recommends 3.8 Flash.
520
+ defaultModel: "gemini-3.8-flash",
507
521
  max_tokens: 8192,
508
- models: ["gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite"],
522
+ models: ["gemini-3.8-flash", "gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite"],
509
523
  keyName: "GEMINI_API_KEY",
510
524
  },
511
525
  groq: {
512
526
  agentName: "groqAgent",
513
- defaultModel: "llama-3.1-8b-instant",
527
+ // The Llama and DeepSeek models were shut down; Groq names openai/gpt-oss-20b as the 8B model's successor.
528
+ // https://console.groq.com/docs/deprecations
529
+ defaultModel: "openai/gpt-oss-20b",
514
530
  keyName: "GROQ_API_KEY",
515
531
  max_tokens: 4096,
516
- models: ["llama-3.1-8b-instant", "llama-3.3-70b-versatile", "deepseek-r1-distill-llama-70b", "openai/gpt-oss-120b", "openai/gpt-oss-20b"],
532
+ models: ["openai/gpt-oss-20b", "openai/gpt-oss-120b"],
517
533
  },
518
534
  mock: {
519
535
  agentName: "mediaMockAgent",
@@ -562,6 +578,14 @@ export const getModelAudio = (provider, model) => {
562
578
  const modelParams = provider2MovieAgent[provider]?.modelParams;
563
579
  return modelParams?.[model]?.audio;
564
580
  };
581
+ const GOOGLE_VERTEX_DEFAULT_MOVIE_MODEL = "veo-3.1-generate-001";
582
+ // The default model is shared by the Gemini API and Vertex AI, but Vertex retired the Veo 3.1 preview IDs.
583
+ export const defaultMovieModel = (provider, movieParams) => provider === "google" && movieParams?.vertexai_project ? GOOGLE_VERTEX_DEFAULT_MOVIE_MODEL : provider2MovieAgent[provider].defaultModel;
584
+ // Veo 3.1 (preview) builds clips longer than one segment by extending an 8-second first segment 8 seconds at a time.
585
+ export const VEO_EXTENSION_MODEL = "veo-3.1-generate-preview";
586
+ export const VEO_SEGMENT_SEC = 8;
587
+ export const veoExtensionCount = (requestedSec) => Math.ceil((requestedSec - VEO_SEGMENT_SEC) / VEO_SEGMENT_SEC);
588
+ export const veoExtendedSeconds = (requestedSec) => VEO_SEGMENT_SEC * (1 + veoExtensionCount(requestedSec));
565
589
  export const getModelDuration = (provider, model, movieDuration) => {
566
590
  const modelParams = provider2MovieAgent[provider]?.modelParams;
567
591
  const { durations } = modelParams[model];
@@ -582,7 +606,8 @@ export const gptImageOutputTokens = {
582
606
  // Spot-checked against replicate.com model pages on this date (seedance-1-lite, kling-v2.1, omni-human).
583
607
  // Some replicate prices vary by resolution/variant; price_per_sec holds the rate for the typical configuration
584
608
  // (e.g. seedance-1-lite at 720p, kling-v2.1 standard).
585
- const REPLICATE_PRICING_AS_OF = "2026-07-03";
609
+ // Optional-audio models are priced without audio, since mulmocast generates them silent by default.
610
+ const REPLICATE_PRICING_AS_OF = "2026-10-07";
586
611
  const replicateMoviePricing = Object.fromEntries(Object.entries(provider2MovieAgent.replicate.modelParams).map(([model, params]) => [
587
612
  model,
588
613
  { unit: "seconds", perSecUSD: params.price_per_sec, asOf: REPLICATE_PRICING_AS_OF },
@@ -601,9 +626,15 @@ export const modelPricing = {
601
626
  "gpt-image-1.5": { unit: "tokens", inputPerMTokensUSD: 5, outputPerMTokensUSD: 32, asOf: "2026-07-03" },
602
627
  "gpt-image-2": { unit: "tokens", inputPerMTokensUSD: 5, outputPerMTokensUSD: 30, asOf: "2026-07-03" },
603
628
  "gpt-image-1-mini": { unit: "tokens", inputPerMTokensUSD: 2, outputPerMTokensUSD: 8, asOf: "2026-07-03" },
629
+ // https://developers.openai.com/api/docs/pricing (text input $5/1M, image output $30/1M; image input $8/1M is not modeled)
630
+ "gpt-image-2.5-sunburst": { unit: "tokens", inputPerMTokensUSD: 5, outputPerMTokensUSD: 30, asOf: "2026-10-07" },
631
+ "gpt-image-2.5-flare": { unit: "tokens", inputPerMTokensUSD: 5, outputPerMTokensUSD: 30, asOf: "2026-10-07" },
604
632
  // https://developers.openai.com/api/docs/models/gpt-5 etc.
605
633
  "gpt-5": { unit: "tokens", inputPerMTokensUSD: 1.25, outputPerMTokensUSD: 10, asOf: "2026-07-03" },
606
634
  "gpt-5-mini": { unit: "tokens", inputPerMTokensUSD: 0.25, outputPerMTokensUSD: 2, asOf: "2026-07-03" },
635
+ // https://developers.openai.com/api/docs/models/gpt-5.6-sol (promotional price, "at least through November 21, 2026")
636
+ "gpt-5.6-sol": { unit: "tokens", inputPerMTokensUSD: 4, outputPerMTokensUSD: 20, asOf: "2026-10-07" },
637
+ "gpt-5.6-terra": { unit: "tokens", inputPerMTokensUSD: 2, outputPerMTokensUSD: 12, asOf: "2026-10-07" },
607
638
  "gpt-4o": { unit: "tokens", inputPerMTokensUSD: 2.5, outputPerMTokensUSD: 10, asOf: "2026-07-03" },
608
639
  },
609
640
  gemini: {
@@ -614,9 +645,13 @@ export const modelPricing = {
614
645
  google: {
615
646
  // https://ai.google.dev/gemini-api/docs/pricing ($0.039/image ≒ 1290 output tokens at $30/1M)
616
647
  "gemini-2.5-flash-image": { unit: "images", inputPerMTokensUSD: 0.3, perImageUSD: 0.039, asOf: "2026-07-03" },
648
+ // $0.0336 per 1K-resolution image
649
+ "gemini-3.1-flash-lite-image": { unit: "images", inputPerMTokensUSD: 0.25, perImageUSD: 0.0336, asOf: "2026-10-07" },
617
650
  // Veo per second of generated video (720p). veo-2.0-generate-001 and veo-3.0-generate-001
618
651
  // were shut down on 2026-06-30, so they intentionally have no price.
619
652
  "veo-3.1-generate-preview": { unit: "seconds", perSecUSD: 0.4, asOf: "2026-07-03" },
653
+ // Vertex AI: https://cloud.google.com/vertex-ai/generative-ai/pricing (video + audio, 720p/1080p)
654
+ "veo-3.1-generate-001": { unit: "seconds", perSecUSD: 0.4, asOf: "2026-10-07" },
620
655
  "veo-3.1-lite-generate-preview": { unit: "seconds", perSecUSD: 0.05, asOf: "2026-07-03" },
621
656
  // Google Cloud Text-to-Speech per 1M characters, keyed by voice tier.
622
657
  // https://cloud.google.com/text-to-speech/pricing
@@ -629,15 +664,16 @@ export const modelPricing = {
629
664
  anthropic: {
630
665
  // https://platform.claude.com/docs/en/docs/about-claude/pricing
631
666
  "claude-sonnet-4-5-20250929": { unit: "tokens", inputPerMTokensUSD: 3, outputPerMTokensUSD: 15, asOf: "2026-07-03" },
667
+ "claude-sonnet-4-6": { unit: "tokens", inputPerMTokensUSD: 3, outputPerMTokensUSD: 15, asOf: "2026-10-07" },
632
668
  },
633
669
  elevenlabs: {
634
- // https://elevenlabs.io/pricing/api ($0.10 per 1k chars for multilingual/v3, $0.05 for flash/turbo)
635
- eleven_v3: { unit: "chars", perMCharsUSD: 100, asOf: "2026-07-03" },
636
- eleven_multilingual_v2: { unit: "chars", perMCharsUSD: 100, asOf: "2026-07-03" },
637
- eleven_turbo_v2_5: { unit: "chars", perMCharsUSD: 50, asOf: "2026-07-03" },
638
- eleven_turbo_v2: { unit: "chars", perMCharsUSD: 50, asOf: "2026-07-03" },
639
- eleven_flash_v2_5: { unit: "chars", perMCharsUSD: 50, asOf: "2026-07-03" },
640
- eleven_flash_v2: { unit: "chars", perMCharsUSD: 50, asOf: "2026-07-03" },
670
+ // https://elevenlabs.io/pricing/api ($0.08 per 1k chars for multilingual/v3, $0.04 for flash/turbo)
671
+ eleven_v3: { unit: "chars", perMCharsUSD: 80, asOf: "2026-10-07" },
672
+ eleven_multilingual_v2: { unit: "chars", perMCharsUSD: 80, asOf: "2026-10-07" },
673
+ eleven_turbo_v2_5: { unit: "chars", perMCharsUSD: 40, asOf: "2026-10-07" },
674
+ eleven_turbo_v2: { unit: "chars", perMCharsUSD: 40, asOf: "2026-10-07" },
675
+ eleven_flash_v2_5: { unit: "chars", perMCharsUSD: 40, asOf: "2026-10-07" },
676
+ eleven_flash_v2: { unit: "chars", perMCharsUSD: 40, asOf: "2026-10-07" },
641
677
  },
642
678
  replicate: {
643
679
  ...replicateMoviePricing,
@@ -46,7 +46,9 @@ export const avatarPositionSchema = z
46
46
  export const avatarEmotionSchema = z.enum(["neutral", "happy", "sad", "angry", "surprised", "relaxed"]);
47
47
  export const speakerAvatarSchema = z
48
48
  .object({
49
- source: z.string().describe("AvatarScript avatar package (a folder with avatar.json, or a mesh-avatar-studio project), relative to the script"),
49
+ source: z
50
+ .string()
51
+ .describe("AvatarScript avatar package (a folder with avatar.json, or a mesh-avatar-studio project): a path relative to the script, or an http(s) URL"),
50
52
  position: avatarPositionSchema.optional(),
51
53
  })
52
54
  .strict();
@@ -4,7 +4,7 @@
4
4
  // assume every asset is generated fresh.
5
5
  import { Tiktoken } from "js-tiktoken/lite";
6
6
  import o200k_base from "js-tiktoken/ranks/o200k_base";
7
- import { provider2TTSAgent, provider2MovieAgent, gptImageOutputTokens, getModelDuration, getModelPricing, defaultProviders, } from "../types/provider2agent.js";
7
+ import { provider2TTSAgent, provider2MovieAgent, gptImageOutputTokens, getModelDuration, defaultMovieModel, veoExtendedSeconds, VEO_EXTENSION_MODEL, VEO_SEGMENT_SEC, getModelPricing, defaultProviders, } from "../types/provider2agent.js";
8
8
  import { text2SpeechProviderSchema, text2MovieProviderSchema } from "../types/schema.js";
9
9
  import { MulmoPresentationStyleMethods } from "../methods/mulmo_presentation_style.js";
10
10
  import { MulmoBeatMethods } from "../methods/mulmo_beat.js";
@@ -217,6 +217,8 @@ const beatDurationMetric = (beat) => {
217
217
  }
218
218
  return estimated(beat.text ? estimateSpeechSec(beat.text) : DEFAULT_MOVIE_DURATION_SEC);
219
219
  };
220
+ // The movie agent extends these clips past one segment instead of capping them, and Google bills every generated second.
221
+ const isVeoExtension = (provider, model, duration) => provider === "google" && model === VEO_EXTENSION_MODEL && duration.value > VEO_SEGMENT_SEC;
220
222
  const snapMovieDuration = (provider, model, duration) => {
221
223
  if (!isKeyOf(provider2MovieAgent[provider].modelParams, model)) {
222
224
  return duration;
@@ -230,9 +232,11 @@ const estimateMovieGeneration = (style, beat, beatIndex, refKey) => {
230
232
  if (!isKeyOf(provider2MovieAgent, provider) || provider === "mock") {
231
233
  return undefined;
232
234
  }
233
- const model = info.movieParams?.model ?? provider2MovieAgent[provider].defaultModel;
235
+ const model = info.movieParams?.model ?? defaultMovieModel(provider, info.movieParams);
234
236
  const duration = beat ? beatDurationMetric(beat) : estimated(DEFAULT_MOVIE_DURATION_SEC);
235
- const predictSec = snapMovieDuration(provider, model, duration);
237
+ const predictSec = isVeoExtension(provider, model, duration)
238
+ ? metric(veoExtendedSeconds(duration.value), duration.precision === "exact")
239
+ : snapMovieDuration(provider, model, duration);
236
240
  return attachCost({ process: refKey === undefined ? "movie" : "movieReference", beatIndex, refKey, provider, model, predictSec });
237
241
  };
238
242
  const estimateSoundEffect = (style, beat, beatIndex) => {
@@ -8,6 +8,7 @@ import { REMOTION_COMPONENT_FILE, buildRepairPrompt, buildReviewPrompt, buildSce
8
8
  import { reviewComponentWithClaude, writeComponentWithClaude } from "../remotion/claude_runner.js";
9
9
  import { ensureRemotionPackages, renderRemotionScene } from "../remotion/render.js";
10
10
  import { commitFiles } from "../remotion/commit_files.js";
11
+ import { REMOTION_REVIEW_FRACTIONS, frameAtFraction, toFrameCount } from "../remotion/frames.js";
11
12
  import { parrotingImagePath } from "./utils.js";
12
13
  export const imageType = ImageMediaType.Remotion;
13
14
  const DEFAULT_FPS = 30;
@@ -15,7 +16,6 @@ const MAX_REPAIR_ATTEMPTS = 2;
15
16
  // Without audio the length is unknown; any length gives the same final frame, since the system
16
17
  // prompt makes every animation finish before the end.
17
18
  const STILL_ONLY_DURATION_SEC = 10;
18
- const REVIEW_FRACTIONS = [0.3, 0.6, 0.95];
19
19
  export const remotionWorkDir = (imagePath) => imagePath.replace(/(_animated)?\.(mp4|png)$/, "_remotion");
20
20
  export const remotionStillPath = (imagePath) => imagePath.replace(/_animated\.mp4$/, ".png");
21
21
  const loadOrWriteComponent = async (job, force, deps) => {
@@ -45,9 +45,9 @@ const renderWithRepair = async (job, code, attemptsLeft, deps, reviewStills = []
45
45
  return await renderWithRepair(job, repaired, attemptsLeft - 1, deps, reviewStills);
46
46
  }
47
47
  };
48
- const reviewStillsFor = (job) => REVIEW_FRACTIONS.map((fraction, index) => ({
48
+ const reviewStillsFor = (job) => REMOTION_REVIEW_FRACTIONS.map((fraction, index) => ({
49
49
  fraction,
50
- frame: Math.min(job.props.durationInFrames - 1, Math.floor(job.props.durationInFrames * fraction)),
50
+ frame: frameAtFraction(job.props.durationInFrames, fraction),
51
51
  file: nodePath.join(job.workDir, `review_${index}.png`),
52
52
  }));
53
53
  const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
@@ -104,13 +104,6 @@ const reviewAndImprove = async (job, renderedCode, reviewStills, deps) => {
104
104
  GraphAILogger.info(`remotion: visual review improved ${job.codePath}`);
105
105
  await renderReviewedVersion(job, improved, deps);
106
106
  };
107
- const toFrameCount = (durationSec, fps) => {
108
- const frames = Math.floor(durationSec * fps);
109
- if (frames <= 0) {
110
- throw new Error(`remotion: frame count is ${frames} (duration=${durationSec}, fps=${fps}). Increase duration or fps.`);
111
- }
112
- return frames;
113
- };
114
107
  // Lets a scene number itself and pace its place in the video; unknown when the beat is not in the script.
115
108
  const scenePosition = (beats, index) => (index < 0 ? undefined : { index, count: beats.length });
116
109
  // The words actually spoken over the scene: the translation when the video is rendered in another language.
@@ -0,0 +1,3 @@
1
+ export declare const REMOTION_REVIEW_FRACTIONS: readonly number[];
2
+ export declare const toFrameCount: (durationSec: number, fps: number) => number;
3
+ export declare const frameAtFraction: (durationInFrames: number, fraction: number) => number;
@@ -0,0 +1,11 @@
1
+ // The same moments the visual review looks at; a host reviewing its own component gets these by default.
2
+ export const REMOTION_REVIEW_FRACTIONS = [0.3, 0.6, 0.95];
3
+ export const toFrameCount = (durationSec, fps) => {
4
+ const frames = Math.floor(durationSec * fps);
5
+ if (!Number.isFinite(frames) || frames <= 0) {
6
+ throw new Error(`remotion: frame count is ${frames} (duration=${durationSec}, fps=${fps}). Increase duration or fps.`);
7
+ }
8
+ return frames;
9
+ };
10
+ // Clamped to the last frame, so a fraction of 1 is the final frame rather than one past it.
11
+ export const frameAtFraction = (durationInFrames, fraction) => Math.min(durationInFrames - 1, Math.floor(durationInFrames * fraction));
@@ -0,0 +1,5 @@
1
+ import type { HeadlessBrowser } from "@remotion/renderer";
2
+ export type NetworkGuard = {
3
+ failures: string[];
4
+ };
5
+ export declare const guardSceneNetwork: (browser: HeadlessBrowser, serverPort: number, onBlocked: (url: string) => void) => Promise<NetworkGuard>;
@@ -0,0 +1,48 @@
1
+ import { WEBRTC_REMOVAL_SCRIPT, isAllowedSceneRequest } from "./network_policy.js";
2
+ const isRecord = (value) => typeof value === "object" && value !== null;
3
+ const isPausedRequest = (value) => isRecord(value) && typeof value.requestId === "string" && isRecord(value.request) && typeof value.request.url === "string";
4
+ const isAttachedTarget = (value) => isRecord(value) && typeof value.sessionId === "string" && isRecord(value.targetInfo) && typeof value.targetInfo.type === "string";
5
+ // Remotion types only the CDP commands it uses itself; Fetch and Runtime.runIfWaitingForDebugger
6
+ // are sent through the same send(), which resolves on the reply and rejects on a CDP error.
7
+ const sendCommand = async (channel, method, params = {}) => {
8
+ const send = Reflect.get(channel, "send");
9
+ if (typeof send !== "function")
10
+ throw new Error(`remotion: cannot send ${method}: the browser connection has no send()`);
11
+ await send.call(channel, method, params);
12
+ };
13
+ const FRAME_TARGET_TYPES = ["page", "iframe"];
14
+ const answerRequest = async (connection, paused, serverPort, onBlocked) => {
15
+ if (isAllowedSceneRequest({ url: paused.request.url, isDocument: paused.resourceType === "Document" }, serverPort)) {
16
+ await sendCommand(connection, "Fetch.continueRequest", { requestId: paused.requestId });
17
+ return;
18
+ }
19
+ onBlocked(paused.request.url);
20
+ await sendCommand(connection, "Fetch.failRequest", { requestId: paused.requestId, errorReason: "BlockedByClient" });
21
+ };
22
+ // A target that could not be guarded stays paused, so its render fails instead of running unguarded.
23
+ const guardTarget = async (browser, attached) => {
24
+ const session = browser.connection.session(attached.sessionId);
25
+ if (!session)
26
+ throw new Error(`remotion: no CDP session for a new ${attached.targetInfo.type}`);
27
+ if (FRAME_TARGET_TYPES.includes(attached.targetInfo.type)) {
28
+ await session.send("Page.enable");
29
+ await session.send("Page.addScriptToEvaluateOnNewDocument", { source: WEBRTC_REMOVAL_SCRIPT });
30
+ }
31
+ await sendCommand(session, "Runtime.runIfWaitingForDebugger");
32
+ };
33
+ export const guardSceneNetwork = async (browser, serverPort, onBlocked) => {
34
+ const guard = { failures: [] };
35
+ const fail = (error) => guard.failures.push(error instanceof Error ? error.message : String(error));
36
+ const connection = browser.connection;
37
+ connection.on("Fetch.requestPaused", (event) => {
38
+ if (isPausedRequest(event))
39
+ answerRequest(connection, event, serverPort, onBlocked).catch(fail);
40
+ });
41
+ connection.on("Target.attachedToTarget", (event) => {
42
+ if (isAttachedTarget(event))
43
+ guardTarget(browser, event).catch(fail);
44
+ });
45
+ await sendCommand(connection, "Fetch.enable", { patterns: [{ urlPattern: "*" }] });
46
+ await connection.send("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
47
+ return guard;
48
+ };
@@ -0,0 +1,10 @@
1
+ export type SceneRequest = {
2
+ url: string;
3
+ isDocument: boolean;
4
+ };
5
+ export declare const isAllowedSceneRequest: ({ url, isDocument }: SceneRequest, serverPort: number) => boolean;
6
+ export declare const isAllowedMediaDownload: (src: string) => boolean;
7
+ export declare const REMOTION_CONTENT_SECURITY_POLICY = "connect-src 'self' data: blob:";
8
+ export declare const withContentSecurityPolicy: (html: string) => string;
9
+ export declare const WEBRTC_GLOBALS: readonly ["RTCPeerConnection", "webkitRTCPeerConnection", "RTCDataChannel", "RTCIceCandidate", "RTCSessionDescription"];
10
+ export declare const WEBRTC_REMOVAL_SCRIPT: string;
@@ -0,0 +1,51 @@
1
+ // What a remotion scene's browser may reach: only the bundle server started for that render, and
2
+ // in-memory URLs. Kept free of I/O so the rules can be tested without a browser.
3
+ const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
4
+ const IN_MEMORY_SCHEMES = ["data:", "blob:"];
5
+ const parseUrl = (url) => {
6
+ try {
7
+ return new URL(url);
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ };
13
+ // Remotion's /proxy makes the bundle server download its `src` from Node, on the scene's behalf.
14
+ const REMOTION_PROXY_PATH = "/proxy";
15
+ // The only page that carries the CSP; any other HTML the server returns (a directory listing, a 404)
16
+ // would run without it, so no other path may be loaded as a document.
17
+ const SCENE_DOCUMENT_PATHS = ["/", "/index.html"];
18
+ // Another port on localhost is another service, so only the render's own port counts as local.
19
+ const isBundleServer = (parsed, serverPort) => parsed.protocol === "http:" && LOOPBACK_HOSTS.includes(parsed.hostname) && parsed.port === String(serverPort);
20
+ export const isAllowedSceneRequest = ({ url, isDocument }, serverPort) => {
21
+ const parsed = parseUrl(url);
22
+ if (!parsed)
23
+ return false;
24
+ if (IN_MEMORY_SCHEMES.includes(parsed.protocol))
25
+ return true;
26
+ if (!isBundleServer(parsed, serverPort) || parsed.pathname.startsWith(REMOTION_PROXY_PATH))
27
+ return false;
28
+ return !isDocument || SCENE_DOCUMENT_PATHS.includes(parsed.pathname);
29
+ };
30
+ // Remotion downloads <Audio> / <Video> sources from Node while rendering, outside the browser;
31
+ // a scene uses no media files, so only inline data survives.
32
+ export const isAllowedMediaDownload = (src) => parseUrl(src)?.protocol === "data:";
33
+ // The browser-level request block does not see WebSocket or EventSource connections; this does.
34
+ export const REMOTION_CONTENT_SECURITY_POLICY = "connect-src 'self' data: blob:";
35
+ const HEAD_TAG = /<head(\s[^>]*)?>/i;
36
+ export const withContentSecurityPolicy = (html) => {
37
+ const match = HEAD_TAG.exec(html);
38
+ if (!match)
39
+ throw new Error("remotion: the bundle's index.html has no <head>, so the network policy cannot be applied");
40
+ const meta = `<meta http-equiv="Content-Security-Policy" content="${REMOTION_CONTENT_SECURITY_POLICY}">`;
41
+ const insertAt = match.index + match[0].length;
42
+ return [html.slice(0, insertAt), meta, html.slice(insertAt)].join("");
43
+ };
44
+ export const WEBRTC_GLOBALS = ["RTCPeerConnection", "webkitRTCPeerConnection", "RTCDataChannel", "RTCIceCandidate", "RTCSessionDescription"];
45
+ // Run before any page script in every frame: WebRTC opens UDP connections that neither the request
46
+ // block nor CSP covers. Non-configurable, so scene code cannot put the constructors back.
47
+ export const WEBRTC_REMOVAL_SCRIPT = [
48
+ `for (const name of ${JSON.stringify(WEBRTC_GLOBALS)}) {`,
49
+ " try { Object.defineProperty(globalThis, name, { value: undefined, writable: false, configurable: false }); } catch (_) {}",
50
+ "}",
51
+ ].join("\n");