mulmocast 2.6.23 → 2.7.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.
Files changed (63) hide show
  1. package/README.md +24 -0
  2. package/lib/actions/bundle.js +2 -1
  3. package/lib/actions/pdf.js +4 -1
  4. package/lib/agents/image_genai_agent.js +5 -2
  5. package/lib/agents/image_openai_agent.js +2 -1
  6. package/lib/agents/image_replicate_agent.js +2 -1
  7. package/lib/agents/lipsync_replicate_agent.js +2 -1
  8. package/lib/agents/media_mock_agent.js +2 -1
  9. package/lib/agents/movie_genai_agent.js +17 -3
  10. package/lib/agents/movie_replicate_agent.js +2 -1
  11. package/lib/agents/sound_effect_replicate_agent.js +6 -2
  12. package/lib/agents/tts_elevenlabs_agent.js +4 -3
  13. package/lib/agents/tts_gemini_agent.js +4 -1
  14. package/lib/agents/tts_kotodama_agent.js +7 -3
  15. package/lib/cli/commands/audio/builder.d.ts +2 -14
  16. package/lib/cli/commands/audio/builder.js +2 -2
  17. package/lib/cli/commands/audio/handler.d.ts +2 -0
  18. package/lib/cli/commands/audio/handler.js +5 -1
  19. package/lib/cli/commands/image/builder.d.ts +2 -14
  20. package/lib/cli/commands/image/builder.js +2 -2
  21. package/lib/cli/commands/image/handler.d.ts +2 -0
  22. package/lib/cli/commands/image/handler.js +5 -1
  23. package/lib/cli/commands/movie/builder.d.ts +2 -14
  24. package/lib/cli/commands/movie/builder.js +2 -2
  25. package/lib/cli/commands/movie/handler.d.ts +2 -0
  26. package/lib/cli/commands/movie/handler.js +5 -1
  27. package/lib/cli/commands/pdf/builder.d.ts +2 -14
  28. package/lib/cli/commands/pdf/builder.js +2 -2
  29. package/lib/cli/commands/pdf/handler.d.ts +2 -0
  30. package/lib/cli/commands/pdf/handler.js +5 -1
  31. package/lib/cli/commands/translate/builder.d.ts +3 -15
  32. package/lib/cli/commands/translate/builder.js +2 -2
  33. package/lib/cli/commands/translate/handler.d.ts +2 -0
  34. package/lib/cli/commands/translate/handler.js +5 -1
  35. package/lib/cli/common.d.ts +5 -0
  36. package/lib/cli/common.js +13 -0
  37. package/lib/cli/helpers.d.ts +2 -0
  38. package/lib/cli/helpers.js +19 -0
  39. package/lib/index.common.d.ts +2 -0
  40. package/lib/index.common.js +2 -0
  41. package/lib/methods/mulmo_media_source.js +9 -13
  42. package/lib/methods/mulmo_presentation_style.d.ts +2 -0
  43. package/lib/methods/mulmo_presentation_style.js +9 -6
  44. package/lib/types/index.d.ts +1 -0
  45. package/lib/types/index.js +1 -0
  46. package/lib/types/provider2agent.d.ts +17 -0
  47. package/lib/types/provider2agent.js +79 -0
  48. package/lib/types/usage.d.ts +21 -0
  49. package/lib/utils/estimate_usage.d.ts +11 -0
  50. package/lib/utils/estimate_usage.js +332 -0
  51. package/lib/utils/estimate_usage_format.d.ts +2 -0
  52. package/lib/utils/estimate_usage_format.js +64 -0
  53. package/lib/utils/fetch.d.ts +28 -0
  54. package/lib/utils/fetch.js +52 -0
  55. package/lib/utils/file.js +2 -1
  56. package/lib/utils/image_plugins/bg_image_util.js +7 -20
  57. package/lib/utils/openai_client.js +6 -0
  58. package/lib/utils/replicate_usage.d.ts +4 -1
  59. package/lib/utils/replicate_usage.js +25 -5
  60. package/lib/utils/sdk_timeout.d.ts +3 -0
  61. package/lib/utils/sdk_timeout.js +7 -0
  62. package/lib/utils/zip.js +2 -2
  63. package/package.json +13 -12
