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": "list_voices",
"description": "List the speaking voices available for generate_voiceover — both Studio's \
stock presets and any voices this organization has CLONED from real recordings of \
real people. Call this FIRST whenever the user asks for narration 'in my voice', in \
a named person's voice, or in a particular style, so you can pick the right one \
instead of guessing. Read-only and instant. Each entry has an id, a name, and a \
source ('cloned' = a real person's voice, 'preset' = a stock voice).",
"parameters": {"type": "object", "properties": {}, "required": []},
"mutating": false,
"tier": STUDIO_TOOL_TIER
}),
json!({
"name": "generate_voiceover",
"description": "Generate NARRATION / VOICEOVER speech from text in a SPECIFIC voice via \
Parslee Studio (ElevenLabs), including this organization's cloned real-person \
voices. Writes an audio file under the working directory and returns its path plus \
its measured duration in seconds. Prefer this over generate_speech whenever the \
voice matters — generate_speech uses a local stock voice and CANNOT do a named or \
cloned voice. Use it for video narration, explainers, training courses, audiobooks, \
and per-slide voiceover. Synchronous, a few seconds per call. Call list_voices first \
to choose a voice.",
"parameters": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The words to speak. Write it as natural spoken narration, not slide text."
},
"voice": {
"type": "string",
"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."
},
"rate": {
"type": "string",
"description": "Speaking rate, e.g. \"-10%\". CAUTION — this is NOT a delta \
from the default: OMITTING it gives ~126 wpm, while supplying \"+0%\" \
gives ~161 wpm, so any value at all speeds the voice up substantially. \
Measured against this org's cloned voice: omitted 126, \"-20%\" 129, \
\"-10%\" 145, \"+0%\" 161, \"+10%\" 177, \"+20%\" 194. Natural \
presentation pace is 130-150 wpm, so USE \"-10%\" FOR NARRATION; \
omitting it sounds noticeably slow over a long video."
},
"output_path": {
"type": "string",
"description": "Where to write the MP3, relative to the working directory (default assets/<slug>.mp3)."
}
},
"required": ["text"]
},
"mutating": true,
"tier": STUDIO_TOOL_TIER
}),
json!({
"name": "generate_video",
"description": "Animate a STILL IMAGE into a short video clip (image-to-video), or \
generate a clip from text alone, via Parslee Studio. Give it `image_path` to \
animate an existing image — camera moves, drifting light, subtle motion — or omit \
it for text-to-video. Writes an MP4 under the working directory and returns its \
path. Takes roughly 1–4 minutes per clip. \
IMPORTANT: this is a diffusion model that repaints every frame, so any fine TEXT, \
NUMBERS, TABLES, CHARTS, or UI in the source image WILL be warped into gibberish. \
Use it on pictorial, abstract, or title imagery. To add motion to a text-heavy \
slide or screenshot, do NOT use this — keep the image crisp and animate it with an \
ffmpeg pan/zoom (Ken Burns) via the shell instead.",
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "DESCRIBE THE IMAGE FIRST, THEN THE MOTION. The model does not see the \
source image the way you do — if you give it only a camera direction it has no idea \
what it is looking at, and you get a hovering, shaky camera over an inert picture \
instead of animation. Name the actual subject, colours, layout and mood of THIS \
image, then say what should move and how. Good: 'A deep navy title card; large white \
serif title at left; angular pale-blue shard shapes fanning across the right side. \
The shards drift slowly outward and catch a soft moving highlight while the camera \
pushes in almost imperceptibly.' Bad: 'slow cinematic push-in'."
},
"image_path": {
"type": "string",
"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)."
},
"duration_seconds": {
"type": "integer",
"description": "Clip length in seconds (default 5; keep short — cost and time scale with it)."
},
"provider": {
"type": "string",
"description": "Optional backend: 'veo' (default), 'kling', or 'ltx'. Studio has no server-side default, so one is always sent."
},
"output_path": {
"type": "string",
"description": "Where to write the MP4, relative to the working directory (default assets/<slug>.mp4)."
}
},
"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_list_voices(&self) -> Result<Value, String> {
let voices = self
.studio
.list_voices()
.await
.map_err(|e| format!("list voices failed: {e}"))?;
let (cloned, preset): (Vec<_>, Vec<_>) = voices.iter().partition(|v| v.source == "cloned");
let render = |v: &car_parslee::studio::StudioVoice| {
json!({
"id": v.id,
"name": v.name,
"source": v.source,
"description": v.description,
})
};
Ok(json!({
"cloned_voices": cloned.iter().map(|v| render(v)).collect::<Vec<_>>(),
"preset_voices": preset.iter().map(|v| render(v)).collect::<Vec<_>>(),
"note": format!(
"{} cloned (real-person) and {} preset voices. Pass a name or id as `voice` to generate_voiceover.",
cloned.len(),
preset.len()
),
}))
}
async fn resolve_voice(&self, voice: &str) -> Result<String, String> {
let want = voice.trim();
let looks_like_id = want.len() >= 20 && want.chars().all(|c| c.is_ascii_alphanumeric());
if looks_like_id {
return Ok(want.to_string());
}
let voices = self
.studio
.list_voices()
.await
.map_err(|e| format!("resolve voice '{want}': {e}"))?;
let lower = want.to_ascii_lowercase();
if let Some(v) = voices
.iter()
.find(|v| v.name.to_ascii_lowercase() == lower && !v.id.is_empty())
{
return Ok(v.id.clone());
}
let hits: Vec<_> = voices
.iter()
.filter(|v| v.name.to_ascii_lowercase().contains(&lower) && !v.id.is_empty())
.collect();
match hits.len() {
1 => Ok(hits[0].id.clone()),
0 => Err(format!(
"no voice matches '{want}'. Call list_voices to see what's available."
)),
_ => Err(format!(
"'{want}' is ambiguous — matches {}. Use the exact name or id.",
hits.iter()
.map(|v| format!("'{}'", v.name))
.collect::<Vec<_>>()
.join(", ")
)),
}
}
async fn run_generate_voiceover(&self, params: &Value) -> Result<Value, String> {
let text = params
.get("text")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or("generate_voiceover requires non-empty `text`")?;
let rate = params.get("rate").and_then(|v| v.as_str());
let (rel, final_path) =
resolve_output_under(&self.root, params, &format!("assets/{}.mp3", slug(text)))?;
let voice_id = match params.get("voice").and_then(|v| v.as_str()) {
Some(v) if !v.trim().is_empty() => Some(self.resolve_voice(v).await?),
_ => None,
};
let result = self
.studio
.synthesize_voiceover(text, voice_id.as_deref(), rate)
.await
.map_err(|e| format!("voiceover generation failed: {e}"))?;
let bytes = self
.http
.get(&result.audio_url)
.send()
.await
.map_err(|e| format!("download voiceover: {e}"))?
.error_for_status()
.map_err(|e| format!("download voiceover: {e}"))?
.bytes()
.await
.map_err(|e| format!("read voiceover bytes: {e}"))?;
std::fs::write(&final_path, &bytes).map_err(|e| format!("write voiceover file: {e}"))?;
let duration = mp3_duration_seconds(&final_path);
Ok(json!({
"audio_path": rel,
"media_type": "audio/mpeg",
"bytes": bytes.len(),
"duration_seconds": duration,
"voice_id": voice_id,
"note": match duration {
Some(d) => format!(
"Wrote voiceover to {rel} ({d:.2}s, {} KB).",
bytes.len() / 1024
),
None => format!("Wrote voiceover to {rel} ({} KB).", bytes.len() / 1024),
},
}))
}
async fn run_generate_video(&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_video requires a non-empty `prompt`")?;
let seconds = params
.get("duration_seconds")
.and_then(Value::as_u64)
.unwrap_or(5)
.clamp(1, 30) as u32;
let provider = params.get("provider").and_then(|v| v.as_str());
let (rel, final_path) =
resolve_output_under(&self.root, params, &format!("assets/{}.mp4", slug(prompt)))?;
let source_url = match params.get("image_path").and_then(|v| v.as_str()) {
Some(p) if !p.trim().is_empty() => {
let src = resolve_input_under(&self.root, p)?;
let bytes = std::fs::read(&src)
.map_err(|e| format!("read source image {}: {e}", src.display()))?;
let name = src
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("frame.png")
.to_string();
Some(
self.studio
.upload_reference_image(bytes, &name)
.await
.map_err(|e| format!("upload source image: {e}"))?,
)
}
_ => None,
};
let result = self
.studio
.animate_image(source_url.as_deref(), prompt, seconds, provider, 300)
.await
.map_err(|e| format!("video generation failed: {e}"))?;
let resp = self
.http
.get(&result.video_url)
.send()
.await
.map_err(|e| format!("download video: {e}"))?;
let status = resp.status();
if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::NOT_FOUND {
return Err(format!(
"Studio generated the video but returned a URL that cannot be read \
(HTTP {status}). It looks like a PROVIDER-hosted URL that needs the \
provider's own API key, not a Studio storage URL — Studio's result \
normalization skips its blob upload whenever the provider already set \
a URL. Upstream Studio fix; not retryable from here. URL: {}",
result.video_url
));
}
let bytes = resp
.error_for_status()
.map_err(|e| format!("download video: {e}"))?
.bytes()
.await
.map_err(|e| format!("read video bytes: {e}"))?;
std::fs::write(&final_path, &bytes).map_err(|e| format!("write video file: {e}"))?;
Ok(json!({
"video_path": rel,
"media_type": "video/mp4",
"bytes": bytes.len(),
"mode": if source_url.is_some() { "image_to_video" } else { "text_to_video" },
"note": format!(
"Wrote a {seconds}s clip to {rel} ({} KB). Embed it (<video controls src=\"{rel}\">) or read it like any file.",
bytes.len() / 1024
),
}))
}
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 {
"list_voices" => self.run_list_voices().await,
"generate_voiceover" => self.run_generate_voiceover(params).await,
"generate_video" => self.run_generate_video(params).await,
"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_input_under(root: &Path, rel: &str) -> Result<PathBuf, String> {
if !stays_under(root, rel) {
return Err(format!("path '{rel}' escapes the working directory"));
}
let abs = root.join(rel);
let real = abs
.canonicalize()
.map_err(|e| format!("resolve '{rel}': {e}"))?;
let root_real = root
.canonicalize()
.map_err(|e| format!("resolve working directory: {e}"))?;
if !real.starts_with(&root_real) {
return Err(format!(
"path '{rel}' resolves outside the working directory"
));
}
Ok(real)
}
fn mp3_duration_seconds(path: &Path) -> Option<f64> {
const BITRATES_V1L3: [u32; 16] = [
0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0,
];
const BITRATES_V2L3: [u32; 16] = [
0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0,
];
const RATES_V1: [u32; 4] = [44100, 48000, 32000, 0];
const RATES_V2: [u32; 4] = [22050, 24000, 16000, 0];
const RATES_V25: [u32; 4] = [11025, 12000, 8000, 0];
let data = std::fs::read(path).ok()?;
let mut i = 0usize;
if data.len() > 10 && &data[0..3] == b"ID3" {
let size = ((data[6] as usize & 0x7f) << 21)
| ((data[7] as usize & 0x7f) << 14)
| ((data[8] as usize & 0x7f) << 7)
| (data[9] as usize & 0x7f);
i = 10 + size;
}
let mut seconds = 0.0f64;
let mut frames = 0u32;
while i + 4 <= data.len() {
if data[i] != 0xff || (data[i + 1] & 0xe0) != 0xe0 {
i += 1;
continue;
}
let version_bits = (data[i + 1] >> 3) & 0x03; let layer_bits = (data[i + 1] >> 1) & 0x03; if layer_bits != 1 || version_bits == 1 {
i += 1;
continue;
}
let bitrate_idx = ((data[i + 2] >> 4) & 0x0f) as usize;
let rate_idx = ((data[i + 2] >> 2) & 0x03) as usize;
let padding = ((data[i + 2] >> 1) & 0x01) as u32;
let is_v1 = version_bits == 3;
let bitrate_kbps = if is_v1 {
BITRATES_V1L3[bitrate_idx]
} else {
BITRATES_V2L3[bitrate_idx]
};
let sample_rate = match version_bits {
3 => RATES_V1[rate_idx],
2 => RATES_V2[rate_idx],
_ => RATES_V25[rate_idx],
};
if bitrate_kbps == 0 || sample_rate == 0 {
i += 1;
continue;
}
let samples_per_frame: u32 = if is_v1 { 1152 } else { 576 };
let frame_len =
((samples_per_frame / 8) * bitrate_kbps * 1000 / sample_rate + padding) as usize;
if frame_len == 0 {
i += 1;
continue;
}
let is_header_frame = frames == 0
&& data[i..(i + frame_len).min(data.len())]
.windows(4)
.any(|w| w == b"Xing" || w == b"Info" || w == b"VBRI");
if is_header_frame {
i += frame_len;
continue;
}
seconds += samples_per_frame as f64 / sample_rate as f64;
frames += 1;
i += frame_len;
}
(frames > 0).then_some(seconds)
}
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",
"list_voices",
"generate_voiceover",
"generate_video",
"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"]
);
let expect_mutating = def["name"] != "list_voices";
assert_eq!(
def["mutating"], expect_mutating,
"{} has the wrong mutating flag",
def["name"]
);
}
}
#[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"));
}
fn synth_mp3(count: usize, with_xing: bool) -> Vec<u8> {
const FRAME_LEN: usize = 417;
let mut out = Vec::new();
let mut frame = |tag: Option<&[u8]>| {
let mut f = vec![0u8; FRAME_LEN];
f[0] = 0xff;
f[1] = 0xfb; f[2] = 0x90; f[3] = 0x00;
if let Some(t) = tag {
f[36..36 + t.len()].copy_from_slice(t);
}
out.extend_from_slice(&f);
};
if with_xing {
frame(Some(b"Xing"));
}
for _ in 0..count {
frame(None);
}
out
}
#[test]
fn mp3_duration_sums_frames() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("a.mp3");
std::fs::write(&p, synth_mp3(100, false)).unwrap();
let d = mp3_duration_seconds(&p).expect("parses");
assert!((d - 2.612).abs() < 0.01, "got {d}");
}
#[test]
fn mp3_duration_excludes_xing_header_frame() {
let dir = tempfile::tempdir().unwrap();
let with = dir.path().join("with.mp3");
let without = dir.path().join("without.mp3");
std::fs::write(&with, synth_mp3(50, true)).unwrap();
std::fs::write(&without, synth_mp3(50, false)).unwrap();
let a = mp3_duration_seconds(&with).expect("parses");
let b = mp3_duration_seconds(&without).expect("parses");
assert!(
(a - b).abs() < 1e-9,
"xing frame counted as audio: {a} vs {b}"
);
}
#[test]
fn mp3_duration_skips_id3_tag() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("tagged.mp3");
let audio = synth_mp3(10, false);
let mut bytes = vec![b'I', b'D', b'3', 3, 0, 0, 0, 0, 0, 20];
bytes.extend_from_slice(&[0u8; 20]);
bytes.extend_from_slice(&audio);
std::fs::write(&p, &bytes).unwrap();
let d = mp3_duration_seconds(&p).expect("parses");
assert!((d - 0.2612).abs() < 0.01, "got {d}");
}
#[test]
fn mp3_duration_returns_none_for_non_mpeg() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("junk.bin");
std::fs::write(&p, b"this is not audio at all, not even close").unwrap();
assert!(mp3_duration_seconds(&p).is_none());
}
#[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");
}
}