Files
ncakanghanandClaude Opus 5 4be0235d2f
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
지문을 오디오 우선으로 — 네트워크 소스에서 읽는 양을 크게 줄인다
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>
2026-08-03 18:04:23 +09:00

595 lines
25 KiB
Rust
Raw Permalink 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})");
}
}
}
/// **오디오 지문만으로 어디까지 잡히는가.**
///
/// 프레임 지문은 파일 전체를 읽어야 해서(실측 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}");
}
}
}