영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
중복 탐지 - 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시) - 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(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
@@ -16,20 +16,89 @@ type Debouncer = notify_debouncer_full::Debouncer<
|
||||
notify_debouncer_full::RecommendedCache,
|
||||
>;
|
||||
|
||||
/// 워치 불가 소스의 **첫** 재스캔 간격. 네트워크 공유(SMB/NAS)는 변경 알림을 안 주는
|
||||
/// 서버가 있어 폴백이 필요하다.
|
||||
const POLL_MIN: Duration = Duration::from_secs(60);
|
||||
/// 변경이 계속 없을 때 늘어나는 상한.
|
||||
///
|
||||
/// 고정 60초는 대규모 소스에서 치명적이다 — 100만 건 열거가 60초 안에 끝나지 않으므로
|
||||
/// 재스캔이 끝나기 전에 다음 재스캔이 큐에 쌓이고, 앱이 켜져 있는 동안 영구히 디스크를 간다.
|
||||
/// 그래서 변경이 없으면 간격을 2배씩 늘리고(상한 30분), 변경이 있으면 즉시 되돌린다.
|
||||
const POLL_MAX: Duration = Duration::from_secs(30 * 60);
|
||||
/// 만기 검사 주기 — 간격 자체가 아니라 '지금 만기된 소스가 있는지' 보는 틱.
|
||||
const POLL_TICK: Duration = Duration::from_secs(5);
|
||||
|
||||
/// 폴링 소스 한 개의 상태. 소스별로 따로 늘어난다(느린 NAS 하나가 다른 소스를 늦추지 않는다).
|
||||
struct PollState {
|
||||
/// 다음 재스캔 예정 시각
|
||||
due: std::time::Instant,
|
||||
/// 현재 간격 (POLL_MIN..=POLL_MAX)
|
||||
interval: Duration,
|
||||
}
|
||||
|
||||
type PollMap = std::sync::Arc<Mutex<HashMap<i64, PollState>>>;
|
||||
|
||||
pub struct WatcherManager {
|
||||
watchers: Mutex<HashMap<i64, Debouncer>>,
|
||||
/// 워치 불가로 폴링에 의존하는 소스 (네트워크 공유 등)
|
||||
polled: PollMap,
|
||||
dirty_tx: crossbeam_channel::Sender<i64>,
|
||||
}
|
||||
|
||||
/// 재스캔 결과를 폴링 간격에 반영한다. 변경이 있었으면 최소 간격으로 되돌리고,
|
||||
/// 없었으면 2배로 늘린다. 폴링 대상이 아닌 소스(워처가 붙은 소스)는 아무 일도 하지 않는다.
|
||||
fn report_scan(polled: &PollMap, source_id: i64, changed: bool) {
|
||||
let mut map = polled.lock().unwrap();
|
||||
if let Some(st) = map.get_mut(&source_id) {
|
||||
st.interval = if changed {
|
||||
POLL_MIN
|
||||
} else {
|
||||
(st.interval * 2).min(POLL_MAX)
|
||||
};
|
||||
st.due = std::time::Instant::now() + st.interval;
|
||||
tracing::debug!(source_id, secs = st.interval.as_secs(), changed, "폴링 간격 갱신");
|
||||
}
|
||||
}
|
||||
|
||||
impl WatcherManager {
|
||||
/// 워커 스레드를 시작하고 매니저를 만든다.
|
||||
pub fn new(app: tauri::AppHandle) -> std::sync::Arc<WatcherManager> {
|
||||
let (tx, rx) = crossbeam_channel::unbounded::<i64>();
|
||||
let polled: PollMap = std::sync::Arc::new(Mutex::new(HashMap::new()));
|
||||
let manager = std::sync::Arc::new(WatcherManager {
|
||||
watchers: Mutex::new(HashMap::new()),
|
||||
dirty_tx: tx,
|
||||
polled: polled.clone(),
|
||||
dirty_tx: tx.clone(),
|
||||
});
|
||||
|
||||
// 워치 불가 소스 폴링 — 이벤트를 못 받는 공유도 결국 최신화된다.
|
||||
// 만기된 소스만 보낸다. 보낼 때 다음 만기를 미리 밀어 두므로, 재스캔이 오래 걸려도
|
||||
// 그 사이에 같은 소스가 다시 큐에 쌓이지 않는다(간격 갱신은 report_scan이 한다).
|
||||
{
|
||||
let polled = polled.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("watch-poll".into())
|
||||
.spawn(move || loop {
|
||||
std::thread::sleep(POLL_TICK);
|
||||
let now = std::time::Instant::now();
|
||||
let mut due: Vec<i64> = Vec::new();
|
||||
{
|
||||
let mut map = polled.lock().unwrap();
|
||||
for (id, st) in map.iter_mut() {
|
||||
if st.due <= now {
|
||||
due.push(*id);
|
||||
st.due = now + st.interval;
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in due {
|
||||
let _ = tx.send(id);
|
||||
}
|
||||
})
|
||||
.expect("watch-poll 스레드 생성 실패");
|
||||
}
|
||||
let poll_report = polled;
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("watch-rescan".into())
|
||||
.spawn(move || {
|
||||
@@ -56,7 +125,7 @@ impl WatcherManager {
|
||||
match root {
|
||||
Ok(root) => {
|
||||
tracing::info!(source_id, "워처 트리거 재스캔");
|
||||
crate::commands::run_scan_pipeline(
|
||||
let stats = crate::commands::run_scan_pipeline(
|
||||
&state.db,
|
||||
&state.tools,
|
||||
&state.thumbs,
|
||||
@@ -68,6 +137,12 @@ impl WatcherManager {
|
||||
let _ = app.emit("library-changed", source_id);
|
||||
},
|
||||
);
|
||||
// 아무것도 바뀌지 않았으면 다음 폴링을 늦춘다 —
|
||||
// 조용한 대규모 NAS를 60초마다 다시 훑지 않게 하는 핵심이다.
|
||||
let changed = stats
|
||||
.map(|s| s.added + s.changed + s.removed + s.moved > 0)
|
||||
.unwrap_or(false);
|
||||
report_scan(&poll_report, source_id, changed);
|
||||
state.scan_running.store(false, Ordering::SeqCst);
|
||||
let _ = app.emit("library-changed", source_id);
|
||||
}
|
||||
@@ -88,6 +163,7 @@ impl WatcherManager {
|
||||
if self.watchers.lock().unwrap().remove(&source_id).is_some() {
|
||||
tracing::info!(source_id, "파일 워처 해제");
|
||||
}
|
||||
self.polled.lock().unwrap().remove(&source_id);
|
||||
}
|
||||
|
||||
/// DB의 로컬 소스 전체를 워치 대상으로 동기화한다 (시작 시 + 소스 추가 후 호출).
|
||||
@@ -127,8 +203,18 @@ impl WatcherManager {
|
||||
watchers.insert(source_id, debouncer);
|
||||
}
|
||||
Err(e) => {
|
||||
// 네트워크 드라이브 등 워치 불가 — 수동 새로고침으로 폴백
|
||||
tracing::warn!(source_id, "워치 불가(수동 새로고침 사용): {e}");
|
||||
// 네트워크 드라이브 등 워치 불가 — 주기적 재스캔으로 폴백
|
||||
tracing::warn!(
|
||||
source_id,
|
||||
secs = POLL_MIN.as_secs(),
|
||||
"워치 불가 — 주기 재스캔으로 폴백(변경 없으면 간격 증가): {e}"
|
||||
);
|
||||
self.polled.lock().unwrap().entry(source_id).or_insert_with(|| {
|
||||
PollState {
|
||||
due: std::time::Instant::now() + POLL_MIN,
|
||||
interval: POLL_MIN,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user