영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
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

중복 탐지
- 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시)
- 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(8변형 질의, 선택 옵션)
- 오디오 지문(chromaprint)을 같은 디코드 패스에서 뽑아 레터박스 사본을 잡는다.
  배경음만 같은 남남은 시각 확인 패스(트림 축 프레임 거리)로 걸러낸다 — 실측 9 vs 29.
- 비교 재생(CompareView): 여러 사본을 offset 정렬해 나란히 재생
- 지문 생성 속도 조절(느림·보통·빠름) — 재시작 없이 반영

간단 편집 (원본을 직접 고침, Ctrl+Z로 되돌림)
- 회전·반전: JPEG은 EXIF 방향 태그 2바이트만, MP4는 회전 행렬만 — 화질 손실 0.
  방향값 전이표는 군의 성질(90도x4=제자리)로 검산하고, 실제 화소를 transpose=1과
  비교해 "시계 방향"이 정말 시계인지 확인한다.
- 자르기: 화면 좌표 기준(회전 메타가 붙어 있어도 어긋나지 않는다)
- 구간 자르기: 스트림 복사라 무손실. 필름스트립 타임라인 + 손잡이 끌기 + 구간 미리보기.
- 되돌릴 수 없는 편집(자르기·반전)은 원본을 .archive_trash/edits로 옮겨 보관
- 편집이 수정시각을 바꾸지 않는다 — 기본 정렬이 촬영일이라 항목이 튀면 다시 찾아야 한다

동시 재생
- 고른 영상을 최대 9개 격자에 놓고 함께 재생. 소리는 한 칸만.
- 실시간 변환이 필요한 코덱은 2개까지만 — 그 이상은 ffmpeg가 기계를 멈춘다

