mulmocast 2.6.20 → 2.6.22

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 (53) hide show
  1. package/README.md +36 -0
  2. package/lib/actions/audio.js +3 -0
  3. package/lib/actions/image_references.js +3 -0
  4. package/lib/actions/images.js +3 -0
  5. package/lib/actions/translate.d.ts +2 -0
  6. package/lib/actions/translate.js +5 -0
  7. package/lib/agents/image_genai_agent.js +12 -3
  8. package/lib/agents/image_openai_agent.js +20 -5
  9. package/lib/agents/image_replicate_agent.js +9 -3
  10. package/lib/agents/lipsync_replicate_agent.js +10 -5
  11. package/lib/agents/movie_genai_agent.js +22 -4
  12. package/lib/agents/movie_replicate_agent.js +16 -5
  13. package/lib/agents/sound_effect_replicate_agent.js +4 -4
  14. package/lib/agents/tts_elevenlabs_agent.js +12 -1
  15. package/lib/agents/tts_gemini_agent.js +59 -32
  16. package/lib/agents/tts_google_agent.js +9 -1
  17. package/lib/agents/tts_kotodama_agent.js +9 -1
  18. package/lib/agents/tts_openai_agent.js +9 -1
  19. package/lib/cli/commands/audio/handler.js +2 -1
  20. package/lib/cli/commands/bundle/handler.js +2 -1
  21. package/lib/cli/commands/html/handler.js +2 -1
  22. package/lib/cli/commands/image/handler.js +2 -1
  23. package/lib/cli/commands/markdown/handler.js +2 -1
  24. package/lib/cli/commands/movie/handler.js +2 -1
  25. package/lib/cli/commands/pdf/handler.js +2 -1
  26. package/lib/cli/commands/tool/whisper/handler.js +60 -37
  27. package/lib/cli/commands/translate/handler.js +2 -1
  28. package/lib/cli/helpers.d.ts +1 -0
  29. package/lib/cli/helpers.js +46 -0
  30. package/lib/tools/create_mulmo_script_from_url.d.ts +2 -2
  31. package/lib/tools/create_mulmo_script_from_url.js +5 -2
  32. package/lib/tools/create_mulmo_script_interactively.d.ts +1 -1
  33. package/lib/tools/create_mulmo_script_interactively.js +6 -3
  34. package/lib/tools/deep_research.d.ts +2 -1
  35. package/lib/tools/deep_research.js +3 -1
  36. package/lib/tools/story_to_script.d.ts +3 -1
  37. package/lib/tools/story_to_script.js +3 -1
  38. package/lib/types/agent.d.ts +2 -0
  39. package/lib/types/index.d.ts +1 -0
  40. package/lib/types/index.js +1 -0
  41. package/lib/types/type.d.ts +3 -0
  42. package/lib/types/usage.d.ts +32 -0
  43. package/lib/types/usage.js +1 -0
  44. package/lib/utils/context.d.ts +2 -0
  45. package/lib/utils/context.js +2 -0
  46. package/lib/utils/filters.js +3 -6
  47. package/lib/utils/replicate_usage.d.ts +5 -0
  48. package/lib/utils/replicate_usage.js +11 -0
  49. package/lib/utils/usage_callback.d.ts +4 -0
  50. package/lib/utils/usage_callback.js +93 -0
  51. package/lib/utils/usage_collector.d.ts +12 -0
  52. package/lib/utils/usage_collector.js +18 -0
  53. package/package.json +6 -6
package/README.md CHANGED
@@ -137,6 +137,14 @@ ELEVENLABS_API_KEY=your_elevenlabs_api_key
137
137
  BROWSERLESS_API_TOKEN=your_browserless_api_token # to access web in mulmo tool
138
138
  ```
139
139
 
140
+ #### (Optional) Local usage tracking
141
+ ```bash
142
+ MULMOCAST_DUMP_USAGE=1 # print JSON usage dump to stdout after each CLI action
143
+ MULMOCAST_DUMP_USAGE=/tmp/usage.json # ...or write it to a file
144
+ ```
145
+
146
+ Token / per-second / per-char usage per provider:model. See [docs/api.md](./docs/api.md#usage-tracking) for the full reference.
147
+
140
148
  ### Google Vertex AI
141
149
 
142
150
  For enterprise/production environments, or to use Veo movie models that are only available on Vertex AI, use Vertex AI with Application Default Credentials (ADC):
@@ -383,6 +391,34 @@ mulmo movie script.json
383
391
  mulmo translate script.json
384
392
  ```
385
393
 
