--- a/crates/adom-aiflow/src/main.rs +++ b/crates/adom-aiflow/src/main.rs @@ -1720,8 +1720,19 @@ if let Some(sheet) = contact_sheet(file) { r.log("artifact", json!({"step": step, "kind": "contact-sheet", "file": sheet})); eprintln!("contact sheet (look at it before you go on): {sheet}"); } + // A clip rejected as blank or truncated is NOT suitable for video evidence. + // Keep its original recording, immutable fault ledger and contact sheet, + // but never spend engineer time compressing known-invalid footage. + // This guard applies to every shared clip-processing call site. + if let Some(why) = verdict.as_deref() { + r.log("clip-encode-skipped", json!({ + "step": step, "file": file, "reason": why, + "policy": "invalid-video-no-encode", "originalPreserved": true + })); + return; + } let out = format!("{}-{speed}x.mp4", file.trim_end_matches(".mp4")); // the recorder's first frames are black before the window paints: they are cut off the head // of both cuts (never more than five seconds; longer black is a real clip problem, kept) let lead = leading_black(file); @@ -7002,8 +7013,92 @@ let e = clip_verdict("/nonexistent/clip.mp4", &["aiflow-no-such-ffprobe", "ffmpeg"]).unwrap_err(); assert!(e.contains("aiflow-no-such-ffprobe") && !e.contains("no frames"), "{e}"); } + /// Regression: a long flat recorder must never trigger + /// costly 10x/action FFmpeg conversion on ANY workflow step. + #[test] + fn invalid_blank_video_is_preserved_and_never_encoded() { + if std::process::Command::new("ffmpeg").arg("-version").output().is_err() { return; } + if std::process::Command::new("ffprobe").arg("-version").output().is_err() { return; } + let dir=std::env::temp_dir().join(format!("aiflow-blank-noencode-{}",std::process::id())); + let _=std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let f=dir.join("routing-blank.mp4"); + let status=std::process::Command::new("ffmpeg").args([ + "-v","error","-y","-f","lavfi", + "-i","color=c=black:s=96x96:r=2", + "-t","4","-pix_fmt","yuv420p", + f.to_str().unwrap() + ]).status().unwrap(); + assert!(status.success(),"test fixture video failed"); + let source=f.display().to_string(); + let mut r=Run{dir:dir.clone(),data:json!({ + "captures":[{"file":source,"step":"routing","visit":1}], + })}; + clip_artifacts_with(&mut r,&json!("routing-1"), &source,10, MEDIA_TOOLS); + assert!(f.is_file(),"raw evidence destroyed"); + assert!(r.data["captures"][0]["suspect"].as_str().unwrap_or("").contains("flat colour")); + assert!(!dir.join("routing-blank-10x.mp4").exists(),"invalid clip was encoded"); + assert!(!dir.join("routing-blank-action.mp4").exists(),"invalid clip produced an action derivative"); + let ledger=r.ledger(); + assert!(ledger.iter().any(|e| e["event"]=="clip-encode-skipped")); + assert!(ledger.iter().any(|e| e["event"]=="clip-suspect")); + assert!(ledger.iter().any(|e| e["event"]=="artifact" && e["kind"]=="clip")); + assert!(!ledger.iter().any(|e| e["event"]=="artifact" && e["kind"]=="clip10x")); + let _=std::fs::remove_dir_all(dir); + } + + #[test] + fn invalid_truncated_video_has_no_encoded_derivatives() { + if std::process::Command::new("ffmpeg").arg("-version").output().is_err() { return; } + if std::process::Command::new("ffprobe").arg("-version").output().is_err() { return; } + let dir=std::env::temp_dir().join(format!("aiflow-truncated-noencode-{}",std::process::id())); + let _=std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let f=dir.join("routing-truncated.mp4"); + std::fs::write(&f,b"not a valid MP4").unwrap(); + let source=f.display().to_string(); + let mut r=Run{dir:dir.clone(),data:json!({ + "captures":[{"file":source,"step":"routing","visit":1}], + })}; + clip_artifacts_with(&mut r,&json!("routing-1"), &source,10,MEDIA_TOOLS); + let ledger=r.ledger(); + assert!(f.is_file()); + assert!(!dir.join("routing-truncated-10x.mp4").exists()); + assert!(ledger.iter().any(|e| e["event"]=="clip-encode-skipped")); + let _=std::fs::remove_dir_all(dir); + } + + /// Normal changing footage must still generate the same evidence derivatives. + #[test] + fn healthy_motion_clip_still_generates_video_derivatives() { + if std::process::Command::new("ffmpeg").arg("-version").output().is_err() { return; } + if std::process::Command::new("ffprobe").arg("-version").output().is_err() { return; } + let dir=std::env::temp_dir().join(format!("aiflow-motion-kept-{}",std::process::id())); + let _=std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let f=dir.join("routing-motion.mp4"); + let status=std::process::Command::new("ffmpeg").args([ + "-v","error","-y","-f","lavfi", + "-i","testsrc2=size=128x128:rate=10", + "-t","6","-pix_fmt","yuv420p", + f.to_str().unwrap() + ]).status().unwrap(); + assert!(status.success()); + let source=f.display().to_string(); + let mut r=Run{dir:dir.clone(),data:json!({ + "captures":[{"file":source,"step":"routing","visit":1}] + })}; + clip_artifacts_with(&mut r,&json!("routing-1"), &source,10,MEDIA_TOOLS); + let ledger=r.ledger(); + assert!(f.is_file()); + assert!(!ledger.iter().any(|e|e["event"]=="clip-encode-skipped")); + assert!(dir.join("routing-motion-10x.mp4").is_file(),"healthy video derivative missing"); + assert!(ledger.iter().any(|e|e["event"]=="artifact" && e["kind"]=="clip10x")); + let _=std::fs::remove_dir_all(dir); + } + #[test] fn a_clip_is_left_unjudged_when_the_tools_are_missing() { let dir = std::env::temp_dir().join(format!("aiflow-media-tools-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir);