diff --git a/tasks.md b/tasks.md index 5093dae..3a63c9d 100644 --- a/tasks.md +++ b/tasks.md @@ -242,8 +242,7 @@ kaller dem direkte. Samme verktøy, to brukere. ### Prosessering (erstatter jobbkø-handlere) - [x] 21.1 `synops-transcribe`: Whisper-transkribering. Input: `--cas-hash --model [--initial-prompt ]`. Output: JSON med segmenter. Skriver segmenter til PG, oppdaterer node metadata. Erstatter `transcribe.rs`. -- [~] 21.2 `synops-audio`: FFmpeg-prosessering. Input: `--cas-hash --edl `. Output: ny CAS-hash. Erstatter `audio.rs`. Inkluder parametervalidering (fase 17.2–17.3). - > Påbegynt: 2026-03-18T09:02 +- [x] 21.2 `synops-audio`: FFmpeg-prosessering. Input: `--cas-hash --edl `. Output: ny CAS-hash. Erstatter `audio.rs`. Inkluder parametervalidering (fase 17.2–17.3). - [ ] 21.3 `synops-render`: Tera HTML-rendering. Input: `--node-id --theme `. Output: CAS-hash for rendret HTML. Erstatter `publishing.rs`. - [ ] 21.4 `synops-rss`: RSS/Atom-generering. Input: `--collection-id `. Output: XML til stdout. Erstatter `rss.rs`. - [ ] 21.5 `synops-tts`: Tekst-til-tale. Input: `--text --voice `. Output: CAS-hash for lydfil. Erstatter `tts.rs`. diff --git a/tools/README.md b/tools/README.md index 2eb5473..3ea8c84 100644 --- a/tools/README.md +++ b/tools/README.md @@ -8,6 +8,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall. | Verktøy | Beskrivelse | Status | |---------|-------------|--------| | `synops-transcribe` | Whisper-transkribering av lydfil fra CAS | Ferdig | +| `synops-audio` | FFmpeg lydprosessering med EDL (cut, normalize, EQ, m.m.) | Ferdig | ## Konvensjoner - Navnekonvensjon: `synops-` (f.eks. `synops-context`) diff --git a/tools/synops-audio/Cargo.toml b/tools/synops-audio/Cargo.toml new file mode 100644 index 0000000..2562ee2 --- /dev/null +++ b/tools/synops-audio/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "synops-audio" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "synops-audio" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v7", "serde"] } +sha2 = "0.10" +hex = "0.4" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/tools/synops-audio/src/main.rs b/tools/synops-audio/src/main.rs new file mode 100644 index 0000000..6b0298a --- /dev/null +++ b/tools/synops-audio/src/main.rs @@ -0,0 +1,1109 @@ +// synops-audio — FFmpeg lydprosessering via EDL (Edit Decision List). +// +// Input: CAS-hash til kildefil + EDL som JSON-streng. +// Output: JSON med ny CAS-hash til stdout. +// Med --write: oppretter prosessert medienode og derived_from-edge i PG. +// +// Miljøvariabler: +// DATABASE_URL — PostgreSQL-tilkobling (påkrevd med --write) +// CAS_ROOT — Rot for content-addressable store (default: /srv/synops/media/cas) +// +// Ref: docs/retninger/unix_filosofi.md, docs/features/lydstudio.md + +use clap::Parser; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; +use std::process; +use uuid::Uuid; + +/// Prosesser lydfil fra CAS via FFmpeg med EDL-operasjoner. +#[derive(Parser)] +#[command(name = "synops-audio", about = "FFmpeg lydprosessering med EDL")] +struct Cli { + /// SHA-256 CAS-hash til kildefilen + #[arg(long)] + cas_hash: String, + + /// EDL (Edit Decision List) som JSON-streng + #[arg(long)] + edl: String, + + /// Utdataformat: mp3, wav, flac, ogg + #[arg(long, default_value = "mp3")] + output_format: String, + + /// Medienode-ID (original node, påkrevd med --write) + #[arg(long)] + node_id: Option, + + /// Bruker-ID som utløste prosesseringen (for ressurslogging) + #[arg(long)] + requested_by: Option, + + /// Skriv resultater til database (uten dette flagget: kun stdout) + #[arg(long)] + write: bool, +} + +// ─── EDL-datastrukturer ─────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EdlDocument { + pub source_hash: String, + pub operations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum EdlOperation { + Cut { + start_ms: i64, + end_ms: i64, + }, + Normalize { + target_lufs: f64, + }, + TrimSilence { + threshold_db: f32, + min_duration_ms: u32, + }, + FadeIn { + duration_ms: u32, + }, + FadeOut { + duration_ms: u32, + }, + NoiseReduction { + strength_db: f32, + }, + Equalizer { + low_gain: f32, + mid_gain: f32, + high_gain: f32, + }, + Compressor { + threshold_db: f32, + ratio: f32, + }, +} + +// ─── Hjelpedatastrukturer ───────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize)] +struct LoudnessInfo { + input_i: f64, + input_tp: f64, + input_lra: f64, + input_thresh: f64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SilenceRegion { + start_ms: i64, + end_ms: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AudioInfo { + duration_ms: i64, +} + +#[derive(Serialize)] +struct AudioProcessResult { + cas_hash: String, + size_bytes: u64, + output_format: String, + source_hash: String, + operations_applied: usize, + #[serde(skip_serializing_if = "Option::is_none")] + processed_node_id: Option, +} + +// ─── Entrypoint ─────────────────────────────────────────────────── + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "synops_audio=info".parse().unwrap()), + ) + .with_target(false) + .with_writer(std::io::stderr) + .init(); + + let cli = Cli::parse(); + + if cli.write && cli.node_id.is_none() { + eprintln!("Feil: --node-id er påkrevd sammen med --write"); + process::exit(1); + } + + if let Err(e) = run(cli).await { + eprintln!("Feil: {e}"); + process::exit(1); + } +} + +async fn run(cli: Cli) -> Result<(), String> { + let cas_root = std::env::var("CAS_ROOT").unwrap_or_else(|_| "/srv/synops/media/cas".into()); + + // 1. Parse EDL + let edl: EdlDocument = serde_json::from_str(&cli.edl) + .map_err(|e| format!("Ugyldig EDL JSON: {e}"))?; + + // Verifiser at source_hash matcher cas_hash + if edl.source_hash != cli.cas_hash { + return Err(format!( + "source_hash i EDL ({}) matcher ikke --cas-hash ({})", + edl.source_hash, cli.cas_hash + )); + } + + // 2. Valider alle parametere + validate_operations(&edl.operations)?; + + // Valider output-format + match cli.output_format.as_str() { + "mp3" | "wav" | "flac" | "ogg" => {} + other => return Err(format!("Ugyldig output-format: '{other}'. Gyldige: mp3, wav, flac, ogg")), + } + + // 3. Sjekk at kildefilen finnes i CAS + let source_path = cas_path(&cas_root, &cli.cas_hash); + if !source_path.exists() { + return Err(format!("Kildefil finnes ikke i CAS: {}", cli.cas_hash)); + } + + if edl.operations.is_empty() { + return Err("Ingen operasjoner å utføre".to_string()); + } + + tracing::info!( + cas_hash = %cli.cas_hash, + operations = edl.operations.len(), + output_format = %cli.output_format, + "Starter lydprosessering" + ); + + // 4. Hent lydvarighet for fade-beregning + let info = get_audio_info(&source_path).await?; + validate_fade_durations(&edl.operations, info.duration_ms)?; + + // 5. Resolve trim_silence til cuts via silence detection + let silence_cuts = resolve_silence_cuts(&cas_root, &edl).await?; + + // 6. To-pass loudnorm hvis normalize er med + let has_normalize = edl.operations.iter().any(|op| matches!(op, EdlOperation::Normalize { .. })); + let loudness_measured = if has_normalize { + // Pass 1: mål loudness etter andre filtre (uten normalize) + let mut pass1_ops: Vec = edl.operations.clone(); + pass1_ops.retain(|op| !matches!(op, EdlOperation::Normalize { .. })); + pass1_ops.extend(silence_cuts.iter().cloned()); + + let pass1_filter = build_filter_chain(&pass1_ops, info.duration_ms, None); + + let measured = if pass1_filter.is_empty() { + analyze_loudness(&source_path).await? + } else { + analyze_with_filter(&source_path, &pass1_filter).await? + }; + Some(measured) + } else { + None + }; + + // 7. Bygg endelig filtergraf + let mut all_ops = edl.operations.clone(); + all_ops.retain(|op| !matches!(op, EdlOperation::TrimSilence { .. })); + all_ops.extend(silence_cuts); + + let filter = build_filter_chain(&all_ops, info.duration_ms, loudness_measured.as_ref()); + + if filter.is_empty() { + return Err("Ingen filtre generert fra operasjoner".to_string()); + } + + // 8. Kjør FFmpeg + let codec_args = match cli.output_format.as_str() { + "mp3" => vec!["-codec:a", "libmp3lame", "-q:a", "2"], + "wav" => vec!["-codec:a", "pcm_s16le"], + "flac" => vec!["-codec:a", "flac"], + "ogg" => vec!["-codec:a", "libvorbis", "-q:a", "6"], + _ => vec!["-codec:a", "libmp3lame", "-q:a", "2"], + }; + + let ext = match cli.output_format.as_str() { + "wav" => "wav", + "flac" => "flac", + "ogg" => "ogg", + _ => "mp3", + }; + + let tmp_dir = PathBuf::from(&cas_root).join("tmp"); + tokio::fs::create_dir_all(&tmp_dir) + .await + .map_err(|e| format!("Kunne ikke opprette tmp-katalog: {e}"))?; + let tmp_output = tmp_dir.join(format!("audio_process_{}.{ext}", Uuid::now_v7())); + + let mut cmd = tokio::process::Command::new("ffmpeg"); + cmd.args(["-i"]) + .arg(&source_path) + .args(["-af", &filter]) + .args(&codec_args) + .args(["-y"]) + .arg(&tmp_output); + + tracing::info!(filter = %filter, "Kjører ffmpeg"); + + let output = cmd + .output() + .await + .map_err(|e| format!("Kunne ikke kjøre ffmpeg: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let _ = tokio::fs::remove_file(&tmp_output).await; + return Err(format!("ffmpeg feilet: {stderr}")); + } + + // 9. Les resultat, hash det, og lagre i CAS + let result_bytes = tokio::fs::read(&tmp_output) + .await + .map_err(|e| format!("Kunne ikke lese ffmpeg-output: {e}"))?; + + let _ = tokio::fs::remove_file(&tmp_output).await; + + let (result_hash, result_size) = store_in_cas(&cas_root, &result_bytes).await?; + + tracing::info!( + source = %cli.cas_hash, + result = %result_hash, + size = result_size, + "Lydprosessering fullført" + ); + + // 10. Valgfritt: skriv til database + let mut processed_node_id = None; + + if cli.write { + let node_id = cli.node_id.unwrap(); + let requested_by = cli.requested_by + .ok_or("--requested-by er påkrevd sammen med --write")?; + + let db_url = std::env::var("DATABASE_URL") + .map_err(|_| "DATABASE_URL må settes med --write".to_string())?; + + let db = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&db_url) + .await + .map_err(|e| format!("Kunne ikke koble til database: {e}"))?; + + let pnode_id = write_to_db( + &db, + node_id, + requested_by, + &result_hash, + result_size, + &cli.output_format, + &edl, + ) + .await?; + + processed_node_id = Some(pnode_id.to_string()); + tracing::info!(node_id = %node_id, processed = %pnode_id, "Database oppdatert"); + } + + // 11. Skriv JSON-resultat til stdout + let result = AudioProcessResult { + cas_hash: result_hash, + size_bytes: result_size, + output_format: cli.output_format, + source_hash: cli.cas_hash, + operations_applied: edl.operations.len(), + processed_node_id, + }; + + println!( + "{}", + serde_json::to_string_pretty(&result) + .map_err(|e| format!("JSON-serialisering feilet: {e}"))? + ); + + Ok(()) +} + +// ─── Parametervalidering ───────────────────────────────────────── + +/// Valider at alle numeriske verdier i EDL-operasjoner er innenfor +/// sikre grenser før de interpoleres i FFmpeg-filterstrenger. +fn validate_operations(ops: &[EdlOperation]) -> Result<(), String> { + let mut errors: Vec = Vec::new(); + + for (i, op) in ops.iter().enumerate() { + let idx = i + 1; + match op { + EdlOperation::Cut { start_ms, end_ms } => { + if *start_ms < 0 { + errors.push(format!("Operasjon {idx} (cut): start_ms ({start_ms}) kan ikke være negativ")); + } + if *end_ms < 0 { + errors.push(format!("Operasjon {idx} (cut): end_ms ({end_ms}) kan ikke være negativ")); + } + if *end_ms <= *start_ms { + errors.push(format!("Operasjon {idx} (cut): end_ms ({end_ms}) må være større enn start_ms ({start_ms})")); + } + } + EdlOperation::Normalize { target_lufs } => { + if target_lufs.is_nan() || target_lufs.is_infinite() { + errors.push(format!("Operasjon {idx} (normalize): target_lufs er ikke et gyldig tall")); + } else if !(-70.0..=0.0).contains(target_lufs) { + errors.push(format!("Operasjon {idx} (normalize): target_lufs ({target_lufs}) må være mellom -70.0 og 0.0")); + } + } + EdlOperation::TrimSilence { threshold_db, min_duration_ms } => { + if threshold_db.is_nan() || threshold_db.is_infinite() { + errors.push(format!("Operasjon {idx} (trim_silence): threshold_db er ikke et gyldig tall")); + } else if !(-96.0..=0.0).contains(threshold_db) { + errors.push(format!("Operasjon {idx} (trim_silence): threshold_db ({threshold_db}) må være mellom -96.0 og 0.0")); + } + if *min_duration_ms == 0 { + errors.push(format!("Operasjon {idx} (trim_silence): min_duration_ms må være større enn 0")); + } + if *min_duration_ms > 60_000 { + errors.push(format!("Operasjon {idx} (trim_silence): min_duration_ms ({min_duration_ms}) kan ikke overstige 60000 (60 sekunder)")); + } + } + EdlOperation::FadeIn { duration_ms } => { + if *duration_ms == 0 { + errors.push(format!("Operasjon {idx} (fade_in): duration_ms må være større enn 0")); + } + if *duration_ms > 300_000 { + errors.push(format!("Operasjon {idx} (fade_in): duration_ms ({duration_ms}) kan ikke overstige 300000 (5 minutter)")); + } + } + EdlOperation::FadeOut { duration_ms } => { + if *duration_ms == 0 { + errors.push(format!("Operasjon {idx} (fade_out): duration_ms må være større enn 0")); + } + if *duration_ms > 300_000 { + errors.push(format!("Operasjon {idx} (fade_out): duration_ms ({duration_ms}) kan ikke overstige 300000 (5 minutter)")); + } + } + EdlOperation::NoiseReduction { strength_db } => { + if strength_db.is_nan() || strength_db.is_infinite() { + errors.push(format!("Operasjon {idx} (noise_reduction): strength_db er ikke et gyldig tall")); + } else if !(-80.0..=0.0).contains(strength_db) { + errors.push(format!("Operasjon {idx} (noise_reduction): strength_db ({strength_db}) må være mellom -80.0 og 0.0")); + } + } + EdlOperation::Equalizer { low_gain, mid_gain, high_gain } => { + for (name, val) in [("low_gain", low_gain), ("mid_gain", mid_gain), ("high_gain", high_gain)] { + if val.is_nan() || val.is_infinite() { + errors.push(format!("Operasjon {idx} (equalizer): {name} er ikke et gyldig tall")); + } else if !(-30.0..=30.0).contains(val) { + errors.push(format!("Operasjon {idx} (equalizer): {name} ({val}) må være mellom -30.0 og 30.0")); + } + } + } + EdlOperation::Compressor { threshold_db, ratio } => { + if threshold_db.is_nan() || threshold_db.is_infinite() { + errors.push(format!("Operasjon {idx} (compressor): threshold_db er ikke et gyldig tall")); + } else if !(-60.0..=0.0).contains(threshold_db) { + errors.push(format!("Operasjon {idx} (compressor): threshold_db ({threshold_db}) må være mellom -60.0 og 0.0")); + } + if ratio.is_nan() || ratio.is_infinite() { + errors.push(format!("Operasjon {idx} (compressor): ratio er ikke et gyldig tall")); + } else if !(1.0..=20.0).contains(ratio) { + errors.push(format!("Operasjon {idx} (compressor): ratio ({ratio}) må være mellom 1.0 og 20.0")); + } + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(format!("Ugyldig EDL:\n- {}", errors.join("\n- "))) + } +} + +/// Valider fade-varigheter mot faktisk lydvarighet. +fn validate_fade_durations(ops: &[EdlOperation], duration_ms: i64) -> Result<(), String> { + let mut errors: Vec = Vec::new(); + + for (i, op) in ops.iter().enumerate() { + let idx = i + 1; + match op { + EdlOperation::FadeIn { duration_ms: fade_ms } => { + if *fade_ms as i64 > duration_ms { + errors.push(format!( + "Operasjon {idx} (fade_in): varighet ({fade_ms} ms) \ + overstiger lydens varighet ({duration_ms} ms)" + )); + } + } + EdlOperation::FadeOut { duration_ms: fade_ms } => { + if *fade_ms as i64 > duration_ms { + errors.push(format!( + "Operasjon {idx} (fade_out): varighet ({fade_ms} ms) \ + overstiger lydens varighet ({duration_ms} ms)" + )); + } + } + _ => {} + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(format!("Ugyldig fade-varighet:\n- {}", errors.join("\n- "))) + } +} + +// ─── FFmpeg-kommandoer ──────────────────────────────────────────── + +/// Hent lydvarighet via ffprobe. +async fn get_audio_info(path: &PathBuf) -> Result { + let output = tokio::process::Command::new("ffprobe") + .args([ + "-v", "quiet", + "-print_format", "json", + "-show_format", + ]) + .arg(path) + .output() + .await + .map_err(|e| format!("Kunne ikke kjøre ffprobe: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("ffprobe feilet: {stderr}")); + } + + let json: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("Kunne ikke parse ffprobe-output: {e}"))?; + + let duration_secs: f64 = json["format"]["duration"] + .as_str() + .and_then(|s| s.parse().ok()) + .unwrap_or(0.0); + + Ok(AudioInfo { + duration_ms: (duration_secs * 1000.0) as i64, + }) +} + +/// Analyser loudness (EBU R128) via ffmpeg loudnorm. +async fn analyze_loudness(path: &PathBuf) -> Result { + let output = tokio::process::Command::new("ffmpeg") + .args(["-i"]) + .arg(path) + .args(["-af", "loudnorm=print_format=json", "-f", "null", "-"]) + .output() + .await + .map_err(|e| format!("Kunne ikke kjøre ffmpeg loudnorm: {e}"))?; + + let stderr = String::from_utf8_lossy(&output.stderr); + parse_loudnorm_json(&stderr) +} + +/// Kjør loudnorm-analyse med et forhåndsfilter (for to-pass normalisering). +async fn analyze_with_filter(path: &PathBuf, pre_filter: &str) -> Result { + let filter = format!("{pre_filter},loudnorm=print_format=json"); + + let output = tokio::process::Command::new("ffmpeg") + .args(["-i"]) + .arg(path) + .args(["-af", &filter, "-f", "null", "-"]) + .output() + .await + .map_err(|e| format!("Kunne ikke kjøre ffmpeg loudnorm pass 1: {e}"))?; + + let stderr = String::from_utf8_lossy(&output.stderr); + parse_loudnorm_json(&stderr) +} + +fn parse_loudnorm_json(stderr: &str) -> Result { + let json_start = stderr + .find("{\n") + .ok_or("Fant ikke loudnorm JSON i ffmpeg-output")?; + let json_end = stderr[json_start..] + .find("\n}") + .map(|i| json_start + i + 2) + .ok_or("Ufullstendig loudnorm JSON")?; + + let json: serde_json::Value = serde_json::from_str(&stderr[json_start..json_end]) + .map_err(|e| format!("Kunne ikke parse loudnorm JSON: {e}"))?; + + let get = |field: &str| -> Result { + json[field] + .as_str() + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| format!("Mangler felt '{field}' i loudnorm-output")) + }; + + Ok(LoudnessInfo { + input_i: get("input_i")?, + input_tp: get("input_tp")?, + input_lra: get("input_lra")?, + input_thresh: get("input_thresh")?, + }) +} + +/// Detekter stille regioner i en lydfil. +async fn detect_silence( + path: &PathBuf, + threshold_db: f32, + min_duration_ms: u32, +) -> Result, String> { + let min_duration_secs = min_duration_ms as f64 / 1000.0; + let filter = format!("silencedetect=noise={threshold_db}dB:d={min_duration_secs}"); + + let output = tokio::process::Command::new("ffmpeg") + .args(["-i"]) + .arg(path) + .args(["-af", &filter, "-f", "null", "-"]) + .output() + .await + .map_err(|e| format!("Kunne ikke kjøre ffmpeg silencedetect: {e}"))?; + + let stderr = String::from_utf8_lossy(&output.stderr); + let mut regions = Vec::new(); + let mut current_start: Option = None; + + for line in stderr.lines() { + if let Some(pos) = line.find("silence_start: ") { + let val_str = &line[pos + 15..]; + if let Some(secs) = val_str.split_whitespace().next().and_then(|s| s.parse::().ok()) { + current_start = Some(secs); + } + } + if let Some(pos) = line.find("silence_end: ") { + let val_str = &line[pos + 13..]; + if let Some(end_secs) = val_str.split_whitespace().next().and_then(|s| s.parse::().ok()) { + if let Some(start_secs) = current_start.take() { + regions.push(SilenceRegion { + start_ms: (start_secs * 1000.0) as i64, + end_ms: (end_secs * 1000.0) as i64, + }); + } + } + } + } + + Ok(regions) +} + +/// Konverter TrimSilence-operasjoner til faktiske Cut-operasjoner. +async fn resolve_silence_cuts( + cas_root: &str, + edl: &EdlDocument, +) -> Result, String> { + let mut cuts = Vec::new(); + for op in &edl.operations { + if let EdlOperation::TrimSilence { + threshold_db, + min_duration_ms, + } = op + { + let source_path = cas_path(cas_root, &edl.source_hash); + let regions = detect_silence(&source_path, *threshold_db, *min_duration_ms).await?; + for region in regions { + // Behold 200ms stillhet på hver side for naturlig lyd, + // men aldri mer enn halve regionens varighet + let region_duration = region.end_ms - region.start_ms; + let margin_ms = 200i64.min(region_duration / 2); + let start = region.start_ms + margin_ms; + let end = region.end_ms - margin_ms; + if end > start { + cuts.push(EdlOperation::Cut { + start_ms: start, + end_ms: end, + }); + } + } + } + } + Ok(cuts) +} + +// ─── EDL → FFmpeg filtergraf ────────────────────────────────────── + +/// Bygg ffmpeg-filtergraf fra EDL-operasjoner. +/// +/// Operasjonsrekkefølge: +/// 1. Cuts (aselect) — fjerner regioner +/// 2. Noise reduction (afftdn) +/// 3. EQ (equalizer) +/// 4. Compressor (acompressor) +/// 5. Normalize (loudnorm) — alltid nest sist +/// 6. Fades (afade) — aller sist +fn build_filter_chain( + ops: &[EdlOperation], + duration_ms: i64, + loudness_measured: Option<&LoudnessInfo>, +) -> String { + let mut filters: Vec = Vec::new(); + + // Samle alle cuts + let mut cuts: Vec<(i64, i64)> = Vec::new(); + for op in ops { + if let EdlOperation::Cut { start_ms, end_ms } = op { + cuts.push((*start_ms, *end_ms)); + } + } + + // Sorter cuts og bygg aselect-filter + if !cuts.is_empty() { + cuts.sort_by_key(|c| c.0); + let conditions: Vec = cuts + .iter() + .map(|(s, e)| { + format!( + "between(t,{:.3},{:.3})", + *s as f64 / 1000.0, + *e as f64 / 1000.0 + ) + }) + .collect(); + filters.push(format!( + "aselect='not({})',asetpts=N/SR/TB", + conditions.join("+") + )); + } + + // Noise reduction + for op in ops { + if let EdlOperation::NoiseReduction { strength_db } = op { + filters.push(format!("afftdn=nf={strength_db}")); + } + } + + // EQ — tre-bånds parametrisk + for op in ops { + if let EdlOperation::Equalizer { low_gain, mid_gain, high_gain } = op { + let mut eq_parts = Vec::new(); + if *low_gain != 0.0 { + eq_parts.push(format!("equalizer=f=100:t=h:w=200:g={low_gain}")); + } + if *mid_gain != 0.0 { + eq_parts.push(format!("equalizer=f=1000:t=h:w=1000:g={mid_gain}")); + } + if *high_gain != 0.0 { + eq_parts.push(format!("equalizer=f=8000:t=h:w=4000:g={high_gain}")); + } + filters.extend(eq_parts); + } + } + + // Compressor + for op in ops { + if let EdlOperation::Compressor { threshold_db, ratio } = op { + filters.push(format!( + "acompressor=threshold={threshold_db}dB:ratio={ratio}:attack=5:release=50" + )); + } + } + + // Normalize (loudnorm) — to-pass hvis vi har målte verdier + for op in ops { + if let EdlOperation::Normalize { target_lufs } = op { + if let Some(measured) = loudness_measured { + filters.push(format!( + "loudnorm=I={target_lufs}:TP=-1.5:LRA=11:\ + measured_I={:.1}:measured_TP={:.1}:measured_LRA={:.1}:\ + measured_thresh={:.1}:linear=true", + measured.input_i, + measured.input_tp, + measured.input_lra, + measured.input_thresh, + )); + } else { + filters.push(format!("loudnorm=I={target_lufs}:TP=-1.5:LRA=11")); + } + } + } + + // Beregn varighet etter cuts for fade-out posisjonering + let total_cut_ms: i64 = cuts.iter().map(|(s, e)| e - s).sum(); + let effective_duration_ms = duration_ms - total_cut_ms; + + // Fades — helt sist + for op in ops { + match op { + EdlOperation::FadeIn { duration_ms } => { + let d = *duration_ms as f64 / 1000.0; + filters.push(format!("afade=t=in:d={d:.3}")); + } + EdlOperation::FadeOut { duration_ms: dur } => { + let d = *dur as f64 / 1000.0; + let start = ((effective_duration_ms as f64 / 1000.0) - d).max(0.0); + filters.push(format!("afade=t=out:st={start:.3}:d={d:.3}")); + } + _ => {} + } + } + + filters.join(",") +} + +// ─── CAS-operasjoner ───────────────────────────────────────────── + +/// CAS-filsti: {root}/{hash[0..2]}/{hash[2..4]}/{hash} +fn cas_path(root: &str, hash: &str) -> PathBuf { + PathBuf::from(root) + .join(&hash[..2]) + .join(&hash[2..4]) + .join(hash) +} + +/// Beregn SHA-256, lagre i CAS med atomisk rename. +async fn store_in_cas(cas_root: &str, data: &[u8]) -> Result<(String, u64), String> { + let mut hasher = Sha256::new(); + hasher.update(data); + let hash = hex::encode(hasher.finalize()); + + let dest = cas_path(cas_root, &hash); + + // Allerede lagret? + if dest.exists() { + return Ok((hash, data.len() as u64)); + } + + // Opprett mappen + if let Some(parent) = dest.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("Kunne ikke opprette CAS-katalog: {e}"))?; + } + + // Skriv til temp-fil, deretter atomisk rename + let tmp_path = PathBuf::from(cas_root) + .join("tmp") + .join(format!("{}.tmp", hash)); + tokio::fs::write(&tmp_path, data) + .await + .map_err(|e| format!("Kunne ikke skrive CAS temp-fil: {e}"))?; + tokio::fs::rename(&tmp_path, &dest) + .await + .map_err(|e| format!("Kunne ikke flytte til CAS: {e}"))?; + + Ok((hash, data.len() as u64)) +} + +// ─── Database-operasjoner (kun med --write) ─────────────────────── + +async fn write_to_db( + db: &sqlx::PgPool, + media_node_id: Uuid, + requested_by: Uuid, + result_hash: &str, + result_size: u64, + output_format: &str, + edl: &EdlDocument, +) -> Result { + let mime = match output_format { + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "flac" => "audio/flac", + "ogg" => "audio/ogg", + _ => "audio/mpeg", + }; + + let processed_node_id = Uuid::now_v7(); + let metadata = serde_json::json!({ + "cas_hash": result_hash, + "mime": mime, + "size_bytes": result_size, + "source_hash": edl.source_hash, + "edl": edl, + }); + + // Hent tittel fra original node + let original_title: Option = sqlx::query_scalar( + "SELECT title FROM nodes WHERE id = $1" + ) + .bind(media_node_id) + .fetch_optional(db) + .await + .map_err(|e| format!("DB-feil: {e}"))? + .flatten(); + + let title = original_title + .map(|t| format!("{t} (prosessert)")) + .unwrap_or_else(|| "Prosessert lyd".to_string()); + + // Insert processed media node + sqlx::query( + "INSERT INTO nodes (id, node_kind, title, visibility, metadata, created_by) + VALUES ($1, 'media', $2, 'hidden', $3, $4)", + ) + .bind(processed_node_id) + .bind(&title) + .bind(&metadata) + .bind(requested_by) + .execute(db) + .await + .map_err(|e| format!("Kunne ikke opprette prosessert node: {e}"))?; + + // Opprett derived_from edge: processed → original + let edge_id = Uuid::now_v7(); + sqlx::query( + "INSERT INTO edges (id, source_id, target_id, edge_type, system, created_by) + VALUES ($1, $2, $3, 'derived_from', true, $4)", + ) + .bind(edge_id) + .bind(processed_node_id) + .bind(media_node_id) + .bind(requested_by) + .execute(db) + .await + .map_err(|e| format!("Kunne ikke opprette derived_from edge: {e}"))?; + + // Logg ressursforbruk + let collection_id: Option = sqlx::query_scalar( + "SELECT e.target_id FROM edges e + JOIN nodes n ON n.id = e.target_id + WHERE e.source_id = $1 AND e.edge_type = 'belongs_to' AND n.node_kind = 'collection' + LIMIT 1", + ) + .bind(media_node_id) + .fetch_optional(db) + .await + .ok() + .flatten(); + + let detail = serde_json::json!({ + "output_format": output_format, + "operations": edl.operations.len(), + "result_size_bytes": result_size, + }); + + if let Err(e) = sqlx::query( + "INSERT INTO resource_usage_log (target_node_id, triggered_by, collection_id, resource_type, detail) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(media_node_id) + .bind(requested_by) + .bind(collection_id) + .bind("ffmpeg_audio") + .bind(&detail) + .execute(db) + .await + { + tracing::warn!(error = %e, "Kunne ikke logge ressursforbruk"); + } + + Ok(processed_node_id) +} + +// ─── Tester ────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_operations_pass() { + let ops = vec![ + EdlOperation::Cut { start_ms: 0, end_ms: 1000 }, + EdlOperation::Normalize { target_lufs: -16.0 }, + EdlOperation::TrimSilence { threshold_db: -30.0, min_duration_ms: 500 }, + EdlOperation::FadeIn { duration_ms: 1000 }, + EdlOperation::FadeOut { duration_ms: 2000 }, + EdlOperation::NoiseReduction { strength_db: -25.0 }, + EdlOperation::Equalizer { low_gain: 2.0, mid_gain: 0.0, high_gain: -1.0 }, + EdlOperation::Compressor { threshold_db: -20.0, ratio: 4.0 }, + ]; + assert!(validate_operations(&ops).is_ok()); + } + + #[test] + fn cut_end_before_start_rejected() { + let ops = vec![EdlOperation::Cut { start_ms: 5000, end_ms: 3000 }]; + let err = validate_operations(&ops).unwrap_err(); + assert!(err.contains("end_ms")); + } + + #[test] + fn cut_negative_start_rejected() { + let ops = vec![EdlOperation::Cut { start_ms: -100, end_ms: 1000 }]; + let err = validate_operations(&ops).unwrap_err(); + assert!(err.contains("negativ")); + } + + #[test] + fn normalize_out_of_range_rejected() { + let ops = vec![EdlOperation::Normalize { target_lufs: 5.0 }]; + assert!(validate_operations(&ops).is_err()); + + let ops = vec![EdlOperation::Normalize { target_lufs: -80.0 }]; + assert!(validate_operations(&ops).is_err()); + } + + #[test] + fn compressor_ratio_out_of_range_rejected() { + let ops = vec![EdlOperation::Compressor { threshold_db: -20.0, ratio: 0.5 }]; + let err = validate_operations(&ops).unwrap_err(); + assert!(err.contains("ratio")); + + let ops = vec![EdlOperation::Compressor { threshold_db: -20.0, ratio: 25.0 }]; + assert!(validate_operations(&ops).is_err()); + } + + #[test] + fn eq_gain_out_of_range_rejected() { + let ops = vec![EdlOperation::Equalizer { low_gain: 50.0, mid_gain: 0.0, high_gain: 0.0 }]; + let err = validate_operations(&ops).unwrap_err(); + assert!(err.contains("low_gain")); + } + + #[test] + fn noise_reduction_out_of_range_rejected() { + let ops = vec![EdlOperation::NoiseReduction { strength_db: 10.0 }]; + assert!(validate_operations(&ops).is_err()); + } + + #[test] + fn fade_zero_duration_rejected() { + let ops = vec![EdlOperation::FadeIn { duration_ms: 0 }]; + assert!(validate_operations(&ops).is_err()); + } + + #[test] + fn trim_silence_zero_duration_rejected() { + let ops = vec![EdlOperation::TrimSilence { threshold_db: -30.0, min_duration_ms: 0 }]; + assert!(validate_operations(&ops).is_err()); + } + + #[test] + fn nan_values_rejected() { + let ops = vec![EdlOperation::Normalize { target_lufs: f64::NAN }]; + assert!(validate_operations(&ops).is_err()); + + let ops = vec![EdlOperation::Compressor { threshold_db: f32::NAN, ratio: 4.0 }]; + assert!(validate_operations(&ops).is_err()); + } + + #[test] + fn infinity_values_rejected() { + let ops = vec![EdlOperation::Normalize { target_lufs: f64::INFINITY }]; + assert!(validate_operations(&ops).is_err()); + + let ops = vec![EdlOperation::Equalizer { low_gain: f32::INFINITY, mid_gain: 0.0, high_gain: 0.0 }]; + assert!(validate_operations(&ops).is_err()); + } + + #[test] + fn multiple_errors_collected() { + let ops = vec![ + EdlOperation::Normalize { target_lufs: 100.0 }, + EdlOperation::Compressor { threshold_db: 50.0, ratio: 0.0 }, + ]; + let err = validate_operations(&ops).unwrap_err(); + assert!(err.contains("normalize")); + assert!(err.contains("compressor")); + } + + #[test] + fn empty_operations_valid() { + assert!(validate_operations(&[]).is_ok()); + } + + #[test] + fn filter_chain_cuts() { + let ops = vec![ + EdlOperation::Cut { start_ms: 5000, end_ms: 8000 }, + EdlOperation::Cut { start_ms: 15000, end_ms: 17000 }, + ]; + let chain = build_filter_chain(&ops, 60000, None); + assert!(chain.contains("aselect")); + assert!(chain.contains("between")); + assert!(chain.contains("asetpts")); + } + + #[test] + fn filter_chain_order() { + let ops = vec![ + EdlOperation::FadeIn { duration_ms: 1000 }, + EdlOperation::NoiseReduction { strength_db: -25.0 }, + EdlOperation::Normalize { target_lufs: -16.0 }, + ]; + let chain = build_filter_chain(&ops, 60000, None); + let nr_pos = chain.find("afftdn").unwrap(); + let norm_pos = chain.find("loudnorm").unwrap(); + let fade_pos = chain.find("afade").unwrap(); + // noise reduction < normalize < fade + assert!(nr_pos < norm_pos); + assert!(norm_pos < fade_pos); + } + + #[test] + fn filter_chain_two_pass_loudnorm() { + let ops = vec![EdlOperation::Normalize { target_lufs: -16.0 }]; + let measured = LoudnessInfo { + input_i: -24.0, + input_tp: -1.0, + input_lra: 7.0, + input_thresh: -34.0, + }; + let chain = build_filter_chain(&ops, 60000, Some(&measured)); + assert!(chain.contains("measured_I=-24.0")); + assert!(chain.contains("linear=true")); + } + + #[test] + fn filter_chain_fade_out_positioning() { + let ops = vec![EdlOperation::FadeOut { duration_ms: 2000 }]; + let chain = build_filter_chain(&ops, 10000, None); + // 10s - 2s = 8s start + assert!(chain.contains("st=8.000")); + } + + #[test] + fn filter_chain_fade_out_after_cuts() { + let ops = vec![ + EdlOperation::Cut { start_ms: 2000, end_ms: 5000 }, + EdlOperation::FadeOut { duration_ms: 1000 }, + ]; + // 10s - 3s cut = 7s effective. 7s - 1s fade = 6s start + let chain = build_filter_chain(&ops, 10000, None); + assert!(chain.contains("st=6.000")); + } + + #[test] + fn cas_path_format() { + let p = cas_path("/srv/synops/media/cas", "b94d27b9934d3e08"); + assert_eq!( + p, + PathBuf::from("/srv/synops/media/cas/b9/4d/b94d27b9934d3e08") + ); + } + + #[test] + fn edl_json_roundtrip() { + let edl = EdlDocument { + source_hash: "abc123".to_string(), + operations: vec![ + EdlOperation::Cut { start_ms: 0, end_ms: 1000 }, + EdlOperation::Normalize { target_lufs: -16.0 }, + ], + }; + let json = serde_json::to_string(&edl).unwrap(); + let parsed: EdlDocument = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.source_hash, "abc123"); + assert_eq!(parsed.operations.len(), 2); + } + + #[test] + fn validate_fade_duration_exceeds_audio() { + let ops = vec![EdlOperation::FadeIn { duration_ms: 5000 }]; + assert!(validate_fade_durations(&ops, 3000).is_err()); + assert!(validate_fade_durations(&ops, 5000).is_ok()); + assert!(validate_fade_durations(&ops, 10000).is_ok()); + } +}