@@ -0,0 +1,332 @@
1
+ // Pre-run usage estimator: walks a MulmoScript and returns, per beat and per
2
+ // process, the API usage a full generation run would consume. Pure and
3
+ // browser-compatible (js-tiktoken is pure JS). No cache awareness: estimates
4
+ // assume every asset is generated fresh.
5
+ import { Tiktoken } from "js-tiktoken/lite";
6
+ import o200k_base from "js-tiktoken/ranks/o200k_base";
7
+ import { provider2TTSAgent, provider2MovieAgent, gptImageOutputTokens, getModelDuration, getModelPricing, defaultProviders, } from "../types/provider2agent.js";
8
+ import { text2SpeechProviderSchema, text2MovieProviderSchema } from "../types/schema.js";
9
+ import { MulmoPresentationStyleMethods } from "../methods/mulmo_presentation_style.js";
10
+ import { MulmoBeatMethods } from "../methods/mulmo_beat.js";
11
+ import { imagePrompt, htmlImageSystemPrompt, translateSystemPrompt, translatePrompts } from "./prompt.js";
12
+ const MILLION = 1_000_000;
13
+ const LATIN_CHARS_PER_TOKEN = 4;
14
+ const CJK_CHARS_PER_SEC = 6;
15
+ const LATIN_CHARS_PER_SEC = 15;
16
+ // Measured ≈50 audio output tokens per second (docs/api.md § Known gaps, probed in PR #1439).
17
+ const OPENAI_TTS_AUDIO_TOKENS_PER_SEC = 50;
18
+ // Documented: Gemini TTS audio tokens = 25 per second of audio.
19
+ const GEMINI_TTS_AUDIO_TOKENS_PER_SEC = 25;
20
+ // gpt-5 completion_tokens include reasoning tokens; a simple slide measured ≈4.4k (probe 2026-07-03).
21
+ const HTML_OUTPUT_TOKENS_GUESS = 4000;
22
+ const TRANSLATE_OUTPUT_FACTOR = 1.2;
23
+ // The translate action doesn't set a model, so @graphai/openai_agent's default applies.
24
+ const TRANSLATE_DEFAULT_MODEL = "gpt-4o";
25
+ const DEFAULT_MOVIE_DURATION_SEC = 8;
26
+ const GPT_IMAGE_FIXED_TOKEN_MODELS = ["gpt-image-1", "gpt-image-1-mini"];
27
+ // Fixed request framing the API adds on top of the tokenized prompt text. Both values
28
+ // reproduced the billed prompt_tokens exactly, per record, in the 2026-07-03 probes
29
+ // (scripts/probe/probe_estimate_vs_actual.ts): chat completions (system + user) +10,
30
+ // image generation +6.
31
+ const OPENAI_CHAT_INPUT_OVERHEAD_TOKENS = 10;
32
+ const GPT_IMAGE_INPUT_OVERHEAD_TOKENS = 6;
33
+ // What each CLI action actually consumes. translate is part of every generation scope
34
+ // because the CLI runs it implicitly when the target/caption language differs from the
35
+ // script language; the estimator emits zero translate records otherwise.
36
+ const VISUAL_PROCESSES = ["image", "htmlImage", "movie", "soundEffect", "lipSync", "imageReference", "movieReference"];
37
+ export const actionEstimateProcesses = {
38
+ audio: ["tts", "translate"],
39
+ images: [...VISUAL_PROCESSES, "translate"],
40
+ pdf: [...VISUAL_PROCESSES, "translate"],
41
+ movie: ["tts", ...VISUAL_PROCESSES, "translate"],
42
+ translate: ["translate"],
43
+ };
44
+ const isDefined = (value) => value !== undefined;
45
+ const isKeyOf = (obj, key) => key in obj;
46
+ let openAITokenizer;
47
+ const countOpenAITokens = (text) => {
48
+ // Lazy: building the o200k_base BPE table is expensive and most importers never estimate.
49
+ openAITokenizer = openAITokenizer ?? new Tiktoken(o200k_base);
50
+ return openAITokenizer.encode(text).length;
51
+ };
52
+ const cjkPattern = /[⺀-鿿가-힯豈-﫿ヲ-゚]/g;
53
+ const countCjkChars = (text) => (text.match(cjkPattern) ?? []).length;
54
+ const countHeuristicTokens = (text) => {
55
+ const cjkChars = countCjkChars(text);
56
+ return cjkChars + Math.ceil((text.length - cjkChars) / LATIN_CHARS_PER_TOKEN);
57
+ };
58
+ const estimateSpeechSec = (text) => {
59
+ const cjkChars = countCjkChars(text);
60
+ const latinChars = text.length - cjkChars;
61
+ return Math.max(1, Math.round(cjkChars / CJK_CHARS_PER_SEC + latinChars / LATIN_CHARS_PER_SEC));
62
+ };
63
+ const exact = (value) => ({ value, precision: "exact" });
64
+ const estimated = (value) => ({ value, precision: "estimated" });
65
+ const metric = (value, isExact) => ({ value, precision: isExact ? "exact" : "estimated" });
66
+ const computeCostUSD = (pricing, record) => {
67
+ const tokenValue = (m, rate) => (m && rate ? (m.value * rate) / MILLION : 0);
68
+ const total = tokenValue(record.inputTokens, pricing.inputPerMTokensUSD) +
69
+ tokenValue(record.outputTokens, pricing.outputPerMTokensUSD) +
70
+ tokenValue(record.inputChars, pricing.perMCharsUSD) +
71
+ (record.predictSec && pricing.perSecUSD ? record.predictSec.value * pricing.perSecUSD : 0) +
72
+ (record.imageCount && pricing.perImageUSD ? record.imageCount.value * pricing.perImageUSD : 0);
73
+ return total > 0 ? total : undefined;
74
+ };
75
+ const attachCost = (record, pricingKey) => {
76
+ const pricing = getModelPricing(record.provider, pricingKey ?? record.model);
77
+ if (!pricing) {
78
+ return record;
79
+ }
80
+ const costUSD = computeCostUSD(pricing, record);
81
+ return costUSD !== undefined ? { ...record, costUSD, pricingAsOf: pricing.asOf } : record;
82
+ };
83
+ const buildOpenAITts = ({ speaker, text, textIsFinal, beatIndex, lang }) => {
84
+ const model = speaker.model ?? provider2TTSAgent.openai.defaultModel;
85
+ // gpt-* TTS models are token-billed; legacy tts-1 models are character-billed.
86
+ const tokenMetrics = model.startsWith("gpt-")
87
+ ? {
88
+ inputTokens: metric(countOpenAITokens(text), textIsFinal),
89
+ outputTokens: estimated(estimateSpeechSec(text) * OPENAI_TTS_AUDIO_TOKENS_PER_SEC),
90
+ }
91
+ : {};
92
+ return { process: "tts", beatIndex, lang, provider: "openai", model, inputChars: metric(text.length, textIsFinal), ...tokenMetrics };
93
+ };
94
+ const buildGeminiTts = ({ speaker, speechOptions, text, beatIndex, lang }) => {
95
+ // The gemini TTS agent wraps the transcript in a "Director's Notes" prompt when an instruction is set.
96
+ const prompt = speechOptions?.instruction ? `### DIRECTOR'S NOTES\n${speechOptions.instruction}\n\n#### TRANSCRIPT\n${text}` : text;
97
+ return {
98
+ process: "tts",
99
+ beatIndex,
100
+ lang,
101
+ provider: "gemini",
102
+ model: speaker.model ?? provider2TTSAgent.gemini.defaultModel,
103
+ inputTokens: estimated(countHeuristicTokens(prompt)),
104
+ outputTokens: estimated(estimateSpeechSec(text) * GEMINI_TTS_AUDIO_TOKENS_PER_SEC),
105
+ };
106
+ };
107
+ const buildGoogleTts = ({ speaker, text, textIsFinal, beatIndex, lang }) => {
108
+ // Mirrors the runtime usage record: model falls back to the voice name; billing is per character.
109
+ const model = speaker.model ?? speaker.voiceId;
110
+ return { process: "tts", beatIndex, lang, provider: "google", model, inputChars: metric(text.length, textIsFinal) };
111
+ };
112
+ const buildElevenLabsTts = ({ speaker, text, textIsFinal, beatIndex, lang }) => {
113
+ const model = speaker.model ?? provider2TTSAgent.elevenlabs.defaultModel;
114
+ return { process: "tts", beatIndex, lang, provider: "elevenlabs", model, inputChars: metric(text.length, textIsFinal) };
115
+ };
116
+ const buildKotodamaTts = ({ speaker, text, textIsFinal, beatIndex, lang }) => {
117
+ // Mirrors the runtime usage record: the kotodama agent reports the speaker id as the model.
118
+ return { process: "tts", beatIndex, lang, provider: "kotodama", model: speaker.voiceId, inputChars: metric(text.length, textIsFinal) };
119
+ };
120
+ const ttsRecordBuilders = {
121
+ openai: buildOpenAITts,
122
+ gemini: buildGeminiTts,
123
+ google: buildGoogleTts,
124
+ elevenlabs: buildElevenLabsTts,
125
+ kotodama: buildKotodamaTts,
126
+ };
127
+ const googleTtsPricingKey = (voiceId) => {
128
+ const tiers = {
129
+ neural2: "tts-neural2",
130
+ wavenet: "tts-wavenet",
131
+ studio: "tts-studio",
132
+ chirp: "tts-chirp3-hd",
133
+ standard: "tts-standard",
134
+ };
135
+ const lower = voiceId.toLowerCase();
136
+ const tier = Object.keys(tiers).find((key) => lower.includes(key));
137
+ return tier ? tiers[tier] : undefined;
138
+ };
139
+ const estimateBeatTts = (script, style, beat, beatIndex, lang) => {
140
+ if (beat.audio || !beat.text || script.audioParams?.suppressSpeech || !style.speechParams?.speakers) {
141
+ return undefined;
142
+ }
143
+ const speaker = MulmoPresentationStyleMethods.getSpeakerData(style, beat, lang);
144
+ const provider = text2SpeechProviderSchema.parse(speaker.provider);
145
+ const builder = ttsRecordBuilders[provider];
146
+ if (!builder) {
147
+ return undefined; // mock
148
+ }
149
+ // For non-default languages the TTS input is a translation that doesn't exist yet.
150
+ const textIsFinal = lang === (script.lang ?? "en");
151
+ const speechOptions = { ...speaker.speechOptions, ...beat.speechOptions };
152
+ const record = builder({ speaker, speechOptions, text: beat.text, textIsFinal, beatIndex, lang });
153
+ return attachCost(record, provider === "google" ? googleTtsPricingKey(speaker.voiceId) : undefined);
154
+ };
155
+ // ---- Images / movies / sound effects / lip sync ----
156
+ const gptImageSize = (canvasSize) => {
157
+ if (canvasSize.width > canvasSize.height) {
158
+ return "1536x1024";
159
+ }
160
+ return canvasSize.width < canvasSize.height ? "1024x1536" : "1024x1024";
161
+ };
162
+ const gptImageOutputMetric = (model, canvasSize, quality) => {
163
+ const table = gptImageOutputTokens[gptImageSize(canvasSize)];
164
+ const knownQuality = quality === "low" || quality === "medium" || quality === "high" ? quality : undefined;
165
+ // Unspecified quality means API "auto"; assume "high" as the conservative upper bound.
166
+ const tokens = table[knownQuality ?? "high"];
167
+ return metric(tokens, GPT_IMAGE_FIXED_TOKEN_MODELS.includes(model) && knownQuality !== undefined);
168
+ };
169
+ const buildImageRecord = ({ process, provider, model, prompt, canvasSize, quality, beatIndex, refKey }) => {
170
+ const base = { process, beatIndex, refKey, provider, model };
171
+ if (provider === "openai") {
172
+ return {
173
+ ...base,
174
+ inputTokens: exact(countOpenAITokens(prompt) + GPT_IMAGE_INPUT_OVERHEAD_TOKENS),
175
+ outputTokens: gptImageOutputMetric(model, canvasSize, quality),
176
+ };
177
+ }
178
+ if (provider === "google") {
179
+ return { ...base, inputTokens: estimated(countHeuristicTokens(prompt)), imageCount: exact(1) };
180
+ }
181
+ return { ...base, imageCount: exact(1) }; // replicate bills per image
182
+ };
183
+ const isMovieBeat = (beat) => Boolean(beat.moviePrompt || beat.image?.type === "movie");
184
+ // Mirrors imagePreprocessAgent: no AI image when a plugin renders the beat or the beat is movie-only.
185
+ const needsImageGeneration = (beat) => !beat.image && !(beat.moviePrompt && !beat.imagePrompt);
186
+ const estimateImageGeneration = (style, beat, beatIndex) => {
187
+ const info = MulmoPresentationStyleMethods.getImageAgentInfo(style, beat);
188
+ const { provider, model, quality } = info.imageParams;
189
+ if (!provider || provider === "mock" || !model) {
190
+ return undefined;
191
+ }
192
+ const prompt = imagePrompt(beat, info.imageParams.style);
193
+ const canvasSize = MulmoPresentationStyleMethods.getCanvasSize(style);
194
+ return attachCost(buildImageRecord({ process: "image", provider, model, prompt, canvasSize, quality, beatIndex }));
195
+ };
196
+ const estimateHtmlImage = (style, beat, beatIndex) => {
197
+ const info = MulmoPresentationStyleMethods.getHtmlImageAgentInfo(style);
198
+ if (info.provider === "mock") {
199
+ return undefined;
200
+ }
201
+ const fullPrompt = htmlImageSystemPrompt(MulmoPresentationStyleMethods.getCanvasSize(style)) + "\n" + (MulmoBeatMethods.getHtmlPrompt(beat) ?? "");
202
+ const inputTokens = info.provider === "openai"
203
+ ? exact(countOpenAITokens(fullPrompt) + OPENAI_CHAT_INPUT_OVERHEAD_TOKENS)
204
+ : estimated(countHeuristicTokens(fullPrompt) + OPENAI_CHAT_INPUT_OVERHEAD_TOKENS);
205
+ return attachCost({
206
+ process: "htmlImage",
207
+ beatIndex,
208
+ provider: info.provider,
209
+ model: info.model,
210
+ inputTokens,
211
+ outputTokens: estimated(HTML_OUTPUT_TOKENS_GUESS),
212
+ });
213
+ };
214
+ const beatDurationMetric = (beat) => {
215
+ if (beat.duration !== undefined) {
216
+ return exact(beat.duration);
217
+ }
218
+ return estimated(beat.text ? estimateSpeechSec(beat.text) : DEFAULT_MOVIE_DURATION_SEC);
219
+ };
220
+ const snapMovieDuration = (provider, model, duration) => {
221
+ if (!isKeyOf(provider2MovieAgent[provider].modelParams, model)) {
222
+ return duration;
223
+ }
224
+ const snapped = getModelDuration(provider, model, duration.value);
225
+ return snapped !== undefined ? metric(snapped, duration.precision === "exact") : duration;
226
+ };
227
+ const estimateMovieGeneration = (style, beat, beatIndex, refKey) => {
228
+ const info = MulmoPresentationStyleMethods.getMovieAgentInfo(style, beat);
229
+ const provider = text2MovieProviderSchema.parse(info.movieParams?.provider ?? defaultProviders.text2movie);
230
+ if (!isKeyOf(provider2MovieAgent, provider) || provider === "mock") {
231
+ return undefined;
232
+ }
233
+ const model = info.movieParams?.model ?? provider2MovieAgent[provider].defaultModel;
234
+ const duration = beat ? beatDurationMetric(beat) : estimated(DEFAULT_MOVIE_DURATION_SEC);
235
+ const predictSec = snapMovieDuration(provider, model, duration);
236
+ return attachCost({ process: refKey === undefined ? "movie" : "movieReference", beatIndex, refKey, provider, model, predictSec });
237
+ };
238
+ const estimateSoundEffect = (style, beat, beatIndex) => {
239
+ const info = MulmoPresentationStyleMethods.getSoundEffectAgentInfo(style, beat);
240
+ const model = beat.soundEffectParams?.model ?? style.soundEffectParams?.model ?? info.defaultModel;
241
+ const provider = beat.soundEffectParams?.provider ?? style.soundEffectParams?.provider ?? defaultProviders.soundEffect;
242
+ // Replicate bills sound effects by GPU prediction time, which differs from clip duration.
243
+ const predictSec = estimated(beatDurationMetric(beat).value);
244
+ return attachCost({ process: "soundEffect", beatIndex, provider, model, predictSec });
245
+ };
246
+ const estimateLipSync = (style, beat, beatIndex) => {
247
+ const info = MulmoPresentationStyleMethods.getLipSyncAgentInfo(style, beat);
248
+ const model = beat.lipSyncParams?.model ?? style.lipSyncParams?.model ?? info.defaultModel;
249
+ const provider = beat.lipSyncParams?.provider ?? style.lipSyncParams?.provider ?? defaultProviders.lipSync;
250
+ return attachCost({ process: "lipSync", beatIndex, provider, model, predictSec: beatDurationMetric(beat) });
251
+ };
252
+ const estimateBeatVisuals = (style, beat, beatIndex) => {
253
+ if (beat.htmlPrompt) {
254
+ // The image preprocessor returns early for htmlPrompt beats; no other generation runs.
255
+ return [estimateHtmlImage(style, beat, beatIndex)].filter(isDefined);
256
+ }
257
+ return [
258
+ needsImageGeneration(beat) ? estimateImageGeneration(style, beat, beatIndex) : undefined,
259
+ beat.moviePrompt ? estimateMovieGeneration(style, beat, beatIndex) : undefined,
260
+ beat.soundEffectPrompt && isMovieBeat(beat) ? estimateSoundEffect(style, beat, beatIndex) : undefined,
261
+ beat.enableLipSync ? estimateLipSync(style, beat, beatIndex) : undefined,
262
+ ].filter(isDefined);
263
+ };
264
+ // ---- Reference images / movies (imageParams.images and beat.images) ----
265
+ const estimateReferenceImage = (style, prompt, canvasSize, refKey, beatIndex) => {
266
+ const info = MulmoPresentationStyleMethods.getImageAgentInfo(style);
267
+ const { provider, model, quality } = info.imageParams;
268
+ if (!provider || provider === "mock" || !model) {
269
+ return undefined;
270
+ }
271
+ // Mirrors generateReferenceImage: the style is appended to the reference prompt.
272
+ const fullPrompt = `${prompt}\n${info.imageParams.style || ""}`;
273
+ const size = canvasSize ?? MulmoPresentationStyleMethods.getCanvasSize(style);
274
+ return attachCost(buildImageRecord({ process: "imageReference", provider, model, prompt: fullPrompt, canvasSize: size, quality, refKey, beatIndex }));
275
+ };
276
+ const estimateMediaReferences = (style, images, beatIndex) => {
277
+ return Object.entries(images ?? {})
278
+ .map(([refKey, media]) => {
279
+ if (media.type === "imagePrompt") {
280
+ return estimateReferenceImage(style, media.prompt, media.canvasSize, refKey, beatIndex);
281
+ }
282
+ if (media.type === "moviePrompt") {
283
+ return estimateMovieGeneration(style, undefined, beatIndex, refKey);
284
+ }
285
+ return undefined;
286
+ })
287
+ .filter(isDefined);
288
+ };
289
+ // ---- Translate ----
290
+ const buildTranslateInput = (defaultLang, text, targetLang) => {
291
+ const substitutions = { ":lang": defaultLang, ":beat.text": text, ":targetLang": targetLang };
292
+ const prompt = translatePrompts.map((line) => substitutions[line] ?? line).join("\n");
293
+ return translateSystemPrompt + "\n" + prompt;
294
+ };
295
+ const buildTranslateRecord = (defaultLang, text, targetLang, beatIndex) => {
296
+ const input = buildTranslateInput(defaultLang, text, targetLang);
297
+ return attachCost({
298
+ process: "translate",
299
+ beatIndex,
300
+ lang: targetLang,
301
+ provider: "openai",
302
+ model: TRANSLATE_DEFAULT_MODEL,
303
+ inputTokens: exact(countOpenAITokens(input) + OPENAI_CHAT_INPUT_OVERHEAD_TOKENS),
304
+ outputTokens: estimated(Math.ceil(countOpenAITokens(text) * TRANSLATE_OUTPUT_FACTOR)),
305
+ });
306
+ };
307
+ const estimateTranslate = (script, targetLangs) => {
308
+ const defaultLang = script.lang ?? "en";
309
+ return targetLangs
310
+ .filter((targetLang) => targetLang !== defaultLang)
311
+ .flatMap((targetLang) => script.beats.map((beat, beatIndex) => (beat.text ? buildTranslateRecord(defaultLang, beat.text, targetLang, beatIndex) : undefined)).filter(isDefined));
312
+ };
313
+ // ---- Entry point ----
314
+ const defaultTargetLangs = (style, langs, defaultLang) => {
315
+ // Mirrors the translate action's default: the requested languages plus the caption language.
316
+ const candidates = [...langs, style.captionParams?.lang];
317
+ return [...new Set(candidates.filter((lang) => !!lang && lang !== defaultLang))];
318
+ };
319
+ export const estimateUsage = (script, options) => {
320
+ const style = options?.presentationStyle ?? script;
321
+ const defaultLang = script.lang ?? "en";
322
+ const langs = options?.langs ?? [defaultLang];
323
+ const targetLangs = options?.targetLangs ?? defaultTargetLangs(style, langs, defaultLang);
324
+ const beatRecords = script.beats.flatMap((beat, beatIndex) => [
325
+ ...langs.map((lang) => estimateBeatTts(script, style, beat, beatIndex, lang)).filter(isDefined),
326
+ ...estimateBeatVisuals(style, beat, beatIndex),
327
+ ...estimateMediaReferences(style, beat.images, beatIndex),
328
+ ]);
329
+ const records = [...estimateMediaReferences(style, style.imageParams?.images), ...beatRecords, ...estimateTranslate(script, targetLangs)];
330
+ const processes = options?.processes;
331
+ return processes ? records.filter((record) => processes.includes(record.process)) : records;
332
+ };
@@ -0,0 +1,2 @@
1
+ import type { UsageEstimate } from "../types/usage.js";
2
+ export declare const formatUsageEstimates: (records: UsageEstimate[]) => string;
@@ -0,0 +1,64 @@
1
+ const METRIC_KEYS = ["inputTokens", "outputTokens", "inputChars", "predictSec", "imageCount"];
2
+ const emptyGroup = (process, model) => ({
3
+ process,
4
+ model,
5
+ metrics: Object.fromEntries(METRIC_KEYS.map((key) => [key, { value: 0, estimated: false, present: false }])),
6
+ costUSD: 0,
7
+ unpricedRecords: 0,
8
+ records: 0,
9
+ });
10
+ const addMetric = (sum, metric) => {
11
+ if (!metric) {
12
+ return;
13
+ }
14
+ sum.value += metric.value;
15
+ sum.estimated = sum.estimated || metric.precision === "estimated";
16
+ sum.present = true;
17
+ };
18
+ const buildGroups = (records) => {
19
+ const groups = new Map();
20
+ records.forEach((record) => {
21
+ const key = `${record.process}|${record.provider}:${record.model}`;
22
+ const group = groups.get(key) ?? emptyGroup(record.process, `${record.provider}:${record.model}`);
23
+ METRIC_KEYS.forEach((metricKey) => addMetric(group.metrics[metricKey], record[metricKey]));
24
+ group.costUSD += record.costUSD ?? 0;
25
+ group.unpricedRecords += record.costUSD === undefined ? 1 : 0;
26
+ group.records += 1;
27
+ groups.set(key, group);
28
+ });
29
+ return [...groups.values()];
30
+ };
31
+ const metricCell = (sum) => {
32
+ if (!sum.present) {
33
+ return "";
34
+ }
35
+ const marker = sum.estimated ? "~" : "";
36
+ return `${marker}${Math.round(sum.value * 100) / 100}`;
37
+ };
38
+ const groupRow = (group) => {
39
+ const cells = [group.process, group.model, ...METRIC_KEYS.map((key) => metricCell(group.metrics[key])), group.costUSD > 0 ? group.costUSD.toFixed(4) : ""];
40
+ return `| ${cells.join(" | ")} |`;
41
+ };
42
+ const totalLine = (groups, records) => {
43
+ const totalCost = groups.reduce((sum, group) => sum + group.costUSD, 0);
44
+ const unpriced = groups.reduce((sum, group) => sum + group.unpricedRecords, 0);
45
+ const asOf = [...new Set(records.map((record) => record.pricingAsOf).filter((date) => !!date))].sort()[0];
46
+ const parts = [`Total estimated cost: ≈ $${totalCost.toFixed(4)}`];
47
+ if (unpriced > 0) {
48
+ parts.push(`(${unpriced} record(s) without pricing data)`);
49
+ }
50
+ if (asOf) {
51
+ parts.push(`(prices as of ${asOf})`);
52
+ }
53
+ return parts.join(" ");
54
+ };
55
+ export const formatUsageEstimates = (records) => {
56
+ if (records.length === 0) {
57
+ return "No billable API usage estimated.";
58
+ }
59
+ const groups = buildGroups(records);
60
+ const header = "| process | provider:model | input tokens | output tokens | input chars | predict sec | images | cost (USD) |";
61
+ const separator = "|---|---|---|---|---|---|---|---|";
62
+ const note = "~ marks heuristic estimates; unmarked values are exact for the given script.";
63
+ return [header, separator, ...groups.map(groupRow), "", totalLine(groups, records), note].join("\n");
64
+ };
@@ -0,0 +1,28 @@
1
+ export declare const DEFAULT_FETCH_TIMEOUT_MS = 30000;
2
+ export declare const FETCH_DOWNLOAD_TIMEOUT_MS = 60000;
3
+ export declare const FETCH_MEDIA_DOWNLOAD_TIMEOUT_MS = 180000;
4
+ export declare const FETCH_API_TIMEOUT_MS = 120000;
5
+ export type SafeFetchResponse = {
6
+ ok: boolean;
7
+ status: number;
8
+ statusText: string;
9
+ headers: {
10
+ get(name: string): string | null;
11
+ };
12
+ arrayBuffer(): Promise<ArrayBuffer>;
13
+ text(): Promise<string>;
14
+ json<T = unknown>(): Promise<T>;
15
+ };
16
+ /**
17
+ * fetch() with an AbortController timeout that covers the whole exchange —
18
+ * headers AND body.
19
+ *
20
+ * A stalled connection otherwise yields a promise that never resolves or
21
+ * rejects; callers relying on GraphAI `retry` never recover because retry only
22
+ * fires on rejection. The body is read once under the same deadline (a server
23
+ * that sends headers and then stalls mid-body would otherwise hang the caller's
24
+ * body read), and the bytes are handed back through a lightweight result so the
25
+ * caller does not buffer them a second time. An optional caller `signal` is
26
+ * composed with the timeout so upstream cancellation still works.
27
+ */
28
+ export declare const safeFetch: (url: string | URL, init?: Parameters<typeof fetch>[1], timeoutMs?: number) => Promise<SafeFetchResponse>;
@@ -0,0 +1,52 @@
1
+ // Timeout budgets for the different kinds of network waits. Generous safety
2
+ // nets against a stalled socket — NOT tight deadlines — so legitimate slow
3
+ // responses still succeed while a genuine hang becomes a rejection.
4
+ export const DEFAULT_FETCH_TIMEOUT_MS = 30_000; // small assets (reference/background images, JSON)
5
+ export const FETCH_DOWNLOAD_TIMEOUT_MS = 60_000; // generated image download
6
+ export const FETCH_MEDIA_DOWNLOAD_TIMEOUT_MS = 180_000; // large video/audio download
7
+ export const FETCH_API_TIMEOUT_MS = 120_000; // generation API POST (e.g. TTS text -> audio)
8
+ /**
9
+ * fetch() with an AbortController timeout that covers the whole exchange —
10
+ * headers AND body.
11
+ *
12
+ * A stalled connection otherwise yields a promise that never resolves or
13
+ * rejects; callers relying on GraphAI `retry` never recover because retry only
14
+ * fires on rejection. The body is read once under the same deadline (a server
15
+ * that sends headers and then stalls mid-body would otherwise hang the caller's
16
+ * body read), and the bytes are handed back through a lightweight result so the
17
+ * caller does not buffer them a second time. An optional caller `signal` is
18
+ * composed with the timeout so upstream cancellation still works.
19
+ */
20
+ export const safeFetch = async (url, init = {}, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) => {
21
+ const controller = new AbortController();
22
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
23
+ const externalSignal = init?.signal ?? undefined;
24
+ const forwardAbort = () => controller.abort();
25
+ externalSignal?.addEventListener("abort", forwardAbort, { once: true });
26
+ if (externalSignal?.aborted)
27
+ controller.abort();
28
+ try {
29
+ const response = await fetch(url, { ...init, signal: controller.signal });
30
+ const body = await response.arrayBuffer();
31
+ const decode = () => new TextDecoder().decode(body);
32
+ return {
33
+ ok: response.ok,
34
+ status: response.status,
35
+ statusText: response.statusText,
36
+ headers: response.headers,
37
+ arrayBuffer: async () => body,
38
+ text: async () => decode(),
39
+ json: async () => JSON.parse(decode()),
40
+ };
41
+ }
42
+ catch (error) {
43
+ if (error instanceof Error && error.name === "AbortError") {
44
+ throw new Error(`Fetch timeout after ${timeoutMs}ms: ${url}`);
45
+ }
46
+ throw error;
47
+ }
48
+ finally {
49
+ clearTimeout(timeoutId);
50
+ externalSignal?.removeEventListener("abort", forwardAbort);
51
+ }
52
+ };
package/lib/utils/file.js CHANGED
@@ -8,6 +8,7 @@ import { MulmoStudioContextMethods } from "../methods/index.js";
8
8
  import { mulmoPromptTemplateSchema } from "../types/schema.js";
