Skip to main content

car_server_core/assistant/
studio_tools.rs

1//! Parslee Studio media tools for the general assistant — capabilities beyond
2//! CAR's LOCAL models, delivered by the Parslee Studio service. Today:
3//! `generate_music` (real generated music, ElevenLabs Music via Studio). A
4//! text-only agent (Claude Code, Codex) structurally can't offer this.
5//!
6//! Same host-side + path-artifact contract as [`MediaTools`](super::media_tools):
7//! write a file under the working root and return its PATH, never inline bytes
8//! (base64 media would be shredded by the loop's 16 KB observation cap). Studio
9//! returns a time-limited download URL; this provider fetches it and persists the
10//! bytes under the root inside the tool call, so the artifact never expires out
11//! from under the agent. The output path is clamped under the root with the same
12//! lexical + canonicalize-and-recheck guard MediaTools uses (this writer runs
13//! host-side, sharing the sandbox mount).
14
15use 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
28/// Host-side Studio media generation. Advertised only when a Parslee session
29/// exists (the user ran `car auth login`).
30pub 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            // Bound artifact downloads: a stalled SAS/CDN fetch (e.g. the final
41            // MP4 after a 25-minute production) must not hang the tool forever.
42            http: reqwest::Client::builder()
43                .timeout(Duration::from_secs(300))
44                .build()
45                .unwrap_or_default(),
46            root,
47        }
48    }
49
50    /// Cheap, non-network availability check: is a Parslee bearer present? Never
51    /// advertise a Studio tool on a host with no Parslee session.
52    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            // Read-only (hence not mutating), but still an outbound call to an
215            // external service, so it keeps the same tier as its siblings.
216            "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        // Cloned voices first: when a user asks for "my voice" the real-person
362        // clones are the answer, and the preset list is long enough to bury them.
363        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    /// Resolve a user-facing voice name to an ElevenLabs voice id.
384    ///
385    /// A raw id is passed straight through. Otherwise the org's voice list is
386    /// matched case-insensitively — exact name first, then a unique substring
387    /// hit. An ambiguous substring is an ERROR listing the candidates rather
388    /// than an arbitrary pick: silently narrating in the wrong person's voice
389    /// is worse than failing.
390    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        // Validate the destination BEFORE the (billed) network call.
437        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        // Studio declares `duration_seconds` but returns null, so measure the
465        // real audio. Callers timing video to narration need a true number.
466        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        // An image_path makes this image-to-video. Studio takes a URL, not an
502        // upload, so a local file has to be uploaded for a URL first.
503        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        // Studio can hand back the *provider's* URL instead of its own. Veo
536        // returns a Google-hosted result URL readable only with Studio's
537        // `x-goog-api-key`; Studio downloads those bytes but leaves `VideoUrl`
538        // set, and its `NormalizeVideoResultAsync` skips uploading whenever
539        // `VideoUrl` is non-empty — so the bytes never reach Studio's storage
540        // and the un-authable Google URL is what the API returns. Nothing on
541        // this side can read it; name the cause rather than surfacing a 403.
542        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        // Validate the destination BEFORE the (billed) network call.
587        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        // Fetch the time-limited URL and persist under root NOW (the SAS URL
597        // expires) — path-artifact contract: return a path, never bytes.
598        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        // Validate the destination BEFORE the (billed) network call.
638        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        // Fetch the time-limited URL and persist under root NOW (path-artifact
655        // contract: return a path, never bytes).
656        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        // Validate the destination BEFORE the (billed) submit call.
696        let (rel, final_path) =
697            resolve_output_under(&self.root, params, &format!("assets/{}.png", slug(prompt)))?;
698
699        // Single-pass gpt-image-2 (refinement disabled client-side) is ~1-2 min.
700        // Studio allows gpt-image-2 up to 300s server-side
701        // (AzureOpenAIImageClientOptions.TimeoutSeconds = 300), so give the poll
702        // that full budget + margin — a shorter deadline gives up before the
703        // server would, which earlier looked like a hang but was just latency.
704        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        // Default: vocals when lyrics are supplied, instrumental otherwise.
750        let instrumental = params
751            .get("instrumental")
752            .and_then(Value::as_bool)
753            .unwrap_or(lyrics.is_none());
754
755        // Validate the destination BEFORE the (billed) submit call.
756        let (rel, final_path) =
757            resolve_output_under(&self.root, params, &format!("assets/{}.mp3", slug(prompt)))?;
758
759        // Suno takes minutes; bound the internal poll at ~6 min.
760        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        // Validate the destination BEFORE the (billed, long) production.
811        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        // A real Express commercial runs many minutes. Slice 1 polls blocking;
831        // StudioClient's start/poll split lets a later slice detach this onto
832        // CAR's tools.poll machinery without reshaping the client.
833        // TODO(detach slice): on a *forced-gate* abandon (definitively stuck),
834        // best-effort POST production/cancel so we stop billing — but NOT on a
835        // transient-poll-loss abandon, where the production may still be healthy.
836        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        // Fetch the time-limited final MP4 and persist under root NOW (the SAS
844        // URL expires) — path-artifact contract: return a path, never bytes.
845        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    /// Fetch a time-limited Studio artifact URL and persist the bytes under root
859    /// NOW (the SAS URL expires) — the path-artifact contract. Returns byte count.
860    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            // The prefix must be exactly "unknown tool" so ChainedDelegate falls
890            // through to the next executor.
891            other => Err(format!("unknown tool: '{other}'")),
892        }
893    }
894}
895
896/// Resolve a caller-supplied `output_path` (or default) to a validated absolute
897/// path under the working root. Same contract as `MediaTools::resolve_output`:
898/// lexical clamp, then — because this writer runs HOST-side and shares its mount
899/// with an agent that has shell — canonicalize and re-assert the REAL parent is
900/// under the REAL root (a planted symlink must not walk the writer out of root).
901/// Clamp a caller-supplied INPUT path under the working root, mirroring
902/// [`resolve_output_under`]'s guard. A tool that uploads a local file to a
903/// remote service is an exfiltration path if the path isn't clamped, so this
904/// is the read-side twin: lexical check, then canonicalize-and-recheck to
905/// defeat symlinks.
906fn 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
925/// Duration of an MPEG audio file, by summing frame headers.
926///
927/// Studio declares a `duration_seconds` on its TTS reply but returns null, and
928/// narration timing (how long to hold each slide) depends on the real number —
929/// so measure it rather than trusting the service. Summing frames is correct
930/// for VBR as well as CBR; a bitrate estimate would not be. Returns `None` if
931/// the bytes don't parse as MPEG audio rather than guessing.
932///
933/// Accuracy: within ~1% of ffprobe. The leading Xing/Info/VBRI header frame is
934/// excluded (it carries no audio), but the encoder delay/padding a LAME gapless
935/// tag would describe is not subtracted, so the result can run a few tens of
936/// milliseconds long. That is well inside the tolerance for holding a slide.
937fn 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    // Skip an ID3v2 tag: "ID3" + 2 version + 1 flags + 4 syncsafe size bytes.
952    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        // Frame sync: 11 set bits.
964        if data[i] != 0xff || (data[i + 1] & 0xe0) != 0xe0 {
965            i += 1;
966            continue;
967        }
968        let version_bits = (data[i + 1] >> 3) & 0x03; // 0=MPEG2.5, 2=MPEG2, 3=MPEG1
969        let layer_bits = (data[i + 1] >> 1) & 0x03; // 1 = Layer III
970        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        // Layer III carries 1152 samples per frame on MPEG-1, 576 on MPEG-2/2.5.
995        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        // A leading Xing/Info/VBRI frame is metadata, not audio — counting it
1003        // adds a phantom ~26 ms. It only ever appears as the first frame.
1004        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
1058/// A short, human-readable project name derived from the brief's first line,
1059/// bounded so the Studio library stays tidy. Falls back to a generic label.
1060fn 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
1075/// A filesystem-safe, bounded slug for the default output name.
1076fn 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            // Every Studio tool writes an artifact except the read-only voice
1131            // listing, which fetches nothing and spends no quota.
1132            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        // Empty prompt is rejected before any network call.
1145        assert!(tools
1146            .execute("generate_music", &json!({"prompt": "  "}))
1147            .await
1148            .unwrap_err()
1149            .contains("non-empty"));
1150        // Escaping output_path is rejected before any network call.
1151        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    /// Build `count` silent MPEG-1 Layer III CBR frames (128 kbps, 44.1 kHz),
1216    /// optionally prefixing a Xing metadata frame, so the parser can be tested
1217    /// without shipping a binary fixture.
1218    fn synth_mp3(count: usize, with_xing: bool) -> Vec<u8> {
1219        // 128 kbps @ 44.1 kHz, no padding -> (1152/8)*128000/44100 = 417 bytes.
1220        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; // MPEG-1, Layer III, no CRC
1226            f[2] = 0x90; // bitrate idx 9 (128k), rate idx 0 (44.1k), no padding
1227            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        // 100 frames * 1152 samples / 44100 Hz = 2.612s
1248        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        // The Xing frame is metadata: both files hold the same amount of audio.
1260        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        // ID3v2 header declaring a 20-byte syncsafe payload.
1274        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        // Empty brief is rejected before any network call.
1301        assert!(tools
1302            .execute("produce_commercial", &json!({"brief": "  "}))
1303            .await
1304            .unwrap_err()
1305            .contains("non-empty"));
1306        // Escaping output_path is rejected before any network call.
1307        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        // Uses the first non-empty line and bounds its length.
1324        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        // Empty/whitespace brief falls back to a generic label.
1330        assert_eq!(commercial_name("   "), "Commercial");
1331    }
1332}