지문을 오디오 우선으로 — 네트워크 소스에서 읽는 양을 크게 줄인다
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

26,000편(26TB, SMB) 라이브러리에서 지문 생성이 하루에 10%밖에 진행되지 않았다.
원인은 구조다: 프레임 지문을 만들려고 **파일을 처음부터 끝까지 읽는다**(실측 96.8%).
동시 실행을 2→8로 올려도 차이가 없었다 — 대역 병목이라 읽는 양을 줄이는 것 말고는 방법이 없다.

오디오 지문은 `-vn`으로 영상 청크를 건너뛰어 파일의 일부만 읽는다(컨테이너별 실측):
  mp4 0.4% · mov 0.4% · ts 0.3% · mkv 1.6%   ← 전체 패스 96.8%

그래서 3단계로 나눴다.
- ① 오디오 지문을 전 파일에 먼저 (sig_state=3). 26TB가 아니라 100~400GB를 읽는다.
- ② 저장된 지문으로 후보 쌍을 뽑아 걸린 파일을 표시 (sig_state=4). 파일을 읽지 않는다.
- ③ 프레임은 우선순위대로 — 오디오 짝 있음(4) → 소리 없음(0) → 짝 없음(3).
     중복일 가능성이 높은 것부터 처리하므로 결과가 며칠이 아니라 몇 분 뒤부터 나온다.

후보 판정은 탐지와 같은 함수(candidates_from_tokens)를 쓴다 — 규칙이 갈라지면
"지문 단계에서는 후보였는데 탐지에서는 아니다"는 설명 불가능한 상태가 생긴다.

측정으로 정한 것들:
- 오디오만으로는 그룹이 **0개**다(시각 확인이 프레임을 요구한다). ②③이 없으면 무의미하다.
- 오디오는 배속·피치 변화에 무너진다. 216개 토큰 중 공통:
    워터마크 216 · 레터박스 216 · 재인코딩 211 · 중간자르기 22/59
    1.25배속 3 · 피치+6% 8 · atempo1.05 40/205
  배속 사본은 프레임 경로가 뒤에서 채운다(그쪽에 배속 후보 로직이 있다).
  근거를 tests/audio_speed_evidence.rs에 고정했다.
- 절감 크기는 중복률에 비례한다. 픽스처는 10편 중 8편이 파생 사본이라 20%만 줄지만,
  실제 라이브러리에서 짝이 5%면 26TB가 1.4TB가 된다. 그 비율은 ②가 로그로 알려준다.

함께 고친 것 — 편집 백업이 영구히 쌓이던 문제
- 자르기·반전은 원본을 .archive_trash/edits에 남기는데 지우는 코드가 없었다.
  되돌리기는 최근 배치만 보므로 그 창을 벗어난 백업은 되돌릴 수도 없이 자리만 차지했다.
  미디어 드라이브에 쌓이므로 영상 라이브러리에서는 수십 GB가 된다.
- 창(20배치)을 벗어난 것만 지운다. 앱 시작 시·편집할 때마다·사이드바 버튼으로 돈다.
- 백업이 사라진 배치는 되돌리기 후보에서 뺀다 — Ctrl+Z가 조용히 실패하면 이유를 알 수 없다.
- 사이드바 정리 영역에 「편집 백업 N개 · N MB」로 용량을 노출한다.

