1use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use async_trait::async_trait;
20use car_engine::ToolExecutor;
21use car_parslee::studio::{StudioClient, VideoProductionRequest};
22use serde_json::{json, Value};
23
24use crate::coder::policy::stays_under;
25
26const STUDIO_TOOL_TIER: &str = "full_access";
27
28pub struct StudioMediaTools {
31 studio: Arc<StudioClient>,
32 http: reqwest::Client,
33 root: PathBuf,
34}
35
36impl StudioMediaTools {
37 pub fn new(root: PathBuf) -> Self {
38 Self {
39 studio: Arc::new(StudioClient::new()),
40 http: reqwest::Client::builder()
43 .timeout(Duration::from_secs(300))
44 .build()
45 .unwrap_or_default(),
46 root,
47 }
48 }
49
50 fn available(&self) -> bool {
53 car_auth::access_token().is_some()
54 }
55
56 pub fn tool_defs(&self) -> Vec<Value> {
57 if !self.available() {
58 return Vec::new();
59 }
60 studio_tool_defs()
61 }
62}
63
64pub(super) fn studio_tool_defs() -> Vec<Value> {
65 vec![
66 json!({
67 "name": "generate_music",
68 "description": "Generate an original music track from a text prompt via Parslee Studio \
69 (ElevenLabs Music). Writes an audio file under the working directory and returns its \
70 path — use it for game soundtracks, background music, intros, ambience. Takes ~30s. \
71 The result is a file path; embed it (e.g. <audio src>) or read it like any file.",
72 "parameters": {
73 "type": "object",
74 "properties": {
75 "prompt": {
76 "type": "string",
77 "description": "Describe the music: mood, genre, instruments, tempo, and intended use."
78 },
79 "duration_seconds": {
80 "type": "integer",
81 "description": "Length in seconds (default 30; clamped to 5–300)."
82 },
83 "output_path": {
84 "type": "string",
85 "description": "Where to write the audio, relative to the working directory (default assets/<slug>.mp3)."
86 }
87 },
88 "required": ["prompt"]
89 },
90 "mutating": true,
91 "tier": STUDIO_TOOL_TIER
92 }),
93 json!({
94 "name": "generate_jingle",
95 "description": "Generate a short sonic-branding jingle (branded audio) for a brand or \
96 product via Parslee Studio. Writes an audio file under the working directory and \
97 returns its path — use it for brand stings, app/game intros, ad audio, logo sounds. \
98 Takes ~30s. The result is a file path; embed it or read it like any file.",
99 "parameters": {
100 "type": "object",
101 "properties": {
102 "brand_name": {
103 "type": "string",
104 "description": "The brand or product the jingle is for."
105 },
106 "style": {
107 "type": "string",
108 "description": "Optional musical style/mood (e.g. 'upbeat corporate', 'luxury cinematic')."
109 },
110 "tagline": {
111 "type": "string",
112 "description": "Optional tagline or lyric to feature."
113 },
114 "output_path": {
115 "type": "string",
116 "description": "Where to write the audio, relative to the working directory (default assets/<slug>-jingle.mp3)."
117 }
118 },
119 "required": ["brand_name"]
120 },
121 "mutating": true,
122 "tier": STUDIO_TOOL_TIER
123 }),
124 json!({
125 "name": "generate_studio_image",
126 "description": "Generate a HIGH-QUALITY image via Parslee Studio (gpt-image-2). Unlike \
127 the local generate_image, this reliably renders LEGIBLE TEXT inside the image \
128 (titles, signage, logos with words) and follows complex prompts more faithfully — \
129 use it for posters, covers, UI mockups with real labels, or any image that must \
130 contain readable text. Writes the image under the working directory and returns \
131 its path. It is SLOW (often 1–4 minutes) and near its time budget, so it can \
132 occasionally return a timeout error — if that happens, retry once, or fall back \
133 to the local generate_image (fast) when you don't need readable in-image text. \
134 The result is a file path; reference it from HTML/CSS or read it like any file.",
135 "parameters": {
136 "type": "object",
137 "properties": {
138 "prompt": {
139 "type": "string",
140 "description": "What the image should depict. Put any exact text that must appear in quotes."
141 },
142 "aspect_ratio": {
143 "type": "string",
144 "description": "e.g. '16:9', '1:1', '9:16' (default 16:9)."
145 },
146 "quality": {
147 "type": "string",
148 "description": "'low' | 'medium' | 'high' (default high)."
149 },
150 "output_path": {
151 "type": "string",
152 "description": "Where to write the PNG, relative to the working directory (default assets/<slug>.png)."
153 }
154 },
155 "required": ["prompt"]
156 },
157 "mutating": true,
158 "tier": STUDIO_TOOL_TIER
159 }),
160 json!({
161 "name": "generate_song",
162 "description": "Generate a full SONG WITH VOCALS AND LYRICS via Parslee Studio (Suno), \
163 up to 8 minutes — distinct from generate_music, which makes shorter instrumental / \
164 ambience tracks. Use it when you want an actual song: a theme with a sung chorus, a \
165 branded anthem, or lyrics you supply set to music. Writes an audio file under the \
166 working directory and returns its path. Takes a few minutes (async). The result is \
167 a file path; embed it or read it like any file.",
168 "parameters": {
169 "type": "object",
170 "properties": {
171 "prompt": {
172 "type": "string",
173 "description": "Describe the song: theme, mood, genre, tempo."
174 },
175 "duration_seconds": {
176 "type": "integer",
177 "description": "Length in seconds (default 60; clamped to 30–480)."
178 },
179 "style": {
180 "type": "string",
181 "description": "Optional musical style (e.g. 'upbeat pop', 'orchestral cinematic')."
182 },
183 "lyrics": {
184 "type": "string",
185 "description": "Optional lyrics to sing. When provided, vocals are generated."
186 },
187 "instrumental": {
188 "type": "boolean",
189 "description": "True for no vocals (default: false when lyrics are given, else true)."
190 },
191 "title": {
192 "type": "string",
193 "description": "Optional song title."
194 },
195 "output_path": {
196 "type": "string",
197 "description": "Where to write the audio, relative to the working directory (default assets/<slug>.mp3)."
198 }
199 },
200 "required": ["prompt"]
201 },
202 "mutating": true,
203 "tier": STUDIO_TOOL_TIER
204 }),
205 json!({
206 "name": "list_voices",
207 "description": "List the speaking voices available for generate_voiceover — both Studio's \
208 stock presets and any voices this organization has CLONED from real recordings of \
209 real people. Call this FIRST whenever the user asks for narration 'in my voice', in \
210 a named person's voice, or in a particular style, so you can pick the right one \
211 instead of guessing. Read-only and instant. Each entry has an id, a name, and a \
212 source ('cloned' = a real person's voice, 'preset' = a stock voice).",
213 "parameters": {"type": "object", "properties": {}, "required": []},
214 "mutating": false,
217 "tier": STUDIO_TOOL_TIER
218 }),
219 json!({
220 "name": "generate_voiceover",
221 "description": "Generate NARRATION / VOICEOVER speech from text in a SPECIFIC voice via \
222 Parslee Studio (ElevenLabs), including this organization's cloned real-person \
223 voices. Writes an audio file under the working directory and returns its path plus \
224 its measured duration in seconds. Prefer this over generate_speech whenever the \
225 voice matters — generate_speech uses a local stock voice and CANNOT do a named or \
226 cloned voice. Use it for video narration, explainers, training courses, audiobooks, \
227 and per-slide voiceover. Synchronous, a few seconds per call. Call list_voices first \
228 to choose a voice.",
229 "parameters": {
230 "type": "object",
231 "properties": {
232 "text": {
233 "type": "string",
234 "description": "The words to speak. Write it as natural spoken narration, not slide text."
235 },
236 "voice": {
237 "type": "string",
238 "description": "Voice NAME (e.g. 'Matt Liotta', 'brian') or raw voice id. Names are matched against list_voices, so the user's own cloned voice can be requested by name. Omit for Studio's default."
239 },
240 "rate": {
241 "type": "string",
242 "description": "Speaking rate, e.g. \"-10%\". CAUTION — this is NOT a delta \
243 from the default: OMITTING it gives ~126 wpm, while supplying \"+0%\" \
244 gives ~161 wpm, so any value at all speeds the voice up substantially. \
245 Measured against this org's cloned voice: omitted 126, \"-20%\" 129, \
246 \"-10%\" 145, \"+0%\" 161, \"+10%\" 177, \"+20%\" 194. Natural \
247 presentation pace is 130-150 wpm, so USE \"-10%\" FOR NARRATION; \
248 omitting it sounds noticeably slow over a long video."
249 },
250 "output_path": {
251 "type": "string",
252 "description": "Where to write the MP3, relative to the working directory (default assets/<slug>.mp3)."
253 }
254 },
255 "required": ["text"]
256 },
257 "mutating": true,
258 "tier": STUDIO_TOOL_TIER
259 }),
260 json!({
261 "name": "generate_video",
262 "description": "Animate a STILL IMAGE into a short video clip (image-to-video), or \
263 generate a clip from text alone, via Parslee Studio. Give it `image_path` to \
264 animate an existing image — camera moves, drifting light, subtle motion — or omit \
265 it for text-to-video. Writes an MP4 under the working directory and returns its \
266 path. Takes roughly 1–4 minutes per clip. \
267 IMPORTANT: this is a diffusion model that repaints every frame, so any fine TEXT, \
268 NUMBERS, TABLES, CHARTS, or UI in the source image WILL be warped into gibberish. \
269 Use it on pictorial, abstract, or title imagery. To add motion to a text-heavy \
270 slide or screenshot, do NOT use this — keep the image crisp and animate it with an \
271 ffmpeg pan/zoom (Ken Burns) via the shell instead.",
272 "parameters": {
273 "type": "object",
274 "properties": {
275 "prompt": {
276 "type": "string",
277 "description": "DESCRIBE THE IMAGE FIRST, THEN THE MOTION. The model does not see the \
278 source image the way you do — if you give it only a camera direction it has no idea \
279 what it is looking at, and you get a hovering, shaky camera over an inert picture \
280 instead of animation. Name the actual subject, colours, layout and mood of THIS \
281 image, then say what should move and how. Good: 'A deep navy title card; large white \
282 serif title at left; angular pale-blue shard shapes fanning across the right side. \
283 The shards drift slowly outward and catch a soft moving highlight while the camera \
284 pushes in almost imperceptibly.' Bad: 'slow cinematic push-in'."
285 },
286 "image_path": {
287 "type": "string",
288 "description": "Optional path to a source image, relative to the working directory. Supplying it makes this image-to-video (the image becomes the first frame)."
289 },
290 "duration_seconds": {
291 "type": "integer",
292 "description": "Clip length in seconds (default 5; keep short — cost and time scale with it)."
293 },
294 "provider": {
295 "type": "string",
296 "description": "Optional backend: 'veo' (default), 'kling', or 'ltx'. Studio has no server-side default, so one is always sent."
297 },
298 "output_path": {
299 "type": "string",
300 "description": "Where to write the MP4, relative to the working directory (default assets/<slug>.mp4)."
301 }
302 },
303 "required": ["prompt"]
304 },
305 "mutating": true,
306 "tier": STUDIO_TOOL_TIER
307 }),
308 json!({
309 "name": "produce_commercial",
310 "description": "Produce a short COMMERCIAL VIDEO (shots, voiceover, and music) from a \
311 creative brief via Parslee Studio's video pipeline. Give it a brief describing the \
312 product and the ad you want; Studio plans shots, generates keyframes and video, \
313 adds a voiceover and a music bed, and assembles a finished MP4. Writes the video \
314 under the working directory and returns its path. This is SLOW — a real production \
315 runs many minutes (up to ~25). Use it when the user wants an actual video ad, \
316 promo, or commercial (for music/jingles/images use the other Studio tools). The \
317 result is a file path; embed it (<video src>) or read it like any file.",
318 "parameters": {
319 "type": "object",
320 "properties": {
321 "brief": {
322 "type": "string",
323 "description": "The creative brief: the product, the story/message, tone, and any must-have visuals."
324 },
325 "duration_seconds": {
326 "type": "integer",
327 "description": "Target length in seconds (default 20; clamped 8–60)."
328 },
329 "voiceover_script": {
330 "type": "string",
331 "description": "Optional exact voiceover narration. Omit to let Studio write one from the brief."
332 },
333 "voiceover_voice": {
334 "type": "string",
335 "description": "Optional voice name (default 'brian')."
336 },
337 "music": {
338 "type": "boolean",
339 "description": "Add a generated background music bed (default true)."
340 },
341 "output_path": {
342 "type": "string",
343 "description": "Where to write the MP4, relative to the working directory (default assets/<slug>.mp4)."
344 }
345 },
346 "required": ["brief"]
347 },
348 "mutating": true,
349 "tier": STUDIO_TOOL_TIER
350 }),
351 ]
352}
353
354impl StudioMediaTools {
355 async fn run_list_voices(&self) -> Result<Value, String> {
356 let voices = self
357 .studio
358 .list_voices()
359 .await
360 .map_err(|e| format!("list voices failed: {e}"))?;
361 let (cloned, preset): (Vec<_>, Vec<_>) = voices.iter().partition(|v| v.source == "cloned");
364 let render = |v: &car_parslee::studio::StudioVoice| {
365 json!({
366 "id": v.id,
367 "name": v.name,
368 "source": v.source,
369 "description": v.description,
370 })
371 };
372 Ok(json!({
373 "cloned_voices": cloned.iter().map(|v| render(v)).collect::<Vec<_>>(),
374 "preset_voices": preset.iter().map(|v| render(v)).collect::<Vec<_>>(),
375 "note": format!(
376 "{} cloned (real-person) and {} preset voices. Pass a name or id as `voice` to generate_voiceover.",
377 cloned.len(),
378 preset.len()
379 ),
380 }))
381 }
382
383 async fn resolve_voice(&self, voice: &str) -> Result<String, String> {
391 let want = voice.trim();
392 let looks_like_id = want.len() >= 20 && want.chars().all(|c| c.is_ascii_alphanumeric());
393 if looks_like_id {
394 return Ok(want.to_string());
395 }
396 let voices = self
397 .studio
398 .list_voices()
399 .await
400 .map_err(|e| format!("resolve voice '{want}': {e}"))?;
401 let lower = want.to_ascii_lowercase();
402 if let Some(v) = voices
403 .iter()
404 .find(|v| v.name.to_ascii_lowercase() == lower && !v.id.is_empty())
405 {
406 return Ok(v.id.clone());
407 }
408 let hits: Vec<_> = voices
409 .iter()
410 .filter(|v| v.name.to_ascii_lowercase().contains(&lower) && !v.id.is_empty())
411 .collect();
412 match hits.len() {
413 1 => Ok(hits[0].id.clone()),
414 0 => Err(format!(
415 "no voice matches '{want}'. Call list_voices to see what's available."
416 )),
417 _ => Err(format!(
418 "'{want}' is ambiguous — matches {}. Use the exact name or id.",
419 hits.iter()
420 .map(|v| format!("'{}'", v.name))
421 .collect::<Vec<_>>()
422 .join(", ")
423 )),
424 }
425 }
426
427 async fn run_generate_voiceover(&self, params: &Value) -> Result<Value, String> {
428 let text = params
429 .get("text")
430 .and_then(|v| v.as_str())
431 .map(str::trim)
432 .filter(|s| !s.is_empty())
433 .ok_or("generate_voiceover requires non-empty `text`")?;
434 let rate = params.get("rate").and_then(|v| v.as_str());
435
436 let (rel, final_path) =
438 resolve_output_under(&self.root, params, &format!("assets/{}.mp3", slug(text)))?;
439
440 let voice_id = match params.get("voice").and_then(|v| v.as_str()) {
441 Some(v) if !v.trim().is_empty() => Some(self.resolve_voice(v).await?),
442 _ => None,
443 };
444
445 let result = self
446 .studio
447 .synthesize_voiceover(text, voice_id.as_deref(), rate)
448 .await
449 .map_err(|e| format!("voiceover generation failed: {e}"))?;
450
451 let bytes = self
452 .http
453 .get(&result.audio_url)
454 .send()
455 .await
456 .map_err(|e| format!("download voiceover: {e}"))?
457 .error_for_status()
458 .map_err(|e| format!("download voiceover: {e}"))?
459 .bytes()
460 .await
461 .map_err(|e| format!("read voiceover bytes: {e}"))?;
462 std::fs::write(&final_path, &bytes).map_err(|e| format!("write voiceover file: {e}"))?;
463
464 let duration = mp3_duration_seconds(&final_path);
467
468 Ok(json!({
469 "audio_path": rel,
470 "media_type": "audio/mpeg",
471 "bytes": bytes.len(),
472 "duration_seconds": duration,
473 "voice_id": voice_id,
474 "note": match duration {
475 Some(d) => format!(
476 "Wrote voiceover to {rel} ({d:.2}s, {} KB).",
477 bytes.len() / 1024
478 ),
479 None => format!("Wrote voiceover to {rel} ({} KB).", bytes.len() / 1024),
480 },
481 }))
482 }
483
484 async fn run_generate_video(&self, params: &Value) -> Result<Value, String> {
485 let prompt = params
486 .get("prompt")
487 .and_then(|v| v.as_str())
488 .map(str::trim)
489 .filter(|s| !s.is_empty())
490 .ok_or("generate_video requires a non-empty `prompt`")?;
491 let seconds = params
492 .get("duration_seconds")
493 .and_then(Value::as_u64)
494 .unwrap_or(5)
495 .clamp(1, 30) as u32;
496 let provider = params.get("provider").and_then(|v| v.as_str());
497
498 let (rel, final_path) =
499 resolve_output_under(&self.root, params, &format!("assets/{}.mp4", slug(prompt)))?;
500
501 let source_url = match params.get("image_path").and_then(|v| v.as_str()) {
504 Some(p) if !p.trim().is_empty() => {
505 let src = resolve_input_under(&self.root, p)?;
506 let bytes = std::fs::read(&src)
507 .map_err(|e| format!("read source image {}: {e}", src.display()))?;
508 let name = src
509 .file_name()
510 .and_then(|s| s.to_str())
511 .unwrap_or("frame.png")
512 .to_string();
513 Some(
514 self.studio
515 .upload_reference_image(bytes, &name)
516 .await
517 .map_err(|e| format!("upload source image: {e}"))?,
518 )
519 }
520 _ => None,
521 };
522
523 let result = self
524 .studio
525 .animate_image(source_url.as_deref(), prompt, seconds, provider, 300)
526 .await
527 .map_err(|e| format!("video generation failed: {e}"))?;
528
529 let resp = self
530 .http
531 .get(&result.video_url)
532 .send()
533 .await
534 .map_err(|e| format!("download video: {e}"))?;
535 let status = resp.status();
543 if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::NOT_FOUND {
544 return Err(format!(
545 "Studio generated the video but returned a URL that cannot be read \
546 (HTTP {status}). It looks like a PROVIDER-hosted URL that needs the \
547 provider's own API key, not a Studio storage URL — Studio's result \
548 normalization skips its blob upload whenever the provider already set \
549 a URL. Upstream Studio fix; not retryable from here. URL: {}",
550 result.video_url
551 ));
552 }
553 let bytes = resp
554 .error_for_status()
555 .map_err(|e| format!("download video: {e}"))?
556 .bytes()
557 .await
558 .map_err(|e| format!("read video bytes: {e}"))?;
559 std::fs::write(&final_path, &bytes).map_err(|e| format!("write video file: {e}"))?;
560
561 Ok(json!({
562 "video_path": rel,
563 "media_type": "video/mp4",
564 "bytes": bytes.len(),
565 "mode": if source_url.is_some() { "image_to_video" } else { "text_to_video" },
566 "note": format!(
567 "Wrote a {seconds}s clip to {rel} ({} KB). Embed it (<video controls src=\"{rel}\">) or read it like any file.",
568 bytes.len() / 1024
569 ),
570 }))
571 }
572
573 async fn run_generate_music(&self, params: &Value) -> Result<Value, String> {
574 let prompt = params
575 .get("prompt")
576 .and_then(|v| v.as_str())
577 .map(str::trim)
578 .filter(|s| !s.is_empty())
579 .ok_or("generate_music requires a non-empty `prompt`")?;
580 let seconds = params
581 .get("duration_seconds")
582 .and_then(Value::as_u64)
583 .unwrap_or(30)
584 .clamp(5, 300);
585
586 let (rel, final_path) =
588 resolve_output_under(&self.root, params, &format!("assets/{}.mp3", slug(prompt)))?;
589
590 let result = self
591 .studio
592 .generate_music(prompt, (seconds as u32) * 1000)
593 .await
594 .map_err(|e| format!("music generation failed: {e}"))?;
595
596 let bytes = self
599 .http
600 .get(&result.audio_url)
601 .send()
602 .await
603 .map_err(|e| format!("download music: {e}"))?
604 .error_for_status()
605 .map_err(|e| format!("download music: {e}"))?
606 .bytes()
607 .await
608 .map_err(|e| format!("read music bytes: {e}"))?;
609 std::fs::write(&final_path, &bytes).map_err(|e| format!("write music file: {e}"))?;
610
611 Ok(json!({
612 "audio_path": rel,
613 "media_type": "audio/mpeg",
614 "bytes": bytes.len(),
615 "note": format!("Wrote generated music to {rel} ({} KB). Embed it (<audio controls src=\"{rel}\">) or read it like any file.", bytes.len() / 1024),
616 }))
617 }
618
619 async fn run_generate_jingle(&self, params: &Value) -> Result<Value, String> {
620 let brand = params
621 .get("brand_name")
622 .and_then(|v| v.as_str())
623 .map(str::trim)
624 .filter(|s| !s.is_empty())
625 .ok_or("generate_jingle requires a non-empty `brand_name`")?;
626 let style = params
627 .get("style")
628 .and_then(|v| v.as_str())
629 .map(str::trim)
630 .filter(|s| !s.is_empty());
631 let tagline = params
632 .get("tagline")
633 .and_then(|v| v.as_str())
634 .map(str::trim)
635 .filter(|s| !s.is_empty());
636
637 let (rel, final_path) = resolve_output_under(
639 &self.root,
640 params,
641 &format!("assets/{}-jingle.mp3", slug(brand)),
642 )?;
643
644 let result = self
645 .studio
646 .generate_jingle(brand, style, tagline, 1)
647 .await
648 .map_err(|e| format!("jingle generation failed: {e}"))?;
649 let audio_url = result
650 .audio_urls
651 .first()
652 .ok_or("Studio returned no jingle audio")?;
653
654 let bytes = self
657 .http
658 .get(audio_url)
659 .send()
660 .await
661 .map_err(|e| format!("download jingle: {e}"))?
662 .error_for_status()
663 .map_err(|e| format!("download jingle: {e}"))?
664 .bytes()
665 .await
666 .map_err(|e| format!("read jingle bytes: {e}"))?;
667 std::fs::write(&final_path, &bytes).map_err(|e| format!("write jingle file: {e}"))?;
668
669 Ok(json!({
670 "audio_path": rel,
671 "media_type": "audio/mpeg",
672 "bytes": bytes.len(),
673 "note": format!("Wrote generated jingle to {rel} ({} KB). Embed it (<audio controls src=\"{rel}\">) or read it like any file.", bytes.len() / 1024),
674 }))
675 }
676
677 async fn run_generate_studio_image(&self, params: &Value) -> Result<Value, String> {
678 let prompt = params
679 .get("prompt")
680 .and_then(|v| v.as_str())
681 .map(str::trim)
682 .filter(|s| !s.is_empty())
683 .ok_or("generate_studio_image requires a non-empty `prompt`")?;
684 let aspect = params
685 .get("aspect_ratio")
686 .and_then(|v| v.as_str())
687 .map(str::trim)
688 .filter(|s| !s.is_empty());
689 let quality = params
690 .get("quality")
691 .and_then(|v| v.as_str())
692 .map(str::trim)
693 .filter(|s| !s.is_empty());
694
695 let (rel, final_path) =
697 resolve_output_under(&self.root, params, &format!("assets/{}.png", slug(prompt)))?;
698
699 let deadline = Instant::now() + Duration::from_secs(330);
705 let result = self
706 .studio
707 .generate_image_hq(prompt, aspect, quality, deadline)
708 .await
709 .map_err(|e| format!("studio image generation failed: {e}"))?;
710 let n = self
711 .download_to(&result.image_url, &final_path, "image")
712 .await?;
713
714 Ok(json!({
715 "image_path": rel,
716 "media_type": "image/png",
717 "bytes": n,
718 "note": format!("Wrote a high-quality Studio image to {rel} ({} KB). Reference it from HTML/CSS (<img src=\"{rel}\">) or read it like any file.", n / 1024),
719 }))
720 }
721
722 async fn run_generate_song(&self, params: &Value) -> Result<Value, String> {
723 let prompt = params
724 .get("prompt")
725 .and_then(|v| v.as_str())
726 .map(str::trim)
727 .filter(|s| !s.is_empty())
728 .ok_or("generate_song requires a non-empty `prompt`")?;
729 let seconds = params
730 .get("duration_seconds")
731 .and_then(Value::as_u64)
732 .unwrap_or(60)
733 .clamp(30, 480) as u32;
734 let style = params
735 .get("style")
736 .and_then(|v| v.as_str())
737 .map(str::trim)
738 .filter(|s| !s.is_empty());
739 let lyrics = params
740 .get("lyrics")
741 .and_then(|v| v.as_str())
742 .map(str::trim)
743 .filter(|s| !s.is_empty());
744 let title = params
745 .get("title")
746 .and_then(|v| v.as_str())
747 .map(str::trim)
748 .filter(|s| !s.is_empty());
749 let instrumental = params
751 .get("instrumental")
752 .and_then(Value::as_bool)
753 .unwrap_or(lyrics.is_none());
754
755 let (rel, final_path) =
757 resolve_output_under(&self.root, params, &format!("assets/{}.mp3", slug(prompt)))?;
758
759 let deadline = Instant::now() + Duration::from_secs(360);
761 let result = self
762 .studio
763 .generate_music_suno(
764 prompt,
765 seconds,
766 style,
767 instrumental,
768 lyrics,
769 title,
770 deadline,
771 )
772 .await
773 .map_err(|e| format!("song generation failed: {e}"))?;
774 let n = self
775 .download_to(&result.audio_url, &final_path, "song")
776 .await?;
777
778 Ok(json!({
779 "audio_path": rel,
780 "media_type": "audio/mpeg",
781 "bytes": n,
782 "note": format!("Wrote a generated song to {rel} ({} KB). Embed it (<audio controls src=\"{rel}\">) or read it like any file.", n / 1024),
783 }))
784 }
785
786 async fn run_produce_commercial(&self, params: &Value) -> Result<Value, String> {
787 let brief = params
788 .get("brief")
789 .and_then(|v| v.as_str())
790 .map(str::trim)
791 .filter(|s| !s.is_empty())
792 .ok_or("produce_commercial requires a non-empty `brief`")?;
793 let duration = params
794 .get("duration_seconds")
795 .and_then(Value::as_u64)
796 .unwrap_or(20)
797 .clamp(8, 60) as u32;
798 let voiceover_script = params
799 .get("voiceover_script")
800 .and_then(|v| v.as_str())
801 .map(str::trim)
802 .filter(|s| !s.is_empty());
803 let voiceover_voice = params
804 .get("voiceover_voice")
805 .and_then(|v| v.as_str())
806 .map(str::trim)
807 .filter(|s| !s.is_empty());
808 let music = params.get("music").and_then(Value::as_bool).unwrap_or(true);
809
810 let (rel, final_path) =
812 resolve_output_under(&self.root, params, &format!("assets/{}.mp4", slug(brief)))?;
813
814 let name = commercial_name(brief);
815 let req = VideoProductionRequest {
816 brief,
817 name: &name,
818 duration_seconds: duration,
819 video_format: "youtube_landscape",
820 voiceover_script,
821 voiceover_voice,
822 generate_music_bed: music,
823 };
824 let production = self
825 .studio
826 .start_video_production(&req)
827 .await
828 .map_err(|e| format!("start commercial production failed: {e}"))?;
829
830 let deadline = Instant::now() + Duration::from_secs(25 * 60);
837 let result = self
838 .studio
839 .poll_video_production(&production.project_id, deadline)
840 .await
841 .map_err(|e| format!("commercial production failed: {e}"))?;
842
843 let n = self
846 .download_to(&result.video_url, &final_path, "commercial")
847 .await?;
848
849 Ok(json!({
850 "video_path": rel,
851 "media_type": "video/mp4",
852 "bytes": n,
853 "project_id": production.project_id,
854 "note": format!("Wrote a Studio commercial to {rel} ({} KB). Embed it (<video controls src=\"{rel}\">) or read it like any file.", n / 1024),
855 }))
856 }
857
858 async fn download_to(&self, url: &str, final_path: &Path, what: &str) -> Result<usize, String> {
861 let bytes = self
862 .http
863 .get(url)
864 .send()
865 .await
866 .map_err(|e| format!("download {what}: {e}"))?
867 .error_for_status()
868 .map_err(|e| format!("download {what}: {e}"))?
869 .bytes()
870 .await
871 .map_err(|e| format!("read {what} bytes: {e}"))?;
872 std::fs::write(final_path, &bytes).map_err(|e| format!("write {what} file: {e}"))?;
873 Ok(bytes.len())
874 }
875}
876
877#[async_trait]
878impl ToolExecutor for StudioMediaTools {
879 async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
880 match tool {
881 "list_voices" => self.run_list_voices().await,
882 "generate_voiceover" => self.run_generate_voiceover(params).await,
883 "generate_video" => self.run_generate_video(params).await,
884 "generate_music" => self.run_generate_music(params).await,
885 "generate_jingle" => self.run_generate_jingle(params).await,
886 "generate_studio_image" => self.run_generate_studio_image(params).await,
887 "generate_song" => self.run_generate_song(params).await,
888 "produce_commercial" => self.run_produce_commercial(params).await,
889 other => Err(format!("unknown tool: '{other}'")),
892 }
893 }
894}
895
896fn resolve_input_under(root: &Path, rel: &str) -> Result<PathBuf, String> {
907 if !stays_under(root, rel) {
908 return Err(format!("path '{rel}' escapes the working directory"));
909 }
910 let abs = root.join(rel);
911 let real = abs
912 .canonicalize()
913 .map_err(|e| format!("resolve '{rel}': {e}"))?;
914 let root_real = root
915 .canonicalize()
916 .map_err(|e| format!("resolve working directory: {e}"))?;
917 if !real.starts_with(&root_real) {
918 return Err(format!(
919 "path '{rel}' resolves outside the working directory"
920 ));
921 }
922 Ok(real)
923}
924
925fn mp3_duration_seconds(path: &Path) -> Option<f64> {
938 const BITRATES_V1L3: [u32; 16] = [
939 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0,
940 ];
941 const BITRATES_V2L3: [u32; 16] = [
942 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0,
943 ];
944 const RATES_V1: [u32; 4] = [44100, 48000, 32000, 0];
945 const RATES_V2: [u32; 4] = [22050, 24000, 16000, 0];
946 const RATES_V25: [u32; 4] = [11025, 12000, 8000, 0];
947
948 let data = std::fs::read(path).ok()?;
949 let mut i = 0usize;
950
951 if data.len() > 10 && &data[0..3] == b"ID3" {
953 let size = ((data[6] as usize & 0x7f) << 21)
954 | ((data[7] as usize & 0x7f) << 14)
955 | ((data[8] as usize & 0x7f) << 7)
956 | (data[9] as usize & 0x7f);
957 i = 10 + size;
958 }
959
960 let mut seconds = 0.0f64;
961 let mut frames = 0u32;
962 while i + 4 <= data.len() {
963 if data[i] != 0xff || (data[i + 1] & 0xe0) != 0xe0 {
965 i += 1;
966 continue;
967 }
968 let version_bits = (data[i + 1] >> 3) & 0x03; let layer_bits = (data[i + 1] >> 1) & 0x03; if layer_bits != 1 || version_bits == 1 {
971 i += 1;
972 continue;
973 }
974 let bitrate_idx = ((data[i + 2] >> 4) & 0x0f) as usize;
975 let rate_idx = ((data[i + 2] >> 2) & 0x03) as usize;
976 let padding = ((data[i + 2] >> 1) & 0x01) as u32;
977
978 let is_v1 = version_bits == 3;
979 let bitrate_kbps = if is_v1 {
980 BITRATES_V1L3[bitrate_idx]
981 } else {
982 BITRATES_V2L3[bitrate_idx]
983 };
984 let sample_rate = match version_bits {
985 3 => RATES_V1[rate_idx],
986 2 => RATES_V2[rate_idx],
987 _ => RATES_V25[rate_idx],
988 };
989 if bitrate_kbps == 0 || sample_rate == 0 {
990 i += 1;
991 continue;
992 }
993
994 let samples_per_frame: u32 = if is_v1 { 1152 } else { 576 };
996 let frame_len =
997 ((samples_per_frame / 8) * bitrate_kbps * 1000 / sample_rate + padding) as usize;
998 if frame_len == 0 {
999 i += 1;
1000 continue;
1001 }
1002 let is_header_frame = frames == 0
1005 && data[i..(i + frame_len).min(data.len())]
1006 .windows(4)
1007 .any(|w| w == b"Xing" || w == b"Info" || w == b"VBRI");
1008 if is_header_frame {
1009 i += frame_len;
1010 continue;
1011 }
1012 seconds += samples_per_frame as f64 / sample_rate as f64;
1013 frames += 1;
1014 i += frame_len;
1015 }
1016
1017 (frames > 0).then_some(seconds)
1018}
1019
1020fn resolve_output_under(
1021 root: &Path,
1022 params: &Value,
1023 default_rel: &str,
1024) -> Result<(String, PathBuf), String> {
1025 let rel = params
1026 .get("output_path")
1027 .and_then(|v| v.as_str())
1028 .filter(|s| !s.trim().is_empty())
1029 .map(|s| s.to_string())
1030 .unwrap_or_else(|| default_rel.to_string());
1031
1032 if !stays_under(root, &rel) {
1033 return Err(format!("output_path '{rel}' escapes the working directory"));
1034 }
1035 let abs = root.join(&rel);
1036 let parent = abs
1037 .parent()
1038 .ok_or_else(|| "output_path has no parent directory".to_string())?;
1039 std::fs::create_dir_all(parent).map_err(|e| format!("create output dir: {e}"))?;
1040
1041 let root_real = root
1042 .canonicalize()
1043 .map_err(|e| format!("resolve working directory: {e}"))?;
1044 let parent_real = parent
1045 .canonicalize()
1046 .map_err(|e| format!("resolve output directory: {e}"))?;
1047 if !parent_real.starts_with(&root_real) {
1048 return Err(format!(
1049 "output_path '{rel}' resolves outside the working directory"
1050 ));
1051 }
1052 let file_name = abs
1053 .file_name()
1054 .ok_or_else(|| "output_path has no file name".to_string())?;
1055 Ok((rel, parent_real.join(file_name)))
1056}
1057
1058fn commercial_name(brief: &str) -> String {
1061 let first = brief
1062 .lines()
1063 .map(str::trim)
1064 .find(|l| !l.is_empty())
1065 .unwrap_or("");
1066 let name: String = first.chars().take(60).collect();
1067 let name = name.trim();
1068 if name.is_empty() {
1069 "Commercial".to_string()
1070 } else {
1071 format!("Commercial — {name}")
1072 }
1073}
1074
1075fn slug(s: &str) -> String {
1077 let mut out = String::new();
1078 for c in s.chars() {
1079 if c.is_ascii_alphanumeric() {
1080 out.push(c.to_ascii_lowercase());
1081 } else if !out.ends_with('-') {
1082 out.push('-');
1083 }
1084 if out.len() >= 40 {
1085 break;
1086 }
1087 }
1088 let trimmed = out.trim_matches('-').to_string();
1089 if trimmed.is_empty() {
1090 "music".to_string()
1091 } else {
1092 trimmed
1093 }
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098 use super::*;
1099
1100 #[tokio::test]
1101 async fn unknown_tool_falls_through() {
1102 let tools = StudioMediaTools::new(std::env::temp_dir());
1103 let err = tools.execute("nope", &json!({})).await.unwrap_err();
1104 assert!(err.starts_with("unknown tool"), "{err}");
1105 }
1106
1107 #[test]
1108 fn studio_tools_require_full_access_tier() {
1109 let defs = studio_tool_defs();
1110 let names: Vec<_> = defs.iter().filter_map(|def| def["name"].as_str()).collect();
1111 assert_eq!(
1112 names,
1113 vec![
1114 "generate_music",
1115 "generate_jingle",
1116 "generate_studio_image",
1117 "generate_song",
1118 "list_voices",
1119 "generate_voiceover",
1120 "generate_video",
1121 "produce_commercial"
1122 ]
1123 );
1124 for def in defs {
1125 assert_eq!(
1126 def["tier"], STUDIO_TOOL_TIER,
1127 "{} must require full-access approval because it calls an external Studio service",
1128 def["name"]
1129 );
1130 let expect_mutating = def["name"] != "list_voices";
1133 assert_eq!(
1134 def["mutating"], expect_mutating,
1135 "{} has the wrong mutating flag",
1136 def["name"]
1137 );
1138 }
1139 }
1140
1141 #[tokio::test]
1142 async fn generate_music_rejects_empty_prompt_and_escaping_path() {
1143 let tools = StudioMediaTools::new(std::env::temp_dir());
1144 assert!(tools
1146 .execute("generate_music", &json!({"prompt": " "}))
1147 .await
1148 .unwrap_err()
1149 .contains("non-empty"));
1150 assert!(tools
1152 .execute(
1153 "generate_music",
1154 &json!({"prompt": "x", "output_path": "../escape.mp3"})
1155 )
1156 .await
1157 .unwrap_err()
1158 .contains("escapes"));
1159 }
1160
1161 #[tokio::test]
1162 async fn generate_jingle_rejects_empty_brand_and_escaping_path() {
1163 let tools = StudioMediaTools::new(std::env::temp_dir());
1164 assert!(tools
1165 .execute("generate_jingle", &json!({"brand_name": " "}))
1166 .await
1167 .unwrap_err()
1168 .contains("non-empty"));
1169 assert!(tools
1170 .execute(
1171 "generate_jingle",
1172 &json!({"brand_name": "Apex", "output_path": "../escape.mp3"})
1173 )
1174 .await
1175 .unwrap_err()
1176 .contains("escapes"));
1177 }
1178
1179 #[tokio::test]
1180 async fn generate_studio_image_rejects_empty_prompt_and_escaping_path() {
1181 let tools = StudioMediaTools::new(std::env::temp_dir());
1182 assert!(tools
1183 .execute("generate_studio_image", &json!({"prompt": " "}))
1184 .await
1185 .unwrap_err()
1186 .contains("non-empty"));
1187 assert!(tools
1188 .execute(
1189 "generate_studio_image",
1190 &json!({"prompt": "x", "output_path": "../escape.png"})
1191 )
1192 .await
1193 .unwrap_err()
1194 .contains("escapes"));
1195 }
1196
1197 #[tokio::test]
1198 async fn generate_song_rejects_empty_prompt_and_escaping_path() {
1199 let tools = StudioMediaTools::new(std::env::temp_dir());
1200 assert!(tools
1201 .execute("generate_song", &json!({"prompt": " "}))
1202 .await
1203 .unwrap_err()
1204 .contains("non-empty"));
1205 assert!(tools
1206 .execute(
1207 "generate_song",
1208 &json!({"prompt": "x", "output_path": "../escape.mp3"})
1209 )
1210 .await
1211 .unwrap_err()
1212 .contains("escapes"));
1213 }
1214
1215 fn synth_mp3(count: usize, with_xing: bool) -> Vec<u8> {
1219 const FRAME_LEN: usize = 417;
1221 let mut out = Vec::new();
1222 let mut frame = |tag: Option<&[u8]>| {
1223 let mut f = vec![0u8; FRAME_LEN];
1224 f[0] = 0xff;
1225 f[1] = 0xfb; f[2] = 0x90; f[3] = 0x00;
1228 if let Some(t) = tag {
1229 f[36..36 + t.len()].copy_from_slice(t);
1230 }
1231 out.extend_from_slice(&f);
1232 };
1233 if with_xing {
1234 frame(Some(b"Xing"));
1235 }
1236 for _ in 0..count {
1237 frame(None);
1238 }
1239 out
1240 }
1241
1242 #[test]
1243 fn mp3_duration_sums_frames() {
1244 let dir = tempfile::tempdir().unwrap();
1245 let p = dir.path().join("a.mp3");
1246 std::fs::write(&p, synth_mp3(100, false)).unwrap();
1247 let d = mp3_duration_seconds(&p).expect("parses");
1249 assert!((d - 2.612).abs() < 0.01, "got {d}");
1250 }
1251
1252 #[test]
1253 fn mp3_duration_excludes_xing_header_frame() {
1254 let dir = tempfile::tempdir().unwrap();
1255 let with = dir.path().join("with.mp3");
1256 let without = dir.path().join("without.mp3");
1257 std::fs::write(&with, synth_mp3(50, true)).unwrap();
1258 std::fs::write(&without, synth_mp3(50, false)).unwrap();
1259 let a = mp3_duration_seconds(&with).expect("parses");
1261 let b = mp3_duration_seconds(&without).expect("parses");
1262 assert!(
1263 (a - b).abs() < 1e-9,
1264 "xing frame counted as audio: {a} vs {b}"
1265 );
1266 }
1267
1268 #[test]
1269 fn mp3_duration_skips_id3_tag() {
1270 let dir = tempfile::tempdir().unwrap();
1271 let p = dir.path().join("tagged.mp3");
1272 let audio = synth_mp3(10, false);
1273 let mut bytes = vec![b'I', b'D', b'3', 3, 0, 0, 0, 0, 0, 20];
1275 bytes.extend_from_slice(&[0u8; 20]);
1276 bytes.extend_from_slice(&audio);
1277 std::fs::write(&p, &bytes).unwrap();
1278 let d = mp3_duration_seconds(&p).expect("parses");
1279 assert!((d - 0.2612).abs() < 0.01, "got {d}");
1280 }
1281
1282 #[test]
1283 fn mp3_duration_returns_none_for_non_mpeg() {
1284 let dir = tempfile::tempdir().unwrap();
1285 let p = dir.path().join("junk.bin");
1286 std::fs::write(&p, b"this is not audio at all, not even close").unwrap();
1287 assert!(mp3_duration_seconds(&p).is_none());
1288 }
1289
1290 #[test]
1291 fn slug_is_bounded_and_safe() {
1292 assert_eq!(slug("Upbeat Electronic!! Theme"), "upbeat-electronic-theme");
1293 assert_eq!(slug("***"), "music");
1294 assert!(slug(&"x".repeat(100)).len() <= 40);
1295 }
1296
1297 #[tokio::test]
1298 async fn produce_commercial_rejects_empty_brief_and_escaping_path() {
1299 let tools = StudioMediaTools::new(std::env::temp_dir());
1300 assert!(tools
1302 .execute("produce_commercial", &json!({"brief": " "}))
1303 .await
1304 .unwrap_err()
1305 .contains("non-empty"));
1306 assert!(tools
1308 .execute(
1309 "produce_commercial",
1310 &json!({"brief": "Sell CHEESUS", "output_path": "../escape.mp4"})
1311 )
1312 .await
1313 .unwrap_err()
1314 .contains("escapes"));
1315 }
1316
1317 #[test]
1318 fn commercial_name_is_bounded_and_falls_back() {
1319 assert_eq!(
1320 commercial_name("Sell CHEESUS to snack lovers"),
1321 "Commercial — Sell CHEESUS to snack lovers"
1322 );
1323 assert_eq!(
1325 commercial_name("\n\n Hero shot \nmore"),
1326 "Commercial — Hero shot"
1327 );
1328 assert!(commercial_name(&"x".repeat(200)).len() <= "Commercial — ".len() + 60);
1329 assert_eq!(commercial_name(" "), "Commercial");
1331 }
1332}