grapheme-stdlib 0.6.0

Standard library operation implementations for Grapheme
Documentation
//! Media capability module (ffmpeg/ffprobe CLI bridge).

use crate::capability::CapabilityResponse;
use serde_json::{json, Value as JsonValue};
use std::path::Path;
use std::process::Command;

pub fn probe(args: &JsonValue) -> JsonValue {
    let path = arg_text(args, "path");
    if path.is_empty() {
        return CapabilityResponse::invalid_args("missing required arg: path");
    }

    match run_ffprobe(&path) {
        Ok(probe_json) => {
            let stream_count = probe_json
                .get("streams")
                .and_then(|streams| streams.as_array())
                .map(|streams| streams.len())
                .unwrap_or(0);
            let format_name = probe_json
                .get("format")
                .and_then(|format| format.get("format_name"))
                .and_then(|value| value.as_str())
                .map(str::to_owned);
            let duration = probe_json
                .get("format")
                .and_then(|format| format.get("duration"))
                .and_then(|value| value.as_str())
                .map(str::to_owned);

            CapabilityResponse::ok(json!({
                "op": "media.probe",
                "path": path,
                "stream_count": stream_count,
                "format_name": format_name,
                "duration": duration,
                "probe": probe_json,
            }))
        }
        Err(err) => CapabilityResponse::invalid_args(err),
    }
}

pub fn transcode(args: &JsonValue) -> JsonValue {
    let input = arg_text(args, "input");
    let output = arg_text(args, "output");
    if input.is_empty() || output.is_empty() {
        return CapabilityResponse::invalid_args("missing required args: input and output");
    }

    match run_ffmpeg_transcode(&input, &output) {
        Ok(summary) => CapabilityResponse::ok(json!({
            "op": "media.transcode",
            "input": input,
            "output": output,
            "ok": true,
            "summary": summary,
        })),
        Err(err) => CapabilityResponse::invalid_args(err),
    }
}

fn run_ffprobe(path: &str) -> Result<JsonValue, String> {
    let output = Command::new("ffprobe")
        .args([
            "-v",
            "quiet",
            "-print_format",
            "json",
            "-show_format",
            "-show_streams",
            path,
        ])
        .output()
        .map_err(|err| format!("ffprobe not available or failed to spawn: {err}"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("ffprobe failed for '{path}': {}", stderr.trim()));
    }

    serde_json::from_slice(&output.stdout).map_err(|err| format!("parse ffprobe json: {err}"))
}

fn run_ffmpeg_transcode(input: &str, output: &str) -> Result<JsonValue, String> {
    let mut command = Command::new("ffmpeg");
    command.args([
        "-y",
        "-hide_banner",
        "-loglevel",
        "error",
        "-i",
        input,
    ]);

    match output
        .rsplit('.')
        .next()
        .map(|ext| ext.to_ascii_lowercase())
        .as_deref()
    {
        Some("mp4") => {
            command.args(["-c:v", "libx264", "-c:a", "aac", "-movflags", "+faststart"]);
        }
        Some("mp3") => {
            command.args(["-c:a", "libmp3lame"]);
        }
        Some("wav") => {
            command.args(["-c:a", "pcm_s16le"]);
        }
        _ => {
            command.args(["-c", "copy"]);
        }
    }

    command.arg(output);

    let result = command
        .output()
        .map_err(|err| format!("ffmpeg not available or failed to spawn: {err}"))?;

    if !result.status.success() {
        let stderr = String::from_utf8_lossy(&result.stderr);
        return Err(format!(
            "ffmpeg transcode failed for '{input}' -> '{output}': {}",
            stderr.trim()
        ));
    }

    let output_path = Path::new(output);
    let bytes = fs_metadata_len(output_path).unwrap_or(0);

    Ok(json!({
        "codec_strategy": codec_strategy_label(output),
        "output_bytes": bytes,
    }))
}

fn codec_strategy_label(output: &str) -> &'static str {
    match output
        .rsplit('.')
        .next()
        .map(|ext| ext.to_ascii_lowercase())
        .as_deref()
    {
        Some("mp4") => "h264_aac",
        Some("mp3") => "mp3",
        Some("wav") => "pcm_s16le",
        _ => "copy",
    }
}

fn fs_metadata_len(path: &Path) -> Option<u64> {
    std::fs::metadata(path).ok().map(|meta| meta.len())
}

fn arg_text(args: &JsonValue, key: &str) -> String {
    args.get(key)
        .and_then(|v| v.as_str())
        .map(ToOwned::to_owned)
        .or_else(|| {
            args.get("__input")
                .and_then(|v| v.as_str())
                .map(ToOwned::to_owned)
        })
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn probe_requires_path() {
        let out = probe(&json!({}));
        assert!(out.get("error").is_some_and(|value| !value.is_null()));
    }

    #[test]
    fn transcode_requires_input_and_output() {
        let out = transcode(&json!({ "input": "a.mp4" }));
        assert!(out.get("error").is_some_and(|value| !value.is_null()));
    }
}