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.
- package/README.md +36 -0
- package/lib/actions/audio.js +3 -0
- package/lib/actions/image_references.js +3 -0
- package/lib/actions/images.js +3 -0
- package/lib/actions/translate.d.ts +2 -0
- package/lib/actions/translate.js +5 -0
- package/lib/agents/image_genai_agent.js +12 -3
- package/lib/agents/image_openai_agent.js +20 -5
- package/lib/agents/image_replicate_agent.js +9 -3
- package/lib/agents/lipsync_replicate_agent.js +10 -5
- package/lib/agents/movie_genai_agent.js +22 -4
- package/lib/agents/movie_replicate_agent.js +16 -5
- package/lib/agents/sound_effect_replicate_agent.js +4 -4
- package/lib/agents/tts_elevenlabs_agent.js +12 -1
- package/lib/agents/tts_gemini_agent.js +59 -32
- package/lib/agents/tts_google_agent.js +9 -1
- package/lib/agents/tts_kotodama_agent.js +9 -1
- package/lib/agents/tts_openai_agent.js +9 -1
- package/lib/cli/commands/audio/handler.js +2 -1
- package/lib/cli/commands/bundle/handler.js +2 -1
- package/lib/cli/commands/html/handler.js +2 -1
- package/lib/cli/commands/image/handler.js +2 -1
- package/lib/cli/commands/markdown/handler.js +2 -1
- package/lib/cli/commands/movie/handler.js +2 -1
- package/lib/cli/commands/pdf/handler.js +2 -1
- package/lib/cli/commands/tool/whisper/handler.js +60 -37
- package/lib/cli/commands/translate/handler.js +2 -1
- package/lib/cli/helpers.d.ts +1 -0
- package/lib/cli/helpers.js +46 -0
- package/lib/tools/create_mulmo_script_from_url.d.ts +2 -2
- package/lib/tools/create_mulmo_script_from_url.js +5 -2
- package/lib/tools/create_mulmo_script_interactively.d.ts +1 -1
- package/lib/tools/create_mulmo_script_interactively.js +6 -3
- package/lib/tools/deep_research.d.ts +2 -1
- package/lib/tools/deep_research.js +3 -1
- package/lib/tools/story_to_script.d.ts +3 -1
- package/lib/tools/story_to_script.js +3 -1
- package/lib/types/agent.d.ts +2 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/index.js +1 -0
- package/lib/types/type.d.ts +3 -0
- package/lib/types/usage.d.ts +32 -0
- package/lib/types/usage.js +1 -0
- package/lib/utils/context.d.ts +2 -0
- package/lib/utils/context.js +2 -0
- package/lib/utils/filters.js +3 -6
- package/lib/utils/replicate_usage.d.ts +5 -0
- package/lib/utils/replicate_usage.js +11 -0
- package/lib/utils/usage_callback.d.ts +4 -0
- package/lib/utils/usage_callback.js +93 -0
- package/lib/utils/usage_collector.d.ts +12 -0
- package/lib/utils/usage_collector.js +18 -0
- 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
|
|
package/lib/actions/audio.js
CHANGED
|
@@ -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
|
}
|
package/lib/actions/images.js
CHANGED
|
@@ -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) {
|
package/lib/actions/translate.js
CHANGED
|
@@ -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(
|
|
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
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
158
|
-
if (
|
|
159
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
55
|
-
|
|
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
|
-
|
|
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) {
|