394
+ ### Usage tracking (token / character / predict-second per provider:model)
395
+
396
+ Any of the commands above can dump a JSON breakdown of upstream AI consumption when `MULMOCAST_DUMP_USAGE` is set:
397
+
398
+ ```bash
399
+ # print JSON to stdout (pipe-friendly: `... | jq .`)
400
+ MULMOCAST_DUMP_USAGE=1 mulmo audio script.json
401
+
402
+ # ...or write it to a file
403
+ MULMOCAST_DUMP_USAGE=/tmp/usage.json mulmo movie script.json
404
+ ```
405
+
406
+ Payload shape:
407
+
408
+ ```json
409
+ {
410
+ "records": 3,
411
+ "byModel": [
412
+ { "provider": "openai", "model": "gpt-4o-mini-tts", "records": 1, "inputChars": 20 },
413
+ { "provider": "gemini", "model": "gemini-2.5-flash-preview-tts", "records": 1, "inputTokens": 9, "outputTokens": 59, "totalTokens": 68 },
414
+ { "provider": "elevenlabs", "model": "eleven_multilingual_v2", "records": 1, "inputChars": 18 }
415
+ ],
416
+ "snapshot": [ /* per-call UsageRecord[] */ ]
417
+ }
418
+ ```
419
+
420
+ `byModel` groups by `provider:model` (different models have different rate cards, so summing across them isn't meaningful). The full per-call snapshot is also included so a billing layer can apply its own grouping. See [docs/api.md § Usage tracking](./docs/api.md#usage-tracking) for the per-agent field-population matrix and the programmatic (library) API.
421
+
386
422
  ## Cache and Re-run
387
423
  When running the same `mulmo` command multiple times, previously generated files are treated as cache. For example, audio or image files will not be regenerated if they already exist.
388
424
 
@@ -4,6 +4,7 @@ import * as agents from "@graphai/vanilla";
4
4
  import { fileWriteAgent } from "@graphai/vanilla_node_agents";
5
5
  import { ttsOpenaiAgent, ttsGoogleAgent, ttsGeminiAgent, ttsElevenlabsAgent, ttsKotodamaAgent, addBGMAgent, combineAudioFilesAgent, mediaMockAgent, } from "../agents/index.js";
6
6
  import { audioGraphOption } from "./graph_option.js";
7
+ import { createUsageCallback } from "../utils/usage_callback.js";
7
8
  import { getAudioArtifactFilePath, getAudioFilePath, getGroupedAudioFilePath, getOutputStudioFilePath, resolveDirPath, defaultBGMPath, mkdir, writingMessage, } from "../utils/file.js";
8
9
  import { localizedText } from "../utils/utils.js";
9
10
  import { text2hash } from "../utils/utils_node.js";
@@ -232,6 +233,7 @@ export const generateBeatAudio = async (index, context, args) => {
232
233
  mkdir(audioSegmentDirPath);
233
234
  const graph = new GraphAI(langs ? graph_tts_map : graph_tts, audioAgents, await audioGraphOption(context, settings));
234
235
  callbacks?.forEach((callback) => graph.registerCallback(callback));
236
+ graph.registerCallback(createUsageCallback(context));
235
237
  graph.injectValue("__mapIndex", index);
236
238
  graph.injectValue("beat", context.studio.script.beats[index]);
237
239
  graph.injectValue("studioBeat", context.studio.beats[index]);
@@ -268,6 +270,7 @@ export const audio = async (context, args) => {
268
270
  mkdir(audioSegmentDirPath);
269
271
  const graph = new GraphAI(audio_graph_data, audioAgents, await audioGraphOption(context, settings));
270
272
  callbacks?.forEach((callback) => graph.registerCallback(callback));
273
+ graph.registerCallback(createUsageCallback(context));
271
274
  graph.injectValue("context", context);
272
275
  graph.injectValue("audioArtifactFilePath", audioArtifactFilePath);
273
276
  graph.injectValue("audioCombinedFilePath", audioCombinedFilePath);
@@ -1,6 +1,7 @@
1
1
  import { GraphAI, GraphAILogger } from "graphai";
2
2
  import { getReferenceImagePath } from "../utils/file.js";
3
3
  import { imageGraphOption } from "./graph_option.js";
4
+ import { createUsageCallback } from "../utils/usage_callback.js";
4
5
  import { MulmoPresentationStyleMethods, MulmoMediaSourceMethods } from "../methods/index.js";
5
6
  import { imageOpenaiAgent, mediaMockAgent, imageGenAIAgent, imageReplicateAgent, movieGenAIAgent, movieReplicateAgent } from "../agents/index.js";
6
7
  import { agentGenerationError, imageReferenceAction, imageFileTarget, movieFileTarget } from "../utils/error_cause.js";
@@ -43,6 +44,7 @@ export const generateReferenceImage = async (inputs) => {
43
44
  try {
44
45
  const options = await imageGraphOption(context);
45
46
  const graph = new GraphAI(image_graph_data, { imageGenAIAgent, imageOpenaiAgent, mediaMockAgent, imageReplicateAgent }, options);
47
+ graph.registerCallback(createUsageCallback(context));
46
48
  await graph.run();
47
49
  return imagePath;
48
50
  }
@@ -129,6 +131,7 @@ const generateReferenceMovie = async (inputs) => {
129
131
  try {
130
132
  const options = await imageGraphOption(context);
131
133
  const graph = new GraphAI(movie_graph_data, { movieGenAIAgent, movieReplicateAgent, mediaMockAgent }, options);
134
+ graph.registerCallback(createUsageCallback(context));
132
135
  await graph.run();
133
136
  return moviePath;
134
137
  }
@@ -15,6 +15,7 @@ import { extractImageFromMovie, ffmpegGetMediaDuration, trimMusic } from "../uti
15
15
  import { getMediaRefs, resolveBeatLocalRefs } from "./image_references.js";
16
16
  import { imagePreprocessAgent, imagePluginAgent, htmlImageGeneratorAgent } from "./image_agents.js";
17
17
  import { imageGraphOption } from "./graph_option.js";
18
+ import { createUsageCallback } from "../utils/usage_callback.js";
18
19
  const vanillaAgents = vanilla.default ?? vanilla;
19
20
  const imageAgents = {
20
21
  imageGenAIAgent,
@@ -468,6 +469,7 @@ const generateImages = async (context, args) => {
468
469
  graph.registerCallback(callback);
469
470
  });
470
471
  }
472
+ graph.registerCallback(createUsageCallback(context));
471
473
  const res = await graph.run();
472
474
  return res.mergeResult;
473
475
  };
@@ -519,6 +521,7 @@ export const generateBeatImage = async (inputs) => {
519
521
  graph.registerCallback(callback);
520
522
  });
521
523
  }
524
+ graph.registerCallback(createUsageCallback(context));
522
525
  await graph.run();
523
526
  }
