mulmocast 2.6.21 → 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.
package/README.md CHANGED
@@ -391,6 +391,34 @@ mulmo movie script.json
391
391
  mulmo translate script.json
392
392
  ```
393
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
+
394
422
  ## Cache and Re-run
395
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.
396
424
 
@@ -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
  }
@@ -76,7 +76,11 @@ export const imageReplicateAgent = async ({ namedInputs, params, config, }) => {
76
76
  });
77
77
  }
78
78
  }
79
- 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}`, {
80
84
  cause: agentGenerationError("imageReplicateAgent", imageAction, imageFileTarget),
81
85
  });
82
86
  }
@@ -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
  }
@@ -166,6 +166,15 @@ export const movieReplicateAgent = async ({ namedInputs, params, config, }) => {
166
166
  throw error;
167
167
  }
168
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
+ });
169
178
  }
170
179
  throw new Error("ERROR: generateMovie returned undefined", {
171
180
  cause: agentInvalidResponseError("movieReplicateAgent", imageAction, movieFileTarget),
@@ -19,55 +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
- 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,
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
+ });
53
68
  }
54
- : undefined;
55
- return { buffer: await pcmToMp3(rawPcm, sampleRate), usage };
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 };
56
80
  }
57
81
  catch (e) {
58
82
  if (suppressError) {
59
- return {
60
- error: e,
61
- };
83
+ return { error: e };
62
84
  }
63
85
  GraphAILogger.info(e);
64
- const reasonDetail = getGenAIErrorReason(e);
65
- if (reasonDetail && reasonDetail.reason && reasonDetail.reason === "API_KEY_INVALID") {
66
- throw new Error("Failed to generate tts: 400 Incorrect API key provided with gemini", {
67
- cause: agentIncorrectAPIKeyError("ttsGeminiAgent", audioAction, audioFileTarget),
68
- });
69
- }
70
- 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}`, {
71
88
  cause: agentGenerationError("ttsGeminiAgent", audioAction, audioFileTarget),
72
89
  });
73
90
  }
@@ -51,40 +51,63 @@ export const handler = async (argv) => {
51
51
  GraphAILogger.error("Error: OPENAI_API_KEY environment variable is required");
52
52
  process.exit(1);
53
53
  }
54
- try {
55
- // Get audio duration using FFmpeg
56
- const { duration: audioDuration } = await ffmpegGetMediaDuration(fullPath);
57
- GraphAILogger.info(`Audio duration: ${audioDuration.toFixed(2)} seconds`);
58
- const openai = new OpenAI({ apiKey });
59
- const transcription = await openai.audio.transcriptions.create({
60
- file: createReadStream(fullPath),
61
- model: "whisper-1",
62
- response_format: "verbose_json",
63
- timestamp_granularities: ["word", "segment"],
64
- });
65
- if (transcription.segments) {
66
- const starts = transcription.segments.map((segment) => segment.start);
67
- starts[0] = 0;
68
- starts.push(audioDuration);
69
- // Create beats from transcription segments
70
- const beats = transcription.segments.map((segment, index) => {
71
- const duration = Math.round((starts[index + 1] - starts[index]) * 100) / 100;
72
- return {
73
- text: segment.text,
74
- duration,
75
- /*
76
- image: {
77
- type: "textSlide",
78
- slide: {
79
- title: "Place Holder",
80
- },
81
- },
82
- */
83
- };
54
+ // Each phase has its own try/catch so a failure points at the right
55
+ // subsystem instead of being collapsed into "Error transcribing audio"
56
+ // (same anti-pattern as #1451 in tts_gemini_agent.ts — an ffmpeg
57
+ // SIGABRT or a permission-denied write would otherwise read as an
58
+ // OpenAI-side failure).
59
+ // Phase 1 ffmpeg: read the audio file's duration.
60
+ const audioDuration = await (async () => {
61
+ try {
62
+ const { duration } = await ffmpegGetMediaDuration(fullPath);
63
+ return duration;
64
+ }
65
+ catch (error) {
66
+ GraphAILogger.error("Error reading audio duration with ffmpeg:", error);
67
+ process.exit(1);
68
+ }
69
+ })();
70
+ GraphAILogger.info(`Audio duration: ${audioDuration.toFixed(2)} seconds`);
71
+ // Phase 2 OpenAI: Whisper transcription API call.
72
+ const transcription = await (async () => {
73
+ try {
74
+ const openai = new OpenAI({ apiKey });
75
+ return await openai.audio.transcriptions.create({
76
+ file: createReadStream(fullPath),
77
+ model: "whisper-1",
78
+ response_format: "verbose_json",
79
+ timestamp_granularities: ["word", "segment"],
84
80
  });
85
- // Create the script with the processed beats
86
- const script = createMulmoScript(fullPath, beats);
87
- // Save script to output directory
81
+ }
82
+ catch (error) {
83
+ GraphAILogger.error("Error calling OpenAI transcription API:", error);
84
+ process.exit(1);
85
+ }
86
+ })();
87
+ if (transcription.segments) {
88
+ const starts = transcription.segments.map((segment) => segment.start);
89
+ starts[0] = 0;
90
+ starts.push(audioDuration);
91
+ // Create beats from transcription segments
92
+ const beats = transcription.segments.map((segment, index) => {
93
+ const duration = Math.round((starts[index + 1] - starts[index]) * 100) / 100;
94
+ return {
95
+ text: segment.text,
96
+ duration,
97
+ /*
98
+ image: {
99
+ type: "textSlide",
100
+ slide: {
101
+ title: "Place Holder",
102
+ },
103
+ },
104
+ */
105
+ };
106
+ });
107
+ // Create the script with the processed beats
108
+ const script = createMulmoScript(fullPath, beats);
109
+ // Phase 3 — fs: write the generated script to disk.
110
+ try {
88
111
  const outputDir = "output";
89
112
  if (!existsSync(outputDir)) {
90
113
  mkdirSync(outputDir, { recursive: true });
@@ -93,9 +116,9 @@ export const handler = async (argv) => {
93
116
  writeFileSync(outputPath, JSON.stringify(script, null, 2));
94
117
  GraphAILogger.info(`Script saved to: ${outputPath}`);
95
118
  }
96
- }
97
- catch (error) {
98
- GraphAILogger.error("Error transcribing audio:", error);
99
- process.exit(1);
119
+ catch (error) {
120
+ GraphAILogger.error("Error writing transcription output file:", error);
121
+ process.exit(1);
122
+ }
100
123
  }
101
124
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmocast",
3
- "version": "2.6.21",
3
+ "version": "2.6.22",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "lib/index.node.js",