//! ffmpeg 사이드카 실기 테스트 — src-tauri/binaries에 사이드카가 있을 때만 실행된다. //! (없으면 조용히 통과 — CI에서 fetch-ffmpeg 후 실행하면 전체 커버) use archive_indexer::ffmpeg::{self, FfTools}; use archive_indexer::sigmatch; use std::path::{Path, PathBuf}; fn tools() -> Option { // crates/archive-indexer → ../../binaries let bin = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../binaries"); let triple = if cfg!(all(windows, target_arch = "x86_64")) { "x86_64-pc-windows-msvc" } else if cfg!(all(windows, target_arch = "aarch64")) { "aarch64-pc-windows-msvc" } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { "aarch64-apple-darwin" } else { "x86_64-apple-darwin" }; let ext = if cfg!(windows) { ".exe" } else { "" }; let ffmpeg = bin.join(format!("ffmpeg-{triple}{ext}")); let ffprobe = bin.join(format!("ffprobe-{triple}{ext}")); if ffmpeg.is_file() && ffprobe.is_file() { Some(FfTools { ffmpeg, ffprobe }) } else { None } } fn make_test_video(t: &FfTools, dir: &Path) -> PathBuf { let out = dir.join("테스트영상.mp4"); let vcodec = if cfg!(windows) { "h264_mf" } else { "h264_videotoolbox" }; let status = ffmpeg::command(&t.ffmpeg) .args([ "-y", "-v", "error", "-f", "lavfi", "-i", "testsrc2=duration=2:size=320x240:rate=25", "-c:v", vcodec, "-b:v", "300k", ]) .arg(&out) .status() .expect("ffmpeg 실행 실패"); assert!(status.success(), "테스트 영상 생성 실패"); out } #[test] fn probe_and_thumbnail_roundtrip() { let Some(t) = tools() else { eprintln!("사이드카 없음 — 건너뜀 (scripts/fetch-ffmpeg.ps1 실행 후 재시도)"); return; }; let dir = tempfile::tempdir().unwrap(); let video = make_test_video(&t, dir.path()); // ffprobe 메타 let info = ffmpeg::probe(&t, &video).unwrap(); assert_eq!(info.vcodec.as_deref(), Some("h264")); assert_eq!(info.width, Some(320)); assert_eq!(info.height, Some(240)); let dur = info.duration_ms.expect("duration 없음"); assert!((1500..=2500).contains(&dur), "duration {dur}ms"); assert!(info.fps.unwrap() > 20.0); // 프레임 썸네일 let thumb = dir.path().join("thumbs/aa/key.jpg"); let (w, h) = ffmpeg::extract_frame_jpeg(&t, &video, &thumb, 0.5, 256).unwrap(); assert!(thumb.is_file()); assert!(w == 256 || h == 256, "긴 변이 256이어야 함: {w}x{h}"); } // ── 지문 추출 (단일 패스) ───────────────────────────────── const ENC: &str = if cfg!(windows) { "h264_mf" } else { "h264_videotoolbox" }; /// 합성 영상을 만든다. `extra`는 입력 뒤에 붙는 추가 인자 /// (두 번째 입력을 붙이는 호출이 있으므로 여기서 `-t` 같은 입력 옵션을 끼워 넣으면 안 된다). fn make_video(t: &FfTools, out: &Path, secs: u32, extra: &[&str]) { let src = format!("testsrc2=duration={secs}:size=320x240:rate=25"); let mut cmd = ffmpeg::command(&t.ffmpeg); cmd.args(["-y", "-v", "error", "-f", "lavfi", "-i", &src]); cmd.args(extra); cmd.args(["-c:v", ENC, "-b:v", "300k"]).arg(out); let status = cmd.status().expect("ffmpeg 실행 실패"); assert!(status.success(), "테스트 영상 생성 실패: {}", out.display()); } /// 프레임마다 구조가 크게 달라지는 영상 — **오프셋 투표를 검증할 때는 이걸 써야 한다.** /// /// testsrc2는 320×240을 32×32 그레이로 줄여 9×8 dHash를 뽑으면 프레임끼리 전부 해밍 8 안에 /// 들어온다(실측: 24프레임 중 고유 19개인데 **모든 오프셋 버킷이 똑같이 10표**). 즉 정렬을 /// 판별할 정보가 남지 않아 어느 오프셋이 1위가 되는지는 우연이다. /// 확대되는 mandelbrot은 매 프레임이 달라 오프셋이 유일하게 정해진다(4000ms×10 vs 3500ms×9). fn make_diverse_video(t: &FfTools, out: &Path, secs: u32) { let mut cmd = ffmpeg::command(&t.ffmpeg); // mandelbrot은 무한 소스라 duration이 없다 → -t로 끊는다 cmd.args(["-y", "-v", "error", "-f", "lavfi", "-i", "mandelbrot=size=320x240:rate=25"]); cmd.args(["-t", &secs.to_string()]); cmd.args(["-c:v", ENC, "-b:v", "300k"]).arg(out); let status = cmd.status().expect("ffmpeg 실행 실패"); assert!(status.success(), "테스트 영상 생성 실패: {}", out.display()); } #[test] fn gray_sequence_is_single_pass_and_uniformly_sampled() { let Some(t) = tools() else { eprintln!("사이드카 없음 — 건너뜀"); return; }; let dir = tempfile::tempdir().unwrap(); let video = dir.path().join("샘플.mp4"); make_video(&t, &video, 6, &[]); let frames = ffmpeg::frame_gray_sequence( &t, &video, 1000, // 1초 간격 sigmatch::FRAME as u32, std::time::Duration::from_secs(60), None, ) .expect("프레임 시퀀스 추출 실패"); // 6초 영상에서 1초 간격 → 6장 안팎 assert!((5..=7).contains(&frames.len()), "프레임 수 {}", frames.len()); for (i, (t_ms, px)) in frames.iter().enumerate() { assert_eq!(px.len(), sigmatch::FRAME_LEN, "프레임 크기가 32×32여야 한다"); assert_eq!(*t_ms, i as u32 * 1000, "시각은 균등 간격이어야 한다"); assert!(sigmatch::derive(px).is_some(), "파생 지문 계산 실패"); } // testsrc2는 시간에 따라 화면이 변한다 → 연속 프레임의 해시가 달라야 한다 let a = sigmatch::derive(&frames[0].1).unwrap(); let b = sigmatch::derive(&frames[frames.len() - 1].1).unwrap(); assert!( sigmatch::hamming(a.dhash, b.dhash) > 0, "서로 다른 장면인데 해시가 같다" ); } /// 이번 고도화의 핵심 검증 — **긴 영상에서 잘라낸 구간을 재인코딩해도 찾아내는지.** /// (예전 구현은 duration ±5% 프리필터 때문에 이 케이스를 원천적으로 놓쳤다) /// /// 소재는 mandelbrot이어야 한다 — 이유는 `make_diverse_video` 주석 참조. #[test] fn clipped_segment_is_matched_with_correct_offset() { let Some(t) = tools() else { eprintln!("사이드카 없음 — 건너뜀"); return; }; let dir = tempfile::tempdir().unwrap(); let full = dir.path().join("원본.mp4"); make_diverse_video(&t, &full, 12); // 원본의 4초~9초 구간을 다시 인코딩 (바이트는 완전히 다르다) let clip = dir.path().join("구간.mp4"); let status = ffmpeg::command(&t.ffmpeg) .args(["-y", "-v", "error", "-ss", "4", "-t", "5"]) .arg("-i") .arg(&full) .args(["-c:v", ENC, "-b:v", "200k"]) .arg(&clip) .status() .expect("ffmpeg 실행 실패"); assert!(status.success(), "구간 추출 실패"); let seq = |p: &Path| -> Vec<(u32, u64)> { ffmpeg::frame_gray_sequence(&t, p, 500, sigmatch::FRAME as u32, std::time::Duration::from_secs(120), None) .expect("프레임 시퀀스 추출 실패") .iter() .filter_map(|(ms, px)| sigmatch::derive(px).map(|s| (*ms, s.dhash))) .collect() }; let a = seq(&full); let b = seq(&clip); assert!(a.len() >= 20 && b.len() >= 8, "a={} b={}", a.len(), b.len()); let m = sigmatch::offset_vote(&a, &b, 8, 500, 4).expect("부분 구간을 찾지 못했다"); assert!( (3500..=4500).contains(&m.offset_ms), "오프셋이 4초 근처여야 한다 (실제 {}ms, 지지 {})", m.offset_ms, m.support ); assert!(m.a_span_ms() >= 3000, "일치 구간이 3초 이상이어야 한다 ({}ms)", m.a_span_ms()); } /// 오디오 지문 — 번들 ffmpeg의 chromaprint로 새 의존성 없이 얻는다. #[test] fn audio_fingerprint_is_available_and_partial_matchable() { let Some(t) = tools() else { eprintln!("사이드카 없음 — 건너뜀"); return; }; let dir = tempfile::tempdir().unwrap(); let with_audio = dir.path().join("소리있음.mp4"); make_video( &t, &with_audio, 20, &["-f", "lavfi", "-i", "sine=frequency=440:duration=20", "-c:a", "aac", "-shortest"], ); let fp = ffmpeg::audio_fingerprint(&t, &with_audio, std::time::Duration::from_secs(60)) .expect("오디오 지문 실패"); // 실측 기준 약 7.9개/초 assert!(fp.len() >= 100, "20초 오디오의 지문 항목 수 {}", fp.len()); // 오디오가 없는 영상은 빈 지문 (오류가 아니어야 한다) let silent = dir.path().join("소리없음.mp4"); make_video(&t, &silent, 2, &[]); let empty = ffmpeg::audio_fingerprint(&t, &silent, std::time::Duration::from_secs(60)) .expect("무성 영상에서 오류가 나면 안 된다"); assert!(empty.is_empty(), "무성 영상은 빈 지문이어야 한다 ({}개)", empty.len()); } /// 프레임과 오디오 지문을 **한 번의 디코드**로 받는다 — NAS에서 파일을 두 번 읽지 않기 위해서다. /// /// 여기서 고정하는 실패 모드: 오디오 스트림이 없는 파일에 chromaprint 출력을 붙이면 ffmpeg가 /// `Output file does not contain any stream`으로 **출력 초기화에서** 죽어 프레임까지 0장이 된다. /// 그래서 호출부는 오디오가 있다고 아는 파일에만 켜고, 그래도 실패하면 오디오 없이 재시도한다. #[test] fn one_decode_yields_both_frames_and_audio_fingerprint() { let Some(t) = tools() else { eprintln!("사이드카 없음 — 건너뜀"); return; }; let dir = tempfile::tempdir().unwrap(); let timeout = std::time::Duration::from_secs(120); let with_audio = dir.path().join("소리있음.mp4"); make_video( &t, &with_audio, 10, &["-f", "lavfi", "-i", "sine=frequency=440:duration=10", "-c:a", "aac", "-shortest"], ); let fp_out = dir.path().join("fp.raw"); let (frames, fp) = ffmpeg::frame_gray_sequence_with_audio(&t, &with_audio, 1000, 32, timeout, None, Some(&fp_out)) .expect("한 패스 추출 실패"); assert!(frames.len() >= 8, "10초에서 프레임 {}장", frames.len()); assert!(fp.len() >= 50, "10초 오디오 지문 {}개", fp.len()); assert!(!fp_out.exists(), "임시 지문 파일은 읽은 뒤 지워야 한다"); // 오디오가 없는 파일에 지문 출력을 붙여도 **프레임은 살아야 한다** (폴백 경로) let silent = dir.path().join("소리없음.mp4"); make_video(&t, &silent, 4, &[]); let fp_out2 = dir.path().join("fp2.raw"); let (frames2, fp2) = ffmpeg::frame_gray_sequence_with_audio(&t, &silent, 1000, 32, timeout, None, Some(&fp_out2)) .expect("무성 영상에서도 프레임은 나와야 한다"); assert!(frames2.len() >= 3, "무성 영상 프레임 {}장", frames2.len()); assert!(fp2.is_empty(), "무성 영상은 빈 지문"); } /// 손상된 입력에서 워커가 영구 점유되지 않아야 한다 (타임아웃/오류 반환). #[test] fn broken_input_fails_fast() { let Some(t) = tools() else { eprintln!("사이드카 없음 — 건너뜀"); return; }; let dir = tempfile::tempdir().unwrap(); let junk = dir.path().join("깨진파일.mp4"); std::fs::write(&junk, b"this is not a video").unwrap(); let started = std::time::Instant::now(); let r = ffmpeg::frame_gray_sequence(&t, &junk, 1000, 32, std::time::Duration::from_secs(20), None); assert!(r.is_err() || r.as_ref().unwrap().is_empty(), "깨진 파일에서 프레임이 나왔다"); assert!(started.elapsed().as_secs() < 20, "즉시 실패해야 한다"); }