524
527
  catch (error) {
@@ -19,6 +19,8 @@ export declare const translateTextGraph: {
19
19
  };
20
20
  output: {
21
21
  text: string;
22
+ usage: string;
23
+ model: string;
22
24
  };
23
25
  agent: string;
24
26
  };
@@ -5,6 +5,7 @@ import * as agents from "@graphai/vanilla";
5
5
  import { openAIAgent } from "@graphai/openai_agent";
6
6
  import { fileWriteAgent } from "@graphai/vanilla_node_agents";
7
7
  import { splitText } from "../utils/string.js";
8
+ import { createUsageCallback } from "../utils/usage_callback.js";
8
9
  import { settings2GraphAIConfig, beatId, multiLingualObjectToArray } from "../utils/utils.js";
9
10
  import { getMultiLingual } from "../utils/context.js";
10
11
  import { currentMulmoScriptVersion } from "../types/const.js";
@@ -36,6 +37,8 @@ export const translateTextGraph = {
36
37
  },
37
38
  output: {
38
39
  text: ".text",
40
+ usage: ".usage",
41
+ model: ".model",
39
42
  },
40
43
  // return { lang, text } <- localizedText
41
44
  agent: "openAIAgent",
@@ -253,6 +256,7 @@ export const translateBeat = async (index, context, targetLangs, args) => {
253
256
  graph.registerCallback(callback);
254
257
  });
255
258
  }
259
+ graph.registerCallback(createUsageCallback(context));
256
260
  const results = await graph.run();
257
261
  const multiLingual = getMultiLingual(outputMultilingualFilePath, context.studio.beats);
258
262
  const key = beatId(context.studio.script.beats[index]?.id, index);
@@ -294,6 +298,7 @@ export const translate = async (context, args) => {
294
298
  graph.registerCallback(callback);
295
299
  });
296
300
  }
301
+ graph.registerCallback(createUsageCallback(context));
297
302
  const results = await graph.run();
298
303
  writingMessage(outputMultilingualFilePath);
299
304
  if (results.mergeStudioResult) {
@@ -21,12 +21,21 @@ const getGeminiContents = (prompt, referenceImages) => {
21
21
  });
22
22
  return contents;
23
23
  };
24
- const geminiFlashResult = (response) => {
24
+ const geminiFlashResult = (response, model) => {
25
25
  if (!response.candidates?.[0]?.content?.parts) {
26
26
  throw new Error("ERROR: generateContent returned no candidates", {
27
27
  cause: agentInvalidResponseError("imageGenAIAgent", imageAction, imageFileTarget),
28
28
  });
29
29
  }
30
+ const usage = response.usageMetadata
31
+ ? {
32
+ provider: "google",
33
+ model,
34
+ inputTokens: response.usageMetadata.promptTokenCount,
35
+ outputTokens: response.usageMetadata.candidatesTokenCount,
36
+ totalTokens: response.usageMetadata.totalTokenCount,
37
+ }
38
+ : undefined;
30
39
  for (const part of response.candidates[0].content.parts) {
31
40
  if (part.text) {
32
41
  GraphAILogger.info("Gemini image generation response:", part.text);
@@ -39,7 +48,7 @@ const geminiFlashResult = (response) => {
39
48
  });
40
49
  }
41
50
  const buffer = Buffer.from(imageData, "base64");
42
- return { buffer };
51
+ return { buffer, usage };
43
52
  }
44
53
  }
45
54
  throw new Error("ERROR: generateContent returned no image data", {
@@ -102,7 +111,7 @@ export const imageGenAIAgent = async ({ namedInputs, params, config, }) => {
102
111
  })();
103
112
  const res = await resultify(() => ai.models.generateContent(contentParams));
104
113
  if (res.ok) {
105
- return geminiFlashResult(res.value);
114
+ return geminiFlashResult(res.value, model);
106
115
  }
107
116
  return errorProcess(res.error);
108
117
  }
@@ -95,17 +95,22 @@ export const imageOpenaiAgent = async ({ namedInputs, params, config, }) => {
95
95
  }
