26,000편(26TB, SMB) 라이브러리에서 지문 생성이 하루에 10%밖에 진행되지 않았다.
원인은 구조다: 프레임 지문을 만들려고 **파일을 처음부터 끝까지 읽는다**(실측 96.8%).
동시 실행을 2→8로 올려도 차이가 없었다 — 대역 병목이라 읽는 양을 줄이는 것 말고는 방법이 없다.
오디오 지문은 `-vn`으로 영상 청크를 건너뛰어 파일의 일부만 읽는다(컨테이너별 실측):
mp4 0.4% · mov 0.4% · ts 0.3% · mkv 1.6% ← 전체 패스 96.8%
그래서 3단계로 나눴다.
- ① 오디오 지문을 전 파일에 먼저 (sig_state=3). 26TB가 아니라 100~400GB를 읽는다.
- ② 저장된 지문으로 후보 쌍을 뽑아 걸린 파일을 표시 (sig_state=4). 파일을 읽지 않는다.
- ③ 프레임은 우선순위대로 — 오디오 짝 있음(4) → 소리 없음(0) → 짝 없음(3).
중복일 가능성이 높은 것부터 처리하므로 결과가 며칠이 아니라 몇 분 뒤부터 나온다.
후보 판정은 탐지와 같은 함수(candidates_from_tokens)를 쓴다 — 규칙이 갈라지면
"지문 단계에서는 후보였는데 탐지에서는 아니다"는 설명 불가능한 상태가 생긴다.
측정으로 정한 것들:
- 오디오만으로는 그룹이 **0개**다(시각 확인이 프레임을 요구한다). ②③이 없으면 무의미하다.
- 오디오는 배속·피치 변화에 무너진다. 216개 토큰 중 공통:
워터마크 216 · 레터박스 216 · 재인코딩 211 · 중간자르기 22/59
1.25배속 3 · 피치+6% 8 · atempo1.05 40/205
배속 사본은 프레임 경로가 뒤에서 채운다(그쪽에 배속 후보 로직이 있다).
근거를 tests/audio_speed_evidence.rs에 고정했다.
- 절감 크기는 중복률에 비례한다. 픽스처는 10편 중 8편이 파생 사본이라 20%만 줄지만,
실제 라이브러리에서 짝이 5%면 26TB가 1.4TB가 된다. 그 비율은 ②가 로그로 알려준다.
함께 고친 것 — 편집 백업이 영구히 쌓이던 문제
- 자르기·반전은 원본을 .archive_trash/edits에 남기는데 지우는 코드가 없었다.
되돌리기는 최근 배치만 보므로 그 창을 벗어난 백업은 되돌릴 수도 없이 자리만 차지했다.
미디어 드라이브에 쌓이므로 영상 라이브러리에서는 수십 GB가 된다.
- 창(20배치)을 벗어난 것만 지운다. 앱 시작 시·편집할 때마다·사이드바 버튼으로 돈다.
- 백업이 사라진 배치는 되돌리기 후보에서 뺀다 — Ctrl+Z가 조용히 실패하면 이유를 알 수 없다.
- 사이드바 정리 영역에 「편집 백업 N개 · N MB」로 용량을 노출한다.
빌드: profile.dev에서 의존성 디버그 정보를 뺐다(PDB 200여 개가 시간·디스크를 크게 먹는다).
자체 크레이트는 line-tables-only — 패닉 위치는 그대로 안다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
595 lines
25 KiB
Rust
595 lines
25 KiB
Rust
//! 픽스처 실기 통합 테스트 — `.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})");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **오디오 지문만으로 어디까지 잡히는가.**
|
||
///
|
||
/// 프레임 지문은 파일 전체를 읽어야 해서(실측 96.8%) 26TB 네트워크 라이브러리에서는 며칠이 간다.
|
||
/// 오디오 지문은 1% 남짓만 읽으므로 한 시간이면 끝난다 — 그 상태에서 실제로 무엇이 잡히는지를
|
||
/// 매니페스트 기대값과 맞춰 본다. **추측이 아니라 이 숫자로 설계를 정한다.**
|
||
#[test]
|
||
fn audio_only_stage_coverage() {
|
||
let Some(t) = tools() else {
|
||
eprintln!("사이드카 없음 — 건너뜀");
|
||
return;
|
||
};
|
||
let Some((dir, entries)) = load_manifest("videos") else {
|
||
eprintln!("픽스처 없음 — 건너뜀");
|
||
return;
|
||
};
|
||
let tmp = 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();
|
||
|
||
// 오디오 단계만 — 프레임은 만들지 않는다
|
||
let n = sigq::run_video_audio_sigs(&db, &t, &cancel, &pause).unwrap();
|
||
println!("오디오 지문 생성: {n}편");
|
||
assert!(n > 0, "오디오 지문이 하나도 안 만들어졌다");
|
||
|
||
let stats =
|
||
dedupe::run(&db, dedupe::Scope::Source(sid), 5, true, false, &cancel, |_| {}).unwrap();
|
||
let names = names(&db);
|
||
let groups = groups_by_name(&db, &names);
|
||
println!(
|
||
"오디오만: 그룹 {}개 · 지문없이빠짐 {} · 정확중복 {}",
|
||
groups.len(),
|
||
stats.skipped_no_sig,
|
||
stats.exact_groups
|
||
);
|
||
dump(&db, &groups);
|
||
|
||
// 매니페스트 기대와 비교 — 통과/실패를 단정하지 않고 **무엇이 빠지는지 인쇄**한다.
|
||
// 이 단계는 완전한 탐지를 목표로 하지 않는다(프레임이 없으니 시각 확인을 못 한다).
|
||
let fails = check(&entries, &groups, false);
|
||
if fails.is_empty() {
|
||
println!("→ 오디오 지문만으로 매니페스트 기대를 전부 만족한다");
|
||
} else {
|
||
println!("→ 오디오만으로는 놓치는 것 {}건:", fails.len());
|
||
for f in &fails {
|
||
println!(" {f}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **오디오 우선 → 후보 표시 → 그 파일만 프레임** 순서가 실제로 그룹을 만드는가.
|
||
///
|
||
/// 이게 26TB 라이브러리의 핵심이다. 전부의 프레임을 만들면 며칠, 오디오 짝이 있는 것만
|
||
/// 만들면 몇 분이다. 그 짧은 경로로도 매니페스트 기대를 만족하는지 재고,
|
||
/// **프레임을 만든 파일 수**를 인쇄해 실제 절감분을 보여준다.
|
||
#[test]
|
||
fn audio_first_then_candidate_frames_finds_groups() {
|
||
let Some(t) = tools() else {
|
||
eprintln!("사이드카 없음 — 건너뜀");
|
||
return;
|
||
};
|
||
let Some((dir, entries)) = load_manifest("videos") else {
|
||
eprintln!("픽스처 없음 — 건너뜀");
|
||
return;
|
||
};
|
||
let tmp = 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();
|
||
|
||
let total: i64 = db
|
||
.with_read(|c| c.query_row("SELECT count(*) FROM files WHERE kind=1", [], |r| r.get(0)))
|
||
.unwrap();
|
||
|
||
// ① 오디오만 (파일의 1% 남짓 읽음)
|
||
let audio = sigq::run_video_audio_sigs(&db, &t, &cancel, &pause).unwrap();
|
||
// ② 후보 표시 (파일 읽지 않음)
|
||
let marked = sigq::mark_audio_candidates(&db).unwrap();
|
||
println!("영상 {total}편 · 오디오 지문 {audio}편 · 프레임 우선 대상 {marked}편");
|
||
|
||
// ③ 프레임 — 우선 대상만 처리되도록 딱 그 개수만큼 돌린다.
|
||
// (실제 앱은 계속 돌지만, 여기서는 '우선분만으로 그룹이 되는가'를 봐야 한다)
|
||
let wanted: Vec<i64> = db
|
||
.with_read(|c| {
|
||
let mut s = c.prepare("SELECT id FROM files WHERE sig_state = 4")?;
|
||
let out: Result<Vec<i64>, _> = s.query_map([], |r| r.get(0))?.collect();
|
||
out
|
||
})
|
||
.unwrap();
|
||
assert_eq!(wanted.len() as u64, marked, "표시 개수와 상태가 어긋난다");
|
||
// 우선 대상 외에는 프레임 단계가 손대지 못하게 잠시 제외한다
|
||
db.with_write(|c| {
|
||
c.execute("UPDATE files SET sig_state = 9 WHERE sig_state IN (0, 3) AND kind = 1", [])?;
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
sigq::run_video_sigs(&db, &t, None, &cancel, &pause).unwrap();
|
||
let framed: i64 = db
|
||
.with_read(|c| {
|
||
c.query_row("SELECT count(*) FROM files WHERE sig_state = 1 AND kind = 1", [], |r| r.get(0))
|
||
})
|
||
.unwrap();
|
||
println!("프레임 만든 파일: {framed}편 / 전체 {total}편");
|
||
|
||
let stats =
|
||
dedupe::run(&db, dedupe::Scope::Source(sid), 5, true, false, &cancel, |_| {}).unwrap();
|
||
let names = names(&db);
|
||
let groups = groups_by_name(&db, &names);
|
||
println!("그룹 {}개 · 정확중복 {}", groups.len(), stats.exact_groups);
|
||
dump(&db, &groups);
|
||
|
||
let fails = check(&entries, &groups, false);
|
||
if fails.is_empty() {
|
||
println!("→ 오디오 우선 경로만으로 매니페스트 기대를 전부 만족한다");
|
||
} else {
|
||
println!("→ 놓치는 것 {}건 (프레임 경로가 뒤에서 채운다):", fails.len());
|
||
for f in &fails {
|
||
println!(" {f}");
|
||
}
|
||
}
|
||
}
|