지문을 오디오 우선으로 — 네트워크 소스에서 읽는 양을 크게 줄인다
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8aa74c1a8d
commit
4be0235d2f
@@ -462,3 +462,133 @@ fn fixture_videos_group_as_manifest_expects() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **오디오 지문만으로 어디까지 잡히는가.**
|
||||
///
|
||||
/// 프레임 지문은 파일 전체를 읽어야 해서(실측 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user