use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use car_engine::ToolExecutor;
use car_parslee::studio::StudioClient;
use serde_json::{json, Value};
use crate::coder::policy::stays_under;
const STUDIO_TOOL_TIER: &str = "full_access";
pub struct StudioMediaTools {
studio: Arc<StudioClient>,
http: reqwest::Client,
root: PathBuf,
}
impl StudioMediaTools {
pub fn new(root: PathBuf) -> Self {
Self {
studio: Arc::new(StudioClient::new()),
http: reqwest::Client::new(),
root,
}
}
fn available(&self) -> bool {
car_auth::access_token().is_some()
}
pub fn tool_defs(&self) -> Vec<Value> {
if !self.available() {
return Vec::new();
}
studio_tool_defs()
}
}
fn studio_tool_defs() -> Vec<Value> {
vec![
json!({
"name": "generate_music",
"description": "Generate an original music track from a text prompt via Parslee Studio \
(ElevenLabs Music). Writes an audio file under the working directory and returns its \
path — use it for game soundtracks, background music, intros, ambience. Takes ~30s. \
The result is a file path; embed it (e.g. <audio src>) or read it like any file.",
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "Describe the music: mood, genre, instruments, tempo, and intended use."
},
"duration_seconds": {
"type": "integer",
"description": "Length in seconds (default 30; clamped to 5–300)."
},
"output_path": {
"type": "string",
"description": "Where to write the audio, relative to the working directory (default assets/<slug>.mp3)."
}
},
"required": ["prompt"]
},
"mutating": true,
"tier": STUDIO_TOOL_TIER
}),
json!({
"name": "generate_jingle",
"description": "Generate a short sonic-branding jingle (branded audio) for a brand or \
product via Parslee Studio. Writes an audio file under the working directory and \
returns its path — use it for brand stings, app/game intros, ad audio, logo sounds. \
Takes ~30s. The result is a file path; embed it or read it like any file.",
"parameters": {
"type": "object",
"properties": {
"brand_name": {
"type": "string",
"description": "The brand or product the jingle is for."
},
"style": {
"type": "string",
"description": "Optional musical style/mood (e.g. 'upbeat corporate', 'luxury cinematic')."
},
"tagline": {
"type": "string",
"description": "Optional tagline or lyric to feature."
},
"output_path": {
"type": "string",
"description": "Where to write the audio, relative to the working directory (default assets/<slug>-jingle.mp3)."
}
},
"required": ["brand_name"]
},
"mutating": true,
"tier": STUDIO_TOOL_TIER
}),
json!({
"name": "generate_studio_image",
"description": "Generate a HIGH-QUALITY image via Parslee Studio (gpt-image-2). Unlike \
the local generate_image, this reliably renders LEGIBLE TEXT inside the image \
(titles, signage, logos with words) and follows complex prompts more faithfully — \
use it for posters, covers, UI mockups with real labels, or any image that must \
contain readable text. Writes the image under the working directory and returns \
its path. It is SLOW (often 1–4 minutes) and near its time budget, so it can \
occasionally return a timeout error — if that happens, retry once, or fall back \
to the local generate_image (fast) when you don't need readable in-image text. \
The result is a file path; reference it from HTML/CSS or read it like any file.",
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "What the image should depict. Put any exact text that must appear in quotes."
},
"aspect_ratio": {
"type": "string",
"description": "e.g. '16:9', '1:1', '9:16' (default 16:9)."
},
"quality": {
"type": "string",
"description": "'low' | 'medium' | 'high' (default high)."
},
"output_path": {
"type": "string",
"description": "Where to write the PNG, relative to the working directory (default assets/<slug>.png)."
}
},
"required": ["prompt"]
},
"mutating": true,
"tier": STUDIO_TOOL_TIER
}),
json!({
"name": "generate_song",
"description": "Generate a full SONG WITH VOCALS AND LYRICS via Parslee Studio (Suno), \
up to 8 minutes — distinct from generate_music, which makes shorter instrumental / \
ambience tracks. Use it when you want an actual song: a theme with a sung chorus, a \
branded anthem, or lyrics you supply set to music. Writes an audio file under the \
working directory and returns its path. Takes a few minutes (async). The result is \
a file path; embed it or read it like any file.",
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "Describe the song: theme, mood, genre, tempo."
},
"duration_seconds": {
"type": "integer",
"description": "Length in seconds (default 60; clamped to 30–480)."
},
"style": {
"type": "string",
"description": "Optional musical style (e.g. 'upbeat pop', 'orchestral cinematic')."
},
"lyrics": {
"type": "string",
"description": "Optional lyrics to sing. When provided, vocals are generated."
},
"instrumental": {
"type": "boolean",
"description": "True for no vocals (default: false when lyrics are given, else true)."
},
"title": {
"type": "string",
"description": "Optional song title."
},
"output_path": {
"type": "string",
"description": "Where to write the audio, relative to the working directory (default assets/<slug>.mp3)."
}
},
"required": ["prompt"]
},
"mutating": true,
"tier": STUDIO_TOOL_TIER
}),
]
}
impl StudioMediaTools {
async fn run_generate_music(&self, params: &Value) -> Result<Value, String> {
let prompt = params
.get("prompt")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or("generate_music requires a non-empty `prompt`")?;
let seconds = params
.get("duration_seconds")
.and_then(Value::as_u64)
.unwrap_or(30)
.clamp(5, 300);
let (rel, final_path) =
resolve_output_under(&self.root, params, &format!("assets/{}.mp3", slug(prompt)))?;
let result = self
.studio
.generate_music(prompt, (seconds as u32) * 1000)
.await
.map_err(|e| format!("music generation failed: {e}"))?;
let bytes = self
.http
.get(&result.audio_url)
.send()
.await
.map_err(|e| format!("download music: {e}"))?
.error_for_status()
.map_err(|e| format!("download music: {e}"))?
.bytes()
.await
.map_err(|e| format!("read music bytes: {e}"))?;
std::fs::write(&final_path, &bytes).map_err(|e| format!("write music file: {e}"))?;
Ok(json!({
"audio_path": rel,
"media_type": "audio/mpeg",
"bytes": bytes.len(),
"note": format!("Wrote generated music to {rel} ({} KB). Embed it (<audio controls src=\"{rel}\">) or read it like any file.", bytes.len() / 1024),
}))
}
async fn run_generate_jingle(&self, params: &Value) -> Result<Value, String> {
let brand = params
.get("brand_name")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or("generate_jingle requires a non-empty `brand_name`")?;
let style = params
.get("style")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let tagline = params
.get("tagline")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let (rel, final_path) = resolve_output_under(
&self.root,
params,
&format!("assets/{}-jingle.mp3", slug(brand)),
)?;
let result = self
.studio
.generate_jingle(brand, style, tagline, 1)
.await
.map_err(|e| format!("jingle generation failed: {e}"))?;
let audio_url = result
.audio_urls
.first()
.ok_or("Studio returned no jingle audio")?;
let bytes = self
.http
.get(audio_url)
.send()
.await
.map_err(|e| format!("download jingle: {e}"))?
.error_for_status()
.map_err(|e| format!("download jingle: {e}"))?
.bytes()
.await
.map_err(|e| format!("read jingle bytes: {e}"))?;
std::fs::write(&final_path, &bytes).map_err(|e| format!("write jingle file: {e}"))?;
Ok(json!({
"audio_path": rel,
"media_type": "audio/mpeg",
"bytes": bytes.len(),
"note": format!("Wrote generated jingle to {rel} ({} KB). Embed it (<audio controls src=\"{rel}\">) or read it like any file.", bytes.len() / 1024),
}))
}
async fn run_generate_studio_image(&self, params: &Value) -> Result<Value, String> {
let prompt = params
.get("prompt")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or("generate_studio_image requires a non-empty `prompt`")?;
let aspect = params
.get("aspect_ratio")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let quality = params
.get("quality")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let (rel, final_path) =
resolve_output_under(&self.root, params, &format!("assets/{}.png", slug(prompt)))?;
let deadline = Instant::now() + Duration::from_secs(330);
let result = self
.studio
.generate_image_hq(prompt, aspect, quality, deadline)
.await
.map_err(|e| format!("studio image generation failed: {e}"))?;
let n = self
.download_to(&result.image_url, &final_path, "image")
.await?;
Ok(json!({
"image_path": rel,
"media_type": "image/png",
"bytes": n,
"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),
}))
}
async fn run_generate_song(&self, params: &Value) -> Result<Value, String> {
let prompt = params
.get("prompt")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or("generate_song requires a non-empty `prompt`")?;
let seconds = params
.get("duration_seconds")
.and_then(Value::as_u64)
.unwrap_or(60)
.clamp(30, 480) as u32;
let style = params
.get("style")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let lyrics = params
.get("lyrics")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let title = params
.get("title")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let instrumental = params
.get("instrumental")
.and_then(Value::as_bool)
.unwrap_or(lyrics.is_none());
let (rel, final_path) =
resolve_output_under(&self.root, params, &format!("assets/{}.mp3", slug(prompt)))?;
let deadline = Instant::now() + Duration::from_secs(360);
let result = self
.studio
.generate_music_suno(
prompt,
seconds,
style,
instrumental,
lyrics,
title,
deadline,
)
.await
.map_err(|e| format!("song generation failed: {e}"))?;
let n = self
.download_to(&result.audio_url, &final_path, "song")
.await?;
Ok(json!({
"audio_path": rel,
"media_type": "audio/mpeg",
"bytes": n,
"note": format!("Wrote a generated song to {rel} ({} KB). Embed it (<audio controls src=\"{rel}\">) or read it like any file.", n / 1024),
}))
}
async fn download_to(&self, url: &str, final_path: &Path, what: &str) -> Result<usize, String> {
let bytes = self
.http
.get(url)
.send()
.await
.map_err(|e| format!("download {what}: {e}"))?
.error_for_status()
.map_err(|e| format!("download {what}: {e}"))?
.bytes()
.await
.map_err(|e| format!("read {what} bytes: {e}"))?;
std::fs::write(final_path, &bytes).map_err(|e| format!("write {what} file: {e}"))?;
Ok(bytes.len())
}
}
#[async_trait]
impl ToolExecutor for StudioMediaTools {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
match tool {
"generate_music" => self.run_generate_music(params).await,
"generate_jingle" => self.run_generate_jingle(params).await,
"generate_studio_image" => self.run_generate_studio_image(params).await,
"generate_song" => self.run_generate_song(params).await,
other => Err(format!("unknown tool: '{other}'")),
}
}
}
fn resolve_output_under(
root: &Path,
params: &Value,
default_rel: &str,
) -> Result<(String, PathBuf), String> {
let rel = params
.get("output_path")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string())
.unwrap_or_else(|| default_rel.to_string());
if !stays_under(root, &rel) {
return Err(format!("output_path '{rel}' escapes the working directory"));
}
let abs = root.join(&rel);
let parent = abs
.parent()
.ok_or_else(|| "output_path has no parent directory".to_string())?;
std::fs::create_dir_all(parent).map_err(|e| format!("create output dir: {e}"))?;
let root_real = root
.canonicalize()
.map_err(|e| format!("resolve working directory: {e}"))?;
let parent_real = parent
.canonicalize()
.map_err(|e| format!("resolve output directory: {e}"))?;
if !parent_real.starts_with(&root_real) {
return Err(format!(
"output_path '{rel}' resolves outside the working directory"
));
}
let file_name = abs
.file_name()
.ok_or_else(|| "output_path has no file name".to_string())?;
Ok((rel, parent_real.join(file_name)))
}
fn slug(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
if out.len() >= 40 {
break;
}
}
let trimmed = out.trim_matches('-').to_string();
if trimmed.is_empty() {
"music".to_string()
} else {
trimmed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn unknown_tool_falls_through() {
let tools = StudioMediaTools::new(std::env::temp_dir());
let err = tools.execute("nope", &json!({})).await.unwrap_err();
assert!(err.starts_with("unknown tool"), "{err}");
}
#[test]
fn studio_tools_require_full_access_tier() {
let defs = studio_tool_defs();
let names: Vec<_> = defs.iter().filter_map(|def| def["name"].as_str()).collect();
assert_eq!(
names,
vec![
"generate_music",
"generate_jingle",
"generate_studio_image",
"generate_song"
]
);
for def in defs {
assert_eq!(
def["tier"], STUDIO_TOOL_TIER,
"{} must require full-access approval because it calls an external Studio service",
def["name"]
);
assert_eq!(def["mutating"], true);
}
}
#[tokio::test]
async fn generate_music_rejects_empty_prompt_and_escaping_path() {
let tools = StudioMediaTools::new(std::env::temp_dir());
assert!(tools
.execute("generate_music", &json!({"prompt": " "}))
.await
.unwrap_err()
.contains("non-empty"));
assert!(tools
.execute(
"generate_music",
&json!({"prompt": "x", "output_path": "../escape.mp3"})
)
.await
.unwrap_err()
.contains("escapes"));
}
#[tokio::test]
async fn generate_jingle_rejects_empty_brand_and_escaping_path() {
let tools = StudioMediaTools::new(std::env::temp_dir());
assert!(tools
.execute("generate_jingle", &json!({"brand_name": " "}))
.await
.unwrap_err()
.contains("non-empty"));
assert!(tools
.execute(
"generate_jingle",
&json!({"brand_name": "Apex", "output_path": "../escape.mp3"})
)
.await
.unwrap_err()
.contains("escapes"));
}
#[tokio::test]
async fn generate_studio_image_rejects_empty_prompt_and_escaping_path() {
let tools = StudioMediaTools::new(std::env::temp_dir());
assert!(tools
.execute("generate_studio_image", &json!({"prompt": " "}))
.await
.unwrap_err()
.contains("non-empty"));
assert!(tools
.execute(
"generate_studio_image",
&json!({"prompt": "x", "output_path": "../escape.png"})
)
.await
.unwrap_err()
.contains("escapes"));
}
#[tokio::test]
async fn generate_song_rejects_empty_prompt_and_escaping_path() {
let tools = StudioMediaTools::new(std::env::temp_dir());
assert!(tools
.execute("generate_song", &json!({"prompt": " "}))
.await
.unwrap_err()
.contains("non-empty"));
assert!(tools
.execute(
"generate_song",
&json!({"prompt": "x", "output_path": "../escape.mp3"})
)
.await
.unwrap_err()
.contains("escapes"));
}
#[test]
fn slug_is_bounded_and_safe() {
assert_eq!(slug("Upbeat Electronic!! Theme"), "upbeat-electronic-theme");
assert_eq!(slug("***"), "music");
assert!(slug(&"x".repeat(100)).len() <= 40);
}
}