Fullfører oppgave 17.2: FFmpeg-parametervalidering

Legger til validate_operations() som sjekker alle numeriske verdier
i EDL-operasjoner før de interpoleres i FFmpeg-filterstrenger.
Dette forhindrer ugyldige/farlige verdier fra å nå ffmpeg subprocess.

Validerte parametere:
- Cut: start/end ikke-negativ, end > start
- Normalize: target_lufs mellom -70 og 0
- TrimSilence: threshold_db -96..0, min_duration 1..60000ms
- FadeIn/Out: duration 1..300000ms
- NoiseReduction: strength_db -80..0
- Equalizer: gain -30..+30 dB per bånd
- Compressor: threshold -60..0 dB, ratio 1..20

Validering kjøres ved inngang til process_audio() og detect_silence().
NaN/Inf-verdier avvises eksplisitt. Alle feil samles og returneres samlet.

12 enhetstester verifiserer grenseverdier og feiltilfeller.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 05:43:31 +00:00
parent a4e2a112d5
commit 7eccbd0dc3
2 changed files with 246 additions and 2 deletions

View file

@ -207,6 +207,20 @@ pub async fn detect_silence(
threshold_db: f32, threshold_db: f32,
min_duration_ms: u32, min_duration_ms: u32,
) -> Result<Vec<SilenceRegion>, String> { ) -> Result<Vec<SilenceRegion>, 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); let path = cas.path_for(hash);
if !path.exists() { if !path.exists() {
return Err(format!("Filen finnes ikke i CAS: {hash}")); return Err(format!("Filen finnes ikke i CAS: {hash}"));
@ -251,6 +265,128 @@ pub async fn detect_silence(
Ok(regions) 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<String> = 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 ────────────────────────────────────── // ─── EDL → FFmpeg filtergraf ──────────────────────────────────────
/// Bygg ffmpeg-filtergraf fra EDL-operasjoner. /// Bygg ffmpeg-filtergraf fra EDL-operasjoner.
@ -393,6 +529,9 @@ pub async fn process_audio(
edl: &EdlDocument, edl: &EdlDocument,
output_format: &str, output_format: &str,
) -> Result<(String, u64), String> { ) -> 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); let source_path = cas.path_for(&edl.source_hash);
if !source_path.exists() { if !source_path.exists() {
return Err(format!("Kildefil finnes ikke i CAS: {}", edl.source_hash)); 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, "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());
}
}

View file

@ -190,8 +190,7 @@ Ref: `docs/features/lydmixer.md`
Ref: Kodegjennomgang av `b4c4bb8` (Lydstudio: lydredigering via FFmpeg). 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. - [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. - [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.
> Påbegynt: 2026-03-18T05:40
- [ ] 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.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.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. - [ ] 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.