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, VideoProductionRequest};
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::builder()
.timeout(Duration::from_secs(300))
.build()
.unwrap_or_default(),
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
}),
json!({
"name": "produce_commercial",
"description": "Produce a short COMMERCIAL VIDEO (shots, voiceover, and music) from a \
creative brief via Parslee Studio's video pipeline. Give it a brief describing the \
product and the ad you want; Studio plans shots, generates keyframes and video, \
adds a voiceover and a music bed, and assembles a finished MP4. Writes the video \
under the working directory and returns its path. This is SLOW — a real production \
runs many minutes (up to ~25). Use it when the user wants an actual video ad, \
promo, or commercial (for music/jingles/images use the other Studio tools). The \
result is a file path; embed it (<video src>) or read it like any file.",
"parameters": {
"type": "object",
"properties": {
"brief": {
"type": "string",
"description": "The creative brief: the product, the story/message, tone, and any must-have visuals."
},
"duration_seconds": {
"type": "integer",
"description": "Target length in seconds (default 20; clamped 8–60)."
},
"voiceover_script": {
"type": "string",
"description": "Optional exact voiceover narration. Omit to let Studio write one from the brief."
},
"voiceover_voice": {
"type": "string",
"description": "Optional voice name (default 'brian')."
},
"music": {
"type": "boolean",
"description": "Add a generated background music bed (default true)."
},
"output_path": {
"type": "string",
"description": "Where to write the MP4, relative to the working directory (default assets/<slug>.mp4)."
}
},
"required": ["brief"]
},
"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 run_produce_commercial(&self, params: &Value) -> Result<Value, String> {
let brief = params
.get("brief")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or("produce_commercial requires a non-empty `brief`")?;
let duration = params
.get("duration_seconds")
.and_then(Value::as_u64)
.unwrap_or(20)
.clamp(8, 60) as u32;
let voiceover_script = params
.get("voiceover_script")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let voiceover_voice = params
.get("voiceover_voice")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let music = params.get("music").and_then(Value::as_bool).unwrap_or(true);
let (rel, final_path) =
resolve_output_under(&self.root, params, &format!("assets/{}.mp4", slug(brief)))?;
let name = commercial_name(brief);
let req = VideoProductionRequest {
brief,
name: &name,
duration_seconds: duration,
video_format: "youtube_landscape",
voiceover_script,
voiceover_voice,
generate_music_bed: music,
};
let production = self
.studio
.start_video_production(&req)
.await
.map_err(|e| format!("start commercial production failed: {e}"))?;
let deadline = Instant::now() + Duration::from_secs(25 * 60);
let result = self
.studio
.poll_video_production(&production.project_id, deadline)
.await
.map_err(|e| format!("commercial production failed: {e}"))?;
let n = self
.download_to(&result.video_url, &final_path, "commercial")
.await?;
Ok(json!({
"video_path": rel,
"media_type": "video/mp4",
"bytes": n,
"project_id": production.project_id,
"note": format!("Wrote a Studio commercial to {rel} ({} KB). Embed it (<video 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,
"produce_commercial" => self.run_produce_commercial(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 commercial_name(brief: &str) -> String {
let first = brief
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("");
let name: String = first.chars().take(60).collect();
let name = name.trim();
if name.is_empty() {
"Commercial".to_string()
} else {
format!("Commercial — {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",
"produce_commercial"
]
);
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);
}
#[tokio::test]
async fn produce_commercial_rejects_empty_brief_and_escaping_path() {
let tools = StudioMediaTools::new(std::env::temp_dir());
assert!(tools
.execute("produce_commercial", &json!({"brief": " "}))
.await
.unwrap_err()
.contains("non-empty"));
assert!(tools
.execute(
"produce_commercial",
&json!({"brief": "Sell CHEESUS", "output_path": "../escape.mp4"})
)
.await
.unwrap_err()
.contains("escapes"));
}
#[test]
fn commercial_name_is_bounded_and_falls_back() {
assert_eq!(
commercial_name("Sell CHEESUS to snack lovers"),
"Commercial — Sell CHEESUS to snack lovers"
);
assert_eq!(
commercial_name("\n\n Hero shot \nmore"),
"Commercial — Hero shot"
);
assert!(commercial_name(&"x".repeat(200)).len() <= "Commercial — ".len() + 60);
assert_eq!(commercial_name(" "), "Commercial");
}
}