영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
CI / windows-x64 (push) Canceled after 0s
CI / windows-arm64-cross (push) Canceled after 0s
CI / windows-arm64-native (push) Canceled after 0s
CI / macos (push) Canceled after 0s

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ncakanghan
2026-08-03 16:18:24 +09:00
co-authored by Claude Opus 5
parent 67d2ddc59e
commit 8aa74c1a8d
67 changed files with 19747 additions and 1283 deletions
+135 -98
View File
@@ -1,4 +1,5 @@
// 커스텀 justified 가상 그리드 — 레이아웃은 워커에서, 렌더는 뷰포트 ±1화면만.
import { IconImage, IconTrash } from "../../ui/icons";
import {
createEffect,
createMemo,
@@ -10,15 +11,15 @@ import {
Show,
type Component,
} from "solid-js";
import { createStore, produce } from "solid-js/store";
import { createStore, produce, reconcile } from "solid-js/store";
import { computeLayout, type LayoutResult } from "./layout";
import {
anchorIndex,
clearSelection,
gridRowHeight,
info,
markedIds,
openLightbox,
openShortcuts,
registerItemViewFocus,
selected,
selectSingle,
setSelectionFromIds,
@@ -26,20 +27,29 @@ import {
toggleSelect,
selectRange,
snapshot,
tagVersion,
thumbVersions,
trashTag,
zoomGrid,
} from "../../state/store";
import { fileDetails, setThumbPriority, thumbUrl, type Snapshot } from "../../ipc/commands";
import { ko } from "../../i18n/ko";
import {
openContextMenu,
openRenameDialog,
performTrash,
performUndo,
} from "../fileops/ContextMenu";
fileDetails,
fileTagsBulk,
setThumbPriority,
thumbUrl,
type Snapshot,
} from "../../ipc/commands";
import { ko } from "../../i18n/ko";
import { openContextMenu } from "../fileops/ContextMenu";
import { handleItemKeys } from "../browse/itemKeys";
const GAP = 6;
const GAP = 8;
/** 스크롤러 안쪽 여백. 레이아웃 폭·마퀴 좌표가 이 한 상수를 공유한다 —
흩어져 있으면 여백을 바꾼 순간 드래그 사각형이 커서에서 어긋난다. */
const PAD = 8;
const OVERSCAN_PX = 800;
/** 이 폭 미만 셀에서는 태그 라벨을 감춘다 (썸네일을 가림) */
const TAG_MIN_CELL_W = 120;
function fmtSize(bytes: number): string {
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)}MB`;
@@ -74,7 +84,7 @@ export const JustifiedGrid: Component = () => {
setLayout(undefined);
return;
}
const w = containerRef.clientWidth - 16; // 패딩 보정
const w = containerRef.clientWidth - PAD * 2; // 패딩 보정
if (w <= 0) return;
const ratios = snap.aspectRatios();
setLayout(computeLayout(ratios, w, gridRowHeight(), GAP));
@@ -85,6 +95,8 @@ export const JustifiedGrid: Component = () => {
onMount(() => {
const el = containerRef!;
// 크롬(툴바·브레드크럼·폴더 칩·사이드바)이 클릭 뒤 방향키를 여기로 되돌린다
onCleanup(registerItemViewFocus(() => containerRef?.focus({ preventScroll: true })));
let lastW = -1;
let lastRh = -1;
// 폭과 줌(gridRowHeight)을 함께 감시 — 어느 쪽이 바뀌든 재배치.
@@ -165,6 +177,43 @@ export const JustifiedGrid: Component = () => {
});
onCleanup(() => clearTimeout(prioTimer));
// 썸네일 위 태그 라벨 — 가시 범위만 배치 조회. 태그가 바뀌면(tagVersion) 캐시를 버린다.
const [tagCache, setTagCache] = createStore<Record<number, string[]>>({});
let tagTimer: ReturnType<typeof setTimeout> | undefined;
createEffect(
on(tagVersion, () => setTagCache(reconcile({})), { defer: true }),
);
createEffect(() => {
const [first, last] = visibleRange();
const snap = snapshot();
tagVersion(); // 태그 변경 후에도 다시 읽도록 구독
if (!snap || last < first) return;
clearTimeout(tagTimer);
tagTimer = setTimeout(() => {
const need: number[] = [];
for (let i = first; i <= last && need.length < 512; i++) {
const id = snap.id(i);
if (tagCache[id] === undefined) need.push(id);
}
if (need.length === 0) return;
void fileTagsBulk(need)
.then((links) =>
setTagCache(
produce((s) => {
// 조회한 id는 태그가 없어도 빈 배열로 채워 재조회를 막는다
for (const id of need) s[id] = [];
for (const l of links) {
if (l.tagId === trashTag()) continue; // 삭제 예정은 🗑 배지가 담당
(s[l.fileId] ??= []).push(l.name);
}
}),
),
)
.catch(() => {});
}, 150);
});
onCleanup(() => clearTimeout(tagTimer));
// 정보 오버레이용 파일명/크기 지연 로드 (showInfo 켜졌을 때 가시 범위만)
const [detailsCache, setDetailsCache] = createStore<Record<number, { name: string; size: number }>>({});
let detTimer: ReturnType<typeof setTimeout> | undefined;
@@ -234,80 +283,29 @@ export const JustifiedGrid: Component = () => {
return best;
};
/** 선택된 셀을 화면 안으로 (키보드 이동용) */
const revealCell = (index: number) => {
const l = layout();
const el = containerRef;
if (!l || !el) return;
const top = l.boxes[index * 4 + 1];
const h = l.boxes[index * 4 + 3];
if (top < el.scrollTop) el.scrollTop = top;
else if (top + h > el.scrollTop + el.clientHeight)
el.scrollTop = top + h - el.clientHeight;
};
const onKeyDown = (e: KeyboardEvent) => {
const snap = snapshot();
if (!snap || snap.count === 0) return;
const anchor = anchorIndex();
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
void performUndo();
return;
}
// Ctrl+A 전체 선택 / Ctrl+Shift+A 선택 반전
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
e.preventDefault();
const all: number[] = [];
for (let i = 0; i < snap.count; i++) all.push(snap.id(i));
if (e.shiftKey) {
const cur = selected();
setSelectionFromIds(all.filter((id) => !cur.has(id)));
} else {
setSelectionFromIds(all);
}
return;
}
// ? 단축키 도움말
if (e.key === "?") {
e.preventDefault();
openShortcuts();
return;
}
switch (e.key) {
case "Enter":
case " ":
if (anchor >= 0) {
e.preventDefault();
openLightbox(anchor);
}
break;
case "Delete":
if (selected().size > 0) {
e.preventDefault();
void performTrash();
}
break;
case "F2":
if (anchor >= 0 && selected().size === 1) {
e.preventDefault();
const id = snap.id(anchor);
void fileDetails([id]).then((d) => {
if (d[0]) openRenameDialog(id, d[0].name);
});
}
break;
case "ArrowLeft":
if (anchor > 0) {
e.preventDefault();
selectSingle(snap.id(anchor - 1), anchor - 1);
}
break;
case "ArrowRight":
if (anchor >= 0 && anchor < snap.count - 1) {
e.preventDefault();
selectSingle(snap.id(anchor + 1), anchor + 1);
}
break;
case "ArrowUp":
case "ArrowDown": {
if (anchor < 0) break;
e.preventDefault();
const next = verticalNeighbor(anchor, e.key === "ArrowDown" ? 1 : -1);
if (next !== undefined) selectSingle(snap.id(next), next);
break;
}
}
if (!snap) return;
handleItemKeys(e, snap, {
neighbor: (from, key) => {
if (key === "ArrowLeft") return from > 0 ? from - 1 : undefined;
if (key === "ArrowRight") return from < snap.count - 1 ? from + 1 : undefined;
return verticalNeighbor(from, key === "ArrowDown" ? 1 : -1);
},
onMove: revealCell,
});
};
const onCellContextMenu = (e: MouseEvent, snap: Snapshot, index: number) => {
@@ -327,12 +325,12 @@ export const JustifiedGrid: Component = () => {
};
// ── 마퀴(드래그 사각형) 선택 ──
// 콘텐츠 좌표 = clientX - 컨테이너rect - 패딩(8) (+ y는 scrollTop)
// 콘텐츠 좌표 = clientX - 컨테이너rect - 패딩(PAD) (+ y는 scrollTop)
const toContent = (clientX: number, clientY: number): [number, number] => {
const rect = containerRef!.getBoundingClientRect();
return [
clientX - rect.left - 8,
clientY - rect.top - 8 + containerRef!.scrollTop,
clientX - rect.left - PAD,
clientY - rect.top - PAD + containerRef!.scrollTop,
];
};
@@ -397,7 +395,8 @@ export const JustifiedGrid: Component = () => {
onKeyDown={onKeyDown}
onMouseDown={onBgMouseDown}
tabindex={0}
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 outline-none"
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden outline-none"
style={{ padding: `${PAD}px` }}
>
<Show
when={snapshot() && snapshot()!.count > 0}
@@ -415,7 +414,7 @@ export const JustifiedGrid: Component = () => {
<Show when={marqueeRect()}>
{(r) => (
<div
class="pointer-events-none absolute z-10 border border-[var(--accent)] bg-[var(--accent-soft)]"
class="marquee pointer-events-none absolute z-10"
style={{
left: `${r().left}px`,
top: `${r().top}px`,
@@ -453,11 +452,23 @@ export const JustifiedGrid: Component = () => {
const s = snapshot();
return !!s && s.kind(i) === 1;
};
const marked = () => markedIds().has(id());
// 별점은 스냅샷 flags 비트2~4에서 온다(추가 조회 없음)
const rating = () => {
const s = snapshot();
return s ? s.rating(i) : 0;
};
// 좁은 셀일수록 적게 — 나머지는 +N으로 접는다
const cellTags = () => tagCache[id()] ?? [];
const tagLimit = () => (box()[2] >= 260 ? 3 : box()[2] >= 180 ? 2 : 1);
const visibleTags = () => cellTags().slice(0, tagLimit());
const hiddenTagCount = () => Math.max(0, cellTags().length - tagLimit());
return (
<div
class="absolute cursor-pointer overflow-hidden rounded-[3px] bg-[var(--grid-cell-bg)] outline-offset-[-2px]"
class="grid-cell"
classList={{
"outline outline-2 outline-[var(--accent)]": selected().has(id()),
"is-sel": selected().has(id()),
"is-marked": marked() && !selected().has(id()),
}}
style={{
transform: `translate(${box()[0]}px, ${box()[1]}px)`,
@@ -487,11 +498,11 @@ export const JustifiedGrid: Component = () => {
when={!failed()}
fallback={
<div class="grid h-full w-full place-items-center text-[var(--text-muted)]">
<span class="text-base opacity-60">🖼</span>
<IconImage size={18} class="opacity-60" />
</div>
}
>
<div class="h-full w-full animate-pulse bg-[var(--bg-hover)]" />
<div class="h-full w-full animate-pulse bg-[var(--grid-skeleton)]" />
</Show>
}
>
@@ -509,16 +520,42 @@ export const JustifiedGrid: Component = () => {
/>
</Show>
<Show when={isVideo()}>
<span class="absolute bottom-1 right-1 rounded bg-black/60 px-1 text-[10px] text-white">
<span class="chip--on-media absolute bottom-1 right-1">{ko.filter.video}</span>
</Show>
{/* 별점 — 매긴 것만 보여준다. 0점까지 빈 별로 그리면 모든 셀에 별 5개가
깔려서 사진을 가린다(별점은 소수의 항목에만 붙는 표시다). */}
<Show when={rating() > 0}>
<span class="cell-stars" title={ko.rating.set(rating())}>
{"★".repeat(rating())}
</span>
</Show>
{/* 상단 라벨 줄 — 삭제 예정 배지 + 태그.
셀이 좁으면 태그가 썸네일을 다 덮으므로 일정 폭부터만 보여준다. */}
<div class="pointer-events-none absolute inset-x-1 top-1 flex flex-wrap gap-0.5">
<Show when={marked()}>
<span class="chip--on-media chip--on-media--danger" title={ko.trash.badge}>
<IconTrash size={11} />
</span>
</Show>
<Show when={box()[2] >= TAG_MIN_CELL_W}>
<For each={visibleTags()}>
{(name) => (
<span class="chip--on-media chip--on-media--tag" title={name}>
{name}
</span>
)}
</For>
<Show when={hiddenTagCount() > 0}>
<span class="chip--on-media chip--on-media--dim">+{hiddenTagCount()}</span>
</Show>
</Show>
</div>
{/* 파일명·크기 오버레이 (툴바 ⓘ 토글) */}
<Show when={showInfo()}>
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-black/55 px-1 py-0.5 leading-tight">
<div class="truncate text-[10px] text-white/90">{detailsCache[id()]?.name ?? ""}</div>
<div class="info-overlay">
<div class="info-overlay__name">{detailsCache[id()]?.name ?? ""}</div>
<Show when={detailsCache[id()]}>
<div class="text-[9px] text-white/60">{fmtSize(detailsCache[id()]!.size)}</div>
<div class="info-overlay__sub">{fmtSize(detailsCache[id()]!.size)}</div>
</Show>
</div>
</Show>