diff --git a/maskinrommet/src/audio.rs b/maskinrommet/src/audio.rs index 691d89f..a636d6a 100644 --- a/maskinrommet/src/audio.rs +++ b/maskinrommet/src/audio.rs @@ -207,6 +207,20 @@ pub async fn detect_silence( threshold_db: f32, min_duration_ms: u32, ) -> Result, String> { + // Valider parametere før de interpoleres i filterstreng + if !(-96.0..=0.0).contains(&threshold_db) { + return Err(format!("threshold_db ({threshold_db}) må være mellom -96.0 og 0.0")); + } + if threshold_db.is_nan() || threshold_db.is_infinite() { + return Err("threshold_db er ikke et gyldig tall".to_string()); + } + if min_duration_ms == 0 { + return Err("min_duration_ms må være større enn 0".to_string()); + } + if min_duration_ms > 60_000 { + return Err(format!("min_duration_ms ({min_duration_ms}) kan ikke overstige 60000 (60 sekunder)")); + } + let path = cas.path_for(hash); if !path.exists() { return Err(format!("Filen finnes ikke i CAS: {hash}")); @@ -251,6 +265,128 @@ pub async fn detect_silence( Ok(regions) } +// ─── Parametervalidering ───────────────────────────────────────── + +/// Valider at alle numeriske verdier i EDL-operasjoner er innenfor +/// sikre grenser før de interpoleres i FFmpeg-filterstrenger. +/// +/// Returnerer liste med feilmeldinger (tom = gyldig). +pub 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 } => { + // LUFS: -70 (svært stille) til 0 (maks) + 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 } => { + // Threshold: -96 dB (nesten stille) til 0 dB + 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 } => { + // afftdn nf: typisk -80 til 0 dB + 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 } => { + // EQ gain: -30 til +30 dB er rimelig + for (name, val) in [("low_gain", low_gain), ("mid_gain", mid_gain), ("high_gain", high_gain)] { + if !(-30.0..=30.0).contains(val) { + errors.push(format!("Operasjon {idx} (equalizer): {name} ({val}) må være mellom -30.0 og 30.0")); + } + if val.is_nan() || val.is_infinite() { + errors.push(format!("Operasjon {idx} (equalizer): {name} er ikke et gyldig tall")); + } + } + } + EdlOperation::Compressor { threshold_db, ratio } => { + // Kompressor threshold: -60 til 0 dB + 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")); + } + // Ratio: 1.0 (ingen kompresjon) til 20.0 (limiter) + if !(1.0..=20.0).contains(ratio) { + errors.push(format!("Operasjon {idx} (compressor): ratio ({ratio}) må være mellom 1.0 og 20.0")); + } + } + } + + // Generell NaN/Inf-sjekk for alle f32/f64-verdier + match op { + 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")); + } + } + EdlOperation::TrimSilence { threshold_db, .. } => { + if threshold_db.is_nan() || threshold_db.is_infinite() { + errors.push(format!("Operasjon {idx} (trim_silence): threshold_db er ikke et gyldig tall")); + } + } + 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")); + } + } + 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")); + } + if ratio.is_nan() || ratio.is_infinite() { + errors.push(format!("Operasjon {idx} (compressor): ratio er ikke et gyldig tall")); + } + } + _ => {} + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(format!("Ugyldig EDL:\n- {}", errors.join("\n- "))) + } +} + // ─── EDL → FFmpeg filtergraf ────────────────────────────────────── /// Bygg ffmpeg-filtergraf fra EDL-operasjoner. @@ -393,6 +529,9 @@ pub async fn process_audio( edl: &EdlDocument, output_format: &str, ) -> Result<(String, u64), String> { + // Valider alle parametere før vi starter prosessering + validate_operations(&edl.operations)?; + let source_path = cas.path_for(&edl.source_hash); if !source_path.exists() { return Err(format!("Kildefil finnes ikke i CAS: {}", edl.source_hash)); @@ -722,3 +861,109 @@ pub async fn handle_audio_process_job( "size_bytes": result_size, })) } + +// ─── 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 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(); + // Should contain errors for both operations + assert!(err.contains("normalize")); + assert!(err.contains("compressor")); + } + + #[test] + fn empty_operations_valid() { + assert!(validate_operations(&[]).is_ok()); + } +} diff --git a/tasks.md b/tasks.md index 4f86ad2..fccbc0b 100644 --- a/tasks.md +++ b/tasks.md @@ -190,8 +190,7 @@ Ref: `docs/features/lydmixer.md` Ref: Kodegjennomgang av `b4c4bb8` (Lydstudio: lydredigering via FFmpeg). - [x] 17.1 Responsivt studio-layout: `/studio/[id]` sidebar stacker under waveform på mobil. Verktøypanel som modal/sheet på små skjermer. Ref: feedback om at alt UI skal være responsivt uten unntak. -- [~] 17.2 FFmpeg-parametervalidering: valider at alle numeriske verdier (threshold, gain, ratio, frekvenser) er innenfor sikre grenser i `audio.rs` før de interpoleres i filterstrenger. Avvis ugyldige verdier med feilmelding. - > Påbegynt: 2026-03-18T05:40 +- [x] 17.2 FFmpeg-parametervalidering: valider at alle numeriske verdier (threshold, gain, ratio, frekvenser) er innenfor sikre grenser i `audio.rs` før de interpoleres i filterstrenger. Avvis ugyldige verdier med feilmelding. - [ ] 17.3 Fade/silence-logikk: fiks negativ fade-out start (clamp til 0), og adaptiv silence-margin (margin skal ikke overstige halve regionens varighet). Gi feilmelding ved ugyldige fade-varigheter. - [ ] 17.4 Frontend input-begrensninger: legg til `min`/`max` på alle tallfelter i OperationPanel (silenceThreshold, fadeMs, normTarget, compRatio). Hindre ugyldig input. - [ ] 17.5 Job-polling opprydding: rydd opp interval/timeout ved navigering bort fra studio-siden. Vis feilmelding etter N mislykkede polling-forsøk. Wrap metadata JSON.parse i try/catch.