영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
중복 탐지 - 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시) - 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(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
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(¤t) { 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::*;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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가 거의 같아야 한다
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 값(1–8)을 읽는다. 없으면 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 섞인 데이터에서 큰 것을 고른다
|
||||
|
||||
Reference in New Issue
Block a user