Files
Archive/src-tauri/crates/archive-indexer/tests/fixture_dedupe.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

465 lines
20 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.
//! 픽스처 실기 통합 테스트 — `.fixtures\manifest.json`의 기대값을 **실제 파이프라인**으로 검증한다.
//!
//! 스캔 → 메타 → 썸네일 → 지문 → 중복 탐지를 순서대로 돌리고, manifest가 "잡혀야 한다"고
//! 말한 변형이 정말 같은 그룹에 들어갔는지 / "잡히면 안 된다"는 것이 안 묶였는지 확인한다.
//! 이 순서 자체가 검증 대상이다 — 이미지 지문은 썸네일에서 뽑으므로 앞 단계가 빠지면 못 나온다.
//!
//! 픽스처가 없으면 조용히 통과한다 (`.\scripts\gen-fixtures.ps1`로 생성).
//! manifest의 expect/stage 의미는 그 스크립트 헤더에 적혀 있다.
use archive_db::Db;
use archive_indexer::{dedupe, ffmpeg::FfTools, meta, pipeline, sigq, thumbq::ThumbQueue};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::time::Duration;
fn repo_root() -> PathBuf {
// crates/archive-indexer → src-tauri → 리포 루트
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 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, ffprobe) = (
bin.join(format!("ffmpeg-{triple}{ext}")),
bin.join(format!("ffprobe-{triple}{ext}")),
);
(ffmpeg.is_file() && ffprobe.is_file()).then_some(FfTools { ffmpeg, ffprobe })
}
/// manifest 한 항목.
struct Entry {
/// 파일명만 (경로 제외)
name: String,
/// 같은 원본에서 나온 것들의 공통 키 (음성은 None)
group: Option<String>,
variant: String,
expect: String,
stage: i64,
}
fn load_manifest(kind: &str) -> Option<(PathBuf, Vec<Entry>)> {
let fixtures = repo_root().join(".fixtures");
let text = std::fs::read_to_string(fixtures.join("manifest.json")).ok()?;
let json: serde_json::Value = serde_json::from_str(&text).expect("manifest.json 파싱 실패");
let arr = json.get(kind)?.as_array()?.clone();
let entries: Vec<Entry> = arr
.iter()
.map(|e| Entry {
name: e["file"].as_str().unwrap().rsplit('/').next().unwrap().to_string(),
group: e["group"].as_str().map(|s| s.to_string()),
variant: e["variant"].as_str().unwrap_or("").to_string(),
expect: e["expect"].as_str().unwrap_or("").to_string(),
stage: e["stage"].as_i64().unwrap_or(1),
})
.collect();
let dir = fixtures.join(if kind == "images" { "images" } else { "videos" });
dir.is_dir().then_some((dir, entries))
}
fn open_db(dir: &Path) -> Arc<Db> {
Arc::new(Db::open(&dir.join("t.db")).unwrap())
}
fn add_source(db: &Arc<Db>, root: &Path) -> i64 {
let r = root.to_string_lossy().into_owned();
db.with_write(move |conn| {
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','fx',?1)", [&r])?;
Ok(conn.last_insert_rowid())
})
.unwrap()
}
/// file_id → 파일명
fn names(db: &Arc<Db>) -> HashMap<i64, String> {
db.with_read(|conn| {
let mut stmt = conn.prepare("SELECT id, name FROM files WHERE deleted_at IS NULL")?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
rows.collect()
})
.unwrap()
}
/// 탐지된 그룹 — (kind, 파일명 집합). kind 0=완전 동일 1=유사 이미지 2=유사 영상.
#[derive(Debug)]
struct Group {
kind: i64,
members: HashSet<String>,
}
fn groups_by_name(db: &Arc<Db>, names: &HashMap<i64, String>) -> Vec<Group> {
let rows: Vec<(i64, i64, i64)> = db
.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT dm.group_id, dg.kind, dm.file_id FROM dupe_members dm
JOIN dupe_groups dg ON dg.id = dm.group_id WHERE dg.status = 'open'",
)?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
rows.collect()
})
.unwrap();
let mut by_gid: HashMap<i64, Group> = HashMap::new();
for (gid, kind, fid) in rows {
if let Some(n) = names.get(&fid) {
by_gid
.entry(gid)
.or_insert_with(|| Group { kind, members: HashSet::new() })
.members
.insert(n.clone());
}
}
by_gid.into_values().collect()
}
/// 탐지 결과를 사람이 읽을 형태로 찍는다 (`--nocapture`). 점수·거리가 실제로 채워지는지도 보인다.
fn dump(db: &Arc<Db>, groups: &[Group]) {
let scores: Vec<(i64, i64, Option<f64>)> = db
.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT id, kind, score FROM dupe_groups WHERE status='open' ORDER BY kind, id",
)?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
rows.collect()
})
.unwrap();
let kind_name = |k: i64| match k {
0 => "완전동일",
1 => "유사이미지",
_ => "유사영상",
};
println!("── 탐지 결과: 그룹 {}개", groups.len());
for (_, kind, score) in &scores {
let _ = (kind, score);
}
let mut sorted: Vec<&Group> = groups.iter().collect();
sorted.sort_by_key(|g| (g.kind, g.members.len()));
for g in sorted {
let mut m: Vec<&str> = g.members.iter().map(|s| s.as_str()).collect();
m.sort_unstable();
println!(" [{}] {}", kind_name(g.kind), m.join(", "));
}
let dists: Vec<i64> = db
.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT distance FROM dupe_members WHERE distance IS NOT NULL AND is_keeper = 0",
)?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.collect()
})
.unwrap();
let sc: Vec<f64> = scores.iter().filter_map(|(_, _, s)| *s).collect();
println!(
" 거리 채워진 멤버 {}개(최대 {:?}) · 점수 채워진 그룹 {}/{}개",
dists.len(),
dists.iter().max(),
sc.len(),
scores.len()
);
}
/// 썸네일이 다 만들어질 때까지 기다린다 (없으면 이미지 지문이 안 나온다).
fn build_thumbs(db: &Arc<Db>, sid: i64, thumb_root: &Path, tools: Option<FfTools>) {
let want: i64 = db
.with_read(|c| {
c.query_row(
"SELECT count(*) FROM files WHERE kind IN (0,1) AND deleted_at IS NULL",
[],
|r| r.get(0),
)
})
.unwrap();
// 완료를 이벤트로 기다린다 — 썸네일 워커는 저우선순위라 폴링+마감은 부하에서 흔들린다.
// 마감(60초 무진전)은 정말 멈춘 경우를 위한 안전장치다.
let (tx, rx) = std::sync::mpsc::channel::<usize>();
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let d2 = done.clone();
let q = ThumbQueue::start(db.clone(), thumb_root.to_path_buf(), tools, 4, move |ids| {
let n = d2.fetch_add(ids.len(), std::sync::atomic::Ordering::SeqCst) + ids.len();
let _ = tx.send(n);
});
q.enqueue_pending(Some(sid));
while (done.load(std::sync::atomic::Ordering::SeqCst) as i64) < want {
if rx.recv_timeout(Duration::from_secs(60)).is_err() {
break;
}
}
}
/// manifest 기대값과 실제 그룹을 대조한다. 반환: 실패 메시지들.
///
/// 판정 기준은 "**같은 원본 계열의 다른 파일과 한 그룹에 있는가**"다. 원본 파일 자신과
/// 직접 묶일 필요는 없다 — 완전 동일 그룹의 keeper만 유사 패스에 남기는 설계(같은 파일이
/// 두 그룹에 중복 표시되는 것을 막는다) 때문에, 바이트 동일 사본을 경유해 연결되는 것이 정상이다.
/// `rotation`은 회전·반전 탐지를 켠 실행인지. 꺼져 있으면 rot90/flip은 단정하지 않는다
/// (그 두 변형만 8변형 질의를 필요로 한다 — 나머지 단계 3 항목은 기본 경로에서 잡힌다).
fn check(entries: &[Entry], groups: &[Group], rotation: bool) -> Vec<String> {
let mut fails = Vec::new();
let mut member_of: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, g) in groups.iter().enumerate() {
for n in &g.members {
member_of.entry(n.as_str()).or_default().push(i);
}
}
// manifest 그룹 → 그 계열의 파일명들
let mut family: HashMap<&str, Vec<&str>> = HashMap::new();
for e in entries {
if let Some(g) = e.group.as_deref() {
family.entry(g).or_default().push(e.name.as_str());
}
}
// 이 실행에서 단정할 단계인가. 단계 1·3·4 모두 구현됐다 —
// 단 rot90/flip은 회전 옵션을 켠 실행에서만 본다.
let asserted = |e: &Entry| match e.stage {
1 | 4 => true,
3 => rotation || !(e.variant == "rot90" || e.variant == "flip"),
_ => false,
};
for e in entries {
let mine = member_of.get(e.name.as_str()).cloned().unwrap_or_default();
match e.expect.as_str() {
"exact" | "similar" | "segment" if asserted(e) => {
let Some(g) = e.group.as_deref() else { continue };
let siblings: Vec<&str> = family
.get(g)
.map(|v| v.iter().copied().filter(|n| *n != e.name.as_str()).collect())
.unwrap_or_default();
let together = mine
.iter()
.any(|i| siblings.iter().any(|s| groups[*i].members.contains(*s)));
if !together {
fails.push(format!(
"{} ({}): {g} 계열과 같은 그룹에 없다 (소속 그룹 {}개)",
e.name,
e.variant,
mine.len()
));
}
// 바이트까지 같은 사본은 '완전 동일' 그룹(kind 0)이어야 한다
if e.expect == "exact" && !mine.iter().any(|i| groups[*i].kind == 0) {
fails.push(format!("{}: 바이트 동일인데 완전 동일 그룹이 아니다", e.name));
}
}
// 묶이면 안 되는 것
"none" if e.stage == 1 => {
if !mine.is_empty() {
let others: Vec<&str> = groups[mine[0]]
.members
.iter()
.filter(|n| n.as_str() != e.name)
.map(|s| s.as_str())
.collect();
fails.push(format!("{}: 묶이면 안 되는데 묶였다 — {:?}", e.name, others));
}
}
_ => {} // 단계 3·4 대상은 지금 무관 (잡혀도 실패 아님)
}
}
fails
}
#[test]
fn fixture_images_group_as_manifest_expects() {
let Some((dir, entries)) = load_manifest("images") else {
eprintln!("픽스처 없음 — 건너뜀 (scripts\\gen-fixtures.ps1 실행)");
return;
};
let tmp = tempfile::tempdir().unwrap();
let thumbs = tempfile::tempdir().unwrap();
let db = open_db(tmp.path());
let sid = add_source(&db, &dir);
let cancel = AtomicBool::new(false);
let pause = AtomicBool::new(false);
pipeline::scan_source(&db, sid, &dir, &cancel, |_| {}).unwrap();
meta::extract_image_meta(&db, sid, &cancel).unwrap();
build_thumbs(&db, sid, thumbs.path(), None);
let n = sigq::run_image_sigs(&db, thumbs.path(), &cancel, &pause).unwrap();
assert!(n > 0, "이미지 지문이 하나도 생성되지 않았다");
let stats = dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, false, &cancel, |_| {}).unwrap();
assert_eq!(stats.skipped_no_sig, 0, "지문 없이 빠진 파일이 있다");
let names = names(&db);
let groups = groups_by_name(&db, &names);
dump(&db, &groups); // cargo test -- --nocapture 로 실제 결과를 본다
let fails = check(&entries, &groups, false);
assert!(
fails.is_empty(),
"manifest 기대와 다름 ({}건):\n {}\n그룹 {}개: {:?}",
fails.len(),
fails.join("\n "),
groups.len(),
groups
);
// 정지 이미지는 시간축이 없다 — 비교 뷰가 그대로 겹칠 수 있게 오프셋 0이어야 한다
let bad: i64 = db
.with_read(|c| {
c.query_row(
"SELECT count(*) FROM dupe_members WHERE offset_ms IS NULL OR offset_ms <> 0",
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(bad, 0, "이미지 멤버의 offset_ms가 0이 아니다");
}
/// 회전·반전본은 8변형 질의를 켰을 때만 잡힌다 — 켜면 잡히고, 무관한 그림은 여전히 안 묶인다.
#[test]
fn fixture_rotated_copies_found_when_enabled() {
let Some((dir, entries)) = load_manifest("images") else {
eprintln!("픽스처 없음 — 건너뜀");
return;
};
let tmp = tempfile::tempdir().unwrap();
let thumbs = tempfile::tempdir().unwrap();
let db = open_db(tmp.path());
let sid = add_source(&db, &dir);
let cancel = AtomicBool::new(false);
let pause = AtomicBool::new(false);
pipeline::scan_source(&db, sid, &dir, &cancel, |_| {}).unwrap();
meta::extract_image_meta(&db, sid, &cancel).unwrap();
build_thumbs(&db, sid, thumbs.path(), None);
sigq::run_image_sigs(&db, thumbs.path(), &cancel, &pause).unwrap();
// 먼저 꺼진 상태 — 회전본이 자기 계열과 묶이지 않아야 한다(그게 옵션의 존재 이유다)
dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, false, &cancel, |_| {}).unwrap();
let names = names(&db);
let off_groups = groups_by_name(&db, &names);
let rot_grouped_off = off_groups.iter().any(|g| {
g.members.iter().any(|m| m.contains("_rot90")) && g.members.len() >= 2
});
assert!(!rot_grouped_off, "옵션이 꺼졌는데 회전본이 묶였다");
// 켜고 다시 — 이번엔 잡혀야 한다
dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, true, &cancel, |_| {}).unwrap();
let groups = groups_by_name(&db, &names);
dump(&db, &groups);
let fails = check(&entries, &groups, true);
assert!(
fails.is_empty(),
"회전 옵션을 켠 결과가 manifest 기대와 다름 ({}건):\n {}",
fails.len(),
fails.join("\n ")
);
}
/// 영상 경로 — 프레임 지문까지 만들어야 하므로 느리다(영상 8편 × 30초).
#[test]
fn fixture_videos_group_as_manifest_expects() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let Some((dir, entries)) = load_manifest("videos") else {
eprintln!("픽스처 없음 — 건너뜀 (scripts\\gen-fixtures.ps1 실행)");
return;
};
let tmp = tempfile::tempdir().unwrap();
let thumbs = tempfile::tempdir().unwrap();
let db = open_db(tmp.path());
let sid = add_source(&db, &dir);
let cancel = AtomicBool::new(false);
let pause = AtomicBool::new(false);
pipeline::scan_source(&db, sid, &dir, &cancel, |_| {}).unwrap();
// 영상 길이가 있어야 커버리지 판정을 할 수 있다
meta::extract_video_meta(&db, sid, &cancel, &t).unwrap();
build_thumbs(&db, sid, thumbs.path(), Some(t.clone()));
let n = sigq::run_video_sigs(&db, &t, None, &cancel, &pause).unwrap();
assert!(n > 0, "영상 지문이 하나도 생성되지 않았다");
let stats = dedupe::run(&db, dedupe::Scope::Source(sid), 5, true, false, &cancel, |_| {}).unwrap();
assert_eq!(stats.skipped_no_sig, 0, "지문 없이 빠진 영상이 있다");
let names = names(&db);
let groups = groups_by_name(&db, &names);
dump(&db, &groups); // cargo test -- --nocapture 로 실제 결과를 본다
let fails = check(&entries, &groups, false);
assert!(
fails.is_empty(),
"manifest 기대와 다름 ({}건):\n {}\n그룹 {}개: {:?}",
fails.len(),
fails.join("\n "),
groups.len(),
groups
);
// ── 비교 재생용 정렬값 ────────────────────────────────────────────
// 비교 뷰는 `offset_ms`(keeper 시각 멤버 시각)로 두 영상을 맞춘다. 이 값이 틀리면
// 나란히 놓고도 계속 다른 장면을 보게 되므로, 아는 답과 대조한다.
// manifest의 expectOffsetMs는 vidA 기준이고 keeper는 vidA가 아닐 수 있으므로
// (rank_keeper: 해상도 → 파일크기순 → vidA_reenc가 가장 크다) **간격**만 본다.
let rows: Vec<(String, Option<i64>, Option<i64>, Option<i64>)> = db
.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT f.name, dm.offset_ms, dm.seg_start_ms, dm.seg_end_ms
FROM dupe_members dm JOIN files f ON f.id = dm.file_id
JOIN dupe_groups dg ON dg.id = dm.group_id
WHERE dg.kind = 2",
)?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?;
rows.collect()
})
.unwrap();
assert!(!rows.is_empty(), "영상 그룹 멤버가 없다");
let by_name: HashMap<&str, (Option<i64>, Option<i64>, Option<i64>)> =
rows.iter().map(|(n, o, s, e)| (n.as_str(), (*o, *s, *e))).collect();
println!("── 비교 재생 정렬값 (keeper 기준)");
for (n, o, s, e) in &rows {
println!(" {n:<22} offset={o:?} seg={s:?}..{e:?}");
}
let keeper_name: &str = rows
.iter()
.map(|(n, _, _, _)| n.as_str())
.find(|n| by_name[*n].0 == Some(0))
.expect("offset 0인 keeper가 있어야 한다");
let off = |n: &str| by_name.get(n).and_then(|(o, _, _)| *o);
// 같은 원본을 그대로 재인코딩/워터마크/fps변환한 것은 keeper와 시간축이 같다
for n in ["vidA_reenc.mp4", "vidA_wm.mp4", "vidA_fps24.mp4", "vidA.mp4"] {
if n == keeper_name {
continue;
}
if let Some(o) = off(n) {
assert!(o.abs() <= 1000, "{n}: 시간축이 같아야 한다 (offset {o}ms)");
}
}
// 인트로 5초가 붙은 사본과 10초 지점을 잘라낸 사본은 그만큼 어긋나 있어야 한다.
// keeper가 vidA 계열 원본(오프셋 0)이라는 전제 아래 간격을 확인한다.
if let (Some(intro), Some(clip)) = (off("vidA_intro.mp4"), off("vidA_clip.mp4")) {
assert!(
(intro.abs() - 5000).abs() <= 1000,
"인트로 삽입본은 5초 어긋나야 한다 (offset {intro}ms)"
);
assert!(
(clip.abs() - 10000).abs() <= 1000,
"10초 지점 구간 추출본은 10초 어긋나야 한다 (offset {clip}ms)"
);
assert_eq!(intro.signum(), -clip.signum(), "두 사본은 반대 방향으로 어긋난다");
}
// 일치 구간은 keeper 시간축 안에 있어야 한다 (비교 뷰가 '구간으로 이동'에 쓴다)
for (n, _, s, e) in &rows {
if let (Some(s), Some(e)) = (s, e) {
assert!(s < e, "{n}: 구간이 뒤집혔다 ({s}..{e})");
assert!(*s >= 0, "{n}: 구간 시작이 음수 ({s})");
}
}
}