96
96
  if (error instanceof APIError) {
97
97
  if (error.code && error.type) {
98
- throw new Error("Failed to generate image with OpenAI", {
98
+ throw new Error(`Failed to generate image with OpenAI: ${error.message}`, {
99
99
  cause: openAIAgentGenerationError("imageOpenaiAgent", imageAction, error.code, error.type),
100
100
  });
101
101
  }
102
102
  if (error.type === "invalid_request_error" && error?.error?.message?.includes("Your organization must be verified")) {
103
- throw new Error("Failed to generate image with OpenAI", {
103
+ throw new Error(`Failed to generate image with OpenAI: ${error.message}`, {
104
104
  cause: openAIAgentGenerationError("imageOpenaiAgent", imageAction, "need_verified_organization", error.type),
105
105
  });
106
106
  }
107
107
  }
108
- throw new Error("Failed to generate image with OpenAI", {
108
+ // Catch-all: include the underlying message so unknown errors
109
+ // (OpenAI SDK exceptions w/o APIError shape, fetch resets) are
110
+ // diagnosable from the thrown error alone — not just the log
111
+ // line above. Same template as #1452 / #1453 / #1454 / #1455 / #1456.
112
+ const detail = error instanceof Error ? error.message : String(error);
113
+ throw new Error(`Failed to generate image with OpenAI: ${detail}`, {
109
114
  cause: agentGenerationError("imageOpenaiAgent", imageAction, imageFileTarget),
110
115
  });
111
116
  }
@@ -115,6 +120,16 @@ export const imageOpenaiAgent = async ({ namedInputs, params, config, }) => {
115
120
  cause: agentInvalidResponseError("imageOpenaiAgent", imageAction, imageFileTarget),
116
121
  });
117
122
  }
123
+ // gpt-image-1 surfaces token usage; legacy DALL·E response shapes don't.
124
+ const usage = response.usage
125
+ ? {
126
+ provider: "openai",
127
+ model,
128
+ inputTokens: response.usage.input_tokens,
129
+ outputTokens: response.usage.output_tokens,
130
+ totalTokens: response.usage.total_tokens,
131
+ }
132
+ : undefined;
118
133
  const url = response.data[0].url;
119
134
  if (!url) {
120
135
  // For gpt-image-1
@@ -124,7 +139,7 @@ export const imageOpenaiAgent = async ({ namedInputs, params, config, }) => {
124
139
  cause: agentInvalidResponseError("imageOpenaiAgent", imageAction, imageFileTarget),
125
140
  });
126
141
  }
127
- return { buffer: Buffer.from(image_base64, "base64") };
142
+ return { buffer: Buffer.from(image_base64, "base64"), usage };
128
143
  }
129
144
  // URL response handling (legacy OpenAI image API response format)
130
145
  const res = await fetch(url);
@@ -136,7 +151,7 @@ export const imageOpenaiAgent = async ({ namedInputs, params, config, }) => {
136
151
  // 2. Read the response as an ArrayBuffer
137
152
  const arrayBuffer = await res.arrayBuffer();
138
153
  // 3. Convert the ArrayBuffer to a Node.js Buffer and return it along with url
139
- return { buffer: Buffer.from(arrayBuffer) };
154
+ return { buffer: Buffer.from(arrayBuffer), usage };
140
155
  };
141
156
  const imageOpenaiAgentInfo = {
142
157
  name: "imageOpenaiAgent",
@@ -4,6 +4,7 @@ import Replicate from "replicate";
4
4
  import { getAspectRatio } from "./movie_replicate_agent.js";
5
5
  import { apiKeyMissingError, agentIncorrectAPIKeyError, agentGenerationError, agentInvalidResponseError, imageAction, imageFileTarget, hasCause, } from "../utils/error_cause.js";
6
6
  import { provider2ImageAgent } from "../types/provider2agent.js";
7
+ import { runReplicateWithMetrics } from "../utils/replicate_usage.js";
7
8
  // Replicate image models return one of: FileOutput (object with url() method),
8
9
  // Array<FileOutput>, string URL, or { url: string }. Normalize to URL string/URL.
9
10
  const extractImageUrl = (output) => {
@@ -44,7 +45,7 @@ export const imageReplicateAgent = async ({ namedInputs, params, config, }) => {
44
45
  });
45
46
  }
46
47
  try {
47
- const output = await replicate.run(model, { input });
48
+ const { output, predictSec } = await runReplicateWithMetrics(replicate, model, input);
48
49
  const imageUrl = extractImageUrl(output);
49
50
  if (!imageUrl) {
50
51
  throw new Error("ERROR: generateImage returned undefined", {
@@ -59,7 +60,8 @@ export const imageReplicateAgent = async ({ namedInputs, params, config, }) => {
59
60
  }
60
61
  const arrayBuffer = await imageResponse.arrayBuffer();
61
62
  const buffer = Buffer.from(arrayBuffer);
62
- return { buffer };
63
+ const usage = predictSec !== undefined ? { provider: "replicate", model, predictSec } : undefined;
64
+ return { buffer, usage };
63
65
  }
64
66
  catch (error) {
65
67
  GraphAILogger.info("Replicate generation error:", error);
@@ -74,7 +76,11 @@ export const imageReplicateAgent = async ({ namedInputs, params, config, }) => {
74
76
  });
75
77
  }
76
78
  }
77
- throw new Error("Failed to generate image with Replicate", {
79
+ // Include the underlying message so the catch-all path doesn't
80
+ // mask Replicate SDK / fetch / arrayBuffer failures behind a
81
+ // generic label (same fix as tts_gemini #1452, whisper #1453).
82
+ const detail = error instanceof Error ? error.message : String(error);
83
+ throw new Error(`Failed to generate image with Replicate: ${detail}`, {
78
84
  cause: agentGenerationError("imageReplicateAgent", imageAction, imageFileTarget),
79
85
  });
80
86
  }
@@ -3,6 +3,7 @@ import { GraphAILogger } from "graphai";
3
3
  import Replicate from "replicate";
4
4
  import { provider2LipSyncAgent } from "../types/provider2agent.js";
5
5
  import { apiKeyMissingError, agentGenerationError, agentFileNotExistError, imageAction, movieFileTarget, audioFileTarget, hasCause, } from "../utils/error_cause.js";
6
+ import { runReplicateWithMetrics } from "../utils/replicate_usage.js";
6
7
  export const lipSyncReplicateAgent = async ({ namedInputs, params, config, }) => {
7
8
  const { movieFile, audioFile, imageFile } = namedInputs;
8
9
  const apiKey = config?.apiKey;
@@ -60,9 +61,7 @@ export const lipSyncReplicateAgent = async ({ namedInputs, params, config, }) =>
60
61
  }
61
62
  const model_identifier = provider2LipSyncAgent.replicate.modelParams[model]?.identifier ?? model;
62
63
  try {
63
- const output = await replicate.run(model_identifier, {
64
- input,
65
- });
64
+ const { output, predictSec } = await runReplicateWithMetrics(replicate, model_identifier, input);
66
65
  if (output && typeof output === "object" && "url" in output) {
67
66
  const videoUrl = output.url();
68
67
  const videoResponse = await fetch(videoUrl);
@@ -72,7 +71,8 @@ export const lipSyncReplicateAgent = async ({ namedInputs, params, config, }) =>
72
71
  });
73
72
  }
74
73
  const arrayBuffer = await videoResponse.arrayBuffer();
75
- return { buffer: Buffer.from(arrayBuffer) };
74
+ const usage = predictSec !== undefined ? { provider: "replicate", model, predictSec } : undefined;
75
+ return { buffer: Buffer.from(arrayBuffer), usage };
76
76
  }
77
77
  return undefined;
78
78
  }
