지문을 오디오 우선으로 — 네트워크 소스에서 읽는 양을 크게 줄인다
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
@@ -0,0 +1,111 @@
|
||||
//! **배속·피치가 다른 사본을 오디오 지문으로 잡을 수 있는가.**
|
||||
//!
|
||||
//! chromaprint는 고정 시간 격자에서 크로마 특징을 뽑는다. 재생 속도가 바뀌면 시간축과
|
||||
//! 주파수가 함께 밀리므로 값 자체가 달라질 것으로 예상되지만, **예상으로 설계를 정하면
|
||||
//! 안 된다** — 오디오 우선 파이프라인이 어떤 사본을 놓치는지가 여기서 결정된다.
|
||||
//!
|
||||
//! 픽스처의 1.25배속 사본과 원본을 비교해 공통 토큰 수를 센다.
|
||||
//! (후보 생성은 값 토큰 교집합으로 하므로 그 수가 0이면 후보 자체가 안 만들어진다)
|
||||
|
||||
use archive_indexer::ffmpeg::{self, FfTools};
|
||||
use std::collections::HashSet;
|
||||
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 })
|
||||
}
|
||||
|
||||
fn fp(t: &FfTools, p: &Path) -> Vec<u32> {
|
||||
ffmpeg::audio_fingerprint(t, p, std::time::Duration::from_secs(120)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 서로 다른 값의 교집합 크기 — 후보 생성이 보는 것과 같은 기준.
|
||||
fn shared_distinct(a: &[u32], b: &[u32]) -> (usize, usize, usize) {
|
||||
let sa: HashSet<u32> = a.iter().copied().collect();
|
||||
let sb: HashSet<u32> = b.iter().copied().collect();
|
||||
(sa.len(), sb.len(), sa.intersection(&sb).count())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_and_pitch_changes_break_audio_matching() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let vids = repo_root().join(".fixtures/videos");
|
||||
let base = vids.join("vidA.mp4");
|
||||
if !base.is_file() {
|
||||
eprintln!("픽스처 없음 — 건너뜀");
|
||||
return;
|
||||
}
|
||||
let a = fp(&t, &base);
|
||||
assert!(!a.is_empty(), "원본 오디오 지문이 비었다");
|
||||
|
||||
// 픽스처에 있는 변형들 — 오디오를 건드리지 않은 것과 건드린 것을 함께 본다
|
||||
let cases: [(&str, &str); 5] = [
|
||||
("vidA_reenc.mp4", "재인코딩(오디오 복사)"),
|
||||
("vidA_wm.mp4", "워터마크(오디오 복사)"),
|
||||
("vidA_letterbox.mp4", "레터박스(오디오 복사)"),
|
||||
("vidA_clip.mp4", "중간 잘라내기"),
|
||||
("vidA_speed125.mp4", "1.25배속 ← 오디오가 바뀐다"),
|
||||
];
|
||||
println!("{:<22} {:>8} {:>8} {:>8} {}", "파일", "원본값", "사본값", "공통값", "설명");
|
||||
let mut speed_shared = usize::MAX;
|
||||
for (name, desc) in cases {
|
||||
let p = vids.join(name);
|
||||
if !p.is_file() {
|
||||
continue;
|
||||
}
|
||||
let b = fp(&t, &p);
|
||||
let (na, nb, common) = shared_distinct(&a, &b);
|
||||
println!("{name:<22} {na:>8} {nb:>8} {common:>8} {desc}");
|
||||
if name.contains("speed125") {
|
||||
speed_shared = common;
|
||||
}
|
||||
}
|
||||
|
||||
// 그 자리에서 만든 순수 배속·피치 변형도 본다 — 픽스처 하나에 의존하지 않기 위해
|
||||
let tmp = std::env::temp_dir().join("archive-audio-speed");
|
||||
let _ = std::fs::create_dir_all(&tmp);
|
||||
let variants: [(&str, &str); 3] = [
|
||||
("atempo105.mp4", "atempo=1.05"), // 5% 빠르게 (피치 유지)
|
||||
("atempo125.mp4", "atempo=1.25"), // 25% 빠르게 (피치 유지)
|
||||
("pitch.mp4", "asetrate=44100*1.06,aresample=44100"), // 피치만 6% 올림
|
||||
];
|
||||
println!();
|
||||
for (out, filt) in variants {
|
||||
let dst = tmp.join(out);
|
||||
let r = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-v", "error", "-y", "-i"])
|
||||
.arg(&base)
|
||||
.args(["-af", filt, "-c:v", "copy"])
|
||||
.arg(&dst)
|
||||
.output();
|
||||
if r.map(|o| o.status.success()).unwrap_or(false) {
|
||||
let b = fp(&t, &dst);
|
||||
let (na, nb, common) = shared_distinct(&a, &b);
|
||||
println!("{out:<22} {na:>8} {nb:>8} {common:>8} {filt}");
|
||||
}
|
||||
}
|
||||
|
||||
// 배속 사본이 공통 토큰을 거의 안 가진다는 것을 고정한다 —
|
||||
// 오디오 우선 파이프라인의 알려진 한계이고, 그 사실이 바뀌면 설계를 다시 봐야 한다.
|
||||
if speed_shared != usize::MAX {
|
||||
println!("\n1.25배속 공통 토큰 = {speed_shared}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user