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