@@ -81,7 +81,12 @@ export const lipSyncReplicateAgent = async ({ namedInputs, params, config, }) =>
81
81
  if (hasCause(error) && error.cause) {
82
82
  throw error;
83
83
  }
84
- throw new Error("Failed to lipSync with Replicate", {
84
+ // Include the underlying message so the catch-all path doesn't
85
+ // mask Replicate SDK / fetch / arrayBuffer failures behind a
86
+ // generic label (same fix as tts_gemini #1452, whisper #1453,
87
+ // image_replicate #1454).
88
+ const detail = error instanceof Error ? error.message : String(error);
89
+ throw new Error(`Failed to lipSync with Replicate: ${detail}`, {
85
90
  cause: agentGenerationError("lipSyncReplicateAgent", imageAction, movieFileTarget),
86
91
  });
87
92
  }
@@ -3,6 +3,7 @@ import { GraphAILogger, sleep } from "graphai";
3
3
  import { GoogleGenAI, PersonGeneration } from "@google/genai";
4
4
  import { apiKeyMissingError, agentGenerationError, agentInvalidResponseError, imageAction, movieFileTarget, videoDurationTarget, unsupportedModelTarget, hasCause, } from "../utils/error_cause.js";
5
5
  import { getAspectRatio } from "../utils/utils.js";
6
+ import { ffmpegGetMediaDuration } from "../utils/ffmpeg_utils.js";
6
7
  import { ASPECT_RATIOS } from "../types/const.js";
7
8
  import { getModelDuration, provider2MovieAgent, AUDIO_MODE_NEVER, AUDIO_MODE_ALWAYS } from "../types/provider2agent.js";
8
9
  const pollUntilDone = async (ai, operation) => {
@@ -35,7 +36,22 @@ const loadImageAsBase64 = (imagePath) => {
35
36
  mimeType: "image/png",
36
37
  };
37
38
  };
38
- const downloadVideo = async (ai, video, movieFile, isVertexAI) => {
39
+ // Veo bills per second of generated video. The SDK response carries no usage
40
+ // metadata (GenerateVideosResponse only has generatedVideos and RAI flags), so
41
+ // we ffprobe the downloaded file to get the actual duration. Falls back to
42
+ // undefined if ffprobe fails, which lets the billing layer use the requested
43
+ // duration as a fallback.
44
+ const probeDurationSec = async (movieFile) => {
45
+ try {
46
+ const { duration } = await ffmpegGetMediaDuration(movieFile);
47
+ return duration > 0 ? duration : undefined;
48
+ }
49
+ catch (e) {
50
+ GraphAILogger.warn("movieGenAIAgent: ffprobe failed, predictSec will be omitted", e);
51
+ return undefined;
52
+ }
53
+ };
54
+ const downloadVideo = async (ai, video, movieFile, isVertexAI, model) => {
39
55
  if (isVertexAI) {
40
56
  // Vertex AI returns videoBytes directly
41
57
  writeFileSync(movieFile, Buffer.from(video.videoBytes, "base64"));
@@ -48,7 +64,9 @@ const downloadVideo = async (ai, video, movieFile, isVertexAI) => {
48
64
  });
49
65
  await sleep(5000); // HACK: Without this, the file is not ready yet.
50
66
  }
51
- return { saved: movieFile };
67
+ const predictSec = await probeDurationSec(movieFile);
68
+ const usage = predictSec !== undefined ? { provider: "google", model, predictSec } : undefined;
69
+ return { saved: movieFile, usage };
52
70
  };
53
71
  const createVeo31Payload = (model, prompt, aspectRatio, source) => ({
54
72
  model,
@@ -92,7 +110,7 @@ const generateExtendedVideo = async (ai, model, prompt, aspectRatio, imagePath,
92
110
  cause: agentInvalidResponseError("movieGenAIAgent", imageAction, movieFileTarget),
93
111
  });
94
112
  }
95
- return downloadVideo(ai, result.video, movieFile, isVertexAI);
113
+ return downloadVideo(ai, result.video, movieFile, isVertexAI, model);
96
114
  };
