mulmocast 2.6.20 → 2.6.21

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 (52) hide show
  1. package/README.md +8 -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 +12 -2
  9. package/lib/agents/image_replicate_agent.js +4 -2
  10. package/lib/agents/lipsync_replicate_agent.js +4 -4
  11. package/lib/agents/movie_genai_agent.js +22 -4
  12. package/lib/agents/movie_replicate_agent.js +7 -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 +11 -1
  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/translate/handler.js +2 -1
  27. package/lib/cli/helpers.d.ts +1 -0
  28. package/lib/cli/helpers.js +46 -0
  29. package/lib/tools/create_mulmo_script_from_url.d.ts +2 -2
  30. package/lib/tools/create_mulmo_script_from_url.js +5 -2
  31. package/lib/tools/create_mulmo_script_interactively.d.ts +1 -1
  32. package/lib/tools/create_mulmo_script_interactively.js +6 -3
  33. package/lib/tools/deep_research.d.ts +2 -1
  34. package/lib/tools/deep_research.js +3 -1
  35. package/lib/tools/story_to_script.d.ts +3 -1
  36. package/lib/tools/story_to_script.js +3 -1
  37. package/lib/types/agent.d.ts +2 -0
  38. package/lib/types/index.d.ts +1 -0
  39. package/lib/types/index.js +1 -0
  40. package/lib/types/type.d.ts +3 -0
  41. package/lib/types/usage.d.ts +32 -0
  42. package/lib/types/usage.js +1 -0
  43. package/lib/utils/context.d.ts +2 -0
  44. package/lib/utils/context.js +2 -0
  45. package/lib/utils/filters.js +3 -6
  46. package/lib/utils/replicate_usage.d.ts +5 -0
  47. package/lib/utils/replicate_usage.js +11 -0
  48. package/lib/utils/usage_callback.d.ts +4 -0
  49. package/lib/utils/usage_callback.js +93 -0
  50. package/lib/utils/usage_collector.d.ts +12 -0
  51. package/lib/utils/usage_collector.js +18 -0
  52. 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):
@@ -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
  }
@@ -115,6 +115,16 @@ export const imageOpenaiAgent = async ({ namedInputs, params, config, }) => {
115
115
  cause: agentInvalidResponseError("imageOpenaiAgent", imageAction, imageFileTarget),
116
116
  });
117
117
  }
118
+ // gpt-image-1 surfaces token usage; legacy DALL·E response shapes don't.
119
+ const usage = response.usage
120
+ ? {
121
+ provider: "openai",
122
+ model,
123
+ inputTokens: response.usage.input_tokens,
124
+ outputTokens: response.usage.output_tokens,
125
+ totalTokens: response.usage.total_tokens,
126
+ }
127
+ : undefined;
118
128
  const url = response.data[0].url;
119
129
  if (!url) {
120
130
  // For gpt-image-1
@@ -124,7 +134,7 @@ export const imageOpenaiAgent = async ({ namedInputs, params, config, }) => {
124
134
  cause: agentInvalidResponseError("imageOpenaiAgent", imageAction, imageFileTarget),
125
135
  });
126
136
  }
127
- return { buffer: Buffer.from(image_base64, "base64") };
137
+ return { buffer: Buffer.from(image_base64, "base64"), usage };
128
138
  }
129
139
  // URL response handling (legacy OpenAI image API response format)
130
140
  const res = await fetch(url);
@@ -136,7 +146,7 @@ export const imageOpenaiAgent = async ({ namedInputs, params, config, }) => {
136
146
  // 2. Read the response as an ArrayBuffer
137
147
  const arrayBuffer = await res.arrayBuffer();
138
148
  // 3. Convert the ArrayBuffer to a Node.js Buffer and return it along with url
139
- return { buffer: Buffer.from(arrayBuffer) };
149
+ return { buffer: Buffer.from(arrayBuffer), usage };
140
150
  };
141
151
  const imageOpenaiAgentInfo = {
142
152
  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);
@@ -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
  }
@@ -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) {
@@ -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",
@@ -42,7 +42,17 @@ export const ttsGeminiAgent = async ({ namedInputs, params, config, }) => {
42
42
  const rateMatch = mimeType?.match(/rate=(\d+)/);
43
43
  const sampleRate = rateMatch ? parseInt(rateMatch[1]) : 24000;
44
44
  const rawPcm = Buffer.from(pcmBase64, "base64");
45
- return { buffer: await pcmToMp3(rawPcm, sampleRate) };
45
+ const usedModel = model ?? provider2TTSAgent.gemini.defaultModel;
46
+ const usage = response.usageMetadata
47
+ ? {
48
+ provider: "gemini",
49
+ model: usedModel,
50
+ inputTokens: response.usageMetadata.promptTokenCount,
51
+ outputTokens: response.usageMetadata.candidatesTokenCount,
52
+ totalTokens: response.usageMetadata.totalTokenCount,
53
+ }
54
+ : undefined;
55
+ return { buffer: await pcmToMp3(rawPcm, sampleRate), usage };
46
56
  }
47
57
  catch (e) {
48
58
  if (suppressError) {
@@ -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) {
@@ -44,7 +44,15 @@ export const ttsKotodamaAgent = async ({ namedInputs, params, config, }) => {
44
44
  });
45
45
  }
46
46
  const buffer = Buffer.from(json.audios[0], "base64");
47
- return { buffer };
47
+ // Kotodama is subscription-billed externally (no public per-char rate API
48
+ // surface). Report inputChars + voice/decoration so the billing layer can
49
+ // do its own accounting.
50
+ const usage = {
51
+ provider: "kotodama",
52
+ model: body.speaker_id,
53
+ inputChars: text.length,
54
+ };
55
+ return { buffer, usage };
48
56
  }
