영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
중복 탐지 - 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시) - 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(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:
co-authored by
Claude Opus 5
parent
67d2ddc59e
commit
8aa74c1a8d
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user