use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use car_engine::ToolExecutor;
use car_inference::tasks::synthesize::SynthesizeRequest;
use car_inference::{GenerateImageRequest, InferenceEngine, ModelCapability};
use serde_json::{json, Value};
use crate::coder::policy::stays_under;
pub struct MediaTools {
engine: Arc<InferenceEngine>,
root: PathBuf,
}
impl MediaTools {
pub fn new(engine: Arc<InferenceEngine>, root: PathBuf) -> Self {
Self { engine, root }
}
fn has_capability(&self, cap: ModelCapability) -> bool {
self.engine
.list_models_unified()
.iter()
.any(|m| m.available && m.capabilities.contains(&cap))
}
pub fn tool_defs(&self) -> Vec<Value> {
let mut defs = Vec::new();
if self.has_capability(ModelCapability::ImageGeneration) {
defs.push(image_tool_def());
}
if self.has_capability(ModelCapability::TextToSpeech) {
defs.push(speech_tool_def());
}
defs
}
fn resolve_output(
&self,
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(&self.root, &rel) {
return Err(format!("output_path '{rel}' escapes the working directory"));
}
let abs = self.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 = self
.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)))
}
async fn run_generate_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_image requires a non-empty `prompt`")?;
let (rel, final_path) =
self.resolve_output(params, &format!("assets/{}.png", slug(prompt)))?;
let clamp_dim = |v: Option<u64>| v.map(|n| (n as u32).clamp(64, 1536));
let req = GenerateImageRequest {
prompt: prompt.to_string(),
width: clamp_dim(params.get("width").and_then(Value::as_u64)),
height: clamp_dim(params.get("height").and_then(Value::as_u64)),
seed: params.get("seed").and_then(Value::as_u64),
output_path: Some(final_path.to_string_lossy().to_string()),
..Default::default()
};
let result = self
.engine
.generate_image(req)
.await
.map_err(|e| format!("image generation failed: {e}"))?;
Ok(json!({
"image_path": rel,
"media_type": result.media_type,
"model_used": result.model_used,
"note": format!("Wrote a generated image to {rel}. Reference it from HTML/CSS (e.g. <img src=\"{rel}\">) or read it like any file."),
}))
}
async fn run_generate_speech(&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_speech requires non-empty `text`")?;
let (rel, final_path) =
self.resolve_output(params, &format!("assets/{}.wav", slug(text)))?;
let req = SynthesizeRequest {
text: text.to_string(),
voice: params
.get("voice")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.map(String::from),
output_path: Some(final_path.to_string_lossy().to_string()),
..Default::default()
};
let result = self
.engine
.synthesize(req)
.await
.map_err(|e| format!("speech synthesis failed: {e}"))?;
Ok(json!({
"audio_path": rel,
"media_type": result.media_type,
"model_used": result.model_used,
"voice_used": result.voice_used,
"note": format!("Wrote generated speech audio to {rel}. Embed it (e.g. <audio controls src=\"{rel}\">) or read it like any file."),
}))
}
}
#[async_trait]
impl ToolExecutor for MediaTools {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
match tool {
"generate_image" => self.run_generate_image(params).await,
"generate_speech" => self.run_generate_speech(params).await,
other => Err(format!("unknown tool: '{other}'")),
}
}
}
fn image_tool_def() -> Value {
json!({
"name": "generate_image",
"description": "Generate an image from a text prompt using CAR's local image model, \
write it as a PNG under the working directory, and return its path. Use this to \
create real visual assets — illustrations, art, backgrounds, icons, textures, \
logos — that you can then reference from HTML/CSS or place in a project. The \
result is a file path; read or embed it like any other file. Generation takes \
roughly a minute, so prefer a few well-chosen images over many.",
"parameters": {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "What the image should depict. Be specific and descriptive (subject, style, lighting, composition)."},
"output_path": {"type": "string", "description": "Where to write the PNG, relative to the working directory (e.g. \"assets/jet.png\"). Parent directories are created. Defaults to \"assets/<slug>.png\"."},
"width": {"type": "integer", "description": "Pixels wide (default 768; keep <= 1024)."},
"height": {"type": "integer", "description": "Pixels tall (default 512; keep <= 1024)."},
"seed": {"type": "integer", "description": "Optional seed for a reproducible result."}
},
"required": ["prompt"]
},
"mutating": true,
"tier": "sandbox_edit"
})
}
fn speech_tool_def() -> Value {
json!({
"name": "generate_speech",
"description": "Generate spoken audio from text using CAR's local voice model, write it \
as a WAV under the working directory, and return its path. Use this for narration, \
voiceover, a spoken UI, or game/video audio — real synthesized speech, not a text \
placeholder. The result is a file path; embed it (e.g. <audio controls src=\"...\">) \
or read it like any other file.",
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "The words to speak."},
"output_path": {"type": "string", "description": "Where to write the WAV, relative to the working directory (e.g. \"assets/welcome.wav\"). Parent directories are created. Defaults to \"assets/<slug>.wav\"."},
"voice": {"type": "string", "description": "Optional voice id (e.g. \"af_heart\"). Omit for the default voice."}
},
"required": ["text"]
},
"mutating": true,
"tier": "sandbox_edit"
})
}
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() {
"media".to_string()
} else {
trimmed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_is_filesystem_safe_and_bounded() {
assert_eq!(
slug("A sleek Private Jet!!! at sunset"),
"a-sleek-private-jet-at-sunset"
);
assert_eq!(slug("***"), "media");
assert!(slug(&"x".repeat(100)).len() <= 40);
}
#[tokio::test]
async fn unknown_tool_falls_through() {
let engine = Arc::new(InferenceEngine::new(Default::default()));
let media = MediaTools::new(engine, std::env::temp_dir());
let err = media.execute("nope", &json!({})).await.unwrap_err();
assert!(err.starts_with("unknown tool"), "{err}");
}
#[tokio::test]
async fn generate_image_rejects_empty_prompt_and_escaping_path() {
let engine = Arc::new(InferenceEngine::new(Default::default()));
let media = MediaTools::new(engine, std::env::temp_dir());
assert!(media
.execute("generate_image", &json!({"prompt": " "}))
.await
.unwrap_err()
.contains("non-empty"));
assert!(media
.execute(
"generate_image",
&json!({"prompt": "x", "output_path": "../escape.png"})
)
.await
.unwrap_err()
.contains("escapes"));
}
#[tokio::test]
async fn generate_speech_rejects_empty_text_and_escaping_path() {
let engine = Arc::new(InferenceEngine::new(Default::default()));
let media = MediaTools::new(engine, std::env::temp_dir());
assert!(media
.execute("generate_speech", &json!({"text": " "}))
.await
.unwrap_err()
.contains("non-empty"));
assert!(media
.execute(
"generate_speech",
&json!({"text": "hi", "output_path": "../escape.wav"})
)
.await
.unwrap_err()
.contains("escapes"));
}
}