97
115
  const generateStandardVideo = async (ai, model, prompt, aspectRatio, imagePath, lastFrameImagePath, referenceImages, duration, movieFile, isVertexAI) => {
98
116
  const capabilities = provider2MovieAgent.google.modelParams[model];
@@ -139,7 +157,7 @@ const generateStandardVideo = async (ai, model, prompt, aspectRatio, imagePath,
139
157
  const operation = await ai.models.generateVideos(payload);
140
158
  const response = await pollUntilDone(ai, operation);
141
159
  const video = getVideoFromResponse(response);
142
- return downloadVideo(ai, video, movieFile, isVertexAI);
160
+ return downloadVideo(ai, video, movieFile, isVertexAI, model);
143
161
  };
144
162
  export const movieGenAIAgent = async ({ namedInputs, params, config, }) => {
145
163
  const { prompt, imagePath, lastFrameImagePath, referenceImages, movieFile } = namedInputs;
@@ -3,6 +3,7 @@ import { GraphAILogger } from "graphai";
3
3
  import Replicate from "replicate";
4
4
  import { apiKeyMissingError, agentGenerationError, agentInvalidResponseError, hasCause, imageAction, movieFileTarget, videoDurationTarget, unsupportedModelTarget, } from "../utils/error_cause.js";
5
5
  import { provider2MovieAgent, getModelDuration, AUDIO_MODE_OPTIONAL, AUDIO_MODE_NEVER, AUDIO_MODE_ALWAYS } from "../types/provider2agent.js";
6
+ import { runReplicateWithMetrics } from "../utils/replicate_usage.js";
6
7
  function replicate_get_videoUrl(output) {
7
8
  if (typeof output === "string")
8
9
  return output;
@@ -93,7 +94,7 @@ async function generateMovie(model, apiKey, prompt, imagePath, lastFrameImagePat
93
94
  }
94
95
  }
95
96
  try {
96
- const output = await replicate.run(model, { input });
97
+ const { output, predictSec } = await runReplicateWithMetrics(replicate, model, input);
97
98
  // Download the generated video
98
99
  // Some models return a FileOutput object with a url() method; others return a plain string URL.
99
100
  const videoUrl = replicate_get_videoUrl(output);
@@ -105,7 +106,7 @@ async function generateMovie(model, apiKey, prompt, imagePath, lastFrameImagePat
105
106
  });
106
107
  }
107
108
  const arrayBuffer = await videoResponse.arrayBuffer();
108
- return Buffer.from(arrayBuffer);
109
+ return { buffer: Buffer.from(arrayBuffer), predictSec };
109
110
  }
110
111
  return undefined;
111
112
  }
@@ -154,9 +155,10 @@ export const movieReplicateAgent = async ({ namedInputs, params, config, }) => {
154
155
  });
155
156
  }
