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:
parent
a4e2a112d5
commit
7eccbd0dc3
2 changed files with 246 additions and 2 deletions
|
|
@ -207,6 +207,20 @@ pub async fn detect_silence(
|
|||
threshold_db: f32,
|
||||
min_duration_ms: u32,
|
||||
) -> 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);
|
||||
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<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 ──────────────────────────────────────
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
tasks.md
3
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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue