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 +28 -0
- package/lib/agents/image_openai_agent.js +8 -3
- package/lib/agents/image_replicate_agent.js +5 -1
- package/lib/agents/lipsync_replicate_agent.js +6 -1
- package/lib/agents/movie_replicate_agent.js +9 -0
- package/lib/agents/tts_gemini_agent.js +58 -41
- package/lib/cli/commands/tool/whisper/handler.js +60 -37
- package/package.json +1 -1
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
:
|
|
55
|
-
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
119
|
+
catch (error) {
|
|
120
|
+
GraphAILogger.error("Error writing transcription output file:", error);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
100
123
|
}
|
|
101
124
|
};
|