156
157
  try {
157
- const buffer = await generateMovie(model, apiKey, prompt, imagePath, lastFrameImagePath, referenceImages, aspectRatio, duration, params.generateAudio);
158
- if (buffer) {
159
- return { buffer };
158
+ const result = await generateMovie(model, apiKey, prompt, imagePath, lastFrameImagePath, referenceImages, aspectRatio, duration, params.generateAudio);
159
+ if (result) {
160
+ const usage = result.predictSec !== undefined ? { provider: "replicate", model, predictSec: result.predictSec } : undefined;
161
+ return { buffer: result.buffer, usage };
160
162
  }
161
163
  }
162
164
  catch (error) {
@@ -164,6 +166,15 @@ export const movieReplicateAgent = async ({ namedInputs, params, config, }) => {
164
166
  throw error;
165
167
  }
166
168
  GraphAILogger.info("Failed to generate movie:", error.message);
169
+ // Throw a properly-labeled error with the underlying message
170
+ // preserved rather than falling through to the "returned
171
+ // undefined" line below — that message is only meaningful when
172
+ // `result` actually IS undefined, not when generation threw.
173
+ // (Same template as #1452 / #1453 / #1454 / #1455.)
174
+ const detail = error instanceof Error ? error.message : String(error);
175
+ throw new Error(`Failed to generate movie with Replicate: ${detail}`, {
176
+ cause: agentGenerationError("movieReplicateAgent", imageAction, movieFileTarget),
177
+ });
167
178
  }
168
179
  throw new Error("ERROR: generateMovie returned undefined", {
169
180
  cause: agentInvalidResponseError("movieReplicateAgent", imageAction, movieFileTarget),
@@ -3,6 +3,7 @@ import { GraphAILogger } from "graphai";
3
3
  import Replicate from "replicate";
4
4
  import { provider2SoundEffectAgent } from "../types/provider2agent.js";
5
5
  import { apiKeyMissingError, agentGenerationError, imageAction, movieFileTarget, hasCause } from "../utils/error_cause.js";
6
+ import { runReplicateWithMetrics } from "../utils/replicate_usage.js";
6
7
  export const soundEffectReplicateAgent = async ({ namedInputs, params, config }) => {
7
8
  const { prompt, movieFile } = namedInputs;
8
9
  const apiKey = config?.apiKey;
@@ -28,9 +29,7 @@ export const soundEffectReplicateAgent = async ({ namedInputs, params, config })
28
29
  };
29
30
  try {
30
31
  const model_identifier = provider2SoundEffectAgent.replicate.modelParams[model]?.identifier ?? model;
31
- const output = await replicate.run(model_identifier, {
32
- input,
33
- });
32
+ const { output, predictSec } = await runReplicateWithMetrics(replicate, model_identifier, input);
34
33
  if (output && typeof output === "object" && "url" in output) {
35
34
  const videoUrl = output.url();
36
35
  const videoResponse = await fetch(videoUrl);
@@ -40,7 +39,8 @@ export const soundEffectReplicateAgent = async ({ namedInputs, params, config })
40
39
  });
41
40
  }
42
41
  const arrayBuffer = await videoResponse.arrayBuffer();
43
- return { buffer: Buffer.from(arrayBuffer) };
42
+ const usage = predictSec !== undefined ? { provider: "replicate", model, predictSec } : undefined;
43
+ return { buffer: Buffer.from(arrayBuffer), usage };
44
44
  }
45
45
  return undefined;
46
46
  }
@@ -68,9 +68,20 @@ export const ttsElevenlabsAgent = async ({ namedInputs, params, config, }) => {
68
68
  cause: agentGenerationError("ttsElevenlabsAgent", audioAction, audioFileTarget),
69
69
  });
70
70
  }
71
+ // ElevenLabs returns the exact billed character count in the `character-cost`
72
+ // header (already discounted for V2 Flash/Turbo etc. — see #1420 research).
73
+ // We read it BEFORE consuming the body so the header is still accessible.
74
+ const billedHeader = response.headers.get("character-cost");
75
+ const billedChars = billedHeader ? Number(billedHeader) : undefined;
71
76
  const arrayBuffer = await response.arrayBuffer();
72
77
  const buffer = Buffer.from(arrayBuffer);
73
- return { buffer };
78
+ const usage = {
79
+ provider: "elevenlabs",
80
+ model: requestBody.model_id,
81
+ // Use upstream-billed amount when available (exact); fall back to raw text length.
82
+ inputChars: Number.isFinite(billedChars) ? billedChars : text.length,
83
+ };
84
+ return { buffer, usage };
74
85
  };
75
86
  const ttsElevenlabsAgentInfo = {
76
87
  name: "ttsElevenlabsAgent",
@@ -19,45 +19,72 @@ export const ttsGeminiAgent = async ({ namedInputs, params, config, }) => {
19
19
  cause: apiKeyMissingError("ttsGeminiAgent", audioAction, "GEMINI_API_KEY"),
20
20
  });
21
21
  }
