영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
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
+439
View File
@@ -0,0 +1,439 @@
// 리스트(표) 보기 — 고정 행 높이 가상 스크롤. 이름·크기·날짜를 나란히 비교하고
// 열 머리글로 바로 정렬한다. 선택·정렬·필터·파일 작업은 격자 보기와 완전히 공유한다.
import { IconArrowDown, IconArrowUp, IconTrash } from "../../ui/icons";
import {
createEffect,
createMemo,
createSignal,
For,
on,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import { createStore, produce, reconcile } from "solid-js/store";
import {
anchorIndex,
info,
markedIds,
openLightbox,
registerItemViewFocus,
selected,
selectRange,
selectSingle,
setSort,
snapshot,
sortDesc,
sortKey,
tagVersion,
thumbVersions,
toggleSelect,
trashTag,
} from "../../state/store";
import {
fileDetails,
fileTagsBulk,
setThumbPriority,
thumbUrl,
type FileDetails,
type Snapshot,
type SortKey,
} from "../../ipc/commands";
import { ko } from "../../i18n/ko";
import { openContextMenu } from "../fileops/ContextMenu";
import { handleItemKeys } from "./itemKeys";
const ROW_H = 38;
/** 화면 위·아래로 더 그려 두는 행 수 (스크롤 시 빈칸 방지) */
const OVERSCAN = 10;
/** 한 번에 메타/태그를 채워 넣는 최대 행 수 (백엔드 file_details 상한 500 이내) */
const BATCH_MAX = 400;
/**
* 머리글과 행이 같은 열 정의를 공유한다.
* 부가 정보(태그·해상도·길이)는 minmax(0,…)로 두어 창이 좁아지면 그 열부터 줄어든다 —
* 이름·크기·날짜를 지키면서 가로 스크롤 없이 한 줄에 들어가게 하기 위함이다.
*/
const COLS =
"2.75rem minmax(5rem,1fr) minmax(0,11rem) 3rem 4.75rem minmax(0,5.5rem) minmax(0,3.75rem) 8.5rem";
function fmtSize(bytes: number): string {
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(2)} GB`;
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)} MB`;
if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)} KB`;
return `${bytes} B`;
}
function fmtDate(ms: number | null | undefined): string {
if (!ms) return "—";
const d = new Date(ms);
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function fmtDuration(ms: number | null | undefined): string {
if (!ms) return "";
const s = Math.round(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
return h > 0
? `${h}:${String(m % 60).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`
: `${m}:${String(s % 60).padStart(2, "0")}`;
}
export const ListView: Component = () => {
let containerRef: HTMLDivElement | undefined;
const [scrollTop, setScrollTop] = createSignal(0);
const [viewportH, setViewportH] = createSignal(600);
const count = () => snapshot()?.count ?? 0;
// ── 가상 스크롤 ──
const visibleRange = createMemo<[number, number]>(() => {
const n = count();
if (n === 0) return [0, -1];
const first = Math.max(0, Math.floor(scrollTop() / ROW_H) - OVERSCAN);
const last = Math.min(n - 1, Math.ceil((scrollTop() + viewportH()) / ROW_H) + OVERSCAN);
return [first, last];
});
const indices = createMemo(() => {
const [first, last] = visibleRange();
const out: number[] = [];
for (let i = first; i <= last; i++) out.push(i);
return out;
});
let rafPending = false;
const onScroll = () => {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
if (containerRef) setScrollTop(containerRef.scrollTop);
});
};
/** 행을 화면 안으로 (키보드 이동·보기 전환용) */
const revealRow = (index: number) => {
const el = containerRef;
if (!el) return;
const top = index * ROW_H;
if (top < el.scrollTop) el.scrollTop = top;
else if (top + ROW_H > el.scrollTop + el.clientHeight)
el.scrollTop = top + ROW_H - el.clientHeight;
};
onMount(() => {
const el = containerRef!;
// 크롬(툴바·브레드크럼·폴더 칩·사이드바)이 클릭 뒤 방향키를 여기로 되돌린다
onCleanup(registerItemViewFocus(() => containerRef?.focus({ preventScroll: true })));
// 높이는 폴링을 단일 진실원으로 — 값이 같으면 시그널이 갱신되지 않으므로 비용은 없다
const tick = () => setViewportH(el.clientHeight);
tick();
const ro = new ResizeObserver(tick);
ro.observe(el);
const poll = setInterval(tick, 250);
// 격자에서 넘어왔을 때 보고 있던 항목을 그대로 보여준다
const anchor = anchorIndex();
if (anchor >= 0) requestAnimationFrame(() => revealRow(anchor));
onCleanup(() => {
ro.disconnect();
clearInterval(poll);
});
});
// ── 지연 로드: 파일 메타 (이름·크기·날짜·길이) ──
// 이름/경로는 이름변경·이동으로 바뀌므로 스냅샷이 갈릴 때 캐시를 버린다.
const [detailsCache, setDetailsCache] = createStore<Record<number, FileDetails>>({});
let detTimer: ReturnType<typeof setTimeout> | undefined;
createEffect(on(snapshot, () => setDetailsCache(reconcile({})), { defer: true }));
createEffect(() => {
const [first, last] = visibleRange();
const snap = snapshot();
if (!snap || last < first) return;
clearTimeout(detTimer);
detTimer = setTimeout(() => {
const need: number[] = [];
for (let i = first; i <= last && need.length < BATCH_MAX; i++) {
const id = snap.id(i);
if (detailsCache[id] === undefined) need.push(id);
}
if (need.length === 0) return;
void fileDetails(need)
.then((ds) =>
setDetailsCache(
produce((s) => {
for (const d of ds) s[d.id] = d;
}),
),
)
.catch(() => {});
}, 120);
});
onCleanup(() => clearTimeout(detTimer));
// ── 지연 로드: 태그 (격자 오버레이와 같은 배치 조회) ──
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 < BATCH_MAX; 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) => {
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));
// 화면에 보이는데 썸네일이 없는 항목 우선 생성 요청
let prioTimer: ReturnType<typeof setTimeout> | undefined;
createEffect(() => {
const [first, last] = visibleRange();
const snap = snapshot();
if (!snap || last < first) return;
clearTimeout(prioTimer);
prioTimer = setTimeout(() => {
const ids: number[] = [];
for (let i = first; i <= last && ids.length < BATCH_MAX; i++) {
if (!snap.hasThumb(i)) ids.push(snap.id(i));
}
if (ids.length > 0) void setThumbPriority(ids);
}, 200);
});
onCleanup(() => clearTimeout(prioTimer));
// ── 상호작용 ──
const onRowClick = (e: MouseEvent, snap: Snapshot, index: number) => {
const id = snap.id(index);
if (e.shiftKey) selectRange(snap, index);
else if (e.ctrlKey || e.metaKey) toggleSelect(id, index);
else selectSingle(id, index);
};
const onRowContextMenu = (e: MouseEvent, snap: Snapshot, index: number) => {
e.preventDefault();
const id = snap.id(index);
if (!selected().has(id)) selectSingle(id, index);
void fileDetails([id]).then((d) => {
openContextMenu({ x: e.clientX, y: e.clientY, fileId: id, fileName: d[0]?.name });
});
};
const onRowDragStart = (e: DragEvent, snap: Snapshot, index: number) => {
const id = snap.id(index);
if (!selected().has(id)) selectSingle(id, index);
e.dataTransfer?.setData("application/x-archive-move", "1");
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
};
const onKeyDown = (e: KeyboardEvent) => {
const snap = snapshot();
if (!snap) return;
handleItemKeys(e, snap, {
neighbor: (from, key) =>
key === "ArrowUp" || key === "ArrowLeft"
? from > 0
? from - 1
: undefined
: from < snap.count - 1
? from + 1
: undefined,
onMove: revealRow,
});
};
// ── 날짜 열: 정렬이 '수정일'이면 수정일을, 그 밖에는 촬영일을 보여준다 ──
const dateKey = (): SortKey => (sortKey() === "mtime" ? "mtime" : "taken");
const dateLabel = () => (sortKey() === "mtime" ? ko.list.modified : ko.list.taken);
const dateOf = (d: FileDetails | undefined) =>
d ? (sortKey() === "mtime" ? d.mtimeMs : (d.takenAt ?? d.mtimeMs)) : undefined;
/** 열 머리글 — 누르면 그 열로 정렬, 다시 누르면 방향 반전 */
const SortHead: Component<{ label: string; col: SortKey; right?: boolean }> = (props) => (
<button
class="flex min-w-0 items-center gap-1 hover:text-[var(--text-primary)]"
classList={{
"justify-end": props.right,
"text-[var(--text-primary)]": sortKey() === props.col,
}}
onClick={() => setSort(props.col, sortKey() === props.col ? !sortDesc() : true)}
title={ko.list.sortHint(props.label)}
>
<span class="truncate">{props.label}</span>
<Show when={sortKey() === props.col}>
<span class="shrink-0">
{sortDesc() ? <IconArrowDown size={12} /> : <IconArrowUp size={12} />}
</span>
</Show>
</button>
);
return (
<div class="flex min-h-0 flex-1 flex-col">
{/* 머리글 — 열 폭은 행과 같은 정의를 쓴다 */}
<div
class="grid shrink-0 items-center gap-2 border-b border-[var(--border)] px-2 py-1.5 text-[11px] text-[var(--text-muted)]"
style={{ "grid-template-columns": COLS }}
>
<span />
<SortHead label={ko.list.name} col="name" />
<span class="truncate">{ko.list.tags}</span>
<span class="truncate">{ko.list.kind}</span>
<SortHead label={ko.list.size} col="size" right />
<span class="truncate text-right">{ko.list.dims}</span>
<span class="truncate text-right">{ko.list.duration}</span>
<SortHead label={dateLabel()} col={dateKey()} right />
</div>
<div
ref={containerRef}
onScroll={onScroll}
onKeyDown={onKeyDown}
tabindex={0}
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden outline-none"
>
<Show
when={count() > 0}
fallback={
<div class="grid h-full place-items-center text-sm text-[var(--text-muted)]">
{ko.grid.empty}
</div>
}
>
<div class="relative" style={{ height: `${count() * ROW_H}px` }}>
<For each={indices()}>
{(i) => {
// 모든 파생값은 반응형으로 — For가 같은 인덱스의 DOM을 재사용해도
// 뷰 전환으로 스냅샷이 바뀌면 즉시 갱신되도록 한다.
const id = () => snapshot()?.id(i) ?? -1;
const d = () => detailsCache[id()];
const isSel = () => selected().has(id());
const marked = () => markedIds().has(id());
const isVideo = () => snapshot()?.kind(i) === 1;
const w = () => snapshot()?.width(i) ?? 0;
const h = () => snapshot()?.height(i) ?? 0;
const version = () => thumbVersions[id()] ?? 0;
const showImg = () => {
const s = snapshot();
return !!s && (s.hasThumb(i) || version() > 0);
};
const rowTags = () => tagCache[id()] ?? [];
return (
<div
class="list-row absolute inset-x-0 grid cursor-pointer items-center gap-2 px-2 text-xs"
classList={{ "is-sel": isSel() }}
style={{
transform: `translateY(${i * ROW_H}px)`,
height: `${ROW_H}px`,
"grid-template-columns": COLS,
}}
onClick={(e) => {
const s = snapshot();
if (s) onRowClick(e, s, i);
}}
onDblClick={() => openLightbox(i)}
onContextMenu={(e) => {
const s = snapshot();
if (s) onRowContextMenu(e, s, i);
}}
draggable={true}
onDragStart={(e) => {
const s = snapshot();
if (s) onRowDragStart(e, s, i);
}}
>
{/* 썸네일 — 미디어 면이 아니라 28×36 크롬 슬롯이다.
--grid-cell-bg(고정 다크)를 쓰면 흰 카드 안에 검은 사각형이 박힌다. */}
<div class="h-7 w-9 shrink-0 overflow-hidden rounded-[var(--r-xs)] bg-[var(--bg-panel-raised)]">
<Show when={showImg() && info()}>
<img
src={`${thumbUrl(info()!, id())}&v=${version()}`}
class="h-full w-full object-cover"
decoding="async"
draggable={false}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
</Show>
</div>
{/* 이름 (+ 삭제 예정 배지) */}
<div class="flex min-w-0 items-center gap-1.5">
<Show when={marked()}>
<span class="chip chip--danger chip--xs shrink-0" title={ko.trash.badge}>
<IconTrash size={11} />
</span>
</Show>
<span
class="truncate text-[var(--text-primary)]"
title={d() ? `${d()!.dir}\\${d()!.name}` : undefined}
>
{d()?.name ?? "…"}
</span>
</div>
{/* 태그 */}
<div class="flex min-w-0 items-center gap-1 overflow-hidden">
<For each={rowTags().slice(0, 3)}>
{(name) => (
<span class="chip chip--accent chip--xs min-w-0" title={name}>
<span>{name}</span>
</span>
)}
</For>
<Show when={rowTags().length > 3}>
<span class="shrink-0 text-[10px] text-[var(--text-muted)]">
+{rowTags().length - 3}
</span>
</Show>
</div>
<span class="truncate text-[var(--text-muted)]">
{isVideo() ? ko.filter.video : ko.filter.image}
</span>
<span class="text-right tabular-nums text-[var(--text-secondary)]">
{d() ? fmtSize(d()!.size) : ""}
</span>
{/* 좁아지면 0폭까지 줄어드는 열 — 넘친 글자가 옆 열을 침범하지 않게 자른다 */}
<span class="truncate text-right tabular-nums text-[var(--text-muted)]">
{w() > 0 && h() > 0 ? `${w()}×${h()}` : "—"}
</span>
<span class="truncate text-right tabular-nums text-[var(--text-muted)]">
{fmtDuration(d()?.durationMs)}
</span>
<span class="text-right tabular-nums text-[var(--text-muted)]">
{fmtDate(dateOf(d()))}
</span>
</div>
);
}}
</For>
</div>
</Show>
</div>
</div>
);
};