//! 근거 측정 — 레터박스 사본을 어떻게 잡는지, 그리고 왜 프레임만으로는 못 잡는지. //! `cargo test -p archive-indexer --test letterbox_evidence -- --ignored --nocapture` //! //! 측정 결과(2026-08)와 그로부터 나온 설계: //! //! 1. 검정 띠 트림 자체는 정확하다 — 32×32에서 x6..26 y6..26, 딱 안쪽 콘텐츠다. //! 2. 그런데 20×20을 9×8로 다시 표본화한 해시는 원본(32×32→9×8)과 **거리 7~16**(중앙값 9.5)이고 //! **정확히 같은 값은 0개**다. 후보 생성이 값 기반 토큰이라 두 사본은 영영 만나지 못한다. //! → 그래서 트림 해시를 후보 토큰으로 쓰려던 시도는 폐기했다(`dedupe::video_candidates` 주석). //! 3. 오디오는 화면 변형에 영향받지 않는다 — 레터박스 사본이 chromaprint 221/221 일치. //! → 오디오가 후보를 만든다. //! 4. 다만 **배경음만 같은 남남**도 221/221로 붙는다(`vidC_sharedaudio`). 둘을 가르는 건 시각뿐: //! 진짜 사본은 트림 축 프레임 거리 9, 남남은 29. → `AUDIO_VOTE_MAX_DIST = 16`이 그 사이다. //! //! 이 파일은 그 네 숫자를 다시 뽑아본다. 임계값을 손댈 일이 생기면 여기부터 돌려라. use archive_indexer::{ffmpeg::{self, FfTools}, sigmatch}; use std::path::{Path, PathBuf}; fn repo_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap() } fn tools() -> Option { let bin = repo_root().join("src-tauri/binaries"); let triple = if cfg!(all(windows, target_arch = "x86_64")) { "x86_64-pc-windows-msvc" } else { "aarch64-pc-windows-msvc" }; let ext = if cfg!(windows) { ".exe" } else { "" }; let (a, b) = (bin.join(format!("ffmpeg-{triple}{ext}")), bin.join(format!("ffprobe-{triple}{ext}"))); (a.is_file() && b.is_file()).then_some(FfTools { ffmpeg: a, ffprobe: b }) } #[test] #[ignore = "근거 측정용"] fn print_letterbox_evidence() { let Some(t) = tools() else { eprintln!("사이드카 없음"); return; }; let dir = repo_root().join(".fixtures/videos"); if !dir.is_dir() { eprintln!("픽스처 없음"); return; } let grab = |name: &str| -> Vec<(u32, Vec)> { ffmpeg::frame_gray_sequence( &t, &dir.join(name), 1000, sigmatch::FRAME as u32, std::time::Duration::from_secs(120), None, ) .unwrap_or_default() }; let a = grab("vidA.mp4"); let l = grab("vidA_letterbox.mp4"); println!("프레임 수: vidA {} · letterbox {}", a.len(), l.len()); if a.is_empty() || l.is_empty() { return; } for i in [0usize, 5, 10] { let (Some((_, pa)), Some((_, pl))) = (a.get(i), l.get(i)) else { continue }; let (sa, sl) = (sigmatch::derive(pa).unwrap(), sigmatch::derive(pl).unwrap()); let bounds = sigmatch::letterbox(pl); // 32×32 픽셀에서 검정(값 <= 16) 비율 — 패딩이 얼마나 되는지 let black = pl.iter().filter(|v| **v <= 16).count() * 100 / pl.len(); println!("── 프레임 {i}"); println!( " letterbox 판정 x{}..{} y{}..{} (검정 픽셀 {black}%)", bounds.0, bounds.2, bounds.1, bounds.3 ); println!( " 전체↔전체 {} · 트림(레터박스)↔전체(원본) {} · 전체↔트림 {}", sigmatch::hamming(sa.dhash, sl.dhash), sigmatch::hamming(sl.scales[2], sa.dhash), sigmatch::hamming(sa.dhash, sl.scales[2]) ); println!( " 원본의 scales[2]는 전체와 같은가: {} (거리 {})", sa.scales[2] == sa.dhash, sigmatch::hamming(sa.scales[2], sa.dhash) ); } // 후보 생성은 '값이 정확히 같은 토큰'을 요구한다 — 공유 토큰이 몇 개인지가 결정적이다 let sig_of = |frames: &[(u32, Vec)]| -> (Vec, Vec) { let mut whole = Vec::new(); let mut lb = Vec::new(); for (_, px) in frames { if let Some(s) = sigmatch::derive(px) { whole.push(s.dhash); lb.push(s.scales[2]); } } (whole, lb) }; let (aw, _al) = sig_of(&a); let (lw, ll) = sig_of(&l); let set: std::collections::HashSet = aw.iter().copied().collect(); println!("\n── 후보 토큰 공유 (정확히 일치하는 값의 개수)"); println!(" 원본 전체해시 {}개 중", set.len()); println!(" 레터박스본 전체해시와 공유: {}", lw.iter().filter(|h| set.contains(h)).count()); println!(" 레터박스본 트림해시와 공유: {}", ll.iter().filter(|h| set.contains(h)).count()); // 거리 분포 — 몇 비트나 벌어지는지 let mut d: Vec = ll .iter() .zip(aw.iter()) .map(|(x, y)| sigmatch::hamming(*x, *y)) .collect(); d.sort_unstable(); println!(" 트림↔전체 프레임별 거리(정렬): {d:?}"); // ── 오디오가 후보를 만들 때 시각으로 진짜/남남을 가를 수 있는가 ────────── // 오디오는 vidA_letterbox(진짜 사본)와 vidC_sharedaudio(배경음만 같은 남남)를 // 똑같이 221/221 일치로 붙인다. 둘을 가르는 건 시각뿐이므로 그 간격을 잰다. let median = |mut v: Vec| -> u32 { v.sort_unstable(); v.get(v.len() / 2).copied().unwrap_or(0) }; println!("\n── 오디오 후보를 시각으로 거를 때의 간격 (중앙값, 64비트 중)"); for (name, use_lb) in [("vidA_letterbox.mp4", true), ("vidC_sharedaudio.mp4", false)] { let f = grab(name); if f.is_empty() { println!(" {name}: 프레임 없음"); continue; } let (w, lb) = sig_of(&f); let seq = if use_lb { &lb } else { &w }; let n = seq.len().min(aw.len()); let dm = median((0..n).map(|i| sigmatch::hamming(seq[i], aw[i])).collect()); println!(" vidA ↔ {name:<22} {dm} {}", if use_lb { "(트림 축)" } else { "(전체 축)" }); } }