22
- try {
23
- const ai = new GoogleGenAI({ apiKey });
24
- const response = await ai.models.generateContent({
25
- model: model ?? provider2TTSAgent.gemini.defaultModel,
26
- contents: [{ parts: [{ text: getPrompt(text, instructions) }] }],
27
- config: {
28
- responseModalities: ["AUDIO"],
29
- speechConfig: {
30
- voiceConfig: {
31
- prebuiltVoiceConfig: { voiceName: voice ?? provider2TTSAgent.gemini.defaultVoice },
22
+ const geminiResult = await (async () => {
23
+ try {
24
+ const ai = new GoogleGenAI({ apiKey });
25
+ const response = await ai.models.generateContent({
26
+ model: model ?? provider2TTSAgent.gemini.defaultModel,
27
+ contents: [{ parts: [{ text: getPrompt(text, instructions) }] }],
28
+ config: {
29
+ responseModalities: ["AUDIO"],
30
+ speechConfig: {
31
+ voiceConfig: {
32
+ prebuiltVoiceConfig: { voiceName: voice ?? provider2TTSAgent.gemini.defaultVoice },
33
+ },
32
34
  },
33
35
  },
34
- },
35
- });
36
- const inlineData = response.candidates?.[0]?.content?.parts?.[0]?.inlineData;
37
- const pcmBase64 = inlineData?.data;
38
- const mimeType = inlineData?.mimeType;
39
- if (!pcmBase64 || typeof pcmBase64 !== "string")
40
- throw new Error("No audio data returned");
41
- // Extract sample rate from mimeType (e.g., "audio/L16;codec=pcm;rate=24000")
42
- const rateMatch = mimeType?.match(/rate=(\d+)/);
43
- const sampleRate = rateMatch ? parseInt(rateMatch[1]) : 24000;
44
- const rawPcm = Buffer.from(pcmBase64, "base64");
45
- return { buffer: await pcmToMp3(rawPcm, sampleRate) };
36
+ });
37
+ const inlineData = response.candidates?.[0]?.content?.parts?.[0]?.inlineData;
38
+ const pcmBase64 = inlineData?.data;
39
+ const mimeType = inlineData?.mimeType;
40
+ if (!pcmBase64 || typeof pcmBase64 !== "string")
41
+ throw new Error("No audio data returned");
42
+ // Extract sample rate from mimeType (e.g., "audio/L16;codec=pcm;rate=24000")
43
+ const rateMatch = mimeType?.match(/rate=(\d+)/);
44
+ const sampleRate = rateMatch ? parseInt(rateMatch[1]) : 24000;
45
+ const rawPcm = Buffer.from(pcmBase64, "base64");
46
+ const usedModel = model ?? provider2TTSAgent.gemini.defaultModel;
47
+ const usage = response.usageMetadata
48
+ ? {
49
+ provider: "gemini",
50
+ model: usedModel,
51
+ inputTokens: response.usageMetadata.promptTokenCount,
52
+ outputTokens: response.usageMetadata.candidatesTokenCount,
53
+ totalTokens: response.usageMetadata.totalTokenCount,
54
+ }
55
+ : undefined;
56
+ return { rawPcm, sampleRate, usage };
57
+ }
58
+ catch (e) {
59
+ if (suppressError) {
60
+ return { error: e };
61
+ }
62
+ GraphAILogger.info(e);
63
+ const reasonDetail = getGenAIErrorReason(e);
64
+ if (reasonDetail && reasonDetail.reason && reasonDetail.reason === "API_KEY_INVALID") {
65
+ throw new Error("Failed to generate tts: 400 Incorrect API key provided with gemini", {
66
+ cause: agentIncorrectAPIKeyError("ttsGeminiAgent", audioAction, audioFileTarget),
67
+ });
68
+ }
69
+ const detail = e instanceof Error ? e.message : String(e);
70
+ throw new Error(`TTS Gemini Error: ${detail}`, {
71
+ cause: agentGenerationError("ttsGeminiAgent", audioAction, audioFileTarget),
72
+ });
73
+ }
74
+ })();
75
+ if ("error" in geminiResult) {
76
+ return geminiResult;
77
+ }
78
+ try {
79
+ return { buffer: await pcmToMp3(geminiResult.rawPcm, geminiResult.sampleRate), usage: geminiResult.usage };
46
80
  }
47
81
  catch (e) {
48
82
  if (suppressError) {
49
- return {
50
- error: e,
51
- };
83
+ return { error: e };
52
84
  }
53
85
  GraphAILogger.info(e);
54
- const reasonDetail = getGenAIErrorReason(e);
55
- if (reasonDetail && reasonDetail.reason && reasonDetail.reason === "API_KEY_INVALID") {
56
- throw new Error("Failed to generate tts: 400 Incorrect API key provided with gemini", {
57
- cause: agentIncorrectAPIKeyError("ttsGeminiAgent", audioAction, audioFileTarget),
58
- });
59
- }
60
- throw new Error("TTS Gemini Error", {
86
+ const detail = e instanceof Error ? e.message : String(e);
87
+ throw new Error(`Audio encoding (ffmpeg) failed: ${detail}`, {
61
88
  cause: agentGenerationError("ttsGeminiAgent", audioAction, audioFileTarget),
62
89
  });
63
90
  }
@@ -42,7 +42,15 @@ export const ttsGoogleAgent = async ({ namedInputs, params }) => {
42
42
  try {
43
43
  // Call the Text-to-Speech API
44
44
  const [response] = await client.synthesizeSpeech(request, { timeout: SYNTHESIZE_TIMEOUT_MS });
45
- return { buffer: response.audioContent };
45
+ // Google Cloud TTS bills per character; SynthesizeSpeechResponse exposes
46
+ // no usage info. Record inputChars so the billing layer can compute cost
47
+ // from `model` (voice tier) + char count.
48
+ const usage = {
49
+ provider: "google",
50
+ model: model ?? voice ?? "default",
51
+ inputChars: text.length,
52
+ };
53
+ return { buffer: response.audioContent, usage };
46
54
  }
47
55
  catch (e) {
48
56
  if (suppressError) {