지문을 오디오 우선으로 — 네트워크 소스에서 읽는 양을 크게 줄인다
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>
This commit is contained in:
ncakanghan
2026-08-03 18:04:23 +09:00
co-authored by Claude Opus 5
parent 8aa74c1a8d
commit 4be0235d2f
11 changed files with 761 additions and 4 deletions
+10
View File
@@ -60,5 +60,15 @@ strip = "symbols"
# 개발 빌드에서도 의존성(이미지 디코더, 해시, sqlite 등)은 최적화한다.
# 자체 크레이트만 디버그 빌드 — 이미지 디코드가 10배 이상 빨라진다.
#
# 다만 **디버그 정보는 넣지 않는다.** rustls나 tokio 안으로 들어가 단계 실행할 일이 없는데,
# 의존성 200여 개의 PDB를 만드는 데 빌드 시간과 디스크가 크게 든다(target이 수십 GB로 불어난
# 주된 이유였다). 백트레이스에 의존성 프레임의 줄번호가 안 나오는 것이 유일한 대가다.
[profile.dev.package."*"]
opt-level = 2
debug = false
# 자체 크레이트는 줄번호까지만. 패닉 위치를 아는 데는 충분하고,
# 전체 타입 정보를 담은 PDB보다 훨씬 작고 빠르다.
[profile.dev]
debug = "line-tables-only"
@@ -844,7 +844,10 @@ fn audio_candidates(sigs: &[&VideoSig]) -> Vec<(usize, usize, u32)> {
}
/// 값 토큰 역색인 → 공통 토큰이 충분한 쌍. 프레임·오디오가 같은 규칙을 쓴다.
fn candidates_from_tokens<I: Iterator<Item = u64>>(
///
/// `sigq`의 오디오 우선 단계도 이 함수를 쓴다 — 후보 판정 규칙이 두 곳에서 갈라지면
/// "지문 단계에서는 후보였는데 탐지에서는 아니다" 같은 설명 불가능한 상태가 생긴다.
pub(crate) fn candidates_from_tokens<I: Iterator<Item = u64>>(
len: usize,
tokens: impl Fn(usize) -> I,
) -> Vec<(usize, usize, u32)> {
@@ -366,8 +366,11 @@ pub struct UndoBatch {
pub fn list_batches(db: &Db, limit: i64) -> Result<Vec<UndoBatch>> {
Ok(db.with_read(move |conn| {
let mut stmt = conn.prepare_cached(
// 되돌릴 수 있는가: trash/delete가 없고, 백업이 정리된 편집도 없어야 한다.
// 백업이 사라진 배치를 후보로 내주면 Ctrl+Z가 조용히 실패한다.
"SELECT batch_id, count(*), group_concat(DISTINCT op), max(performed_at),
sum(CASE WHEN op IN ('trash','delete') THEN 1 ELSE 0 END) = 0
AND sum(CASE WHEN payload = 'restore-gone' THEN 1 ELSE 0 END) = 0
FROM ops_journal WHERE undone = 0
GROUP BY batch_id ORDER BY max(performed_at) DESC LIMIT ?1",
)?;
@@ -492,6 +495,12 @@ pub fn undo_batch(
summary.failed += 1;
continue;
};
// 백업이 정리된 편집은 되돌릴 수 없다. list_batches가 후보에서 빼지만
// 그 사이에 정리가 돌 수 있으므로 여기서도 막는다.
if tok == BACKUP_GONE {
summary.failed += 1;
continue;
}
// 자르기는 역연산이 없다 — 옮겨 둔 원본을 제자리로 되돌린다
if tok == "restore" {
let Some(bk) = e.dst_path.as_deref() else {
@@ -759,6 +768,65 @@ mod tests {
assert_eq!((s.done, s.skipped, s.failed), (0, 1, 0));
}
/// 되돌릴 수 없게 된 편집 백업은 정리하고, 최근 것은 남긴다.
///
/// 이 함수가 없어서 자르기·반전한 원본이 미디어 드라이브에 영원히 쌓였다 —
/// 영상 라이브러리에서는 수십 GB가 된다.
#[test]
fn prune_removes_only_unreachable_backups() {
let (_d, media, db, _sid) = setup();
let bkdir = media.path().join(APP_TRASH_DIR).join(EDIT_BACKUP_DIR);
std::fs::create_dir_all(&bkdir).unwrap();
// 최근 배치 하나 + 아주 오래된 배치 하나를 만든다
let fresh = bkdir.join("fresh.jpg");
let old = bkdir.join("old.jpg");
std::fs::write(&fresh, vec![7u8; 1000]).unwrap();
std::fs::write(&old, vec![7u8; 2000]).unwrap();
let (f, o) = (fresh.to_string_lossy().into_owned(), old.to_string_lossy().into_owned());
db.with_write(move |conn| {
// performed_at을 직접 준다 — 오래된 쪽이 창 밖으로 밀려나야 한다
conn.execute(
"INSERT INTO ops_journal(batch_id, op, file_id, dst_path, payload, performed_at)
VALUES ('b-new', 'edit', 1, ?1, 'restore', 9000)",
[f],
)?;
conn.execute(
"INSERT INTO ops_journal(batch_id, op, file_id, dst_path, payload, performed_at)
VALUES ('b-old', 'edit', 2, ?1, 'restore', 1)",
[o],
)?;
// 창(20개)을 넘기려면 사이에 배치가 더 있어야 한다
for i in 0..25 {
conn.execute(
"INSERT INTO ops_journal(batch_id, op, file_id, performed_at)
VALUES (?1, 'tag', 1, ?2)",
rusqlite::params![format!("filler-{i}"), 100 + i],
)?;
}
Ok(())
})
.unwrap();
let (n, bytes) = super::edit_backup_stats(&db).unwrap();
assert_eq!((n, bytes), (2, 3000), "정리 전 백업 2개 3000바이트");
let (removed, freed) = super::prune_edit_backups(&db).unwrap();
assert_eq!((removed, freed), (1, 2000), "오래된 것 하나만 지워야 한다");
assert!(fresh.is_file(), "최근 백업을 지웠다");
assert!(!old.is_file(), "오래된 백업이 남았다");
// 지운 항목은 되돌리기 후보에서 빠져야 한다 — 조용히 실패하면 안 된다
let batches = list_batches(&db, 40).unwrap();
let gone = batches.iter().find(|b| b.batch_id == "b-old").expect("기록은 남는다");
assert!(!gone.undoable, "백업이 없는데 되돌릴 수 있다고 한다");
let keep = batches.iter().find(|b| b.batch_id == "b-new").unwrap();
assert!(keep.undoable, "최근 편집은 되돌릴 수 있어야 한다");
// 두 번 돌려도 같은 것을 다시 세지 않는다
assert_eq!(super::prune_edit_backups(&db).unwrap().0, 0);
}
#[test]
fn trash_tag_marks_and_lists_pending() {
let (_d, _m, db, _sid) = setup();
@@ -949,6 +1017,8 @@ pub fn edit_files(
}
}
on_progress(total, total);
// 반전은 백업을 남기므로 여기서도 옛 백업을 치운다 (회전만 했으면 지울 게 없다)
let _ = prune_edit_backups(db);
Ok(sum)
}
@@ -1118,6 +1188,9 @@ pub fn cut_file(
)?;
tx.commit()
})?;
// 새 백업을 만든 참에 되돌릴 수 없게 된 옛 백업을 치운다 —
// 따로 청소 시점을 두면 그 시점이 영영 오지 않는 경우가 생긴다.
let _ = prune_edit_backups(db);
Ok(r)
}
@@ -1166,3 +1239,107 @@ fn restore_from_backup(db: &Db, file_id: i64, backup: &str) -> Result<()> {
})?;
Ok(())
}
/// 백업을 남겨 두는 배치 수.
///
/// `undo_last`는 최근 배치 하나만 되돌리지만, 목록을 훑어 고르는 UI가 붙을 수 있으니
/// [`list_batches`]가 노출하는 10개보다 넉넉하게 잡는다. 이 창을 벗어난 백업은
/// **되돌릴 방법이 없는데 자리만 차지한다** — 영상 라이브러리에서는 그게 수십 GB가 된다.
const BACKUP_KEEP_BATCHES: i64 = 20;
/// 백업이 사라진 편집 항목의 표식. `payload`에 넣어 되돌리기 후보에서 빼고,
/// 되돌리기를 시도했을 때 이유를 말할 수 있게 한다.
pub const BACKUP_GONE: &str = "restore-gone";
/// 편집 백업 정리 — 되돌릴 수 없게 된 것만 지운다.
///
/// 자르기·반전은 원본을 `.archive_trash/edits`에 남긴다. 되돌리면 그 파일이 제자리로
/// 돌아가지만(자르기) 반전은 복사본이라 남고, 아예 되돌리지 않으면 영원히 쌓인다.
/// **지우는 코드가 없어서 미디어 드라이브가 차던 문제를 여기서 닫는다.**
///
/// 반환값은 (지운 파일 수, 회수한 바이트).
pub fn prune_edit_backups(db: &Db) -> Result<(u64, u64)> {
// 최근 배치는 남긴다 — 그 안에 있는 것만 실제로 되돌릴 수 있다
let stale: Vec<(i64, String)> = db.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT id, dst_path FROM ops_journal
WHERE op = 'edit' AND payload = 'restore' AND dst_path IS NOT NULL
AND batch_id NOT IN (
SELECT batch_id FROM ops_journal
GROUP BY batch_id ORDER BY max(performed_at) DESC LIMIT ?1
)",
)?;
let rows = stmt.query_map([BACKUP_KEEP_BATCHES], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect()
})?;
// 되돌리기로 이미 소진된 백업(파일이 없다)도 표식을 정리한다 — 안 하면 매번 다시 훑는다
let consumed: Vec<i64> = db.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT id, dst_path FROM ops_journal
WHERE op = 'edit' AND payload = 'restore' AND dst_path IS NOT NULL AND undone = 1",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
Ok(rows
.filter_map(|x| x.ok())
.filter(|(_, p)| !Path::new(p).exists())
.map(|(id, _)| id)
.collect::<Vec<_>>())
})?;
let mut removed = 0u64;
let mut bytes = 0u64;
let mut ids: Vec<i64> = consumed;
for (id, path) in stale {
let p = Path::new(&path);
if let Ok(m) = std::fs::metadata(p) {
if std::fs::remove_file(p).is_ok() {
removed += 1;
bytes += m.len();
} else {
// 지울 수 없으면 표식도 바꾸지 않는다 — 다음에 다시 시도한다
continue;
}
}
ids.push(id);
}
if !ids.is_empty() {
db.with_write(move |conn| {
let tx = conn.transaction()?;
for id in &ids {
tx.execute(
"UPDATE ops_journal SET payload = ?2, dst_path = NULL WHERE id = ?1",
rusqlite::params![id, BACKUP_GONE],
)?;
}
tx.commit()
})?;
}
if removed > 0 {
tracing::info!(removed, mb = bytes / 1_048_576, "편집 백업 정리");
}
Ok((removed, bytes))
}
/// 지금 남아 있는 편집 백업의 개수와 용량. '정리' 화면에서 보여준다 —
/// 눈에 보이지 않는 용량은 사용자가 손댈 수 없다.
pub fn edit_backup_stats(db: &Db) -> Result<(u64, u64)> {
let paths: Vec<String> = db.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT dst_path FROM ops_journal
WHERE op = 'edit' AND payload = 'restore' AND dst_path IS NOT NULL",
)?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.collect()
})?;
let mut n = 0u64;
let mut bytes = 0u64;
for p in paths {
if let Ok(m) = std::fs::metadata(&p) {
n += 1;
bytes += m.len();
}
}
Ok((n, bytes))
}
+206 -3
View File
@@ -115,9 +115,11 @@ pub fn pending_counts(db: &Db) -> Result<(i64, i64), archive_db::DbError> {
[],
|r| r.get(0),
)?;
// 오디오만 끝난 상태(3)도 '아직 남은 것'으로 센다 — 프레임이 없으면 시각 확인이
// 안 되므로 사용자에게 '완료'라고 말할 수 없다.
let vid: i64 = conn.query_row(
"SELECT count(*) FROM files
WHERE sig_state = 0 AND kind = 1 AND deleted_at IS NULL",
WHERE sig_state IN (0, 3, 4) AND kind = 1 AND deleted_at IS NULL",
[],
|r| r.get(0),
)?;
@@ -283,7 +285,134 @@ fn image_sig(thumb: &Path) -> Option<Computed> {
// ── 영상 ─────────────────────────────────────────────────
/// `sig_state=0`인 영상의 프레임 시퀀스 지문을 채운다. ffmpeg 한 프로세스/영상.
/// 오디오 지문만 만들어진 상태 — 프레임은 아직 없다.
///
/// **왜 단계를 나누는가.** 프레임 지문은 초당 1장을 뽑느라 파일을 처음부터 끝까지 읽는다
/// (실측 96.8%). 오디오 지문은 `-vn`으로 영상 청크를 건너뛰어 **0.4~1.6%만** 읽는다
/// (mp4 0.4 · mov 0.4 · ts 0.3 · mkv 1.6 — 컨테이너별 실측).
///
/// 네트워크 소스에서는 이 차이가 전부다. 26TB 라이브러리를 전부 읽으면 며칠이고,
/// 오디오만 읽으면 100GB 남짓이라 한 시간이면 끝난다. 오디오 지문만 있어도
/// **중간을 잘라낸 사본**은 chromaprint 오프셋 정렬로 후보가 되므로,
/// 사용자는 며칠을 기다리지 않고 그 시점부터 중복을 찾을 수 있다.
pub const SIG_STATE_AUDIO_ONLY: i64 = 3;
/// 영상의 **오디오 지문만** 먼저 채운다 — 파일의 1% 남짓만 읽는다.
///
/// 소리가 없는 파일은 건너뛴다(상태를 그대로 0으로 둬서 프레임 단계가 집어간다).
pub fn run_video_audio_sigs(
db: &Arc<Db>,
tools: &FfTools,
cancel: &AtomicBool,
pause: &AtomicBool,
) -> Result<u64, archive_db::DbError> {
let mut total = 0u64;
loop {
if !wait_if_paused(cancel, pause) {
break;
}
// 오디오가 있는 것만. `acodec`은 메타가 채우므로 meta_state를 요구한다.
let batch: Vec<VideoJob> = db.with_read(|conn| {
let mut stmt = conn.prepare_cached(
"SELECT f.id, fo.path, f.name, f.duration_ms FROM files f
JOIN folders fo ON fo.id = f.folder_id
WHERE f.sig_state = 0 AND f.kind = 1 AND f.meta_state <> 0
AND f.acodec IS NOT NULL AND f.deleted_at IS NULL
LIMIT ?1",
)?;
let rows = stmt.query_map([BATCH as i64], |r| {
let dir: String = r.get(1)?;
let name: String = r.get(2)?;
Ok(VideoJob {
id: r.get(0)?,
path: Path::new(&dir).join(name),
duration_ms: r.get(3)?,
has_audio: true,
})
})?;
rows.collect()
})?;
if batch.is_empty() {
break;
}
let workers = read_speed(db).workers();
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(workers)
.start_handler(|_| crate::prio::set_current_thread_low())
.build()
.ok();
let compute = || -> Vec<(i64, Option<i64>, Vec<u32>)> {
batch
.par_iter()
.map(|j| {
if cancel.load(Ordering::Relaxed) {
return (j.id, j.duration_ms, Vec::new());
}
let secs = j.duration_ms.unwrap_or(0).max(0) as u64 / 1000;
// 오디오만 읽으므로 전체 패스보다 훨씬 짧게 잡아도 된다
let timeout = Duration::from_secs((secs).clamp(30, 600));
let fp = crate::ffmpeg::audio_fingerprint(tools, &j.path, timeout)
.unwrap_or_default();
(j.id, j.duration_ms, fp)
})
.collect()
};
let results = match &pool {
Some(p) => p.install(compute),
None => compute(),
};
if cancel.load(Ordering::Relaxed) {
break;
}
let n = results.len() as u64;
db.with_write(move |conn| {
let tx = conn.transaction()?;
{
// 프레임 칸은 비워 둔 채 오디오만 넣는다. 프레임 단계가 나중에 UPDATE로 채운다 —
// 그쪽에서 INSERT OR REPLACE를 쓰면 여기서 넣은 오디오가 날아간다.
let mut ins = tx.prepare_cached(
"INSERT OR REPLACE INTO file_sigs
(file_id, sig_version, kind, dhash, phash, frame_count, duration_ms, flags, audio)
VALUES (?1, ?2, 1, 0, 0, 0, ?3, 0, ?4)",
)?;
let mut upd = tx.prepare_cached("UPDATE files SET sig_state = ?2 WHERE id = ?1")?;
for (id, dur, fp) in &results {
if fp.is_empty() {
// 오디오를 못 뽑았다 — 상태를 그대로 두면 이 배치를 영원히 다시 집는다.
// 프레임 단계로 넘겨 거기서 판정하게 한다.
continue;
}
// 프레임 단계와 **같은 변환**을 거쳐야 한다 — 앵커 선택률까지 같아야
// 두 경로에서 만든 지문이 서로 비교된다.
ins.execute(rusqlite::params![
id,
sigmatch::SIG_VERSION,
dur,
sigmatch::pack_audio(&audio_anchors(fp))
])?;
upd.execute(rusqlite::params![id, SIG_STATE_AUDIO_ONLY])?;
}
// 오디오 실패분은 프레임 단계가 집도록 표시한다(무한 재시도 방지)
let mut fail = tx.prepare_cached("UPDATE files SET sig_state = ?2 WHERE id = ?1")?;
for (id, _, fp) in &results {
if fp.is_empty() {
fail.execute(rusqlite::params![id, SIG_STATE_AUDIO_ONLY])?;
}
}
}
tx.commit()
})?;
total += n;
}
Ok(total)
}
/// 영상의 프레임 시퀀스 지문을 채운다 — **파일 전체를 읽는다**. ffmpeg 한 프로세스/영상.
///
/// 오디오 단계가 끝난 뒤(`sig_state=3`) 또는 소리가 없어 그 단계를 건너뛴 파일(`0`)을 집는다.
pub fn run_video_sigs(
db: &Arc<Db>,
tools: &FfTools,
@@ -303,7 +432,11 @@ pub fn run_video_sigs(
let mut stmt = conn.prepare_cached(
"SELECT f.id, fo.path, f.name, f.duration_ms, f.acodec FROM files f
JOIN folders fo ON fo.id = f.folder_id
WHERE f.sig_state = 0 AND f.kind = 1 AND f.meta_state <> 0 AND f.deleted_at IS NULL
WHERE f.sig_state IN (0, 3, 4) AND f.kind = 1 AND f.meta_state <> 0
AND f.deleted_at IS NULL
-- 오디오 짝이 있는 것(4)부터, 그다음 소리 없는 것(0),
-- 짝이 없는 것(3)은 맨 뒤. 중복일 가능성이 높은 것부터 결과를 낸다.
ORDER BY CASE f.sig_state WHEN 4 THEN 0 WHEN 0 THEN 1 ELSE 2 END, f.id
LIMIT ?1",
)?;
let rows = stmt.query_map([BATCH as i64], |r| {
@@ -709,3 +842,73 @@ mod tests {
);
}
}
// ── 오디오 후보 우선 프레임 채우기 ───────────────────────
/// 오디오 짝이 있어 **프레임을 먼저 만들어야 하는** 상태.
///
/// 오디오 지문만으로는 그룹이 하나도 안 만들어진다(픽스처로 측정했다) — 시각 확인 단계가
/// 프레임을 요구하기 때문이다. 그렇다고 26,000편의 프레임을 다 만들면 며칠이 걸린다.
///
/// 그래서 **오디오가 겹친 파일만** 먼저 프레임을 만든다. 중복일 가능성이 있는 것부터
/// 처리하므로 결과가 며칠이 아니라 몇 분 뒤부터 나오기 시작한다.
pub const SIG_STATE_FRAMES_WANTED: i64 = 4;
/// 저장된 오디오 지문으로 후보 쌍을 찾아, 걸린 파일을 프레임 우선 대상으로 표시한다.
///
/// **파일을 읽지 않는다** — DB에 있는 지문만 본다. 26,000편이라도 초 단위다.
/// 반환: 우선 대상으로 새로 표시한 파일 수.
pub fn mark_audio_candidates(db: &Arc<Db>) -> Result<u64, archive_db::DbError> {
// 오디오만 끝난 행을 모은다. 이미 프레임이 있는 것(state 1)은 볼 이유가 없다.
let rows: Vec<(i64, Vec<u32>)> = db.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT s.file_id, s.audio FROM file_sigs s
JOIN files f ON f.id = s.file_id
WHERE f.sig_state = ?1 AND f.deleted_at IS NULL
AND s.sig_version = ?2 AND s.audio IS NOT NULL",
)?;
let it = stmt.query_map(rusqlite::params![SIG_STATE_AUDIO_ONLY, sigmatch::SIG_VERSION], |r| {
let blob: Vec<u8> = r.get::<_, Option<Vec<u8>>>(1)?.unwrap_or_default();
Ok((r.get::<_, i64>(0)?, sigmatch::unpack_audio(&blob).into_iter().map(|(_, v)| v).collect()))
})?;
it.collect()
})?;
if rows.len() < 2 {
return Ok(0);
}
// 탐지와 **같은 함수**로 후보를 뽑는다 — 규칙이 갈라지면 설명할 수 없는 차이가 생긴다
let pairs = crate::dedupe::candidates_from_tokens(rows.len(), |i| {
rows[i].1.iter().map(|v| *v as u64)
});
if pairs.is_empty() {
return Ok(0);
}
let mut wanted: Vec<i64> = Vec::new();
for (a, b, _) in &pairs {
wanted.push(rows[*a].0);
wanted.push(rows[*b].0);
}
wanted.sort_unstable();
wanted.dedup();
let n = wanted.len() as u64;
db.with_write(move |conn| {
let tx = conn.transaction()?;
{
let mut upd = tx.prepare_cached(
"UPDATE files SET sig_state = ?2 WHERE id = ?1 AND sig_state = ?3",
)?;
for id in &wanted {
upd.execute(rusqlite::params![
id,
SIG_STATE_FRAMES_WANTED,
SIG_STATE_AUDIO_ONLY
])?;
}
}
tx.commit()
})?;
tracing::info!(pairs = pairs.len(), files = n, "오디오 후보 — 프레임 우선 대상 표시");
Ok(n)
}
@@ -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}");
}
}
@@ -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}");
}
}
}
+30
View File
@@ -2479,3 +2479,33 @@ fn b64(bytes: &[u8]) -> String {
}
out
}
#[derive(serde::Serialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct BackupStats {
pub count: u64,
pub bytes: u64,
}
/// 편집 백업(자르기·반전한 원본)이 지금 얼마나 남아 있는지.
/// 눈에 보이지 않는 용량은 사용자가 손댈 수 없어서 노출한다.
#[tauri::command]
pub async fn edit_backup_stats(state: tauri::State<'_, AppState>) -> CmdResult<BackupStats> {
let db = state.db.clone();
let (count, bytes) = tauri::async_runtime::spawn_blocking(move || fileops::edit_backup_stats(&db))
.await
.map_err(err_str)?
.map_err(err_str)?;
Ok(BackupStats { count, bytes })
}
/// 되돌릴 수 없게 된 편집 백업을 지금 정리한다. 되돌리기 창 안의 것은 남는다.
#[tauri::command]
pub async fn prune_edit_backups(state: tauri::State<'_, AppState>) -> CmdResult<BackupStats> {
let db = state.db.clone();
let (count, bytes) = tauri::async_runtime::spawn_blocking(move || fileops::prune_edit_backups(&db))
.await
.map_err(err_str)?
.map_err(err_str)?;
Ok(BackupStats { count, bytes })
}
+27
View File
@@ -192,6 +192,12 @@ fn main() {
if let Some(w) = state.watchers.get() {
w.refresh(&state.db);
}
// 편집 백업 정리 — 되돌리기 창을 벗어난 것만 지운다.
// 편집할 때마다도 돌지만, 편집을 안 하는 사이에도 창이 밀려나므로
// 시작할 때 한 번 훑는다(메인 스레드 밖이라 기동을 늦추지 않는다).
if let Err(e) = archive_indexer::fileops::prune_edit_backups(&state.db) {
tracing::warn!("편집 백업 정리 실패: {e}");
}
})
.ok();
}
@@ -338,6 +344,25 @@ fn main() {
_ => {}
}
if let Some(t) = &tools {
// **오디오를 먼저 전부 끝낸다.** 파일의 1% 남짓만 읽으므로
// 네트워크 소스에서도 빠르고, 그것만으로 중간을 잘라낸 사본이
// 후보가 된다 — 며칠짜리 프레임 작업을 기다릴 필요가 없다.
match archive_indexer::sigq::run_video_audio_sigs(
&db, t, &cancel, &pause,
) {
Ok(n) if n > 0 => {
tracing::info!(count = n, "영상 오디오 지문 생성")
}
Err(e) => tracing::warn!("영상 오디오 지문 오류: {e}"),
_ => {}
}
// 오디오 짝이 있는 파일을 프레임 우선 대상으로 표시한다.
// 파일을 읽지 않고 DB만 보므로 매 바퀴 돌려도 싸다 —
// 오디오가 새로 채워질 때마다 후보가 늘어난다.
match archive_indexer::sigq::mark_audio_candidates(&db) {
Ok(_) => {}
Err(e) => tracing::warn!("오디오 후보 표시 오류: {e}"),
}
match archive_indexer::sigq::run_video_sigs(
&db, t, hw, &cancel, &pause,
) {
@@ -429,6 +454,8 @@ fn main() {
commands::edit_files,
commands::cut_file,
commands::filmstrip,
commands::edit_backup_stats,
commands::prune_edit_backups,
commands::list_tag_sets,
commands::create_tag_set,
commands::update_tag_set,
+9
View File
@@ -381,6 +381,15 @@ export const ko = {
inProgress: (done: number, total: number) => `처리 중 ${done}/${total}`,
},
trash: {
/** 편집 백업 — 되돌리기 창을 벗어나면 지워도 되는 용량이다 */
backups: (n: number, size: string) => `편집 백업 ${n}개 · ${size}`,
backupsHint:
"자르기·반전한 원본입니다. 되돌리기 창(최근 20개 작업)을 벗어난 것은 되돌릴 수 없어 지워도 됩니다.",
backupsPrune: "정리",
backupsPruned: (n: number, size: string) =>
n === 0
? "지울 백업이 없습니다 — 전부 되돌릴 수 있는 범위입니다"
: `백업 ${n}개 정리 · ${size} 회수`,
tagName: "삭제 예정",
mark: "삭제 표시",
unmark: "삭제 표시 해제",
+10
View File
@@ -646,3 +646,13 @@ export interface StripFrame {
export const filmstrip = (fileId: number, count: number) =>
invoke<StripFrame[]>("filmstrip", { fileId, count });
/** 편집 백업(자르기·반전한 원본) 현황 */
export interface BackupStats {
count: number;
bytes: number;
}
export const editBackupStats = () => invoke<BackupStats>("edit_backup_stats");
/** 되돌리기 창을 벗어난 백업만 지운다 — 최근 편집은 계속 되돌릴 수 있다 */
export const pruneEditBackups = () => invoke<BackupStats>("prune_edit_backups");
+47
View File
@@ -74,6 +74,7 @@ import {
} from "../state/store";
import { childrenOf, libraryTotal, rootsOf, subtotalOf } from "../state/folderIndex";
import { performEmptyDeleted } from "../features/fileops/ContextMenu";
import { editBackupStats, pruneEditBackups } from "../ipc/commands";
import { performMove } from "../features/fileops/MoveDialog";
import { openRemoteDialog } from "../features/sources/RemoteDialog";
import { openSourceEdit } from "../features/sources/SourceEditDialog";
@@ -420,6 +421,13 @@ const SourceRow: Component<{ source: Source; status?: SourceStatus }> = (props)
);
};
/** 사람이 읽는 용량 — 사이드바 한 줄에 들어가야 하므로 짧게 */
function fmtBytes(b: number): string {
if (b >= 1 << 30) return `${(b / (1 << 30)).toFixed(1)} GB`;
if (b >= 1 << 20) return `${Math.round(b / (1 << 20))} MB`;
return `${Math.max(1, Math.round(b / 1024))} KB`;
}
export const Sidebar: Component = () => {
const [sources, { refetch }] = createResource(
() => sidebarVersion(),
@@ -440,6 +448,11 @@ export const Sidebar: Component = () => {
() => sidebarVersion(),
() => listSmartFolders(),
);
// 편집 백업 용량 — 사이드바가 갱신될 때 같이 읽는다. 파일 stat 몇 번이라 싸다.
const [backups, { refetch: refetchBackups }] = createResource(
() => sidebarVersion(),
() => editBackupStats(),
);
const [newTagMode, setNewTagMode] = createSignal(false);
// '삭제 예정'은 시스템 태그 — 일반 태그 목록에서 빼고 전용 행으로 고정 표시한다
const trashRow = () => (tags() ?? []).find((t) => t.id === trashTag());
@@ -891,6 +904,40 @@ export const Sidebar: Component = () => {
<span class="nav-item__tail" />
</div>
{/* .
· ,
( GB가 ). */}
<Show when={(backups()?.count ?? 0) > 0}>
{(_) => (
<div class="nav-item" title={ko.trash.backupsHint}>
<span class="nav-item__icon">
<IconTrash size={14} />
</span>
<span class="nav-item__label">
{ko.trash.backups(backups()!.count, fmtBytes(backups()!.bytes))}
</span>
<span class="nav-item__tail">
<span class="nav-item__actions">
<button
class="toolbar-btn h-5 px-1.5 text-[10px]"
onClick={(e) => {
e.stopPropagation();
void (async () => {
const r = await pruneEditBackups();
toast(ko.trash.backupsPruned(r.count, fmtBytes(r.bytes)));
void refetchBackups();
})();
}}
title={ko.trash.backupsHint}
>
{ko.trash.backupsPrune}
</button>
</span>
</span>
</div>
)}
</Show>
{/* .
2. */}
<Show when={guarded()}>