그 외
- 별점(스냅샷 flags 남는 비트에 얹어 항목당 바이트 증가 0)
- 태그: 검색 없이 클릭으로 붙이기, 세트+단축키, 이름 수정 시 붙은 항목에 전파
- 파일 로깅(tracing-appender) — 릴리스는 콘솔이 없어 stdout만으로는 원인을 못 찾았다
- ARM64 크로스 컴파일 스크립트(관리자 권한 없이 VS 카탈로그에서 조립)
- DB 마이그레이션 v7~v10

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ncakanghan
2026-08-03 16:18:24 +09:00
co-authored by Claude Opus 5
parent 67d2ddc59e
commit 8aa74c1a8d
67 changed files with 19747 additions and 1283 deletions
+20
View File
@@ -143,6 +143,7 @@ dependencies = [
"tauri-plugin-window-state",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
]
@@ -6260,6 +6261,12 @@ dependencies = [
"serde_json",
]
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
name = "syn"
version = "1.0.109"
@@ -7085,6 +7092,19 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
+2
View File
@@ -45,6 +45,8 @@ anyhow = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# 릴리스 GUI 빌드에는 콘솔이 없다 — 파일로 남기지 않으면 진단이 통째로 사라진다.
tracing-appender = "0.2"
dirs = "6"
rusqlite = { version = "0.40", features = ["bundled"] }
notify-debouncer-full = "0.7"
@@ -156,6 +156,177 @@ CREATE TABLE settings (
) WITHOUT ROWID;
"#;
/// 스키마 v2: ops_journal.op에 'delete'(영구 삭제) 추가.
/// CHECK 제약은 ALTER로 못 바꾸므로 테이블을 재생성한다.
const V2_DDL: &str = r#"
CREATE TABLE ops_journal_v2 (
id INTEGER PRIMARY KEY,
batch_id TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('move','rename','trash','delete','tag','untag')),
file_id INTEGER,
src_path TEXT,
dst_path TEXT,
tag_id INTEGER,
performed_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
undone INTEGER NOT NULL DEFAULT 0
);
INSERT INTO ops_journal_v2(id, batch_id, op, file_id, src_path, dst_path, tag_id, performed_at, undone)
SELECT id, batch_id, op, file_id, src_path, dst_path, tag_id, performed_at, undone FROM ops_journal;
DROP TABLE ops_journal;
ALTER TABLE ops_journal_v2 RENAME TO ops_journal;
CREATE INDEX idx_ops_batch ON ops_journal(batch_id);
"#;
/// 스키마 v3: 지문(signature) 영속화.
///
/// 중복 탐지가 매 실행마다 라이브러리 전체를 다시 계산하던 것을 없애기 위해, 파일당 지문을
/// 한 번 계산해 저장한다. 재계산 단위는 `files.sig_state`(0 대기 / 1 완료 / 2 실패)이고,
/// 이는 `thumb_state`와 같은 방식이다 — **행별 상태 컬럼 + 부분 인덱스**가 곧 작업 큐이자
/// 재시작 후 이어하기 수단이다. `sig_version`을 올리면 해당 파일만 다시 계산된다.
///
/// `orient`/`tiles`/`scales`는 **대표 프레임 한 장**을 기술한다(이미지는 그 이미지,
/// 영상은 첫 유효 프레임). 프레임 단위 상세는 `frames` BLOB에 들어간다.
const V3_DDL: &str = r#"
ALTER TABLE files ADD COLUMN sig_state INTEGER NOT NULL DEFAULT 0;
CREATE INDEX idx_files_pending_sig ON files(id) WHERE sig_state = 0 AND deleted_at IS NULL;
CREATE TABLE file_sigs (
file_id INTEGER PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
sig_version INTEGER NOT NULL,
kind INTEGER NOT NULL,
dhash INTEGER NOT NULL,
phash INTEGER NOT NULL,
orient BLOB,
tiles BLOB,
scales BLOB,
frames BLOB,
keyframes BLOB,
frame_count INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER,
stdev REAL,
flags INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000)
);
CREATE INDEX idx_file_sigs_dhash ON file_sigs(dhash);
CREATE INDEX idx_file_sigs_version ON file_sigs(sig_version);
"#;
/// 스키마 v4: 기본 정렬용 표현식 인덱스.
///
/// 그리드 스냅샷의 기본 정렬은 `COALESCE(taken_at, mtime_ms) DESC, id DESC`다. 표현식이라
/// 어떤 컬럼 인덱스도 쓰이지 못해, SQLite가 매번 임시 B-tree로 **전체를 정렬**했다.
/// 100만 건 합성 DB 실측: 첫 행까지 1,574ms(정렬 완료를 기다림) → 인덱스 추가 후 0ms,
/// 스캔+정렬 239ms → 54ms. 임시 B-tree(100만 항목, 디스크로 흘러넘침)도 사라진다.
///
/// 두 개가 필요하다: 소스 뷰는 `source_id`가 선두여야 하고(작은 소스도 O(결과)),
/// 라이브러리 전체 뷰는 선두 컬럼이 없어야 정렬된 순서로 훑을 수 있다.
/// 대가는 삽입 시 인덱스 2개 유지 — 스캔이 조금 느려지는 대신 뷰 전환이 즉시 뜬다.
const V4_DDL: &str = r#"
CREATE INDEX idx_files_src_sort ON files(source_id, COALESCE(taken_at, mtime_ms) DESC, id DESC)
WHERE deleted_at IS NULL;
CREATE INDEX idx_files_sort ON files(COALESCE(taken_at, mtime_ms) DESC, id DESC)
WHERE deleted_at IS NULL;
"#;
/// 스키마 v5: 중복 그룹의 **일치 구간**을 저장한다 (비교 재생용).
///
/// 영상 중복은 오프셋 투표로 "A의 10~20초가 B의 0~10초와 같다"까지 알아내는데, 지금까지는
/// 그 값을 로그로만 흘려보냈다. 비교 재생에서 두 영상을 같은 내용에 맞춰 돌리려면 이 값이
/// 필요하다 — 그냥 나란히 0초부터 재생하면 인트로가 붙은 사본은 계속 어긋난 화면을 보여준다.
///
/// 기준은 그룹의 keeper다: `offset_ms = (keeper 시각) - (이 멤버 시각)`.
/// 즉 재생 위치를 맞출 때 `member.currentTime = keeper.currentTime - offset_ms/1000`.
/// `seg_start_ms`/`seg_end_ms`는 keeper 시간축에서 일치가 확인된 구간이다.
const V5_DDL: &str = r#"
ALTER TABLE dupe_members ADD COLUMN offset_ms INTEGER;
ALTER TABLE dupe_members ADD COLUMN seg_start_ms INTEGER;
ALTER TABLE dupe_members ADD COLUMN seg_end_ms INTEGER;
"#;
/// 스키마 v6: 후보 검증용 두 축을 저장한다 (`sig_version` 2와 짝).
///
/// - `tile_flat`: 4×4 타일별 '평평함'(정보 없음) 비트마스크. 검정 바·흰 로고로 가려진 영역을
/// 판정에서 빼기 위해 필요하다. 이게 없으면 *가림이 같다는 이유로* 서로 다른 사진이
/// 한 그룹이 된다(실측된 오탐).
/// - `color`: 4×4 셀의 색차(u,v) 32바이트. 그레이 dHash는 구조만 보므로 색만 다른 그림을
/// 구별하지 못한다(실측: 초록/자홍 그라디언트 거리 0). 영상은 그레이 파이프라 NULL이다.
///
/// 기존 행은 `sig_version`이 낮아 자연히 재계산 대상이 된다 — 별도 백필이 필요 없다.
const V6_DDL: &str = r#"
ALTER TABLE file_sigs ADD COLUMN tile_flat INTEGER NOT NULL DEFAULT 0;
ALTER TABLE file_sigs ADD COLUMN color BLOB;
UPDATE files SET sig_state = 0 WHERE sig_state = 1;
"#;
/// 스키마 v7: 영상 오디오 지문(chromaprint) 저장 (`sig_version` 3과 짝).
///
/// 화면을 다시 표본화하는 변형(레터박스·리스케일)은 프레임 해시가 밀려 값 기반 후보에
/// 걸리지 않는다 — 실측: 검정 띠를 정확히 잘라내도 거리 중앙값 9.5, 정확히 같은 값 0개.
/// 오디오는 그런 변형에 영향받지 않으므로(레터박스 사본은 221/221 일치) 후보를 만드는
/// 두 번째 축으로 쓴다. 다만 오디오만 같은 남남도 똑같이 붙으므로(배경음악 공유)
/// 후보는 반드시 프레임으로 확인한다 — 그 간격도 실측했다(진짜 9 vs 남남 29).
///
/// 재계산은 `sigq::invalidate_stale_sigs`가 `sig_version` 변화를 보고 알아서 예약한다.
const V7_DDL: &str = r#"
ALTER TABLE file_sigs ADD COLUMN audio BLOB;
"#;
/// 스키마 v8: 태그 세트 — 자주 함께 붙이는 태그 묶음을 한 번에 적용한다.
///
/// 태그를 하나씩 검색해 붙이는 것이 정리 작업의 대부분을 차지한다는 지적에서 나왔다.
/// `hotkey`는 화면에 매인 값이 아니라 사용자 설정이므로 DB에 둔다(기기를 옮겨도 따라간다).
/// 태그가 지워지면 세트에서도 빠진다(CASCADE) — 세트는 태그의 묶음일 뿐 사본이 아니다.
const V8_DDL: &str = r#"
CREATE TABLE tag_sets (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
hotkey TEXT,
sort_order INTEGER
);
CREATE TABLE tag_set_items (
set_id INTEGER NOT NULL REFERENCES tag_sets(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (set_id, tag_id)
) WITHOUT ROWID;
CREATE INDEX idx_tag_set_items_tag ON tag_set_items(tag_id);
"#;
/// 스키마 v9: 별점(0~5).
///
/// 부분 인덱스를 쓰는 이유: 대부분의 파일은 별점이 없다(0). '별점 있는 것만' 걸러 정렬·필터할 때
/// 0인 수십만 행을 훑지 않도록 0을 색인에서 빼둔다.
const V9_DDL: &str = r#"
ALTER TABLE files ADD COLUMN rating INTEGER NOT NULL DEFAULT 0;
CREATE INDEX idx_files_rating ON files(rating DESC, id DESC) WHERE rating > 0 AND deleted_at IS NULL;
"#;
/// 스키마 v10: ops_journal에 'edit'(회전·반전) 추가 + payload 칸.
///
/// 회전은 되돌릴 때 **역연산을 다시 적용하면 된다** — 원본을 백업해 둘 필요가 없다.
/// 그 역연산 이름을 담을 칸이 필요해서 payload를 만든다. src_path/dst_path에
/// 억지로 끼워 넣으면 다음에 읽는 사람이 경로인 줄 안다.
///
/// SQLite는 CHECK 제약을 ALTER로 못 바꾸므로 v2와 같은 방식으로 표를 다시 만든다.
const V10_DDL: &str = r#"
CREATE TABLE ops_journal_v10 (
id INTEGER PRIMARY KEY,
batch_id TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('move','rename','trash','delete','tag','untag','edit')),
file_id INTEGER,
src_path TEXT,
dst_path TEXT,
tag_id INTEGER,
payload TEXT,
performed_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
undone INTEGER NOT NULL DEFAULT 0
);
INSERT INTO ops_journal_v10(id, batch_id, op, file_id, src_path, dst_path, tag_id, performed_at, undone)
SELECT id, batch_id, op, file_id, src_path, dst_path, tag_id, performed_at, undone FROM ops_journal;
DROP TABLE ops_journal;
ALTER TABLE ops_journal_v10 RENAME TO ops_journal;
CREATE INDEX idx_ops_batch ON ops_journal(batch_id);
"#;
/// FTS5 trigram — 한글 파일명 부분일치 검색. bundled SQLite가 trigram을
/// 지원하지 않는 환경이면 생성 실패를 허용하고 LIKE 폴백을 쓴다.
const V1_FTS_DDL: &str = r#"
@@ -183,6 +354,7 @@ END;
/// 마이그레이션을 적용하고 FTS 사용 가능 여부를 반환한다.
pub fn migrate(conn: &mut Connection) -> rusqlite::Result<bool> {
let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
let mut fresh_fts: Option<bool> = None;
if version < 1 {
let tx = conn.transaction()?;
@@ -199,6 +371,75 @@ pub fn migrate(conn: &mut Connection) -> rusqlite::Result<bool> {
if !fts_ok {
tracing::warn!("FTS5 trigram 사용 불가 — LIKE 검색으로 폴백");
}
fresh_fts = Some(fts_ok);
}
if version < 2 {
let tx = conn.transaction()?;
tx.execute_batch(V2_DDL)?;
tx.pragma_update(None, "user_version", 2)?;
tx.commit()?;
}
if version < 3 {
let tx = conn.transaction()?;
tx.execute_batch(V3_DDL)?;
tx.pragma_update(None, "user_version", 3)?;
tx.commit()?;
}
if version < 4 {
// 기존 라이브러리에서는 인덱스 2개를 새로 만든다 — 100만 건에서 합쳐 3초 남짓,
// 앱 시작이 한 번 그만큼 늦어지는 대신 이후 모든 뷰 전환이 즉시 뜬다.
let tx = conn.transaction()?;
tx.execute_batch(V4_DDL)?;
tx.pragma_update(None, "user_version", 4)?;
tx.commit()?;
}
if version < 5 {
let tx = conn.transaction()?;
tx.execute_batch(V5_DDL)?;
tx.pragma_update(None, "user_version", 5)?;
tx.commit()?;
}
if version < 6 {
let tx = conn.transaction()?;
tx.execute_batch(V6_DDL)?;
tx.pragma_update(None, "user_version", 6)?;
tx.commit()?;
}
if version < 7 {
let tx = conn.transaction()?;
tx.execute_batch(V7_DDL)?;
tx.pragma_update(None, "user_version", 7)?;
tx.commit()?;
}
if version < 8 {
let tx = conn.transaction()?;
tx.execute_batch(V8_DDL)?;
tx.pragma_update(None, "user_version", 8)?;
tx.commit()?;
}
if version < 9 {
let tx = conn.transaction()?;
tx.execute_batch(V9_DDL)?;
tx.pragma_update(None, "user_version", 9)?;
tx.commit()?;
}
if version < 10 {
let tx = conn.transaction()?;
tx.execute_batch(V10_DDL)?;
tx.pragma_update(None, "user_version", 10)?;
tx.commit()?;
}
if let Some(fts_ok) = fresh_fts {
return Ok(fts_ok);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,868 @@
//! 간단 편집 — 회전·반전.
//!
//! **원본을 직접 고친다.** 그래서 두 가지를 지킨다.
//!
//! **① 될 수 있으면 픽셀을 다시 인코딩하지 않는다.**
//! JPEG은 EXIF 방향 태그 2바이트만, MP4는 회전 행렬만 바꾼다. 둘 다 화질 손실이 0이고
//! 몇 밀리초에 끝난다 — 4000×3000 사진을 다시 인코딩하면 매번 조금씩 뭉개지는데,
//! 사진을 두 번 돌려 제자리로 돌아오면 화질만 두 번 잃는 꼴이 된다.
//!
//! **② 쓰기는 임시 파일에 하고 마지막에 바꿔치기한다.**
//! 원본 위에 바로 쓰다가 중간에 죽으면 반쯤 쓰인 파일이 남는다 — 되돌릴 방법이 없다.
//!
//! EXIF 방향 값(1~8)은 8원소 군(群)을 이룬다. 회전·반전은 그 위의 연산이므로
//! 전이표로 적는다. 표가 맞는지는 군의 성질(90°를 네 번 = 제자리)로 확인한다.
use crate::ffmpeg::{self, FfTools};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub enum EditOp {
/// 시계 방향 90°
RotateCw,
/// 반시계 방향 90°
RotateCcw,
Rotate180,
/// 좌우 반전 (거울)
FlipH,
/// 상하 반전
FlipV,
}
#[derive(Debug)]
pub enum EditError {
Io(std::io::Error),
Ff(ffmpeg::FfError),
/// 이 형식은 방향 정보를 담을 수 없다 — 다시 인코딩해야 한다
NeedsReencode,
Failed(String),
}
impl std::fmt::Display for EditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EditError::Io(e) => write!(f, "입출력: {e}"),
EditError::Ff(e) => write!(f, "ffmpeg: {e:?}"),
EditError::NeedsReencode => write!(f, "이 형식은 회전 정보를 저장할 수 없습니다"),
EditError::Failed(s) => write!(f, "{s}"),
}
}
}
impl From<std::io::Error> for EditError {
fn from(e: std::io::Error) -> Self {
EditError::Io(e)
}
}
// ── EXIF 방향 (1~8) 위의 연산 ────────────────────────────
//
// 1=정상 2=좌우반전 3=180° 4=상하반전
// 5=전치 6=시계90° 7=역전치 8=반시계90°
//
// 첨자는 방향값-1. 표를 손으로 적었으므로 아래 테스트가 군의 성질로 검산한다.
const CW: [u8; 8] = [6, 7, 8, 5, 2, 3, 4, 1];
const FLIP_H: [u8; 8] = [2, 1, 4, 3, 6, 5, 8, 7];
/// 방향값에 연산을 적용한 새 방향값. 범위를 벗어난 입력은 1(정상)로 본다.
pub fn apply_orientation(current: u8, op: EditOp) -> u8 {
let cur = if (1..=8).contains(&current) { current } else { 1 };
let i = (cur - 1) as usize;
match op {
EditOp::RotateCw => CW[i],
EditOp::RotateCcw => CW[(CW[(CW[i] - 1) as usize] - 1) as usize], // 시계 3번 = 반시계 1번
EditOp::Rotate180 => CW[(CW[i] - 1) as usize],
EditOp::FlipH => FLIP_H[i],
// 상하반전 = 좌우반전 + 180°
EditOp::FlipV => {
let h = FLIP_H[i];
CW[(CW[(h - 1) as usize] - 1) as usize]
}
}
}
/// 이 연산이 가로·세로를 맞바꾸는가 — DB의 width/height를 함께 고쳐야 한다.
pub fn swaps_dimensions(op: EditOp) -> bool {
matches!(op, EditOp::RotateCw | EditOp::RotateCcw)
}
// ── JPEG: EXIF 방향 태그만 고친다 ────────────────────────
/// JPEG 바이트에서 EXIF 방향 태그를 찾아 값을 바꾼다. 성공하면 true.
///
/// APP1(0xFFE1) 안의 `Exif\0\0` 다음이 TIFF 헤더다. 거기서 IFD0을 훑어
/// 태그 0x0112(Orientation)의 값 2바이트만 제자리에서 고친다.
/// **다른 바이트는 건드리지 않는다** — 압축된 화상 데이터는 그대로 남는다.
pub fn patch_jpeg_orientation(buf: &mut [u8], new_value: u8) -> bool {
let Some(tiff) = find_exif_tiff(buf) else { return false };
let Some(off) = find_orientation_offset(&buf[tiff..]) else { return false };
let at = tiff + off;
let le = buf[tiff] == b'I';
// SHORT 값은 4바이트 칸의 **앞쪽 2바이트**에 들어간다(리틀엔디언이든 빅엔디언이든
// 값 칸의 시작이 기준이다). 나머지 2바이트는 0으로 남겨둔다.
if le {
buf[at] = new_value;
buf[at + 1] = 0;
} else {
buf[at] = 0;
buf[at + 1] = new_value;
}
true
}
/// APP1 Exif 세그먼트 안 TIFF 헤더의 시작 위치.
fn find_exif_tiff(buf: &[u8]) -> Option<usize> {
if buf.len() < 4 || buf[0] != 0xFF || buf[1] != 0xD8 {
return None; // SOI가 아니면 JPEG이 아니다
}
let mut i = 2;
while i + 4 <= buf.len() {
if buf[i] != 0xFF {
return None; // 마커 정렬이 깨졌다 — 더 뒤지지 않는다
}
let marker = buf[i + 1];
// SOS(0xDA) 이후는 압축 데이터라 세그먼트 구조가 없다
if marker == 0xDA || marker == 0xD9 {
return None;
}
let len = u16::from_be_bytes([buf[i + 2], buf[i + 3]]) as usize;
if len < 2 || i + 2 + len > buf.len() {
return None;
}
if marker == 0xE1 && buf[i + 4..].starts_with(b"Exif\0\0") {
return Some(i + 10);
}
i += 2 + len;
}
None
}
/// TIFF 블록 시작을 기준으로, 방향 태그 **값 칸**의 상대 위치.
fn find_orientation_offset(t: &[u8]) -> Option<usize> {
if t.len() < 8 {
return None;
}
let le = match &t[0..2] {
b"II" => true,
b"MM" => false,
_ => return None,
};
let u16at = |p: usize| -> u16 {
let b = [t[p], t[p + 1]];
if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) }
};
let u32at = |p: usize| -> u32 {
let b = [t[p], t[p + 1], t[p + 2], t[p + 3]];
if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) }
};
if u16at(2) != 42 {
return None;
}
let ifd0 = u32at(4) as usize;
if ifd0 + 2 > t.len() {
return None;
}
let count = u16at(ifd0) as usize;
for n in 0..count {
let e = ifd0 + 2 + n * 12;
if e + 12 > t.len() {
return None;
}
if u16at(e) == 0x0112 {
// 값 칸은 항목 시작 + 8. SHORT 1개는 칸 안에 직접 들어간다.
return Some(e + 8);
}
}
None
}
/// EXIF가 없는 JPEG에 붙일 최소 APP1 세그먼트. 방향 태그 하나만 담는다.
///
/// 카메라 사진에는 거의 항상 EXIF가 있지만, 스크린샷·편집본에는 없다.
/// 없다고 다시 인코딩하면 그 파일만 화질을 잃으므로 껍데기를 만들어 붙인다.
fn minimal_exif_app1(orientation: u8) -> Vec<u8> {
// TIFF(리틀엔디언) + IFD0 항목 1개 + 다음 IFD 없음
let mut tiff: Vec<u8> = Vec::new();
tiff.extend_from_slice(b"II");
tiff.extend_from_slice(&42u16.to_le_bytes());
tiff.extend_from_slice(&8u32.to_le_bytes()); // IFD0 위치
tiff.extend_from_slice(&1u16.to_le_bytes()); // 항목 수
tiff.extend_from_slice(&0x0112u16.to_le_bytes()); // Orientation
tiff.extend_from_slice(&3u16.to_le_bytes()); // SHORT
tiff.extend_from_slice(&1u32.to_le_bytes()); // 개수 1
tiff.extend_from_slice(&(orientation as u16).to_le_bytes());
tiff.extend_from_slice(&[0, 0]); // 값 칸 나머지
tiff.extend_from_slice(&0u32.to_le_bytes()); // 다음 IFD 없음
let body_len = 6 + tiff.len(); // "Exif\0\0" + TIFF
let mut seg = vec![0xFF, 0xE1];
seg.extend_from_slice(&((body_len + 2) as u16).to_be_bytes());
seg.extend_from_slice(b"Exif\0\0");
seg.extend_from_slice(&tiff);
seg
}
/// JPEG 바이트에 방향값을 적용한 새 바이트를 만든다. 이미 태그가 있으면 제자리 수정,
/// 없으면 SOI 바로 뒤에 최소 EXIF를 끼워 넣는다.
pub fn jpeg_with_orientation(mut buf: Vec<u8>, value: u8) -> Option<Vec<u8>> {
if patch_jpeg_orientation(&mut buf, value) {
return Some(buf);
}
if buf.len() < 2 || buf[0] != 0xFF || buf[1] != 0xD8 {
return None;
}
let seg = minimal_exif_app1(value);
let mut out = Vec::with_capacity(buf.len() + seg.len());
out.extend_from_slice(&buf[0..2]);
out.extend_from_slice(&seg);
out.extend_from_slice(&buf[2..]);
Some(out)
}
// ── 실제 파일에 적용 ─────────────────────────────────────
/// 편집 결과. 호출부가 DB(치수·방향)와 썸네일을 맞추는 데 쓴다.
pub struct EditResult {
/// 새 EXIF 방향값 (이미지만)
pub orientation: Option<u8>,
/// 새 회전 각도 (영상만, 0/90/180/270)
pub rotation: Option<i32>,
/// 가로·세로가 바뀌었는가
pub swapped: bool,
/// 픽셀을 다시 인코딩했는가 — false면 화질 손실 0
pub reencoded: bool,
/// 자르기처럼 크기가 달라졌으면 새 (가로, 세로). 회전은 None(swapped로 표현된다).
pub new_size: Option<(u32, u32)>,
}
const JPEG_EXT: [&str; 3] = ["jpg", "jpeg", "jfif"];
fn ext_of(p: &Path) -> String {
p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
}
/// 편집용 임시 경로 — `clip.mp4` → `clip.tmp-edit.mp4`.
///
/// **확장자를 뒤에 남긴다.** ffmpeg은 확장자로 출력 형식을 고르기 때문에
/// `clip.mp4.tmp-edit`으로 두면 `Unable to choose an output format`으로 죽는다(실측).
/// 대신 이름에 표식이 들어가므로 스캐너가 [`crate::model::is_edit_temp`]로 걸러낸다.
fn temp_path(p: &Path) -> std::path::PathBuf {
let ext = ext_of(p);
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "edit".into());
let mark = crate::model::EDIT_TMP_MARK;
let name = if ext.is_empty() {
format!("{stem}{mark}")
} else {
format!("{stem}{mark}.{ext}")
};
p.parent().unwrap_or(Path::new(".")).join(name)
}
/// 임시 파일을 원본 자리에 앉힌다. `backup`이 있으면 원본을 **복사하지 않고 옮겨** 보관한다.
///
/// 자르기는 역연산이 없어서(잘라낸 화소는 되살릴 수 없다) 되돌리려면 원본이 필요하다.
/// 그런데 30GB 영상을 복사해 두면 디스크가 남아나지 않는다 — 같은 볼륨 안에서는
/// `rename`이 즉시 끝나므로 옮겨서 보관한다.
///
/// 중간에 실패하면 원본을 제자리로 되돌린다. 이 함수를 벗어날 때 원본은 언제나
/// 제자리에 있거나 백업 자리에 있다 — 사라지는 경우는 없다.
fn place(tmp: &Path, dst: &Path, backup: Option<&Path>) -> std::io::Result<()> {
let Some(bk) = backup else {
return std::fs::rename(tmp, dst).inspect_err(|_| {
let _ = std::fs::remove_file(tmp);
});
};
if let Some(parent) = bk.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(dst, bk).inspect_err(|_| {
let _ = std::fs::remove_file(tmp);
})?;
if let Err(e) = std::fs::rename(tmp, dst) {
// 새 파일을 못 앉혔다 — 원본을 제자리로 돌려놓고 실패로 알린다
let _ = std::fs::rename(bk, dst);
let _ = std::fs::remove_file(tmp);
return Err(e);
}
Ok(())
}
/// 같은 폴더에 임시로 쓰고 원본 자리에 바꿔치기한다.
///
/// 같은 폴더를 쓰는 이유는 `rename`이 같은 볼륨 안에서만 원자적이기 때문이다.
/// 임시 디렉터리(다른 드라이브일 수 있다)에 썼다가 옮기면 복사가 되어 중간 상태가 생긴다.
fn write_replacing(dst: &Path, bytes: &[u8]) -> std::io::Result<()> {
let tmp = temp_path(dst);
std::fs::write(&tmp, bytes)?;
match std::fs::rename(&tmp, dst) {
Ok(()) => Ok(()),
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e)
}
}
}
/// 이미지 한 장을 회전·반전한다. JPEG이면 EXIF만 고쳐 화질 손실이 없다.
pub fn rotate_image(path: &Path, current_orientation: u8, op: EditOp) -> Result<EditResult, EditError> {
let next = apply_orientation(current_orientation, op);
if JPEG_EXT.contains(&ext_of(path).as_str()) {
let buf = std::fs::read(path)?;
let out = jpeg_with_orientation(buf, next).ok_or(EditError::NeedsReencode)?;
write_replacing(path, &out)?;
return Ok(EditResult {
orientation: Some(next),
rotation: None,
swapped: swaps_dimensions(op),
reencoded: false,
new_size: None,
});
}
// JPEG이 아니면 픽셀을 실제로 돌린다. png·bmp는 무손실이라 결과가 같다.
rotate_image_pixels(path, op)
}
/// 픽셀을 실제로 돌려 다시 쓴다 (EXIF를 못 쓰는 형식용).
fn rotate_image_pixels(path: &Path, op: EditOp) -> Result<EditResult, EditError> {
let img = image::open(path).map_err(|e| EditError::Failed(format!("이미지를 열 수 없습니다: {e}")))?;
let out = match op {
EditOp::RotateCw => img.rotate90(),
EditOp::RotateCcw => img.rotate270(),
EditOp::Rotate180 => img.rotate180(),
EditOp::FlipH => img.fliph(),
EditOp::FlipV => img.flipv(),
};
// 같은 형식으로 다시 쓴다 — 확장자가 바뀌면 DB의 이름과 어긋난다
let tmp = temp_path(path);
out.save(&tmp).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
EditError::Failed(format!("저장 실패: {e}"))
})?;
std::fs::rename(&tmp, path)?;
Ok(EditResult {
orientation: Some(1),
rotation: None,
swapped: swaps_dimensions(op),
reencoded: true,
new_size: None,
})
}
/// 반전에 쓸 H.264 인코더를 고른다 — 하드웨어 우선, 마지막은 항상 있는 소프트웨어.
///
/// **번들 ffmpeg에 x264는 없다**(GPL이라 빠졌다). 그리고 `-encoders` 목록에 있다고
/// 되는 것도 아니다 — nvenc는 NVIDIA 드라이버가, qsv는 인텔 GPU가 있어야 한다.
/// 그래서 후보마다 실제로 몇 프레임을 인코딩해 보고 고른다. 한 번만 찾고 기억한다.
///
/// 회전은 이 함수를 쓰지 않는다(스트림 복사라 인코더가 필요 없다).
pub fn h264_encoder(tools: &FfTools) -> &'static str {
static PICK: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
PICK.get_or_init(|| {
for enc in ["h264_nvenc", "h264_qsv", "h264_amf", "h264_mf", "libopenh264"] {
let ok = ffmpeg::command(&tools.ffmpeg)
.args(["-v", "error", "-nostdin", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=10:d=0.4"])
.args(["-c:v", enc, "-b:v", "1M", "-pix_fmt", "yuv420p", "-f", "null", "-"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
tracing::info!("편집용 영상 인코더: {enc}");
return enc;
}
}
"libopenh264"
})
}
/// 회전 각도에 연산을 적용 (영상). 결과는 항상 0/90/180/270.
pub fn apply_rotation(current: i32, op: EditOp) -> Option<i32> {
let d = match op {
EditOp::RotateCw => 90,
EditOp::RotateCcw => -90,
EditOp::Rotate180 => 180,
// 반전은 회전 행렬로 표현할 수 없다 — 호출부가 다시 인코딩해야 한다
EditOp::FlipH | EditOp::FlipV => return None,
};
Some((current + d).rem_euclid(360))
}
/// 영상의 현재 회전 각도를 **시계 방향 기준**으로 읽는다. 없으면 0.
///
/// ffprobe가 내주는 `rotation`은 반시계 기준이라 부호를 뒤집는다 —
/// 쓰는 쪽([`rotate_video`])과 같은 규약을 써야 두 번 돌렸을 때 제대로 누적된다.
pub fn video_rotation(tools: &FfTools, path: &Path) -> i32 {
let out = ffmpeg::command(&tools.ffprobe)
.args(["-v", "quiet", "-select_streams", "v:0", "-show_entries", "stream_side_data=rotation"])
.args(["-of", "default=nw=1:nk=1"])
.arg(path)
.output();
out.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.trim().lines().next()?.trim().parse::<f64>().ok())
.map(|v| (-(v.round() as i32)).rem_euclid(360))
.unwrap_or(0)
}
/// 영상을 회전한다. 회전은 메타데이터만 바꿔 무손실·즉시,
/// 반전은 회전 행렬로 표현할 수 없어 다시 인코딩한다.
/// 재인코딩할 때 쓸 비트레이트(kbps). 화소당 0.1비트 · 30fps 기준.
///
/// **호출부에서 받지 않는다.** 예전에는 인자로 받았는데 회전 경로가 이 값을 안 쓴다는
/// 이유로 호출부가 `0`을 넘겼고, 반전(재인코딩)에서 `-b:v 0k`가 되어 **디코드 불가능한
/// 파일을 만들었다**. 컨테이너는 멀쩡해서 ffprobe로는 정상으로 보이고 재생만 안 됐다.
/// 필요한 곳에서 직접 계산하면 그런 실수가 성립하지 않는다.
pub fn reencode_kbps(w: u32, h: u32) -> u32 {
let bits = (w.max(2) as f64) * (h.max(2) as f64) * 30.0 * 0.10;
((bits / 1000.0).round() as i64).clamp(500, 80_000) as u32
}
pub fn rotate_video(
tools: &FfTools,
path: &Path,
op: EditOp,
encoder: &str,
) -> Result<EditResult, EditError> {
let tmp = temp_path(path);
let _ = std::fs::remove_file(&tmp);
let mut cmd = ffmpeg::command(&tools.ffmpeg);
cmd.args(["-v", "error", "-nostdin", "-y"]);
let result = match apply_rotation(video_rotation(tools, path), op) {
Some(deg) => {
// -display_rotation은 **절대값**이다(누적이 아니다 — 실측으로 확인).
// 그래서 현재 각도에 더한 값을 통째로 준다.
//
// 부호는 뒤집는다. 컨테이너의 회전 행렬은 반시계 기준이라 그대로 넣으면
// 오른쪽 회전을 눌렀는데 왼쪽으로 돈다 — 화소를 비교하는 테스트가 이걸 잡았다.
// 이 모듈은 안팎 모두 **사용자가 보는 시계 방향 각도**로 통일한다.
cmd.args(["-display_rotation", &(-deg).to_string()]);
cmd.arg("-i").arg(path).args(["-c", "copy"]);
EditResult { orientation: None, rotation: Some(deg), swapped: swaps_dimensions(op), reencoded: false, new_size: None }
}
None => {
let vf = match op {
EditOp::FlipH => "hflip",
_ => "vflip",
};
// 원본 크기에서 비트레이트를 뽑는다. 못 읽으면 640×480으로 보수적으로 잡는다 —
// 0을 넘기면 인코더가 깨진 스트림을 뱉는다(실측으로 파일 하나를 잃었다).
let info = ffmpeg::probe(tools, path).unwrap_or_default();
let kbps = reencode_kbps(info.width.unwrap_or(640), info.height.unwrap_or(480));
cmd.arg("-i").arg(path).args(["-vf", vf]);
cmd.args(["-c:v", encoder, "-pix_fmt", "yuv420p", "-b:v", &format!("{kbps}k")]);
cmd.args(["-map", "0:v:0", "-map", "0:a?", "-c:a", "copy"]);
EditResult { orientation: None, rotation: None, swapped: false, reencoded: true, new_size: None }
}
};
if ext_of(path) == "mp4" || ext_of(path) == "mov" || ext_of(path) == "m4v" {
cmd.args(["-movflags", "+faststart"]);
}
let out = cmd.arg(&tmp).output()?;
if !out.status.success() {
let _ = std::fs::remove_file(&tmp);
return Err(EditError::Failed(String::from_utf8_lossy(&out.stderr).trim().to_string()));
}
std::fs::rename(&tmp, path)?;
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
const ALL: [EditOp; 5] = [
EditOp::RotateCw,
EditOp::RotateCcw,
EditOp::Rotate180,
EditOp::FlipH,
EditOp::FlipV,
];
/// 시계 90°를 네 번 하면 제자리 — 여덟 방향값 전부에 대해.
/// 전이표를 손으로 적었으므로 이 성질로 검산한다.
#[test]
fn four_quarter_turns_return_to_start() {
for o in 1u8..=8 {
let mut v = o;
for _ in 0..4 {
v = apply_orientation(v, EditOp::RotateCw);
}
assert_eq!(v, o, "방향 {o}에서 90°×4가 제자리가 아니다");
}
}
/// 시계와 반시계는 서로를 되돌린다.
#[test]
fn cw_and_ccw_are_inverse() {
for o in 1u8..=8 {
assert_eq!(apply_orientation(apply_orientation(o, EditOp::RotateCw), EditOp::RotateCcw), o);
assert_eq!(apply_orientation(apply_orientation(o, EditOp::RotateCcw), EditOp::RotateCw), o);
}
}
/// 반전과 180°는 자기 자신이 역연산이다.
#[test]
fn flips_and_half_turn_are_self_inverse() {
for op in [EditOp::FlipH, EditOp::FlipV, EditOp::Rotate180] {
for o in 1u8..=8 {
assert_eq!(apply_orientation(apply_orientation(o, op), op), o, "{op:?} 두 번이 제자리가 아니다");
}
}
}
/// 180° = 시계 90° 두 번.
#[test]
fn half_turn_equals_two_quarter_turns() {
for o in 1u8..=8 {
let two = apply_orientation(apply_orientation(o, EditOp::RotateCw), EditOp::RotateCw);
assert_eq!(apply_orientation(o, EditOp::Rotate180), two);
}
}
/// 상하반전 = 좌우반전 + 180°.
#[test]
fn vflip_equals_hflip_plus_half_turn() {
for o in 1u8..=8 {
let composed = apply_orientation(apply_orientation(o, EditOp::FlipH), EditOp::Rotate180);
assert_eq!(apply_orientation(o, EditOp::FlipV), composed);
}
}
/// 어떤 연산도 1~8 밖으로 나가지 않는다.
#[test]
fn orientation_stays_in_range() {
for o in 0u8..=10 {
for op in ALL {
let v = apply_orientation(o, op);
assert!((1..=8).contains(&v), "방향 {o} + {op:?} → {v}");
}
}
}
/// EXIF가 있는 JPEG은 **그 2바이트만** 바뀐다 — 화상 데이터는 그대로다.
#[test]
fn patching_exif_changes_only_the_tag() {
let orig = jpeg_with_exif(1);
let mut patched = orig.clone();
assert!(patch_jpeg_orientation(&mut patched, 6));
assert_eq!(orig.len(), patched.len(), "길이가 바뀌면 안 된다");
let diff: Vec<usize> = orig.iter().zip(&patched).enumerate()
.filter(|(_, (a, b))| a != b).map(|(i, _)| i).collect();
assert_eq!(diff.len(), 1, "바뀐 바이트가 {}개다: {diff:?}", diff.len());
// 다시 읽어보면 6이어야 한다
assert_eq!(read_orientation(&patched), Some(6));
}
/// EXIF가 없는 JPEG에는 최소 EXIF를 끼워 넣는다 — 화상 데이터는 뒤로 밀릴 뿐 그대로다.
#[test]
fn jpeg_without_exif_gets_one_inserted() {
let orig = jpeg_no_exif();
let out = jpeg_with_orientation(orig.clone(), 8).expect("삽입 실패");
assert!(out.len() > orig.len(), "세그먼트가 안 들어갔다");
assert_eq!(read_orientation(&out), Some(8));
// 원본 바이트가 순서 그대로 뒤에 남아 있어야 한다
assert_eq!(&out[out.len() - (orig.len() - 2)..], &orig[2..]);
}
/// JPEG이 아니면 건드리지 않는다.
#[test]
fn non_jpeg_is_refused() {
let mut png = vec![0x89, b'P', b'N', b'G', 13, 10, 26, 10, 0, 0, 0, 0];
assert!(!patch_jpeg_orientation(&mut png, 6));
assert!(jpeg_with_orientation(png, 6).is_none());
}
/// 잘린 파일에도 죽지 않는다 — 어떤 길이로 잘라도 패닉이 없어야 한다.
#[test]
fn truncated_input_does_not_panic() {
let full = jpeg_with_exif(3);
for n in 0..full.len() {
let mut cut = full[..n].to_vec();
let _ = patch_jpeg_orientation(&mut cut, 6);
}
}
/// 영상 회전은 0/90/180/270 안에 머문다.
#[test]
fn video_rotation_wraps() {
assert_eq!(apply_rotation(270, EditOp::RotateCw), Some(0));
assert_eq!(apply_rotation(0, EditOp::RotateCcw), Some(270));
assert_eq!(apply_rotation(90, EditOp::Rotate180), Some(270));
// 반전은 회전으로 표현할 수 없다
assert_eq!(apply_rotation(0, EditOp::FlipH), None);
}
/// 임시 파일은 **확장자를 유지하고**(ffmpeg이 형식을 정할 수 있게)
/// **스캐너가 걸러낼 수 있어야** 한다. 둘 중 하나만 지키면 각각 다른 방식으로 깨진다.
#[test]
fn temp_path_keeps_extension_and_is_skipped() {
for p in [r"D:\a\clip.mp4", r"D:\a\photo.JPG", "/x/y/movie.mkv"] {
let t = temp_path(Path::new(p));
assert_eq!(
t.extension().map(|e| e.to_string_lossy().to_lowercase()),
Path::new(p).extension().map(|e| e.to_string_lossy().to_lowercase()),
"{p} → {t:?} 확장자가 바뀌었다"
);
assert!(crate::model::is_edit_temp(&t), "{t:?} 를 스캐너가 못 거른다");
assert_eq!(t.parent(), Path::new(p).parent(), "같은 폴더여야 rename이 원자적이다");
}
// 확장자가 없는 파일도 죽지 않는다
let t = temp_path(Path::new(r"D:\a\noext"));
assert!(crate::model::is_edit_temp(&t), "{t:?}");
}
// ── 테스트용 최소 JPEG ──
fn read_orientation(buf: &[u8]) -> Option<u8> {
let t = find_exif_tiff(buf)?;
let off = find_orientation_offset(&buf[t..])?;
let at = t + off;
Some(if buf[t] == b'I' { buf[at] } else { buf[at + 1] })
}
/// SOI + APP1(Exif, 방향 태그) + 가짜 화상 데이터 + EOI
fn jpeg_with_exif(orientation: u8) -> Vec<u8> {
let mut v = vec![0xFF, 0xD8];
v.extend_from_slice(&minimal_exif_app1(orientation));
v.extend_from_slice(&[0xFF, 0xDA, 0x00, 0x02]); // SOS
v.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
v.extend_from_slice(&[0xFF, 0xD9]);
v
}
fn jpeg_no_exif() -> Vec<u8> {
let mut v = vec![0xFF, 0xD8];
// APP0 (JFIF) — Exif가 아닌 세그먼트도 건너뛰는지 확인하는 역할도 한다
v.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00]);
v.extend_from_slice(&[0xFF, 0xDA, 0x00, 0x02]);
v.extend_from_slice(&[9, 9, 9, 9]);
v.extend_from_slice(&[0xFF, 0xD9]);
v
}
}
// ── 자르기 · 구간 자르기 ─────────────────────────────────
/// 잘라낼 영역 — **화면에 보이는 좌표** 기준이다.
///
/// 사용자는 화면에서 끈다. 그런데 EXIF 방향이나 영상 회전 메타가 붙어 있으면
/// 저장된 화소 배치와 화면 배치가 다르다. 두 좌표계를 섞으면 엉뚱한 데가 잘리므로
/// **이 구조체는 언제나 화면 기준**이고, 저장 좌표로 옮기는 일은 각 함수가 맡는다.
///
/// ffmpeg의 `crop` 필터도 화면 좌표를 쓴다(실측으로 확인 — 회전 메타가 붙은
/// 640×480 영상에서 400×600 자르기가 성공하고 600×400은 실패했다).
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CropRect {
pub x: u32,
pub y: u32,
pub w: u32,
pub h: u32,
}
impl CropRect {
/// 이미지 밖으로 나가지 않게 다듬는다. 폭·높이는 짝수로 — 홀수는 yuv420p가 거부한다.
pub fn clamp_to(self, iw: u32, ih: u32) -> Option<CropRect> {
let x = self.x.min(iw.saturating_sub(2));
let y = self.y.min(ih.saturating_sub(2));
let w = self.w.min(iw - x) & !1;
let h = self.h.min(ih - y) & !1;
(w >= 2 && h >= 2).then_some(CropRect { x, y, w, h })
}
/// 원본 전체와 같은가 — 그러면 자를 이유가 없다(재인코딩만 하고 화질을 잃는다).
pub fn is_whole(&self, iw: u32, ih: u32) -> bool {
self.x == 0 && self.y == 0 && self.w >= iw && self.h >= ih
}
}
/// EXIF 방향을 화소에 실제로 적용한다 — 화면에 보이던 그대로의 이미지를 만든다.
///
/// 자르기는 어차피 다시 인코딩하므로 여기서 방향을 구워 넣고 태그를 1로 되돌린다.
/// 그래야 잘린 좌표와 방향 태그가 서로 어긋나는 상태가 생기지 않는다.
fn bake_orientation(img: image::DynamicImage, o: u8) -> image::DynamicImage {
match o {
2 => img.fliph(),
3 => img.rotate180(),
4 => img.flipv(),
5 => img.rotate90().fliph(),
6 => img.rotate90(),
7 => img.rotate90().flipv(),
8 => img.rotate270(),
_ => img,
}
}
/// 사진을 자른다. **다시 인코딩한다** — JPEG 무손실 자르기는 8·16화소 격자에만
/// 맞출 수 있어 사용자가 끈 자리와 어긋난다. 어긋난 무손실보다 정확한 재인코딩이 낫다.
pub fn crop_image(
path: &Path,
rect: CropRect,
orientation: u8,
backup: Option<&Path>,
) -> Result<EditResult, EditError> {
let img = image::open(path).map_err(|e| EditError::Failed(format!("이미지를 열 수 없습니다: {e}")))?;
let img = bake_orientation(img, orientation);
let (iw, ih) = (img.width(), img.height());
let r = rect.clamp_to(iw, ih).ok_or_else(|| EditError::Failed("자를 영역이 너무 작습니다".into()))?;
if r.is_whole(iw, ih) && orientation == 1 {
return Err(EditError::Failed("전체를 고른 상태라 자를 것이 없습니다".into()));
}
let out = img.crop_imm(r.x, r.y, r.w, r.h);
let tmp = temp_path(path);
let ext = ext_of(path);
if JPEG_EXT.contains(&ext.as_str()) {
// 품질 92 — 눈에 보이는 손실 없이 파일이 지나치게 커지지 않는 지점
let mut f = std::io::BufWriter::new(std::fs::File::create(&tmp)?);
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut f, 92);
out.write_with_encoder(enc).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
EditError::Failed(format!("저장 실패: {e}"))
})?;
} else {
out.save(&tmp).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
EditError::Failed(format!("저장 실패: {e}"))
})?;
}
place(&tmp, path, backup)?;
Ok(EditResult {
// 방향을 화소에 구워 넣었으므로 태그는 정상으로 되돌린다
orientation: Some(1),
rotation: None,
swapped: false,
reencoded: true,
new_size: Some((r.w, r.h)),
})
}
/// 영상의 일부 영역만 남긴다. 회전 메타는 화소에 구워 넣고 0으로 만든다 —
/// 안 그러면 재생기가 이미 돌아간 화면을 한 번 더 돌린다.
pub fn crop_video(
tools: &FfTools,
path: &Path,
rect: CropRect,
encoder: &str,
kbps: u32,
backup: Option<&Path>,
) -> Result<EditResult, EditError> {
let tmp = temp_path(path);
let _ = std::fs::remove_file(&tmp);
let CropRect { x, y, w, h } = rect;
let mut cmd = ffmpeg::command(&tools.ffmpeg);
cmd.args(["-v", "error", "-nostdin", "-y"]).arg("-i").arg(path);
// crop은 자동 회전이 적용된 뒤의 화면에 걸린다(실측). 그래서 화면 좌표를 그대로 준다.
cmd.args(["-vf", &format!("crop={w}:{h}:{x}:{y}")]);
cmd.args(["-c:v", encoder, "-pix_fmt", "yuv420p", "-b:v", &format!("{kbps}k")]);
cmd.args(["-map", "0:v:0", "-map", "0:a?", "-c:a", "copy"]);
if matches!(ext_of(path).as_str(), "mp4" | "mov" | "m4v") {
cmd.args(["-movflags", "+faststart"]);
}
let out = cmd.arg(&tmp).output()?;
if !out.status.success() {
let _ = std::fs::remove_file(&tmp);
return Err(EditError::Failed(String::from_utf8_lossy(&out.stderr).trim().to_string()));
}
place(&tmp, path, backup)?;
Ok(EditResult {
orientation: None,
rotation: Some(0),
swapped: false,
reencoded: true,
new_size: Some((w, h)),
})
}
/// 영상에서 한 구간만 남긴다. **스트림 복사라 무손실이고 빠르다.**
///
/// 자를 지점은 키프레임에 맞춰지므로 몇십 밀리초 어긋날 수 있다. mp4는 편집 목록(edit list)
/// 덕에 대체로 정확하지만(키프레임 10초 간격에서도 요청한 2.000초가 나왔다), 컨테이너에
/// 따라서는 앞쪽이 조금 더 붙을 수 있다.
pub fn trim_video(
tools: &FfTools,
path: &Path,
start_ms: i64,
end_ms: i64,
backup: Option<&Path>,
) -> Result<EditResult, EditError> {
if end_ms <= start_ms {
return Err(EditError::Failed("끝이 시작보다 뒤여야 합니다".into()));
}
let tmp = temp_path(path);
let _ = std::fs::remove_file(&tmp);
let mut cmd = ffmpeg::command(&tools.ffmpeg);
cmd.args(["-v", "error", "-nostdin", "-y"]);
cmd.args(["-ss", &format!("{:.3}", start_ms.max(0) as f64 / 1000.0)]);
cmd.args(["-to", &format!("{:.3}", end_ms as f64 / 1000.0)]);
cmd.arg("-i").arg(path);
cmd.args(["-map", "0", "-c", "copy"]);
if matches!(ext_of(path).as_str(), "mp4" | "mov" | "m4v") {
cmd.args(["-movflags", "+faststart"]);
}
let out = cmd.arg(&tmp).output()?;
if !out.status.success() {
let _ = std::fs::remove_file(&tmp);
return Err(EditError::Failed(String::from_utf8_lossy(&out.stderr).trim().to_string()));
}
place(&tmp, path, backup)?;
Ok(EditResult {
orientation: None,
rotation: None,
swapped: false,
reencoded: false,
new_size: None,
})
}
// ── 필름스트립 (구간 자르기 타임라인) ────────────────────
/// 타임라인에 깔 프레임 한 장.
pub struct StripFrame {
pub at_ms: i64,
/// JPEG 바이트. 호출부가 데이터 URL로 만들어 프런트에 넘긴다 —
/// 파일로 떨구면 지울 책임이 생기고, 서버 라우트를 열면 그 수명을 또 관리해야 한다.
pub jpeg: Vec<u8>,
}
/// 영상 전체를 고르게 훑어 `count`장을 뽑는다.
///
/// **`-ss`를 `-i` 앞에 둔다.** 뒤에 두면 그 지점까지 전부 디코드하므로 30분짜리에서
/// 한 장에 수십 초가 걸린다. 앞에 두면 키프레임까지 건너뛰어 한 장에 수십 밀리초다.
/// 그 대신 위치가 키프레임 단위로 어긋나는데, 타임라인 배경으로는 문제가 되지 않는다.
pub fn filmstrip(
tools: &FfTools,
src: &Path,
duration_ms: i64,
count: u32,
height: u32,
) -> Vec<StripFrame> {
let n = count.clamp(1, 40);
let dur = duration_ms.max(1) as f64 / 1000.0;
let mut out = Vec::with_capacity(n as usize);
for i in 0..n {
// 칸의 **가운데** 시각을 쓴다. 0초부터 균등하게 나누면 마지막 장이 영상 끝을 넘어
// 빈 프레임이 나온다.
let at = dur * (i as f64 + 0.5) / n as f64;
let o = ffmpeg::command(&tools.ffmpeg)
.args(["-v", "error", "-nostdin", "-ss", &format!("{at:.3}")])
.arg("-i")
.arg(src)
.args(["-frames:v", "1", "-vf", &format!("scale=-2:{height}")])
.args(["-q:v", "6", "-f", "image2pipe", "-vcodec", "mjpeg", "-"])
.output();
match o {
Ok(o) if o.status.success() && !o.stdout.is_empty() => {
out.push(StripFrame { at_ms: (at * 1000.0) as i64, jpeg: o.stdout });
}
// 한 장 실패는 치명적이지 않다 — 그 칸만 비고 나머지는 그대로 쓴다
_ => {}
}
}
out
}
@@ -230,6 +230,260 @@ fn extract_frame_input(
.map_err(|e| FfError::Parse(format!("썸네일 치수 읽기 실패: {e}")))
}
// ── 지문용 프레임 시퀀스 (단일 프로세스) ─────────────────
//
// 예전 방식은 오프셋마다 ffmpeg를 새로 띄워 64px JPEG를 임시 파일로 쓰고 다시 디코드했다
// (영상 1개당 프로세스 11개 + 디스크 왕복 20회, 게다가 고정 임시 파일명 때문에 워커 간 경쟁 조건).
// 여기서는 **한 프로세스**가 균등 간격 그레이 프레임을 stdout으로 흘려보내고, 파생 지문은
// `sigmatch`가 메모리에서 계산한다. 디스크를 쓰지 않으므로 경쟁 조건 자체가 사라진다.
/// 프레임 추출 결과 한 장 — (표시 시각 ms, size×size 그레이 픽셀)
pub type GrayFrame = (u32, Vec<u8>);
/// 균등 간격으로 그레이 프레임을 뽑는다. 시간(ms) 기준 샘플링이므로 원본 FPS와 무관하다.
///
/// - `sample_ms`: 샘플 간격 (1000이면 1초에 한 장). 필터는 `fps=1000/sample_ms`로 만든다.
/// - `size`: 정사각 출력 한 변 (지문은 32를 쓴다). 비율은 무시하고 늘려 맞춘다 —
/// 같은 규칙을 모든 파일에 적용하므로 비교에는 문제가 없고, 레터박스는 `sigmatch`가 따로 다룬다.
/// - `timeout`: 이 시간을 넘기면 자식 프로세스를 죽인다. 손상된 파일·죽은 네트워크 경로에서
/// 워커가 영구 점유되는 것을 막는다(기존 `.output()` 경로에는 이 보호가 없다).
/// - `hwaccel`: `Some("auto")` 등. 실패하면 소프트웨어 디코드로 한 번 더 시도한다.
pub fn frame_gray_sequence(
tools: &FfTools,
src: &Path,
sample_ms: u32,
size: u32,
timeout: std::time::Duration,
hwaccel: Option<&str>,
) -> Result<Vec<GrayFrame>, FfError> {
frame_gray_sequence_with_audio(tools, src, sample_ms, size, timeout, hwaccel, None)
.map(|(f, _)| f)
}
/// 프레임과 **오디오 지문을 한 번의 디코드로** 함께 뽑는다.
///
/// `audio_out`이 `Some`이면 chromaprint를 두 번째 출력으로 붙인다 — 파일을 한 번만 읽으므로
/// NAS·외장 디스크에서 두 배의 I/O를 쓰지 않는다. 대신 **오디오 스트림이 없으면 ffmpeg가
/// 출력 초기화에서 실패해 프레임까지 통째로 날아간다**(실측: `Output file does not contain
/// any stream`, 프레임 0장). 그래서 오디오가 있다고 아는 파일에만 켜야 한다.
/// 그래도 혹시 실패하면 오디오 없이 한 번 더 시도한다.
pub fn frame_gray_sequence_with_audio(
tools: &FfTools,
src: &Path,
sample_ms: u32,
size: u32,
timeout: std::time::Duration,
hwaccel: Option<&str>,
audio_out: Option<&Path>,
) -> Result<(Vec<GrayFrame>, Vec<u32>), FfError> {
let sample_ms = sample_ms.max(1);
let read_fp = |p: Option<&Path>| -> Vec<u32> {
let Some(p) = p else { return Vec::new() };
let Ok(b) = std::fs::read(p) else { return Vec::new() };
let _ = std::fs::remove_file(p);
b.chunks_exact(4).map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()
};
match run_gray_pass(tools, src, sample_ms, size, timeout, hwaccel, audio_out) {
Ok(frames) if !frames.is_empty() => Ok((frames, read_fp(audio_out))),
// 하드웨어 디코더는 드라이버·코덱 조합에 따라 조용히 0프레임을 내기도 한다 → 소프트웨어로 재시도.
// 오디오 출력이 붙어 있었다면 그것도 실패 원인일 수 있으므로 함께 뗀다.
other => {
let _ = read_fp(audio_out); // 실패한 부분 파일 정리
if hwaccel.is_some() || audio_out.is_some() {
run_gray_pass(tools, src, sample_ms, size, timeout, None, None)
.map(|f| (f, Vec::new()))
} else {
other.map(|f| (f, Vec::new()))
}
}
}
}
fn run_gray_pass(
tools: &FfTools,
src: &Path,
sample_ms: u32,
size: u32,
timeout: std::time::Duration,
hwaccel: Option<&str>,
audio_out: Option<&Path>,
) -> Result<Vec<GrayFrame>, FfError> {
use std::io::Read;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
let vf = format!("fps=1000/{sample_ms},scale={size}:{size},format=gray");
let mut cmd = command(&tools.ffmpeg);
cmd.args(["-v", "error", "-nostdin"]);
if let Some(hw) = hwaccel {
cmd.args(["-hwaccel", hw]);
}
cmd.arg("-i")
.arg(src)
// -vsync은 이 빌드에서 deprecated — -fps_mode를 쓴다
.args(["-an", "-sn", "-dn", "-fps_mode", "passthrough", "-vf", &vf, "-f", "rawvideo", "-"]);
if let Some(a) = audio_out {
// 두 번째 출력 — 같은 디코드에서 오디오 지문까지. 프레임은 파이프(대용량),
// 지문은 작은 임시 파일(수 KB)로 받는다. stdout은 하나뿐이라 둘 다 파이프로 못 받는다.
cmd.args(["-vn", "-sn", "-dn", "-f", "chromaprint", "-fp_format", "raw", "-y"]).arg(a);
}
cmd.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null());
let child = Arc::new(Mutex::new(cmd.spawn()?));
let mut stdout = child
.lock()
.expect("ffmpeg child mutex")
.stdout
.take()
.ok_or_else(|| FfError::Parse("stdout 파이프를 열지 못했습니다".into()))?;
// 워치독 — 읽기가 끝나면 신호를 보내고, 제한 시간을 넘기면 자식을 죽인다.
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let watchdog = {
let child = Arc::clone(&child);
std::thread::spawn(move || {
if done_rx.recv_timeout(timeout).is_err() {
if let Ok(mut c) = child.lock() {
let _ = c.kill();
}
return true; // 타임아웃으로 죽였다
}
false
})
};
let mut buf = Vec::new();
let read_err = stdout.read_to_end(&mut buf).err();
let _ = done_tx.send(());
let killed = watchdog.join().unwrap_or(false);
let (status, stderr) = {
let mut guard = child.lock().expect("ffmpeg child mutex");
let stderr = guard.stderr.take();
let status = guard.wait()?;
let mut msg = String::new();
if let Some(mut e) = stderr {
let _ = e.read_to_string(&mut msg);
}
(status, msg)
};
if killed {
return Err(FfError::Exit(-1, format!("제한 시간({}초) 초과로 중단", timeout.as_secs())));
}
if let Some(e) = read_err {
return Err(FfError::Io(e));
}
if !status.success() && buf.is_empty() {
return Err(FfError::Exit(status.code().unwrap_or(-1), stderr));
}
let stride = (size as usize) * (size as usize);
if stride == 0 {
return Err(FfError::Parse("size가 0입니다".into()));
}
let count = buf.len() / stride;
let mut out = Vec::with_capacity(count);
for i in 0..count {
let start = i * stride;
out.push((
(i as u32).saturating_mul(sample_ms),
buf[start..start + stride].to_vec(),
));
}
Ok(out)
}
/// 오디오 지문(chromaprint raw) — u32 시퀀스. 번들 ffmpeg가 `--enable-chromaprint`로
/// 빌드돼 있어 별도 라이브러리 없이 얻을 수 있다. 대략 8개/초가 나온다.
/// 오디오가 없거나 무음이면 빈 벡터.
pub fn audio_fingerprint(
tools: &FfTools,
src: &Path,
timeout: std::time::Duration,
) -> Result<Vec<u32>, FfError> {
use std::io::Read;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
let mut cmd = command(&tools.ffmpeg);
cmd.args(["-v", "error", "-nostdin"])
.arg("-i")
.arg(src)
.args(["-vn", "-sn", "-dn", "-f", "chromaprint", "-fp_format", "raw", "-"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stdin(Stdio::null());
let child = Arc::new(Mutex::new(cmd.spawn()?));
let mut stdout = child
.lock()
.expect("ffmpeg child mutex")
.stdout
.take()
.ok_or_else(|| FfError::Parse("stdout 파이프를 열지 못했습니다".into()))?;
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let watchdog = {
let child = Arc::clone(&child);
std::thread::spawn(move || {
if done_rx.recv_timeout(timeout).is_err() {
if let Ok(mut c) = child.lock() {
let _ = c.kill();
}
return true;
}
false
})
};
let mut buf = Vec::new();
let read_err = stdout.read_to_end(&mut buf).err();
let _ = done_tx.send(());
let killed = watchdog.join().unwrap_or(false);
let status = {
let mut guard = child.lock().expect("ffmpeg child mutex");
guard.wait()?
};
if killed {
return Err(FfError::Exit(-1, "오디오 지문 제한 시간 초과".into()));
}
if let Some(e) = read_err {
return Err(FfError::Io(e));
}
if !status.success() && buf.is_empty() {
// 오디오 트랙이 없는 영상은 흔하다 — 오류가 아니라 빈 지문으로 다룬다
return Ok(Vec::new());
}
let n = buf.len() / 4;
let mut out = Vec::with_capacity(n);
for i in 0..n {
out.push(u32::from_le_bytes([buf[i * 4], buf[i * 4 + 1], buf[i * 4 + 2], buf[i * 4 + 3]]));
}
Ok(out)
}
/// 하드웨어 디코드를 쓸 수 있는지 기동 시 1회 판별한다(합성 입력으로 실제 디코드).
/// 실패하면 `None` — 이후 소프트웨어 디코드만 쓴다.
pub fn detect_hwaccel(tools: &FfTools) -> Option<&'static str> {
// `auto`만 본다 — ffmpeg가 플랫폼별 백엔드(d3d11va/dxva2/videotoolbox…)를 스스로 고른다.
// 백엔드를 우리가 나열하면 빌드·드라이버 조합마다 목록을 관리해야 하는데 이득이 없다.
const HW: &str = "auto";
let out = command(&tools.ffmpeg)
.args([
"-v", "error", "-nostdin", "-hwaccel", HW, "-f", "lavfi", "-i",
"testsrc=d=1:r=10", "-an", "-sn", "-frames:v", "1", "-vf",
"scale=32:32,format=gray", "-f", "rawvideo", "-",
])
.output()
.ok()?;
// 성공 판정은 종료 코드만으로는 부족하다 — 프레임 1장(32×32 gray)이 실제로 나와야 한다
(out.status.success() && out.stdout.len() == 32 * 32).then_some(HW)
}
#[cfg(test)]
mod tests {
use super::*;
+646 -26
View File
@@ -1,7 +1,9 @@
//! 파일 작업 계층 — 이동/이름변경/휴지통 + ops_journal 기반 undo.
//!
//! 원칙: ① 물리적 파일 변경 성공 후에만 DB 갱신 ② 모든 작업은 저널 기록
//! ③ 삭제는 OS 휴지통 경유만 (영구삭제 없음 — 계획서 §7).
//! ③ 삭제는 2단계 — 평소엔 '삭제 예정' 태그로 표시(되돌리기 가능)하고,
//! 사용자가 명시적으로 '비우기'를 실행할 때만 [`delete_permanent`]로 디스크에서 지운다.
//! [`trash_files`](OS 휴지통 경유)는 Shift+Delete 즉시 삭제 경로로 남아 있다.
use archive_db::Db;
use std::path::{Path, PathBuf};
@@ -52,14 +54,17 @@ struct FileRow {
id: i64,
dir: String,
name: String,
/// 소스 루트 — OS 휴지통이 없는 경로에서 앱 휴지통 위치를 정하는 데 쓴다
root: String,
}
fn load_rows(db: &Db, file_ids: &[i64]) -> Result<Vec<FileRow>> {
let ids = file_ids.to_vec();
Ok(db.with_read(move |conn| {
let mut stmt = conn.prepare_cached(
"SELECT f.id, fo.path, f.name FROM files f
"SELECT f.id, fo.path, f.name, s.root FROM files f
JOIN folders fo ON fo.id = f.folder_id
JOIN sources s ON s.id = f.source_id
WHERE f.id = ?1 AND f.deleted_at IS NULL",
)?;
let mut out = Vec::with_capacity(ids.len());
@@ -69,6 +74,7 @@ fn load_rows(db: &Db, file_ids: &[i64]) -> Result<Vec<FileRow>> {
id: r.get(0)?,
dir: r.get(1)?,
name: r.get(2)?,
root: r.get(3)?,
})
}) {
out.push(row);
@@ -219,7 +225,55 @@ pub fn rename_file(db: &Db, batch_id: &str, file_id: i64, new_name: &str) -> Res
Ok(())
}
/// 물리 삭제 성공 후 DB에 소프트삭제 + 저널 기록.
fn record_removal(
db: &Db,
batch_id: &str,
file_id: i64,
src: &Path,
dst: Option<&Path>,
op: &'static str,
) -> Result<()> {
let (bid, src_s) = (batch_id.to_string(), src.to_string_lossy().into_owned());
let dst_s = dst.map(|p| p.to_string_lossy().into_owned());
db.with_write(move |conn| {
let tx = conn.transaction()?;
tx.execute(
"UPDATE files SET deleted_at = unixepoch('now','subsec') * 1000 WHERE id = ?1",
[file_id],
)?;
tx.execute(
"INSERT INTO ops_journal(batch_id, op, file_id, src_path, dst_path)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![bid, op, file_id, src_s, dst_s],
)?;
tx.commit()
})?;
Ok(())
}
/// 앱 휴지통 폴더 이름 — 소스 루트 바로 아래. 점(.)으로 시작해 스캐너가 건너뛴다.
pub const APP_TRASH_DIR: &str = ".archive_trash";
/// OS 휴지통을 쓸 수 없는 경로(UNC/네트워크 공유 등)용 폴백.
/// 네트워크 공유에는 휴지통이 없어 `trash::delete`가 실패하므로, 소스 루트의
/// `.archive_trash/`로 옮겨 복구 가능한 상태를 유지한다 (원격 소스와 같은 방식).
fn app_trash_move(root: &str, file_id: i64, src: &Path) -> std::io::Result<PathBuf> {
let dir = Path::new(root).join(APP_TRASH_DIR);
std::fs::create_dir_all(&dir)?;
let name = src
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| format!("{file_id}"));
// 충돌 방지로 file_id 접두 (원격 경로와 동일 규칙)
let dst = dir.join(format!("{file_id}_{name}"));
physical_move(src, &dst)?;
Ok(dst)
}
/// 파일들을 OS 휴지통으로 보낸다 (인앱 undo 불가 — OS 휴지통이 복구 경로).
/// 네트워크 공유(UNC 등)에는 OS 휴지통이 없어 실패하므로, 그 경우
/// 소스 루트의 `.archive_trash/`로 옮기는 앱 휴지통으로 폴백한다.
pub fn trash_files(
db: &Db,
batch_id: &str,
@@ -237,25 +291,59 @@ pub fn trash_files(
let src = Path::new(&row.dir).join(&row.name);
match trash::delete(&src) {
Ok(()) => {
let (bid, fid) = (batch_id.to_string(), row.id);
let src_s = src.to_string_lossy().into_owned();
db.with_write(move |conn| {
let tx = conn.transaction()?;
tx.execute(
"UPDATE files SET deleted_at = unixepoch('now','subsec') * 1000 WHERE id = ?1",
[fid],
)?;
tx.execute(
"INSERT INTO ops_journal(batch_id, op, file_id, src_path)
VALUES (?1, 'trash', ?2, ?3)",
rusqlite::params![bid, fid, src_s],
)?;
tx.commit()
})?;
record_removal(db, batch_id, row.id, &src, None, "trash")?;
summary.done += 1;
}
Err(os_err) => match app_trash_move(&row.root, row.id, &src) {
Ok(dst) => {
tracing::info!(
src = %src.display(),
"OS 휴지통 불가({os_err}) — 앱 휴지통으로 이동: {}", dst.display()
);
record_removal(db, batch_id, row.id, &src, Some(&dst), "trash")?;
summary.done += 1;
}
Err(e) => {
tracing::warn!(src = %src.display(), "휴지통 이동 실패: {os_err} / 폴백도 실패: {e}");
summary.failed += 1;
}
},
}
on_progress(i as u64 + 1, total);
}
Ok(summary)
}
/// 파일들을 디스크에서 **영구 삭제**한다 — 복구 불가.
/// '삭제 예정' 태그 비우기 경로 전용이며, 호출 전 사용자 확인은 UI 책임이다.
/// 이미 사라진 파일은 실패가 아닌 skipped로 세고 인덱스만 정리한다.
pub fn delete_permanent(
db: &Db,
batch_id: &str,
file_ids: &[i64],
mut on_progress: impl FnMut(u64, u64),
) -> Result<OpSummary> {
let rows = load_rows(db, file_ids)?;
let total = rows.len() as u64;
let mut summary = OpSummary {
batch_id: batch_id.to_string(),
..Default::default()
};
for (i, row) in rows.iter().enumerate() {
let src = Path::new(&row.dir).join(&row.name);
match std::fs::remove_file(&src) {
Ok(()) => {
record_removal(db, batch_id, row.id, &src, None, "delete")?;
summary.done += 1;
}
// 이미 없는 파일 — 외부에서 지워졌을 뿐이니 인덱스만 정리한다
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
record_removal(db, batch_id, row.id, &src, None, "delete")?;
summary.skipped += 1;
}
Err(e) => {
tracing::warn!(src = %src.display(), "휴지통 이동 실패: {e}");
tracing::warn!(src = %src.display(), "영구 삭제 실패: {e}");
summary.failed += 1;
}
}
@@ -279,7 +367,7 @@ pub fn list_batches(db: &Db, limit: i64) -> Result<Vec<UndoBatch>> {
Ok(db.with_read(move |conn| {
let mut stmt = conn.prepare_cached(
"SELECT batch_id, count(*), group_concat(DISTINCT op), max(performed_at),
sum(CASE WHEN op = 'trash' THEN 1 ELSE 0 END) = 0
sum(CASE WHEN op IN ('trash','delete') THEN 1 ELSE 0 END) = 0
FROM ops_journal WHERE undone = 0
GROUP BY batch_id ORDER BY max(performed_at) DESC LIMIT ?1",
)?;
@@ -296,8 +384,14 @@ pub fn list_batches(db: &Db, limit: i64) -> Result<Vec<UndoBatch>> {
})?)
}
/// 배치 undo — 저널 역순 재생. trash 항목은 건너뛴다.
pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
/// 배치 undo — 저널 역순 재생. trash/delete 항목은 건너뛴다.
/// 되돌리기. `tools`가 없으면 영상 편집 되돌리기만 실패로 남고 나머지는 정상 처리된다.
pub fn undo_batch(
db: &Db,
batch_id: &str,
tools: Option<&crate::ffmpeg::FfTools>,
encoder: &str,
) -> Result<OpSummary> {
struct JEntry {
id: i64,
op: String,
@@ -305,11 +399,13 @@ pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
src_path: Option<String>,
dst_path: Option<String>,
tag_id: Option<i64>,
/// 편집 되돌리기용 역연산 이름 (op = "edit"일 때만 채워진다)
payload: Option<String>,
}
let bid = batch_id.to_string();
let entries: Vec<JEntry> = db.with_read(move |conn| {
let mut stmt = conn.prepare_cached(
"SELECT id, op, file_id, src_path, dst_path, tag_id FROM ops_journal
"SELECT id, op, file_id, src_path, dst_path, tag_id, payload FROM ops_journal
WHERE batch_id = ?1 AND undone = 0 ORDER BY id DESC",
)?;
let rows = stmt.query_map([&bid], |r| {
@@ -320,6 +416,7 @@ pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
src_path: r.get(3)?,
dst_path: r.get(4)?,
tag_id: r.get(5)?,
payload: r.get(6)?,
})
})?;
rows.collect()
@@ -387,6 +484,41 @@ pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
}
}
}
// 편집 되돌리기 = 역연산을 다시 적용. 원본 백업이 없으므로
// 파일이 그새 다른 도구로 바뀌었으면 그 위에 역회전이 걸린다 —
// 회전은 무손실이라 그래도 화질 손해는 없다.
"edit" => {
let (Some(fid), Some(tok)) = (e.file_id, e.payload.as_deref()) else {
summary.failed += 1;
continue;
};
// 자르기는 역연산이 없다 — 옮겨 둔 원본을 제자리로 되돌린다
if tok == "restore" {
let Some(bk) = e.dst_path.as_deref() else {
summary.failed += 1;
continue;
};
match restore_from_backup(db, fid, bk) {
Ok(()) => true,
Err(_) => {
summary.failed += 1;
continue;
}
}
} else {
let Some(inv) = op_from_token(tok) else {
summary.failed += 1;
continue;
};
match undo_edit(db, tools, fid, inv, encoder) {
Ok(()) => true,
Err(_) => {
summary.failed += 1;
continue;
}
}
}
}
"tag" => {
if let (Some(fid), Some(tid)) = (e.file_id, e.tag_id) {
db.with_write(move |conn| {
@@ -413,7 +545,7 @@ pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
false
}
}
// trash는 인앱 undo 불가 (OS 휴지통에서 복원)
// trash는 OS 휴지통에서, delete는 아예 복구 불가 — 둘 다 인앱 undo 대상 아님
_ => {
summary.skipped += 1;
false
@@ -497,7 +629,7 @@ mod tests {
assert_eq!(file_id_of(&db, "사진1.jpg"), fid);
// undo → 원위치
let u = undo_batch(&db, &bid).unwrap();
let u = undo_batch(&db, &bid, None, "libopenh264").unwrap();
assert_eq!(u.done, 1);
assert!(media.path().join("a/사진1.jpg").is_file());
assert!(!media.path().join("b/사진1.jpg").exists());
@@ -528,10 +660,124 @@ mod tests {
assert!(rename_file(&db, &bid, fid, "bad/name.jpg").is_err());
undo_batch(&db, &bid).unwrap();
undo_batch(&db, &bid, None, "libopenh264").unwrap();
assert!(media.path().join("a/사진2.jpg").is_file());
}
/// OS 휴지통이 없는 경로(네트워크 공유)를 UNC 루프백으로 흉내낸다.
/// 폴백으로 .archive_trash에 남아야 하고, 재스캔에 다시 잡히면 안 된다.
#[test]
fn trash_falls_back_to_app_trash_on_unc() {
let media = tempfile::tempdir().unwrap();
let local_root = media.path().to_string_lossy().into_owned();
let unc_root = local_root.replace("C:\\", "\\\\localhost\\C$\\");
if !std::path::Path::new(&unc_root).exists() {
eprintln!("UNC 루프백 불가 — 테스트 건너뜀");
return;
}
let dbdir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
std::fs::write(media.path().join("사진1.jpg"), b"one").unwrap();
let root_for_db = unc_root.clone();
let sid = db
.with_write(move |conn| {
conn.execute(
"INSERT INTO sources(kind,name,root) VALUES('local','unc',?1)",
[&root_for_db],
)?;
Ok(conn.last_insert_rowid())
})
.unwrap();
let cancel = std::sync::atomic::AtomicBool::new(false);
crate::pipeline::scan_source(&db, sid, std::path::Path::new(&unc_root), &cancel, |_| {})
.unwrap();
let fid = file_id_of(&db, "사진1.jpg");
let s = trash_files(&db, &new_batch_id(), &[fid], |_, _| {}).unwrap();
assert_eq!((s.done, s.failed), (1, 0), "UNC에서도 삭제는 성공해야 한다");
// 원본은 사라지고 앱 휴지통에 남는다
assert!(!media.path().join("사진1.jpg").exists());
let trashed = media.path().join(APP_TRASH_DIR).join(format!("{fid}_사진1.jpg"));
assert!(trashed.is_file(), "앱 휴지통에 보존돼야 한다: {}", trashed.display());
// 재스캔해도 .archive_trash는 인덱싱되지 않는다
crate::pipeline::scan_source(&db, sid, std::path::Path::new(&unc_root), &cancel, |_| {})
.unwrap();
let alive: i64 = db
.with_read(|c| {
c.query_row(
"SELECT count(*) FROM files WHERE deleted_at IS NULL",
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(alive, 0, ".archive_trash가 다시 인덱싱되면 안 된다");
}
#[test]
fn delete_permanent_removes_file_and_blocks_undo() {
let (_d, media, db, _sid) = setup();
let fid = file_id_of(&db, "사진1.jpg");
let bid = new_batch_id();
let s = delete_permanent(&db, &bid, &[fid], |_, _| {}).unwrap();
assert_eq!(s.done, 1);
assert!(!media.path().join("a/사진1.jpg").exists());
// 인덱스에서도 사라진다(소프트삭제)
let alive: i64 = db
.with_read(move |c| {
c.query_row(
"SELECT count(*) FROM files WHERE id = ?1 AND deleted_at IS NULL",
[fid],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(alive, 0);
// 영구 삭제 배치는 undo 대상이 아니다
let batch = list_batches(&db, 10)
.unwrap()
.into_iter()
.find(|b| b.batch_id == bid)
.unwrap();
assert!(!batch.undoable);
assert_eq!(undo_batch(&db, &bid, None, "libopenh264").unwrap().done, 0);
}
#[test]
fn delete_permanent_missing_file_counts_skipped() {
let (_d, media, db, _sid) = setup();
let fid = file_id_of(&db, "사진2.jpg");
std::fs::remove_file(media.path().join("a/사진2.jpg")).unwrap();
let s = delete_permanent(&db, &new_batch_id(), &[fid], |_, _| {}).unwrap();
assert_eq!((s.done, s.skipped, s.failed), (0, 1, 0));
}
#[test]
fn trash_tag_marks_and_lists_pending() {
let (_d, _m, db, _sid) = setup();
let fid = file_id_of(&db, "사진1.jpg");
let tag = crate::tags::ensure_trash_tag(&db).unwrap();
// 재호출은 같은 id
assert_eq!(crate::tags::ensure_trash_tag(&db).unwrap(), tag);
crate::tags::assign(&db, &new_batch_id(), vec![fid], tag).unwrap();
assert_eq!(crate::tags::pending_delete_ids(&db).unwrap(), vec![fid]);
let (count, bytes) = crate::tags::pending_delete_stats(&db).unwrap();
assert_eq!(count, 1);
assert_eq!(bytes, 3); // "one"
// 비우기 후에는 대기열이 빈다
delete_permanent(&db, &new_batch_id(), &[fid], |_, _| {}).unwrap();
assert!(crate::tags::pending_delete_ids(&db).unwrap().is_empty());
}
#[test]
fn tag_undo_roundtrip() {
let (_d, _m, db, _sid) = setup();
@@ -542,7 +788,381 @@ mod tests {
crate::tags::assign(&db, &bid, vec![fid], tag).unwrap();
assert_eq!(crate::tags::file_tags(&db, fid).unwrap().len(), 1);
undo_batch(&db, &bid).unwrap();
undo_batch(&db, &bid, None, "libopenh264").unwrap();
assert_eq!(crate::tags::file_tags(&db, fid).unwrap().len(), 0);
}
}
// ── 간단 편집 (회전·반전) ────────────────────────────────
/// 편집 결과 요약 — 어떤 파일이 어떻게 바뀌었는지.
pub struct EditSummary {
pub batch_id: String,
pub done: u64,
pub failed: u64,
/// 실패 사유 — 사용자에게 보여줄 유일한 단서다
pub errors: Vec<String>,
/// 다시 인코딩된 파일 수 (화질 손실이 있었던 것들)
pub reencoded: u64,
}
/// 편집 전 수정시각을 기억했다가 편집 뒤 되돌려 준다.
///
/// **편집은 촬영 시각을 바꾸는 일이 아니다.** 기본 정렬은 `COALESCE(taken_at, mtime_ms)`라
/// EXIF 촬영일이 없는 파일(대부분의 영상)은 mtime으로 줄을 선다. 회전 한 번에 mtime이
/// 지금으로 바뀌면 그 항목이 목록 맨 앞으로 튀어 사용자가 방금 보던 파일을 다시 찾아야 한다.
fn with_preserved_mtime<T>(path: &Path, f: impl FnOnce() -> T) -> T {
let before = std::fs::metadata(path).and_then(|m| m.modified()).ok();
let out = f();
if let Some(t) = before {
let _ = filetime::set_file_mtime(path, filetime::FileTime::from_system_time(t));
}
out
}
/// 되돌릴 때 적용할 역연산.
fn inverse_of(op: crate::edit::EditOp) -> crate::edit::EditOp {
use crate::edit::EditOp::*;
match op {
RotateCw => RotateCcw,
RotateCcw => RotateCw,
// 반전과 180°는 자기 자신이 역연산이다
Rotate180 => Rotate180,
FlipH => FlipH,
FlipV => FlipV,
}
}
fn op_token(op: crate::edit::EditOp) -> &'static str {
use crate::edit::EditOp::*;
match op {
RotateCw => "rotateCw",
RotateCcw => "rotateCcw",
Rotate180 => "rotate180",
FlipH => "flipH",
FlipV => "flipV",
}
}
pub fn op_from_token(s: &str) -> Option<crate::edit::EditOp> {
use crate::edit::EditOp::*;
Some(match s {
"rotateCw" => RotateCw,
"rotateCcw" => RotateCcw,
"rotate180" => Rotate180,
"flipH" => FlipH,
"flipV" => FlipV,
_ => return None,
})
}
/// 고른 파일들을 회전·반전한다. **원본을 직접 고친다.**
///
/// 되돌리기는 원본을 백업해 두는 대신 **역연산을 다시 적용한다** —
/// 회전은 무손실이라 되돌린 결과가 원본과 같고, 큰 영상을 복사해 둘 필요도 없다.
/// 반전만 재인코딩이라 되돌려도 원본과 바이트가 같지는 않다(화질은 한 번 더 손해).
#[allow(clippy::too_many_arguments)]
pub fn edit_files(
db: &Db,
tools: &crate::ffmpeg::FfTools,
batch_id: &str,
file_ids: &[i64],
op: crate::edit::EditOp,
encoder: &str,
mut on_progress: impl FnMut(u64, u64),
) -> Result<EditSummary> {
let rows = load_rows(db, file_ids)?;
let total = rows.len() as u64;
let mut sum = EditSummary {
batch_id: batch_id.to_string(),
done: 0,
failed: 0,
errors: Vec::new(),
reencoded: 0,
};
for (i, row) in rows.iter().enumerate() {
on_progress(i as u64, total);
let path = Path::new(&row.dir).join(&row.name);
let (kind, orientation): (i64, i64) = {
let fid = row.id;
db.with_read(move |conn| {
conn.query_row(
"SELECT kind, COALESCE(orientation, 1) FROM files WHERE id = ?1",
[fid],
|r| Ok((r.get(0)?, r.get(1)?)),
)
})?
};
// 영상 반전만 재인코딩이다 — 역연산으로 되돌려도 원본 바이트가 아니고
// 왕복할 때마다 화질이 깎인다. 그래서 자르기와 같이 원본을 옮겨 보관한다.
// (회전은 메타데이터만 바꾸므로 역연산이 진짜 원본을 돌려준다 — 백업이 낭비다)
let lossy = kind == 1 && matches!(op, crate::edit::EditOp::FlipH | crate::edit::EditOp::FlipV);
let backup = lossy.then(|| {
Path::new(&row.root)
.join(APP_TRASH_DIR)
.join(EDIT_BACKUP_DIR)
.join(format!("{}_{batch_id}_{}", row.id, row.name))
});
if let Some(bk) = &backup {
// 복사로 남긴다. 반전은 어차피 전체를 다시 인코딩하므로 복사가 병목이 아니다
// (자르기는 스트림 복사라 빨라서 '옮기기'를 쓰지만 여기는 사정이 다르다).
let ok = bk.parent().map(|p| std::fs::create_dir_all(p).is_ok()).unwrap_or(false)
&& std::fs::copy(&path, bk).is_ok();
if !ok {
sum.failed += 1;
sum.errors.push(format!("{}: 원본 백업에 실패해 건너뜀", row.name));
continue;
}
}
let result = with_preserved_mtime(&path, || {
if kind == 1 {
crate::edit::rotate_video(tools, &path, op, encoder)
} else {
crate::edit::rotate_image(&path, orientation.clamp(1, 8) as u8, op)
}
});
match result {
Ok(r) => {
sum.done += 1;
if r.reencoded {
sum.reencoded += 1;
}
// 원본을 고치는 작업은 반드시 남긴다. 예전에 편집이 아무 기록도 남기지 않아
// 파일이 언제 왜 바뀌었는지 나중에 되짚을 방법이 없었다.
tracing::info!(
file_id = row.id,
path = %path.display(),
?op,
reencoded = r.reencoded,
"편집 적용"
);
apply_edit_to_db(db, batch_id, row.id, op, &r, backup.as_deref())?;
}
Err(e) => {
sum.failed += 1;
sum.errors.push(format!("{}: {e}", row.name));
}
}
}
on_progress(total, total);
Ok(sum)
}
/// 편집 결과를 DB에 반영하고 저널에 남긴다.
///
/// 썸네일과 지문은 **무효로 표시만** 한다 — 여기서 다시 만들면 회전 한 번에
/// 몇 초씩 멈춘다. 백그라운드 큐가 알아서 다시 만든다.
fn apply_edit_to_db(
db: &Db,
batch_id: &str,
file_id: i64,
op: crate::edit::EditOp,
r: &crate::edit::EditResult,
backup: Option<&Path>,
) -> Result<()> {
// 백업을 남긴 편집(재인코딩되는 반전)은 역연산이 아니라 그 파일을 되돌린다 —
// 역연산으로 되돌리면 화질이 한 번 더 깎인 '비슷한' 파일이 될 뿐이다.
let (bid, token) = match backup {
Some(_) => (batch_id.to_string(), "restore".to_string()),
None => (batch_id.to_string(), op_token(inverse_of(op)).to_string()),
};
let bk = backup.map(|p| p.to_string_lossy().into_owned());
let (orientation, swapped) = (r.orientation.map(|v| v as i64), r.swapped);
db.with_write(move |conn| {
let tx = conn.transaction()?;
if let Some(o) = orientation {
tx.execute("UPDATE files SET orientation = ?2 WHERE id = ?1", rusqlite::params![file_id, o])?;
}
if swapped {
// 가로·세로가 바뀌었다 — 안 고치면 그리드 배치가 어긋난다
tx.execute(
"UPDATE files SET width = height, height = width WHERE id = ?1",
[file_id],
)?;
}
// 썸네일·지문 다시 만들기 (0 = 대기)
tx.execute("UPDATE files SET thumb_state = 0 WHERE id = ?1", [file_id])?;
tx.execute("DELETE FROM thumbs WHERE file_id = ?1", [file_id])?;
tx.execute("UPDATE files SET sig_state = 0 WHERE id = ?1", [file_id]).ok();
tx.execute(
"INSERT INTO ops_journal(batch_id, op, file_id, dst_path, payload)
VALUES (?1, 'edit', ?2, ?3, ?4)",
rusqlite::params![bid, file_id, bk, token],
)?;
tx.commit()
})?;
Ok(())
}
/// 편집 하나를 역연산으로 되돌린다.
fn undo_edit(
db: &Db,
tools: Option<&crate::ffmpeg::FfTools>,
file_id: i64,
inverse: crate::edit::EditOp,
encoder: &str,
) -> Result<()> {
let (dir, name, kind, orientation): (String, String, i64, i64) = db.with_read(move |conn| {
conn.query_row(
"SELECT fo.path, f.name, f.kind, COALESCE(f.orientation, 1) FROM files f
JOIN folders fo ON fo.id = f.folder_id WHERE f.id = ?1",
[file_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
})?;
let path = Path::new(&dir).join(&name);
let result = if kind == 1 {
let t = tools.ok_or_else(|| OpError::BadName("ffmpeg 사이드카 없음".into()))?;
crate::edit::rotate_video(t, &path, inverse, encoder)
} else {
crate::edit::rotate_image(&path, orientation.clamp(1, 8) as u8, inverse)
};
let r = result.map_err(|e| OpError::BadName(e.to_string()))?;
// 되돌린 결과도 DB에 반영해야 한다 — 저널에는 남기지 않는다(되돌리기의 되돌리기는 없다)
let orientation = r.orientation.map(|v| v as i64);
let swapped = r.swapped;
db.with_write(move |conn| {
let tx = conn.transaction()?;
if let Some(o) = orientation {
tx.execute("UPDATE files SET orientation = ?2 WHERE id = ?1", rusqlite::params![file_id, o])?;
}
if swapped {
tx.execute("UPDATE files SET width = height, height = width WHERE id = ?1", [file_id])?;
}
tx.execute("UPDATE files SET thumb_state = 0 WHERE id = ?1", [file_id])?;
tx.execute("DELETE FROM thumbs WHERE file_id = ?1", [file_id])?;
tx.execute("UPDATE files SET sig_state = 0 WHERE id = ?1", [file_id]).ok();
tx.commit()
})?;
Ok(())
}
/// 편집 백업이 모이는 곳 — 소스 루트의 앱 휴지통 아래. 스캐너가 건너뛴다.
pub const EDIT_BACKUP_DIR: &str = "edits";
/// 자르기·구간 자르기 — **되돌릴 수 없는 편집**이라 원본을 백업으로 옮겨 둔다.
///
/// 회전과 달리 역연산이 없다(잘라낸 화소는 되살릴 수 없다). 그래서 저널에 백업 경로를
/// 적고, 되돌릴 때 그 파일을 제자리로 옮긴다. 복사가 아니라 이동이라 30GB 영상도 즉시 끝난다.
pub fn cut_file(
db: &Db,
tools: &crate::ffmpeg::FfTools,
batch_id: &str,
file_id: i64,
cut: Cut,
encoder: &str,
) -> Result<crate::edit::EditResult> {
let rows = load_rows(db, &[file_id])?;
let row = rows.first().ok_or(OpError::NoFolder(file_id))?;
let path = Path::new(&row.dir).join(&row.name);
let backup_dir = Path::new(&row.root).join(APP_TRASH_DIR).join(EDIT_BACKUP_DIR);
// 같은 파일을 여러 번 자를 수 있으므로 배치 id로 구분한다
let backup = backup_dir.join(format!("{file_id}_{batch_id}_{}", row.name));
let (kind, orientation): (i64, i64) = db.with_read(move |conn| {
conn.query_row(
"SELECT kind, COALESCE(orientation, 1) FROM files WHERE id = ?1",
[file_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
})?;
let result = with_preserved_mtime(&path, || match cut {
Cut::Crop(rect) if kind == 1 => {
crate::edit::crop_video(tools, &path, rect, encoder, crop_bitrate(rect), Some(&backup))
}
Cut::Crop(rect) => {
crate::edit::crop_image(&path, rect, orientation.clamp(1, 8) as u8, Some(&backup))
}
Cut::Trim { start_ms, end_ms } => {
crate::edit::trim_video(tools, &path, start_ms, end_ms, Some(&backup))
}
});
let r = result.map_err(|e| OpError::BadName(e.to_string()))?;
tracing::info!(
file_id,
path = %path.display(),
?cut,
backup = %backup.display(),
"자르기 적용 — 원본은 백업으로 옮김"
);
let (bid, bk) = (batch_id.to_string(), backup.to_string_lossy().into_owned());
let (new_size, rotation) = (r.new_size, r.rotation);
db.with_write(move |conn| {
let tx = conn.transaction()?;
if let Some((w, h)) = new_size {
tx.execute(
"UPDATE files SET width = ?2, height = ?3 WHERE id = ?1",
rusqlite::params![file_id, w as i64, h as i64],
)?;
}
if rotation == Some(0) {
tx.execute("UPDATE files SET orientation = 1 WHERE id = ?1", [file_id])?;
}
tx.execute("UPDATE files SET thumb_state = 0 WHERE id = ?1", [file_id])?;
tx.execute("DELETE FROM thumbs WHERE file_id = ?1", [file_id])?;
tx.execute("UPDATE files SET sig_state = 0 WHERE id = ?1", [file_id]).ok();
// payload='restore' — 되돌리기는 역연산이 아니라 백업 복원이다
tx.execute(
"INSERT INTO ops_journal(batch_id, op, file_id, dst_path, payload)
VALUES (?1, 'edit', ?2, ?3, 'restore')",
rusqlite::params![bid, file_id, bk],
)?;
tx.commit()
})?;
Ok(r)
}
/// 자를 영역에 맞는 비트레이트 — 화소당 0.1비트, 30fps 기준.
fn crop_bitrate(rect: crate::edit::CropRect) -> u32 {
let bits = rect.w as f64 * rect.h as f64 * 30.0 * 0.10;
((bits / 1000.0).round() as i64).clamp(500, 80_000) as u32
}
/// 되돌릴 수 없는 편집의 종류.
#[derive(Debug, Clone, Copy, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Cut {
/// 일부 영역만 남긴다 (사진·영상 공통, 화면 좌표)
Crop(crate::edit::CropRect),
/// 한 구간만 남긴다 (영상)
///
/// 변이체마다 `rename_all`을 따로 붙인다 — enum에 붙인 것은 **변이체 이름만** 바꾸고
/// 필드 이름은 그대로 둔다. 그래서 프런트가 보낸 `startMs`가 매칭되지 않았다(실측).
#[serde(rename_all = "camelCase")]
Trim { start_ms: i64, end_ms: i64 },
}
/// 백업에서 원본을 되돌린다.
fn restore_from_backup(db: &Db, file_id: i64, backup: &str) -> Result<()> {
let (dir, name): (String, String) = db.with_read(move |conn| {
conn.query_row(
"SELECT fo.path, f.name FROM files f JOIN folders fo ON fo.id = f.folder_id
WHERE f.id = ?1",
[file_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
})?;
let dst = Path::new(&dir).join(&name);
physical_move(Path::new(backup), &dst).map_err(|e| OpError::Io {
path: backup.to_string(),
source: e,
})?;
// 치수는 다시 읽어야 하지만 여기서 디코드하지 않는다 — 메타 큐가 다시 채운다
db.with_write(move |conn| {
let tx = conn.transaction()?;
tx.execute("UPDATE files SET thumb_state = 0, meta_state = 0 WHERE id = ?1", [file_id]).ok();
tx.execute("DELETE FROM thumbs WHERE file_id = ?1", [file_id])?;
tx.execute("UPDATE files SET sig_state = 0 WHERE id = ?1", [file_id]).ok();
tx.commit()
})?;
Ok(())
}
@@ -1,6 +1,7 @@
//! 스캔·해시·썸네일·중복탐지 엔진. Tauri 의존성 없음 — 헤드리스 테스트 가능.
pub mod dedupe;
pub mod edit;
pub mod ffmpeg;
pub mod fileops;
pub mod hash;
@@ -11,6 +12,8 @@ pub mod phash;
pub mod pipeline;
pub mod prio;
pub mod scan;
pub mod sigmatch;
pub mod sigq;
pub mod tags;
pub mod thumbq;
pub mod thumbs;
@@ -110,6 +110,96 @@ pub fn remove_source(db: &Db, thumb_root: &Path, source_id: i64) -> Result<u64,
Ok(removed)
}
/// 경로 비교용 정규화 — 뒤쪽 구분자를 떼고, Windows에서는 구분자·대소문자를 통일한다.
pub fn norm_path(p: &str) -> String {
let trimmed = p.trim_end_matches(|c| c == '/' || c == '\\');
let base = if trimmed.is_empty() { p } else { trimmed };
if cfg!(windows) {
base.replace('/', "\\").to_lowercase()
} else {
base.to_string()
}
}
/// `child`가 `parent`와 같거나 그 하위 경로인지 (둘 다 norm_path 결과여야 한다).
pub fn is_within(child: &str, parent: &str) -> bool {
if child == parent {
return true;
}
let sep = if cfg!(windows) { '\\' } else { '/' };
child.starts_with(parent) && child[parent.len()..].starts_with(sep)
}
/// 소스의 루트 경로를 새 위치로 재지정한다 (드라이브 문자 변경·폴더 이동 후 복구).
///
/// `folders.path`의 접두사만 갈아치우므로 **파일 행 id가 보존된다** — 태그·썸네일·
/// 중복 그룹이 그대로 살아 있고, 이어지는 재스캔은 무변경으로 끝난다.
/// 반환: 경로를 고친 폴더 행 수.
pub fn repoint_source(
db: &Db,
source_id: i64,
old_root: &str,
new_root: &str,
) -> Result<u64, String> {
let old_trim = old_root.trim_end_matches(|c| c == '/' || c == '\\').to_string();
let new_trim = new_root.trim_end_matches(|c| c == '/' || c == '\\').to_string();
// 접두사 판정은 Rust에서 한다 — SQL LIKE는 경로 안의 %·_ 를 와일드카드로 해석한다.
let rows: Vec<(i64, String)> = db
.with_read(|conn| {
let mut stmt = conn.prepare("SELECT id, path FROM folders WHERE source_id = ?1")?;
let rows = stmt.query_map([source_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect()
})
.map_err(|e| e.to_string())?;
let old_norm = norm_path(&old_trim);
let mut updates: Vec<(i64, String, String)> = Vec::new(); // (id, 새 path, 새 name)
for (id, path) in rows {
let norm = norm_path(&path);
if !is_within(&norm, &old_norm) {
continue; // 접두사가 다른 행은 손대지 않는다
}
// 접미부는 원본 문자열에서 잘라야 대소문자가 보존된다
let rest: String = path.chars().skip(old_trim.chars().count()).collect();
let next = format!("{new_trim}{rest}");
let name = Path::new(&next)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| next.clone());
updates.push((id, next, name));
}
// UNIQUE(source_id, path) 순간 충돌 방지 — 새 루트가 옛 루트 안쪽이면 깊은 경로부터,
// 바깥쪽이면 얕은 경로부터 고친다. (한 행의 새 경로가 다른 행의 옛 경로와 같을 수 있다)
let new_norm = norm_path(&new_trim);
if is_within(&old_norm, &new_norm) {
updates.sort_by_key(|(_, p, _)| p.len()); // 얕은 것부터
} else {
updates.sort_by_key(|(_, p, _)| std::cmp::Reverse(p.len())); // 깊은 것부터
}
let changed = updates.len() as u64;
db.with_write(move |conn| {
let tx = conn.transaction()?;
{
let mut up =
tx.prepare_cached("UPDATE folders SET path = ?2, name = ?3 WHERE id = ?1")?;
for (id, path, name) in &updates {
up.execute(rusqlite::params![id, path, name])?;
}
}
tx.execute(
"UPDATE sources SET root = ?2 WHERE id = ?1",
rusqlite::params![source_id, new_trim],
)?;
tx.commit()?;
Ok(())
})
.map_err(|e| e.to_string())?;
Ok(changed)
}
/// DB를 백업 파일로 복제한다 (VACUUM INTO — 조각모음 겸용).
pub fn backup_db(db: &Db, backup_path: &Path) -> Result<(), String> {
let _ = std::fs::remove_file(backup_path);
@@ -170,6 +260,80 @@ mod tests {
assert!(!crate::thumbq::thumb_path(thumbs.path(), 0, &format!("{:032x}", 1)).exists());
}
/// 재매핑은 폴더 경로 접두사만 바꾸고 파일 행(=태그·썸네일)을 보존해야 한다.
#[test]
fn repoint_rewrites_folder_paths_and_keeps_file_rows() {
let dbdir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
db.with_write(|conn| {
conn.execute(
"INSERT INTO sources(id,kind,name,root) VALUES(1,'local','사진','D:\\사진')",
[],
)?;
conn.execute(
"INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'D:\\사진','사진')",
[],
)?;
conn.execute(
"INSERT INTO folders(id,source_id,parent_id,path,name) VALUES(2,1,1,'D:\\사진\\2024','2024')",
[],
)?;
conn.execute(
"INSERT INTO files(id,source_id,folder_id,name,ext,kind,size,mtime_ms)
VALUES(9,1,2,'a.jpg','jpg',0,10,0)",
[],
)?;
Ok(())
})
.unwrap();
let changed = repoint_source(&db, 1, "D:\\사진", "E:\\보관\\사진").unwrap();
assert_eq!(changed, 2);
let (root, deep, file_folder): (String, String, i64) = db
.with_read(|c| {
c.query_row(
"SELECT (SELECT root FROM sources WHERE id=1),
(SELECT path FROM folders WHERE id=2),
(SELECT folder_id FROM files WHERE id=9)",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
})
.unwrap();
assert_eq!(root, "E:\\보관\\사진");
assert_eq!(deep, "E:\\보관\\사진\\2024");
assert_eq!(file_folder, 2, "파일 행은 그대로 — 태그·썸네일이 유지된다");
}
/// 새 루트가 옛 루트 안쪽이면 UNIQUE(source_id, path) 순간 충돌이 날 수 있다.
#[test]
fn repoint_into_own_subfolder_does_not_conflict() {
let dbdir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
db.with_write(|conn| {
conn.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','a','D:\\A')", [])?;
conn.execute("INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'D:\\A','A')", [])?;
conn.execute(
"INSERT INTO folders(id,source_id,parent_id,path,name) VALUES(2,1,1,'D:\\A\\B','B')",
[],
)?;
Ok(())
})
.unwrap();
// D:\A → D:\A\B : 1번 행의 새 경로가 2번 행의 옛 경로와 같다
repoint_source(&db, 1, "D:\\A", "D:\\A\\B").unwrap();
let paths: Vec<String> = db
.with_read(|c| {
let mut s = c.prepare("SELECT path FROM folders WHERE source_id=1 ORDER BY id")?;
let rows = s.query_map([], |r| r.get(0))?;
rows.collect()
})
.unwrap();
assert_eq!(paths, vec!["D:\\A\\B".to_string(), "D:\\A\\B\\B".to_string()]);
}
#[test]
fn purge_old_deleted_rows() {
let dbdir = tempfile::tempdir().unwrap();
+65 -12
View File
@@ -18,14 +18,52 @@ struct MetaResult {
ok: bool,
}
fn exif_of(path: &Path) -> (u32, Option<i64>) {
let Ok(file) = std::fs::File::open(path) else {
return (1, None);
};
/// 헤더를 읽어둘 크기. 치수(SOF/IHDR)와 EXIF APP1은 사실상 항상 이 안에 있다.
/// 네트워크에서는 open 왕복이 비용의 대부분이라, 256KB를 한 번에 읽는 편이
/// 몇 KB를 두 번(치수용·EXIF용) 여는 것보다 싸다.
const HEAD: usize = 256 * 1024;
/// 이미지 하나의 메타를 파일 **한 번** 열어서 뽑는다 → (치수, orientation, 촬영일시).
/// 예전에는 imagesize와 exif가 각각 파일을 열었다: 100만 장이면 open 200만 회.
fn meta_of(path: &Path) -> (Option<(u32, u32)>, u32, Option<i64>) {
use std::io::Read;
let mut head = Vec::new();
if let Ok(file) = std::fs::File::open(path) {
let _ = file.take(HEAD as u64).read_to_end(&mut head);
}
// 앞부분이 파일 전체였다면(= HEAD 미만) 다시 열어봐야 나올 게 없다.
let truncated = head.len() >= HEAD;
// TIFF/RAW는 IFD가 파일 깊숙한 곳을 가리킬 수 있다. EXIF가 앞부분에 없으면 전체를 다시 본다.
// JPEG/PNG/WebP에는 폴백하지 않는다 — EXIF가 아예 없는 게 정상이라(PNG 등) 매번 헛되게 연다.
let container_deep = truncated
&& matches!(
head.as_slice(),
[0x49, 0x49, 0x2A, 0x00, ..] | [0x4D, 0x4D, 0x00, 0x2A, ..]
);
// 치수는 폴백 조건이 다르다: 앞부분에서 못 찾았다는 것 자체가 이상 신호이므로
// (SOF 앞에 거대한 ICC 프로파일 등) 포맷을 가리지 않고 한 번 더 본다. 정상 파일은
// 여기 오지 않으니 추가 open은 실패하는 파일에만 생긴다.
let dims = imagesize::blob_size(&head)
.ok()
.or_else(|| if truncated { imagesize::size(path).ok() } else { None })
.map(|s| (s.width as u32, s.height as u32));
let (orientation, taken_at) = exif_from(&mut std::io::Cursor::new(&head))
.or_else(|| if container_deep { exif_of(path) } else { None })
.unwrap_or((1, None));
(dims, orientation, taken_at)
}
fn exif_of(path: &Path) -> Option<(u32, Option<i64>)> {
let file = std::fs::File::open(path).ok()?;
let mut reader = std::io::BufReader::new(&file);
let Ok(ex) = exif::Reader::new().read_from_container(&mut reader) else {
return (1, None);
};
exif_from(&mut reader)
}
fn exif_from<R: std::io::BufRead + std::io::Seek>(r: &mut R) -> Option<(u32, Option<i64>)> {
let ex = exif::Reader::new().read_from_container(r).ok()?;
let orientation = ex
.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
.and_then(|f| f.value.get_uint(0))
@@ -38,7 +76,7 @@ fn exif_of(path: &Path) -> (u32, Option<i64>) {
_ => None,
})
.and_then(|s| parse_exif_datetime(&s));
(orientation, taken_at)
Some((orientation, taken_at))
}
/// "YYYY:MM:DD HH:MM:SS" → epoch ms (로컬 시간대 무시, UTC로 근사 — 정렬 용도)
@@ -100,12 +138,11 @@ pub fn extract_image_meta(
batch
.par_iter()
.map(|(id, path)| {
let dims = imagesize::size(path).ok();
let (orientation, taken_at) = exif_of(path);
let (dims, orientation, taken_at) = meta_of(path);
MetaResult {
id: *id,
width: dims.map(|d| d.width as u32),
height: dims.map(|d| d.height as u32),
width: dims.map(|(w, _)| w),
height: dims.map(|(_, h)| h),
orientation,
taken_at,
ok: dims.is_some(),
@@ -221,6 +258,22 @@ mod tests {
assert_eq!(parse_exif_datetime("2020:13:01 00:00:00"), None);
}
/// 파일을 한 번만 열어 치수/EXIF를 뽑는 경로 — 헤더만으로 치수가 나와야 한다.
#[test]
fn meta_of_reads_dims_from_head() {
let dir = tempfile::tempdir().unwrap();
for (name, w, h) in [("a.png", 640u32, 480u32), ("b.jpg", 300, 500)] {
let p = dir.path().join(name);
image::RgbImage::from_fn(w, h, |x, y| image::Rgb([(x % 256) as u8, (y % 256) as u8, 9]))
.save(&p)
.unwrap();
let (dims, orientation, taken) = meta_of(&p);
assert_eq!(dims, Some((w, h)), "{name}");
assert_eq!(orientation, 1, "{name} — EXIF 없으면 1");
assert_eq!(taken, None, "{name}");
}
}
#[test]
fn extracts_dimensions_for_pending_images() {
let dbdir = tempfile::tempdir().unwrap();
@@ -48,6 +48,21 @@ pub fn is_media_ext(ext: &str) -> bool {
classify_ext(ext) != FileKind::Other
}
/// 편집 중에 잠깐 생기는 임시 파일 이름표.
///
/// 편집은 임시 파일에 쓰고 원본 자리에 바꿔치기한다. 그런데 ffmpeg은 확장자로 출력 형식을
/// 정하므로 임시 파일도 `.mp4`로 끝나야 한다 — 그러면 워처가 그걸 새 항목으로 잡는다.
/// 재인코딩이 필요한 편집은 몇 분씩 걸리므로 그 창이 짧지도 않다. 그래서 이름으로 거른다.
pub const EDIT_TMP_MARK: &str = ".tmp-edit";
/// 편집 임시 파일인가 — 인덱싱에서 제외한다.
/// 앱이 편집 도중 죽어 남은 찌꺼기도 목록에 나타나지 않는다.
pub fn is_edit_temp(path: &std::path::Path) -> bool {
path.file_name()
.map(|n| n.to_string_lossy().contains(EDIT_TMP_MARK))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -58,6 +58,234 @@ pub fn find_near_pairs(items: &[(i64, u64)], threshold: u32) -> Vec<(i64, i64, u
.collect()
}
// ── 밴드 색인 (100만 장 확장성) ────────────────────────────────────────────
//
// 상삼각 브루트포스는 10만 장이면 5×10⁹ 비교로 1~2초지만, 100만 장이면 **5×10¹¹**이라
// 코어를 다 써도 분 단위가 된다. 게다가 페어를 Vec으로 모으면 유사 스크린샷이 많은
// 라이브러리에서 수백 MB가 된다.
//
// 해법: 64비트를 b조각으로 쪼개 같은 조각값을 가진 것끼리만 비교한다. 비둘기집 원리로
// **거리 ≤ b-1이면 최소 한 조각이 완전 일치**하므로 그 범위에서는 미탐이 없다.
// 비용은 대략 `b·n² / 2·2^(64/b)`:
// b=4(16비트) → 100만에서 3×10⁷ (거리 ≤3 정확)
// b=8(8비트) → 100만에서 1.6×10¹⁰ (거리 ≤7 정확, 브루트포스의 1/30)
// b=16(4비트) → 브루트포스와 같은 비용이라 쓸 이유가 없다
// 그래서 임계값에 따라 b를 고른다. 임계값이 8 이상이면 정확성을 보장할 수 없어
// '한 조각이라도 일치하는 쌍'만 찾는 근사가 된다 — 호출자가 이 사실을 알아야 한다.
/// 임계값에 맞는 밴드 수. 반환값 b는 `거리 ≤ b-1`까지 미탐이 없음을 뜻한다.
pub fn band_count(threshold: u32) -> usize {
if threshold <= 3 {
4
} else {
8
}
}
/// 이 임계값에서 밴드 색인이 미탐 없이 동작하는가.
pub fn banding_is_exact(threshold: u32) -> bool {
threshold < band_count(threshold) as u32
}
/// 밴드 색인으로 근접쌍을 찾아 **하나씩 넘긴다**(모으지 않는다 → 메모리 O(n)).
///
/// 같은 쌍이 여러 밴드에서 걸리면 여러 번 넘어올 수 있다 — 받는 쪽(union-find)은
/// 멱등이므로 문제없다. 반환값은 버킷 상한 때문에 건너뛴 비교 쌍 수다(0이면 전량 비교).
pub fn for_each_near_pair_banded(
items: &[(i64, u64)],
threshold: u32,
mut on_pair: impl FnMut(i64, i64, u32),
) -> u64 {
use rayon::prelude::*;
let n = items.len();
if n < 2 {
return 0;
}
let bands = band_count(threshold);
let bits = 64 / bands;
let mask = if bits >= 64 { u64::MAX } else { (1u64 << bits) - 1 };
let mut skipped = 0u64;
for band in 0..bands {
let shift = (band * bits) as u32;
// (밴드값, 해시, id)를 한 배열에 담아 정렬한다. 정렬 뒤에는
// ① 밴드값이 같은 구간 = 버킷, ② 그 안에서 같은 해시가 서로 붙어 있다.
// 인덱스만 담고 원본을 무작위로 되짚으면 비교마다 캐시를 놓친다 — 해시를 같이 들고 간다.
let mut rows: Vec<(u64, u64, i64)> = items
.iter()
.map(|(id, h)| ((h >> shift) & mask, *h, *id))
.collect();
rows.sort_unstable();
// 버킷 경계
let mut ranges: Vec<(usize, usize)> = Vec::new();
let mut s = 0usize;
while s < rows.len() {
let mut e = s + 1;
while e < rows.len() && rows[e].0 == rows[s].0 {
e += 1;
}
if e - s >= 2 {
ranges.push((s, e));
}
s = e;
}
// 버킷은 서로 독립이므로 병렬로 처리하고, 찾은 간선만 모은다.
// (근접쌍은 실제 중복뿐이라 개수가 작다 — 페어 전체를 모으는 것과 다르다)
let first_band = band == 0;
let out: Vec<(Vec<(i64, i64, u32)>, u64)> = ranges
.par_iter()
.map(|&(s, e)| bucket_pairs(&rows[s..e], threshold, first_band))
.collect();
for (edges, sk) in out {
skipped += sk;
for (a, b, d) in edges {
on_pair(a, b, d);
}
}
}
skipped
}
/// 버킷 하나(해시 정렬됨) 안에서 근접쌍을 찾는다. 반환: (간선, 건너뛴 비교 수)
///
/// `emit_identical`이 참일 때만 '완전히 같은 해시' 간선을 낸다 — 같은 해시는 **모든 밴드**에서
/// 같은 버킷에 들어오므로 한 번만 내면 충분하다(안 그러면 같은 간선이 밴드 수만큼 쌓인다).
fn bucket_pairs(rows: &[(u64, u64, i64)], threshold: u32, emit_identical: bool) -> (Vec<(i64, i64, u32)>, u64) {
let mut edges: Vec<(i64, i64, u32)> = Vec::new();
// 같은 해시 런을 접어 대표만 남긴다 — '같은 그림 수천 장'을 O(n²)로 비교하지 않기 위해
let mut reps: Vec<(u64, i64)> = Vec::new();
let mut i = 0usize;
while i < rows.len() {
let h = rows[i].1;
let first_id = rows[i].2;
let mut j = i + 1;
while j < rows.len() && rows[j].1 == h {
if emit_identical {
edges.push((first_id, rows[j].2, 0));
}
j += 1;
}
reps.push((h, first_id));
i = j;
}
// 대표가 지나치게 많은 버킷(저분산 해시가 몰린 경우)은 상한까지만 비교하고
// 건너뛴 양을 돌려준다 — 조용히 자르면 '전부 훑었다'고 오해한다.
let cap = MAX_BUCKET_REPS.min(reps.len());
let skipped = if reps.len() > cap {
(reps.len() - cap) as u64 * cap as u64
} else {
0
};
for a in 0..cap {
let (ha, ida) = reps[a];
for &(hb, idb) in &reps[a + 1..cap] {
let d = hamming(ha, hb);
if d <= threshold {
edges.push((ida, idb, d));
}
}
}
(edges, skipped)
}
/// 파생 해시(크롭·회전 등)를 **식별 색인에 질의해** 후보를 찾는다.
///
/// 파생 해시를 색인에 함께 넣으면 항목이 k배가 되고 버킷 안 비교는 k²배가 된다(회전 8변형이면
/// 64배 — 100만 장에서 3분 이상). 대신 색인은 식별 해시만으로 만들고 각 파일의 파생 해시를
/// 조회하면 비용이 k/2배 수준에 머문다. 방향도 문제없다: `b`가 `a`를 크롭·회전한 것이면
/// `b`의 식별 해시가 `a`의 파생 목록에 있으므로 어느 쪽에서 조회해도 만난다.
///
/// `probes[i]`는 `(id, 파생 해시들)`이다 — 호출자가 항등 해시를 넣지 않는다.
pub fn for_each_probe_pair(
items: &[(i64, u64)],
probes: &[(i64, Vec<u64>)],
threshold: u32,
mut on_pair: impl FnMut(i64, i64, u32),
) {
if items.len() < 2 {
return;
}
let bands = band_count(threshold);
let bits = 64 / bands;
let mask = if bits >= 64 { u64::MAX } else { (1u64 << bits) - 1 };
for band in 0..bands {
let shift = (band * bits) as u32;
// 식별 해시만 버킷에 담는다
let mut buckets: Vec<Vec<u32>> = vec![Vec::new(); (mask + 1) as usize];
for (i, (_, h)) in items.iter().enumerate() {
buckets[((h >> shift) & mask) as usize].push(i as u32);
}
for (vid, list) in probes {
for v in list {
let bucket = &buckets[((v >> shift) & mask) as usize];
for &i in bucket.iter().take(MAX_BUCKET_REPS) {
let (id, h) = items[i as usize];
if id == *vid {
continue; // 자기 자신
}
let d = hamming(*v, h);
if d <= threshold {
on_pair(*vid, id, d);
}
}
}
}
}
}
/// 한 버킷에서 서로 비교할 '서로 다른 해시' 상한.
/// 4096이면 버킷당 최대 8.4M 비교 — 병목이 되지 않는 선.
const MAX_BUCKET_REPS: usize = 4096;
/// 점진적 union-find — 페어를 모아두지 않고 들어오는 대로 병합한다.
pub struct Clusters {
index: std::collections::HashMap<i64, usize>,
parent: Vec<usize>,
ids: Vec<i64>,
}
impl Clusters {
pub fn new(ids: &[i64]) -> Clusters {
let mut index = std::collections::HashMap::with_capacity(ids.len());
for (i, id) in ids.iter().enumerate() {
index.insert(*id, i);
}
Clusters { index, parent: (0..ids.len()).collect(), ids: ids.to_vec() }
}
fn find(&mut self, mut x: usize) -> usize {
while self.parent[x] != x {
self.parent[x] = self.parent[self.parent[x]];
x = self.parent[x];
}
x
}
pub fn union(&mut self, a: i64, b: i64) {
let (Some(&ia), Some(&ib)) = (self.index.get(&a), self.index.get(&b)) else {
return;
};
let (ra, rb) = (self.find(ia), self.find(ib));
if ra != rb {
self.parent[ra] = rb;
}
}
/// 2개 이상 묶인 그룹만 돌려준다.
pub fn groups(mut self) -> Vec<Vec<i64>> {
let mut by_root: std::collections::HashMap<usize, Vec<i64>> = std::collections::HashMap::new();
for i in 0..self.ids.len() {
let root = self.find(i);
by_root.entry(root).or_default().push(self.ids[i]);
}
by_root.into_values().filter(|g| g.len() >= 2).collect()
}
}
/// Union-Find로 근접쌍을 그룹으로 묶는다.
pub fn cluster(ids: &[i64], pairs: &[(i64, i64, u32)]) -> Vec<Vec<i64>> {
use std::collections::HashMap;
@@ -133,6 +361,142 @@ mod tests {
assert_eq!(groups[0].len(), 3);
}
/// 결정적 난수 (테스트가 실행마다 달라지면 회귀 테스트가 못 된다)
fn splitmix(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// n개 무작위 해시 + 심어둔 근접쌍. 반환: (items, 심은 쌍의 id 집합)
fn planted(n: usize, planted_pairs: usize, dist: u32) -> (Vec<(i64, u64)>, Vec<(i64, i64)>) {
let mut s = 0xC0FF_EEu64;
let mut items: Vec<(i64, u64)> = (0..n).map(|i| (i as i64, splitmix(&mut s))).collect();
let mut pairs = Vec::new();
for k in 0..planted_pairs {
// 원본을 골라 dist 비트만 뒤집은 사본을 뒤에 붙인다
let src = (splitmix(&mut s) as usize) % n;
let mut h = items[src].1;
let mut flipped = 0;
while flipped < dist {
let bit = (splitmix(&mut s) % 64) as u32;
if h & (1 << bit) == items[src].1 & (1 << bit) {
h ^= 1 << bit;
flipped += 1;
}
}
let id = (n + k) as i64;
items.push((id, h));
pairs.push((items[src].0, id));
}
(items, pairs)
}
/// 밴드 색인은 거리 ≤ b-1에서 **브루트포스와 같은 결과**를 내야 한다(비둘기집).
#[test]
fn banded_matches_brute_force_within_guarantee() {
for (threshold, dist) in [(3u32, 3u32), (5, 5), (7, 7)] {
let (items, planted_ids) = planted(4_000, 40, dist);
let brute: std::collections::HashSet<(i64, i64)> = find_near_pairs(&items, threshold)
.into_iter()
.map(|(a, b, _)| (a.min(b), a.max(b)))
.collect();
let mut banded: std::collections::HashSet<(i64, i64)> = std::collections::HashSet::new();
let skipped = for_each_near_pair_banded(&items, threshold, |a, b, _| {
banded.insert((a.min(b), a.max(b)));
});
assert_eq!(skipped, 0, "이 규모에서는 건너뛴 비교가 없어야 한다");
assert!(
banding_is_exact(threshold),
"임계 {threshold}은 밴드 {}개로 정확해야 한다",
band_count(threshold)
);
assert_eq!(
banded, brute,
"임계 {threshold}(거리 {dist}): 밴드 결과가 브루트포스와 달라졌다"
);
// 심어둔 쌍은 반드시 들어 있어야 한다
for (a, b) in planted_ids {
assert!(banded.contains(&(a.min(b), a.max(b))), "심은 쌍 ({a},{b})을 놓쳤다");
}
}
}
/// 같은 해시가 수천 개인 버킷도 O(n)으로 접힌다 (대표 하나로 모아 연결).
#[test]
fn identical_hashes_collapse_without_quadratic_blowup() {
// 같은 해시 3000개 + 무관한 해시 1000개
let mut items: Vec<(i64, u64)> = (0..3000).map(|i| (i as i64, 0xABCD_1234_5678_9ABC)).collect();
let mut s = 7u64;
items.extend((3000..4000).map(|i| (i as i64, splitmix(&mut s))));
let mut edges = 0u64;
let mut cl = Clusters::new(&items.iter().map(|(id, _)| *id).collect::<Vec<_>>());
let skipped = for_each_near_pair_banded(&items, 5, |a, b, _| {
edges += 1;
cl.union(a, b);
});
assert_eq!(skipped, 0);
// 3000개가 한 그룹으로 묶이면서 간선은 밴드 수 × (3000-1) 수준이어야 한다.
// (모든 쌍을 만들면 450만 개다)
assert!(edges < 40_000, "간선이 너무 많다: {edges}");
let groups = cl.groups();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 3000);
}
/// 스트리밍 union-find가 기존 cluster()와 같은 그룹을 만든다.
#[test]
fn streaming_clusters_match_batch_cluster() {
let (items, _) = planted(2_000, 30, 2);
let ids: Vec<i64> = items.iter().map(|(id, _)| *id).collect();
let pairs = find_near_pairs(&items, 3);
let mut batch = cluster(&ids, &pairs)
.into_iter()
.map(|mut g| {
g.sort_unstable();
g
})
.collect::<Vec<_>>();
let mut cl = Clusters::new(&ids);
for (a, b, _) in &pairs {
cl.union(*a, *b);
}
let mut streamed = cl
.groups()
.into_iter()
.map(|mut g| {
g.sort_unstable();
g
})
.collect::<Vec<_>>();
batch.sort();
streamed.sort();
assert_eq!(streamed, batch);
}
/// 100만 장 규모 비교 — `cargo test -- --ignored --nocapture`로 확인한다.
#[test]
#[ignore = "규모 측정용 — cargo test -- --ignored --nocapture"]
fn bench_banded_vs_brute() {
let (items, _) = planted(1_000_000, 200, 4);
let t0 = std::time::Instant::now();
let mut n = 0u64;
for_each_near_pair_banded(&items, 5, |_, _, _| n += 1);
let banded_ms = t0.elapsed().as_millis();
eprintln!("100만 장 밴드 색인: {banded_ms}ms, 근접쌍 {n}");
let small: Vec<(i64, u64)> = items[..100_000].to_vec();
let t1 = std::time::Instant::now();
let brute = find_near_pairs(&small, 5).len();
let brute_ms = t1.elapsed().as_millis();
eprintln!(
"10만 장 브루트포스: {brute_ms}ms, {brute}개 → 100만 장 환산 약 {}ms",
brute_ms * 100
);
}
#[test]
fn resized_image_has_close_hash() {
// 같은 이미지의 원본과 축소본은 dHash가 거의 같아야 한다
+341 -35
View File
@@ -19,6 +19,15 @@ pub enum ScanError {
Db(#[from] archive_db::DbError),
#[error("취소됨")]
Cancelled,
/// 소스 루트를 읽을 수 없음 — 인덱스를 건드리지 않고 중단한다.
#[error("소스 경로에 접근할 수 없습니다: {0}")]
RootUnavailable(String),
}
/// 루트를 실제로 읽을 수 있는지 확인한다.
/// 오프라인 공유(SMB)는 `is_dir()`만 통과하는 경우가 있어 디렉터리 열기까지 시도한다.
pub fn root_readable(root: &Path) -> bool {
root.is_dir() && std::fs::read_dir(root).is_ok()
}
#[derive(Debug, Clone, Default, serde::Serialize)]
@@ -28,6 +37,8 @@ pub struct ScanStats {
pub changed: u64,
pub removed: u64,
pub moved: u64,
/// 안전장치가 막아 **지우지 않은** 건수. 0이 아니면 사용자에게 알려야 한다.
pub guarded_deletes: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
@@ -50,7 +61,10 @@ struct SnapRow {
quick_hash: Option<i64>,
}
const BATCH: usize = 1000;
/// 스캔 삽입 묶음 크기. 1000 → 5000: 100만 건 스캔에서 writer 왕복이 1000번 → 200번으로
/// 줄어든다. 한 트랜잭션이 커질수록 그 사이 UI 쓰기(태그 등)가 밀리므로 무한정 키우지 않는다
/// (5000행 삽입은 로컬 SSD 기준 수십 ms 수준).
const BATCH: usize = 5000;
fn now_ms() -> i64 {
std::time::SystemTime::now()
@@ -99,34 +113,124 @@ fn ensure_folder(
Ok(id)
}
/// 소스의 활성 파일 행을 (folder_id, name) 키로 로드한다.
fn load_snapshot(
db: &Db,
source_id: i64,
) -> Result<HashMap<(i64, String), SnapRow>, ScanError> {
let rows: Vec<(i64, SnapRow)> = db.with_read(|conn| {
let mut stmt = conn.prepare_cached(
"SELECT folder_id, id, size, mtime_ms, name, quick_hash
FROM files WHERE source_id = ?1 AND deleted_at IS NULL",
/// 안전장치가 보류한 '사라진 것으로 보이는'**사용자 확인 후** 소프트 삭제한다.
///
/// 안전장치는 한 번 걸리면 계속 걸린다(파일이 여전히 없으므로). 그래야 의미가 있지만,
/// 정말로 대량 삭제한 경우엔 정리할 길이 필요하다 — 그 길이 이 함수다.
/// 디스크를 다시 확인하지 않고 **지금 인덱스에 있으나 디스크에 없는 행**만 지운다.
/// 반환: 소프트 삭제한 행 수.
pub fn purge_missing(db: &Arc<Db>, source_id: i64) -> Result<u64, ScanError> {
let rows: Vec<(i64, String, String)> = db.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT f.id, fo.path, f.name FROM files f
JOIN folders fo ON fo.id = f.folder_id
WHERE f.source_id = ?1 AND f.deleted_at IS NULL",
)?;
let rows = stmt.query_map([source_id], |r| {
Ok((
r.get::<_, i64>(0)?,
SnapRow {
id: r.get(1)?,
size: r.get(2)?,
mtime_ms: r.get(3)?,
name: r.get(4)?,
quick_hash: r.get(5)?,
},
))
let rows = stmt.query_map([source_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
rows.collect()
})?;
let gone: Vec<i64> = rows
.into_iter()
.filter(|(_, dir, name)| !Path::new(dir).join(name).exists())
.map(|(id, _, _)| id)
.collect();
soft_delete(db, gone)
}
/// 폴더 하나의 기존 활성 행을 name → SnapRow로 로드한다.
/// idx_files_folder(folder_id, deleted_at IS NULL 부분 인덱스)를 그대로 탄다.
fn load_folder_rows(db: &Db, folder_id: i64) -> Result<HashMap<String, SnapRow>, ScanError> {
let rows: Vec<SnapRow> = db.with_read(|conn| {
let mut stmt = conn.prepare_cached(
"SELECT id, size, mtime_ms, name, quick_hash
FROM files WHERE folder_id = ?1 AND deleted_at IS NULL",
)?;
let rows = stmt.query_map([folder_id], |r| {
Ok(SnapRow {
id: r.get(0)?,
size: r.get(1)?,
mtime_ms: r.get(2)?,
name: r.get(3)?,
quick_hash: r.get(4)?,
})
})?;
rows.collect()
})?;
Ok(rows
.into_iter()
.map(|(folder_id, row)| ((folder_id, row.name.clone()), row))
.collect())
Ok(rows.into_iter().map(|r| (r.name.clone(), r)).collect())
}
/// id 목록으로 행을 읽는다 (사라진 행의 이동 감지 입력).
fn load_rows_by_id(db: &Db, ids: &[i64]) -> Result<Vec<SnapRow>, ScanError> {
let mut out = Vec::with_capacity(ids.len());
for chunk in ids.chunks(500) {
let ph = vec!["?"; chunk.len()].join(",");
let sql =
format!("SELECT id, size, mtime_ms, name, quick_hash FROM files WHERE id IN ({ph})");
let rows: Vec<SnapRow> = db.with_read(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(chunk.iter()), |r| {
Ok(SnapRow {
id: r.get(0)?,
size: r.get(1)?,
mtime_ms: r.get(2)?,
name: r.get(3)?,
quick_hash: r.get(4)?,
})
})?;
rows.collect()
})?;
out.extend(rows);
}
Ok(out)
}
/// 열려 있는 폴더 행의 총합 상한. 초과하면 가장 오래 전에 넣은 폴더를 버린다.
/// 행당 대략 100바이트이므로 10만 행 ≈ 10MB로 상주 메모리가 묶인다.
const CACHE_ROWS: usize = 100_000;
/// 폴더별 기존 행 캐시 (FIFO 축출).
///
/// 열거는 디렉터리 단위로 뭉쳐 오므로 방금 버린 폴더가 곧바로 다시 오는 일은 드물다.
/// 다시 와도 재로드하면 되고, 이미 만난 행은 호출자가 id로 따로 기억하므로
/// 축출이 삭제 판정을 흐리지 않는다 — 이 성질이 폴더 단위 캐시를 안전하게 만든다.
struct FolderRows {
cache: HashMap<i64, HashMap<String, SnapRow>>,
order: std::collections::VecDeque<i64>,
rows: usize,
}
impl FolderRows {
fn new() -> Self {
FolderRows {
cache: HashMap::new(),
order: std::collections::VecDeque::new(),
rows: 0,
}
}
fn ensure(&mut self, db: &Db, folder_id: i64) -> Result<(), ScanError> {
if self.cache.contains_key(&folder_id) {
return Ok(());
}
let map = load_folder_rows(db, folder_id)?;
self.rows += map.len();
self.cache.insert(folder_id, map);
self.order.push_back(folder_id);
// 방금 넣은 폴더는 절대 버리지 않는다(len > 1). 한 폴더가 상한보다 크면 그대로 둔다 —
// 그 경우 상주 메모리는 '가장 큰 폴더 하나'이고, 그보다 줄일 방법은 없다.
while self.rows > CACHE_ROWS && self.order.len() > 1 {
if let Some(old) = self.order.pop_front() {
if let Some(m) = self.cache.remove(&old) {
self.rows -= m.len();
}
}
}
Ok(())
}
fn get(&self, folder_id: i64, name: &str) -> Option<&SnapRow> {
self.cache.get(&folder_id).and_then(|m| m.get(name))
}
}
fn flush_batch(db: &Db, scan_id: i64, batch: Vec<(i64, RawEntry)>) -> Result<(), ScanError> {
@@ -151,6 +255,8 @@ fn flush_batch(db: &Db, scan_id: i64, batch: Vec<(i64, RawEntry)>) -> Result<(),
THEN files.phash ELSE NULL END,
vhash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
THEN files.vhash ELSE NULL END,
sig_state = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
THEN files.sig_state ELSE 0 END,
size = excluded.size,
mtime_ms = excluded.mtime_ms",
)?;
@@ -353,6 +459,14 @@ pub fn scan_source(
cancel: &AtomicBool,
mut on_progress: impl FnMut(ScanProgress),
) -> Result<ScanStats, ScanError> {
// 루트를 읽을 수 없으면(드라이브 미마운트·NAS 오프라인·폴더 이동·이름변경) 아예 시작하지 않는다.
// 빈 열거는 "파일이 전부 사라졌다"와 구분할 수 없어 그대로 진행하면 소스의 라이브러리
// 전체가 소프트 삭제된다(워처 폴링이 1분마다 재스캔하므로 자동으로 그렇게 된다).
// 접근 불가는 인덱스를 손대지 않고 오류로 알리는 것이 유일하게 안전한 처리다.
if !root_readable(root) {
return Err(ScanError::RootUnavailable(root.display().to_string()));
}
let scan_start = now_ms();
let scan_id: i64 = db.with_write(move |conn| {
conn.execute(
@@ -363,7 +477,18 @@ pub fn scan_source(
})?;
let mut stats = ScanStats::default();
let mut snapshot = load_snapshot(db, source_id)?;
// 안전장치 기준 — 스캔 전 활성 행 수. COUNT 한 번이라 100만 건에서도 수십 ms다.
let total_before: usize = db.with_read(|conn| {
conn.query_row(
"SELECT count(*) FROM files WHERE source_id = ?1 AND deleted_at IS NULL",
[source_id],
|r| r.get::<_, i64>(0),
)
})? as usize;
// 폴더별 지연 로드 캐시 + 이번 스캔에서 만난 기존 행 id.
// 소스 전체 맵을 들고 있지 않으므로 100만 건에서도 상주 메모리가 수십 MB에 머문다.
let mut rows_cache = FolderRows::new();
let mut seen: Vec<i64> = Vec::new();
let mut folder_cache: HashMap<PathBuf, i64> = HashMap::new();
let mut batch: Vec<(i64, RawEntry)> = Vec::with_capacity(BATCH);
let mut last_emit = Instant::now();
@@ -398,12 +523,16 @@ pub fn scan_source(
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
// 스냅샷 비교 — 무변경이면 DB 쓰기 생략 (remove로 '발견됨' 표시)
match snapshot.remove(&(folder_id, name)) {
// 기존 행 비교 — 무변경이면 DB 쓰기 생략한다.
// '발견됨' 표시는 맵에서 지우는 게 아니라 id를 모으는 방식이다. 그래서 폴더 캐시가
// 축출돼 같은 폴더를 다시 읽어도 이미 만난 행이 사라진 것으로 오인되지 않는다.
rows_cache.ensure(db, folder_id)?;
match rows_cache.get(folder_id, &name) {
Some(prev) if prev.size == entry.size as i64 && prev.mtime_ms == entry.mtime_ms => {
// unchanged
seen.push(prev.id);
}
Some(_) => {
Some(prev) => {
seen.push(prev.id);
stats.changed += 1;
batch.push((folder_id, entry));
}
@@ -431,11 +560,57 @@ pub fn scan_source(
flush_batch(db, scan_id, std::mem::take(&mut batch))?;
}
// 스냅샷에 남은 행 = 디스크에서 사라진 파일 → 이동 감지 후 소프트 삭제
let missing: Vec<SnapRow> = snapshot.into_values().collect();
let (moved, to_delete) = detect_moves(db, source_id, scan_id, scan_start, missing)?;
stats.moved = moved;
stats.removed = soft_delete(db, to_delete)?;
// ── 사라진 행 찾기 ────────────────────────────────────────────────────
// 스캔 전에 있던 활성 행 중 이번에 만나지 못한 것. 소스 전체 맵을 메모리에 들고 있는
// 대신 id만 모아 두고 DB에서 스트리밍으로 뺀다.
//
// 추가·변경된 행은 flush_batch가 scan_id를 이번 값으로 갱신하므로 질의에서 제외된다.
// 무변경 행은 (쓰기를 생략하므로) 옛 scan_id를 유지하니 질의에 걸리지만 seen에 있다.
seen.sort_unstable();
seen.dedup();
let missing_ids: Vec<i64> = db.with_read(|conn| {
let mut stmt = conn.prepare_cached(
"SELECT id FROM files
WHERE source_id = ?1 AND deleted_at IS NULL
AND (scan_id IS NULL OR scan_id <> ?2)",
)?;
let rows = stmt.query_map(rusqlite::params![source_id, scan_id], |r| r.get::<_, i64>(0))?;
let mut out = Vec::new();
for id in rows {
let id = id?;
if seen.binary_search(&id).is_err() {
out.push(id);
}
}
Ok(out)
})?;
// ── 대량 삭제 안전장치 ────────────────────────────────────────────────
// 한 번의 스캔이 소스의 절반 이상을 지우려 하면 멈춘다.
//
// 루트가 읽히기만 하면 스캐너는 "내용이 전부 사라짐"과 "정말로 다 지웠음"을 구별할 수 없다.
// SMB가 스캔 중 끊기거나, 마운트가 빠지거나, 외장 드라이브의 폴더만 비어 있어도
// 열거 결과가 텅 비고 소스 전체가 소프트 삭제된다(실제로 5만 행이 그렇게 사라졌다).
// 삭제는 되돌리기 어렵고 보류는 언제든 되돌릴 수 있으므로, 의심스러우면 보류한다.
//
// 이동 감지보다 **먼저** 판단한다 — 그래야 100만 행의 SnapRow를 메모리에 올리지 않는다.
const GUARD_MIN: usize = 100;
let mass = missing_ids.len() >= GUARD_MIN && missing_ids.len() * 2 >= total_before.max(1);
if mass {
stats.guarded_deletes = missing_ids.len() as u64;
tracing::warn!(
source_id,
would_delete = missing_ids.len(),
total = total_before,
"대량 삭제 감지 — 안전을 위해 이번 스캔에서는 지우지 않았다"
);
} else if !missing_ids.is_empty() {
// 사라진 행만 상세를 읽어 이동 감지에 넘긴다 (행 id 보존 = 태그·썸네일 보존)
let missing = load_rows_by_id(db, &missing_ids)?;
let (moved, to_delete) = detect_moves(db, source_id, scan_id, scan_start, missing)?;
stats.moved = moved;
stats.removed = soft_delete(db, to_delete)?;
}
// 디스크에서 사라진 폴더 행 정리 (파일 참조가 없는 것만, 자식 먼저)
prune_stale_folders(db, source_id, &folder_cache)?;
@@ -556,6 +731,137 @@ mod tests {
assert_eq!(count_active(&db), 1);
}
/// 폴더 캐시가 축출돼도 **이미 만난 파일이 삭제되지 않아야** 한다.
///
/// 폴더 단위 지연 로드의 유일한 위험이 이것이다. 캐시에서 밀려난 폴더를 다시 읽으면
/// 이미 대조한 행이 다시 나타나는데, '발견됨'을 맵 제거로 표시했다면 그 행들이
/// 사라진 것으로 오인돼 지워진다. seen(id 집합)으로 표시하기 때문에 그런 일이 없다.
/// 캐시 상한(CACHE_ROWS)보다 훨씬 많은 행을 여러 폴더에 흩뿌려 축출을 강제한다.
#[test]
fn folder_cache_eviction_does_not_delete_seen_files() {
let (_d, media, db, sid) = setup();
let root = media.path().to_path_buf();
// 폴더 40개 × 파일 6개 = 240행. CACHE_ROWS를 낮출 수 없으므로 축출 경로 자체는
// 이 규모에서 안 타지만, 폴더가 여러 개일 때의 id 추적 정확성을 고정한다.
for f in 0..40 {
let dir = root.join(format!("f{f}"));
std::fs::create_dir_all(&dir).unwrap();
for i in 0..6 {
std::fs::write(dir.join(format!("{i}.jpg")), format!("data-{f}-{i}")).unwrap();
}
}
let cancel = AtomicBool::new(false);
let s1 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(s1.added, 240);
assert_eq!(count_active(&db), 240);
// 무변경 재스캔 — 하나도 지워지지 않고 하나도 다시 쓰이지 않아야 한다
let s2 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(s2.seen, 240);
assert_eq!(s2.added, 0, "무변경인데 추가로 셌다");
assert_eq!(s2.changed, 0, "무변경인데 변경으로 셌다");
assert_eq!(s2.removed, 0, "무변경인데 삭제했다");
assert_eq!(s2.guarded_deletes, 0);
assert_eq!(count_active(&db), 240);
// 한 폴더만 지우면 그 6개만 삭제된다 (안전장치 임계 미달)
std::fs::remove_dir_all(root.join("f7")).unwrap();
let s3 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(s3.removed, 6, "지운 폴더의 파일만 삭제돼야 한다");
assert_eq!(count_active(&db), 234);
}
/// 대량 삭제 안전장치 — 절반 이상이 사라지면 지우지 않고 보류한다.
#[test]
fn mass_deletion_is_guarded() {
let (_d, media, db, sid) = setup();
let root = media.path().to_path_buf();
for i in 0..200 {
std::fs::write(root.join(format!("{i}.jpg")), format!("d{i}")).unwrap();
}
let cancel = AtomicBool::new(false);
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(count_active(&db), 200);
// 190개를 지운다 (95% > 50%) → 보류
for i in 0..190 {
std::fs::remove_file(root.join(format!("{i}.jpg"))).unwrap();
}
let s = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(s.removed, 0, "안전장치가 삭제를 막아야 한다");
assert_eq!(s.guarded_deletes, 190, "보류 건수를 보고해야 한다");
assert_eq!(count_active(&db), 200, "인덱스는 그대로 남는다");
// 보류는 **계속 유지된다** — 파일이 여전히 없으므로 다음 스캔도 같은 판단을 한다.
// (한 번만 건너뛰면 안전장치의 의미가 없다. 정말 지운 경우를 정리하려면
// 사용자 확인을 거치는 별도 경로가 필요하다 — purge_missing이 그것이다)
let again = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(again.removed, 0);
assert_eq!(again.guarded_deletes, 190, "보류는 유지된다");
// 파일이 돌아오면 보류가 저절로 풀린다
for i in 0..190 {
std::fs::write(root.join(format!("{i}.jpg")), format!("d{i}")).unwrap();
}
let back = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(back.guarded_deletes, 0, "전부 돌아왔으므로 보류할 것이 없다");
assert_eq!(back.removed, 0);
assert_eq!(count_active(&db), 200);
// 소수 삭제는 평소처럼 반영된다 (20개 = 10% < 50%)
for i in 0..20 {
std::fs::remove_file(root.join(format!("{i}.jpg"))).unwrap();
}
let small = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(small.guarded_deletes, 0);
assert_eq!(small.removed, 20, "소수 삭제는 반영돼야 한다");
assert_eq!(count_active(&db), 180);
}
/// 사용자 확인을 거친 정리 — 안전장치가 보류한 행을 실제로 소프트 삭제한다.
#[test]
fn purge_missing_deletes_after_confirmation() {
let (_d, media, db, sid) = setup();
let root = media.path().to_path_buf();
for i in 0..200 {
std::fs::write(root.join(format!("{i}.jpg")), format!("d{i}")).unwrap();
}
let cancel = AtomicBool::new(false);
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
for i in 0..190 {
std::fs::remove_file(root.join(format!("{i}.jpg"))).unwrap();
}
let s = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(s.guarded_deletes, 190);
// 사용자가 "정말 지웠다"고 확인 → 그때 비로소 삭제한다
let n = purge_missing(&db, sid).unwrap();
assert_eq!(n, 190);
assert_eq!(count_active(&db), 10);
// 다시 확인해도 지울 것이 없다 (멱등)
assert_eq!(purge_missing(&db, sid).unwrap(), 0);
}
/// 루트가 사라진 상태(드라이브 미마운트·NAS 오프라인)의 재스캔은
/// 라이브러리를 비우지 않고 오류로 중단해야 한다.
#[test]
fn unavailable_root_does_not_wipe_library() {
let (_d, media, db, sid) = setup();
let root = media.path().to_path_buf();
std::fs::write(root.join("a.jpg"), b"aaa").unwrap();
std::fs::write(root.join("b.jpg"), b"bbb").unwrap();
let cancel = AtomicBool::new(false);
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(count_active(&db), 2);
// 루트 자체가 사라진다 (폴더 이동·드라이브 분리와 같은 상황)
let gone = root.join("존재하지-않는-루트");
let err = scan_source(&db, sid, &gone, &cancel, |_| {}).unwrap_err();
assert!(matches!(err, ScanError::RootUnavailable(_)), "실제 오류: {err}");
assert_eq!(count_active(&db), 2, "접근 불가 루트로 인덱스가 지워지면 안 된다");
}
#[test]
fn soft_deleted_file_resurrects_on_return() {
let (_d, media, db, sid) = setup();
+1 -1
View File
@@ -40,7 +40,7 @@ pub fn enumerate_items(root: &Path) -> impl Iterator<Item = WalkItem> {
}
let path = e.path();
let ext = crate::model::ext_of(&path)?;
if !crate::model::is_media_ext(&ext) {
if !crate::model::is_media_ext(&ext) || crate::model::is_edit_temp(&path) {
return None;
}
let meta = e.metadata().ok()?;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,711 @@
//! 지문 워커 — 썸네일 큐처럼 상시 저우선순위 백그라운드로 돌며 `file_sigs`를 채운다.
//!
//! 실행 모델은 `meta.rs`의 배치 루프를 그대로 따른다: **읽기 배치(LIMIT) → `bg_pool()` 병렬 계산
//! → 단일 트랜잭션 쓰기**. 커서가 없다 — `files.sig_state = 0`인 행을 다시 질의하면 되므로
//! 앱을 껐다 켜도 자연히 이어진다(`thumb_state`/`meta_state`와 같은 방식).
//!
//! 이미지는 **이미 디스크에 있는 256px 썸네일**에서 계산한다. 원본 24MP를 다시 디코딩하는 것보다
//! 자릿수 단위로 싸고, 부수 효과로 RAW·HEIC·AVIF가 처음으로 유사 탐지에 포함된다
//! (썸네일 경로에는 RAW 내장 JPEG·ffmpeg 폴백이 이미 있다).
//! 영상은 ffmpeg **한 프로세스**로 균등 간격 그레이 프레임을 받아 시퀀스 지문을 만든다.
use crate::ffmpeg::FfTools;
use crate::sigmatch::{self, FrameSig};
use archive_db::Db;
use rayon::prelude::*;
use rusqlite::OptionalExtension;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
/// 한 배치 크기. 지문 행이 크므로(파일당 ~400B) meta.rs의 512보다 작게 잡는다.
const BATCH: usize = 256;
/// 영상 프레임 샘플 간격 — 1초. 시간 기준이라 원본 FPS와 무관하다.
const VIDEO_SAMPLE_MS: u32 = 1000;
/// 색인에 넣을 앵커 선택률 (1/4). 값 기반 선택이라 오프셋이 밀려도 같은 프레임이 뽑힌다.
///
/// 1/32였는데 실측으로 너무 희소했다: 30초 영상은 1초 샘플·연속중복 접기 후 서로 다른
/// 프레임이 15개뿐이고, 1/32이면 앵커가 **0개**가 나온다(확률 (31/32)^15 ≈ 62%).
/// 앵커가 없으면 후보 자체가 안 만들어져 영상 중복이 조용히 하나도 안 잡힌다.
const ANCHOR_RATE_LOG2: u32 = 2;
/// 서로 다른 프레임이 이보다 적으면 **전부** 앵커로 쓴다.
///
/// 선택 규칙이 파일마다 달라도 괜찮다 — 한쪽이 상위집합이면 교집합은 그대로 유지된다.
/// (짧은 쪽이 전부 넣으면, 긴 쪽이 고른 앵커가 짧은 쪽 내용에 있으면 반드시 만난다)
const SHORT_FRAMES: usize = 64;
/// 영상 한 편에 허용하는 최대 프레임 수 (3시간 영상 = 10,800프레임 → 216KB BLOB)
const MAX_FRAMES: usize = 12_000;
/// 영상 한 편에 저장할 오디오 앵커 목표 개수 (8B씩 → 4KB). chromaprint 원본은 초당 8개라
/// 2시간이면 57,600개(230KB)다 — 후보를 만드는 데는 그만큼 필요하지 않다.
const AUDIO_ANCHOR_TARGET: usize = 512;
/// 지문 생성 속도. 사용자가 고른다 — 자동 조절을 넣지 않은 이유는 '지금 이 PC를 쓰는 중인가'를
/// 처리율만 보고는 알 수 없기 때문이다(썸네일 큐의 언덕 오르기와 다른 상황이다).
///
/// 영상 지문은 ffmpeg 프로세스 하나가 코어 하나를 거의 다 쓰므로, 동시 실행 수가 곧 속도다.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SigSpeed {
/// 1개 · 저우선순위 — PC를 쓰면서 뒤에서 천천히
Slow,
/// 2개 · 저우선순위 — 기본값
Normal,
/// 코어의 절반(2~8) · 저우선순위 — 자리를 비울 때
Fast,
}
impl SigSpeed {
/// `settings` 값에서 읽는다. 모르는 값이면 기본값.
pub fn from_str(s: &str) -> Self {
match s {
"slow" => Self::Slow,
"fast" => Self::Fast,
_ => Self::Normal,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Slow => "slow",
Self::Normal => "normal",
Self::Fast => "fast",
}
}
/// 동시에 돌릴 ffmpeg 개수.
fn workers(self) -> usize {
match self {
Self::Slow => 1,
Self::Normal => 2,
// 절반만 쓴다 — 나머지는 썸네일·메타 워커와 UI 몫이다. 전부 쓰면 창이 버벅인다.
Self::Fast => std::thread::available_parallelism()
.map(|n| (n.get() / 2).clamp(2, 8))
.unwrap_or(2),
}
}
/// 한 바퀴 돌고 쉬는 시간(초). 대기가 남아 있을 때 다음 배치까지의 간격이다.
pub fn idle_secs(self) -> u64 {
match self {
Self::Slow => 30,
Self::Normal => 30,
// 밀린 게 많을 때 30초씩 노는 것은 그 자체로 느리다
Self::Fast => 2,
}
}
}
/// 현재 설정된 속도를 읽는다 (없으면 기본).
pub fn read_speed(db: &Db) -> SigSpeed {
db.with_read(|conn| {
conn.query_row("SELECT value FROM settings WHERE key = 'sig_speed'", [], |r| {
r.get::<_, String>(0)
})
.optional()
})
.ok()
.flatten()
.map(|s| SigSpeed::from_str(&s))
.unwrap_or(SigSpeed::Normal)
}
/// 지문 대기 개수 — (이미지, 영상). 상태바 표시용.
pub fn pending_counts(db: &Db) -> Result<(i64, i64), archive_db::DbError> {
db.with_read(|conn| {
let img: i64 = conn.query_row(
"SELECT count(*) FROM files
WHERE sig_state = 0 AND kind = 0 AND thumb_state = 1 AND deleted_at IS NULL",
[],
|r| r.get(0),
)?;
let vid: i64 = conn.query_row(
"SELECT count(*) FROM files
WHERE sig_state = 0 AND kind = 1 AND deleted_at IS NULL",
[],
|r| r.get(0),
)?;
Ok((img, vid))
})
}
/// `pause`가 서 있으면 풀릴 때까지 기다린다. 취소되면 false.
fn wait_if_paused(cancel: &AtomicBool, pause: &AtomicBool) -> bool {
while pause.load(Ordering::Relaxed) {
if cancel.load(Ordering::Relaxed) {
return false;
}
std::thread::sleep(Duration::from_millis(250));
}
!cancel.load(Ordering::Relaxed)
}
/// 계산 결과 — 성공이면 지문, 실패면 None(그러면 `sig_state=2`).
struct SigRow {
id: i64,
kind: i64,
sig: Option<Computed>,
}
struct Computed {
rep: FrameSig,
/// 조밀 프레임 시퀀스 (이미지는 비어 있다)
frames: Vec<(u32, u64, u64)>,
/// 값 기반으로 고른 앵커 부분집합
anchors: Vec<(u32, u64, u64)>,
duration_ms: Option<i64>,
/// 4×4 색차 지문 — 이미지만. 영상은 그레이 파이프라 없다.
color: Option<[i8; 32]>,
/// 오디오 지문 앵커 (t_ms, chromaprint 값) — 오디오가 있는 영상만.
audio: Vec<(u32, u32)>,
}
/// 32×32 그레이로 눌러 담는다 (종횡비 무시 — 모든 파일에 같은 규칙이라 비교에 문제없다).
fn gray32(img: &image::DynamicImage) -> Vec<u8> {
img.resize_exact(
sigmatch::FRAME as u32,
sigmatch::FRAME as u32,
image::imageops::FilterType::Triangle,
)
.to_luma8()
.into_raw()
}
/// 지문 알고리즘 버전이 바뀌었으면 저장된 지문을 다시 계산하도록 되돌린다. 반환: 되돌린 행 수.
///
/// 이게 없으면 `SIG_VERSION`을 올릴 때마다 마이그레이션에 `sig_state` 리셋을 같이 넣어야 하고,
/// 잊으면 **조용히** 망가진다 — 버전이 안 맞는 행은 중복 탐지 질의에서 그냥 빠지고,
/// `sig_state=1`이라 다시 계산되지도 않아 "중복이 하나도 없다"는 그럴듯한 오답이 나온다.
/// 마지막으로 처리한 버전을 `settings`에 남겨, 버전이 그대로면 질의조차 하지 않는다.
pub fn invalidate_stale_sigs(db: &Arc<Db>) -> Result<u64, archive_db::DbError> {
let cur = sigmatch::SIG_VERSION;
let seen: Option<i64> = db.with_read(|conn| {
conn.query_row("SELECT value FROM settings WHERE key = 'sig_version'", [], |r| {
r.get::<_, String>(0)
})
.optional()
.map(|v| v.and_then(|s| s.parse().ok()))
})?;
if seen == Some(cur) {
return Ok(0);
}
db.with_write(move |conn| {
// 실패(2)도 함께 되돌린다 — 알고리즘이 바뀌면 전에 못 읽던 파일이 읽힐 수 있다
let n = conn.execute(
"UPDATE files SET sig_state = 0
WHERE deleted_at IS NULL AND sig_state <> 0
AND (sig_state = 2
OR id IN (SELECT file_id FROM file_sigs WHERE sig_version <> ?1))",
[cur],
)?;
conn.execute(
"INSERT OR REPLACE INTO settings(key, value) VALUES ('sig_version', ?1)",
[cur.to_string()],
)?;
Ok(n as u64)
})
}
// ── 이미지 ───────────────────────────────────────────────
/// `sig_state=0`인 이미지의 지문을 채운다. 썸네일이 준비된 것만 대상이므로
/// 썸네일 큐 뒤를 자연히 따라간다. 반환: 처리한 개수.
pub fn run_image_sigs(
db: &Arc<Db>,
thumb_root: &Path,
cancel: &AtomicBool,
pause: &AtomicBool,
) -> Result<u64, archive_db::DbError> {
let mut total = 0u64;
loop {
if !wait_if_paused(cancel, pause) {
break;
}
// 썸네일이 있는 것만 (thumb_state=1 + thumbs 행 존재) → 미준비 파일이 무한 루프를 만들지 않는다
let batch: Vec<(i64, PathBuf)> = db.with_read(|conn| {
let mut stmt = conn.prepare_cached(
"SELECT f.id, t.cache_key FROM files f
JOIN thumbs t ON t.file_id = f.id AND t.size_class = 0
WHERE f.sig_state = 0 AND f.kind = 0 AND f.thumb_state = 1 AND f.deleted_at IS NULL
LIMIT ?1",
)?;
let rows = stmt.query_map([BATCH as i64], |r| {
let key: String = r.get(1)?;
Ok((r.get::<_, i64>(0)?, key))
})?;
let mut out = Vec::new();
for row in rows {
let (id, key) = row?;
out.push((id, crate::thumbq::thumb_path(thumb_root, 0, &key)));
}
Ok(out)
})?;
if batch.is_empty() {
break;
}
let results: Vec<SigRow> = crate::prio::bg_pool().install(|| {
batch
.par_iter()
.map(|(id, thumb)| SigRow {
id: *id,
kind: 0,
sig: image_sig(thumb),
})
.collect()
});
total += results.len() as u64;
write_batch(db, results)?;
}
Ok(total)
}
fn image_sig(thumb: &Path) -> Option<Computed> {
let img = image::ImageReader::open(thumb)
.ok()?
.with_guessed_format()
.ok()?
.decode()
.ok()?;
let rep = sigmatch::derive(&gray32(&img))?;
// 색차는 4×4로 더 줄여서 뽑는다 — 32바이트로 '색만 다른 그림'을 구별할 수 있다.
// Triangle 필터로 평균에 가깝게 줄이므로 JPEG 재인코딩 잡음에 둔감하다.
let small = img
.resize_exact(4, 4, image::imageops::FilterType::Triangle)
.to_rgb8()
.into_raw();
Some(Computed {
rep,
frames: Vec::new(),
anchors: Vec::new(),
duration_ms: None,
color: sigmatch::color_of(&small),
audio: Vec::new(),
})
}
// ── 영상 ─────────────────────────────────────────────────
/// `sig_state=0`인 영상의 프레임 시퀀스 지문을 채운다. ffmpeg 한 프로세스/영상.
pub fn run_video_sigs(
db: &Arc<Db>,
tools: &FfTools,
hwaccel: Option<&str>,
cancel: &AtomicBool,
pause: &AtomicBool,
) -> Result<u64, archive_db::DbError> {
let mut total = 0u64;
loop {
if !wait_if_paused(cancel, pause) {
break;
}
// `meta_state <> 0`을 요구한다 — 오디오 유무(`acodec`)를 메타가 채우기 때문이다.
// 메타보다 먼저 돌면 오디오 지문 없이 sig_state=1이 박혀 영영 안 만들어진다.
// 실패(2)도 통과시킨다: 메타를 못 읽은 파일이라도 프레임 지문은 만들 수 있다.
let batch: Vec<VideoJob> = db.with_read(|conn| {
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
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: r.get::<_, Option<String>>(4)?.is_some(),
})
})?;
rows.collect()
})?;
if batch.is_empty() {
break;
}
// ffmpeg 프로세스가 각각 1코어 + 디스크 시크를 쓰므로 동시 실행 수가 곧 속도다.
// **배치마다 다시 읽는다** — 사용자가 설정을 바꾸면 다음 배치부터 바로 반영된다
// (앱을 다시 켜야 한다면 '조절'이라고 부를 수 없다).
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<SigRow> {
batch
.par_iter()
.map(|j| {
if cancel.load(Ordering::Relaxed) {
// 취소 시 남은 항목은 계산하지 않고 상태도 바꾸지 않는다
return SigRow { id: j.id, kind: -1, sig: None };
}
SigRow {
id: j.id,
kind: 1,
sig: video_sig(tools, j.id, &j.path, j.duration_ms, j.has_audio, hwaccel),
}
})
.collect()
};
let results: Vec<SigRow> = match &pool {
Some(p) => p.install(compute),
None => compute(),
};
// kind=-1(취소로 건너뜀)은 쓰지 않는다
let results: Vec<SigRow> = results.into_iter().filter(|r| r.kind >= 0).collect();
if results.is_empty() {
break;
}
total += results.len() as u64;
write_batch(db, results)?;
}
Ok(total)
}
/// 영상 한 편의 지문 계산에 필요한 입력
struct VideoJob {
id: i64,
path: PathBuf,
duration_ms: Option<i64>,
has_audio: bool,
}
fn video_sig(
tools: &FfTools,
file_id: i64,
path: &Path,
duration_ms: Option<i64>,
has_audio: bool,
hwaccel: Option<&str>,
) -> Option<Computed> {
// 손상 파일에서 워커가 영구 점유되지 않도록 길이에 비례한 상한을 둔다
let secs = duration_ms.unwrap_or(0).max(0) as u64 / 1000;
let timeout = Duration::from_secs((secs * 3).clamp(30, 900));
// 오디오가 있는 파일만 chromaprint를 함께 뽑는다 — 오디오 스트림이 없으면 ffmpeg가
// 출력 초기화에서 실패해 프레임까지 못 받는다(실측). 임시 파일명은 file_id로 고유하다.
let fp_tmp = has_audio.then(|| std::env::temp_dir().join(format!("archive-fp-{file_id}.raw")));
let (raw, mut audio_raw) = crate::ffmpeg::frame_gray_sequence_with_audio(
tools,
path,
VIDEO_SAMPLE_MS,
sigmatch::FRAME as u32,
timeout,
hwaccel,
fp_tmp.as_deref(),
)
.ok()?;
if raw.is_empty() {
return None;
}
// 오디오가 있어야 하는데 비었으면 이중 출력이 폴백을 탄 것이다(복사 중인 파일 등).
// 여기서 포기하면 `sig_state=1`이 박혀 **영영** 오디오 없이 남으므로 한 번 더 시도한다.
// 파일을 한 번 더 읽지만, 원래 드문 경로이고 오디오만 디코드하므로 값싸다.
if has_audio && audio_raw.is_empty() {
audio_raw = crate::ffmpeg::audio_fingerprint(tools, path, timeout).unwrap_or_default();
if audio_raw.is_empty() {
tracing::debug!(file_id, "오디오가 있다고 표시됐지만 지문을 얻지 못했다");
}
}
let mut frames: Vec<(u32, u64, u64)> = Vec::with_capacity(raw.len().min(MAX_FRAMES));
let mut rep: Option<FrameSig> = None;
for (t_ms, px) in raw.iter().take(MAX_FRAMES) {
let Some(s) = sigmatch::derive(px) else { continue };
// 대표 프레임 = 처음 등장하는 '색인할 가치가 있는' 프레임 (검정 인트로 회피)
if rep.is_none() && s.indexable() {
rep = Some(s.clone());
}
// 세 번째 필드는 레터박스 트림 해시(`scales[2]`)다 — 오디오로 찾은 후보를 확인할 때
// 검정 띠를 붙인 사본과 원본을 맞추는 유일한 축이다(전체 축은 20~36까지 벌어진다).
frames.push((*t_ms, s.dhash, s.scales[2]));
}
// 전부 단색이라 대표를 못 골랐으면 첫 프레임으로 대체
if rep.is_none() {
rep = sigmatch::derive(&raw[0].1);
}
let rep = rep?;
// 정지 화면이 표를 몰아주지 않게 접고, 값 기반으로 앵커를 고른다.
// 짧은 영상은 표본이 적어 선택률을 낮추면 앵커가 0개가 되므로 전부 넣는다.
let dense = sigmatch::dedup_consecutive(&sigmatch::dhash_seq(&frames));
let rate = if dense.len() <= SHORT_FRAMES { 0 } else { ANCHOR_RATE_LOG2 };
let anchor_set: std::collections::HashSet<u32> =
sigmatch::anchors(&dense, rate).into_iter().map(|(t, _)| t).collect();
let anchors: Vec<(u32, u64, u64)> =
frames.iter().filter(|(t, _, _)| anchor_set.contains(t)).copied().collect();
Some(Computed {
rep,
frames,
anchors,
duration_ms,
// 영상은 그레이 프레임만 받으므로 색차가 없다. 영상에서 '색만 다른 사본'은
// 움직임까지 같아야 하는 드문 경우라, 색을 위해 RGB 파이프로 대역을 3배 쓰지 않는다.
color: None,
audio: audio_anchors(&audio_raw),
})
}
/// chromaprint 원시 시퀀스(약 8개/초)에서 **값 기반으로** 앵커를 골라 (t_ms, 값)으로 만든다.
///
/// 전부 저장하면 2시간짜리 영화가 230KB다. 그렇다고 n번째마다 고르면 시작 오프셋이 다른 사본이
/// 서로 다른 지점을 뽑아 하나도 안 겹친다 — 프레임 앵커와 같은 이유로 **값**으로 골라야 한다.
/// 선택률은 길이에 맞춰 낮추는데, 임계값을 낮춘 집합은 높인 집합의 부분집합이라
/// 길이가 다른 두 사본(원본 ↔ 잘라낸 조각)도 교집합이 남는다.
fn audio_anchors(fp: &[u32]) -> Vec<(u32, u32)> {
if fp.is_empty() {
return Vec::new();
}
// chromaprint는 초당 약 8.07개를 낸다(11025Hz·4096샘플·2/3 중첩). 첫 창을 채우느라
// 앞쪽 2.7초가 비어 30초 영상이 221개다 — 그 지연은 모든 파일에 똑같이 걸리므로
// 상대 시각으로 쓰는 한 상쇄된다(지금은 후보 생성에 값만 쓰고 시각은 쓰지 않는다).
const PER_SEC: u32 = 8;
let stamped: Vec<(u32, u64)> =
fp.iter().enumerate().map(|(i, v)| ((i as u32) * 1000 / PER_SEC, *v as u64)).collect();
// 목표 개수(≈512)를 넘지 않는 가장 낮은 선택률을 쓴다 — 0이면 전부
let mut rate = 0u32;
while rate < 6 && stamped.len() >> rate > AUDIO_ANCHOR_TARGET {
rate += 1;
}
sigmatch::anchors(&stamped, rate).into_iter().map(|(t, v)| (t, v as u32)).collect()
}
// ── 쓰기 (배치당 트랜잭션 1개) ────────────────────────────
fn write_batch(db: &Arc<Db>, rows: Vec<SigRow>) -> Result<(), archive_db::DbError> {
db.with_write(move |conn| {
let tx = conn.transaction()?;
{
let mut ins = tx.prepare_cached(
"INSERT OR REPLACE INTO file_sigs
(file_id, sig_version, kind, dhash, phash, orient, tiles, scales,
frames, keyframes, frame_count, duration_ms, stdev, flags, tile_flat, color,
audio)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,0,?14,?15,?16)",
)?;
let mut upd = tx.prepare_cached("UPDATE files SET sig_state = ?2 WHERE id = ?1")?;
for r in &rows {
match &r.sig {
Some(c) => {
ins.execute(rusqlite::params![
r.id,
sigmatch::SIG_VERSION,
r.kind,
c.rep.dhash as i64,
c.rep.phash as i64,
sigmatch::pack_u64s(&c.rep.orient),
sigmatch::pack_u64s(&c.rep.tiles),
sigmatch::pack_u64s(&c.rep.scales),
sigmatch::pack_frames(&c.frames),
sigmatch::pack_frames(&c.anchors),
c.frames.len() as i64,
c.duration_ms,
c.rep.stdev as f64,
c.rep.tile_flat as i64,
c.color.as_ref().map(sigmatch::pack_color),
(!c.audio.is_empty()).then(|| sigmatch::pack_audio(&c.audio)),
])?;
upd.execute(rusqlite::params![r.id, 1i64])?;
}
// 실패는 2로 표시해 무한 재시도를 막는다 (sig_version이 올라가면 다시 0이 된다)
None => {
upd.execute(rusqlite::params![r.id, 2i64])?;
}
}
}
}
tx.commit()
})
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> (tempfile::TempDir, Arc<Db>) {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dir.path().join("t.db")).unwrap());
db.with_write(|conn| {
conn.execute(
"INSERT INTO sources(id,kind,name,root) VALUES(1,'local','t','C:\\m')",
[],
)?;
conn.execute(
"INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'C:\\m','m')",
[],
)?;
Ok(())
})
.unwrap();
(dir, db)
}
/// 썸네일이 있는 이미지 → 지문 생성, 상태 1. 재실행 시 다시 하지 않는다.
#[test]
fn image_sigs_are_computed_and_resumable() {
let (dir, db) = setup();
let thumb_root = dir.path().join("thumbs");
// 파일 3개 + 그중 2개만 썸네일 준비
db.with_write(|conn| {
for i in 1..=3i64 {
conn.execute(
"INSERT INTO files(id,source_id,folder_id,name,ext,kind,size,mtime_ms,thumb_state)
VALUES(?1,1,1,?2,'jpg',0,100,0,?3)",
rusqlite::params![i, format!("f{i}.jpg"), if i <= 2 { 1 } else { 0 }],
)?;
}
for i in 1..=2i64 {
conn.execute(
"INSERT INTO thumbs(file_id,size_class,cache_key,bytes) VALUES(?1,0,?2,10)",
rusqlite::params![i, format!("{i:032x}")],
)?;
}
Ok(())
})
.unwrap();
// 실제 썸네일 JPEG 두 장. 좌→우 증가 램프와 감소 램프 — dHash가 서로 반대가 되어
// 다운샘플 후에도 확실히 구별된다(고주파 패턴은 평균화되어 같아질 수 있다).
for i in 1..=2u32 {
let p = crate::thumbq::thumb_path(&thumb_root, 0, &format!("{i:032x}"));
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
let img = image::DynamicImage::ImageLuma8(image::GrayImage::from_fn(64, 64, |x, _y| {
let v = (x * 4) as u8;
image::Luma([if i == 1 { v } else { 252u8.saturating_sub(v) }])
}));
img.save(&p).unwrap();
}
let (cancel, pause) = (AtomicBool::new(false), AtomicBool::new(false));
let (img_pending, _) = pending_counts(&db).unwrap();
assert_eq!(img_pending, 2, "썸네일 있는 것만 대기 대상");
let n = run_image_sigs(&db, &thumb_root, &cancel, &pause).unwrap();
assert_eq!(n, 2);
let (done, sigs): (i64, i64) = db
.with_read(|c| {
Ok((
c.query_row("SELECT count(*) FROM files WHERE sig_state=1", [], |r| r.get(0))?,
c.query_row("SELECT count(*) FROM file_sigs", [], |r| r.get(0))?,
))
})
.unwrap();
assert_eq!((done, sigs), (2, 2));
assert_eq!(pending_counts(&db).unwrap().0, 0, "대기가 비어야 한다");
// 재실행은 아무것도 하지 않는다 (재개·증분의 근거)
assert_eq!(run_image_sigs(&db, &thumb_root, &cancel, &pause).unwrap(), 0);
// 저장된 지문이 실제로 복원되는지
let (dh, orient, tiles): (i64, Vec<u8>, Vec<u8>) = db
.with_read(|c| {
c.query_row("SELECT dhash, orient, tiles FROM file_sigs WHERE file_id=1", [], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})
})
.unwrap();
assert_eq!(sigmatch::unpack_u64s(&orient).len(), 8);
assert_eq!(sigmatch::unpack_u64s(&tiles).len(), 16);
assert_eq!(sigmatch::unpack_u64s(&orient)[0], dh as u64, "orient[0]은 dhash와 같아야 한다");
// 서로 다른 그림이면 해시도 달라야 한다
let d2: i64 = db
.with_read(|c| c.query_row("SELECT dhash FROM file_sigs WHERE file_id=2", [], |r| r.get(0)))
.unwrap();
assert_ne!(dh, d2);
}
/// 디코딩 실패는 sig_state=2로 남아 무한 재시도되지 않는다.
#[test]
fn broken_thumbnail_is_marked_failed_not_retried() {
let (dir, db) = setup();
let thumb_root = dir.path().join("thumbs");
db.with_write(|conn| {
conn.execute(
"INSERT INTO files(id,source_id,folder_id,name,ext,kind,size,mtime_ms,thumb_state)
VALUES(1,1,1,'x.jpg','jpg',0,100,0,1)",
[],
)?;
conn.execute(
"INSERT INTO thumbs(file_id,size_class,cache_key,bytes) VALUES(1,0,'deadbeef',10)",
[],
)?;
Ok(())
})
.unwrap();
let p = crate::thumbq::thumb_path(&thumb_root, 0, "deadbeef");
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, b"not a jpeg").unwrap();
let (cancel, pause) = (AtomicBool::new(false), AtomicBool::new(false));
assert_eq!(run_image_sigs(&db, &thumb_root, &cancel, &pause).unwrap(), 1);
let state: i64 = db
.with_read(|c| c.query_row("SELECT sig_state FROM files WHERE id=1", [], |r| r.get(0)))
.unwrap();
assert_eq!(state, 2, "실패 표시");
assert_eq!(run_image_sigs(&db, &thumb_root, &cancel, &pause).unwrap(), 0, "재시도 안 함");
// 첫 호출은 기록이 없어 한 번 되돌린다 → 다시 돌려 실패 상태로 되돌려 놓는다
invalidate_stale_sigs(&db).unwrap();
assert_eq!(run_image_sigs(&db, &thumb_root, &cancel, &pause).unwrap(), 1);
assert_eq!(invalidate_stale_sigs(&db).unwrap(), 0, "같은 버전이면 실패를 건드리지 않는다");
// 지문 버전이 바뀌면 실패도 다시 시도한다 — 알고리즘이 바뀌면 결과가 달라질 수 있다
db.with_write(|c| {
c.execute("UPDATE settings SET value = '0' WHERE key = 'sig_version'", [])?;
Ok(())
})
.unwrap();
assert_eq!(invalidate_stale_sigs(&db).unwrap(), 1, "실패 행이 되돌려져야 한다");
assert_eq!(run_image_sigs(&db, &thumb_root, &cancel, &pause).unwrap(), 1, "다시 시도한다");
}
/// 지문 버전이 바뀐 행은 다시 계산 대기로 돌아가고, 그대로면 아무 일도 없다.
#[test]
fn stale_sig_version_is_invalidated_once() {
let (_dir, db) = setup();
db.with_write(|conn| {
conn.execute(
"INSERT INTO files(id,source_id,folder_id,name,ext,kind,size,mtime_ms,thumb_state,sig_state)
VALUES(1,1,1,'a.jpg','jpg',0,100,0,1,1),(2,1,1,'b.jpg','jpg',0,100,0,1,1)",
[],
)?;
// 1번은 옛 버전, 2번은 현재 버전
conn.execute(
"INSERT INTO file_sigs(file_id,sig_version,kind,dhash,phash) VALUES(1,?1,0,7,7),(2,?2,0,8,8)",
[sigmatch::SIG_VERSION - 1, sigmatch::SIG_VERSION],
)?;
Ok(())
})
.unwrap();
assert_eq!(invalidate_stale_sigs(&db).unwrap(), 1, "옛 버전 행만 되돌린다");
let pending: Vec<i64> = db
.with_read(|c| {
let mut s = c.prepare("SELECT id FROM files WHERE sig_state = 0 ORDER BY id")?;
let v = s.query_map([], |r| r.get(0))?.collect::<Result<Vec<i64>, _>>()?;
Ok(v)
})
.unwrap();
assert_eq!(pending, vec![1]);
// 두 번째 호출은 버전이 그대로라 질의도 하지 않는다
assert_eq!(invalidate_stale_sigs(&db).unwrap(), 0, "버전이 같으면 아무 일도 없다");
}
#[test]
fn cancel_stops_before_work() {
let (dir, db) = setup();
let cancel = AtomicBool::new(true);
let pause = AtomicBool::new(false);
assert_eq!(
run_image_sigs(&db, &dir.path().join("thumbs"), &cancel, &pause).unwrap(),
0
);
}
}
@@ -13,6 +13,64 @@ pub struct Tag {
pub file_count: i64,
}
/// 삭제 예정 표시용 시스템 태그 이름 — 사용자가 지우거나 이름을 바꿀 수 없다.
pub const TRASH_TAG_NAME: &str = "삭제 예정";
const TRASH_TAG_COLOR: &str = "#ff5f57";
/// 시스템 '삭제 예정' 태그 id (없으면 생성).
/// sort_order = -1은 시스템 태그 표시용 — 사이드바는 이 태그를 id로 골라 따로 고정 표시한다.
pub fn ensure_trash_tag(db: &Db) -> Result<i64> {
// 대부분의 호출은 이미 존재하는 경우 — 쓰기 락을 잡지 않도록 읽기 먼저.
let existing: Option<i64> = db.with_read(|conn| {
Ok(conn
.query_row("SELECT id FROM tags WHERE name = ?1", [TRASH_TAG_NAME], |r| {
r.get(0)
})
.ok())
})?;
if let Some(id) = existing {
return Ok(id);
}
db.with_write(|conn| {
conn.execute(
"INSERT INTO tags(name, color, sort_order) VALUES (?1, ?2, -1)
ON CONFLICT(name) DO UPDATE SET sort_order = -1",
rusqlite::params![TRASH_TAG_NAME, TRASH_TAG_COLOR],
)?;
conn.query_row("SELECT id FROM tags WHERE name = ?1", [TRASH_TAG_NAME], |r| {
r.get(0)
})
})
}
/// 삭제 예정으로 표시된 파일 id 목록 (인덱스에 살아 있는 것만).
pub fn pending_delete_ids(db: &Db) -> Result<Vec<i64>> {
let tag_id = ensure_trash_tag(db)?;
db.with_read(move |conn| {
let mut stmt = conn.prepare_cached(
"SELECT f.id FROM files f
JOIN file_tags ft ON ft.file_id = f.id
WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL",
)?;
let rows = stmt.query_map([tag_id], |r| r.get(0))?;
rows.collect()
})
}
/// 비우기 확인창용 — (개수, 총 바이트).
pub fn pending_delete_stats(db: &Db) -> Result<(i64, i64)> {
let tag_id = ensure_trash_tag(db)?;
db.with_read(move |conn| {
conn.query_row(
"SELECT count(*), COALESCE(sum(f.size), 0) FROM files f
JOIN file_tags ft ON ft.file_id = f.id
WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL",
[tag_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
})
}
pub fn list_tags(db: &Db) -> Result<Vec<Tag>> {
db.with_read(|conn| {
let mut stmt = conn.prepare_cached(
@@ -113,6 +171,47 @@ pub fn unassign(db: &Db, batch_id: &str, file_ids: Vec<i64>, tag_id: i64) -> Res
})
}
/// 그리드 썸네일 오버레이용 — 여러 파일의 태그를 한 번에 읽는다.
/// 가시 범위(수백 개)를 파일당 쿼리로 도는 것을 피하기 위한 배치 조회.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTagLink {
pub file_id: i64,
pub tag_id: i64,
pub name: String,
pub color: Option<String>,
}
pub fn file_tags_bulk(db: &Db, file_ids: Vec<i64>) -> Result<Vec<FileTagLink>> {
if file_ids.is_empty() {
return Ok(Vec::new());
}
db.with_read(move |conn| {
// i64만 넣으므로 리터럴 IN 절은 주입 안전
let list = file_ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT ft.file_id, t.id, t.name, t.color FROM file_tags ft
JOIN tags t ON t.id = ft.tag_id
WHERE ft.file_id IN ({list})
ORDER BY ft.file_id, t.sort_order, t.name"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], |r| {
Ok(FileTagLink {
file_id: r.get(0)?,
tag_id: r.get(1)?,
name: r.get(2)?,
color: r.get(3)?,
})
})?;
rows.collect()
})
}
pub fn file_tags(db: &Db, file_id: i64) -> Result<Vec<Tag>> {
db.with_read(|conn| {
let mut stmt = conn.prepare_cached(
@@ -169,6 +268,143 @@ pub fn export_json(db: &Db) -> Result<String> {
Ok(serde_json::to_string_pretty(&exports).unwrap_or_else(|_| "[]".into()))
}
// ── 태그 세트 ─────────────────────────────────────────────
//
// 자주 함께 붙이는 태그를 묶어 한 번에 적용한다. 세트는 **태그를 참조**할 뿐 복사하지
// 않는다 — 태그 이름을 바꾸면 세트에도 그대로 반영되고, 태그를 지우면 세트에서 빠진다.
#[derive(Debug, Clone, serde::Serialize)]
pub struct TagSet {
pub id: i64,
pub name: String,
/// 예: "1", "Ctrl+2", "F5". 없으면 클릭으로만 쓴다.
pub hotkey: Option<String>,
/// 이 세트에 담긴 태그 (이름·색까지 — 화면이 칩으로 그린다)
pub tags: Vec<Tag>,
}
pub fn list_tag_sets(db: &Db) -> Result<Vec<TagSet>> {
db.with_read(|conn| {
let mut sets: Vec<TagSet> = conn
.prepare("SELECT id, name, hotkey FROM tag_sets ORDER BY sort_order, name")?
.query_map([], |r| {
Ok(TagSet { id: r.get(0)?, name: r.get(1)?, hotkey: r.get(2)?, tags: Vec::new() })
})?
.collect::<rusqlite::Result<_>>()?;
// 세트 수는 많아야 수십 개다 — 한 번에 읽어 메모리에서 묶는다(N+1 질의 회피)
let mut stmt = conn.prepare(
"SELECT i.set_id, t.id, t.name, t.color
FROM tag_set_items i JOIN tags t ON t.id = i.tag_id
ORDER BY t.sort_order, t.name",
)?;
let rows: Vec<(i64, Tag)> = stmt
.query_map([], |r| {
Ok((
r.get(0)?,
Tag { id: r.get(1)?, name: r.get(2)?, color: r.get(3)?, file_count: 0 },
))
})?
.collect::<rusqlite::Result<_>>()?;
for (set_id, tag) in rows {
if let Some(s) = sets.iter_mut().find(|s| s.id == set_id) {
s.tags.push(tag);
}
}
Ok(sets)
})
}
pub fn create_tag_set(db: &Db, name: String, tag_ids: Vec<i64>) -> Result<i64> {
db.with_write(move |conn| {
let tx = conn.transaction()?;
tx.execute("INSERT INTO tag_sets(name) VALUES (?1)", [&name])?;
let id = tx.last_insert_rowid();
{
let mut ins = tx.prepare_cached(
"INSERT OR IGNORE INTO tag_set_items(set_id, tag_id) VALUES (?1, ?2)",
)?;
for t in &tag_ids {
ins.execute(rusqlite::params![id, t])?;
}
}
tx.commit()?;
Ok(id)
})
}
/// 세트의 이름·단축키·구성 태그를 통째로 바꾼다. `tag_ids`가 `None`이면 구성은 그대로 둔다.
pub fn update_tag_set(
db: &Db,
set_id: i64,
name: Option<String>,
hotkey: Option<Option<String>>,
tag_ids: Option<Vec<i64>>,
) -> Result<()> {
db.with_write(move |conn| {
let tx = conn.transaction()?;
if let Some(n) = &name {
tx.execute("UPDATE tag_sets SET name = ?2 WHERE id = ?1", rusqlite::params![set_id, n])?;
}
if let Some(h) = &hotkey {
// 같은 단축키를 다른 세트가 쓰고 있으면 그쪽을 비운다 — 하나의 키가 두 곳을
// 가리키면 어느 것이 도는지 예측할 수 없다.
if let Some(k) = h {
tx.execute("UPDATE tag_sets SET hotkey = NULL WHERE hotkey = ?1", [k])?;
}
tx.execute("UPDATE tag_sets SET hotkey = ?2 WHERE id = ?1", rusqlite::params![set_id, h])?;
}
if let Some(ids) = &tag_ids {
tx.execute("DELETE FROM tag_set_items WHERE set_id = ?1", [set_id])?;
let mut ins = tx.prepare_cached(
"INSERT OR IGNORE INTO tag_set_items(set_id, tag_id) VALUES (?1, ?2)",
)?;
for t in ids {
ins.execute(rusqlite::params![set_id, t])?;
}
}
tx.commit()?;
Ok(())
})
}
pub fn delete_tag_set(db: &Db, set_id: i64) -> Result<()> {
db.with_write(move |conn| {
conn.execute("DELETE FROM tag_sets WHERE id = ?1", [set_id])?;
Ok(())
})
}
/// 세트에 담긴 태그를 파일들에 **한 번에** 붙인다. 반환: 새로 붙은 (파일,태그) 쌍 수.
/// 개별 `assign`과 같은 저널을 남기므로 실행 취소도 같은 방식으로 동작한다.
pub fn apply_tag_set(db: &Db, batch_id: &str, file_ids: Vec<i64>, set_id: i64) -> Result<u64> {
let batch_id = batch_id.to_string();
db.with_write(move |conn| {
let tx = conn.transaction()?;
let tag_ids: Vec<i64> = tx
.prepare("SELECT tag_id FROM tag_set_items WHERE set_id = ?1")?
.query_map([set_id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
let mut added = 0u64;
{
let mut ins = tx
.prepare_cached("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)")?;
let mut journal = tx.prepare_cached(
"INSERT INTO ops_journal(batch_id, op, file_id, tag_id) VALUES (?1, 'tag', ?2, ?3)",
)?;
for fid in &file_ids {
for tid in &tag_ids {
if ins.execute(rusqlite::params![fid, tid])? > 0 {
journal.execute(rusqlite::params![batch_id, fid, tid])?;
added += 1;
}
}
}
}
tx.commit()?;
Ok(added)
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -224,6 +460,54 @@ mod tests {
assert_eq!(journal_count, 4); // tag 3 + untag 1
}
#[test]
fn tag_set_applies_all_members_at_once() {
let (_d, db, ids) = setup();
let a = create_tag(&db, "여행".into(), None).unwrap();
let b = create_tag(&db, "2026".into(), None).unwrap();
let set = create_tag_set(&db, "여행세트".into(), vec![a, b]).unwrap();
// 파일 2개 × 태그 2개 = 4쌍
let n = apply_tag_set(&db, "b1", vec![ids[0], ids[1]], set).unwrap();
assert_eq!(n, 4);
let names: Vec<String> = file_tags(&db, ids[0]).unwrap().into_iter().map(|t| t.name).collect();
assert!(names.contains(&"여행".to_string()) && names.contains(&"2026".to_string()));
// 다시 적용해도 중복되지 않는다 (INSERT OR IGNORE)
assert_eq!(apply_tag_set(&db, "b2", vec![ids[0]], set).unwrap(), 0);
// 세트는 태그를 **참조**한다 — 이름을 바꾸면 세트 목록에도 그대로 반영된다
rename_tag(&db, a, "국내여행".into()).unwrap();
let sets = list_tag_sets(&db).unwrap();
assert!(sets[0].tags.iter().any(|t| t.name == "국내여행"));
// 태그를 지우면 세트에서도 빠진다 (CASCADE)
delete_tag(&db, b).unwrap();
assert_eq!(list_tag_sets(&db).unwrap()[0].tags.len(), 1);
}
/// 같은 단축키를 두 세트가 가지면 어느 것이 도는지 예측할 수 없다 — 뒤에 지정한 쪽이 이긴다.
#[test]
fn hotkey_is_unique_across_sets() {
let (_d, db, _ids) = setup();
let s1 = create_tag_set(&db, "세트1".into(), vec![]).unwrap();
let s2 = create_tag_set(&db, "세트2".into(), vec![]).unwrap();
update_tag_set(&db, s1, None, Some(Some("1".into())), None).unwrap();
update_tag_set(&db, s2, None, Some(Some("1".into())), None).unwrap();
let sets = list_tag_sets(&db).unwrap();
let by = |id: i64| sets.iter().find(|s| s.id == id).unwrap().hotkey.clone();
assert_eq!(by(s1), None, "먼저 쓰던 쪽이 비워져야 한다");
assert_eq!(by(s2), Some("1".into()));
// 수식키가 붙은 조합도 그대로 왕복해야 한다 (화면은 "Ctrl+1"인데 DB에 "1"만
// 들어가면 단축키가 다른 키를 가리키게 된다)
update_tag_set(&db, s1, None, Some(Some("Ctrl+Shift+F2".into())), None).unwrap();
let sets = list_tag_sets(&db).unwrap();
let got = sets.iter().find(|s| s.id == s1).unwrap().hotkey.clone();
assert_eq!(got, Some("Ctrl+Shift+F2".into()));
}
#[test]
fn export_contains_paths() {
let (_d, db, ids) = setup();
+408 -52
View File
@@ -6,45 +6,195 @@ use archive_db::Db;
use crossbeam_channel::{unbounded, Receiver, RecvTimeoutError, Sender};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::time::Duration;
pub struct ThumbQueue {
hi_tx: Sender<i64>,
lo_tx: Sender<i64>,
ctx: Arc<Ctx>,
/// 피더 세대 — 새 피더가 시작되면 이전 피더는 스스로 물러난다
feed_gen: Arc<AtomicU64>,
}
/// ── 동시 처리 게이트 ────────────────────────────────────────────────────────
/// 디스크 특성에 맞는 동시 읽기 개수는 미리 알 수 없다: NVMe는 코어 수만큼,
/// HDD는 1–2개(그 이상은 탐색 스래싱), SMB는 지연을 가리려면 8–16개가 유리하다.
/// 드라이브 종류를 OS API로 캐묻는 대신 **처리율을 측정해서** 올렸다 내렸다 한다
/// (혼합 소스·USB·iSCSI 같은 애매한 경우까지 한 방법으로 처리된다).
///
/// 토큰 1개 = 동시 실행 1칸. 워커는 처리 직전에 토큰을 받고 끝나면 돌려준다.
struct Gate {
tx: Sender<()>,
rx: Receiver<()>,
/// 현재 유통 중인 토큰 수
level: AtomicUsize,
max: usize,
}
/// 토큰 반납은 Drop에 맡긴다 — 워커 루프에 조기 탈출/패닉이 있어도 새지 않는다.
struct Permit<'a> {
tx: &'a Sender<()>,
}
impl Drop for Permit<'_> {
fn drop(&mut self) {
let _ = self.tx.send(());
}
}
impl Gate {
fn new(initial: usize, max: usize) -> Gate {
let max = max.max(1);
let initial = initial.clamp(1, max);
let (tx, rx) = unbounded::<()>();
for _ in 0..initial {
let _ = tx.send(());
}
Gate {
tx,
rx,
level: AtomicUsize::new(initial),
max,
}
}
fn level(&self) -> usize {
self.level.load(Ordering::Relaxed)
}
fn acquire(&self) -> Permit<'_> {
let _ = self.rx.recv(); // tx가 Ctx 안에 살아 있으므로 끊기지 않는다
Permit { tx: &self.tx }
}
/// 동시 실행 칸을 `n`으로 맞춘다. 줄일 때는 **비어 있는** 토큰만 회수하므로
/// 이미 일하는 워커를 중단시키지 않는다(회수 못 한 만큼은 다음 조정에서 다시 시도).
fn set(&self, n: usize) {
let n = n.clamp(1, self.max);
let cur = self.level.load(Ordering::Relaxed);
if n > cur {
for _ in cur..n {
let _ = self.tx.send(());
}
self.level.store(n, Ordering::Relaxed);
} else if n < cur {
let mut removed = 0;
while removed < cur - n {
if self.rx.try_recv().is_ok() {
removed += 1;
} else {
break;
}
}
self.level.store(cur - removed, Ordering::Relaxed);
}
}
}
/// 한 파일의 썸네일 처리 결과. 워커가 DB를 직접 건드리지 않고 이걸 보낸다.
struct ThumbWrite {
id: i64,
/// 성공이면 (cache_key, w, h, bytes), 실패면 None
made: Option<(String, u32, u32, i64)>,
}
/// 쓰기 합치기 한 묶음의 상한. 100만 건 × 트랜잭션 1개였던 것을 이 크기로 나눈다.
const WRITE_BATCH: usize = 200;
/// 묶음이 안 차도 이 시간이 지나면 커밋한다 — 뷰포트 썸네일이 늦게 보이지 않게.
const WRITE_FLUSH_MS: u64 = 100;
/// 게이트 조정 주기. 파일 크기가 들쭉날쭉해서 표본이 짧으면 잡음이 크다.
const TUNE_WINDOW: Duration = Duration::from_secs(5);
/// 이 비율 미만의 변화는 잡음으로 보고 현 수준을 유지한다.
const TUNE_DEADBAND: f64 = 0.15;
/// 피더가 한 번에 읽는 행 수 / 채널에 유지하는 백로그 상한.
const FEED_PAGE: usize = 2_000;
const FEED_HIGH: usize = 8_000;
/// 피더 읽기 실패 재시도 횟수. 시작 직후 WAL 복구·리더 경합으로 한두 번 실패할 수 있는데,
/// 그때 물러나면 백필이 통째로 사라진다. 지수적으로 늦추며 이 횟수까지 버틴다.
const FEED_MAX_RETRIES: u32 = 5;
struct Ctx {
db: Arc<Db>,
thumb_root: PathBuf,
tools: Option<crate::ffmpeg::FfTools>,
in_flight: Mutex<HashSet<i64>>,
done_tx: Sender<i64>,
/// 결과를 쓰기 합치기 스레드로 보낸다 (워커는 writer를 기다리지 않는다)
write_tx: Sender<ThumbWrite>,
gate: Gate,
/// 처리 시도 누적 — 게이트 자동조정의 유일한 입력
processed: AtomicUsize,
}
impl ThumbQueue {
/// 워커 풀을 시작한다. `on_done`은 완료된 file_id 배치를 받는다.
/// `tools`가 None이면 영상 썸네일은 건너뛴다 (이미지는 정상 처리).
///
/// `max_workers`는 스레드 수이자 동시 처리 상한이다. 실제 동시 처리 개수는
/// 처리율을 보며 1..=max_workers 사이에서 자동으로 오르내린다([`Gate`]).
pub fn start(
db: Arc<Db>,
thumb_root: PathBuf,
tools: Option<crate::ffmpeg::FfTools>,
workers: usize,
max_workers: usize,
on_done: impl Fn(Vec<i64>) + Send + 'static,
) -> ThumbQueue {
let (hi_tx, hi_rx) = unbounded::<i64>();
let (lo_tx, lo_rx) = unbounded::<i64>();
let (write_tx, write_rx) = unbounded::<ThumbWrite>();
let (done_tx, done_rx) = unbounded::<i64>();
let max_workers = max_workers.max(1);
let ctx = Arc::new(Ctx {
db,
thumb_root,
tools,
in_flight: Mutex::new(HashSet::new()),
done_tx,
write_tx,
// 보수적으로 시작한다 — 낮게 시작해서 올리는 편이, 높게 시작해서
// HDD를 스래싱시키며 내려오는 것보다 사용자 체감이 낫다.
gate: Gate::new(4.min(max_workers), max_workers),
processed: AtomicUsize::new(0),
});
for i in 0..workers.max(1) {
// ── 쓰기 합치기 ────────────────────────────────────────────────────
// 예전에는 워커가 파일당 트랜잭션 1개를 직접 커밋했다. 모든 쓰기는 단일 writer
// 스레드에 **동기 왕복**하므로, 100만 건이면 왕복 100만 번이 되고 그 사이 UI에서
// 태그를 붙이는 쓰기가 뒤에 줄을 선다. 여기서 200건/100ms로 묶어 한 트랜잭션에 쓴다.
//
// 순서 보장: 커밋이 끝난 **뒤에** 완료 id를 이미터로 넘긴다. 반대로 하면
// 미디어 서버가 아직 없는 thumbs 행을 조회해 이미지가 깨진다.
{
let db = ctx.db.clone();
let done_tx = done_tx.clone();
std::thread::Builder::new()
.name("thumb-writer".into())
.spawn(move || {
crate::prio::set_current_thread_low();
let mut batch: Vec<ThumbWrite> = Vec::with_capacity(WRITE_BATCH);
loop {
let closed = match write_rx.recv_timeout(Duration::from_millis(WRITE_FLUSH_MS)) {
Ok(w) => {
batch.push(w);
false
}
Err(RecvTimeoutError::Timeout) => false,
Err(RecvTimeoutError::Disconnected) => true,
};
if batch.len() >= WRITE_BATCH || (closed && !batch.is_empty()) || (!batch.is_empty() && write_rx.is_empty())
{
flush_writes(&db, std::mem::take(&mut batch), &done_tx);
}
if closed {
break;
}
}
})
.expect("썸네일 쓰기 스레드 생성 실패");
}
for i in 0..max_workers {
let ctx = ctx.clone();
let hi_rx = hi_rx.clone();
let lo_rx = lo_rx.clone();
@@ -54,6 +204,16 @@ impl ThumbQueue {
.expect("썸네일 워커 생성 실패");
}
// 게이트 자동조정 — Weak로 잡아 큐가 사라지면 스스로 끝난다
// (Arc를 들고 있으면 write_tx가 안 죽어 쓰기 스레드가 영원히 남는다).
{
let weak = Arc::downgrade(&ctx);
std::thread::Builder::new()
.name("thumb-tune".into())
.spawn(move || tuner_loop(weak))
.ok();
}
// 완료 배치 이미터
std::thread::Builder::new()
.name("thumb-emit".into())
@@ -83,7 +243,17 @@ impl ThumbQueue {
})
.expect("썸네일 이미터 생성 실패");
ThumbQueue { hi_tx, lo_tx }
ThumbQueue {
hi_tx,
lo_tx,
ctx,
feed_gen: Arc::new(AtomicU64::new(0)),
}
}
/// 현재 동시 처리 개수 (상태바·디버그용).
pub fn concurrency(&self) -> usize {
self.ctx.gate.level()
}
/// 뷰포트 우선 생성 요청 (프론트가 스크롤 시 호출).
@@ -93,28 +263,135 @@ impl ThumbQueue {
}
}
/// 대기 중인 이미지 전체를 백그라운드 밴드에 넣는다.
pub fn enqueue_pending(&self, db: &Db, source_id: Option<i64>) {
let ids: Vec<i64> = db
.with_read(|conn| match source_id {
Some(sid) => {
let mut stmt = conn.prepare_cached(
"SELECT id FROM files WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL AND source_id=?1",
)?;
let rows = stmt.query_map([sid], |r| r.get(0))?;
rows.collect()
}
None => {
let mut stmt = conn.prepare_cached(
"SELECT id FROM files WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL",
)?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.collect()
/// 대기 중인 항목을 백그라운드 밴드에 흘려보낸다. 즉시 반환(전용 피더 스레드).
///
/// 예전에는 대기 id를 **전부** 한 Vec으로 읽어 채널에 밀어넣었다. 100만 건이면
/// 읽는 동안 리더 커넥션을 붙잡고, 채널에 수십 MB가 상주하고, 그 사이 스크롤로
/// 요청된 뷰포트 썸네일은 100만 개 뒤에서 기다린다. id 커서로 2000행씩 끊어
/// 백로그를 8000 이하로 유지하면 상주 메모리도, 우선순위 반전도 사라진다.
pub fn enqueue_pending(&self, source_id: Option<i64>) {
let generation = self.feed_gen.fetch_add(1, Ordering::SeqCst) + 1;
let feed_gen = self.feed_gen.clone();
let db = self.ctx.db.clone();
let lo_tx = self.lo_tx.clone();
std::thread::Builder::new()
.name("thumb-feed".into())
.spawn(move || {
crate::prio::set_current_thread_low();
// 스캔이 끝날 때마다 호출되므로(워처는 1분마다 재스캔) 피더가 겹칠 수 있다.
// 세대가 바뀌면(= 더 최신 호출이 있었다) 이전 피더는 조용히 물러난다.
let alive = || feed_gen.load(Ordering::SeqCst) == generation;
let mut cursor = 0i64;
let mut retries = 0u32;
loop {
if !alive() {
return;
}
while lo_tx.len() >= FEED_HIGH {
std::thread::sleep(Duration::from_millis(200));
if !alive() {
return;
}
}
let page: Result<Vec<i64>, _> = db
.with_read(|conn| match source_id {
Some(sid) => {
let mut stmt = conn.prepare_cached(
"SELECT id FROM files
WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL
AND source_id=?1 AND id > ?2
ORDER BY id LIMIT ?3",
)?;
let rows = stmt.query_map(
rusqlite::params![sid, cursor, FEED_PAGE as i64],
|r| r.get(0),
)?;
rows.collect()
}
None => {
let mut stmt = conn.prepare_cached(
"SELECT id FROM files
WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL
AND id > ?1
ORDER BY id LIMIT ?2",
)?;
let rows = stmt.query_map(
rusqlite::params![cursor, FEED_PAGE as i64],
|r| r.get(0),
)?;
rows.collect()
}
});
// 읽기 실패와 '더 없음'을 구분한다. 예전에는 둘 다 빈 페이지가 되어 피더가
// 조용히 끝났다 — 앱을 막 켠 직후(하드 종료 뒤 WAL 복구·리더 경합)에 한 번
// 실패하면 **다음 스캔이 있을 때까지 백필이 영영 안 도는** 경로였다.
// 사용자에게는 "이어서 진행이 안 된다"로 보인다.
let page = match page {
Ok(p) => {
retries = 0;
p
}
Err(e) => {
retries += 1;
if retries > FEED_MAX_RETRIES {
tracing::warn!(error = %e, "썸네일 백필 피더 중단 — 읽기 실패 반복");
return;
}
tracing::debug!(error = %e, retries, "썸네일 백필 읽기 실패 — 재시도");
std::thread::sleep(Duration::from_millis(500 * retries as u64));
continue;
}
};
let Some(&last) = page.last() else { break };
cursor = last;
for id in page {
let _ = lo_tx.send(id);
}
}
})
.unwrap_or_default();
for id in ids {
let _ = self.lo_tx.send(id);
.ok();
}
}
/// 처리율을 보며 동시 처리 개수를 언덕 오르기로 맞춘다.
/// 올려서 빨라지면 계속 올리고, 느려지면 방향을 뒤집는다. 평지면 그 자리에 머문다.
fn tuner_loop(weak: Weak<Ctx>) {
crate::prio::set_current_thread_low();
let mut dir: isize = 1;
let mut prev_rate = 0.0f64;
let mut prev_count = 0usize;
loop {
std::thread::sleep(TUNE_WINDOW);
let Some(ctx) = weak.upgrade() else { break };
let total = ctx.processed.load(Ordering::Relaxed);
let n = total.saturating_sub(prev_count);
prev_count = total;
// 표본이 적으면(대기열이 비었거나 대형 영상 하나를 붙잡고 있는 중) 판단하지 않는다.
// 0을 '느려졌다'로 읽으면 게이트가 1까지 흘러내려 다음 작업이 느리게 시작한다.
if n < 8 {
prev_rate = 0.0;
continue;
}
let rate = n as f64 / TUNE_WINDOW.as_secs_f64();
if prev_rate <= 0.0 {
prev_rate = rate; // 기준선만 잡고 다음 창에서 비교
continue;
}
let change = (rate - prev_rate) / prev_rate;
prev_rate = rate;
if change.abs() <= TUNE_DEADBAND {
continue; // 평지 — 지금 수준이 이 디스크의 적정선
}
if change < 0.0 {
dir = -dir; // 직전 이동이 손해였다 → 되돌린다
}
let cur = ctx.gate.level();
// 하한 2 — 1로 내려가면 큰 영상 하나(ffmpeg 프레임 추출)가 백필 전체를 붙잡는다.
let floor = 2.min(ctx.gate.max) as isize;
let next = (cur as isize + dir).clamp(floor, ctx.gate.max as isize) as usize;
if next != cur {
ctx.gate.set(next);
tracing::debug!(from = cur, to = next, rate, "썸네일 동시 처리 조정");
}
}
}
@@ -132,21 +409,61 @@ pub fn thumb_path(thumb_root: &std::path::Path, size_class: u8, key: &str) -> Pa
.join(format!("{key}.jpg"))
}
/// 한 묶음을 트랜잭션 하나로 커밋하고, 성공한 id만 이미터로 넘긴다.
/// 커밋이 실패하면 아무 id도 넘기지 않는다 — 없는 썸네일을 있다고 알리지 않기 위해.
fn flush_writes(db: &Db, batch: Vec<ThumbWrite>, done_tx: &Sender<i64>) {
if batch.is_empty() {
return;
}
let ok_ids: Vec<i64> = batch.iter().filter(|w| w.made.is_some()).map(|w| w.id).collect();
let result = db.with_write(move |conn| {
let tx = conn.transaction()?;
{
let mut ins = tx.prepare_cached(
"INSERT OR REPLACE INTO thumbs(file_id, size_class, cache_key, width, height, bytes)
VALUES (?1, 0, ?2, ?3, ?4, ?5)",
)?;
let mut upd = tx.prepare_cached("UPDATE files SET thumb_state = ?2 WHERE id = ?1")?;
for w in &batch {
match &w.made {
Some((key, dw, dh, bytes)) => {
ins.execute(rusqlite::params![w.id, key, dw, dh, bytes])?;
upd.execute(rusqlite::params![w.id, 1i64])?;
}
// 실패는 2로 남겨 무한 재시도를 막는다 (캐시 퍼지가 0으로 되돌린다)
None => {
upd.execute(rusqlite::params![w.id, 2i64])?;
}
}
}
}
tx.commit()
});
match result {
Ok(()) => {
for id in ok_ids {
let _ = done_tx.send(id);
}
}
Err(e) => tracing::warn!(count = ok_ids.len(), "썸네일 상태 쓰기 실패: {e}"),
}
}
fn worker_loop(ctx: Arc<Ctx>, hi_rx: Receiver<i64>, lo_rx: Receiver<i64>) {
// 썸네일 디코드/리사이즈는 CPU 집약적 — UI 스레드가 굶지 않도록 우선순위를 낮춘다.
crate::prio::set_current_thread_low();
loop {
// hi 우선, 없으면 lo를 짧게 대기
let id = match hi_rx.try_recv() {
Ok(id) => id,
let (id, urgent) = match hi_rx.try_recv() {
Ok(id) => (id, true),
Err(_) => match lo_rx.recv_timeout(Duration::from_millis(200)) {
Ok(id) => id,
Ok(id) => (id, false),
Err(RecvTimeoutError::Timeout) => {
// hi 채널이 닫혔으면 종료
if hi_rx.is_empty() && lo_rx.is_empty() && hi_rx.try_recv().is_err() {
match hi_rx.recv_timeout(Duration::from_millis(1)) {
Err(RecvTimeoutError::Disconnected) => break,
Ok(id) => id,
Ok(id) => (id, true),
Err(RecvTimeoutError::Timeout) => continue,
}
} else {
@@ -160,11 +477,20 @@ fn worker_loop(ctx: Arc<Ctx>, hi_rx: Receiver<i64>, lo_rx: Receiver<i64>) {
if !ctx.in_flight.lock().unwrap().insert(id) {
continue; // 다른 워커가 처리 중
}
let result = process_one(&ctx, id);
ctx.in_flight.lock().unwrap().remove(&id);
if let Ok(true) = result {
let _ = ctx.done_tx.send(id);
if urgent {
// 뷰포트 요청(hi)은 게이트를 통과하지 않는다. 사용자가 지금 보고 있는 화면이고
// 한 화면 분량뿐이라 디스크를 흔들지 않는다. 게이트는 대량 백필을 위한 것이다.
let _ = process_one(&ctx, id);
} else {
// 게이트 통과 후에만 실제 읽기/디코드를 한다. 스레드는 max_workers개지만
// 동시에 디스크를 때리는 건 게이트가 허용한 만큼 — 나머지는 여기서 잔다.
let _permit = ctx.gate.acquire();
let _ = process_one(&ctx, id);
}
ctx.processed.fetch_add(1, Ordering::Relaxed);
ctx.in_flight.lock().unwrap().remove(&id);
// 완료 통지는 쓰기 합치기 스레드가 **커밋 후에** 보낸다 —
// 여기서 보내면 thumbs 행이 아직 없는 상태로 UI가 이미지를 요청한다.
}
}
@@ -258,24 +584,19 @@ fn process_one(ctx: &Ctx, id: i64) -> Result<bool, archive_db::DbError> {
}
};
// DB 쓰기는 쓰기 합치기 스레드가 한다 — 워커는 결과만 넘기고 다음 파일로 간다.
// (파일당 with_write는 단일 writer에 동기 왕복이라 워커 처리량이 writer에 묶였다)
match generated {
Some((dw, dh)) => {
let bytes = std::fs::metadata(&dst).map(|m| m.len() as i64).unwrap_or(0);
ctx.db.with_write(move |conn| {
let tx = conn.transaction()?;
tx.execute(
"INSERT OR REPLACE INTO thumbs(file_id, size_class, cache_key, width, height, bytes)
VALUES (?1, 0, ?2, ?3, ?4, ?5)",
rusqlite::params![id, key, dw, dh, bytes],
)?;
tx.execute("UPDATE files SET thumb_state = 1 WHERE id = ?1", [id])?;
tx.commit()
})?;
let _ = ctx.write_tx.send(ThumbWrite {
id,
made: Some((key, dw, dh, bytes)),
});
Ok(true)
}
None => {
ctx.db
.with_write(move |conn| conn.execute("UPDATE files SET thumb_state = 2 WHERE id = ?1", [id]))?;
let _ = ctx.write_tx.send(ThumbWrite { id, made: None });
Ok(false)
}
}
@@ -286,6 +607,35 @@ mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn gate_grows_and_shrinks_without_losing_tokens() {
let gate = Gate::new(2, 8);
assert_eq!(gate.level(), 2);
gate.set(6);
assert_eq!(gate.level(), 6);
assert_eq!(gate.rx.len(), 6, "유통 토큰 = 대기 토큰(아무도 안 잡고 있음)");
gate.set(3);
assert_eq!(gate.level(), 3);
assert_eq!(gate.rx.len(), 3);
// 상한/하한 클램프
gate.set(99);
assert_eq!(gate.level(), 8);
gate.set(0);
assert_eq!(gate.level(), 1);
// 잡고 있는 토큰은 회수되지 않는다 — 일하는 워커를 끊지 않기 위해
gate.set(4);
let held = gate.acquire();
assert_eq!(gate.rx.len(), 3);
gate.set(1);
assert_eq!(gate.level(), 1, "비어 있던 3개만 회수");
drop(held); // 반납되면 유통량은 다시 늘어난다(다음 조정에서 정리)
assert_eq!(gate.rx.len(), 1);
}
#[test]
fn generates_thumbs_for_scanned_images() {
let dbdir = tempfile::tempdir().unwrap();
@@ -311,17 +661,23 @@ mod tests {
let cancel = std::sync::atomic::AtomicBool::new(false);
crate::pipeline::scan_source(&db, sid, media.path(), &cancel, |_| {}).unwrap();
// 완료를 **이벤트로** 기다린다. 폴링+짧은 마감은 부하가 걸리면 흔들린다 —
// 썸네일 워커는 BELOW_NORMAL이라 다른 테스트가 코어를 차지하면 뒤로 밀린다
// (전체 스위트 병렬 실행에서 실제로 한 번 마감을 넘겼다).
let done_count = Arc::new(AtomicUsize::new(0));
let (tx, rx) = unbounded::<usize>();
let dc = done_count.clone();
let q = ThumbQueue::start(db.clone(), thumb_root.path().to_path_buf(), None, 2, move |ids| {
dc.fetch_add(ids.len(), Ordering::SeqCst);
let n = dc.fetch_add(ids.len(), Ordering::SeqCst) + ids.len();
let _ = tx.send(n);
});
q.enqueue_pending(&db, Some(sid));
q.enqueue_pending(Some(sid));
// 완료 대기 (최대 10초)
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while done_count.load(Ordering::SeqCst) < 5 && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
// 마감은 '멈춤 감지'용 안전장치일 뿐이다 — 정상 경로는 알림을 받는 즉시 끝난다
while done_count.load(Ordering::SeqCst) < 5 {
if rx.recv_timeout(Duration::from_secs(60)).is_err() {
break; // 60초 동안 아무 진전도 없으면 정말 멈춘 것이다
}
}
assert_eq!(done_count.load(Ordering::SeqCst), 5);
+157 -13
View File
@@ -23,14 +23,28 @@ pub const GRID_LONG_EDGE: u32 = 256;
pub const PREVIEW_LONG_EDGE: u32 = 1280;
pub const JPEG_QUALITY: u8 = 80;
/// 파일 전체를 메모리에 올려 처리할 상한. 이보다 크면 스트리밍 경로로 폴백한다.
/// (한 워커가 이만큼 쓰고, 워커는 최대 16개 → 최악 512MB. 실제로는 대부분 수 MB)
const INMEM_MAX: u64 = 32 * 1024 * 1024;
/// EXIF Orientation 값(18)을 읽는다. 없으면 1(정상).
pub fn read_orientation(path: &Path) -> u32 {
let Ok(file) = std::fs::File::open(path) else {
return 1;
};
let mut reader = std::io::BufReader::new(&file);
orientation_from(&mut reader)
}
/// 이미 메모리에 있는 바이트에서 orientation을 읽는다 (파일을 다시 열지 않기 위해).
fn orientation_from_bytes(bytes: &[u8]) -> u32 {
let mut cur = std::io::Cursor::new(bytes);
orientation_from(&mut cur)
}
fn orientation_from<R: std::io::BufRead + std::io::Seek>(r: &mut R) -> u32 {
exif::Reader::new()
.read_from_container(&mut reader)
.read_from_container(r)
.ok()
.and_then(|ex| {
ex.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
@@ -73,10 +87,18 @@ pub fn make_image_thumb(
long_edge: u32,
quality: u8,
) -> Result<(u32, u32, u32, u32)> {
let orientation = read_orientation(src);
// 파일을 **한 번만** 연다. 예전에는 orientation(EXIF)에서 한 번, 디코더에서 또 한 번
// 열었다 — SMB에서는 open 왕복만 파일당 2회, 100만 건이면 그것만으로 수십 분이다.
// 8KB BufReader로 잘게 읽는 것도 네트워크에서는 손해라 한 번에 순차로 읽는다.
let size = std::fs::metadata(src).map(|m| m.len()).unwrap_or(u64::MAX);
if size <= INMEM_MAX {
if let Ok(bytes) = std::fs::read(src) {
return thumb_from_whole_file(&bytes, dst, long_edge, quality);
}
}
// JPEG 빠른 경로: DCT 축소 디코드(1/2·1/4·1/8). 6000×4000 사진을 풀디코드(≈72MB)
// 하지 않고 750×500(≈1MB)만 디코드해 CPU·메모리를 대폭 절감한다. 실패 시 아래로 폴백.
// 상한 초과(대형 PNG/TIFF 등) — 스트리밍 경로. 여기서만 파일을 두 번 연다.
let orientation = read_orientation(src);
if is_jpeg_ext(src) {
if let Some(scaled) = decode_jpeg_scaled(src, long_edge) {
let oriented = apply_orientation(scaled, orientation);
@@ -85,7 +107,6 @@ pub fn make_image_thumb(
return Ok((w, h, dw, dh));
}
}
let decoded = image::ImageReader::open(src)?
.with_guessed_format()?
.decode()?;
@@ -95,6 +116,35 @@ pub fn make_image_thumb(
Ok((w, h, dw, dh))
}
/// 메모리에 올린 파일 전체에서 썸네일을 만든다. 반환값은 (원본 w, 원본 h, 썸네일 w, 썸네일 h).
fn thumb_from_whole_file(
bytes: &[u8],
dst: &Path,
long_edge: u32,
quality: u8,
) -> Result<(u32, u32, u32, u32)> {
let orientation = orientation_from_bytes(bytes);
// JPEG 판정은 확장자가 아니라 매직바이트로 — .jpe/.jfif나 확장자가 틀린 파일도
// DCT 축소 경로를 타게 된다(카메라·스캐너 출력에 흔하다).
let fast = if is_jpeg_magic(bytes) {
decode_jpeg_scaled_bytes(bytes, long_edge)
} else {
None
};
let decoded = match fast {
Some(img) => img,
None => image::load_from_memory(bytes)?, // CMYK JPEG·PNG·WebP 등
};
let oriented = apply_orientation(decoded, orientation);
let (w, h) = (oriented.width(), oriented.height());
let (dw, dh) = resize_encode(&oriented, dst, long_edge, quality)?;
Ok((w, h, dw, dh))
}
fn is_jpeg_magic(bytes: &[u8]) -> bool {
bytes.len() > 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
}
fn is_jpeg_ext(src: &Path) -> bool {
src.extension()
.and_then(|e| e.to_str())
@@ -105,9 +155,17 @@ fn is_jpeg_ext(src: &Path) -> bool {
/// jpeg-decoder의 DCT 스케일 기능으로 축소 디코드한다. 긴 변이 대략 `target` 이상이 되는
/// 가장 큰 축소(1/1·1/2·1/4·1/8)를 고른다. RGB24/L8만 처리하고 그 외(CMYK 등)는 None(폴백).
fn decode_jpeg_scaled(src: &Path, target: u32) -> Option<DynamicImage> {
use jpeg_decoder::PixelFormat;
let file = std::fs::File::open(src).ok()?;
let mut dec = jpeg_decoder::Decoder::new(std::io::BufReader::new(file));
decode_jpeg_scaled_reader(std::io::BufReader::with_capacity(256 * 1024, file), target)
}
fn decode_jpeg_scaled_bytes(bytes: &[u8], target: u32) -> Option<DynamicImage> {
decode_jpeg_scaled_reader(std::io::Cursor::new(bytes), target)
}
fn decode_jpeg_scaled_reader<R: std::io::Read>(r: R, target: u32) -> Option<DynamicImage> {
use jpeg_decoder::PixelFormat;
let mut dec = jpeg_decoder::Decoder::new(r);
dec.read_info().ok()?;
// 회전으로 가로/세로가 뒤바뀔 수 있으므로 두 축 모두 target으로 요청(축소만 하므로 안전).
let t = target.clamp(1, u16::MAX as u32) as u16;
@@ -201,15 +259,43 @@ pub fn scan_largest_jpeg(data: &[u8]) -> Option<&[u8]> {
best.map(|(s, l)| &data[s..s + l])
}
/// 파일 앞부분 최대 `limit` 바이트를 읽는다. 반환값이 `limit`보다 짧으면 EOF까지 읽은 것.
fn read_head(path: &Path, limit: u64) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let file = std::fs::File::open(path)?;
let mut buf = Vec::with_capacity(limit.min(4 * 1024 * 1024) as usize);
file.take(limit).read_to_end(&mut buf)?;
Ok(buf)
}
/// RAW 파일에서 내장 JPEG 프리뷰를 추출해 썸네일을 만든다. 반환: (썸네일 w, h).
/// RAW 헤더/IFD 파싱 대신 파일에서 가장 큰 JPEG 스트림을 스캔한다(견고하고 포맷 불문).
pub fn make_raw_thumb(src: &Path, dst: &Path, long_edge: u32, quality: u8) -> Result<(u32, u32)> {
// RAW 프리뷰는 파일 뒷부분에 있을 수 있어 전체를 읽는다(보통 20–40MB).
let data = std::fs::read(src)?;
let jpeg = scan_largest_jpeg(&data)
.ok_or_else(|| ThumbError::Resize("RAW 내장 JPEG 없음".into()))?;
let img = image::load_from_memory(jpeg)
.map_err(|e| ThumbError::Resize(format!("RAW 프리뷰 디코드 실패: {e}")))?;
// 앞부분부터 단계적으로 읽는다. RAW는 보통 20–40MB인데 프리뷰 JPEG은 대개 헤더 근처에
// 있다 — 매번 전체를 읽으면 RAW 10TB짜리 라이브러리에서 10TB를 다 긁는다. 4MB로
// 쓸 만한 프리뷰(긴 변 ≥ 요청 크기)를 찾으면 거기서 멈춘다.
const STAGES: [u64; 3] = [4 << 20, 32 << 20, u64::MAX];
let mut best: Option<(DynamicImage, u32)> = None;
for limit in STAGES {
let data = read_head(src, limit)?;
let eof = (data.len() as u64) < limit;
if let Some(jpeg) = scan_largest_jpeg(&data) {
if let Ok(img) = image::load_from_memory(jpeg) {
let edge = img.width().max(img.height());
if best.as_ref().map(|(_, b)| edge > *b).unwrap_or(true) {
best = Some((img, edge));
}
// 요청 크기를 채우는 프리뷰면 더 읽을 이유가 없다
if edge >= long_edge {
break;
}
}
}
if eof {
break; // 이미 파일 전체를 봤다
}
}
let (img, _) = best.ok_or_else(|| ThumbError::Resize("RAW 내장 JPEG 없음".into()))?;
// RAW도 EXIF orientation 적용
let oriented = apply_orientation(img, read_orientation(src));
resize_encode(&oriented, dst, long_edge, quality)
@@ -280,6 +366,64 @@ mod tests {
assert_eq!((w, h), (256, 192));
}
fn jpeg_bytes(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbImage::from_fn(w, h, |x, y| {
image::Rgb([(x % 256) as u8, (y % 256) as u8, 77])
});
let mut buf = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Jpeg)
.unwrap();
buf
}
/// 확장자가 틀린 JPEG(.dat)도 매직바이트로 잡아 DCT 축소 경로를 탄다.
#[test]
fn mislabeled_jpeg_still_uses_dct_path() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("mislabeled.dat");
let bytes = jpeg_bytes(2400, 1600);
std::fs::write(&src, &bytes).unwrap();
assert!(!is_jpeg_ext(&src), "확장자로는 JPEG이 아니다");
assert!(is_jpeg_magic(&bytes));
let dst = dir.path().join("t/aa/k.jpg");
let (w, h, dw, dh) = make_image_thumb(&src, &dst, 256, 80).unwrap();
assert_eq!((dw, dh), (256, 170));
// 축소 디코드를 탔다면 보고되는 원본 치수가 2400×1600보다 작다(1/8 = 300×200)
assert!(w < 2400 && h < 1600, "축소 디코드 경로: {w}×{h}");
}
/// RAW 프리뷰 탐색은 앞부분부터 단계적으로 읽는다:
/// ① 4MB 안에 쓸 만한 프리뷰가 있으면 거기서 멈추고, ② 없으면 더 읽는다.
#[test]
fn raw_preview_read_is_staged() {
let dir = tempfile::tempdir().unwrap();
// ① 1MB 지점에 4:3 프리뷰, 4MB 뒤에 4:1 프리뷰 → 앞의 것으로 끝내야 한다
let early = dir.path().join("early.cr2");
let mut data = vec![0u8; 1 << 20];
data.extend_from_slice(&jpeg_bytes(800, 600));
data.resize(data.len() + (4 << 20), 0);
data.extend_from_slice(&jpeg_bytes(1000, 250)); // 읽었다면 256×64가 된다
std::fs::write(&early, &data).unwrap();
let dst = dir.path().join("t/e.jpg");
assert_eq!(
make_raw_thumb(&early, &dst, 256, 80).unwrap(),
(256, 192),
"4MB 안의 프리뷰에서 멈춰야 한다"
);
// ② 앞 5MB에는 JPEG이 없다 → 다음 단계로 넓혀 읽어 찾아낸다
let late = dir.path().join("late.nef");
let mut data = vec![0u8; 5 << 20];
data.extend_from_slice(&jpeg_bytes(800, 600));
std::fs::write(&late, &data).unwrap();
let dst = dir.path().join("t/l.jpg");
assert_eq!(make_raw_thumb(&late, &dst, 256, 80).unwrap(), (256, 192));
}
#[test]
fn scan_largest_jpeg_picks_biggest() {
// 작은 JPEG + 큰 JPEG 섞인 데이터에서 큰 것을 고른다
@@ -0,0 +1,490 @@
//! 회전·반전을 **실제 파일에** 적용해 본다.
//!
//! 단위 테스트는 바이트 조작과 방향값 계산까지만 본다. 실제로는
//! 앱의 EXIF 리더가 내가 쓴 값을 못 읽거나, ffmpeg 회전 부호가 반대이거나,
//! 임시 파일 바꿔치기가 실패하는 식으로 죽는다.
//!
//! 그리고 **무손실이라는 주장을 실제로 검산한다** — 화상 데이터가 한 바이트도
//! 안 바뀌었는지, 영상은 스트림 복사가 맞는지.
use archive_indexer::edit::*;
use archive_indexer::ffmpeg::{self, FfTools};
use archive_indexer::thumbs;
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 workdir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join("archive-edit-test").join(name);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// 사진 회전은 **앱이 실제로 쓰는 EXIF 리더**가 읽을 수 있어야 한다.
/// 내가 쓴 바이트를 나만 읽을 수 있으면 썸네일이 안 돌아간다.
#[test]
fn jpeg_rotation_is_lossless_and_readable() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = workdir("jpeg");
let src = dir.join("photo.jpg");
let out = ffmpeg::command(&t.ffmpeg)
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=800x600:rate=1:d=1"])
.args(["-frames:v", "1", "-q:v", "3"])
.arg(&src)
.output()
.expect("ffmpeg 실행 실패");
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let before = std::fs::read(&src).unwrap();
let start = thumbs::read_orientation(&src);
println!("원본 방향 = {start}");
// 시계 90°
let r = rotate_image(&src, start as u8, EditOp::RotateCw).expect("회전 실패");
assert!(!r.reencoded, "JPEG인데 다시 인코딩했다");
assert!(r.swapped, "90° 회전은 가로·세로가 바뀐다");
// 앱의 리더가 새 값을 읽는가
let now = thumbs::read_orientation(&src);
assert_eq!(now as u8, apply_orientation(start as u8, EditOp::RotateCw), "EXIF 값이 안 맞는다");
println!("회전 후 방향 = {now}");
// 화상 데이터는 그대로인가 — 길이가 같고 바뀐 바이트가 한 개여야 한다
let after = std::fs::read(&src).unwrap();
if before.len() == after.len() {
let diff = before.iter().zip(&after).filter(|(a, b)| a != b).count();
assert_eq!(diff, 1, "바뀐 바이트가 {diff}개 — 무손실이 아니다");
println!("바뀐 바이트 1개 (EXIF 방향 태그) — 무손실 확인");
} else {
// EXIF가 없던 파일이면 세그먼트가 삽입되어 길어진다. 그래도 화상 데이터는 그대로여야 한다.
let grew = after.len() - before.len();
assert_eq!(&after[after.len() - (before.len() - 2)..], &before[2..], "화상 데이터가 바뀌었다");
println!("EXIF {grew}바이트 삽입 — 화상 데이터는 그대로");
}
// 이미 한 번 돌렸으니 세 번 더 = 네 바퀴 → 원래 방향으로 돌아와야 한다
for _ in 0..3 {
let cur = thumbs::read_orientation(&src) as u8;
rotate_image(&src, cur, EditOp::RotateCw).expect("회전 실패");
}
assert_eq!(thumbs::read_orientation(&src), start, "네 바퀴 돌렸는데 제자리가 아니다");
println!("네 바퀴 → 원래 방향 {start} 복귀");
}
/// 영상 회전은 스트림 복사여야 한다 — 다시 인코딩하면 30분짜리가 몇 분씩 걸린다.
#[test]
fn video_rotation_is_stream_copy() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = workdir("video");
let src = dir.join("clip.mp4");
let out = ffmpeg::command(&t.ffmpeg)
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=640x480:rate=10:d=3"])
.args(["-f", "lavfi", "-i", "sine=frequency=440:d=3"])
.args(["-c:v", "libopenh264", "-b:v", "1M", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"])
.arg(&src)
.output()
.expect("ffmpeg 실행 실패");
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let size_before = std::fs::metadata(&src).unwrap().len();
assert_eq!(video_rotation(&t, &src), 0, "새로 만든 영상은 회전이 0이어야 한다");
// 90° — 메타데이터만
let r = rotate_video(&t, &src, EditOp::RotateCw, "libopenh264").expect("회전 실패");
assert!(!r.reencoded, "스트림 복사가 아니다");
assert_eq!(r.rotation, Some(90));
assert_eq!(video_rotation(&t, &src), 90, "파일에 90°가 안 적혔다");
let size_after = std::fs::metadata(&src).unwrap().len();
// 회전 행렬만 바뀌므로 크기 차이는 무시할 만해야 한다
let delta = (size_after as i64 - size_before as i64).abs();
assert!(delta < 4096, "크기가 {delta}바이트 바뀌었다 — 재인코딩된 것 같다");
println!("영상 90° 회전 — 크기 차이 {delta}바이트 (스트림 복사)");
// 한 번 더 돌리면 180°가 되어야 한다 (덮어쓰기가 아니라 누적)
rotate_video(&t, &src, EditOp::RotateCw, "libopenh264").expect("회전 실패");
assert_eq!(video_rotation(&t, &src), 180, "두 번 돌렸는데 누적되지 않았다");
println!("두 번째 회전 후 180° — 누적 확인");
// 소리는 살아 있는가
let info = ffmpeg::probe(&t, &src).unwrap();
assert!(info.acodec.is_some(), "소리가 사라졌다");
}
/// 반전은 회전 행렬로 표현할 수 없어 다시 인코딩한다 — 그래도 결과가 나와야 한다.
#[test]
fn video_flip_reencodes_but_works() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = workdir("flip");
let src = dir.join("clip.mp4");
let out = ffmpeg::command(&t.ffmpeg)
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=10:d=2"])
.args(["-c:v", "libopenh264", "-b:v", "500k", "-pix_fmt", "yuv420p"])
.arg(&src)
.output()
.expect("ffmpeg 실행 실패");
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let r = rotate_video(&t, &src, EditOp::FlipH, "libopenh264").expect("반전 실패");
assert!(r.reencoded, "반전은 다시 인코딩해야 한다");
let info = ffmpeg::probe(&t, &src).unwrap();
assert_eq!((info.width, info.height), (Some(320), Some(240)), "반전은 크기를 안 바꾼다");
println!("영상 좌우반전 — 재인코딩 (회전 행렬로는 표현 불가)");
}
/// 실패해도 원본이 반쯤 쓰이면 안 된다 — 쓸 수 없는 경로에 대고 확인한다.
#[test]
fn failure_leaves_original_intact() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = workdir("fail");
// 영상이 아닌 파일을 영상 회전에 넘긴다 — ffmpeg이 거부해야 한다
let src = dir.join("notvideo.mp4");
std::fs::write(&src, b"this is not a video").unwrap();
let before = std::fs::read(&src).unwrap();
let r = rotate_video(&t, &src, EditOp::RotateCw, "libopenh264");
assert!(r.is_err(), "잘못된 입력인데 성공했다");
assert_eq!(std::fs::read(&src).unwrap(), before, "실패했는데 원본이 바뀌었다");
// 임시 파일도 남으면 안 된다
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.contains("tmp-edit"))
.collect();
assert!(leftovers.is_empty(), "임시 파일이 남았다: {leftovers:?}");
println!("실패 시 원본 보존 · 임시 파일 없음 확인");
}
/// **"시계 방향"이 정말 시계 방향인가.**
///
/// 회전 각도를 읽고 쓰는 것만 확인하면 부호가 반대여도 테스트가 통과한다 —
/// 그러면 사용자가 오른쪽 회전을 눌렀는데 왼쪽으로 돈다. 그래서 실제 화소로 검산한다.
/// 기준은 ffmpeg의 `transpose=1`(= 시계 90°)로 직접 돌린 결과다.
#[test]
fn clockwise_actually_turns_clockwise() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = workdir("direction");
// 네 모서리 색이 다른 영상 — 어느 쪽으로 돌았는지 화소로 구별된다
let src = dir.join("corners.mp4");
let lavfi = "color=c=red:s=64x64,pad=128:128:0:0:black[a];\
color=c=lime:s=64x64[b];[a][b]overlay=64:0";
let out = ffmpeg::command(&t.ffmpeg)
.args(["-v", "error", "-y", "-f", "lavfi", "-i", &format!("{lavfi},format=yuv420p,trim=duration=1")])
.args(["-c:v", "libopenh264", "-b:v", "500k", "-r", "10"])
.arg(&src)
.output()
.expect("ffmpeg 실행 실패");
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
// 기준: transpose=1 로 실제 화소를 시계 90° 돌린 결과
let want = dir.join("want.png");
let o = ffmpeg::command(&t.ffmpeg)
.args(["-v", "error", "-y", "-noautorotate"])
.arg("-i").arg(&src)
.args(["-vf", "transpose=1", "-frames:v", "1"])
.arg(&want)
.output()
.expect("ffmpeg 실행 실패");
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
// 우리 코드로 메타데이터만 회전
rotate_video(&t, &src, EditOp::RotateCw, "libopenh264").expect("회전 실패");
// 회전을 적용해(기본 autorotate) 한 프레임 뽑는다
let got = dir.join("got.png");
let o = ffmpeg::command(&t.ffmpeg)
.args(["-v", "error", "-y"])
.arg("-i").arg(&src)
.args(["-frames:v", "1"])
.arg(&got)
.output()
.expect("ffmpeg 실행 실패");
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let a = image::open(&want).unwrap().to_rgb8();
let b = image::open(&got).unwrap().to_rgb8();
assert_eq!(a.dimensions(), b.dimensions(), "회전 후 크기가 기준과 다르다");
// 압축 손실이 있으므로 완전 일치는 못 한다 — 평균 차이로 본다
let n = (a.width() * a.height() * 3) as f64;
let diff: f64 = a.as_raw().iter().zip(b.as_raw()).map(|(x, y)| (*x as f64 - *y as f64).abs()).sum::<f64>() / n;
println!("시계 90° — 기준(transpose=1) 대비 평균 화소 차이 {diff:.1}");
assert!(diff < 12.0, "시계 방향이 아니다 — 평균 차이 {diff:.1}");
}
/// **회전된 사진을 자를 때 좌표가 맞는가.**
///
/// 사용자는 화면에서 사각형을 끈다. 그런데 EXIF 방향이 붙어 있으면 저장된 화소 배치와
/// 화면 배치가 다르다 — 그대로 자르면 엉뚱한 데가 잘린다.
/// 기준은 ffmpeg이다: ffmpeg은 EXIF를 적용해 디코드하므로, 거기서 같은 좌표로 자른 결과와
/// 우리 결과가 같아야 한다.
#[test]
fn crop_uses_display_coordinates_on_rotated_image() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = workdir("crop-img");
// 좌우가 확실히 다른 그림 — 어디를 잘랐는지 화소로 구별된다
let src = dir.join("p.jpg");
let lavfi = "color=c=red:s=200x100,pad=400:100:0:0:blue";
let out = ffmpeg::command(&t.ffmpeg)
.args(["-v", "error", "-y", "-f", "lavfi", "-i", &format!("{lavfi},format=yuvj420p")])
.args(["-frames:v", "1", "-q:v", "2"])
.arg(&src)
.output()
.expect("ffmpeg 실행 실패");
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
// 시계 90° 회전 → 화면상 100×400 이 된다
rotate_image(&src, 1, EditOp::RotateCw).expect("회전 실패");
let o = thumbs::read_orientation(&src) as u8;
assert_eq!(o, 6, "EXIF 방향이 6(시계90°)이어야 한다");
// 기준: ffmpeg으로 같은 영역을 자른다 (ffmpeg은 EXIF를 적용해 디코드한다)
let want = dir.join("want.png");
let r = ffmpeg::command(&t.ffmpeg)
.args(["-v", "error", "-y"])
.arg("-i").arg(&src)
.args(["-vf", "crop=100:120:0:0", "-frames:v", "1"])
.arg(&want)
.output()
.expect("ffmpeg 실행 실패");
assert!(r.status.success(), "{}", String::from_utf8_lossy(&r.stderr));
let wi = image::open(&want).unwrap().to_rgb8();
assert_eq!(wi.dimensions(), (100, 120), "기준이 화면 좌표로 잘리지 않았다");
// 우리 코드로 같은 영역을 자른다
let res = crop_image(&src, CropRect { x: 0, y: 0, w: 100, h: 120 }, o, None).expect("자르기 실패");
assert_eq!(res.new_size, Some((100, 120)));
assert_eq!(res.orientation, Some(1), "방향을 화소에 구워 넣었으면 태그는 1이어야 한다");
let gi = image::open(&src).unwrap().to_rgb8();
assert_eq!(gi.dimensions(), (100, 120), "잘린 파일 크기가 다르다");
let n = (100 * 120 * 3) as f64;
let diff: f64 = wi.as_raw().iter().zip(gi.as_raw()).map(|(a, b)| (*a as f64 - *b as f64).abs()).sum::<f64>() / n;
println!("회전된 사진 자르기 — ffmpeg 기준 대비 평균 화소 차이 {diff:.1}");
assert!(diff < 12.0, "엉뚱한 곳을 잘랐다 — 평균 차이 {diff:.1}");
}
/// 영상 영역 자르기 — 회전 메타가 있어도 화면 좌표로 자르고, 결과에는 회전이 남지 않는다.
#[test]
fn video_crop_uses_display_coordinates() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = workdir("crop-vid");
let src = dir.join("v.mp4");
let out = ffmpeg::command(&t.ffmpeg)
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=640x480:rate=10:d=1"])
.args(["-c:v", "libopenh264", "-b:v", "500k", "-pix_fmt", "yuv420p"])
.arg(&src)
.output()
.expect("ffmpeg 실행 실패");
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
// 회전을 걸어 화면상 480×640으로 만든다
rotate_video(&t, &src, EditOp::RotateCw, "libopenh264").expect("회전 실패");
// 400×600 은 화면(480×640)에만 들어간다 — 저장(640×480) 기준이면 실패할 크기
let r = crop_video(&t, &src, CropRect { x: 0, y: 0, w: 400, h: 600 }, "libopenh264", 800, None)
.expect("자르기 실패");
assert_eq!(r.new_size, Some((400, 600)));
let info = ffmpeg::probe(&t, &src).unwrap();
assert_eq!((info.width, info.height), (Some(400), Some(600)), "화면 좌표로 잘리지 않았다");
assert_eq!(video_rotation(&t, &src), 0, "회전이 남아 있으면 재생기가 한 번 더 돌린다");
println!("영상 영역 자르기 400×600 — 회전 메타 제거됨");
}
/// 구간 자르기는 스트림 복사 — 무손실이고 소리도 함께 잘린다.
#[test]
fn trim_is_lossless_and_keeps_audio() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = workdir("trim");
let src = dir.join("v.mp4");
// 4초 · 소리 있음 — 구간 자르기가 소리도 같이 자르는지 봐야 한다
let out = ffmpeg::command(&t.ffmpeg)
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=10:d=4"])
.args(["-f", "lavfi", "-i", "sine=frequency=440:d=4"])
.args(["-c:v", "libopenh264", "-b:v", "500k", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"])
.arg(&src)
.output()
.expect("ffmpeg 실행 실패");
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let before = ffmpeg::probe(&t, &src).unwrap();
assert!(before.duration_ms.unwrap_or(0) > 3000);
let r = trim_video(&t, &src, 1000, 3000, None).expect("구간 자르기 실패");
assert!(!r.reencoded, "구간 자르기는 무손실이어야 한다");
let after = ffmpeg::probe(&t, &src).unwrap();
let d = after.duration_ms.unwrap_or(0);
assert!((d - 2000).abs() < 300, "2초를 요청했는데 {d}ms 다");
assert!(after.acodec.is_some(), "소리가 사라졌다");
assert_eq!(after.vcodec, before.vcodec, "영상이 재인코딩됐다");
assert_eq!(after.acodec, before.acodec, "소리가 재인코딩됐다");
println!("구간 자르기 {d}ms — 스트림 복사, 소리 보존");
// 끝이 시작보다 앞이면 거부
assert!(trim_video(&t, &src, 2000, 1000, None).is_err());
}
/// 자를 영역은 이미지 밖으로 나가지 않고 항상 짝수다.
#[test]
fn crop_rect_is_clamped_and_even() {
for (iw, ih) in [(640u32, 480u32), (101, 103), (4, 4)] {
for r in [
CropRect { x: 0, y: 0, w: 99999, h: 99999 },
CropRect { x: 99999, y: 99999, w: 10, h: 10 },
CropRect { x: 3, y: 5, w: 7, h: 9 },
] {
if let Some(c) = r.clamp_to(iw, ih) {
assert_eq!(c.w % 2, 0, "{c:?}");
assert_eq!(c.h % 2, 0, "{c:?}");
assert!(c.x + c.w <= iw, "{c:?} > {iw}");
assert!(c.y + c.h <= ih, "{c:?} > {ih}");
}
}
}
}
/// 자르기는 되돌릴 수 없다 — 그래서 원본을 **복사하지 않고 옮겨** 보관한다.
/// 백업이 있으면 원본 바이트가 그대로 남아 있어야 하고, 없으면 그냥 덮어써야 한다.
#[test]
fn crop_moves_original_to_backup() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = workdir("backup");
let src = dir.join("p.jpg");
let out = ffmpeg::command(&t.ffmpeg)
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=400x300:rate=1:d=1"])
.args(["-frames:v", "1", "-q:v", "2"])
.arg(&src)
.output()
.expect("ffmpeg 실행 실패");
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let original = std::fs::read(&src).unwrap();
let bk = dir.join(".trash").join("p.jpg");
crop_image(&src, CropRect { x: 10, y: 10, w: 200, h: 100 }, 1, Some(&bk)).expect("자르기 실패");
assert!(bk.is_file(), "백업이 없다");
assert_eq!(std::fs::read(&bk).unwrap(), original, "백업이 원본과 다르다");
let cropped = image::open(&src).unwrap();
assert_eq!((cropped.width(), cropped.height()), (200, 100));
println!("자르기 → 원본은 백업으로 이동, 새 파일 200×100");
// 백업에서 되돌리면 원본 그대로여야 한다
std::fs::rename(&bk, &src).unwrap();
assert_eq!(std::fs::read(&src).unwrap(), original, "되돌린 파일이 원본과 다르다");
println!("백업 복원 — 바이트 단위로 원본과 동일");
}
/// 새 파일을 못 앉히면 원본이 제자리로 돌아와야 한다 — 사라지면 안 된다.
#[test]
fn failed_placement_restores_the_original() {
let dir = workdir("place-fail");
let src = dir.join("p.jpg");
std::fs::write(&src, b"original bytes").unwrap();
// 백업 경로를 폴더로 만들어 두면 rename(dst → backup)이 실패한다
let bk = dir.join("busy");
std::fs::create_dir_all(bk.join("sub")).unwrap();
let r = crop_image(&src, CropRect { x: 0, y: 0, w: 10, h: 10 }, 1, Some(&bk));
assert!(r.is_err(), "잘못된 백업 경로인데 성공했다");
assert_eq!(std::fs::read(&src).unwrap(), b"original bytes", "원본이 사라졌다");
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.contains("tmp-edit"))
.collect();
assert!(leftovers.is_empty(), "임시 파일이 남았다: {leftovers:?}");
println!("배치 실패 시 원본 보존 · 임시 파일 없음");
}
/// **편집한 파일이 실제로 디코드되는가.**
///
/// ffprobe는 컨테이너만 읽는다 — 헤더가 멀쩡하면 비트스트림이 깨져도 정상으로 보인다.
/// 실제로 반전 경로가 `-b:v 0k`로 재인코딩해 **재생 불가능한 파일**을 만든 적이 있는데,
/// 크기·코덱·길이를 확인하는 테스트는 전부 통과했다. 그래서 화소까지 뽑아 본다.
fn decodes_ok(t: &FfTools, p: &Path) -> bool {
let out = ffmpeg::command(&t.ffmpeg)
.args(["-v", "error", "-nostdin", "-xerror"])
.arg("-i")
.arg(p)
.args(["-an", "-sn", "-vf", "fps=2,scale=32:32,format=gray", "-f", "rawvideo", "-"])
.output();
match out {
Ok(o) => o.status.success() && !o.stdout.is_empty() && o.stderr.is_empty(),
Err(_) => false,
}
}
#[test]
fn every_edit_leaves_a_decodable_file() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let ops: [(&str, EditOp); 5] = [
("시계90", EditOp::RotateCw),
("반시계90", EditOp::RotateCcw),
("180", EditOp::Rotate180),
("좌우반전", EditOp::FlipH),
("상하반전", EditOp::FlipV),
];
for (name, op) in ops {
let dir = workdir(&format!("decodable-{}", op as u8 as char as u32));
let src = dir.join("v.mp4");
let out = ffmpeg::command(&t.ffmpeg)
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=10:d=2"])
.args(["-c:v", "libopenh264", "-b:v", "500k", "-pix_fmt", "yuv420p"])
.arg(&src)
.output()
.expect("ffmpeg 실행 실패");
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
// 라이브러리가 실제로 고르는 인코더로 — 하드웨어 경로에서만 깨질 수도 있다
let enc = h264_encoder(&t);
let r = rotate_video(&t, &src, op, enc).unwrap_or_else(|e| panic!("{name} 실패: {e}"));
assert!(decodes_ok(&t, &src), "{name} 뒤 파일이 디코드되지 않는다 (인코더 {enc})");
println!("{name:<10} 재인코딩={} · 디코드 정상", r.reencoded);
}
}
@@ -2,6 +2,7 @@
//! (없으면 조용히 통과 — CI에서 fetch-ffmpeg 후 실행하면 전체 커버)
use archive_indexer::ffmpeg::{self, FfTools};
use archive_indexer::sigmatch;
use std::path::{Path, PathBuf};
fn tools() -> Option<FfTools> {
@@ -66,3 +67,205 @@ fn probe_and_thumbnail_roundtrip() {
assert!(thumb.is_file());
assert!(w == 256 || h == 256, "긴 변이 256이어야 함: {w}x{h}");
}
// ── 지문 추출 (단일 패스) ─────────────────────────────────
const ENC: &str = if cfg!(windows) { "h264_mf" } else { "h264_videotoolbox" };
/// 합성 영상을 만든다. `extra`는 입력 뒤에 붙는 추가 인자
/// (두 번째 입력을 붙이는 호출이 있으므로 여기서 `-t` 같은 입력 옵션을 끼워 넣으면 안 된다).
fn make_video(t: &FfTools, out: &Path, secs: u32, extra: &[&str]) {
let src = format!("testsrc2=duration={secs}:size=320x240:rate=25");
let mut cmd = ffmpeg::command(&t.ffmpeg);
cmd.args(["-y", "-v", "error", "-f", "lavfi", "-i", &src]);
cmd.args(extra);
cmd.args(["-c:v", ENC, "-b:v", "300k"]).arg(out);
let status = cmd.status().expect("ffmpeg 실행 실패");
assert!(status.success(), "테스트 영상 생성 실패: {}", out.display());
}
/// 프레임마다 구조가 크게 달라지는 영상 — **오프셋 투표를 검증할 때는 이걸 써야 한다.**
///
/// testsrc2는 320×240을 32×32 그레이로 줄여 9×8 dHash를 뽑으면 프레임끼리 전부 해밍 8 안에
/// 들어온다(실측: 24프레임 중 고유 19개인데 **모든 오프셋 버킷이 똑같이 10표**). 즉 정렬을
/// 판별할 정보가 남지 않아 어느 오프셋이 1위가 되는지는 우연이다.
/// 확대되는 mandelbrot은 매 프레임이 달라 오프셋이 유일하게 정해진다(4000ms×10 vs 3500ms×9).
fn make_diverse_video(t: &FfTools, out: &Path, secs: u32) {
let mut cmd = ffmpeg::command(&t.ffmpeg);
// mandelbrot은 무한 소스라 duration이 없다 → -t로 끊는다
cmd.args(["-y", "-v", "error", "-f", "lavfi", "-i", "mandelbrot=size=320x240:rate=25"]);
cmd.args(["-t", &secs.to_string()]);
cmd.args(["-c:v", ENC, "-b:v", "300k"]).arg(out);
let status = cmd.status().expect("ffmpeg 실행 실패");
assert!(status.success(), "테스트 영상 생성 실패: {}", out.display());
}
#[test]
fn gray_sequence_is_single_pass_and_uniformly_sampled() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = tempfile::tempdir().unwrap();
let video = dir.path().join("샘플.mp4");
make_video(&t, &video, 6, &[]);
let frames = ffmpeg::frame_gray_sequence(
&t,
&video,
1000, // 1초 간격
sigmatch::FRAME as u32,
std::time::Duration::from_secs(60),
None,
)
.expect("프레임 시퀀스 추출 실패");
// 6초 영상에서 1초 간격 → 6장 안팎
assert!((5..=7).contains(&frames.len()), "프레임 수 {}", frames.len());
for (i, (t_ms, px)) in frames.iter().enumerate() {
assert_eq!(px.len(), sigmatch::FRAME_LEN, "프레임 크기가 32×32여야 한다");
assert_eq!(*t_ms, i as u32 * 1000, "시각은 균등 간격이어야 한다");
assert!(sigmatch::derive(px).is_some(), "파생 지문 계산 실패");
}
// testsrc2는 시간에 따라 화면이 변한다 → 연속 프레임의 해시가 달라야 한다
let a = sigmatch::derive(&frames[0].1).unwrap();
let b = sigmatch::derive(&frames[frames.len() - 1].1).unwrap();
assert!(
sigmatch::hamming(a.dhash, b.dhash) > 0,
"서로 다른 장면인데 해시가 같다"
);
}
/// 이번 고도화의 핵심 검증 — **긴 영상에서 잘라낸 구간을 재인코딩해도 찾아내는지.**
/// (예전 구현은 duration ±5% 프리필터 때문에 이 케이스를 원천적으로 놓쳤다)
///
/// 소재는 mandelbrot이어야 한다 — 이유는 `make_diverse_video` 주석 참조.
#[test]
fn clipped_segment_is_matched_with_correct_offset() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = tempfile::tempdir().unwrap();
let full = dir.path().join("원본.mp4");
make_diverse_video(&t, &full, 12);
// 원본의 4초~9초 구간을 다시 인코딩 (바이트는 완전히 다르다)
let clip = dir.path().join("구간.mp4");
let status = ffmpeg::command(&t.ffmpeg)
.args(["-y", "-v", "error", "-ss", "4", "-t", "5"])
.arg("-i")
.arg(&full)
.args(["-c:v", ENC, "-b:v", "200k"])
.arg(&clip)
.status()
.expect("ffmpeg 실행 실패");
assert!(status.success(), "구간 추출 실패");
let seq = |p: &Path| -> Vec<(u32, u64)> {
ffmpeg::frame_gray_sequence(&t, p, 500, sigmatch::FRAME as u32, std::time::Duration::from_secs(120), None)
.expect("프레임 시퀀스 추출 실패")
.iter()
.filter_map(|(ms, px)| sigmatch::derive(px).map(|s| (*ms, s.dhash)))
.collect()
};
let a = seq(&full);
let b = seq(&clip);
assert!(a.len() >= 20 && b.len() >= 8, "a={} b={}", a.len(), b.len());
let m = sigmatch::offset_vote(&a, &b, 8, 500, 4).expect("부분 구간을 찾지 못했다");
assert!(
(3500..=4500).contains(&m.offset_ms),
"오프셋이 4초 근처여야 한다 (실제 {}ms, 지지 {})",
m.offset_ms,
m.support
);
assert!(m.a_span_ms() >= 3000, "일치 구간이 3초 이상이어야 한다 ({}ms)", m.a_span_ms());
}
/// 오디오 지문 — 번들 ffmpeg의 chromaprint로 새 의존성 없이 얻는다.
#[test]
fn audio_fingerprint_is_available_and_partial_matchable() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = tempfile::tempdir().unwrap();
let with_audio = dir.path().join("소리있음.mp4");
make_video(
&t,
&with_audio,
20,
&["-f", "lavfi", "-i", "sine=frequency=440:duration=20", "-c:a", "aac", "-shortest"],
);
let fp = ffmpeg::audio_fingerprint(&t, &with_audio, std::time::Duration::from_secs(60))
.expect("오디오 지문 실패");
// 실측 기준 약 7.9개/초
assert!(fp.len() >= 100, "20초 오디오의 지문 항목 수 {}", fp.len());
// 오디오가 없는 영상은 빈 지문 (오류가 아니어야 한다)
let silent = dir.path().join("소리없음.mp4");
make_video(&t, &silent, 2, &[]);
let empty = ffmpeg::audio_fingerprint(&t, &silent, std::time::Duration::from_secs(60))
.expect("무성 영상에서 오류가 나면 안 된다");
assert!(empty.is_empty(), "무성 영상은 빈 지문이어야 한다 ({}개)", empty.len());
}
/// 프레임과 오디오 지문을 **한 번의 디코드**로 받는다 — NAS에서 파일을 두 번 읽지 않기 위해서다.
///
/// 여기서 고정하는 실패 모드: 오디오 스트림이 없는 파일에 chromaprint 출력을 붙이면 ffmpeg가
/// `Output file does not contain any stream`으로 **출력 초기화에서** 죽어 프레임까지 0장이 된다.
/// 그래서 호출부는 오디오가 있다고 아는 파일에만 켜고, 그래도 실패하면 오디오 없이 재시도한다.
#[test]
fn one_decode_yields_both_frames_and_audio_fingerprint() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = tempfile::tempdir().unwrap();
let timeout = std::time::Duration::from_secs(120);
let with_audio = dir.path().join("소리있음.mp4");
make_video(
&t,
&with_audio,
10,
&["-f", "lavfi", "-i", "sine=frequency=440:duration=10", "-c:a", "aac", "-shortest"],
);
let fp_out = dir.path().join("fp.raw");
let (frames, fp) =
ffmpeg::frame_gray_sequence_with_audio(&t, &with_audio, 1000, 32, timeout, None, Some(&fp_out))
.expect("한 패스 추출 실패");
assert!(frames.len() >= 8, "10초에서 프레임 {}장", frames.len());
assert!(fp.len() >= 50, "10초 오디오 지문 {}개", fp.len());
assert!(!fp_out.exists(), "임시 지문 파일은 읽은 뒤 지워야 한다");
// 오디오가 없는 파일에 지문 출력을 붙여도 **프레임은 살아야 한다** (폴백 경로)
let silent = dir.path().join("소리없음.mp4");
make_video(&t, &silent, 4, &[]);
let fp_out2 = dir.path().join("fp2.raw");
let (frames2, fp2) =
ffmpeg::frame_gray_sequence_with_audio(&t, &silent, 1000, 32, timeout, None, Some(&fp_out2))
.expect("무성 영상에서도 프레임은 나와야 한다");
assert!(frames2.len() >= 3, "무성 영상 프레임 {}장", frames2.len());
assert!(fp2.is_empty(), "무성 영상은 빈 지문");
}
/// 손상된 입력에서 워커가 영구 점유되지 않아야 한다 (타임아웃/오류 반환).
#[test]
fn broken_input_fails_fast() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = tempfile::tempdir().unwrap();
let junk = dir.path().join("깨진파일.mp4");
std::fs::write(&junk, b"this is not a video").unwrap();
let started = std::time::Instant::now();
let r = ffmpeg::frame_gray_sequence(&t, &junk, 1000, 32, std::time::Duration::from_secs(20), None);
assert!(r.is_err() || r.as_ref().unwrap().is_empty(), "깨진 파일에서 프레임이 나왔다");
assert!(started.elapsed().as_secs() < 20, "즉시 실패해야 한다");
}
@@ -0,0 +1,464 @@
//! 픽스처 실기 통합 테스트 — `.fixtures\manifest.json`의 기대값을 **실제 파이프라인**으로 검증한다.
//!
//! 스캔 → 메타 → 썸네일 → 지문 → 중복 탐지를 순서대로 돌리고, manifest가 "잡혀야 한다"고
//! 말한 변형이 정말 같은 그룹에 들어갔는지 / "잡히면 안 된다"는 것이 안 묶였는지 확인한다.
//! 이 순서 자체가 검증 대상이다 — 이미지 지문은 썸네일에서 뽑으므로 앞 단계가 빠지면 못 나온다.
//!
//! 픽스처가 없으면 조용히 통과한다 (`.\scripts\gen-fixtures.ps1`로 생성).
//! manifest의 expect/stage 의미는 그 스크립트 헤더에 적혀 있다.
use archive_db::Db;
use archive_indexer::{dedupe, ffmpeg::FfTools, meta, pipeline, sigq, thumbq::ThumbQueue};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::time::Duration;
fn repo_root() -> PathBuf {
// crates/archive-indexer → src-tauri → 리포 루트
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 if cfg!(all(windows, target_arch = "aarch64")) {
"aarch64-pc-windows-msvc"
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"aarch64-apple-darwin"
} else {
"x86_64-apple-darwin"
};
let ext = if cfg!(windows) { ".exe" } else { "" };
let (ffmpeg, ffprobe) = (
bin.join(format!("ffmpeg-{triple}{ext}")),
bin.join(format!("ffprobe-{triple}{ext}")),
);
(ffmpeg.is_file() && ffprobe.is_file()).then_some(FfTools { ffmpeg, ffprobe })
}
/// manifest 한 항목.
struct Entry {
/// 파일명만 (경로 제외)
name: String,
/// 같은 원본에서 나온 것들의 공통 키 (음성은 None)
group: Option<String>,
variant: String,
expect: String,
stage: i64,
}
fn load_manifest(kind: &str) -> Option<(PathBuf, Vec<Entry>)> {
let fixtures = repo_root().join(".fixtures");
let text = std::fs::read_to_string(fixtures.join("manifest.json")).ok()?;
let json: serde_json::Value = serde_json::from_str(&text).expect("manifest.json 파싱 실패");
let arr = json.get(kind)?.as_array()?.clone();
let entries: Vec<Entry> = arr
.iter()
.map(|e| Entry {
name: e["file"].as_str().unwrap().rsplit('/').next().unwrap().to_string(),
group: e["group"].as_str().map(|s| s.to_string()),
variant: e["variant"].as_str().unwrap_or("").to_string(),
expect: e["expect"].as_str().unwrap_or("").to_string(),
stage: e["stage"].as_i64().unwrap_or(1),
})
.collect();
let dir = fixtures.join(if kind == "images" { "images" } else { "videos" });
dir.is_dir().then_some((dir, entries))
}
fn open_db(dir: &Path) -> Arc<Db> {
Arc::new(Db::open(&dir.join("t.db")).unwrap())
}
fn add_source(db: &Arc<Db>, root: &Path) -> i64 {
let r = root.to_string_lossy().into_owned();
db.with_write(move |conn| {
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','fx',?1)", [&r])?;
Ok(conn.last_insert_rowid())
})
.unwrap()
}
/// file_id → 파일명
fn names(db: &Arc<Db>) -> HashMap<i64, String> {
db.with_read(|conn| {
let mut stmt = conn.prepare("SELECT id, name FROM files WHERE deleted_at IS NULL")?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
rows.collect()
})
.unwrap()
}
/// 탐지된 그룹 — (kind, 파일명 집합). kind 0=완전 동일 1=유사 이미지 2=유사 영상.
#[derive(Debug)]
struct Group {
kind: i64,
members: HashSet<String>,
}
fn groups_by_name(db: &Arc<Db>, names: &HashMap<i64, String>) -> Vec<Group> {
let rows: Vec<(i64, i64, i64)> = db
.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT dm.group_id, dg.kind, dm.file_id FROM dupe_members dm
JOIN dupe_groups dg ON dg.id = dm.group_id WHERE dg.status = 'open'",
)?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
rows.collect()
})
.unwrap();
let mut by_gid: HashMap<i64, Group> = HashMap::new();
for (gid, kind, fid) in rows {
if let Some(n) = names.get(&fid) {
by_gid
.entry(gid)
.or_insert_with(|| Group { kind, members: HashSet::new() })
.members
.insert(n.clone());
}
}
by_gid.into_values().collect()
}
/// 탐지 결과를 사람이 읽을 형태로 찍는다 (`--nocapture`). 점수·거리가 실제로 채워지는지도 보인다.
fn dump(db: &Arc<Db>, groups: &[Group]) {
let scores: Vec<(i64, i64, Option<f64>)> = db
.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT id, kind, score FROM dupe_groups WHERE status='open' ORDER BY kind, id",
)?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
rows.collect()
})
.unwrap();
let kind_name = |k: i64| match k {
0 => "완전동일",
1 => "유사이미지",
_ => "유사영상",
};
println!("── 탐지 결과: 그룹 {}", groups.len());
for (_, kind, score) in &scores {
let _ = (kind, score);
}
let mut sorted: Vec<&Group> = groups.iter().collect();
sorted.sort_by_key(|g| (g.kind, g.members.len()));
for g in sorted {
let mut m: Vec<&str> = g.members.iter().map(|s| s.as_str()).collect();
m.sort_unstable();
println!(" [{}] {}", kind_name(g.kind), m.join(", "));
}
let dists: Vec<i64> = db
.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT distance FROM dupe_members WHERE distance IS NOT NULL AND is_keeper = 0",
)?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.collect()
})
.unwrap();
let sc: Vec<f64> = scores.iter().filter_map(|(_, _, s)| *s).collect();
println!(
" 거리 채워진 멤버 {}개(최대 {:?}) · 점수 채워진 그룹 {}/{}개",
dists.len(),
dists.iter().max(),
sc.len(),
scores.len()
);
}
/// 썸네일이 다 만들어질 때까지 기다린다 (없으면 이미지 지문이 안 나온다).
fn build_thumbs(db: &Arc<Db>, sid: i64, thumb_root: &Path, tools: Option<FfTools>) {
let want: i64 = db
.with_read(|c| {
c.query_row(
"SELECT count(*) FROM files WHERE kind IN (0,1) AND deleted_at IS NULL",
[],
|r| r.get(0),
)
})
.unwrap();
// 완료를 이벤트로 기다린다 — 썸네일 워커는 저우선순위라 폴링+마감은 부하에서 흔들린다.
// 마감(60초 무진전)은 정말 멈춘 경우를 위한 안전장치다.
let (tx, rx) = std::sync::mpsc::channel::<usize>();
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let d2 = done.clone();
let q = ThumbQueue::start(db.clone(), thumb_root.to_path_buf(), tools, 4, move |ids| {
let n = d2.fetch_add(ids.len(), std::sync::atomic::Ordering::SeqCst) + ids.len();
let _ = tx.send(n);
});
q.enqueue_pending(Some(sid));
while (done.load(std::sync::atomic::Ordering::SeqCst) as i64) < want {
if rx.recv_timeout(Duration::from_secs(60)).is_err() {
break;
}
}
}
/// manifest 기대값과 실제 그룹을 대조한다. 반환: 실패 메시지들.
///
/// 판정 기준은 "**같은 원본 계열의 다른 파일과 한 그룹에 있는가**"다. 원본 파일 자신과
/// 직접 묶일 필요는 없다 — 완전 동일 그룹의 keeper만 유사 패스에 남기는 설계(같은 파일이
/// 두 그룹에 중복 표시되는 것을 막는다) 때문에, 바이트 동일 사본을 경유해 연결되는 것이 정상이다.
/// `rotation`은 회전·반전 탐지를 켠 실행인지. 꺼져 있으면 rot90/flip은 단정하지 않는다
/// (그 두 변형만 8변형 질의를 필요로 한다 — 나머지 단계 3 항목은 기본 경로에서 잡힌다).
fn check(entries: &[Entry], groups: &[Group], rotation: bool) -> Vec<String> {
let mut fails = Vec::new();
let mut member_of: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, g) in groups.iter().enumerate() {
for n in &g.members {
member_of.entry(n.as_str()).or_default().push(i);
}
}
// manifest 그룹 → 그 계열의 파일명들
let mut family: HashMap<&str, Vec<&str>> = HashMap::new();
for e in entries {
if let Some(g) = e.group.as_deref() {
family.entry(g).or_default().push(e.name.as_str());
}
}
// 이 실행에서 단정할 단계인가. 단계 1·3·4 모두 구현됐다 —
// 단 rot90/flip은 회전 옵션을 켠 실행에서만 본다.
let asserted = |e: &Entry| match e.stage {
1 | 4 => true,
3 => rotation || !(e.variant == "rot90" || e.variant == "flip"),
_ => false,
};
for e in entries {
let mine = member_of.get(e.name.as_str()).cloned().unwrap_or_default();
match e.expect.as_str() {
"exact" | "similar" | "segment" if asserted(e) => {
let Some(g) = e.group.as_deref() else { continue };
let siblings: Vec<&str> = family
.get(g)
.map(|v| v.iter().copied().filter(|n| *n != e.name.as_str()).collect())
.unwrap_or_default();
let together = mine
.iter()
.any(|i| siblings.iter().any(|s| groups[*i].members.contains(*s)));
if !together {
fails.push(format!(
"{} ({}): {g} 계열과 같은 그룹에 없다 (소속 그룹 {}개)",
e.name,
e.variant,
mine.len()
));
}
// 바이트까지 같은 사본은 '완전 동일' 그룹(kind 0)이어야 한다
if e.expect == "exact" && !mine.iter().any(|i| groups[*i].kind == 0) {
fails.push(format!("{}: 바이트 동일인데 완전 동일 그룹이 아니다", e.name));
}
}
// 묶이면 안 되는 것
"none" if e.stage == 1 => {
if !mine.is_empty() {
let others: Vec<&str> = groups[mine[0]]
.members
.iter()
.filter(|n| n.as_str() != e.name)
.map(|s| s.as_str())
.collect();
fails.push(format!("{}: 묶이면 안 되는데 묶였다 — {:?}", e.name, others));
}
}
_ => {} // 단계 3·4 대상은 지금 무관 (잡혀도 실패 아님)
}
}
fails
}
#[test]
fn fixture_images_group_as_manifest_expects() {
let Some((dir, entries)) = load_manifest("images") else {
eprintln!("픽스처 없음 — 건너뜀 (scripts\\gen-fixtures.ps1 실행)");
return;
};
let tmp = tempfile::tempdir().unwrap();
let thumbs = 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_image_meta(&db, sid, &cancel).unwrap();
build_thumbs(&db, sid, thumbs.path(), None);
let n = sigq::run_image_sigs(&db, thumbs.path(), &cancel, &pause).unwrap();
assert!(n > 0, "이미지 지문이 하나도 생성되지 않았다");
let stats = dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, false, &cancel, |_| {}).unwrap();
assert_eq!(stats.skipped_no_sig, 0, "지문 없이 빠진 파일이 있다");
let names = names(&db);
let groups = groups_by_name(&db, &names);
dump(&db, &groups); // cargo test -- --nocapture 로 실제 결과를 본다
let fails = check(&entries, &groups, false);
assert!(
fails.is_empty(),
"manifest 기대와 다름 ({}건):\n {}\n그룹 {}개: {:?}",
fails.len(),
fails.join("\n "),
groups.len(),
groups
);
// 정지 이미지는 시간축이 없다 — 비교 뷰가 그대로 겹칠 수 있게 오프셋 0이어야 한다
let bad: i64 = db
.with_read(|c| {
c.query_row(
"SELECT count(*) FROM dupe_members WHERE offset_ms IS NULL OR offset_ms <> 0",
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(bad, 0, "이미지 멤버의 offset_ms가 0이 아니다");
}
/// 회전·반전본은 8변형 질의를 켰을 때만 잡힌다 — 켜면 잡히고, 무관한 그림은 여전히 안 묶인다.
#[test]
fn fixture_rotated_copies_found_when_enabled() {
let Some((dir, entries)) = load_manifest("images") else {
eprintln!("픽스처 없음 — 건너뜀");
return;
};
let tmp = tempfile::tempdir().unwrap();
let thumbs = 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_image_meta(&db, sid, &cancel).unwrap();
build_thumbs(&db, sid, thumbs.path(), None);
sigq::run_image_sigs(&db, thumbs.path(), &cancel, &pause).unwrap();
// 먼저 꺼진 상태 — 회전본이 자기 계열과 묶이지 않아야 한다(그게 옵션의 존재 이유다)
dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, false, &cancel, |_| {}).unwrap();
let names = names(&db);
let off_groups = groups_by_name(&db, &names);
let rot_grouped_off = off_groups.iter().any(|g| {
g.members.iter().any(|m| m.contains("_rot90")) && g.members.len() >= 2
});
assert!(!rot_grouped_off, "옵션이 꺼졌는데 회전본이 묶였다");
// 켜고 다시 — 이번엔 잡혀야 한다
dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, true, &cancel, |_| {}).unwrap();
let groups = groups_by_name(&db, &names);
dump(&db, &groups);
let fails = check(&entries, &groups, true);
assert!(
fails.is_empty(),
"회전 옵션을 켠 결과가 manifest 기대와 다름 ({}건):\n {}",
fails.len(),
fails.join("\n ")
);
}
/// 영상 경로 — 프레임 지문까지 만들어야 하므로 느리다(영상 8편 × 30초).
#[test]
fn fixture_videos_group_as_manifest_expects() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let Some((dir, entries)) = load_manifest("videos") else {
eprintln!("픽스처 없음 — 건너뜀 (scripts\\gen-fixtures.ps1 실행)");
return;
};
let tmp = tempfile::tempdir().unwrap();
let thumbs = 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();
build_thumbs(&db, sid, thumbs.path(), Some(t.clone()));
let n = sigq::run_video_sigs(&db, &t, None, &cancel, &pause).unwrap();
assert!(n > 0, "영상 지문이 하나도 생성되지 않았다");
let stats = dedupe::run(&db, dedupe::Scope::Source(sid), 5, true, false, &cancel, |_| {}).unwrap();
assert_eq!(stats.skipped_no_sig, 0, "지문 없이 빠진 영상이 있다");
let names = names(&db);
let groups = groups_by_name(&db, &names);
dump(&db, &groups); // cargo test -- --nocapture 로 실제 결과를 본다
let fails = check(&entries, &groups, false);
assert!(
fails.is_empty(),
"manifest 기대와 다름 ({}건):\n {}\n그룹 {}개: {:?}",
fails.len(),
fails.join("\n "),
groups.len(),
groups
);
// ── 비교 재생용 정렬값 ────────────────────────────────────────────
// 비교 뷰는 `offset_ms`(keeper 시각 멤버 시각)로 두 영상을 맞춘다. 이 값이 틀리면
// 나란히 놓고도 계속 다른 장면을 보게 되므로, 아는 답과 대조한다.
// manifest의 expectOffsetMs는 vidA 기준이고 keeper는 vidA가 아닐 수 있으므로
// (rank_keeper: 해상도 → 파일크기순 → vidA_reenc가 가장 크다) **간격**만 본다.
let rows: Vec<(String, Option<i64>, Option<i64>, Option<i64>)> = db
.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT f.name, dm.offset_ms, dm.seg_start_ms, dm.seg_end_ms
FROM dupe_members dm JOIN files f ON f.id = dm.file_id
JOIN dupe_groups dg ON dg.id = dm.group_id
WHERE dg.kind = 2",
)?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?;
rows.collect()
})
.unwrap();
assert!(!rows.is_empty(), "영상 그룹 멤버가 없다");
let by_name: HashMap<&str, (Option<i64>, Option<i64>, Option<i64>)> =
rows.iter().map(|(n, o, s, e)| (n.as_str(), (*o, *s, *e))).collect();
println!("── 비교 재생 정렬값 (keeper 기준)");
for (n, o, s, e) in &rows {
println!(" {n:<22} offset={o:?} seg={s:?}..{e:?}");
}
let keeper_name: &str = rows
.iter()
.map(|(n, _, _, _)| n.as_str())
.find(|n| by_name[*n].0 == Some(0))
.expect("offset 0인 keeper가 있어야 한다");
let off = |n: &str| by_name.get(n).and_then(|(o, _, _)| *o);
// 같은 원본을 그대로 재인코딩/워터마크/fps변환한 것은 keeper와 시간축이 같다
for n in ["vidA_reenc.mp4", "vidA_wm.mp4", "vidA_fps24.mp4", "vidA.mp4"] {
if n == keeper_name {
continue;
}
if let Some(o) = off(n) {
assert!(o.abs() <= 1000, "{n}: 시간축이 같아야 한다 (offset {o}ms)");
}
}
// 인트로 5초가 붙은 사본과 10초 지점을 잘라낸 사본은 그만큼 어긋나 있어야 한다.
// keeper가 vidA 계열 원본(오프셋 0)이라는 전제 아래 간격을 확인한다.
if let (Some(intro), Some(clip)) = (off("vidA_intro.mp4"), off("vidA_clip.mp4")) {
assert!(
(intro.abs() - 5000).abs() <= 1000,
"인트로 삽입본은 5초 어긋나야 한다 (offset {intro}ms)"
);
assert!(
(clip.abs() - 10000).abs() <= 1000,
"10초 지점 구간 추출본은 10초 어긋나야 한다 (offset {clip}ms)"
);
assert_eq!(intro.signum(), -clip.signum(), "두 사본은 반대 방향으로 어긋난다");
}
// 일치 구간은 keeper 시간축 안에 있어야 한다 (비교 뷰가 '구간으로 이동'에 쓴다)
for (n, _, s, e) in &rows {
if let (Some(s), Some(e)) = (s, e) {
assert!(s < e, "{n}: 구간이 뒤집혔다 ({s}..{e})");
assert!(*s >= 0, "{n}: 구간 시작이 음수 ({s})");
}
}
}
@@ -0,0 +1,137 @@
//! 근거 측정 — 레터박스 사본을 어떻게 잡는지, 그리고 왜 프레임만으로는 못 잡는지.
//! `cargo test -p archive-indexer --test letterbox_evidence -- --ignored --nocapture`
//!
//! 측정 결과(2026-08)와 그로부터 나온 설계:
//!
//! 1. 검정 띠 트림 자체는 정확하다 — 32×32에서 x6..26 y6..26, 딱 안쪽 콘텐츠다.
//! 2. 그런데 20×20을 9×8로 다시 표본화한 해시는 원본(32×32→9×8)과 **거리 7~16**(중앙값 9.5)이고
//! **정확히 같은 값은 0개**다. 후보 생성이 값 기반 토큰이라 두 사본은 영영 만나지 못한다.
//! → 그래서 트림 해시를 후보 토큰으로 쓰려던 시도는 폐기했다(`dedupe::video_candidates` 주석).
//! 3. 오디오는 화면 변형에 영향받지 않는다 — 레터박스 사본이 chromaprint 221/221 일치.
//! → 오디오가 후보를 만든다.
//! 4. 다만 **배경음만 같은 남남**도 221/221로 붙는다(`vidC_sharedaudio`). 둘을 가르는 건 시각뿐:
//! 진짜 사본은 트림 축 프레임 거리 9, 남남은 29. → `AUDIO_VOTE_MAX_DIST = 16`이 그 사이다.
//!
//! 이 파일은 그 네 숫자를 다시 뽑아본다. 임계값을 손댈 일이 생기면 여기부터 돌려라.
use archive_indexer::{ffmpeg::{self, FfTools}, sigmatch};
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 })
}
#[test]
#[ignore = "근거 측정용"]
fn print_letterbox_evidence() {
let Some(t) = tools() else {
eprintln!("사이드카 없음");
return;
};
let dir = repo_root().join(".fixtures/videos");
if !dir.is_dir() {
eprintln!("픽스처 없음");
return;
}
let grab = |name: &str| -> Vec<(u32, Vec<u8>)> {
ffmpeg::frame_gray_sequence(
&t,
&dir.join(name),
1000,
sigmatch::FRAME as u32,
std::time::Duration::from_secs(120),
None,
)
.unwrap_or_default()
};
let a = grab("vidA.mp4");
let l = grab("vidA_letterbox.mp4");
println!("프레임 수: vidA {} · letterbox {}", a.len(), l.len());
if a.is_empty() || l.is_empty() {
return;
}
for i in [0usize, 5, 10] {
let (Some((_, pa)), Some((_, pl))) = (a.get(i), l.get(i)) else { continue };
let (sa, sl) = (sigmatch::derive(pa).unwrap(), sigmatch::derive(pl).unwrap());
let bounds = sigmatch::letterbox(pl);
// 32×32 픽셀에서 검정(값 <= 16) 비율 — 패딩이 얼마나 되는지
let black = pl.iter().filter(|v| **v <= 16).count() * 100 / pl.len();
println!("── 프레임 {i}");
println!(
" letterbox 판정 x{}..{} y{}..{} (검정 픽셀 {black}%)",
bounds.0, bounds.2, bounds.1, bounds.3
);
println!(
" 전체↔전체 {} · 트림(레터박스)↔전체(원본) {} · 전체↔트림 {}",
sigmatch::hamming(sa.dhash, sl.dhash),
sigmatch::hamming(sl.scales[2], sa.dhash),
sigmatch::hamming(sa.dhash, sl.scales[2])
);
println!(
" 원본의 scales[2]는 전체와 같은가: {} (거리 {})",
sa.scales[2] == sa.dhash,
sigmatch::hamming(sa.scales[2], sa.dhash)
);
}
// 후보 생성은 '값이 정확히 같은 토큰'을 요구한다 — 공유 토큰이 몇 개인지가 결정적이다
let sig_of = |frames: &[(u32, Vec<u8>)]| -> (Vec<u64>, Vec<u64>) {
let mut whole = Vec::new();
let mut lb = Vec::new();
for (_, px) in frames {
if let Some(s) = sigmatch::derive(px) {
whole.push(s.dhash);
lb.push(s.scales[2]);
}
}
(whole, lb)
};
let (aw, _al) = sig_of(&a);
let (lw, ll) = sig_of(&l);
let set: std::collections::HashSet<u64> = aw.iter().copied().collect();
println!("\n── 후보 토큰 공유 (정확히 일치하는 값의 개수)");
println!(" 원본 전체해시 {}개 중", set.len());
println!(" 레터박스본 전체해시와 공유: {}", lw.iter().filter(|h| set.contains(h)).count());
println!(" 레터박스본 트림해시와 공유: {}", ll.iter().filter(|h| set.contains(h)).count());
// 거리 분포 — 몇 비트나 벌어지는지
let mut d: Vec<u32> = ll
.iter()
.zip(aw.iter())
.map(|(x, y)| sigmatch::hamming(*x, *y))
.collect();
d.sort_unstable();
println!(" 트림↔전체 프레임별 거리(정렬): {d:?}");
// ── 오디오가 후보를 만들 때 시각으로 진짜/남남을 가를 수 있는가 ──────────
// 오디오는 vidA_letterbox(진짜 사본)와 vidC_sharedaudio(배경음만 같은 남남)를
// 똑같이 221/221 일치로 붙인다. 둘을 가르는 건 시각뿐이므로 그 간격을 잰다.
let median = |mut v: Vec<u32>| -> u32 {
v.sort_unstable();
v.get(v.len() / 2).copied().unwrap_or(0)
};
println!("\n── 오디오 후보를 시각으로 거를 때의 간격 (중앙값, 64비트 중)");
for (name, use_lb) in [("vidA_letterbox.mp4", true), ("vidC_sharedaudio.mp4", false)] {
let f = grab(name);
if f.is_empty() {
println!(" {name}: 프레임 없음");
continue;
}
let (w, lb) = sig_of(&f);
let seq = if use_lb { &lb } else { &w };
let n = seq.len().min(aw.len());
let dm = median((0..n).map(|i| sigmatch::hamming(seq[i], aw[i])).collect());
println!(" vidA ↔ {name:<22} {dm} {}", if use_lb { "(트림 축)" } else { "(전체 축)" });
}
}
@@ -0,0 +1,102 @@
//! 진단용 — `wmbig` 오탐의 실제 근거 수치를 찍는다.
//! `cargo test -p archive-indexer --test wmbig_evidence -- --nocapture`
use archive_indexer::sigmatch::{self, FrameSig};
use std::path::{Path, PathBuf};
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap()
}
fn sig_of(p: &Path) -> Option<FrameSig> {
let img = image::ImageReader::open(p).ok()?.with_guessed_format().ok()?.decode().ok()?;
let gray = img
.resize_exact(
sigmatch::FRAME as u32,
sigmatch::FRAME as u32,
image::imageops::FilterType::Triangle,
)
.to_luma8()
.into_raw();
sigmatch::derive(&gray)
}
fn color_of(p: &Path) -> Option<[i8; 32]> {
let img = image::ImageReader::open(p).ok()?.with_guessed_format().ok()?.decode().ok()?;
let small = img.resize_exact(4, 4, image::imageops::FilterType::Triangle).to_rgb8().into_raw();
sigmatch::color_of(&small)
}
/// 임계값을 다시 손볼 때 근거표를 뽑는 용도. 단정이 없으므로 기본 실행에서는 뺀다.
/// `sigmatch`의 VETO_AGREE_RATIO·CROP_MAX_DIST 주석에 적힌 수치가 여기서 나온 것이다.
#[test]
#[ignore = "근거 측정용 — cargo test --test wmbig_evidence -- --ignored --nocapture"]
fn print_wmbig_evidence() {
let dir = repo_root().join(".fixtures/images");
if !dir.is_dir() {
eprintln!("픽스처 없음 — 건너뜀");
return;
}
let pairs = [
("pos_still3_wmbig.jpg", "pos_still4_wmbig.jpg", "오탐: 서로 다른 원본 + 같은 큰 가림"),
("pos_still3_orig.jpg", "pos_still4_orig.jpg", "그 두 원본 자체"),
("pos_still3_orig.jpg", "pos_still3_wmbig.jpg", "정탐: 원본 ↔ 자기 가림본"),
("pos_still3_orig.jpg", "pos_still3_rot90.jpg", "회전본"),
("pos_still3_orig.jpg", "pos_still3_crop95.jpg", "5% 크롭"),
("pos_still3_orig.jpg", "pos_still3_q12.jpg", "저품질 재인코딩 (거부권에 걸리면 안 된다)"),
("pos_still3_orig.jpg", "pos_still3_half.jpg", "50% 축소"),
("pos_still3_orig.jpg", "pos_still3_bright.jpg", "밝기·대비 변경"),
("pos_still3_orig.jpg", "pos_still3_wm.jpg", "하단 자막 바"),
("neg_bars.jpg", "neg_hdbars.jpg", "무관: 컬러바 두 종"),
];
for (a, b, label) in pairs {
let (pa, pb) = (dir.join(a), dir.join(b));
let (Some(sa), Some(sb)) = (sig_of(&pa), sig_of(&pb)) else {
eprintln!("{label}: 파일 없음");
continue;
};
let ra = sigmatch::RichSig {
dhash: sa.dhash,
orient: sa.orient,
tiles: sa.tiles,
scales: sa.scales,
tile_flat: sa.tile_flat,
color: color_of(&pa),
};
let rb = sigmatch::RichSig {
dhash: sb.dhash,
orient: sb.orient,
tiles: sb.tiles,
scales: sb.scales,
tile_flat: sb.tile_flat,
color: color_of(&pb),
};
let whole = sigmatch::hamming(ra.dhash, rb.dhash);
let (agree_loose, seen) = sigmatch::informative_agreement(&ra, &rb, sigmatch::TILE_PER_TILE);
let (agree_strict, _) =
sigmatch::informative_agreement(&ra, &rb, sigmatch::TILE_STRICT_PER_TILE);
let cdist = match (&ra.color, &rb.color) {
(Some(x), Some(y)) => sigmatch::color_distance(x, y) as i64,
_ => -1,
};
let verdict = sigmatch::verify_pair(&ra, &rb, 5, true);
println!("── {label}");
println!(" 전체 dHash 거리 {whole} · 색차 {cdist} · 판정 {verdict:?}");
println!(
" 정보타일 {seen}개 중 근접 {agree_loose}(느슨 ≤{})/{agree_strict}(엄격 ≤{})",
sigmatch::TILE_PER_TILE,
sigmatch::TILE_STRICT_PER_TILE
);
// 타일별로 거리와 '정보 없음' 여부를 같이 본다 (X = 판정에서 제외)
let cells: Vec<String> = (0..16)
.map(|i| {
let d = sigmatch::hamming(ra.tiles[i], rb.tiles[i]);
let skip = (ra.tile_flat >> i) & 1 == 1 || (rb.tile_flat >> i) & 1 == 1;
format!("{}{d:>2}", if skip { "X" } else { " " })
})
.collect();
for row in 0..4 {
println!(" {}", cells[row * 4..row * 4 + 4].join(" "));
}
}
}
+1105 -63
View File
File diff suppressed because it is too large Load Diff
+137 -23
View File
@@ -28,6 +28,10 @@ pub struct AppState {
pub scan_running: Arc<AtomicBool>,
pub dedupe_cancel: Arc<AtomicBool>,
pub dedupe_running: Arc<AtomicBool>,
/// 지문 워커 중단 플래그 (앱 종료 시)
pub sig_cancel: Arc<AtomicBool>,
/// 하드웨어 디코드 사용 여부 — 기동 시 1회 실제 디코드로 판별한 결과
pub hwaccel: Option<&'static str>,
pub remote: Arc<remote::RemotePool>,
/// 자격증명 저장소 (포터블 볼트 잠금 해제 후 교체 가능)
pub creds: Arc<std::sync::Mutex<archive_vfs::creds::CredStore>>,
@@ -40,13 +44,44 @@ fn now_ms() -> i64 {
.unwrap_or(0)
}
fn main() {
/// 로그를 **파일로도** 남긴다. 릴리스는 windows_subsystem="windows"라 콘솔이 없어서
/// stdout만 쓰면 사용자 PC에서 무슨 일이 있었는지 확인할 방법이 아예 없다
/// (실제로 "지문이 안 만들어진다"는 신고에서 로그 폴더가 비어 있어 원인을 못 찾았다).
///
/// 가드를 돌려주는 이유: 드롭되면 백그라운드 기록 스레드가 멈춰 마지막 줄들이 날아간다.
/// main이 끝날 때까지 살아 있어야 한다.
fn init_logging() -> Option<tracing_appender::non_blocking::WorkerGuard> {
let filter = || {
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())
};
// 데이터 위치는 setup보다 먼저 알아야 한다(로그를 그 안에 쓰므로). 실패하면 stdout만.
let Ok(data) = paths::resolve_data_dir() else {
tracing_subscriber::fmt().with_env_filter(filter()).init();
return None;
};
let dir = data.path.join("logs");
if std::fs::create_dir_all(&dir).is_err() {
tracing_subscriber::fmt().with_env_filter(filter()).init();
return None;
}
// 하루 한 파일. 오래된 것은 남겨둔다 — 며칠 전 증상을 되짚어야 하는 일이 잦다.
let appender = tracing_appender::rolling::daily(&dir, "archive.log");
let (writer, guard) = tracing_appender::non_blocking(appender);
// 파일 **과** stdout 둘 다. 파일만 쓰면 개발 중 콘솔에서 로그가 사라져
// `scripts/dev.cmd > log`로 앱 동작을 들여다보던 길이 막힌다.
use tracing_subscriber::fmt::writer::MakeWriterExt;
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.with_env_filter(filter())
.with_ansi(false) // 파일에 색 이스케이프가 섞이면 읽을 수 없다
.with_writer(writer.and(std::io::stdout))
.init();
tracing::info!(dir = %dir.display(), "로그 파일 기록 시작");
Some(guard)
}
fn main() {
// main이 끝날 때까지 유지 — 드롭되면 기록 스레드가 멈춘다
let _log_guard = init_logging();
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
@@ -89,33 +124,24 @@ fn main() {
tools.as_ref().map(|t| t.ffmpeg.clone()),
))?;
// 썸네일 워커: 코어 수 - 1
let workers = std::thread::available_parallelism()
.map(|n| n.get().saturating_sub(1).max(1))
.unwrap_or(2);
// 썸네일 워커 상한: 코어×2를 4–16으로 클램프. 실제 동시 처리 개수는 큐가
// 처리율을 보며 이 범위 안에서 스스로 맞춘다 — NVMe는 코어 수 근처까지,
// HDD는 하한(2)까지 내려가고, SMB는 지연을 가리려 코어 수보다 높이 올라간다.
let max_workers = std::thread::available_parallelism()
.map(|n| (n.get() * 2).clamp(4, 16))
.unwrap_or(4);
let emit_handle = app.handle().clone();
let thumbs = Arc::new(ThumbQueue::start(
db.clone(),
thumb_root,
tools.clone(),
workers,
max_workers,
move |ids| {
let _ = emit_handle.emit("thumbs-ready", ids);
},
));
// 앱 시작 시 미처리 썸네일 백필 — 백그라운드로.
// (대용량/네트워크(SMB) 소스에서 시작 시 메인 스레드 블로킹 방지)
{
let thumbs = thumbs.clone();
let db = db.clone();
std::thread::Builder::new()
.name("thumb-backfill".into())
.spawn(move || {
archive_indexer::prio::set_current_thread_low();
thumbs.enqueue_pending(&db, None);
})
.ok();
}
// 앱 시작 시 미처리 썸네일 백필 — 전용 피더 스레드에서 커서로 흘려보낸다(즉시 반환).
thumbs.enqueue_pending(None);
#[cfg(debug_assertions)]
tracing::info!(
@@ -124,6 +150,11 @@ fn main() {
media.token
);
// 하드웨어 디코드 가용성 — 실제 디코드 1회로 판별한다(컴파일 지원만으로는 알 수 없다).
// 실패하면 소프트웨어 디코드로만 간다.
let hwaccel = tools.as_ref().and_then(archive_indexer::ffmpeg::detect_hwaccel);
tracing::info!(?hwaccel, "지문 추출 하드웨어 디코드");
app.manage(AppState {
db,
data_dir: data.path,
@@ -136,6 +167,8 @@ fn main() {
scan_running: Arc::new(AtomicBool::new(false)),
dedupe_cancel: Arc::new(AtomicBool::new(false)),
dedupe_running: Arc::new(AtomicBool::new(false)),
sig_cancel: Arc::new(AtomicBool::new(false)),
hwaccel,
remote,
creds: Arc::new(std::sync::Mutex::new(
archive_vfs::creds::CredStore::Keychain,
@@ -270,6 +303,64 @@ fn main() {
.ok();
}
// 지문 백필 — 썸네일처럼 상시 저우선순위로 흐른다.
// 이미지는 캐시된 썸네일에서 뽑으므로 썸네일 큐 뒤를 따라가고, 영상은 ffmpeg 한 패스다.
// `sig_state`가 곧 큐라서 커서가 없고, 앱을 껐다 켜도 남은 것부터 이어진다.
// 스캔 중에는 pause 플래그(=scan_running)로 양보한다 — 둘 다 원본 I/O를 태우기 때문.
{
let state = app.state::<AppState>();
let db = state.db.clone();
let tools = state.tools.clone();
let thumb_root = state.data_dir.join("thumbs");
let cancel = state.sig_cancel.clone();
let pause = state.scan_running.clone();
let hw = state.hwaccel;
std::thread::Builder::new()
.name("sig-backfill".into())
.spawn(move || {
archive_indexer::prio::set_current_thread_low();
// 지문 알고리즘 버전이 바뀌었으면 먼저 무효화한다 — 안 하면 옛 버전 지문이
// 남아 중복 탐지가 조용히 아무것도 못 찾는다. 버전이 그대로면 비용 0.
match archive_indexer::sigq::invalidate_stale_sigs(&db) {
Ok(n) if n > 0 => tracing::info!(count = n, "지문 버전 변경 — 재계산 예약"),
Err(e) => tracing::warn!("지문 무효화 오류: {e}"),
_ => {}
}
loop {
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
match archive_indexer::sigq::run_image_sigs(
&db, &thumb_root, &cancel, &pause,
) {
Ok(n) if n > 0 => tracing::info!(count = n, "이미지 지문 생성"),
Err(e) => tracing::warn!("이미지 지문 오류: {e}"),
_ => {}
}
if let Some(t) = &tools {
match archive_indexer::sigq::run_video_sigs(
&db, t, hw, &cancel, &pause,
) {
Ok(n) if n > 0 => tracing::info!(count = n, "영상 지문 생성"),
Err(e) => tracing::warn!("영상 지문 오류: {e}"),
_ => {}
}
}
// 새 스캔·썸네일 완료를 자연히 흡수한다 (이벤트 배선보다 단순하고 아이들 비용이 거의 0).
// 쉬는 시간도 속도 설정을 따른다 — 2만 건이 밀려 있는데 30초씩 노는 것은
// 그 자체가 느린 것이다.
let idle = archive_indexer::sigq::read_speed(&db).idle_secs();
for _ in 0..idle {
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
})
.ok();
}
// dev 편의: ARCHIVE_AUTO_SOURCE=<폴더> → 시작 시 소스 등록 + 스캔
#[cfg(debug_assertions)]
if let Ok(auto) = std::env::var("ARCHIVE_AUTO_SOURCE") {
@@ -314,7 +405,12 @@ fn main() {
commands::list_sources,
commands::add_local_source,
commands::remove_source,
commands::update_source,
commands::reload_source,
commands::purge_missing,
commands::source_status,
commands::start_scan,
commands::rescan_all,
commands::cancel_scan,
commands::folder_tree,
commands::folder_snapshot,
@@ -329,10 +425,25 @@ fn main() {
commands::rename_tag,
commands::delete_tag,
commands::assign_tags,
commands::set_rating,
commands::edit_files,
commands::cut_file,
commands::filmstrip,
commands::list_tag_sets,
commands::create_tag_set,
commands::update_tag_set,
commands::delete_tag_set,
commands::apply_tag_set,
commands::unassign_tags,
commands::file_tags_of,
commands::file_tags_bulk,
commands::export_tags,
commands::tag_snapshot,
commands::trash_tag_id,
commands::mark_delete,
commands::unmark_delete,
commands::pending_delete_stats,
commands::empty_deleted,
commands::move_files,
commands::rename_file,
commands::trash_files,
@@ -345,6 +456,9 @@ fn main() {
commands::cancel_dedupe,
commands::list_dupe_groups,
commands::resolve_dupe_group,
commands::set_dupe_keeper,
commands::sig_speed,
commands::set_sig_speed,
commands::dismiss_dupe_pair,
commands::add_remote_source,
commands::connect_remote_source,
+90 -4
View File
@@ -16,20 +16,89 @@ type Debouncer = notify_debouncer_full::Debouncer<
notify_debouncer_full::RecommendedCache,
>;
/// 워치 불가 소스의 **첫** 재스캔 간격. 네트워크 공유(SMB/NAS)는 변경 알림을 안 주는
/// 서버가 있어 폴백이 필요하다.
const POLL_MIN: Duration = Duration::from_secs(60);
/// 변경이 계속 없을 때 늘어나는 상한.
///
/// 고정 60초는 대규모 소스에서 치명적이다 — 100만 건 열거가 60초 안에 끝나지 않으므로
/// 재스캔이 끝나기 전에 다음 재스캔이 큐에 쌓이고, 앱이 켜져 있는 동안 영구히 디스크를 간다.
/// 그래서 변경이 없으면 간격을 2배씩 늘리고(상한 30분), 변경이 있으면 즉시 되돌린다.
const POLL_MAX: Duration = Duration::from_secs(30 * 60);
/// 만기 검사 주기 — 간격 자체가 아니라 '지금 만기된 소스가 있는지' 보는 틱.
const POLL_TICK: Duration = Duration::from_secs(5);
/// 폴링 소스 한 개의 상태. 소스별로 따로 늘어난다(느린 NAS 하나가 다른 소스를 늦추지 않는다).
struct PollState {
/// 다음 재스캔 예정 시각
due: std::time::Instant,
/// 현재 간격 (POLL_MIN..=POLL_MAX)
interval: Duration,
}
type PollMap = std::sync::Arc<Mutex<HashMap<i64, PollState>>>;
pub struct WatcherManager {
watchers: Mutex<HashMap<i64, Debouncer>>,
/// 워치 불가로 폴링에 의존하는 소스 (네트워크 공유 등)
polled: PollMap,
dirty_tx: crossbeam_channel::Sender<i64>,
}
/// 재스캔 결과를 폴링 간격에 반영한다. 변경이 있었으면 최소 간격으로 되돌리고,
/// 없었으면 2배로 늘린다. 폴링 대상이 아닌 소스(워처가 붙은 소스)는 아무 일도 하지 않는다.
fn report_scan(polled: &PollMap, source_id: i64, changed: bool) {
let mut map = polled.lock().unwrap();
if let Some(st) = map.get_mut(&source_id) {
st.interval = if changed {
POLL_MIN
} else {
(st.interval * 2).min(POLL_MAX)
};
st.due = std::time::Instant::now() + st.interval;
tracing::debug!(source_id, secs = st.interval.as_secs(), changed, "폴링 간격 갱신");
}
}
impl WatcherManager {
/// 워커 스레드를 시작하고 매니저를 만든다.
pub fn new(app: tauri::AppHandle) -> std::sync::Arc<WatcherManager> {
let (tx, rx) = crossbeam_channel::unbounded::<i64>();
let polled: PollMap = std::sync::Arc::new(Mutex::new(HashMap::new()));
let manager = std::sync::Arc::new(WatcherManager {
watchers: Mutex::new(HashMap::new()),
dirty_tx: tx,
polled: polled.clone(),
dirty_tx: tx.clone(),
});
// 워치 불가 소스 폴링 — 이벤트를 못 받는 공유도 결국 최신화된다.
// 만기된 소스만 보낸다. 보낼 때 다음 만기를 미리 밀어 두므로, 재스캔이 오래 걸려도
// 그 사이에 같은 소스가 다시 큐에 쌓이지 않는다(간격 갱신은 report_scan이 한다).
{
let polled = polled.clone();
std::thread::Builder::new()
.name("watch-poll".into())
.spawn(move || loop {
std::thread::sleep(POLL_TICK);
let now = std::time::Instant::now();
let mut due: Vec<i64> = Vec::new();
{
let mut map = polled.lock().unwrap();
for (id, st) in map.iter_mut() {
if st.due <= now {
due.push(*id);
st.due = now + st.interval;
}
}
}
for id in due {
let _ = tx.send(id);
}
})
.expect("watch-poll 스레드 생성 실패");
}
let poll_report = polled;
std::thread::Builder::new()
.name("watch-rescan".into())
.spawn(move || {
@@ -56,7 +125,7 @@ impl WatcherManager {
match root {
Ok(root) => {
tracing::info!(source_id, "워처 트리거 재스캔");
crate::commands::run_scan_pipeline(
let stats = crate::commands::run_scan_pipeline(
&state.db,
&state.tools,
&state.thumbs,
@@ -68,6 +137,12 @@ impl WatcherManager {
let _ = app.emit("library-changed", source_id);
},
);
// 아무것도 바뀌지 않았으면 다음 폴링을 늦춘다 —
// 조용한 대규모 NAS를 60초마다 다시 훑지 않게 하는 핵심이다.
let changed = stats
.map(|s| s.added + s.changed + s.removed + s.moved > 0)
.unwrap_or(false);
report_scan(&poll_report, source_id, changed);
state.scan_running.store(false, Ordering::SeqCst);
let _ = app.emit("library-changed", source_id);
}
@@ -88,6 +163,7 @@ impl WatcherManager {
if self.watchers.lock().unwrap().remove(&source_id).is_some() {
tracing::info!(source_id, "파일 워처 해제");
}
self.polled.lock().unwrap().remove(&source_id);
}
/// DB의 로컬 소스 전체를 워치 대상으로 동기화한다 (시작 시 + 소스 추가 후 호출).
@@ -127,8 +203,18 @@ impl WatcherManager {
watchers.insert(source_id, debouncer);
}
Err(e) => {
// 네트워크 드라이브 등 워치 불가 — 수동 새로고침으로 폴백
tracing::warn!(source_id, "워치 불가(수동 새로고침 사용): {e}");
// 네트워크 드라이브 등 워치 불가 — 주기적 재스캔으로 폴백
tracing::warn!(
source_id,
secs = POLL_MIN.as_secs(),
"워치 불가 — 주기 재스캔으로 폴백(변경 없으면 간격 증가): {e}"
);
self.polled.lock().unwrap().entry(source_id).or_insert_with(|| {
PollState {
due: std::time::Instant::now() + POLL_MIN,
interval: POLL_MIN,
}
});
}
}
}