9
9
  import { getMulmoScriptTemplateSystemPrompt } from "./prompt.js";
10
10
  import { resolveAssetFile } from "./asset_import.js";
11
+ import { safeFetch } from "./fetch.js";
11
12
  const promptTemplateDirName = "./assets/templates";
12
13
  const scriptTemplateDirName = "./scripts/templates";
13
14
  const __filename = fileURLToPath(import.meta.url);
@@ -43,7 +44,7 @@ export function readMulmoScriptFile(arg2, errorMessage) {
43
44
  }
44
45
  export const fetchMulmoScriptFile = async (url) => {
45
46
  try {
46
- const res = await fetch(url);
47
+ const res = await safeFetch(url);
47
48
  if (!res.ok) {
48
49
  return { result: false, status: res.status };
49
50
  }
@@ -1,6 +1,6 @@
1
1
  import { MulmoMediaSourceMethods } from "../../methods/mulmo_media_source.js";
2
2
  import { resolveStyle } from "./utils.js";
3
- const DEFAULT_FETCH_TIMEOUT_MS = 30000;
3
+ import { safeFetch, DEFAULT_FETCH_TIMEOUT_MS } from "../fetch.js";
4
4
  /**
5
5
  * Resolve background image from beat level and global level settings.
6
6
  * Beat level takes precedence over global level.
@@ -25,26 +25,13 @@ export const resolveBackgroundImage = (beatBackgroundImage, globalBackgroundImag
25
25
  * Fetch URL and convert to data URL with timeout
26
26
  */
27
27
  const fetchUrlAsDataUrl = async (url, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) => {
28
- const controller = new AbortController();
29
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
30
- try {
31
- const response = await fetch(url, { signal: controller.signal });
32
- if (!response.ok) {
33
- throw new Error(`Failed to fetch background image: ${url} (${response.status})`);
34
- }
35
- const buffer = Buffer.from(await response.arrayBuffer());
36
- const contentType = response.headers.get("content-type") || "image/png";
37
- return `data:${contentType};base64,${buffer.toString("base64")}`;
38
- }
39
- catch (error) {
40
- if (error instanceof Error && error.name === "AbortError") {
41
- throw new Error(`Fetch timeout for background image: ${url}`);
42
- }
43
- throw error;
44
- }
45
- finally {
46
- clearTimeout(timeoutId);
28
+ const response = await safeFetch(url, {}, timeoutMs);
29
+ if (!response.ok) {
30
+ throw new Error(`Failed to fetch background image: ${url} (${response.status})`);
47
31
  }
32
+ const buffer = Buffer.from(await response.arrayBuffer());
33
+ const contentType = response.headers.get("content-type") || "image/png";
34
+ return `data:${contentType};base64,${buffer.toString("base64")}`;
48
35
  };
49
36
  /**
50
37
  * Convert BackgroundImage to CSS string
@@ -1,4 +1,8 @@
1
1
  import OpenAI, { AzureOpenAI } from "openai";
2
+ // Explicit per-request timeout so a stalled connection rejects (and the SDK's
3
+ // default maxRetries=2 re-issues it) instead of hanging on the SDK's 10-minute
4
+ // default. gpt-image-1 generation is typically well under this.
5
+ const OPENAI_REQUEST_TIMEOUT_MS = 120_000;
2
6
  /**
3
7
  * Detects if the given URL is an Azure OpenAI endpoint
4
8
  * Safely parses the URL and checks if the hostname ends with ".openai.azure.com"
@@ -26,10 +30,12 @@ export const createOpenAIClient = (options) => {
26
30
  apiKey,
27
31
  endpoint: baseURL,
28
32
  apiVersion: apiVersion ?? "2025-04-01-preview",
33
+ timeout: OPENAI_REQUEST_TIMEOUT_MS,
29
34
  });
30
35
  }
31
36
  return new OpenAI({
32
37
  apiKey,
33
38
  baseURL,
39
+ timeout: OPENAI_REQUEST_TIMEOUT_MS,
34
40
  });
35
41
  };
@@ -1,5 +1,8 @@
1
1
  import type Replicate from "replicate";
2
- export declare const runReplicateWithMetrics: (replicate: Replicate, identifier: `${string}/${string}` | `${string}/${string}:${string}`, input: object, signal?: AbortSignal) => Promise<{
2
+ export declare const runReplicateWithMetrics: (replicate: Replicate, identifier: `${string}/${string}` | `${string}/${string}:${string}`, input: object, options?: {
3
+ timeoutMs?: number;
4
+ signal?: AbortSignal;
5
+ }) => Promise<{
3
6
  output: unknown;
4
7
  predictSec?: number;
5
8
  }>;
@@ -1,11 +1,31 @@
1
+ // Generous safety net against a stalled run — video models (seedance/kling)
2
+ // legitimately run for minutes — not a tight deadline.
3
+ const REPLICATE_RUN_TIMEOUT_MS = 600_000;
1
4
  // Capture Replicate's exact billed seconds (`metrics.predict_time`) via the
2
5
  // progress callback on `replicate.run()`. Avoids switching the four
3
6
  // replicate agents from `.run()` to `predictions.create()` + poll, which
4
7
  // would be a much bigger blast radius.
5
- export const runReplicateWithMetrics = async (replicate, identifier, input, signal) => {
8
+ //
9
+ // A timeout aborts the run so a stalled job rejects (and the caller's GraphAI
10
+ // `retry` can recover) instead of hanging forever. An optional caller `signal`
11
+ // is composed with the timeout so upstream cancellation still works.
12
+ export const runReplicateWithMetrics = async (replicate, identifier, input, options = {}) => {
13
+ const { timeoutMs = REPLICATE_RUN_TIMEOUT_MS, signal: externalSignal } = options;
14
+ const controller = new AbortController();
15
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
16
+ const forwardAbort = () => controller.abort();
17
+ externalSignal?.addEventListener("abort", forwardAbort, { once: true });
18
+ if (externalSignal?.aborted)
19
+ controller.abort();
6
20
  let lastPrediction;
7
- const output = await replicate.run(identifier, { input, signal }, (prediction) => {
8
- lastPrediction = prediction;
9
- });
10
- return { output, predictSec: lastPrediction?.metrics?.predict_time };
21
+ try {
22
+ const output = await replicate.run(identifier, { input, signal: controller.signal }, (prediction) => {
23
+ lastPrediction = prediction;
24
+ });
25
+ return { output, predictSec: lastPrediction?.metrics?.predict_time };
26
+ }
27
+ finally {
28
+ clearTimeout(timeoutId);
29
+ externalSignal?.removeEventListener("abort", forwardAbort);
30
+ }
11
31
  };
@@ -0,0 +1,3 @@
1
+ export declare const GENAI_REQUEST_TIMEOUT_MS = 120000;
2
+ export declare const REPLICATE_RUN_TIMEOUT_MS = 600000;
3
+ export declare const VIDEO_POLL_TIMEOUT_MS = 1200000;
@@ -0,0 +1,7 @@
1
+ // Timeout budgets for provider SDK calls (OpenAI has its own in openai_client.ts).
2
+ // Generous safety nets against a stalled connection or a never-completing
3
+ // operation — NOT tight deadlines. A stall becomes a rejection so the caller's
4
+ // GraphAI `retry` can recover instead of hanging forever.
5
+ export const GENAI_REQUEST_TIMEOUT_MS = 120_000; // per @google/genai API call (kick-off / each poll)
6
+ export const REPLICATE_RUN_TIMEOUT_MS = 600_000; // whole replicate.run() job (video models run minutes)
7
+ export const VIDEO_POLL_TIMEOUT_MS = 1_200_000; // wall-clock cap for a long-running Veo video operation
package/lib/utils/zip.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import fs from "fs";
2
- import archiver from "archiver";
2
+ import { ZipArchive } from "archiver";
3
3
  import path from "path";
4
4
  /**
5
5
  * Promise-based ZIP archive builder
@@ -18,7 +18,7 @@ export class ZipBuilder {
18
18
  constructor(outputPath) {
19
19
  this.outputPath = outputPath;
20
20
  this.output = fs.createWriteStream(outputPath);
21
- this.archive = archiver("zip", {
21
+ this.archive = new ZipArchive({
22
22
  zlib: { level: 9 }, // Maximum compression level
23
23
  });
24
24
  // Pipe archive data to the file