49
57
  catch (error) {
50
58
  if (suppressError) {
@@ -28,7 +28,15 @@ export const ttsOpenaiAgent = async ({ namedInputs, params, config, }) => {
28
28
  GraphAILogger.log("ttsOptions", tts_options);
29
29
  const response = await openai.audio.speech.create(tts_options);
30
30
  const buffer = Buffer.from(await response.arrayBuffer());
31
- return { buffer };
31
+ // tts-1 / tts-1-hd bill per character. gpt-4o-mini-tts is token-based and
32
+ // is handled by #1428; for now we report inputChars uniformly and let the
33
+ // billing layer pick the right unit from `model`.
34
+ const usage = {
35
+ provider: "openai",
36
+ model: tts_options.model,
37
+ inputChars: text.length,
38
+ };
39
+ return { buffer, usage };
32
40
  }
33
41
  catch (error) {
34
42
  if (suppressError) {
@@ -1,5 +1,5 @@
1
1
  import { audio } from "../../../actions/index.js";
2
- import { initializeContext, runTranslateIfNeeded } from "../../helpers.js";
2
+ import { dumpUsageIfRequested, initializeContext, runTranslateIfNeeded } from "../../helpers.js";
3
3
  export const handler = async (argv) => {
4
4
  const context = await initializeContext(argv);
5
5
  if (!context) {
@@ -7,4 +7,5 @@ export const handler = async (argv) => {
7
7
  }
8
8
  await runTranslateIfNeeded(context);
9
9
  await audio(context);
10
+ dumpUsageIfRequested(context);
10
11
  };
@@ -1,5 +1,5 @@
1
1
  import { mulmoViewerBundle, audio, images, translate } from "../../../actions/index.js";
2
- import { initializeContext } from "../../helpers.js";
2
+ import { dumpUsageIfRequested, initializeContext } from "../../helpers.js";
3
3
  import { bundleTargetLang } from "../../../types/const.js";
4
4
  export const handler = async (argv) => {
5
5
  const context = await initializeContext(argv);
@@ -11,4 +11,5 @@ export const handler = async (argv) => {
11
11
  await audio({ ...context, lang });
12
12
  }
13
13
  await audio(context).then(images).then(mulmoViewerBundle);
14
+ dumpUsageIfRequested(context);
14
15
  };
@@ -1,5 +1,5 @@
1
1
  import { images, html } from "../../../actions/index.js";
2
- import { initializeContext, runTranslateIfNeeded } from "../../helpers.js";
2
+ import { dumpUsageIfRequested, initializeContext, runTranslateIfNeeded } from "../../helpers.js";
3
3
  export const handler = async (argv) => {
4
4
  const context = await initializeContext(argv);
5
5
  if (!context) {
@@ -8,4 +8,5 @@ export const handler = async (argv) => {
8
8
  await runTranslateIfNeeded(context);
9
9
  await images(context);
10
10
  await html(context, argv.image_width);
11
+ dumpUsageIfRequested(context);
11
12
  };
@@ -1,5 +1,5 @@
1
1
  import { images } from "../../../actions/index.js";
2
- import { initializeContext, runTranslateIfNeeded } from "../../helpers.js";
2
+ import { dumpUsageIfRequested, initializeContext, runTranslateIfNeeded } from "../../helpers.js";
3
3
  export const handler = async (argv) => {
4
4
  const context = await initializeContext(argv);
5
5
  if (!context) {
@@ -7,4 +7,5 @@ export const handler = async (argv) => {
7
7
  }
8
8
  await runTranslateIfNeeded(context);
9
9
  await images(context);
10
+ dumpUsageIfRequested(context);
10
11
  };
@@ -1,5 +1,5 @@
1
1
  import { images, markdown } from "../../../actions/index.js";
2
- import { initializeContext, runTranslateIfNeeded } from "../../helpers.js";
2
+ import { dumpUsageIfRequested, initializeContext, runTranslateIfNeeded } from "../../helpers.js";
3
3
  export const handler = async (argv) => {
4
4
  const context = await initializeContext(argv);
5
5
  if (!context) {
@@ -8,4 +8,5 @@ export const handler = async (argv) => {
8
8
  await runTranslateIfNeeded(context);
9
9
  await images(context);
10
10
  await markdown(context, argv.image_width);
11
+ dumpUsageIfRequested(context);
11
12
  };
@@ -1,5 +1,5 @@
1
1
  import { audio, images, movie, captions } from "../../../actions/index.js";
2
- import { initializeContext, runTranslateIfNeeded } from "../../helpers.js";
2
+ import { dumpUsageIfRequested, initializeContext, runTranslateIfNeeded } from "../../helpers.js";
3
3
  export const handler = async (argv) => {
4
4
  const context = await initializeContext(argv);
5
5
  if (!context) {
@@ -7,4 +7,5 @@ export const handler = async (argv) => {
7
7
  }
8
8
  await runTranslateIfNeeded(context, true);
9
9
  await audio(context).then(images).then(captions).then(movie);
10
+ dumpUsageIfRequested(context);
10
11
  };
@@ -1,5 +1,5 @@
1
1
  import { images, pdf } from "../../../actions/index.js";
2
- import { initializeContext, runTranslateIfNeeded } from "../../helpers.js";
2
+ import { dumpUsageIfRequested, initializeContext, runTranslateIfNeeded } from "../../helpers.js";
3
3
  export const handler = async (argv) => {
4
4
  const context = await initializeContext(argv);
5
5
  if (!context) {
@@ -8,4 +8,5 @@ export const handler = async (argv) => {
8
8
  await runTranslateIfNeeded(context);
9
9
  await images(context);
10
10
  await pdf(context, argv.pdf_mode, argv.pdf_size);
11
+ dumpUsageIfRequested(context);
11
12
  };
@@ -1,9 +1,10 @@
1
1
  import { translate } from "../../../actions/index.js";
2
- import { initializeContext } from "../../helpers.js";
2
+ import { dumpUsageIfRequested, initializeContext } from "../../helpers.js";
3
3
  export const handler = async (argv) => {
4
4
  const context = await initializeContext(argv);
5
5
  if (!context) {
6
6
  process.exit(1);
7
7
  }
8
8
  await translate(context);
9
+ dumpUsageIfRequested(context);
9
10
  };
@@ -1,6 +1,7 @@
1
1
  import type { CliArgs } from "../types/cli_types.js";
2
2
  import { FileObject, InitOptions, MulmoStudioContext } from "../types/index.js";
3
3
  export declare const runTranslateIfNeeded: (context: MulmoStudioContext, includeCaption?: boolean) => Promise<void>;
4
+ export declare const dumpUsageIfRequested: (context: MulmoStudioContext) => void;
4
5
  export declare const setGraphAILogger: (verbose: boolean | undefined, logValues?: Record<string, unknown>) => void;
5
6
  export declare const getFileObject: (args: {
6
7
  basedir?: string;
@@ -14,6 +14,52 @@ export const runTranslateIfNeeded = async (context, includeCaption = false) => {
14
14
  await translate(context);
15
15
  }
16
16
  };
17
+ const summarizeUsage = (context) => {
18
+ const snapshot = context.usageCollector?.snapshot() ?? [];
19
+ const groups = new Map();
20
+ for (const record of snapshot) {
21
+ const key = `${record.provider}:${record.model}`;
22
+ const group = groups.get(key) ?? {
23
+ provider: record.provider,
24
+ model: record.model,
25
+ records: 0,
26
+ inputTokens: 0,
27
+ outputTokens: 0,
28
+ totalTokens: 0,
29
+ predictSec: 0,
30
+ inputChars: 0,
31
+ };
32
+ group.records += 1;
33
+ group.inputTokens += record.inputTokens ?? 0;
34
+ group.outputTokens += record.outputTokens ?? 0;
35
+ group.totalTokens += record.totalTokens ?? 0;
36
+ group.predictSec += record.predictSec ?? 0;
37
+ group.inputChars += record.inputChars ?? 0;
38
+ groups.set(key, group);
39
+ }
40
+ return { records: snapshot.length, byModel: Array.from(groups.values()), snapshot };
41
+ };
42
+ // Opt-in usage dump after a CLI action completes. Controlled by
43
+ // MULMOCAST_DUMP_USAGE:
44
+ // - unset / empty → no-op (zero overhead)
45
+ // - "1" / "true" / "stdout" → print JSON to stdout via GraphAILogger.info
46
+ // - any other value → treated as a file path and the JSON is written there
47
+ //
48
+ // The API/billing layer reads context.usageCollector directly; this is for
49
+ // local CLI verification only.
50
+ export const dumpUsageIfRequested = (context) => {
51
+ const setting = process.env.MULMOCAST_DUMP_USAGE;
52
+ if (!setting)
53
+ return;
54
+ const payload = summarizeUsage(context);
55
+ const json = JSON.stringify(payload, null, 2);
56
+ if (setting === "1" || setting === "true" || setting === "stdout") {
57
+ GraphAILogger.info(json);
58
+ return;
59
+ }
60
+ fs.writeFileSync(setting, json, "utf-8");
61
+ GraphAILogger.info(`usage written to ${setting}`);
62
+ };
17
63
  export const setGraphAILogger = (verbose, logValues) => {
18
64
  if (verbose) {
19
65
  if (logValues) {
@@ -1,3 +1,3 @@
1
1
  import { ScriptingParams } from "../types/index.js";
2
- export declare const createMulmoScriptFromUrl: ({ urls, templateName, outDirPath, filename, cacheDirPath, llm, llm_model }: ScriptingParams) => Promise<void>;
3
- export declare const createMulmoScriptFromFile: (fileName: string, { templateName, outDirPath, filename, cacheDirPath, llm, llm_model, verbose }: ScriptingParams) => Promise<void>;
2
+ export declare const createMulmoScriptFromUrl: ({ urls, templateName, outDirPath, filename, cacheDirPath, llm, llm_model, usageCollector }: ScriptingParams) => Promise<void>;
3
+ export declare const createMulmoScriptFromFile: (fileName: string, { templateName, outDirPath, filename, cacheDirPath, llm, llm_model, verbose, usageCollector }: ScriptingParams) => Promise<void>;
@@ -15,6 +15,7 @@ import { mulmoScriptSchema, urlsSchema } from "../types/schema.js";
15
15
  import { cliLoadingPlugin } from "../utils/plugins.js";
16
16
  import { graphDataScriptFromUrlPrompt } from "../utils/prompt.js";
17
17
  import { llmPair, settings2GraphAIConfig } from "../utils/utils.js";
18
+ import { createUsageCallback } from "../utils/usage_callback.js";
18
19
  import { readFileSync } from "fs";
19
20
  dotenv.config({ quiet: true });
20
21
  const vanillaAgents = agents.default ?? agents;
@@ -207,7 +208,7 @@ const graphDataText = {
207
208
  },
208
209
  },
209
210
  };
210
- export const createMulmoScriptFromUrl = async ({ urls, templateName, outDirPath, filename, cacheDirPath, llm, llm_model }) => {
211
+ export const createMulmoScriptFromUrl = async ({ urls, templateName, outDirPath, filename, cacheDirPath, llm, llm_model, usageCollector }) => {
211
212
  mkdir(outDirPath);
212
213
  mkdir(cacheDirPath);
213
214
  const parsedUrls = urlsSchema.parse(urls);
@@ -239,6 +240,7 @@ export const createMulmoScriptFromUrl = async ({ urls, templateName, outDirPath,
239
240
  graph.injectValue("llmModel", model);
240
241
  graph.injectValue("maxTokens", max_tokens);
241
242
  graph.registerCallback(cliLoadingPlugin({ nodeId: "mulmoScript", message: "Generating script..." }));
243
+ graph.registerCallback(createUsageCallback(usageCollector));
242
244
  const result = await graph.run();
243
245
  if (!result?.writeJSON?.path) {
244
246
  showErrorMessage("Script generation failed. Please try again.");
@@ -246,7 +248,7 @@ export const createMulmoScriptFromUrl = async ({ urls, templateName, outDirPath,
246
248
  }
247
249
  writingMessage(result?.writeJSON?.path ?? "");
248
250
  };
249
- export const createMulmoScriptFromFile = async (fileName, { templateName, outDirPath, filename, cacheDirPath, llm, llm_model, verbose }) => {
251
+ export const createMulmoScriptFromFile = async (fileName, { templateName, outDirPath, filename, cacheDirPath, llm, llm_model, verbose, usageCollector }) => {
250
252
  mkdir(outDirPath);
251
253
  mkdir(cacheDirPath);
252
254
  const filePath = path.resolve(process.cwd(), fileName);
@@ -272,6 +274,7 @@ export const createMulmoScriptFromFile = async (fileName, { templateName, outDir
272
274
  if (!verbose) {
273
275
  graph.registerCallback(cliLoadingPlugin({ nodeId: "mulmoScript", message: "Generating script..." }));
274
276
  }
277
+ graph.registerCallback(createUsageCallback(usageCollector));
275
278
  const result = await graph.run();
276
279
  if (!result?.writeJSON?.path) {
277
280
  showErrorMessage("Script generation failed. Please try again.");
@@ -1,2 +1,2 @@
1
1
  import { ScriptingParams } from "../types/index.js";
2
- export declare const createMulmoScriptInteractively: ({ outDirPath, cacheDirPath, filename, templateName, urls, llm, llm_model }: ScriptingParams) => Promise<void>;
2
+ export declare const createMulmoScriptInteractively: ({ outDirPath, cacheDirPath, filename, templateName, urls, llm, llm_model, usageCollector, }: ScriptingParams) => Promise<void>;
@@ -14,6 +14,7 @@ import { mulmoScriptSchema } from "../types/index.js";
14
14
  import { browserlessAgent } from "@graphai/browserless_agent";
15
15
  import validateSchemaAgent from "../agents/validate_schema_agent.js";
16
16
  import { llmPair, settings2GraphAIConfig } from "../utils/utils.js";
17
+ import { createUsageCallback } from "../utils/usage_callback.js";
17
18
  import { interactiveClarificationPrompt, prefixPrompt } from "../utils/prompt.js";
18
19
  // import { cliLoadingPlugin } from "../utils/plugins.js";
19
20
  dotenv.config({ quiet: true });
@@ -199,7 +200,7 @@ const graphData = {
199
200
  },
200
201
  },
201
202
  };
202
- const scrapeWebContent = async (urls, cacheDirPath) => {
203
+ const scrapeWebContent = async (urls, cacheDirPath, usageCollector) => {
203
204
  mkdir(cacheDirPath);
204
205
  GraphAILogger.info(`${agentHeader} Scraping ${urls.length} URLs...\n`);
205
206
  const browserlessCache = browserlessCacheGenerator(cacheDirPath);
@@ -212,16 +213,17 @@ const scrapeWebContent = async (urls, cacheDirPath) => {
212
213
  ];
213
214
  const graph = new GraphAI(graphDataForScraping, { ...vanillaAgents, openAIAgent, textInputAgent, fileWriteAgent, browserlessAgent }, { agentFilters });
214
215
  graph.injectValue("urls", urls);
216
+ graph.registerCallback(createUsageCallback(usageCollector));
215
217
  const result = (await graph.run());
216
218
  if (!result?.sourceText?.text) {
217
219
  return "";
218
220
  }
219
221
  return `\n\n${prefixPrompt}\n${result?.sourceText.text}`;
220
222
  };
221
- export const createMulmoScriptInteractively = async ({ outDirPath, cacheDirPath, filename, templateName, urls, llm, llm_model }) => {
223
+ export const createMulmoScriptInteractively = async ({ outDirPath, cacheDirPath, filename, templateName, urls, llm, llm_model, usageCollector, }) => {
222
224
  mkdir(outDirPath);
223
225
  // if urls is not empty, scrape web content and reference it in the prompt
224
- const webContentPrompt = urls.length > 0 ? await scrapeWebContent(urls, cacheDirPath) : "";
226
+ const webContentPrompt = urls.length > 0 ? await scrapeWebContent(urls, cacheDirPath, usageCollector) : "";
225
227
  const { agent, model, max_tokens } = llmPair(llm, llm_model);
226
228
  GraphAILogger.log({ agent, model, max_tokens });
227
229
  const agentFilters = [
@@ -255,6 +257,7 @@ export const createMulmoScriptInteractively = async ({ outDirPath, cacheDirPath,
255
257
  }
256
258
  }
257
259
  });
260
+ graph.registerCallback(createUsageCallback(usageCollector));
258
261
  // graph.registerCallback(cliLoadingPlugin({ nodeId: "reply", message: "Loading..." }));
259
262
  GraphAILogger.info(`${agentHeader} Hi! What topic would you like me to generate about?\n`);
260
263
  await graph.run();
@@ -1 +1,2 @@
1
- export declare const deepResearch: () => Promise<void>;
1
+ import type { UsageCollectorAPI } from "../types/usage.js";
2
+ export declare const deepResearch: (usageCollector?: UsageCollectorAPI) => Promise<void>;
@@ -7,6 +7,7 @@ import * as agents from "@graphai/vanilla";
7
7
  import tavilySearchAgent from "../agents/tavily_agent.js";
8
8
  import { cliLoadingPlugin } from "../utils/plugins.js";
9
9
  import { searchQueryPrompt, reflectionPrompt, finalAnswerPrompt } from "../utils/prompt.js";
10
+ import { createUsageCallback } from "../utils/usage_callback.js";
10
11
  dotenv.config({ quiet: true });
11
12
  const vanillaAgents = agents.default ?? agents;
12
13
  const agentHeader = "\x1b[34m● \x1b[0m\x1b[1mAgent\x1b[0m:\x1b[0m";
@@ -246,7 +247,7 @@ const graphData = {
246
247
  },
247
248
  },
248
249
  };
249
- export const deepResearch = async () => {
250
+ export const deepResearch = async (usageCollector) => {
250
251
  const agentFilters = [
251
252
  {
252
253
  name: "consoleStreamDataAgentFilter",
@@ -260,6 +261,7 @@ export const deepResearch = async () => {
260
261
  graph.registerCallback(cliLoadingPlugin({ nodeId: "reflectionAgent", message: "Analyzing search results..." }));
261
262
  graph.registerCallback(cliLoadingPlugin({ nodeId: "tavilySearchAgent", message: "Searching..." }));
262
263
  graph.registerCallback(cliLoadingPlugin({ nodeId: "finalAnswer", message: "Generating final answer..." }));
264
+ graph.registerCallback(createUsageCallback(usageCollector));
263
265
  GraphAILogger.info(`${agentHeader} What would you like to know?\n`);
264
266
  await graph.run();
265
267
  };
@@ -1,6 +1,7 @@
1
1
  import { MulmoStoryboard, StoryToScriptGenerateMode } from "../types/index.js";
2
2
  import type { LLM } from "../types/provider2agent.js";
3
- export declare const storyToScript: ({ story, beatsPerScene, templateName, outdir, fileName, llm, llmModel, generateMode, }: {
3
+ import type { UsageCollectorAPI } from "../types/usage.js";
4
+ export declare const storyToScript: ({ story, beatsPerScene, templateName, outdir, fileName, llm, llmModel, generateMode, usageCollector, }: {
4
5
  story: MulmoStoryboard;
5
6
  beatsPerScene: number;
6
7
  templateName: string;
@@ -9,4 +10,5 @@ export declare const storyToScript: ({ story, beatsPerScene, templateName, outdi
9
10
  llm?: LLM;
10
11
  llmModel?: string;
11
12
  generateMode: StoryToScriptGenerateMode;
13
+ usageCollector?: UsageCollectorAPI;
12
14
  }) => Promise<void>;
@@ -10,6 +10,7 @@ import { graphDataScriptGeneratePrompt, sceneToBeatsPrompt, storyToScriptInfoPro
10
10
  import { fileWriteAgent } from "@graphai/vanilla_node_agents";
11
11
  import validateSchemaAgent from "../agents/validate_schema_agent.js";
12
12
  import { llmPair, settings2GraphAIConfig } from "../utils/utils.js";
13
+ import { createUsageCallback } from "../utils/usage_callback.js";
13
14
  import { storyToScriptGenerateMode } from "../types/const.js";
14
15
  import { cliLoadingPlugin } from "../utils/plugins.js";
15
16
  const vanillaAgents = agents.default ?? agents;
@@ -248,7 +249,7 @@ const generateScriptPrompt = async (template, beatsPerScene, story) => {
248
249
  const script = readScriptTemplateFile(template.scriptName);
249
250
  return storyToScriptPrompt(script, beatsPerScene, story);
250
251
  };
251
- export const storyToScript = async ({ story, beatsPerScene, templateName, outdir, fileName, llm, llmModel, generateMode, }) => {
252
+ export const storyToScript = async ({ story, beatsPerScene, templateName, outdir, fileName, llm, llmModel, generateMode, usageCollector, }) => {
252
253
  const template = readAndParseJson(getPromptTemplateFilePath(templateName), mulmoPromptTemplateSchema);
253
254
  const { agent, model, max_tokens } = llmPair(llm, llmModel);
254
255
  const beatsPrompt = await generateBeatsPrompt(template, beatsPerScene, story);
@@ -271,6 +272,7 @@ export const storyToScript = async ({ story, beatsPerScene, templateName, outdir
271
272
  graph.injectValue("llmModel", model);
272
273
  graph.injectValue("maxTokens", max_tokens);
273
274
  graph.registerCallback(cliLoadingPlugin({ nodeId: "script", message: "Generating script..." }));
275
+ graph.registerCallback(createUsageCallback(usageCollector));
274
276
  const result = await graph.run();
275
277
  writingMessage(result?.writeJSON?.path ?? "");
276
278
  };
@@ -10,10 +10,12 @@ export type OpenAIImageOptions = {
10
10
  quality?: OpenAIImageQuality;
11
11
  background?: "opaque" | "transparent" | "auto";
12
12
  };
13
+ import type { AgentUsage } from "./usage.js";
13
14
  export type AgentBufferResult = {
14
15
  buffer?: Buffer;
15
16
  saved?: string;
16
17
  text?: string;
18
+ usage?: AgentUsage;
17
19
  };
18
20
  export type AgentPromptInputs = {
19
21
  prompt: string;
@@ -1,3 +1,4 @@
1
1
  export * from "./type.js";
2
2
  export * from "./schema.js";
3
3
  export * from "./viewer.js";
4
+ export * from "./usage.js";
@@ -1,3 +1,4 @@
1
1
  export * from "./type.js";
2
2
  export * from "./schema.js";
3
3
  export * from "./viewer.js";
4
+ export * from "./usage.js";
@@ -1,4 +1,5 @@
1
1
  import { type CallbackFunction } from "graphai";
2
+ import type { UsageCollectorAPI } from "./usage.js";
2
3
  import { langSchema, localizedTextSchema, mulmoBeatSchema, mulmoScriptSchema, mulmoStudioSchema, mulmoStudioBeatSchema, mulmoStoryboardSchema, mulmoStoryboardSceneSchema, mulmoStudioMultiLingualSchema, mulmoStudioMultiLingualArraySchema, mulmoStudioMultiLingualDataSchema, mulmoStudioMultiLingualFileSchema, speakerDictionarySchema, speakerSchema, mulmoSpeechParamsSchema, mulmoImageParamsSchema, mulmoImageParamsImagesValueSchema, mulmoImageParamsImagesSchema, mulmoFillOptionSchema, mulmoTransitionSchema, mulmoVideoFilterSchema, mulmoMovieParamsSchema, mulmoSoundEffectParamsSchema, mulmoLipSyncParamsSchema, textSlideParamsSchema, speechOptionsSchema, speakerDataSchema, mulmoCanvasDimensionSchema, mulmoPromptTemplateSchema, mulmoPromptTemplateFileSchema, text2ImageProviderSchema, text2HtmlImageProviderSchema, text2MovieProviderSchema, text2SpeechProviderSchema, mulmoPresentationStyleSchema, multiLingualTextsSchema, mulmoImageAssetSchema, mulmoMermaidMediaSchema, mulmoTextSlideMediaSchema, mulmoMarkdownMediaSchema, mulmoImageMediaSchema, mulmoChartMediaSchema, mediaSourceSchema, mediaSourceMermaidSchema, backgroundImageSchema, backgroundImageSourceSchema, mulmoSessionStateSchema, mulmoOpenAIImageModelSchema, mulmoGoogleImageModelSchema, mulmoGoogleMovieModelSchema, mulmoReplicateMovieModelSchema, mulmoImagePromptMediaSchema, mulmoMovieMediaSchema, mulmoMoviePromptMediaSchema, markdownLayoutSchema, row2Schema, grid2x2Schema } from "./schema.js";
3
4
  import { pdf_modes, pdf_sizes, storyToScriptGenerateMode } from "./const.js";
4
5
  import type { LLM } from "./provider2agent.js";
@@ -68,6 +69,7 @@ export type MulmoStudioContext = {
68
69
  sessionState: MulmoSessionState;
69
70
  presentationStyle: MulmoPresentationStyle;
70
71
  multiLingual: MulmoStudioMultiLingualArray;
72
+ usageCollector?: UsageCollectorAPI;
71
73
  };
72
74
  export type ScriptingParams = {
73
75
  urls: string[];
@@ -78,6 +80,7 @@ export type ScriptingParams = {
78
80
  llm_model?: string;
79
81
  llm?: LLM;
80
82
  verbose?: boolean;
83
+ usageCollector?: UsageCollectorAPI;
81
84
  };
82
85
  export type ImageProcessorParams = {
83
86
  beat: MulmoBeat;
@@ -0,0 +1,32 @@
1
+ export type AgentUsage = {
2
+ provider: string;
3
+ model: string;
4
+ inputTokens?: number;
5
+ outputTokens?: number;
6
+ totalTokens?: number;
7
+ predictSec?: number;
8
+ inputChars?: number;
9
+ };
10
+ export type UsageRecord = {
11
+ agent: string;
12
+ provider: string;
13
+ model: string;
14
+ beatIndex?: number;
15
+ inputTokens?: number;
16
+ outputTokens?: number;
17
+ totalTokens?: number;
18
+ predictSec?: number;
19
+ inputChars?: number;
20
+ cached: boolean;
21
+ retryAttempt?: number;
22
+ timestamp: string;
23
+ };
24
+ export type UsageCollectorAPI = {
25
+ add(record: Omit<UsageRecord, "timestamp"> & {
26
+ timestamp?: string;
27
+ }): void;
28
+ snapshot(): UsageRecord[];
29
+ merge(other: UsageCollectorAPI): void;
30
+ clear(): void;
31
+ readonly size: number;
32
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,5 @@
1
1
  import type { MulmoStudioBeat, MulmoScript, MulmoPresentationStyle, MulmoStudioMultiLingual, FileObject } from "../types/type.js";
2
+ import { UsageCollector } from "./usage_collector.js";
2
3
  export declare const silentMp3 = "https://github.com/receptron/mulmocast-cli/raw/refs/heads/main/assets/audio/silent300.mp3";
3
4
  export type MulmoErrorFormatter = (error: unknown) => string | null;
4
5
  export declare const setMulmoErrorFormatter: (formatter: MulmoErrorFormatter | null) => void;
@@ -5504,4 +5505,5 @@ export declare const initializeContextFromFiles: (files: FileObject, raiseError:
5504
5505
  force: boolean;
5505
5506
  backup: boolean;
5506
5507
  lang: string;
5508
+ usageCollector: UsageCollector;
5507
5509
  } | null>;
@@ -4,6 +4,7 @@ import { beatId, multiLingualObjectToArray } from "./utils.js";
4
4
  import { mulmoStudioSchema, mulmoCaptionParamsSchema, mulmoPresentationStyleSchema } from "../types/schema.js";
5
5
  import { MulmoPresentationStyleMethods, MulmoScriptMethods, MulmoStudioMultiLingualMethod } from "../methods/index.js";
6
6
  import { loadMulmoConfig, mergeConfigWithScript } from "./mulmo_config.js";
7
+ import { UsageCollector } from "./usage_collector.js";
7
8
  export const silentMp3 = "https://github.com/receptron/mulmocast-cli/raw/refs/heads/main/assets/audio/silent300.mp3";
8
9
  let mulmoErrorFormatter = null;
9
10
  export const setMulmoErrorFormatter = (formatter) => {
@@ -151,6 +152,7 @@ export const initializeContextFromFiles = async (files, raiseError, force, withB
151
152
  force: Boolean(force),
152
153
  backup: Boolean(withBackup),
153
154
  lang: targetLang ?? studio.script.lang, // This lang is target Language. studio.lang is default Language
155
+ usageCollector: new UsageCollector(),
154
156
  };
155
157
  }
156
158
  catch (error) {
@@ -37,7 +37,7 @@ export const fileCacheAgentFilter = async (context, next) => {
37
37
  const output = (await next(context)) || undefined;
38
38
  const { buffer, text, saved } = output ?? {};
39
39
  if (saved) {
40
- return true;
40
+ return output;
41
41
  }
42
42
  if (buffer) {
43
43
  writingMessage(file);
@@ -45,7 +45,7 @@ export const fileCacheAgentFilter = async (context, next) => {
45
45
  if (backup) {
46
46
  await fsPromise.writeFile(getBackupFilePath(file), buffer);
47
47
  }
48
- return true;
48
+ return output;
49
49
  }
50
50
  else if (text) {
51
51
  writingMessage(file);
@@ -53,10 +53,7 @@ export const fileCacheAgentFilter = async (context, next) => {
53
53
  if (backup) {
54
54
  await fsPromise.writeFile(getBackupFilePath(file), text, "utf-8");
55
55
  }
56
- return true;
57
- }
58
- else if (saved) {
59
- return true;
56
+ return output;
60
57
  }
61
58
  GraphAILogger.log("no cache, no buffer: " + file);
62
59
  return false;
@@ -0,0 +1,5 @@
1
+ import type Replicate from "replicate";
2
+ export declare const runReplicateWithMetrics: (replicate: Replicate, identifier: `${string}/${string}` | `${string}/${string}:${string}`, input: object, signal?: AbortSignal) => Promise<{
3
+ output: unknown;
4
+ predictSec?: number;
5
+ }>;
@@ -0,0 +1,11 @@
1
+ // Capture Replicate's exact billed seconds (`metrics.predict_time`) via the
2
+ // progress callback on `replicate.run()`. Avoids switching the four
3
+ // replicate agents from `.run()` to `predictions.create()` + poll, which
4
+ // would be a much bigger blast radius.
5
+ export const runReplicateWithMetrics = async (replicate, identifier, input, signal) => {
6
+ let lastPrediction;
7
+ const output = await replicate.run(identifier, { input, signal }, (prediction) => {
8
+ lastPrediction = prediction;
9
+ });
10
+ return { output, predictSec: lastPrediction?.metrics?.predict_time };
11
+ };
@@ -0,0 +1,4 @@
1
+ import { type CallbackFunction } from "graphai";
2
+ import type { MulmoStudioContext } from "../types/type.js";
3
+ import type { UsageCollectorAPI } from "../types/usage.js";
4
+ export declare const createUsageCallback: (target: MulmoStudioContext | UsageCollectorAPI | undefined) => CallbackFunction;
@@ -0,0 +1,93 @@
1
+ import { NodeState } from "graphai";
2
+ // @graphai/* LLM agents (openAIAgent / geminiAgent / anthropicAgent / groqAgent)
3
+ // return usage in the OpenAI chat-completions wire shape:
4
+ // { prompt_tokens, completion_tokens, total_tokens }
5
+ // without provider/model on the usage object. We derive provider from the
6
+ // node's agentId and model from the node's params or the spread response.
7
+ const LLM_AGENT_TO_PROVIDER = {
8
+ openAIAgent: "openai",
9
+ geminiAgent: "google",
10
+ anthropicAgent: "anthropic",
11
+ groqAgent: "groq",
12
+ };
13
+ const isAgentUsage = (value) => {
14
+ if (!value || typeof value !== "object")
15
+ return false;
16
+ const { provider, model } = value;
17
+ return typeof provider === "string" && typeof model === "string";
18
+ };
19
+ const extractLLMUsage = (log) => {
20
+ const provider = log.agentId ? LLM_AGENT_TO_PROVIDER[log.agentId] : undefined;
21
+ if (!provider)
22
+ return undefined;
23
+ const result = log.result;
24
+ const u = result?.usage;
25
+ if (!u || typeof u.prompt_tokens !== "number" || typeof u.total_tokens !== "number")
26
+ return undefined;
27
+ const pickModel = () => {
28
+ const fromParams = log.params?.model;
29
+ if (typeof fromParams === "string")
30
+ return fromParams;
31
+ if (typeof result?.model === "string")
32
+ return result.model;
33
+ return "unknown";
34
+ };
35
+ const model = pickModel();
36
+ return {
37
+ provider,
38
+ model,
39
+ inputTokens: u.prompt_tokens,
40
+ outputTokens: u.completion_tokens,
41
+ totalTokens: u.total_tokens,
42
+ };
43
+ };
44
+ const extractUsage = (log) => {
45
+ // 1. mulmocast AgentUsage shape (image/TTS agents in this repo).
46
+ if (log.result && typeof log.result === "object") {
47
+ const { usage } = log.result;
48
+ if (isAgentUsage(usage))
49
+ return usage;
50
+ }
51
+ // 2. @graphai/* LLM shape — derive provider/model from log metadata.
52
+ return extractLLMUsage(log);
53
+ };
54
+ const resolveCollector = (target) => {
55
+ if (!target)
56
+ return undefined;
57
+ // A MulmoStudioContext has the optional `usageCollector` slot; the API itself
58
+ // doesn't. Distinguishing on the slot name lets either form pass through.
59
+ if ("usageCollector" in target)
60
+ return target.usageCollector;
61
+ return target;
62
+ };
63
+ // GraphAI callback that pushes any agent's reported usage into the given
64
+ // UsageCollector when the node completes successfully. No-op when the
65
+ // collector is absent (either undefined directly or `context.usageCollector`
66
+ // undefined), the node fails, or the result has no `usage`.
67
+ export const createUsageCallback = (target) => {
68
+ return (log, isUpdate) => {
69
+ if (isUpdate)
70
+ return;
71
+ if (log.state !== NodeState.Completed)
72
+ return;
73
+ const collector = resolveCollector(target);
74
+ if (!collector)
75
+ return;
76
+ const usage = extractUsage(log);
77
+ if (!usage)
78
+ return;
79
+ collector.add({
80
+ agent: log.agentId ?? "unknown",
81
+ provider: usage.provider,
82
+ model: usage.model,
83
+ beatIndex: log.mapIndex,
84
+ inputTokens: usage.inputTokens,
85
+ outputTokens: usage.outputTokens,
86
+ totalTokens: usage.totalTokens,
87
+ predictSec: usage.predictSec,
88
+ inputChars: usage.inputChars,
89
+ cached: false,
90
+ retryAttempt: log.retryCount,
91
+ });
92
+ };
93
+ };
@@ -0,0 +1,12 @@
1
+ import type { UsageRecord, UsageCollectorAPI } from "../types/usage.js";
2
+ export type { UsageRecord, UsageCollectorAPI } from "../types/usage.js";
3
+ export declare class UsageCollector implements UsageCollectorAPI {
4
+ private readonly records;
5
+ add(record: Omit<UsageRecord, "timestamp"> & {
6
+ timestamp?: string;
7
+ }): void;
8
+ snapshot(): UsageRecord[];
9
+ merge(other: UsageCollectorAPI): void;
10
+ clear(): void;
11
+ get size(): number;
12
+ }
@@ -0,0 +1,18 @@
1
+ export class UsageCollector {
2
+ records = [];
3
+ add(record) {
4
+ this.records.push({ ...record, timestamp: record.timestamp ?? new Date().toISOString() });
5
+ }
6
+ snapshot() {
7
+ return this.records.map((record) => ({ ...record }));
8
+ }
9
+ merge(other) {
10
+ other.snapshot().forEach((record) => this.records.push({ ...record }));
11
+ }
12
+ clear() {
13
+ this.records.length = 0;
14
+ }
15
+ get size() {
16
+ return this.records.length;
17
+ }
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmocast",
3
- "version": "2.6.20",
3
+ "version": "2.6.21",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "lib/index.node.js",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "resolutions": {
27
27
  "minimatch": "^10.2.5",
28
- "tar": "7.5.15",
28
+ "tar": "7.5.16",
29
29
  "yauzl": "^3.3.1"
30
30
  },
31
31
  "bin": {
@@ -102,7 +102,7 @@
102
102
  "@inquirer/input": "^5.0.13",
103
103
  "@inquirer/select": "^5.1.5",
104
104
  "@modelcontextprotocol/sdk": "^1.29.0",
105
- "@modernized/fluent-ffmpeg": "^1.0.0",
105
+ "@modernized/fluent-ffmpeg": "^1.0.1",
106
106
  "@mozilla/readability": "^0.6.0",
107
107
  "@mulmocast/deck": "^1.1.0",
108
108
  "@tavily/core": "^0.7.3",
@@ -127,16 +127,16 @@
127
127
  "@types/jsdom": "^28.0.3",
128
128
  "@types/yargs": "^17.0.35",
129
129
  "cross-env": "^10.1.0",
130
- "eslint": "^10.4.1",
130
+ "eslint": "^10.5.0",
131
131
  "eslint-config-prettier": "^10.1.8",
132
132
  "eslint-plugin-import": "^2.32.0",
133
133
  "eslint-plugin-prettier": "^5.5.6",
134
134
  "eslint-plugin-sonarjs": "^4.0.3",
135
135
  "globals": "^17.6.0",
136
- "prettier": "^3.8.3",
136
+ "prettier": "^3.8.4",
137
137
  "tsx": "^4.22.4",
138
138
  "typescript": "6.0.3",
139
- "typescript-eslint": "^8.60.1"
139
+ "typescript-eslint": "^8.61.1"
140
140
  },
141
141
  "engines": {
142
142
  "node": ">=22.0.0"