빌드: profile.dev에서 의존성 디버그 정보를 뺐다(PDB 200여 개가 시간·디스크를 크게 먹는다).
자체 크레이트는 line-tables-only — 패닉 위치는 그대로 안다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ncakanghan
2026-08-03 18:04:23 +09:00
co-authored by Claude Opus 5
parent 8aa74c1a8d
commit 4be0235d2f
11 changed files with 761 additions and 4 deletions
+9
View File
@@ -381,6 +381,15 @@ export const ko = {
inProgress: (done: number, total: number) => `처리 중 ${done}/${total}`,
},
trash: {
/** 편집 백업 — 되돌리기 창을 벗어나면 지워도 되는 용량이다 */
backups: (n: number, size: string) => `편집 백업 ${n}개 · ${size}`,
backupsHint:
"자르기·반전한 원본입니다. 되돌리기 창(최근 20개 작업)을 벗어난 것은 되돌릴 수 없어 지워도 됩니다.",
backupsPrune: "정리",
backupsPruned: (n: number, size: string) =>
n === 0
? "지울 백업이 없습니다 — 전부 되돌릴 수 있는 범위입니다"
: `백업 ${n}개 정리 · ${size} 회수`,
tagName: "삭제 예정",
mark: "삭제 표시",
unmark: "삭제 표시 해제",
+10
View File
@@ -646,3 +646,13 @@ export interface StripFrame {
export const filmstrip = (fileId: number, count: number) =>
invoke<StripFrame[]>("filmstrip", { fileId, count });
/** 편집 백업(자르기·반전한 원본) 현황 */
export interface BackupStats {
count: number;
bytes: number;
}
export const editBackupStats = () => invoke<BackupStats>("edit_backup_stats");
/** 되돌리기 창을 벗어난 백업만 지운다 — 최근 편집은 계속 되돌릴 수 있다 */
export const pruneEditBackups = () => invoke<BackupStats>("prune_edit_backups");
+47
View File
@@ -74,6 +74,7 @@ import {
} from "../state/store";
import { childrenOf, libraryTotal, rootsOf, subtotalOf } from "../state/folderIndex";
import { performEmptyDeleted } from "../features/fileops/ContextMenu";
import { editBackupStats, pruneEditBackups } from "../ipc/commands";
import { performMove } from "../features/fileops/MoveDialog";
import { openRemoteDialog } from "../features/sources/RemoteDialog";
import { openSourceEdit } from "../features/sources/SourceEditDialog";
@@ -420,6 +421,13 @@ const SourceRow: Component<{ source: Source; status?: SourceStatus }> = (props)
);
};
/** 사람이 읽는 용량 — 사이드바 한 줄에 들어가야 하므로 짧게 */
function fmtBytes(b: number): string {
if (b >= 1 << 30) return `${(b / (1 << 30)).toFixed(1)} GB`;
if (b >= 1 << 20) return `${Math.round(b / (1 << 20))} MB`;
return `${Math.max(1, Math.round(b / 1024))} KB`;
}
export const Sidebar: Component = () => {
const [sources, { refetch }] = createResource(
() => sidebarVersion(),
@@ -440,6 +448,11 @@ export const Sidebar: Component = () => {
() => sidebarVersion(),
() => listSmartFolders(),
);
// 편집 백업 용량 — 사이드바가 갱신될 때 같이 읽는다. 파일 stat 몇 번이라 싸다.
const [backups, { refetch: refetchBackups }] = createResource(
() => sidebarVersion(),
() => editBackupStats(),
);
const [newTagMode, setNewTagMode] = createSignal(false);
// '삭제 예정'은 시스템 태그 — 일반 태그 목록에서 빼고 전용 행으로 고정 표시한다
const trashRow = () => (tags() ?? []).find((t) => t.id === trashTag());
@@ -891,6 +904,40 @@ export const Sidebar: Component = () => {
<span class="nav-item__tail" />
</div>
{/* 편집 백업 — 있을 때만 보인다.
자르기·반전한 원본이 미디어 드라이브에 쌓이는데, 보이지 않으면
사용자가 손댈 수 없다(영상 라이브러리에서는 수십 GB가 된다). */}
<Show when={(backups()?.count ?? 0) > 0}>
{(_) => (
<div class="nav-item" title={ko.trash.backupsHint}>
<span class="nav-item__icon">
<IconTrash size={14} />
</span>
<span class="nav-item__label">
{ko.trash.backups(backups()!.count, fmtBytes(backups()!.bytes))}
</span>
<span class="nav-item__tail">
<span class="nav-item__actions">
<button
class="toolbar-btn h-5 px-1.5 text-[10px]"
onClick={(e) => {
e.stopPropagation();
void (async () => {
const r = await pruneEditBackups();
toast(ko.trash.backupsPruned(r.count, fmtBytes(r.bytes)));
void refetchBackups();
})();
}}
title={ko.trash.backupsHint}
>
{ko.trash.backupsPrune}
</button>
</span>
</span>
</div>
)}
</Show>
{/* 대량 삭제가 보류됐을 때만 나타난다 — 처리하면 사라진다.
필요할 때만 보이는 영역이므로 평소에는 정리 그룹이 2줄이다. */}
<Show when={guarded()}>