Files
Archive/src-tauri/crates/archive-indexer/tests/letterbox_evidence.rs
T
ncakanghanandClaude Opus 5 8aa74c1a8d
CI / windows-x64 (push) Canceled after 0s
CI / windows-arm64-cross (push) Canceled after 0s
CI / windows-arm64-native (push) Canceled after 0s
CI / macos (push) Canceled after 0s
영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
중복 탐지
- 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시)
- 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(8변형 질의, 선택 옵션)
- 오디오 지문(chromaprint)을 같은 디코드 패스에서 뽑아 레터박스 사본을 잡는다.
  배경음만 같은 남남은 시각 확인 패스(트림 축 프레임 거리)로 걸러낸다 — 실측 9 vs 29.
- 비교 재생(CompareView): 여러 사본을 offset 정렬해 나란히 재생
- 지문 생성 속도 조절(느림·보통·빠름) — 재시작 없이 반영

간단 편집 (원본을 직접 고침, Ctrl+Z로 되돌림)
- 회전·반전: JPEG은 EXIF 방향 태그 2바이트만, MP4는 회전 행렬만 — 화질 손실 0.
  방향값 전이표는 군의 성질(90도x4=제자리)로 검산하고, 실제 화소를 transpose=1과
  비교해 "시계 방향"이 정말 시계인지 확인한다.
- 자르기: 화면 좌표 기준(회전 메타가 붙어 있어도 어긋나지 않는다)
- 구간 자르기: 스트림 복사라 무손실. 필름스트립 타임라인 + 손잡이 끌기 + 구간 미리보기.
- 되돌릴 수 없는 편집(자르기·반전)은 원본을 .archive_trash/edits로 옮겨 보관
- 편집이 수정시각을 바꾸지 않는다 — 기본 정렬이 촬영일이라 항목이 튀면 다시 찾아야 한다

동시 재생
- 고른 영상을 최대 9개 격자에 놓고 함께 재생. 소리는 한 칸만.
- 실시간 변환이 필요한 코덱은 2개까지만 — 그 이상은 ffmpeg가 기계를 멈춘다

그 외
- 별점(스냅샷 flags 남는 비트에 얹어 항목당 바이트 증가 0)
- 태그: 검색 없이 클릭으로 붙이기, 세트+단축키, 이름 수정 시 붙은 항목에 전파
- 파일 로깅(tracing-appender) — 릴리스는 콘솔이 없어 stdout만으로는 원인을 못 찾았다
- ARM64 크로스 컴파일 스크립트(관리자 권한 없이 VS 카탈로그에서 조립)
- DB 마이그레이션 v7~v10

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:18:24 +09:00

138 lines
6.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 근거 측정 — 레터박스 사본을 어떻게 잡는지, 그리고 왜 프레임만으로는 못 잡는지.
//! `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<FfTools> {
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<u8>)> {
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<u8>)]| -> (Vec<u64>, Vec<u64>) {
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<u64> = 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<u32> = 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>| -> 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 { "(전체 축)" });
}
}