From 4be0235d2fad9c484539f17586152d384b3c40fa Mon Sep 17 00:00:00 2001 From: ncakanghan Date: Mon, 3 Aug 2026 18:04:23 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A7=80=EB=AC=B8=EC=9D=84=20=EC=98=A4?= =?UTF-8?q?=EB=94=94=EC=98=A4=20=EC=9A=B0=EC=84=A0=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EC=86=8C?= =?UTF-8?q?=EC=8A=A4=EC=97=90=EC=84=9C=20=EC=9D=BD=EB=8A=94=20=EC=96=91?= =?UTF-8?q?=EC=9D=84=20=ED=81=AC=EA=B2=8C=20=EC=A4=84=EC=9D=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src-tauri/Cargo.toml | 10 + .../crates/archive-indexer/src/dedupe.rs | 5 +- .../crates/archive-indexer/src/fileops.rs | 177 +++++++++++++++ src-tauri/crates/archive-indexer/src/sigq.rs | 209 +++++++++++++++++- .../tests/audio_speed_evidence.rs | 111 ++++++++++ .../archive-indexer/tests/fixture_dedupe.rs | 130 +++++++++++ src-tauri/src/commands.rs | 30 +++ src-tauri/src/main.rs | 27 +++ src/i18n/ko.ts | 9 + src/ipc/commands.ts | 10 + src/shell/Sidebar.tsx | 47 ++++ 11 files changed, 761 insertions(+), 4 deletions(-) create mode 100644 src-tauri/crates/archive-indexer/tests/audio_speed_evidence.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 45b9494..b01f5d0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/crates/archive-indexer/src/dedupe.rs b/src-tauri/crates/archive-indexer/src/dedupe.rs index 6d620a7..0fc04e9 100644 --- a/src-tauri/crates/archive-indexer/src/dedupe.rs +++ b/src-tauri/crates/archive-indexer/src/dedupe.rs @@ -844,7 +844,10 @@ fn audio_candidates(sigs: &[&VideoSig]) -> Vec<(usize, usize, u32)> { } /// 값 토큰 역색인 → 공통 토큰이 충분한 쌍. 프레임·오디오가 같은 규칙을 쓴다. -fn candidates_from_tokens>( +/// +/// `sigq`의 오디오 우선 단계도 이 함수를 쓴다 — 후보 판정 규칙이 두 곳에서 갈라지면 +/// "지문 단계에서는 후보였는데 탐지에서는 아니다" 같은 설명 불가능한 상태가 생긴다. +pub(crate) fn candidates_from_tokens>( len: usize, tokens: impl Fn(usize) -> I, ) -> Vec<(usize, usize, u32)> { diff --git a/src-tauri/crates/archive-indexer/src/fileops.rs b/src-tauri/crates/archive-indexer/src/fileops.rs index 751f6e1..5d62d10 100644 --- a/src-tauri/crates/archive-indexer/src/fileops.rs +++ b/src-tauri/crates/archive-indexer/src/fileops.rs @@ -366,8 +366,11 @@ pub struct UndoBatch { pub fn list_batches(db: &Db, limit: i64) -> Result> { 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 = 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::>()) + })?; + + let mut removed = 0u64; + let mut bytes = 0u64; + let mut ids: Vec = 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 = 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)) +} diff --git a/src-tauri/crates/archive-indexer/src/sigq.rs b/src-tauri/crates/archive-indexer/src/sigq.rs index 905a6bb..89cadfd 100644 --- a/src-tauri/crates/archive-indexer/src/sigq.rs +++ b/src-tauri/crates/archive-indexer/src/sigq.rs @@ -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 { // ── 영상 ───────────────────────────────────────────────── -/// `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, + tools: &FfTools, + cancel: &AtomicBool, + pause: &AtomicBool, +) -> Result { + let mut total = 0u64; + loop { + if !wait_if_paused(cancel, pause) { + break; + } + // 오디오가 있는 것만. `acodec`은 메타가 채우므로 meta_state를 요구한다. + let batch: Vec = 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, Vec)> { + 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, 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) -> Result { + // 오디오만 끝난 행을 모은다. 이미 프레임이 있는 것(state 1)은 볼 이유가 없다. + let rows: Vec<(i64, Vec)> = 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 = r.get::<_, Option>>(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 = 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) +} diff --git a/src-tauri/crates/archive-indexer/tests/audio_speed_evidence.rs b/src-tauri/crates/archive-indexer/tests/audio_speed_evidence.rs new file mode 100644 index 0000000..6a98171 --- /dev/null +++ b/src-tauri/crates/archive-indexer/tests/audio_speed_evidence.rs @@ -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 { + 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 { + 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 = a.iter().copied().collect(); + let sb: HashSet = 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}"); + } +} diff --git a/src-tauri/crates/archive-indexer/tests/fixture_dedupe.rs b/src-tauri/crates/archive-indexer/tests/fixture_dedupe.rs index 73ca78d..08302d2 100644 --- a/src-tauri/crates/archive-indexer/tests/fixture_dedupe.rs +++ b/src-tauri/crates/archive-indexer/tests/fixture_dedupe.rs @@ -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 = db + .with_read(|c| { + let mut s = c.prepare("SELECT id FROM files WHERE sig_state = 4")?; + let out: Result, _> = 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}"); + } + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 170593f..b6481ac 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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 { + 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 { + 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 }) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 47cabbb..e879509 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index e7afb3c..9d55c41 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -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: "삭제 표시 해제", diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts index 2f7a7db..cc758f6 100644 --- a/src/ipc/commands.ts +++ b/src/ipc/commands.ts @@ -646,3 +646,13 @@ export interface StripFrame { export const filmstrip = (fileId: number, count: number) => invoke("filmstrip", { fileId, count }); + +/** 편집 백업(자르기·반전한 원본) 현황 */ +export interface BackupStats { + count: number; + bytes: number; +} + +export const editBackupStats = () => invoke("edit_backup_stats"); +/** 되돌리기 창을 벗어난 백업만 지운다 — 최근 편집은 계속 되돌릴 수 있다 */ +export const pruneEditBackups = () => invoke("prune_edit_backups"); diff --git a/src/shell/Sidebar.tsx b/src/shell/Sidebar.tsx index 4a01b62..647945a 100644 --- a/src/shell/Sidebar.tsx +++ b/src/shell/Sidebar.tsx @@ -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 = () => { + {/* 편집 백업 — 있을 때만 보인다. + 자르기·반전한 원본이 미디어 드라이브에 쌓이는데, 보이지 않으면 + 사용자가 손댈 수 없다(영상 라이브러리에서는 수십 GB가 된다). */} + 0}> + {(_) => ( + + )} + + {/* 대량 삭제가 보류됐을 때만 나타난다 — 처리하면 사라진다. 필요할 때만 보이는 영역이므로 평소에는 정리 그룹이 2줄이다. */}