use crate::util::UnwrapPoison;
#[derive(Clone)]
pub struct MediaTranscriber {
api_url: String,
model: String,
}
const VIDEO_TRANSCRIPTION_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(3);
impl MediaTranscriber {
#[must_use]
pub(crate) fn new(api_url: String, model: String) -> Self {
Self { api_url, model }
}
fn chat_url(&self) -> String {
crate::providers::ensure_chat_completions_url(&self.api_url)
}
async fn transcribe_media_raw(
&self,
content_part: serde_json::Value,
marker: Option<&ModelCallMarker>,
) -> anyhow::Result<RawTranscription> {
let prompt = crate::prompt::load_prompt("media_transcription.md");
let body = serde_json::json!({
"model": self.model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
content_part
]
}
],
"max_tokens": 2048,
"reasoning": {"enabled": false},
});
if let Some(marker) = marker {
marker.mark();
}
let result =
crate::util::http::post_json_to_provider(&self.chat_url(), &body, "transcription")
.await?;
let finish_reason = result["choices"][0]["finish_reason"]
.as_str()
.map(str::to_string);
let text = result["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("")
.trim()
.to_string();
if text.is_empty() {
return Ok(RawTranscription::EmptyContent);
}
Ok(RawTranscription::Success {
text: scrub_marker_like(&text),
finish_reason,
})
}
fn transcription_context(
&self,
workspace: Option<&str>,
) -> (Option<LiveTrackingGuard>, crate::stats::LlmCallMeta) {
let tracking = crate::agent::CURRENT_TOOL_AGENT_TRACKING
.try_with(Clone::clone)
.ok()
.flatten();
let live = match &tracking {
Some(t) => Some(LiveTrackingGuard::Agent {
_guard: crate::registry::AGENT_REGISTRY.activity_started(
&t.agent_id,
t.generation,
"transcribing",
),
}),
None => workspace.map(|ws| LiveTrackingGuard::Call {
_guard: crate::call_registry::NON_AGENT_CALLS.register(
"media_transcription",
ws,
None,
false,
None,
),
}),
};
let call = crate::stats::LlmCallMeta {
meta: crate::ChatRequestMeta {
purpose: "media_transcription",
agent_id: tracking
.as_ref()
.map(|t| t.agent_id.clone())
.unwrap_or_default(),
role: tracking
.as_ref()
.map(|t| t.role.clone())
.unwrap_or_default(),
workspace: tracking.as_ref().map_or_else(
|| workspace.unwrap_or_default().to_string(),
|t| t.workspace.clone(),
),
ticket_id: None,
},
model: self.model.clone(),
provider_order: None,
};
(live, call)
}
}
enum RawTranscription {
Success {
text: String,
finish_reason: Option<String>,
},
EmptyContent,
}
struct ModelCallMarker(std::sync::Mutex<Option<std::time::Instant>>);
impl ModelCallMarker {
fn new() -> Self {
Self(std::sync::Mutex::new(None))
}
fn mark(&self) {
*self.0.lock().unwrap_poison() = Some(std::time::Instant::now());
}
fn started_at(&self) -> Option<std::time::Instant> {
self.0.lock().unwrap_poison().as_ref().copied()
}
}
#[expect(clippy::cast_possible_truncation)]
async fn record_transcription(
call: &crate::stats::LlmCallMeta,
started: std::time::Instant,
finish_reason: Option<&str>,
failure_class: Option<&'static str>,
) {
crate::stats::record_llm_operation_meta(
call,
started.elapsed().as_millis() as u64,
1,
None,
finish_reason,
failure_class,
)
.await;
}
async fn finish_transcription(
call: &crate::stats::LlmCallMeta,
started: std::time::Instant,
outcome: Result<RawTranscription, anyhow::Error>,
) -> anyhow::Result<String> {
match outcome {
Ok(RawTranscription::Success {
text,
finish_reason,
}) => {
record_transcription(call, started, finish_reason.as_deref(), None).await;
Ok(text)
}
Ok(RawTranscription::EmptyContent) => {
record_transcription(
call,
started,
None,
Some(crate::retry::FailureClass::NoResponse.label()),
)
.await;
anyhow::bail!("media transcription returned empty content");
}
Err(e) => {
let failure = crate::providers::failure_class(
crate::providers::reliable::classify_err(&e),
false,
);
record_transcription(call, started, None, Some(failure.label())).await;
Err(e)
}
}
}
enum LiveTrackingGuard {
Agent {
_guard: crate::registry::ActivityGuard,
},
Call {
_guard: crate::call_registry::NonAgentCallGuard,
},
}
pub(crate) async fn transcribe_video_file(
path: &std::path::Path,
workspace: Option<&str>,
) -> Option<String> {
let transcriber = crate::providers::media_transcriber()?;
if !crate::util::is_transcribable_video(path) {
tracing::debug!(
path = %path.display(),
"Video format not supported by the transcription provider — skipping"
);
return None;
}
let (_live, call) = transcriber.transcription_context(workspace);
let marker = ModelCallMarker::new();
let outcome = tokio::time::timeout(VIDEO_TRANSCRIPTION_TIMEOUT, async {
let url = crate::util::upload_bridge::upload_video_ephemeral_typed(path).await?;
let content_part = serde_json::json!({"type": "video_url", "video_url": {"url": url}});
transcriber
.transcribe_media_raw(content_part, Some(&marker))
.await
})
.await;
let started = marker.started_at().unwrap_or_else(std::time::Instant::now);
if let Ok(inner) = outcome {
if marker.started_at().is_none() {
if let Err(e) = inner {
tracing::warn!(
path = %path.display(),
error = %e,
"Video transcription failed before the model call"
);
}
return None;
}
match finish_transcription(&call, started, inner).await {
Ok(text) => Some(text),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "Video transcription failed");
None
}
}
} else {
if marker.started_at().is_some() {
record_transcription(
&call,
started,
None,
Some(crate::retry::FailureClass::Transport.label()),
)
.await;
}
tracing::warn!(path = %path.display(), "Video transcription timed out");
None
}
}
fn scrub_marker_like(text: &str) -> String {
static RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"(?i)\[(image|audio|video):").expect("marker scrub regex must compile")
});
RE.replace_all(text, "($1:").to_string()
}