use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::{Path as AxPath, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{Html, IntoResponse, Response};
use serde_json::json;
use uuid::Uuid;
use crate::progress::ProgressSink;
use crate::spec::OutputSpec;
use super::{
ApiError, AppState, ArtifactEntry, JobHandle, Json, Phase, RegistrySink,
};
use super::docs::{openapi_spec, LANDING_HTML, REDOC_HTML, SWAGGER_HTML};
use super::spec::{TranscodeParams, TranscodeRequest, read_input, resolve_path};
pub(super) async fn health() -> Json {
let gpus: Vec<serde_json::Value> = codec::gpu::detect_gpus()
.into_iter()
.map(|g| json!({ "index": g.index, "vendor": format!("{:?}", g.vendor), "name": g.name }))
.collect();
let caps = codec::encode::build_output_caps();
Json(json!({
"status": "ok",
"service": "rivet",
"gpus": gpus,
"output_caps": { "max_bit_depth": caps.max_bit_depth, "hdr": caps.hdr },
}))
}
pub(super) async fn probe(body: Bytes) -> Result<Json, ApiError> {
let info = crate::probe::probe_bytes(&body).map_err(ApiError::bad_request)?;
Ok(Json(json!({
"video_codec": info.video_codec,
"width": info.width,
"height": info.height,
"frame_rate": info.frame_rate,
"duration": info.duration,
})))
}
pub(super) async fn transcode(
State(state): State<AppState>,
headers: HeaderMap,
Query(params): Query<TranscodeParams>,
body: Bytes,
) -> Result<Response, ApiError> {
let is_json = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|ct| ct.starts_with("application/json"))
.unwrap_or(false);
let (media, spec_params, output_path, sync) = if is_json {
let req: TranscodeRequest = serde_json::from_slice(&body)
.map_err(|e| ApiError::bad_request(anyhow::anyhow!("invalid JSON body: {e}")))?;
let media = read_input(&req.input)?;
let output_path = match &req.output {
Some(o) => Some(resolve_path(&o.path, false)?),
None => None,
};
(media, req.spec.into_params(), output_path, req.sync)
} else {
if body.is_empty() {
return Err(ApiError::bad_request(anyhow::anyhow!(
"empty request body — POST media bytes (binary), or send `application/json` with input.path / input.base64"
)));
}
let sync = params.sync.unwrap_or(false);
(body, params, None, sync)
};
if media.is_empty() {
return Err(ApiError::bad_request(anyhow::anyhow!("no input media")));
}
let info = crate::probe::probe_bytes(&media).map_err(ApiError::bad_request)?;
let settings = spec_params.into_settings().map_err(ApiError::bad_request)?;
let spec = settings
.into_spec(info.width, info.height)
.map_err(ApiError::bad_request)?;
let id = Uuid::new_v4();
let mode = if matches!(spec.mode, crate::spec::OutputMode::Hls { .. }) {
"hls"
} else {
"single"
};
let handle = Arc::new(JobHandle::new(id, mode));
state.jobs.write().unwrap().insert(id, Arc::clone(&handle));
let task = run_job_task(Arc::clone(&handle), media, spec, output_path);
if sync {
task.await; return sync_response(&handle);
}
tokio::spawn(task);
Ok((
StatusCode::ACCEPTED,
Json(json!({ "job_id": id.to_string(), "status": "queued" })),
)
.into_response())
}
fn write_single_file(bytes: &[u8], output: &std::path::Path, label: &str, multi: bool) -> Result<String, String> {
let dest = if multi {
std::fs::create_dir_all(output).map_err(|e| format!("creating {}: {e}", output.display()))?;
output.join(format!("{label}.mp4"))
} else {
output.to_path_buf()
};
std::fs::write(&dest, bytes).map_err(|e| format!("writing {}: {e}", dest.display()))?;
Ok(dest.display().to_string())
}
pub(super) fn run_job_task(
handle: Arc<JobHandle>,
body: Bytes,
spec: OutputSpec,
output_path: Option<PathBuf>,
) -> impl std::future::Future<Output = ()> {
async move {
handle.set_phase(Phase::Running);
let is_hls = matches!(spec.mode, crate::spec::OutputMode::Hls { .. });
let mut tmp_guard = None;
let out_dir: Option<PathBuf> = if is_hls {
if let Some(p) = &output_path {
if let Err(e) = std::fs::create_dir_all(p) {
*handle.error.lock().unwrap() =
Some(format!("creating output dir {}: {e}", p.display()));
handle.set_phase(Phase::Failed);
return;
}
*handle.output_dir.lock().unwrap() = Some(p.clone());
Some(p.clone())
} else {
match tempfile::Builder::new().prefix("rivet-api-").tempdir() {
Ok(d) => {
let path = d.path().to_path_buf();
*handle.output_dir.lock().unwrap() = Some(path.clone());
tmp_guard = Some(d);
Some(path)
}
Err(e) => {
*handle.error.lock().unwrap() = Some(format!("tempdir: {e}"));
handle.set_phase(Phase::Failed);
return;
}
}
}
} else {
None
};
let sink: Arc<dyn ProgressSink> = Arc::new(RegistrySink {
handle: Arc::clone(&handle),
});
let result = crate::job::run_job(body, &spec, out_dir.as_deref(), sink).await;
match result {
Ok(out) => {
let multi = out.rungs.len() > 1;
let mut write_err: Option<String> = None;
{
let mut arts = handle.artifacts.lock().unwrap();
for r in out.rungs {
let (data, written) = match r.artifact {
crate::job::RungArtifact::File(bytes) => {
if let Some(p) = &output_path {
match write_single_file(&bytes, p, &r.label, multi) {
Ok(dest) => (None, Some(dest)),
Err(e) => {
write_err.get_or_insert(e);
(Some(Bytes::from(bytes)), None)
}
}
} else {
(Some(Bytes::from(bytes)), None)
}
}
crate::job::RungArtifact::HlsRendition { .. } => (None, None),
};
arts.push(ArtifactEntry {
label: r.label,
width: r.width,
height: r.height,
frames: r.frames,
bytes: r.bytes,
data,
output_path: written,
});
}
}
if out.master_playlist.is_some() {
*handle.master_playlist.lock().unwrap() =
Some(format!("/v1/jobs/{}/files/master.m3u8", handle.id));
}
if let Some(e) = write_err {
*handle.error.lock().unwrap() = Some(e);
handle.set_phase(Phase::Failed);
} else {
handle.set_phase(Phase::Completed);
}
}
Err(e) => {
*handle.error.lock().unwrap() = Some(format!("{e:#}"));
handle.set_phase(Phase::Failed);
}
}
if let Some(d) = tmp_guard {
std::mem::forget(d);
}
}
}
pub(super) fn sync_response(handle: &Arc<JobHandle>) -> Result<Response, ApiError> {
if *handle.phase.lock().unwrap() == Phase::Failed {
let msg = handle.error.lock().unwrap().clone().unwrap_or_default();
return Err(ApiError::internal(anyhow::anyhow!(msg)));
}
let streamable = {
let arts = handle.artifacts.lock().unwrap();
arts.iter().find_map(|a| a.data.clone())
};
if let Some(data) = streamable {
return Ok((StatusCode::OK, [(header::CONTENT_TYPE, "video/mp4")], data).into_response());
}
Ok(Json(handle.status_json()).into_response())
}
pub(super) async fn job_status(
State(state): State<AppState>,
AxPath(id): AxPath<String>,
) -> Result<Json, ApiError> {
let handle = lookup(&state, &id)?;
Ok(Json(handle.status_json()))
}
pub(super) async fn artifact(
State(state): State<AppState>,
AxPath((id, label)): AxPath<(String, String)>,
) -> Result<Response, ApiError> {
let handle = lookup(&state, &id)?;
let arts = handle.artifacts.lock().unwrap();
let entry = arts
.iter()
.find(|a| a.label == label && a.data.is_some())
.ok_or_else(|| ApiError::not_found(format!("artifact '{label}'")))?;
let data = entry.data.clone().unwrap();
Ok((StatusCode::OK, [(header::CONTENT_TYPE, "video/mp4")], data).into_response())
}
pub(super) async fn hls_file(
State(state): State<AppState>,
AxPath((id, path)): AxPath<(String, String)>,
) -> Result<Response, ApiError> {
let handle = lookup(&state, &id)?;
let root = handle
.output_dir
.lock()
.unwrap()
.clone()
.ok_or_else(|| ApiError::not_found("HLS output".into()))?;
if path.split(['/', '\\']).any(|c| c == ".." || c.is_empty()) {
return Err(ApiError::bad_request(anyhow::anyhow!("invalid path")));
}
let full = root.join(&path);
let data = std::fs::read(&full).map_err(|_| ApiError::not_found(path.clone()))?;
let ct = content_type_for(&path);
Ok((StatusCode::OK, [(header::CONTENT_TYPE, ct)], data).into_response())
}
fn content_type_for(path: &str) -> &'static str {
match path.rsplit('.').next() {
Some("m3u8") => "application/vnd.apple.mpegurl",
Some("m4s") => "video/iso.segment",
Some("mp4") => "video/mp4",
_ => "application/octet-stream",
}
}
fn lookup(state: &AppState, id: &str) -> Result<Arc<JobHandle>, ApiError> {
let uuid = Uuid::parse_str(id).map_err(|_| ApiError::not_found("job".into()))?;
state
.jobs
.read()
.unwrap()
.get(&uuid)
.cloned()
.ok_or_else(|| ApiError::not_found(format!("job '{id}'")))
}
pub(super) async fn landing() -> Html<&'static str> {
Html(LANDING_HTML)
}
pub(super) async fn openapi_json() -> Json {
Json(openapi_spec())
}
pub(super) async fn swagger_ui() -> Html<&'static str> {
Html(SWAGGER_HTML)
}
pub(super) async fn redoc_ui() -> Html<&'static str> {
Html(REDOC_HTML)
}