지문을 오디오 우선으로 — 네트워크 소스에서 읽는 양을 크게 줄인다
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
@@ -60,5 +60,15 @@ strip = "symbols"
|
|||||||
|
|
||||||
# 개발 빌드에서도 의존성(이미지 디코더, 해시, sqlite 등)은 최적화한다.
|
# 개발 빌드에서도 의존성(이미지 디코더, 해시, sqlite 등)은 최적화한다.
|
||||||
# 자체 크레이트만 디버그 빌드 — 이미지 디코드가 10배 이상 빨라진다.
|
# 자체 크레이트만 디버그 빌드 — 이미지 디코드가 10배 이상 빨라진다.
|
||||||
|
#
|
||||||
|
# 다만 **디버그 정보는 넣지 않는다.** rustls나 tokio 안으로 들어가 단계 실행할 일이 없는데,
|
||||||
|
# 의존성 200여 개의 PDB를 만드는 데 빌드 시간과 디스크가 크게 든다(target이 수십 GB로 불어난
|
||||||
|
# 주된 이유였다). 백트레이스에 의존성 프레임의 줄번호가 안 나오는 것이 유일한 대가다.
|
||||||
[profile.dev.package."*"]
|
[profile.dev.package."*"]
|
||||||
opt-level = 2
|
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,
|
len: usize,
|
||||||
tokens: impl Fn(usize) -> I,
|
tokens: impl Fn(usize) -> I,
|
||||||
) -> Vec<(usize, usize, u32)> {
|
) -> Vec<(usize, usize, u32)> {
|
||||||
|
|||||||
@@ -366,8 +366,11 @@ pub struct UndoBatch {
|
|||||||
pub fn list_batches(db: &Db, limit: i64) -> Result<Vec<UndoBatch>> {
|
pub fn list_batches(db: &Db, limit: i64) -> Result<Vec<UndoBatch>> {
|
||||||
Ok(db.with_read(move |conn| {
|
Ok(db.with_read(move |conn| {
|
||||||
let mut stmt = conn.prepare_cached(
|
let mut stmt = conn.prepare_cached(
|
||||||
|
// 되돌릴 수 있는가: trash/delete가 없고, 백업이 정리된 편집도 없어야 한다.
|
||||||
|
// 백업이 사라진 배치를 후보로 내주면 Ctrl+Z가 조용히 실패한다.
|
||||||
"SELECT batch_id, count(*), group_concat(DISTINCT op), max(performed_at),
|
"SELECT batch_id, count(*), group_concat(DISTINCT op), max(performed_at),
|
||||||
sum(CASE WHEN op IN ('trash','delete') THEN 1 ELSE 0 END) = 0
|
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
|
FROM ops_journal WHERE undone = 0
|
||||||
GROUP BY batch_id ORDER BY max(performed_at) DESC LIMIT ?1",
|
GROUP BY batch_id ORDER BY max(performed_at) DESC LIMIT ?1",
|
||||||
)?;
|
)?;
|
||||||
@@ -492,6 +495,12 @@ pub fn undo_batch(
|
|||||||
summary.failed += 1;
|
summary.failed += 1;
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
// 백업이 정리된 편집은 되돌릴 수 없다. list_batches가 후보에서 빼지만
|
||||||
|
// 그 사이에 정리가 돌 수 있으므로 여기서도 막는다.
|
||||||
|
if tok == BACKUP_GONE {
|
||||||
|
summary.failed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// 자르기는 역연산이 없다 — 옮겨 둔 원본을 제자리로 되돌린다
|
// 자르기는 역연산이 없다 — 옮겨 둔 원본을 제자리로 되돌린다
|
||||||
if tok == "restore" {
|
if tok == "restore" {
|
||||||
let Some(bk) = e.dst_path.as_deref() else {
|
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));
|
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]
|
#[test]
|
||||||
fn trash_tag_marks_and_lists_pending() {
|
fn trash_tag_marks_and_lists_pending() {
|
||||||
let (_d, _m, db, _sid) = setup();
|
let (_d, _m, db, _sid) = setup();
|
||||||
@@ -949,6 +1017,8 @@ pub fn edit_files(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
on_progress(total, total);
|
on_progress(total, total);
|
||||||
|
// 반전은 백업을 남기므로 여기서도 옛 백업을 치운다 (회전만 했으면 지울 게 없다)
|
||||||
|
let _ = prune_edit_backups(db);
|
||||||
Ok(sum)
|
Ok(sum)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1118,6 +1188,9 @@ pub fn cut_file(
|
|||||||
)?;
|
)?;
|
||||||
tx.commit()
|
tx.commit()
|
||||||
})?;
|
})?;
|
||||||
|
// 새 백업을 만든 참에 되돌릴 수 없게 된 옛 백업을 치운다 —
|
||||||
|
// 따로 청소 시점을 두면 그 시점이 영영 오지 않는 경우가 생긴다.
|
||||||
|
let _ = prune_edit_backups(db);
|
||||||
Ok(r)
|
Ok(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1166,3 +1239,107 @@ fn restore_from_backup(db: &Db, file_id: i64, backup: &str) -> Result<()> {
|
|||||||
})?;
|
})?;
|
||||||
Ok(())
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,9 +115,11 @@ pub fn pending_counts(db: &Db) -> Result<(i64, i64), archive_db::DbError> {
|
|||||||
[],
|
[],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)?;
|
)?;
|
||||||
|
// 오디오만 끝난 상태(3)도 '아직 남은 것'으로 센다 — 프레임이 없으면 시각 확인이
|
||||||
|
// 안 되므로 사용자에게 '완료'라고 말할 수 없다.
|
||||||
let vid: i64 = conn.query_row(
|
let vid: i64 = conn.query_row(
|
||||||
"SELECT count(*) FROM files
|
"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),
|
|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(
|
pub fn run_video_sigs(
|
||||||
db: &Arc<Db>,
|
db: &Arc<Db>,
|
||||||
tools: &FfTools,
|
tools: &FfTools,
|
||||||
@@ -303,7 +432,11 @@ pub fn run_video_sigs(
|
|||||||
let mut stmt = conn.prepare_cached(
|
let mut stmt = conn.prepare_cached(
|
||||||
"SELECT f.id, fo.path, f.name, f.duration_ms, f.acodec FROM files f
|
"SELECT f.id, fo.path, f.name, f.duration_ms, f.acodec FROM files f
|
||||||
JOIN folders fo ON fo.id = f.folder_id
|
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",
|
LIMIT ?1",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([BATCH as i64], |r| {
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2479,3 +2479,33 @@ fn b64(bytes: &[u8]) -> String {
|
|||||||
}
|
}
|
||||||
out
|
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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -192,6 +192,12 @@ fn main() {
|
|||||||
if let Some(w) = state.watchers.get() {
|
if let Some(w) = state.watchers.get() {
|
||||||
w.refresh(&state.db);
|
w.refresh(&state.db);
|
||||||
}
|
}
|
||||||
|
// 편집 백업 정리 — 되돌리기 창을 벗어난 것만 지운다.
|
||||||
|
// 편집할 때마다도 돌지만, 편집을 안 하는 사이에도 창이 밀려나므로
|
||||||
|
// 시작할 때 한 번 훑는다(메인 스레드 밖이라 기동을 늦추지 않는다).
|
||||||
|
if let Err(e) = archive_indexer::fileops::prune_edit_backups(&state.db) {
|
||||||
|
tracing::warn!("편집 백업 정리 실패: {e}");
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
@@ -338,6 +344,25 @@ fn main() {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
if let Some(t) = &tools {
|
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(
|
match archive_indexer::sigq::run_video_sigs(
|
||||||
&db, t, hw, &cancel, &pause,
|
&db, t, hw, &cancel, &pause,
|
||||||
) {
|
) {
|
||||||
@@ -429,6 +454,8 @@ fn main() {
|
|||||||
commands::edit_files,
|
commands::edit_files,
|
||||||
commands::cut_file,
|
commands::cut_file,
|
||||||
commands::filmstrip,
|
commands::filmstrip,
|
||||||
|
commands::edit_backup_stats,
|
||||||
|
commands::prune_edit_backups,
|
||||||
commands::list_tag_sets,
|
commands::list_tag_sets,
|
||||||
commands::create_tag_set,
|
commands::create_tag_set,
|
||||||
commands::update_tag_set,
|
commands::update_tag_set,
|
||||||
|
|||||||
@@ -381,6 +381,15 @@ export const ko = {
|
|||||||
inProgress: (done: number, total: number) => `처리 중 ${done}/${total}…`,
|
inProgress: (done: number, total: number) => `처리 중 ${done}/${total}…`,
|
||||||
},
|
},
|
||||||
trash: {
|
trash: {
|
||||||
|
/** 편집 백업 — 되돌리기 창을 벗어나면 지워도 되는 용량이다 */
|
||||||
|
backups: (n: number, size: string) => `편집 백업 ${n}개 · ${size}`,
|
||||||
|
backupsHint:
|
||||||
|
"자르기·반전한 원본입니다. 되돌리기 창(최근 20개 작업)을 벗어난 것은 되돌릴 수 없어 지워도 됩니다.",
|
||||||
|
backupsPrune: "정리",
|
||||||
|
backupsPruned: (n: number, size: string) =>
|
||||||
|
n === 0
|
||||||
|
? "지울 백업이 없습니다 — 전부 되돌릴 수 있는 범위입니다"
|
||||||
|
: `백업 ${n}개 정리 · ${size} 회수`,
|
||||||
tagName: "삭제 예정",
|
tagName: "삭제 예정",
|
||||||
mark: "삭제 표시",
|
mark: "삭제 표시",
|
||||||
unmark: "삭제 표시 해제",
|
unmark: "삭제 표시 해제",
|
||||||
|
|||||||
@@ -646,3 +646,13 @@ export interface StripFrame {
|
|||||||
|
|
||||||
export const filmstrip = (fileId: number, count: number) =>
|
export const filmstrip = (fileId: number, count: number) =>
|
||||||
invoke<StripFrame[]>("filmstrip", { fileId, count });
|
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");
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import {
|
|||||||
} from "../state/store";
|
} from "../state/store";
|
||||||
import { childrenOf, libraryTotal, rootsOf, subtotalOf } from "../state/folderIndex";
|
import { childrenOf, libraryTotal, rootsOf, subtotalOf } from "../state/folderIndex";
|
||||||
import { performEmptyDeleted } from "../features/fileops/ContextMenu";
|
import { performEmptyDeleted } from "../features/fileops/ContextMenu";
|
||||||
|
import { editBackupStats, pruneEditBackups } from "../ipc/commands";
|
||||||
import { performMove } from "../features/fileops/MoveDialog";
|
import { performMove } from "../features/fileops/MoveDialog";
|
||||||
import { openRemoteDialog } from "../features/sources/RemoteDialog";
|
import { openRemoteDialog } from "../features/sources/RemoteDialog";
|
||||||
import { openSourceEdit } from "../features/sources/SourceEditDialog";
|
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 = () => {
|
export const Sidebar: Component = () => {
|
||||||
const [sources, { refetch }] = createResource(
|
const [sources, { refetch }] = createResource(
|
||||||
() => sidebarVersion(),
|
() => sidebarVersion(),
|
||||||
@@ -440,6 +448,11 @@ export const Sidebar: Component = () => {
|
|||||||
() => sidebarVersion(),
|
() => sidebarVersion(),
|
||||||
() => listSmartFolders(),
|
() => listSmartFolders(),
|
||||||
);
|
);
|
||||||
|
// 편집 백업 용량 — 사이드바가 갱신될 때 같이 읽는다. 파일 stat 몇 번이라 싸다.
|
||||||
|
const [backups, { refetch: refetchBackups }] = createResource(
|
||||||
|
() => sidebarVersion(),
|
||||||
|
() => editBackupStats(),
|
||||||
|
);
|
||||||
const [newTagMode, setNewTagMode] = createSignal(false);
|
const [newTagMode, setNewTagMode] = createSignal(false);
|
||||||
// '삭제 예정'은 시스템 태그 — 일반 태그 목록에서 빼고 전용 행으로 고정 표시한다
|
// '삭제 예정'은 시스템 태그 — 일반 태그 목록에서 빼고 전용 행으로 고정 표시한다
|
||||||
const trashRow = () => (tags() ?? []).find((t) => t.id === trashTag());
|
const trashRow = () => (tags() ?? []).find((t) => t.id === trashTag());
|
||||||
@@ -891,6 +904,40 @@ export const Sidebar: Component = () => {
|
|||||||
<span class="nav-item__tail" />
|
<span class="nav-item__tail" />
|
||||||
</div>
|
</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줄이다. */}
|
필요할 때만 보이는 영역이므로 평소에는 정리 그룹이 2줄이다. */}
|
||||||
<Show when={guarded()}>
|
<Show when={guarded()}>
|
||||||
|
|||||||
Reference in New Issue
Block a user