From 8aa74c1a8d96b78a00d548a5d0e9ce7d1d1d5beb Mon Sep 17 00:00:00 2001 From: ncakanghan Date: Mon, 3 Aug 2026 16:18:24 +0900 Subject: [PATCH] =?UTF-8?q?=EC=98=81=EC=83=81=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=ED=83=90=EC=A7=80=20=EC=99=84=EC=84=B1=20+=20=EA=B0=84?= =?UTF-8?q?=EB=8B=A8=20=ED=8E=B8=EC=A7=91(=ED=9A=8C=EC=A0=84=C2=B7?= =?UTF-8?q?=EC=9E=90=EB=A5=B4=EA=B8=B0)=20+=20=EB=8F=99=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=AC=EC=83=9D=20+=20=EB=B3=84=EC=A0=90=C2=B7=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=84=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 중복 탐지 - 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시) - 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(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 --- .gitignore | 6 + package-lock.json | 10 + package.json | 1 + scripts/build-portable.ps1 | 145 ++ scripts/cdp.ps1 | 56 + scripts/dev.cmd | 41 +- scripts/fetch-arm64-toolchain.ps1 | 165 ++ scripts/gen-fixtures.ps1 | 270 ++++ scripts/test.cmd | 43 +- scripts/verify-fixtures.mjs | 193 +++ src-tauri/Cargo.lock | 20 + src-tauri/Cargo.toml | 2 + src-tauri/crates/archive-db/src/migrations.rs | 241 +++ .../crates/archive-indexer/src/dedupe.rs | 1326 +++++++++++++--- src-tauri/crates/archive-indexer/src/edit.rs | 868 ++++++++++ .../crates/archive-indexer/src/ffmpeg.rs | 254 +++ .../crates/archive-indexer/src/fileops.rs | 672 +++++++- src-tauri/crates/archive-indexer/src/lib.rs | 3 + .../crates/archive-indexer/src/maintenance.rs | 164 ++ src-tauri/crates/archive-indexer/src/meta.rs | 77 +- src-tauri/crates/archive-indexer/src/model.rs | 15 + src-tauri/crates/archive-indexer/src/phash.rs | 364 +++++ .../crates/archive-indexer/src/pipeline.rs | 376 ++++- src-tauri/crates/archive-indexer/src/scan.rs | 2 +- .../crates/archive-indexer/src/sigmatch.rs | 1407 +++++++++++++++++ src-tauri/crates/archive-indexer/src/sigq.rs | 711 +++++++++ src-tauri/crates/archive-indexer/src/tags.rs | 284 ++++ .../crates/archive-indexer/src/thumbq.rs | 460 +++++- .../crates/archive-indexer/src/thumbs.rs | 170 +- .../archive-indexer/tests/edit_files.rs | 490 ++++++ .../tests/ffmpeg_integration.rs | 203 +++ .../archive-indexer/tests/fixture_dedupe.rs | 464 ++++++ .../tests/letterbox_evidence.rs | 137 ++ .../archive-indexer/tests/wmbig_evidence.rs | 102 ++ src-tauri/src/commands.rs | 1168 +++++++++++++- src-tauri/src/main.rs | 160 +- src-tauri/src/watcher.rs | 94 +- src/App.tsx | 48 +- src/features/browse/FolderChips.tsx | 117 ++ src/features/browse/ListView.tsx | 439 +++++ src/features/browse/itemKeys.ts | 148 ++ src/features/dedupe/CompareView.tsx | 731 +++++++++ src/features/dedupe/DedupePanel.tsx | 722 ++++++--- src/features/dedupe/dupeTier.ts | 62 + src/features/dedupe/format.ts | 21 + src/features/fileops/ContextMenu.tsx | 169 +- src/features/fileops/MoveDialog.tsx | 26 +- src/features/grid/JustifiedGrid.tsx | 233 +-- src/features/grid/layout.ts | 7 +- src/features/sources/SourceEditDialog.tsx | 138 ++ src/features/viewer/CropOverlay.tsx | 208 +++ src/features/viewer/Lightbox.tsx | 597 ++++++- src/features/viewer/MultiPlayView.tsx | 286 ++++ src/features/viewer/VideoPlayer.tsx | 604 ++++++- src/i18n/ko.ts | 384 ++++- src/ipc/commands.ts | 217 ++- src/shell/Breadcrumb.tsx | 255 +++ src/shell/ContentArea.tsx | 421 +++-- src/shell/DetailsPanel.tsx | 688 +++++++- src/shell/ShortcutsOverlay.tsx | 31 +- src/shell/Sidebar.tsx | 1064 ++++++++++--- src/shell/StatusBar.tsx | 115 +- src/state/folderIndex.ts | 140 ++ src/state/store.ts | 506 +++++- src/styles/app.css | 1310 ++++++++++++++- src/ui/StarRating.tsx | 38 + src/ui/icons.tsx | 141 ++ 67 files changed, 19747 insertions(+), 1283 deletions(-) create mode 100644 scripts/build-portable.ps1 create mode 100644 scripts/fetch-arm64-toolchain.ps1 create mode 100644 scripts/gen-fixtures.ps1 create mode 100644 scripts/verify-fixtures.mjs create mode 100644 src-tauri/crates/archive-indexer/src/edit.rs create mode 100644 src-tauri/crates/archive-indexer/src/sigmatch.rs create mode 100644 src-tauri/crates/archive-indexer/src/sigq.rs create mode 100644 src-tauri/crates/archive-indexer/tests/edit_files.rs create mode 100644 src-tauri/crates/archive-indexer/tests/fixture_dedupe.rs create mode 100644 src-tauri/crates/archive-indexer/tests/letterbox_evidence.rs create mode 100644 src-tauri/crates/archive-indexer/tests/wmbig_evidence.rs create mode 100644 src/features/browse/FolderChips.tsx create mode 100644 src/features/browse/ListView.tsx create mode 100644 src/features/browse/itemKeys.ts create mode 100644 src/features/dedupe/CompareView.tsx create mode 100644 src/features/dedupe/dupeTier.ts create mode 100644 src/features/dedupe/format.ts create mode 100644 src/features/sources/SourceEditDialog.tsx create mode 100644 src/features/viewer/CropOverlay.tsx create mode 100644 src/features/viewer/MultiPlayView.tsx create mode 100644 src/shell/Breadcrumb.tsx create mode 100644 src/state/folderIndex.ts create mode 100644 src/ui/StarRating.tsx create mode 100644 src/ui/icons.tsx diff --git a/.gitignore b/.gitignore index 062f476..04f47e8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,9 @@ data/ *.log .DS_Store Thumbs.db + +# 로컬 크로스 컴파일 도구 (scripts/fetch-arm64-toolchain.ps1이 내려받는다 — 재생성 가능) +.toolchain/ + +# 포터블 빌드 산출물 +dist-portable/ diff --git a/package-lock.json b/package-lock.json index 87a1cbd..c05a524 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-shell": "^2", + "lucide-solid": "^1.28.0", "solid-js": "^1.9.0" }, "devDependencies": { @@ -2405,6 +2406,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-solid": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/lucide-solid/-/lucide-solid-1.28.0.tgz", + "integrity": "sha512-zF/0H7eiZya+tgmtpJNGzKPUfr+7p+zjLyrIsOSUAPi0IHbEqdLJ+U8w41g+889s63eUoU6BrSLu0HxUx3RTIQ==", + "license": "ISC", + "peerDependencies": { + "solid-js": "^1.4.7" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/package.json b/package.json index d94cae5..57a000e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-shell": "^2", + "lucide-solid": "^1.28.0", "solid-js": "^1.9.0" }, "devDependencies": { diff --git a/scripts/build-portable.ps1 b/scripts/build-portable.ps1 new file mode 100644 index 0000000..873449d --- /dev/null +++ b/scripts/build-portable.ps1 @@ -0,0 +1,145 @@ +# Archive portable build (no installer). ASCII only - PS 5.1 reads BOM-less files as ANSI. +# +# Output: dist-portable\Archive-\ (exe + ffmpeg/ffprobe sidecars + portable.marker) +# The app already supports portable mode: src-tauri/src/paths.rs uses \data when a +# `data\` folder or `portable.marker` sits next to the exe, instead of %APPDATA%\Archive. +# +# Usage: .\scripts\build-portable.ps1 [-Arch arm64|x64] [-Zip] +# +# ARM64 needs the MSVC cross tools ("MSVC ... ARM64 build tools" in the VS installer) and +# `rustup target add aarch64-pc-windows-msvc`. The script checks both and says what is missing. +param( + [ValidateSet("arm64", "x64")] + [string]$Arch = "arm64", + [switch]$Zip +) + +$ErrorActionPreference = "Stop" +$root = Split-Path $PSScriptRoot -Parent +Set-Location $root + +$triple = if ($Arch -eq "arm64") { "aarch64-pc-windows-msvc" } else { "x86_64-pc-windows-msvc" } +# vcvarsall argument: cross builds need "_", native is just the target. +$vcArg = if ($Arch -eq "arm64") { "x64_arm64" } else { "x64" } + +Write-Host "[portable] target $triple" + +# --- toolchain checks ------------------------------------------------------- +if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + $cargoBin = Join-Path $env:USERPROFILE ".cargo\bin" + if (Test-Path (Join-Path $cargoBin "cargo.exe")) { $env:PATH = "$cargoBin;$env:PATH" } + else { throw "cargo not found. Install Rust (https://rustup.rs) and reopen the shell." } +} +$installed = & rustup target list --installed +if ($installed -notcontains $triple) { + throw "Rust target missing: $triple`n fix: rustup target add $triple" +} + +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +if (-not (Test-Path $vswhere)) { throw "vswhere.exe not found - is Visual Studio installed?" } +$vsPath = & $vswhere -latest -products * -property installationPath +if (-not $vsPath) { throw "No Visual Studio installation found." } +$vcvars = Join-Path $vsPath "VC\Auxiliary\Build\vcvarsall.bat" +if (-not (Test-Path $vcvars)) { throw "vcvarsall.bat not found at $vcvars" } + +# ARM64 needs a cross toolchain. Prefer the one installed in Visual Studio; fall back to the +# local copy laid out by fetch-arm64-toolchain.ps1 (that script explains why it exists). +$localTc = $null +if ($Arch -eq "arm64") { + $hasArm = Get-ChildItem (Join-Path $vsPath "VC\Tools\MSVC") -Directory -ErrorAction SilentlyContinue | + ForEach-Object { Test-Path (Join-Path $_.FullName "bin\Hostx64\arm64\cl.exe") } | + Where-Object { $_ } + if (-not $hasArm) { + $localTc = Get-ChildItem (Join-Path $root ".toolchain\arm64\VC\Tools\MSVC") -Directory -ErrorAction SilentlyContinue | + Sort-Object Name | Select-Object -Last 1 + if (-not $localTc -or -not (Test-Path (Join-Path $localTc.FullName "bin\Hostx64\arm64\cl.exe"))) { + throw @" +MSVC ARM64 cross tools missing. + fix (no admin): .\scripts\fetch-arm64-toolchain.ps1 + or (Visual Studio): Installer -> Modify -> Individual components + -> "MSVC v14x - VS 20xx C++ ARM64/ARM64EC build tools" +"@ + } + Write-Host "[portable] using local toolchain: $($localTc.FullName)" + } +} + +# --- sidecars --------------------------------------------------------------- +$ffmpeg = Join-Path $root "src-tauri\binaries\ffmpeg-$triple.exe" +$ffprobe = Join-Path $root "src-tauri\binaries\ffprobe-$triple.exe" +foreach ($f in @($ffmpeg, $ffprobe)) { + if (-not (Test-Path $f)) { throw "sidecar missing: $f" } +} + +# --- build ------------------------------------------------------------------ +# vcvarsall must be loaded into THIS process, so run it in cmd and import the env back. +# With the local toolchain we ask vcvarsall for the plain x64 env (it has no arm64 target) +# and then splice the ARM64 compiler/libs in front of it ourselves. +$vcvarsArg = if ($localTc) { "x64" } else { $vcArg } +Write-Host "[portable] loading MSVC env ($vcvarsArg)" +$envDump = & cmd /c "`"$vcvars`" $vcvarsArg >nul 2>&1 && set" +if ($LASTEXITCODE -ne 0) { throw "vcvarsall $vcvarsArg failed" } +foreach ($line in $envDump) { + if ($line -match '^([^=]+)=(.*)$') { Set-Item -Path "env:$($matches[1])" -Value $matches[2] } +} + +if ($localTc) { + # Headers are architecture-neutral and already come from vcvarsall/the SDK. + # Only the compiler binaries and the CRT import libs differ, so override just those: + # PATH -> Hostx64\arm64 first (cl.exe, link.exe, lib.exe target ARM64) + # LIB -> the ARM64 CRT, then the SDK's arm64 libs, dropping the x64 entries + $armBin = Join-Path $localTc.FullName "bin\Hostx64\arm64" + $armLib = Join-Path $localTc.FullName "lib\arm64" + if (-not (Test-Path $armLib)) { throw "ARM64 CRT libs missing: $armLib" } + # x64 host tools stay reachable behind the ARM64 ones - some build scripts need them. + $env:PATH = "$armBin;$env:PATH" + $sdkLibs = ($env:LIB -split ';' | Where-Object { $_ -match '\\Windows Kits\\' } | + ForEach-Object { $_ -replace '\\x64$', '\arm64' }) -join ';' + $env:LIB = "$armLib;$sdkLibs" + $env:VSCMD_ARG_TGT_ARCH = "arm64" + # cc-rs (libsqlite3-sys, aws-lc-sys, ...) picks its compiler per target triple. + # **clang-cl, not cl.exe**: aws-lc-sys and ring ship their aarch64 assembly as .S, and + # cl.exe answers "unrecognized source file type" and silently SKIPS those files - the + # build then dies at link time on a missing .o. clang-cl assembles them and stays + # MSVC-compatible, so the objects link against the same CRT. + $llvmBin = Join-Path $root ".toolchain\llvm\bin" + $armClang = Join-Path $llvmBin "clang-cl.exe" + if (-not (Test-Path $armClang)) { throw "clang-cl missing - run .\scripts\fetch-arm64-toolchain.ps1" } + # On PATH too: some build scripts (ring) look up plain "clang" themselves rather than + # honouring CC_, and cc-rs resolves the compiler family by running it. + $env:PATH = "$llvmBin;$env:PATH" + $env:CC_aarch64_pc_windows_msvc = $armClang + $env:CFLAGS_aarch64_pc_windows_msvc = "--target=aarch64-pc-windows-msvc" + $env:AR_aarch64_pc_windows_msvc = Join-Path $armBin "lib.exe" + $env:CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER = Join-Path $armBin "link.exe" +} + +Write-Host "[portable] tauri build (no bundle)" +& npx tauri build --target $triple --no-bundle +if ($LASTEXITCODE -ne 0) { throw "build failed" } + +# --- assemble --------------------------------------------------------------- +$exe = Join-Path $root "src-tauri\target\$triple\release\archive.exe" +if (-not (Test-Path $exe)) { throw "built exe not found: $exe" } + +$outDir = Join-Path $root "dist-portable\Archive-$Arch" +if (Test-Path $outDir) { Remove-Item $outDir -Recurse -Force } +New-Item -ItemType Directory -Path $outDir -Force | Out-Null + +Copy-Item $exe (Join-Path $outDir "Archive.exe") +# Drop the triple suffix: locate() looks for both, and the plain name is what a user expects. +Copy-Item $ffmpeg (Join-Path $outDir "ffmpeg.exe") +Copy-Item $ffprobe (Join-Path $outDir "ffprobe.exe") +# Marker = keep the library DB and thumbnails inside this folder, not in %APPDATA%. +Set-Content -Path (Join-Path $outDir "portable.marker") -Value "" -Encoding ascii + +$size = [math]::Round(((Get-ChildItem $outDir -Recurse | Measure-Object Length -Sum).Sum / 1MB), 1) +Write-Host "[portable] $outDir ($size MB)" +Get-ChildItem $outDir | ForEach-Object { " {0,-16} {1,10:N0}" -f $_.Name, $_.Length } + +if ($Zip) { + $zipPath = Join-Path $root "dist-portable\Archive-$Arch.zip" + if (Test-Path $zipPath) { Remove-Item $zipPath -Force } + Compress-Archive -Path $outDir -DestinationPath $zipPath + Write-Host "[portable] $zipPath" +} diff --git a/scripts/cdp.ps1 b/scripts/cdp.ps1 index de8a88b..9aec154 100644 --- a/scripts/cdp.ps1 +++ b/scripts/cdp.ps1 @@ -1,5 +1,6 @@ # WebView2 CDP (Chrome DevTools Protocol) E2E helper. # Usage: . .\scripts\cdp.ps1; Invoke-Cdp "document.title" +# . .\scripts\cdp.ps1; Save-CdpScreenshot shot.png # Prereq: app launched with WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9223 # NOTE: ASCII only in this file (PS 5.1 reads BOM-less files as ANSI). @@ -51,3 +52,58 @@ function Invoke-Cdp { } return $response.result.result.value } + +# Call any CDP method and return the raw result object. +# Invoke-Cdp is hardcoded to Runtime.evaluate; use this for Page.*, Emulation.*, etc. +function Invoke-CdpMethod { + param( + [Parameter(Mandatory)] [string]$Method, + [hashtable]$Params = @{}, + [int]$Port = 9223, + [int]$TimeoutSec = 30 + ) + $targets = Invoke-RestMethod "http://127.0.0.1:$Port/json" + $page = $targets | Where-Object { $_.type -eq 'page' -and $_.url -notlike 'devtools*' } | Select-Object -First 1 + if (-not $page) { throw "no CDP page target" } + + $ws = New-Object System.Net.WebSockets.ClientWebSocket + $ct = [System.Threading.CancellationToken]::None + $ws.ConnectAsync([Uri]$page.webSocketDebuggerUrl, $ct).GetAwaiter().GetResult() | Out-Null + + $msg = @{ id = 1; method = $Method; params = $Params } | ConvertTo-Json -Depth 6 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($msg) + $seg = New-Object System.ArraySegment[byte] -ArgumentList @(, $bytes) + $ws.SendAsync($seg, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $ct).GetAwaiter().GetResult() | Out-Null + + # Screenshots are large: keep reading frames until EndOfMessage, then match the id. + $buffer = New-Object byte[] 1048576 + $deadline = (Get-Date).AddSeconds($TimeoutSec) + $response = $null + while ((Get-Date) -lt $deadline) { + $sb = New-Object System.Text.StringBuilder + do { + $rseg = New-Object System.ArraySegment[byte] -ArgumentList @(, $buffer) + $result = $ws.ReceiveAsync($rseg, $ct).GetAwaiter().GetResult() + $sb.Append([System.Text.Encoding]::UTF8.GetString($buffer, 0, $result.Count)) | Out-Null + } while (-not $result.EndOfMessage) + $obj = $sb.ToString() | ConvertFrom-Json + if ($obj.id -eq 1) { $response = $obj; break } + } + $ws.Abort() + if (-not $response) { throw "CDP response timeout" } + if ($response.error) { throw "CDP error: $($response.error.message)" } + return $response.result +} + +# Save the page as a PNG (what the WebView actually renders, not the OS window). +function Save-CdpScreenshot { + param( + [Parameter(Mandatory)] [string]$Path, + [int]$Port = 9223, + [int]$TimeoutSec = 30 + ) + $r = Invoke-CdpMethod -Method "Page.captureScreenshot" -Params @{ format = "png" } -Port $Port -TimeoutSec $TimeoutSec + if (-not $r.data) { throw "no screenshot data" } + [System.IO.File]::WriteAllBytes($Path, [System.Convert]::FromBase64String($r.data)) + return (Get-Item $Path).Length +} diff --git a/scripts/dev.cmd b/scripts/dev.cmd index 589e11d..d885042 100644 --- a/scripts/dev.cmd +++ b/scripts/dev.cmd @@ -1,6 +1,41 @@ @echo off -rem Archive 개발 실행 — rustc가 라이브러리 없는 VS Community를 잡지 않도록 -rem Build Tools vcvars64 환경에서 tauri dev를 띄운다. -call "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat" >nul +rem Archive dev run. rustc needs an MSVC toolchain env, so load vcvars64 first. +rem The install path differs per machine (Build Tools / Community / VS version), +rem so probe the usual spots and fall back to vswhere instead of hardcoding one. +rem NOTE: ASCII only in this file - cmd.exe reads it in the OEM codepage. +setlocal cd /d "%~dp0.." + +rem Probe vcvarsall.bat, not vcvars64.bat: a VS install without the C++ workload +rem still ships the vcvars64 wrapper but not vcvarsall, and calling it just fails. +set "VCVARS=" +for %%P in ( + "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvarsall.bat" + "C:\Program Files\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvarsall.bat" + "C:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvarsall.bat" + "C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvarsall.bat" + "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvarsall.bat" +) do if not defined VCVARS if exist %%P set "VCVARS=%%~P" + +if defined VCVARS goto :have_vcvars + +rem vswhere only reports installs that actually carry the x64 C++ tools +set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" +if not exist "%VSWHERE%" goto :no_vcvars +for /f "usebackq delims=" %%I in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VCVARS=%%I\VC\Auxiliary\Build\vcvarsall.bat" +if not exist "%VCVARS%" goto :no_vcvars + +:have_vcvars +call "%VCVARS%" x64 >nul +where cargo >nul 2>nul +if errorlevel 1 ( + echo [dev] cargo not found on PATH - install Rust ^(https://rustup.rs^) and reopen the shell. + exit /b 1 +) npm run tauri dev %* +exit /b %ERRORLEVEL% + +:no_vcvars +echo [dev] No MSVC toolchain found (vcvarsall.bat missing). +echo [dev] Install "Desktop development with C++" (VS Build Tools or VS Community). +exit /b 1 diff --git a/scripts/fetch-arm64-toolchain.ps1 b/scripts/fetch-arm64-toolchain.ps1 new file mode 100644 index 0000000..964453d --- /dev/null +++ b/scripts/fetch-arm64-toolchain.ps1 @@ -0,0 +1,165 @@ +# Lay out the MSVC ARM64 cross toolchain locally, without the VS Installer. +# ASCII only - PS 5.1 reads BOM-less files as ANSI. +# +# WHY: the VS Installer on some machines refuses every unattended `modify` - it insists on +# updating itself first and exits with "Status changed to UpdateAvailable". Six different +# invocations (setup.exe modify/update, vs_installer.exe, --noUpdateInstaller, --quiet, +# --passive) all stopped there. The same payloads are plain .vsix (zip) files in the VS +# catalog with direct download URLs, so we fetch and extract them into the repo instead. +# +# This changes nothing outside the repo: no admin, no registry, no Program Files. +# If the ARM64 component is later installed properly, build-portable.ps1 prefers that. +# +# Usage: .\scripts\fetch-arm64-toolchain.ps1 [-Force] +# Output: .toolchain\arm64\ (VC\Tools\MSVC\\{bin,lib}\...) +param([switch]$Force) + +$ErrorActionPreference = "Stop" +$root = Split-Path $PSScriptRoot -Parent +$dest = Join-Path $root ".toolchain\arm64" + +# Two independent halves: MSVC ARM64 (compiler + CRT) and clang (assembles .S). +# Each is skipped when already present, so re-running only fetches what is missing. +$haveMsvc = $false +if ((Test-Path $dest) -and -not $Force) { + $haveMsvc = [bool](Get-ChildItem $dest -Recurse -Filter "cl.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "Hostx64\\arm64" } | Select-Object -First 1) +} + +$cache = Join-Path $root ".toolchain\.cache" +New-Item -ItemType Directory -Path $cache -Force | Out-Null + +# --- resolve packages from the VS catalog on disk --------------------------- +if ($haveMsvc) { Write-Host "[arm64] msvc already present - skipping" } +else { +$chans = Join-Path $env:LOCALAPPDATA "Microsoft\VisualStudio\Packages\_Channels" +if (-not (Test-Path $chans)) { throw "VS catalog not found - is Visual Studio installed?" } +$catalog = Get-ChildItem $chans -Recurse -Filter "catalog.json" | + Sort-Object Length -Descending | Select-Object -First 1 +if (-not $catalog) { throw "catalog.json not found under $chans" } + +$vcRoot = "C:\Program Files\Microsoft Visual Studio\18\Community\VC\Tools\MSVC" +if (-not (Test-Path $vcRoot)) { + $vsw = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $vcRoot = Join-Path (& $vsw -latest -products * -property installationPath) "VC\Tools\MSVC" +} +$toolset = (Get-ChildItem $vcRoot -Directory | Sort-Object Name | Select-Object -Last 1).Name +$minor = ($toolset -split '\.')[0..1] -join '.' +Write-Host "[arm64] host toolset $toolset (family $minor) - matching ARM64 payloads" + +# Parsing a 18MB json with ConvertFrom-Json is slow in PS 5.1; node is already a dependency. +$listScript = @' +const fs=require('fs'); +const cat=JSON.parse(fs.readFileSync(process.argv[2],'utf8')); +const minor=process.argv[3].replace('.','\\.'); +// 순서가 곧 우선순위다 - 뒤 패키지는 '없는 파일만' 채운다(아래 fill-only 참고). +// Tools : cl/link/lib (Hostx64 -> ARM64) +// CRT.Desktop : libcmt, libvcruntime, libcpmt ... +// CRT.Store : oldnames.lib 이 여기에만 있다(데스크톱 패키지에 빠져 있어 링크가 LNK1104로 죽는다) +const want=[ + new RegExp(`^Microsoft\\.VC\\.${minor}\\.Tools\\.HostX64\\.TargetARM64\\.base$`,'i'), + new RegExp(`^Microsoft\\.VC\\.${minor}\\.CRT\\.ARM64\\.Desktop\\.base$`,'i'), + new RegExp(`^Microsoft\\.VC\\.${minor}\\.CRT\\.ARM64\\.Store\\.base$`,'i'), +]; +const seen=new Set(); const out=[]; +for(const re of want){ + for(const p of cat.packages){ + const id=p.id||''; + if(!re.test(id)||seen.has(id)) continue; + for(const pl of (p.payloads||[])){ seen.add(id); out.push({id,url:pl.url,size:pl.size,sha256:pl.sha256}); break; } + } +} +process.stdout.write(JSON.stringify(out)); +'@ +$tmpJs = Join-Path $env:TEMP "vs-arm-list.js" +Set-Content -Path $tmpJs -Value $listScript -Encoding ascii +$pkgs = & node $tmpJs $catalog.FullName $minor | ConvertFrom-Json +if (-not $pkgs -or $pkgs.Count -lt 2) { throw "ARM64 payloads for $minor not found in the catalog" } + +# --- download + extract ----------------------------------------------------- +New-Item -ItemType Directory -Path $dest -Force | Out-Null + +foreach ($p in $pkgs) { + $file = Join-Path $cache "$($p.id).vsix" + if (-not (Test-Path $file) -or (Get-Item $file).Length -ne $p.size) { + Write-Host ("[arm64] download {0} ({1:N1} MB)" -f $p.id, ($p.size / 1MB)) + Invoke-WebRequest -Uri $p.url -OutFile $file -UseBasicParsing + } + # Payloads are signed; verify before extracting anything into the repo. + $got = (Get-FileHash $file -Algorithm SHA256).Hash + if ($p.sha256 -and $got -ne $p.sha256.ToUpper()) { + Remove-Item $file -Force + throw "sha256 mismatch for $($p.id) - download discarded" + } + Write-Host "[arm64] extract $($p.id)" + # .vsix is a zip; the useful tree is under Contents\. + # Expand-Archive refuses anything not named .zip, so go through the .NET API. + $stage = Join-Path $cache "x_$($p.id)" + if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($file, $stage) + $contents = Join-Path $stage "Contents" + if (-not (Test-Path $contents)) { throw "unexpected vsix layout: $stage" } + # **Fill only, never overwrite.** The Store package carries its own store/uwp flavour of + # libcmt & friends next to the one file we actually need from it (oldnames.lib); copying + # over the Desktop CRT would silently swap the runtime out from under the build. + $prefix = (Resolve-Path $contents).Path.TrimEnd('\') + '\' + foreach ($src in Get-ChildItem $contents -Recurse -File) { + $rel = $src.FullName.Substring($prefix.Length) + $dst = Join-Path $dest $rel + if (Test-Path $dst) { continue } + New-Item -ItemType Directory -Path (Split-Path $dst -Parent) -Force | Out-Null + Copy-Item $src.FullName $dst + } + Remove-Item $stage -Recurse -Force +} + +$cl = Get-ChildItem $dest -Recurse -Filter "cl.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "Hostx64\\arm64" } | Select-Object -First 1 +if (-not $cl) { throw "cl.exe for Hostx64/arm64 not found after extraction" } +Write-Host "[arm64] msvc ready: $($cl.FullName)" +} + +# --- clang ----------------------------------------------------------------- +# cl.exe cannot assemble the .S files that aws-lc-sys and ring ship for aarch64-windows +# (it prints "unrecognized source file type" and SKIPS them, so the link fails on a missing +# .o). Both crates expect a clang-compatible driver there. We use the plain tarball rather +# than LLVM's installer: the installer asks for elevation, and on this machine every +# elevation path is what pushed us to this script in the first place. +$llvmDir = Join-Path $root ".toolchain\llvm" +$clangCl = Join-Path $llvmDir "bin\clang-cl.exe" +if ((Test-Path $clangCl) -and -not $Force) { + Write-Host "[arm64] clang already present: $clangCl" + return +} + +Write-Host "[arm64] resolving latest LLVM release" +$rel = Invoke-RestMethod "https://api.github.com/repos/llvm/llvm-project/releases/latest" ` + -Headers @{ "User-Agent" = "archive-build" } +$asset = $rel.assets | Where-Object { $_.name -match "^clang\+llvm-.*-x86_64-pc-windows-msvc\.tar\.xz$" } | + Select-Object -First 1 +if (-not $asset) { throw "no x86_64-pc-windows-msvc tarball in LLVM release $($rel.tag_name)" } + +$tar = Join-Path $cache $asset.name +if (-not (Test-Path $tar) -or (Get-Item $tar).Length -ne $asset.size) { + Write-Host ("[arm64] download {0} ({1:N0} MB) - this one is large" -f $asset.name, ($asset.size / 1MB)) + # Invoke-WebRequest buffers the whole body in memory on PS 5.1; use the streaming client. + $wc = New-Object System.Net.WebClient + $wc.Headers.Add("User-Agent", "archive-build") + $wc.DownloadFile($asset.browser_download_url, $tar) +} + +Write-Host "[arm64] extract clang (bin + builtin headers only)" +$stage = Join-Path $cache "llvm_x" +if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } +New-Item -ItemType Directory -Path $stage -Force | Out-Null +# Windows ships bsdtar, which reads .tar.xz. Pull only what the build needs - the full +# tree is ~2.5GB and we want clang-cl, clang, and the compiler's builtin headers. +& tar -xf $tar -C $stage --strip-components=1 "*/bin/clang*.exe" "*/bin/llvm-lib.exe" "*/lib/clang/*" +if ($LASTEXITCODE -ne 0) { throw "tar extraction failed" } +if (Test-Path $llvmDir) { Remove-Item $llvmDir -Recurse -Force } +New-Item -ItemType Directory -Path (Split-Path $llvmDir -Parent) -Force | Out-Null +Move-Item $stage $llvmDir + +if (-not (Test-Path $clangCl)) { throw "clang-cl.exe missing after extraction" } +Write-Host "[arm64] clang ready: $clangCl" diff --git a/scripts/gen-fixtures.ps1 b/scripts/gen-fixtures.ps1 new file mode 100644 index 0000000..6fafe50 --- /dev/null +++ b/scripts/gen-fixtures.ps1 @@ -0,0 +1,270 @@ +# 중복 탐지 회귀 픽스처 생성 — .fixtures/.bases 의 원본 24장과 lavfi 합성 영상에서 +# "반드시 잡혀야 하는 변형"과 "잡히면 안 되는 것"을 만들고 manifest.json에 기대값을 고정한다. +# +# 사용: .\scripts\gen-fixtures.ps1 [-Bases 6] [-Seconds 30] [-Force] +# 결과: .fixtures\images\*.jpg, .fixtures\videos\*.mp4, .fixtures\manifest.json +# +# manifest의 expect 의미: +# keeper — 그룹의 기준 파일(원본) +# exact — 바이트까지 동일 → 완전 동일 그룹 +# similar — 시각적으로 같음 → 유사 그룹 +# segment — 일부 구간만 같음 → 오프셋 투표로 구간이 나와야 함 +# none — 어떤 그룹에도 들어가면 안 됨(오탐 감시용) +# stage는 계획서 단계 번호다. 1은 지금 잡혀야 하고, 3·4는 그 단계 구현 뒤에 잡혀야 한다. +param( + [int]$Bases = 6, + [int]$Seconds = 30, + [switch]$Force +) + +$ErrorActionPreference = "Stop" +$root = Split-Path $PSScriptRoot -Parent +$fixtures = Join-Path $root ".fixtures" +$basesDir = Join-Path $fixtures ".bases" +$imgDir = Join-Path $fixtures "images" +$vidDir = Join-Path $fixtures "videos" + +if (-not (Test-Path $basesDir)) { + Write-Error "원본이 없습니다: $basesDir (base0.jpg ...)" +} + +# 사이드카 ffmpeg를 쓴다 — 앱이 실제로 쓰는 그 빌드(LGPL)로 만들어야 필터 가용성이 일치한다 +$arch = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "aarch64" } else { "x86_64" } +$ff = Join-Path $root "src-tauri\binaries\ffmpeg-$arch-pc-windows-msvc.exe" +if (-not (Test-Path $ff)) { + Write-Error "ffmpeg 사이드카가 없습니다: $ff (.\scripts\fetch-ffmpeg.ps1 먼저 실행)" +} + +foreach ($d in @($imgDir, $vidDir)) { + if ((Test-Path $d) -and $Force) { Remove-Item $d -Recurse -Force } + if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d | Out-Null } +} + +$manifest = [ordered]@{ + note = "중복 탐지 회귀 픽스처. expect/stage 의미는 gen-fixtures.ps1 헤더 참조." + ffmpeg = Split-Path $ff -Leaf + images = New-Object System.Collections.ArrayList + videos = New-Object System.Collections.ArrayList +} + +function Add-Entry($list, $file, $group, $variant, $expect, $stage, $extra) { + $e = [ordered]@{ + file = $file + group = $group + variant = $variant + expect = $expect + stage = $stage + } + if ($extra) { foreach ($k in $extra.Keys) { $e[$k] = $extra[$k] } } + [void]$list.Add($e) +} + +# ffmpeg 한 번 실행. 실패하면 즉시 멈춘다 — 조용히 빠진 픽스처는 '탐지 실패'로 오해된다. +function Invoke-Ff([string[]]$ffArgs, [string]$what) { + $out = & $ff -hide_banner -loglevel error -y @ffArgs 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "ffmpeg 실패 ($what): $($out -join ' ')" + } +} + +# ── 이미지 ──────────────────────────────────────────────────────────────── +# 원본은 **구조가 있는** 그림이어야 한다. .fixtures\.bases 의 24장은 색만 다른 매끈한 +# 그라디언트여서 그레이스케일 dHash에는 최악의 입력이다(실측: 크롭 5%에 거리 7, +# 서로 다른 장끼리 거리 0). 실사 사진은 구조가 있으므로 확대되는 mandelbrot 스틸을 쓴다. +# .bases 는 '알려진 한계' 케이스로만 남긴다(아래 lim_*). +$allBases = Get-ChildItem (Join-Path $basesDir "base*.jpg") | + Sort-Object { [int]($_.BaseName -replace "\D", "") } + +$stillDir = Join-Path $fixtures ".stills" +if (-not (Test-Path $stillDir)) { New-Item -ItemType Directory -Path $stillDir | Out-Null } +Write-Host "구조 있는 원본 스틸 $($Bases)장 추출" +$stills = @() +for ($i = 0; $i -lt $Bases; $i++) { + # 확대 애니메이션의 서로 다른 지점. 간격 4 — 간격 2는 인접 확대 단계가 실제로 닮아서 + # (실측 거리 5) 서로 '무관한 그림'이라고 할 수 없다. + $t = $i * 4 + $dst = Join-Path $stillDir "still$i.jpg" + Invoke-Ff @("-f", "lavfi", "-i", "mandelbrot=size=640x480:rate=1:end_pts=$($Bases * 4 + 8)", + "-ss", "$t", "-frames:v", "1", "-q:v", "2", $dst) "still$i" + $stills += Get-Item $dst +} +$posBases = $stills + +Write-Host "이미지 변형 생성: 원본 $($posBases.Count)장 x 9변형" +foreach ($b in $posBases) { + $g = $b.BaseName # still0 ... + $orig = Join-Path $imgDir "pos_${g}_orig.jpg" + Copy-Item $b.FullName $orig -Force + Add-Entry $manifest.images "images/pos_${g}_orig.jpg" $g "orig" "keeper" 1 $null + + # 바이트까지 동일 — 완전 동일 그룹(해시 3단계)이 잡아야 한다 + $copy = Join-Path $imgDir "pos_${g}_copy.jpg" + Copy-Item $orig $copy -Force + Add-Entry $manifest.images "images/pos_${g}_copy.jpg" $g "copy" "exact" 1 $null + + # 재인코딩(품질 저하) · 축소 · 크롭 · 밝기 — dHash가 견뎌야 하는 범위 + $variants = @( + @{ name = "q12"; expect = "similar"; stage = 1; vf = $null; extra = @("-q:v", "12") }, + @{ name = "half"; expect = "similar"; stage = 1; vf = "scale=iw/2:ih/2"; extra = @("-q:v", "3") }, + # 5% 크롭은 임계 5에서 절반쯤만 잡힌다(실측: 거리 0~8) — 잘린 만큼 8x8 격자가 밀린다. + # 안정적으로 잡는 것은 단계 3의 중앙 70%/50% 크롭 해시 교차 비교다. + @{ name = "crop95"; expect = "similar"; stage = 3; vf = "crop=iw*0.95:ih*0.95"; extra = @("-q:v", "3") }, + # 밝기·대비는 colorlevels로 한다 — 흔히 쓰는 eq 필터는 GPL이라 이 LGPL 빌드에 없다(실측) + @{ name = "bright"; expect = "similar"; stage = 1; vf = "colorlevels=rimin=0.06:gimin=0.06:bimin=0.06:romax=0.96:gomax=0.96:bomax=0.96"; extra = @("-q:v", "3") }, + # 하단 자막 바 — 실측 결과 dHash가 이미 견딘다(640x480에서 48px = 8행 축소 시 0.8행). + # 단계 3의 타일 다수결이 필요한 것은 아래 wmbig처럼 화면을 크게 가리는 경우다. + @{ name = "wm"; expect = "similar"; stage = 1; vf = "drawbox=x=0:y=ih-48:w=iw:h=48:color=black@0.85:t=fill"; extra = @("-q:v", "3") }, + # 하단 30% + 우상단 큰 로고 박스 — 타일 다수결(단계 3)이 담당한다 + @{ name = "wmbig"; expect = "similar"; stage = 3; vf = "drawbox=x=0:y=ih*0.7:w=iw:h=ih*0.3:color=black:t=fill,drawbox=x=iw*0.6:y=0:w=iw*0.4:h=ih*0.2:color=white:t=fill"; extra = @("-q:v", "3") }, + # 회전·반전 — 8변형 정규 지문(단계 3)이 담당한다 + @{ name = "rot90"; expect = "similar"; stage = 3; vf = "transpose=1"; extra = @("-q:v", "3") }, + @{ name = "flip"; expect = "similar"; stage = 3; vf = "hflip"; extra = @("-q:v", "3") } + ) + foreach ($v in $variants) { + $dst = Join-Path $imgDir "pos_${g}_$($v.name).jpg" + $a = @("-i", $orig) + if ($v.vf) { $a += @("-vf", $v.vf) } + $a += $v.extra + $a += $dst + Invoke-Ff $a "$g/$($v.name)" + Add-Entry $manifest.images "images/pos_${g}_$($v.name).jpg" $g $v.name $v.expect $v.stage $null + } +} +# 음성(오탐 감시) — 서로 완전히 다른 결정적 패턴들. +# "무관한 그림끼리 임계 안에 들어오면 오탐"을 감시한다. +$negSrcs = @( + @{ n = "bars"; src = "smptebars=size=640x480" }, + @{ n = "hdbars"; src = "smptehdbars=size=640x480" }, + @{ n = "test"; src = "testsrc=size=640x480" }, + @{ n = "test2"; src = "testsrc2=size=640x480" }, + @{ n = "rgbtest"; src = "rgbtestsrc=size=640x480" }, + @{ n = "sierp"; src = "sierpinski=size=640x480:seed=7" } +) +Write-Host "음성(구조 상이) 생성: $($negSrcs.Count)장" +foreach ($s in $negSrcs) { + $dst = Join-Path $imgDir "neg_$($s.n).jpg" + Invoke-Ff @("-f", "lavfi", "-i", $s.src, "-frames:v", "1", "-q:v", "3", $dst) "neg/$($s.n)" + Add-Entry $manifest.images "images/neg_$($s.n).jpg" $null "negative" "none" 1 $null +} + +# 알려진 한계: 색만 다른 매끈한 그라디언트 2장. 그레이스케일 dHash로는 구별할 수 없어 +# 단계 1에서 **오탐으로 묶인다**(실측 거리 0). 색 지문이 들어가는 단계 3에서 풀려야 한다. +$limitPairs = @($allBases[11], $allBases[12]) +foreach ($b in $limitPairs) { + $dst = Join-Path $imgDir "lim_$($b.BaseName).jpg" + Copy-Item $b.FullName $dst -Force + Add-Entry $manifest.images "images/lim_$($b.BaseName).jpg" $null "negative" "none" 3 ` + @{ note = "색만 다른 그라디언트 — 그레이 dHash로는 거리 0(알려진 한계, 색 지문 필요)" } +} + +# ── 영상 ────────────────────────────────────────────────────────────────── +# 원본 파일 없이 lavfi로 만든다(결정적이고 재현 가능). testsrc2는 프레임마다 구조가 +# 달라 dHash 시퀀스가 뚜렷하고, mandelbrot은 완전히 다른 영상이라 음성으로 좋다. +$vA = Join-Path $vidDir "vidA.mp4" +$vidCodec = @("-c:v", "libopenh264", "-b:v", "900k", "-pix_fmt", "yuv420p") +Write-Host "영상 생성: $($Seconds)초 x 8편" + +# 매 초 화면이 실제로 달라야 한다. testsrc2는 1초 샘플로 보면 30프레임 중 서로 다른 것이 +# 15개뿐이어서(실측) 오프셋 투표가 여러 후보에서 헷갈린다 — 확대되는 mandelbrot은 매 프레임이 +# 달라 구간 판정이 명확하다. (실사 영상은 원래 이쪽에 가깝다) +# 오디오도 시간에 따라 변해야 한다. 고정 사인파는 chromaprint 값이 221개 **전부 같아서** +# (실측 distinct=1) 영상당 토큰 1개로 접히고 오디오 후보가 아예 만들어지지 않는다. +# 두 성부가 서로 다른 박자로 음을 옮겨 다니게 하면 실제 음악처럼 값이 계속 바뀐다 +# (실측 distinct=216/221 — 사인파 1, 스윕 111, 핑크노이즈 204). +$aud = "aevalsrc='0.5*sin(2*PI*(196*pow(1.0595,floor(mod(t*2,12))))*t)" + + "+0.35*sin(2*PI*(294*pow(1.0595,floor(mod(t*3,7))))*t)':d=$($Seconds):s=44100" +Invoke-Ff (@( + "-f", "lavfi", "-i", "mandelbrot=size=640x480:rate=30:maxiter=200", + "-f", "lavfi", "-i", $aud, + "-t", "$Seconds" + ) + $vidCodec + @("-c:a", "aac", "-b:a", "96k", "-shortest", $vA)) "vidA" +Add-Entry $manifest.videos "videos/vidA.mp4" "vidA" "orig" "keeper" 1 @{ durationSec = $Seconds } + +# 재인코딩(다른 코덱) — 프레임이 보존되므로 앵커가 살아남는다 +Invoke-Ff @("-i", $vA, "-c:v", "mpeg4", "-q:v", "6", "-c:a", "aac", "-b:a", "96k", + (Join-Path $vidDir "vidA_reenc.mp4")) "vidA_reenc" +Add-Entry $manifest.videos "videos/vidA_reenc.mp4" "vidA" "reencode" "similar" 1 $null + +# 중간 10초만 잘라낸 것 — 오프셋 투표가 "A의 10~20초 구간"을 찾아야 한다 +$clipStart = 10 +$clipLen = 10 +Invoke-Ff (@("-ss", "$clipStart", "-t", "$clipLen", "-i", $vA) + $vidCodec + + @("-c:a", "aac", "-b:a", "96k", (Join-Path $vidDir "vidA_clip.mp4"))) "vidA_clip" +Add-Entry $manifest.videos "videos/vidA_clip.mp4" "vidA" "clip" "segment" 1 ` + @{ expectOffsetMs = $clipStart * 1000; expectSpanMs = $clipLen * 1000 } + +# 5초 인트로 삽입 — 오프셋이 +5000ms로 나와야 한다(광고·인트로 삽입 케이스) +$introSec = 5 +Invoke-Ff (@( + "-f", "lavfi", "-i", "color=c=navy:s=640x480:r=30:d=$introSec", + "-i", $vA, + "-filter_complex", "[0:v][1:v]concat=n=2:v=1[v]", "-map", "[v]" + ) + $vidCodec + @((Join-Path $vidDir "vidA_intro.mp4"))) "vidA_intro" +Add-Entry $manifest.videos "videos/vidA_intro.mp4" "vidA" "intro" "segment" 1 ` + @{ expectOffsetMs = -($introSec * 1000); note = "vidA 기준 -5000ms (이쪽이 5초 늦게 시작)" } + +# 하단 워터마크 바 — 실측상 단계 1에서 이미 잡힌다(dHash가 8행으로 줄이므로 바 하나는 묻힌다) +Invoke-Ff (@("-i", $vA, "-vf", "drawbox=x=0:y=ih-48:w=iw:h=48:color=black@0.85:t=fill") + + $vidCodec + @("-c:a", "copy", (Join-Path $vidDir "vidA_wm.mp4"))) "vidA_wm" +Add-Entry $manifest.videos "videos/vidA_wm.mp4" "vidA" "watermark" "similar" 1 $null + +# fps 변환 — 시간축은 그대로이므로 1초 샘플은 거의 같은 내용을 본다 → 단계 1에서 잡힌다(실측). +# 계획서가 우려한 '원본 프레임 소멸'은 프레임 단위 앵커에만 해당하고, 시간 기준 샘플링에는 +# 해당하지 않는다. 시간축을 실제로 늘리는 배속(아래)이 진짜 문제다. +Invoke-Ff (@("-i", $vA, "-vf", "fps=24") + $vidCodec + + @("-c:a", "copy", (Join-Path $vidDir "vidA_fps24.mp4"))) "vidA_fps24" +Add-Entry $manifest.videos "videos/vidA_fps24.mp4" "vidA" "fps24" "similar" 1 $null + +# 1.25배속 — 시간축이 늘어나므로 절대 시간 오프셋 투표가 무력하다(단계 4) +Invoke-Ff (@("-i", $vA, "-vf", "setpts=PTS/1.25", "-af", "atempo=1.25") + $vidCodec + + @("-c:a", "aac", "-b:a", "96k", (Join-Path $vidDir "vidA_speed125.mp4"))) "vidA_speed125" +Add-Entry $manifest.videos "videos/vidA_speed125.mp4" "vidA" "speed1.25" "similar" 3 ` + @{ note = "offset_vote_speed 가 시간축을 되돌려 잡는다" } + +# 레터박스 + 축소 — 프레임 앵커로는 원리상 만나지 못하는 케이스(오디오가 후보를 만든다). +# 검정 띠를 정확히 잘라내도 다시 표본화한 해시가 원본과 거리 7~16(중앙값 9.5)이고 +# 값이 정확히 같은 프레임은 0개다. 오디오는 그대로라 221/221 일치 → 후보가 되고, +# 트림 축 프레임 거리 9로 확인된다(배경음만 같은 남남은 29라 같은 관문에서 걸린다). +# 근거: crates/archive-indexer/tests/letterbox_evidence.rs +Invoke-Ff (@("-i", $vA, + "-vf", "scale=iw*0.6:ih*0.6,pad=640:480:(ow-iw)/2:(oh-ih)/2:color=black") + + $vidCodec + @("-c:a", "copy", (Join-Path $vidDir "vidA_letterbox.mp4"))) "vidA_letterbox" +Add-Entry $manifest.videos "videos/vidA_letterbox.mp4" "vidA" "letterbox" "similar" 4 ` + @{ note = "오디오가 후보를 만들고 트림 축 프레임이 확인한다" } + +# 화면은 완전히 다른데 **오디오만 같은** 영상 — 묶이면 안 된다(같은 배경음악을 쓴 남남). +# 오디오 매칭의 오탐 위험을 고정하는 감시용 픽스처다. +Invoke-Ff (@( + "-f", "lavfi", "-i", "mandelbrot=size=640x480:rate=30:start_x=-0.75:start_y=0.1", + "-i", $vA, "-map", "0:v", "-map", "1:a", "-t", "$Seconds" + ) + $vidCodec + @("-c:a", "copy", (Join-Path $vidDir "vidC_sharedaudio.mp4"))) "vidC_sharedaudio" +Add-Entry $manifest.videos "videos/vidC_sharedaudio.mp4" $null "shared-audio" "none" 1 ` + @{ note = "오디오만 같다 — 중복이 아니다" } + +# 완전히 다른 영상 — 어떤 그룹에도 들어가면 안 된다 +Invoke-Ff (@( + "-f", "lavfi", "-i", "testsrc2=size=640x480:rate=30:duration=$Seconds" + ) + $vidCodec + @((Join-Path $vidDir "vidB.mp4"))) "vidB" +Add-Entry $manifest.videos "videos/vidB.mp4" $null "negative" "none" 1 $null + +# ── manifest ────────────────────────────────────────────────────────────── +$manifestPath = Join-Path $fixtures "manifest.json" +# BOM 없이 쓴다 — Set-Content -Encoding utf8(PS5.1)은 BOM을 붙이고, BOM이 붙은 JSON은 +# serde_json·JSON.parse가 모두 거부한다(기계가 읽는 파일이므로 BOM은 순수한 해악이다). +[System.IO.File]::WriteAllText( + $manifestPath, + ($manifest | ConvertTo-Json -Depth 6), + [System.Text.UTF8Encoding]::new($false) +) + +$imgCount = (Get-ChildItem $imgDir -File).Count +$vidCount = (Get-ChildItem $vidDir -File).Count +$bytes = (Get-ChildItem $fixtures -Recurse -File | Measure-Object -Property Length -Sum).Sum +Write-Host "" +# 한글은 식별자 문자라 "$imgCount개"는 변수 imgCount개를 읽는다 — $()로 끊어야 한다 +Write-Host "완료: 이미지 $($imgCount)개 / 영상 $($vidCount)개 / 합계 $([math]::Round($bytes / 1MB, 1))MB" +Write-Host "manifest: $manifestPath" +Write-Host "" +Write-Host "다음: 앱에서 이 폴더를 소스로 추가하거나" +Write-Host " set ARCHIVE_AUTO_SOURCE=$fixtures && .\scripts\dev.cmd" +Write-Host "썸네일·지문 백필이 끝난 뒤(사이드바 '진행 중'이 비면) 중복 찾기를 실행한다." diff --git a/scripts/test.cmd b/scripts/test.cmd index 2bc3241..2e45fdd 100644 --- a/scripts/test.cmd +++ b/scripts/test.cmd @@ -1,5 +1,44 @@ @echo off -rem 코어 크레이트 헤드리스 테스트 (Build Tools vcvars64 환경) -call "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat" >nul +rem Headless tests for the core crates. Needs an MSVC toolchain env for linking. +rem The VS install path differs per machine (BuildTools / Community / VS version), +rem so probe the usual spots and fall back to vswhere - same logic as dev.cmd. +rem NOTE: ASCII only in this file - cmd.exe reads it in the OEM codepage. +setlocal + +set "VCVARS=" +for %%P in ( + "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvarsall.bat" + "C:\Program Files\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvarsall.bat" + "C:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvarsall.bat" + "C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvarsall.bat" + "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvarsall.bat" + "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvarsall.bat" +) do if not defined VCVARS if exist %%P set "VCVARS=%%~P" + +if defined VCVARS goto :have_vcvars + +set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" +if not exist "%VSWHERE%" goto :no_vcvars +for /f "usebackq delims=" %%I in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VCVARS=%%I\VC\Auxiliary\Build\vcvarsall.bat" +if not exist "%VCVARS%" goto :no_vcvars + +:have_vcvars +call "%VCVARS%" x64 >nul +where cargo >nul 2>nul +if errorlevel 1 ( + if exist "%USERPROFILE%\.cargo\bin\cargo.exe" ( + set "PATH=%USERPROFILE%\.cargo\bin;%PATH%" + ) else ( + echo [test] cargo not found on PATH - install Rust ^(https://rustup.rs^) and reopen the shell. + exit /b 1 + ) +) cd /d "%~dp0..\src-tauri" cargo test --workspace %* +exit /b %ERRORLEVEL% + +:no_vcvars +echo [test] No MSVC toolchain found (vcvarsall.bat missing). +echo [test] Install "Desktop development with C++" (VS Build Tools or VS Community), +echo [test] or add: MSVC v14x x64/x86 build tools + Windows 11 SDK. +exit /b 1 diff --git a/scripts/verify-fixtures.mjs b/scripts/verify-fixtures.mjs new file mode 100644 index 0000000..5469e73 --- /dev/null +++ b/scripts/verify-fixtures.mjs @@ -0,0 +1,193 @@ +// 픽스처 검증 — sigq/sigmatch가 하는 일을 JS로 그대로 재현해, manifest의 기대값이 +// 실제로 성립하는지 확인한다. (Rust 툴체인 없이도 지문 파이프라인의 전제를 검증할 수 있다) +// +// 사용: node scripts\verify-fixtures.mjs (먼저 .\scripts\gen-fixtures.ps1) +// +// 재현 대상: +// ffmpeg -v error -i F -an -sn -dn -fps_mode passthrough -vf "fps=1000/1000,scale=32:32,format=gray" -f rawvideo - +// sigmatch::box_resize(32x32 -> 9x8) + dhash_of + dedup_consecutive + offset_vote +// +// 여기 이식된 함수가 sigmatch.rs와 어긋나면 이 검증은 무의미해진다 — +// sigmatch의 해시 계산을 바꾸면 이 파일도 같이 고쳐야 한다. +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const arch = process.arch === "arm64" ? "aarch64" : "x86_64"; +const FF = join(ROOT, `src-tauri/binaries/ffmpeg-${arch}-pc-windows-msvc.exe`); +const FIX = join(ROOT, ".fixtures"); +const FRAME = 32; +const FRAME_LEN = FRAME * FRAME; + +// ── sigmatch 이식 ───────────────────────────────────────── +function boxResize(src, sw, sh, dw, dh) { + const out = new Uint8Array(dw * dh); + for (let dy = 0; dy < dh; dy++) { + let y0 = Math.floor((dy * sh) / dh); + let y1 = Math.floor(((dy + 1) * sh) / dh); + if (y1 <= y0) y1 = Math.min(y0 + 1, sh); + if (y0 >= sh) { y0 = sh - 1; y1 = sh; } + for (let dx = 0; dx < dw; dx++) { + let x0 = Math.floor((dx * sw) / dw); + let x1 = Math.floor(((dx + 1) * sw) / dw); + if (x1 <= x0) x1 = Math.min(x0 + 1, sw); + if (x0 >= sw) { x0 = sw - 1; x1 = sw; } + let sum = 0, n = 0; + for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { sum += src[y * sw + x]; n++; } + out[dy * dw + dx] = Math.floor(sum / Math.max(n, 1)); + } + } + return out; +} + +function dhashOf(gray, w, h) { + const g = boxResize(gray, w, h, 9, 8); + let v = 0n, bit = 0n; + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + if (g[y * 9 + x] > g[y * 9 + x + 1]) v |= 1n << bit; + bit++; + } + return v; +} + +const hamming = (a, b) => { + let x = a ^ b, c = 0; + while (x) { x &= x - 1n; c++; } + return c; +}; + +function dedupConsecutive(seq) { + const out = []; + for (const [t, h] of seq) if (out.length === 0 || out[out.length - 1][1] !== h) out.push([t, h]); + return out; +} + +function offsetVote(a, b, maxDist, bucketMs, minSupport) { + const hist = new Map(); + for (const [ta, ha] of a) + for (const [tb, hb] of b) { + if (hamming(ha, hb) > maxDist) continue; + const d = ta - tb; + const bucket = d >= 0 + ? Math.floor((d + bucketMs / 2) / bucketMs) + : -Math.floor((-d + bucketMs / 2) / bucketMs); + const e = hist.get(bucket) ?? { n: 0, a0: Infinity, a1: 0, b0: Infinity, b1: 0 }; + e.n++; e.a0 = Math.min(e.a0, ta); e.a1 = Math.max(e.a1, ta); + e.b0 = Math.min(e.b0, tb); e.b1 = Math.max(e.b1, tb); + hist.set(bucket, e); + } + let best = null, bestB = 0; + for (const [bk, e] of hist) if (!best || e.n > best.n) { best = e; bestB = bk; } + if (!best || best.n < minSupport) return null; + return { offsetMs: bestB * bucketMs, support: best.n, aSpanMs: best.a1 - best.a0, bSpanMs: best.b1 - best.b0 }; +} + +// splitmix64 기반 값 선택 (sigmatch::selected, rate_log2=5) +function selected(v, rateLog2 = 5) { + const M = (1n << 64n) - 1n; + let x = (v * 0xff51afd7ed558ccdn) & M; + x ^= x >> 33n; + x = (x * 0xc4ceb9fe1a85ec53n) & M; + x ^= x >> 33n; + return (x >> BigInt(64 - rateLog2)) === 0n; +} + +// ── 프레임 추출 ─────────────────────────────────────────── +function graySeq(file, sampleMs = 1000) { + const buf = execFileSync( + FF, + ["-v", "error", "-nostdin", "-i", file, "-an", "-sn", "-dn", "-fps_mode", "passthrough", + "-vf", `fps=1000/${sampleMs},scale=${FRAME}:${FRAME},format=gray`, "-f", "rawvideo", "-"], + { maxBuffer: 1 << 28 }, + ); + const n = Math.floor(buf.length / FRAME_LEN); + const seq = []; + for (let i = 0; i < n; i++) { + const px = buf.subarray(i * FRAME_LEN, (i + 1) * FRAME_LEN); + seq.push([i * sampleMs, dhashOf(px, FRAME, FRAME)]); + } + return seq; +} + +function imgHash(file) { + const buf = execFileSync( + FF, + ["-v", "error", "-nostdin", "-i", file, "-vf", `scale=${FRAME}:${FRAME},format=gray`, + "-frames:v", "1", "-f", "rawvideo", "-"], + { maxBuffer: 1 << 24 }, + ); + return dhashOf(buf.subarray(0, FRAME_LEN), FRAME, FRAME); +} + +// ── 검증 ────────────────────────────────────────────────── +const manifest = JSON.parse(readFileSync(join(FIX, "manifest.json"), "utf8")); +let fail = 0; +const ok = (cond, msg) => { console.log(`${cond ? "OK " : "실패"} ${msg}`); if (!cond) fail++; }; + +// 계약: stage 1 항목은 **지금** 잡혀야 한다. stage 3·4는 그 단계 구현 뒤에 잡히면 되고, +// 지금 잡히든 안 잡히든 실패가 아니다(먼저 잡히는 건 이득). expect:none + stage 1은 절대 +// 잡히면 안 된다. expect:none + stage 3은 '알려진 한계'로 보고만 한다. +const note = (msg) => console.log(`-- ${msg}`); + +console.log("=== 이미지 (dHash 거리, 임계 5) ==="); +const imgs = manifest.images.map((e) => ({ ...e, h: imgHash(join(FIX, e.file)) })); +const keepers = new Map(imgs.filter((e) => e.expect === "keeper").map((e) => [e.group, e])); +for (const e of imgs) { + if (e.expect === "keeper" || e.expect === "none") continue; + const k = keepers.get(e.group); + const d = hamming(k.h, e.h); + const caught = d <= 5; + const line = `${e.variant.padEnd(8)} ${e.group}: 거리 ${String(d).padStart(2)} → ${caught ? "잡힘" : "안 잡힘"}`; + if (e.stage === 1) ok(caught, `${line} (단계 1: 잡혀야 함)`); + else note(`${line} (단계 ${e.stage} 대상 — 지금은 무관)`); +} +// 음성: 서로 다른 것끼리 임계 5 안에 들어오면 오탐 +const negs = imgs.filter((e) => e.expect === "none" || e.expect === "keeper"); +const hard = negs.filter((e) => e.stage === 1); +const limits = negs.filter((e) => e.stage !== 1); +let falsePos = 0; +for (let i = 0; i < hard.length; i++) + for (let j = i + 1; j < hard.length; j++) { + if (hard[i].group && hard[i].group === hard[j].group) continue; + const d = hamming(hard[i].h, hard[j].h); + if (d <= 5) { falsePos++; console.log(` 오탐: ${hard[i].file} ~ ${hard[j].file} 거리 ${d}`); } + } +ok(falsePos === 0, `무관한 이미지 ${hard.length}장 상호 오탐 ${falsePos}건`); +for (let i = 0; i < limits.length; i++) + for (let j = i + 1; j < limits.length; j++) + note(`알려진 한계: ${limits[i].file} ~ ${limits[j].file} 거리 ${hamming(limits[i].h, limits[j].h)} (색 지문 없으면 구별 불가)`); + +console.log("\n=== 영상 (오프셋 투표: maxDist 8, bucket 500ms, minSupport 4) ==="); +// sigq와 같은 규칙: 서로 다른 프레임이 64개 이하면 전부 앵커, 아니면 1/4 +const anchorRate = (seq) => (seq.length <= 64 ? 0 : 2); +const anchorsOf = (seq) => seq.filter(([, h]) => selected(h, anchorRate(seq))); +const A = dedupConsecutive(graySeq(join(FIX, "videos/vidA.mp4"))); +const aAnchors = anchorsOf(A); +console.log(`vidA 프레임 ${A.length}개(연속중복 접은 뒤) · 앵커 ${aAnchors.length}개`); +ok(aAnchors.length >= 2, `vidA 앵커 ${aAnchors.length}개 (후보 생성에 최소 2개 필요)`); +for (const e of manifest.videos) { + if (e.variant === "orig") continue; + const S = dedupConsecutive(graySeq(join(FIX, e.file))); + const v = offsetVote(A, S, 8, 500, 4); + const sAnchorSet = new Set(anchorsOf(S).map(([, h]) => h)); + const shared = new Set(aAnchors.filter(([, h]) => sAnchorSet.has(h)).map(([, h]) => h)).size; + // dedupe.rs와 같은 판정: 후보(공통앵커 ≥2) → 확정(구간 ≥3초 & 짧은 쪽 30% 이상) + const shorterMs = Math.min(A[A.length - 1][0], S[S.length - 1][0]) + 1000; + const cand = shared >= 2; + const caught = cand && v !== null && v.aSpanMs >= 3000 && v.aSpanMs / shorterMs >= 0.3; + const detail = v ? `오프셋 ${String(v.offsetMs).padStart(6)}ms 지지 ${String(v.support).padStart(2)} 구간 ${String(v.aSpanMs).padStart(5)}ms` : "일치 없음 "; + const line = `${e.variant.padEnd(10)} → ${detail} · 공통앵커 ${String(shared).padStart(2)} → ${caught ? "잡힘" : "안 잡힘"}`; + if (e.expect === "none") ok(!caught, `${line} (묶이면 안 됨)`); + else if (e.stage === 1) ok(caught, `${line} (단계 1: 잡혀야 함)`); + else note(`${line} (단계 ${e.stage} 대상 — 지금은 무관)`); + if (e.expectOffsetMs !== undefined && v) { + ok(Math.abs(v.offsetMs - e.expectOffsetMs) <= 1000, + ` ${e.variant} 오프셋 ${v.offsetMs}ms ≈ 기대 ${e.expectOffsetMs}ms`); + } +} + +console.log(fail === 0 ? "\n전부 기대대로" : `\n기대와 다른 항목 ${fail}건`); +process.exit(fail === 0 ? 0 : 1); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0cb9c1e..c9e771d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -143,6 +143,7 @@ dependencies = [ "tauri-plugin-window-state", "tokio", "tracing", + "tracing-appender", "tracing-subscriber", ] @@ -6260,6 +6261,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -7085,6 +7092,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 26ea170..45b9494 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,6 +45,8 @@ anyhow = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# 릴리스 GUI 빌드에는 콘솔이 없다 — 파일로 남기지 않으면 진단이 통째로 사라진다. +tracing-appender = "0.2" dirs = "6" rusqlite = { version = "0.40", features = ["bundled"] } notify-debouncer-full = "0.7" diff --git a/src-tauri/crates/archive-db/src/migrations.rs b/src-tauri/crates/archive-db/src/migrations.rs index 32a6f64..72cb578 100644 --- a/src-tauri/crates/archive-db/src/migrations.rs +++ b/src-tauri/crates/archive-db/src/migrations.rs @@ -156,6 +156,177 @@ CREATE TABLE settings ( ) WITHOUT ROWID; "#; +/// 스키마 v2: ops_journal.op에 'delete'(영구 삭제) 추가. +/// CHECK 제약은 ALTER로 못 바꾸므로 테이블을 재생성한다. +const V2_DDL: &str = r#" +CREATE TABLE ops_journal_v2 ( + id INTEGER PRIMARY KEY, + batch_id TEXT NOT NULL, + op TEXT NOT NULL CHECK (op IN ('move','rename','trash','delete','tag','untag')), + file_id INTEGER, + src_path TEXT, + dst_path TEXT, + tag_id INTEGER, + performed_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000), + undone INTEGER NOT NULL DEFAULT 0 +); +INSERT INTO ops_journal_v2(id, batch_id, op, file_id, src_path, dst_path, tag_id, performed_at, undone) + SELECT id, batch_id, op, file_id, src_path, dst_path, tag_id, performed_at, undone FROM ops_journal; +DROP TABLE ops_journal; +ALTER TABLE ops_journal_v2 RENAME TO ops_journal; +CREATE INDEX idx_ops_batch ON ops_journal(batch_id); +"#; + +/// 스키마 v3: 지문(signature) 영속화. +/// +/// 중복 탐지가 매 실행마다 라이브러리 전체를 다시 계산하던 것을 없애기 위해, 파일당 지문을 +/// 한 번 계산해 저장한다. 재계산 단위는 `files.sig_state`(0 대기 / 1 완료 / 2 실패)이고, +/// 이는 `thumb_state`와 같은 방식이다 — **행별 상태 컬럼 + 부분 인덱스**가 곧 작업 큐이자 +/// 재시작 후 이어하기 수단이다. `sig_version`을 올리면 해당 파일만 다시 계산된다. +/// +/// `orient`/`tiles`/`scales`는 **대표 프레임 한 장**을 기술한다(이미지는 그 이미지, +/// 영상은 첫 유효 프레임). 프레임 단위 상세는 `frames` BLOB에 들어간다. +const V3_DDL: &str = r#" +ALTER TABLE files ADD COLUMN sig_state INTEGER NOT NULL DEFAULT 0; +CREATE INDEX idx_files_pending_sig ON files(id) WHERE sig_state = 0 AND deleted_at IS NULL; + +CREATE TABLE file_sigs ( + file_id INTEGER PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE, + sig_version INTEGER NOT NULL, + kind INTEGER NOT NULL, + dhash INTEGER NOT NULL, + phash INTEGER NOT NULL, + orient BLOB, + tiles BLOB, + scales BLOB, + frames BLOB, + keyframes BLOB, + frame_count INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER, + stdev REAL, + flags INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000) +); +CREATE INDEX idx_file_sigs_dhash ON file_sigs(dhash); +CREATE INDEX idx_file_sigs_version ON file_sigs(sig_version); +"#; + +/// 스키마 v4: 기본 정렬용 표현식 인덱스. +/// +/// 그리드 스냅샷의 기본 정렬은 `COALESCE(taken_at, mtime_ms) DESC, id DESC`다. 표현식이라 +/// 어떤 컬럼 인덱스도 쓰이지 못해, SQLite가 매번 임시 B-tree로 **전체를 정렬**했다. +/// 100만 건 합성 DB 실측: 첫 행까지 1,574ms(정렬 완료를 기다림) → 인덱스 추가 후 0ms, +/// 스캔+정렬 239ms → 54ms. 임시 B-tree(100만 항목, 디스크로 흘러넘침)도 사라진다. +/// +/// 두 개가 필요하다: 소스 뷰는 `source_id`가 선두여야 하고(작은 소스도 O(결과)), +/// 라이브러리 전체 뷰는 선두 컬럼이 없어야 정렬된 순서로 훑을 수 있다. +/// 대가는 삽입 시 인덱스 2개 유지 — 스캔이 조금 느려지는 대신 뷰 전환이 즉시 뜬다. +const V4_DDL: &str = r#" +CREATE INDEX idx_files_src_sort ON files(source_id, COALESCE(taken_at, mtime_ms) DESC, id DESC) + WHERE deleted_at IS NULL; +CREATE INDEX idx_files_sort ON files(COALESCE(taken_at, mtime_ms) DESC, id DESC) + WHERE deleted_at IS NULL; +"#; + +/// 스키마 v5: 중복 그룹의 **일치 구간**을 저장한다 (비교 재생용). +/// +/// 영상 중복은 오프셋 투표로 "A의 10~20초가 B의 0~10초와 같다"까지 알아내는데, 지금까지는 +/// 그 값을 로그로만 흘려보냈다. 비교 재생에서 두 영상을 같은 내용에 맞춰 돌리려면 이 값이 +/// 필요하다 — 그냥 나란히 0초부터 재생하면 인트로가 붙은 사본은 계속 어긋난 화면을 보여준다. +/// +/// 기준은 그룹의 keeper다: `offset_ms = (keeper 시각) - (이 멤버 시각)`. +/// 즉 재생 위치를 맞출 때 `member.currentTime = keeper.currentTime - offset_ms/1000`. +/// `seg_start_ms`/`seg_end_ms`는 keeper 시간축에서 일치가 확인된 구간이다. +const V5_DDL: &str = r#" +ALTER TABLE dupe_members ADD COLUMN offset_ms INTEGER; +ALTER TABLE dupe_members ADD COLUMN seg_start_ms INTEGER; +ALTER TABLE dupe_members ADD COLUMN seg_end_ms INTEGER; +"#; + +/// 스키마 v6: 후보 검증용 두 축을 저장한다 (`sig_version` 2와 짝). +/// +/// - `tile_flat`: 4×4 타일별 '평평함'(정보 없음) 비트마스크. 검정 바·흰 로고로 가려진 영역을 +/// 판정에서 빼기 위해 필요하다. 이게 없으면 *가림이 같다는 이유로* 서로 다른 사진이 +/// 한 그룹이 된다(실측된 오탐). +/// - `color`: 4×4 셀의 색차(u,v) 32바이트. 그레이 dHash는 구조만 보므로 색만 다른 그림을 +/// 구별하지 못한다(실측: 초록/자홍 그라디언트 거리 0). 영상은 그레이 파이프라 NULL이다. +/// +/// 기존 행은 `sig_version`이 낮아 자연히 재계산 대상이 된다 — 별도 백필이 필요 없다. +const V6_DDL: &str = r#" +ALTER TABLE file_sigs ADD COLUMN tile_flat INTEGER NOT NULL DEFAULT 0; +ALTER TABLE file_sigs ADD COLUMN color BLOB; +UPDATE files SET sig_state = 0 WHERE sig_state = 1; +"#; + +/// 스키마 v7: 영상 오디오 지문(chromaprint) 저장 (`sig_version` 3과 짝). +/// +/// 화면을 다시 표본화하는 변형(레터박스·리스케일)은 프레임 해시가 밀려 값 기반 후보에 +/// 걸리지 않는다 — 실측: 검정 띠를 정확히 잘라내도 거리 중앙값 9.5, 정확히 같은 값 0개. +/// 오디오는 그런 변형에 영향받지 않으므로(레터박스 사본은 221/221 일치) 후보를 만드는 +/// 두 번째 축으로 쓴다. 다만 오디오만 같은 남남도 똑같이 붙으므로(배경음악 공유) +/// 후보는 반드시 프레임으로 확인한다 — 그 간격도 실측했다(진짜 9 vs 남남 29). +/// +/// 재계산은 `sigq::invalidate_stale_sigs`가 `sig_version` 변화를 보고 알아서 예약한다. +const V7_DDL: &str = r#" +ALTER TABLE file_sigs ADD COLUMN audio BLOB; +"#; + +/// 스키마 v8: 태그 세트 — 자주 함께 붙이는 태그 묶음을 한 번에 적용한다. +/// +/// 태그를 하나씩 검색해 붙이는 것이 정리 작업의 대부분을 차지한다는 지적에서 나왔다. +/// `hotkey`는 화면에 매인 값이 아니라 사용자 설정이므로 DB에 둔다(기기를 옮겨도 따라간다). +/// 태그가 지워지면 세트에서도 빠진다(CASCADE) — 세트는 태그의 묶음일 뿐 사본이 아니다. +const V8_DDL: &str = r#" +CREATE TABLE tag_sets ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + hotkey TEXT, + sort_order INTEGER +); +CREATE TABLE tag_set_items ( + set_id INTEGER NOT NULL REFERENCES tag_sets(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (set_id, tag_id) +) WITHOUT ROWID; +CREATE INDEX idx_tag_set_items_tag ON tag_set_items(tag_id); +"#; + +/// 스키마 v9: 별점(0~5). +/// +/// 부분 인덱스를 쓰는 이유: 대부분의 파일은 별점이 없다(0). '별점 있는 것만' 걸러 정렬·필터할 때 +/// 0인 수십만 행을 훑지 않도록 0을 색인에서 빼둔다. +const V9_DDL: &str = r#" +ALTER TABLE files ADD COLUMN rating INTEGER NOT NULL DEFAULT 0; +CREATE INDEX idx_files_rating ON files(rating DESC, id DESC) WHERE rating > 0 AND deleted_at IS NULL; +"#; + +/// 스키마 v10: ops_journal에 'edit'(회전·반전) 추가 + payload 칸. +/// +/// 회전은 되돌릴 때 **역연산을 다시 적용하면 된다** — 원본을 백업해 둘 필요가 없다. +/// 그 역연산 이름을 담을 칸이 필요해서 payload를 만든다. src_path/dst_path에 +/// 억지로 끼워 넣으면 다음에 읽는 사람이 경로인 줄 안다. +/// +/// SQLite는 CHECK 제약을 ALTER로 못 바꾸므로 v2와 같은 방식으로 표를 다시 만든다. +const V10_DDL: &str = r#" +CREATE TABLE ops_journal_v10 ( + id INTEGER PRIMARY KEY, + batch_id TEXT NOT NULL, + op TEXT NOT NULL CHECK (op IN ('move','rename','trash','delete','tag','untag','edit')), + file_id INTEGER, + src_path TEXT, + dst_path TEXT, + tag_id INTEGER, + payload TEXT, + performed_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000), + undone INTEGER NOT NULL DEFAULT 0 +); +INSERT INTO ops_journal_v10(id, batch_id, op, file_id, src_path, dst_path, tag_id, performed_at, undone) + SELECT id, batch_id, op, file_id, src_path, dst_path, tag_id, performed_at, undone FROM ops_journal; +DROP TABLE ops_journal; +ALTER TABLE ops_journal_v10 RENAME TO ops_journal; +CREATE INDEX idx_ops_batch ON ops_journal(batch_id); +"#; + /// FTS5 trigram — 한글 파일명 부분일치 검색. bundled SQLite가 trigram을 /// 지원하지 않는 환경이면 생성 실패를 허용하고 LIKE 폴백을 쓴다. const V1_FTS_DDL: &str = r#" @@ -183,6 +354,7 @@ END; /// 마이그레이션을 적용하고 FTS 사용 가능 여부를 반환한다. pub fn migrate(conn: &mut Connection) -> rusqlite::Result { let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?; + let mut fresh_fts: Option = None; if version < 1 { let tx = conn.transaction()?; @@ -199,6 +371,75 @@ pub fn migrate(conn: &mut Connection) -> rusqlite::Result { if !fts_ok { tracing::warn!("FTS5 trigram 사용 불가 — LIKE 검색으로 폴백"); } + fresh_fts = Some(fts_ok); + } + + if version < 2 { + let tx = conn.transaction()?; + tx.execute_batch(V2_DDL)?; + tx.pragma_update(None, "user_version", 2)?; + tx.commit()?; + } + + if version < 3 { + let tx = conn.transaction()?; + tx.execute_batch(V3_DDL)?; + tx.pragma_update(None, "user_version", 3)?; + tx.commit()?; + } + + if version < 4 { + // 기존 라이브러리에서는 인덱스 2개를 새로 만든다 — 100만 건에서 합쳐 3초 남짓, + // 앱 시작이 한 번 그만큼 늦어지는 대신 이후 모든 뷰 전환이 즉시 뜬다. + let tx = conn.transaction()?; + tx.execute_batch(V4_DDL)?; + tx.pragma_update(None, "user_version", 4)?; + tx.commit()?; + } + + if version < 5 { + let tx = conn.transaction()?; + tx.execute_batch(V5_DDL)?; + tx.pragma_update(None, "user_version", 5)?; + tx.commit()?; + } + + if version < 6 { + let tx = conn.transaction()?; + tx.execute_batch(V6_DDL)?; + tx.pragma_update(None, "user_version", 6)?; + tx.commit()?; + } + + if version < 7 { + let tx = conn.transaction()?; + tx.execute_batch(V7_DDL)?; + tx.pragma_update(None, "user_version", 7)?; + tx.commit()?; + } + + if version < 8 { + let tx = conn.transaction()?; + tx.execute_batch(V8_DDL)?; + tx.pragma_update(None, "user_version", 8)?; + tx.commit()?; + } + + if version < 9 { + let tx = conn.transaction()?; + tx.execute_batch(V9_DDL)?; + tx.pragma_update(None, "user_version", 9)?; + tx.commit()?; + } + + if version < 10 { + let tx = conn.transaction()?; + tx.execute_batch(V10_DDL)?; + tx.pragma_update(None, "user_version", 10)?; + tx.commit()?; + } + + if let Some(fts_ok) = fresh_fts { return Ok(fts_ok); } diff --git a/src-tauri/crates/archive-indexer/src/dedupe.rs b/src-tauri/crates/archive-indexer/src/dedupe.rs index 22c2ae3..6d620a7 100644 --- a/src-tauri/crates/archive-indexer/src/dedupe.rs +++ b/src-tauri/crates/archive-indexer/src/dedupe.rs @@ -1,15 +1,21 @@ -//! 중복 탐지 파이프라인. +//! 중복 탐지 파이프라인 — **저장된 지문을 읽는다**(매번 계산하지 않는다). //! -//! 완전 동일: size 그룹 → quick_hash → BLAKE3 전체 해시 (그룹 내만). -//! 유사 이미지: dHash(썸네일에서) 병렬 브루트포스 → Union-Find 클러스터. -//! 유사 영상: duration ±5% 사전필터 → 프레임 시그니처 중앙값 해밍. -//! 결과는 dupe_groups/dupe_members에 영속화된다. +//! - 완전 동일: size 그룹 → quick_hash → BLAKE3 전체 해시. 여기만 원본을 읽는다(내용 확정에 필요). +//! - 유사 이미지: `file_sigs.dhash` 브루트포스 → 클러스터. +//! - 유사 영상: 앵커 역색인(메모리)으로 후보를 좁히고, 조밀 프레임 시퀀스 **오프셋 투표**로 +//! 일치 구간을 확정한다. 긴 영상의 일부·광고 삽입·FPS 차이가 한 메커니즘으로 처리된다. +//! +//! 지문은 `sigq`가 상시 저우선순위 백그라운드로 채운다. 아직 지문이 없는 파일은 이번 탐지에서 +//! 빠지고 그 수를 `skippedNoSig`로 보고한다 — 조용히 빠지면 "왜 안 잡히지"가 된다. +//! +//! 예전에는 이 파일이 실행마다 전량을 다시 계산했다(영상 1편당 ffprobe 1 + ffmpeg 10 프로세스, +//! 고정 임시파일명 때문에 워커끼리 덮어쓰는 경쟁 조건까지 있었다). 그 코드는 사라졌다. -use crate::ffmpeg::FfTools; +use crate::sigmatch::{self, SegMatch}; use crate::{hash, phash}; use archive_db::Db; use rayon::prelude::*; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -24,6 +30,32 @@ pub enum DedupeError { pub type Result = std::result::Result; +/// 앵커 토큰 하나가 가질 수 있는 최대 포스팅 수. 검정 프레임·공통 인트로는 수천 영상에 +/// 공통이라 후보를 폭발시킨다 — IDF와 함께 2중 방어다(저분산 게이트가 새더라도 동작). +const MAX_DF: usize = 64; +/// 후보로 올릴 최소 공통 앵커 수. 1개는 우연이 잦다. +const MIN_SHARED_ANCHORS: u32 = 2; +/// 오프셋 투표: 프레임 해밍 허용, 오프셋 버킷(샘플 간격 1초의 절반), 최소 지지 프레임 수. +const VOTE_MAX_DIST: u32 = 8; +const VOTE_BUCKET_MS: i64 = 500; +const VOTE_MIN_SUPPORT: u32 = 4; +/// 오디오로 만난 후보의 프레임 허용치. 오디오는 화면 변형에 영향받지 않아 후보를 만들지만, +/// 바로 그 이유로 **배경음만 같은 남남**도 붙인다 — 시각 확인이 유일한 방어선이다. +/// 실측으로 고른 값(`tests/letterbox_evidence.rs`): 진짜 레터박스 사본은 프레임 거리 +/// 중앙값 9(범위 7~16), 배경음만 같은 남남은 29. 16이면 전자는 전부 표를 주고 후자는 0표다. +const AUDIO_VOTE_MAX_DIST: u32 = 16; +/// 일치 구간이 이보다 짧으면 버린다 — 몇 프레임 우연 일치로 그룹이 생기는 것을 막는다. +const MIN_SEGMENT_MS: u32 = 3_000; +/// 일치 구간이 **짧은 쪽** 영상의 이 비율 이상을 덮어야 한다. +/// "긴 영상의 일부"는 짧은 쪽이 거의 전부 덮이므로 통과하고, 2시간짜리 둘이 +/// 3초만 겹치는 우연은 걸러진다. (길이 ±5% 프리필터가 하던 일을 방향을 뒤집어 대신한다) +const MIN_COVERAGE: f64 = 0.30; +/// 조밀 시퀀스 캐시 상한(영상 수). 최악 240KB/편이므로 상주 15MB 수준. +const DENSE_CACHE: usize = 64; +/// 이 개수까지는 상삼각 브루트포스로 **정확히** 비교한다(임계값과 무관). +/// 5만이면 1.25×10⁹ 비교로 1초 안쪽 — 그 위는 밴드 색인이 훨씬 싸다. +const BRUTE_MAX: usize = 50_000; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum GroupKind { @@ -39,6 +71,8 @@ pub struct DedupeStats { pub image_groups: u64, pub video_groups: u64, pub total_dupes: u64, + /// 지문이 아직 없어 유사 탐지에서 빠진 파일 수 (백그라운드가 채우는 중) + pub skipped_no_sig: u64, } #[derive(Debug, Clone, serde::Serialize)] @@ -49,6 +83,7 @@ pub struct DedupeProgress { pub total: u64, } +/// 그룹 표시·keeper 선정에 필요한 만큼만. 영상 길이는 `file_sigs`에서 읽으므로 여기 없다. struct FileMeta { id: i64, path: PathBuf, @@ -56,7 +91,6 @@ struct FileMeta { width: Option, height: Option, mtime_ms: i64, - duration_ms: Option, } /// 중복 탐지 범위. `folder_snapshot`과 동일한 스코핑 규칙을 따른다. @@ -70,11 +104,37 @@ pub enum Scope { Folder { id: i64, recursive: bool }, } +/// 스코프 조건절과 바인딩. `?1`은 항상 kind다 — 뒤 번호부터 스코프가 쓴다. +fn scope_clause(scope: Scope) -> &'static str { + match scope { + Scope::All => "", + Scope::Source(_) => " AND f.source_id = ?2", + Scope::Folder { recursive: false, .. } => " AND f.folder_id = ?2", + Scope::Folder { recursive: true, .. } => { + " AND fo.source_id = ?2 AND (fo.path = ?3 OR fo.path LIKE ?4)" + } + } +} + +/// 재귀 폴더 스코프의 (source_id, path, prefix)를 읽는다. +fn folder_prefix(conn: &rusqlite::Connection, folder_id: i64) -> rusqlite::Result<(i64, String, String)> { + let (src, path): (i64, String) = conn.query_row( + "SELECT source_id, path FROM folders WHERE id = ?1", + [folder_id], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + let prefix = format!("{}{}%", path, std::path::MAIN_SEPARATOR); + Ok((src, path, prefix)) +} + fn load_files(db: &Db, kind: u8, scope: Scope) -> Result> { Ok(db.with_read(move |conn| { - let base = "SELECT f.id, fo.path, f.name, f.size, f.width, f.height, f.mtime_ms, f.duration_ms - FROM files f JOIN folders fo ON fo.id = f.folder_id - WHERE f.kind = ?1 AND f.deleted_at IS NULL"; + let sql = format!( + "SELECT f.id, fo.path, f.name, f.size, f.width, f.height, f.mtime_ms + FROM files f JOIN folders fo ON fo.id = f.folder_id + WHERE f.kind = ?1 AND f.deleted_at IS NULL{}", + scope_clause(scope) + ); let map = |r: &rusqlite::Row<'_>| { let dir: String = r.get(1)?; let name: String = r.get(2)?; @@ -85,43 +145,241 @@ fn load_files(db: &Db, kind: u8, scope: Scope) -> Result> { width: r.get(4)?, height: r.get(5)?, mtime_ms: r.get(6)?, - duration_ms: r.get(7)?, }) }; + let mut stmt = conn.prepare(&sql)?; match scope { - Scope::All => { - let mut stmt = conn.prepare(base)?; - let rows = stmt.query_map([kind], map)?; - rows.collect() - } - Scope::Source(sid) => { - let mut stmt = conn.prepare(&format!("{base} AND f.source_id = ?2"))?; - let rows = stmt.query_map(rusqlite::params![kind, sid], map)?; - rows.collect() - } + Scope::All => stmt.query_map([kind], map)?.collect(), + Scope::Source(sid) => stmt.query_map(rusqlite::params![kind, sid], map)?.collect(), Scope::Folder { id, recursive: false } => { - let mut stmt = conn.prepare(&format!("{base} AND f.folder_id = ?2"))?; - let rows = stmt.query_map(rusqlite::params![kind, id], map)?; - rows.collect() + stmt.query_map(rusqlite::params![kind, id], map)?.collect() } Scope::Folder { id, recursive: true } => { - // 폴더 경로 프리픽스로 하위 전체 (folder_snapshot과 동일) - let (src, path): (i64, String) = conn.query_row( - "SELECT source_id, path FROM folders WHERE id = ?1", - [id], - |r| Ok((r.get(0)?, r.get(1)?)), - )?; - let prefix = format!("{}{}%", path, std::path::MAIN_SEPARATOR); - let mut stmt = conn.prepare(&format!( - "{base} AND fo.source_id = ?2 AND (fo.path = ?3 OR fo.path LIKE ?4)" - ))?; - let rows = stmt.query_map(rusqlite::params![kind, src, path, prefix], map)?; - rows.collect() + let (src, path, prefix) = folder_prefix(conn, id)?; + stmt.query_map(rusqlite::params![kind, src, path, prefix], map)?.collect() } } })?) } +/// 스코프 안의 이미지 지문 (file_id, dhash, [중앙70%, 중앙50%, 레터박스]). +/// 지문이 없는 파일은 빠진다. 크롭 해시는 후보 질의에 쓴다(파일당 24바이트). +fn load_image_index(db: &Db, scope: Scope) -> Result> { + Ok(db.with_read(move |conn| { + let sql = format!( + "SELECT s.file_id, s.dhash, s.scales + FROM file_sigs s + JOIN files f ON f.id = s.file_id + JOIN folders fo ON fo.id = f.folder_id + WHERE f.kind = ?1 AND f.deleted_at IS NULL AND s.sig_version = ?{}{}", + scope_version_slot(scope), + scope_clause(scope) + ); + let map = |r: &rusqlite::Row<'_>| { + let dhash = r.get::<_, i64>(1)? as u64; + let blob: Vec = r.get::<_, Option>>(2)?.unwrap_or_default(); + let v = sigmatch::unpack_u64s(&blob); + // 없으면 dhash로 채워 '추가 정보 없음'이 되게 한다(질의에서 중복 제거됨) + let mut scales = [dhash; 3]; + for (i, x) in v.iter().take(3).enumerate() { + scales[i] = *x; + } + Ok((r.get::<_, i64>(0)?, dhash, scales)) + }; + let mut stmt = conn.prepare(&sql)?; + let v = sigmatch::SIG_VERSION; + match scope { + Scope::All => stmt.query_map(rusqlite::params![kind_img(), v], map)?.collect(), + Scope::Source(sid) => stmt.query_map(rusqlite::params![kind_img(), sid, v], map)?.collect(), + Scope::Folder { id, recursive: false } => { + stmt.query_map(rusqlite::params![kind_img(), id, v], map)?.collect() + } + Scope::Folder { id, recursive: true } => { + let (src, path, prefix) = folder_prefix(conn, id)?; + stmt.query_map(rusqlite::params![kind_img(), src, path, prefix, v], map)?.collect() + } + } + })?) +} + +/// 스코프 안 이미지의 8변형 해시 — 회전·반전 탐지를 켤 때만 읽는다(파일당 64바이트). +fn load_image_orients(db: &Db, scope: Scope) -> Result> { + Ok(db.with_read(move |conn| { + let sql = format!( + "SELECT s.file_id, s.orient + FROM file_sigs s + JOIN files f ON f.id = s.file_id + JOIN folders fo ON fo.id = f.folder_id + WHERE f.kind = ?1 AND f.deleted_at IS NULL AND s.sig_version = ?{}{}", + scope_version_slot(scope), + scope_clause(scope) + ); + let map = |r: &rusqlite::Row<'_>| { + let id: i64 = r.get(0)?; + let blob: Vec = r.get::<_, Option>>(1)?.unwrap_or_default(); + let v = sigmatch::unpack_u64s(&blob); + let mut out = [0u64; 8]; + for (i, x) in v.iter().take(8).enumerate() { + out[i] = *x; + } + Ok((id, out)) + }; + let mut stmt = conn.prepare(&sql)?; + let v = sigmatch::SIG_VERSION; + match scope { + Scope::All => stmt.query_map(rusqlite::params![kind_img(), v], map)?.collect(), + Scope::Source(sid) => stmt.query_map(rusqlite::params![kind_img(), sid, v], map)?.collect(), + Scope::Folder { id, recursive: false } => { + stmt.query_map(rusqlite::params![kind_img(), id, v], map)?.collect() + } + Scope::Folder { id, recursive: true } => { + let (src, path, prefix) = folder_prefix(conn, id)?; + stmt.query_map(rusqlite::params![kind_img(), src, path, prefix, v], map)?.collect() + } + } + })?) +} + +const fn kind_img() -> i64 { + 0 +} +const fn kind_vid() -> i64 { + 1 +} + +/// `sig_version` 바인딩 번호 — 스코프가 쓰는 슬롯 뒤에 온다. +fn scope_version_slot(scope: Scope) -> &'static str { + match scope { + Scope::All => "2", + Scope::Source(_) | Scope::Folder { recursive: false, .. } => "3", + Scope::Folder { recursive: true, .. } => "5", + } +} + +/// 영상 한 편의 희소 지문 — 앵커(콘텐츠 고정 표본)만. 조밀 시퀀스는 확정할 때 따로 읽는다. +struct VideoSig { + id: i64, + /// (t_ms, 전체 dHash) + anchors: Vec<(u32, u64)>, + /// (t_ms, chromaprint 값) — 오디오가 있는 영상만. 프레임이 못 잡는 축의 후보를 만든다. + audio: Vec<(u32, u32)>, + duration_ms: i64, +} + +fn load_video_sigs(db: &Db, scope: Scope) -> Result> { + Ok(db.with_read(move |conn| { + let sql = format!( + "SELECT s.file_id, s.keyframes, s.duration_ms, s.audio + FROM file_sigs s + JOIN files f ON f.id = s.file_id + JOIN folders fo ON fo.id = f.folder_id + WHERE f.kind = ?1 AND f.deleted_at IS NULL AND s.sig_version = ?{}{}", + scope_version_slot(scope), + scope_clause(scope) + ); + let map = |r: &rusqlite::Row<'_>| { + let blob: Vec = r.get::<_, Option>>(1)?.unwrap_or_default(); + let recs = sigmatch::unpack_frames(&blob); + Ok(VideoSig { + id: r.get(0)?, + anchors: sigmatch::dhash_seq(&recs), + duration_ms: r.get::<_, Option>(2)?.unwrap_or(0), + audio: sigmatch::unpack_audio(&r.get::<_, Option>>(3)?.unwrap_or_default()), + }) + }; + let mut stmt = conn.prepare(&sql)?; + let v = sigmatch::SIG_VERSION; + match scope { + Scope::All => stmt.query_map(rusqlite::params![kind_vid(), v], map)?.collect(), + Scope::Source(sid) => stmt.query_map(rusqlite::params![kind_vid(), sid, v], map)?.collect(), + Scope::Folder { id, recursive: false } => { + stmt.query_map(rusqlite::params![kind_vid(), id, v], map)?.collect() + } + Scope::Folder { id, recursive: true } => { + let (src, path, prefix) = folder_prefix(conn, id)?; + stmt.query_map(rusqlite::params![kind_vid(), src, path, prefix, v], map)?.collect() + } + } + })?) +} + +/// 후보로 올라온 파일의 '풍부한' 지문을 읽는다 (타일·크롭·변형·색). +/// +/// 전량 로드는 100만 장에서 152MB라 **후보에만** 읽는다. 후보 수는 실제 근접쌍 수준이다. +fn load_rich(db: &Db, file_id: i64) -> Option { + db.with_read(|conn| { + conn.query_row( + "SELECT dhash, orient, tiles, scales, tile_flat, color + FROM file_sigs WHERE file_id = ?1", + [file_id], + |r| { + Ok(( + r.get::<_, i64>(0)? as u64, + r.get::<_, Option>>(1)?, + r.get::<_, Option>>(2)?, + r.get::<_, Option>>(3)?, + r.get::<_, i64>(4)? as u16, + r.get::<_, Option>>(5)?, + )) + }, + ) + }) + .ok() + .map(|(dhash, orient, tiles, scales, tile_flat, color)| { + // 고정 길이 배열로 펼친다. 값이 없거나 길이가 다르면 dhash로 채워 + // '정보 없음'이 곧 '차이 없음'으로 읽히게 한다(있는 것만 판정에 쓴다). + let to_arr8 = |b: Option>| { + let v = b.map(|b| sigmatch::unpack_u64s(&b)).unwrap_or_default(); + let mut out = [dhash; 8]; + for (i, x) in v.iter().take(8).enumerate() { + out[i] = *x; + } + out + }; + let to_arr16 = |b: Option>| { + let v = b.map(|b| sigmatch::unpack_u64s(&b)).unwrap_or_default(); + let mut out = [0u64; 16]; + for (i, x) in v.iter().take(16).enumerate() { + out[i] = *x; + } + out + }; + let to_arr3 = |b: Option>| { + let v = b.map(|b| sigmatch::unpack_u64s(&b)).unwrap_or_default(); + let mut out = [dhash; 3]; + for (i, x) in v.iter().take(3).enumerate() { + out[i] = *x; + } + out + }; + sigmatch::RichSig { + dhash, + orient: to_arr8(orient), + tiles: to_arr16(tiles), + scales: to_arr3(scales), + tile_flat, + color: color.and_then(|b| sigmatch::unpack_color(&b)), + } + }) +} + +/// 조밀 프레임 시퀀스 — 확정 단계에서만 읽는다. 두 축을 함께 돌려준다. +fn load_dense(db: &Db, file_id: i64) -> Dense { + let recs = db + .with_read(|conn| { + conn.query_row( + "SELECT frames FROM file_sigs WHERE file_id = ?1", + [file_id], + |r| r.get::<_, Option>>(0), + ) + }) + .ok() + .flatten() + .map(|b| sigmatch::unpack_frames(&b)) + .unwrap_or_default(); + Dense { whole: sigmatch::dhash_seq(&recs), lb: sigmatch::lb_seq(&recs) } +} + fn now_ms() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -129,7 +387,7 @@ fn now_ms() -> i64 { .unwrap_or(0) } -/// keeper 자동 선정: 해상도↓ → mtime(오래된 것)↓ → id↓. +/// keeper 자동 선정: 해상도↓ → 파일크기↓ → mtime(오래된 것)↓ → id↓. /// 반환된 첫 요소가 keeper. fn rank_keeper<'a>(group: &'a [&'a FileMeta]) -> Vec { let mut sorted: Vec<&FileMeta> = group.to_vec(); @@ -144,33 +402,67 @@ fn rank_keeper<'a>(group: &'a [&'a FileMeta]) -> Vec { sorted.iter().map(|f| f.id).collect() } +/// 멤버 하나에 대해 keeper 기준으로 알아낸 것들. +#[derive(Default, Clone, Copy)] +struct MemberFacts { + /// keeper와의 거리 (이미지는 해밍, 영상은 정렬 후 절단 평균) + distance: Option, + /// `keeper 시각 - 이 멤버 시각` (ms). 비교 재생이 두 영상을 맞추는 값. + offset_ms: Option, + /// keeper 시간축에서 일치가 확인된 구간 + seg_ms: Option<(u32, u32)>, +} + /// 그룹을 dupe_groups/dupe_members에 저장한다 (keeper 자동 선정). +/// `facts`는 keeper 기준 거리·오프셋·구간 — UI의 유사도 표시와 비교 재생이 이 값을 읽는다. fn persist_group( db: &Db, kind: GroupKind, members: &[&FileMeta], - distances: &HashMap, + facts: &HashMap, ) -> Result<()> { let ranked = rank_keeper(members); let kind_val = kind as i64; - let dists: Vec<(i64, Option)> = ranked + let rows: Vec<(i64, MemberFacts)> = ranked .iter() - .map(|id| (*id, distances.get(id).map(|d| *d as i64))) + .map(|id| (*id, facts.get(id).copied().unwrap_or_default())) .collect(); + // 그룹 점수 = 유사도 0~1 (완전 동일은 1.0). 지금까지 항상 NULL이던 컬럼을 채운다. + let score = match kind { + GroupKind::Exact => 1.0f64, + _ => { + let seen: Vec = rows.iter().skip(1).filter_map(|(_, f)| f.distance).collect(); + if seen.is_empty() { + 1.0 + } else { + let mean = seen.iter().map(|d| *d as f64).sum::() / seen.len() as f64; + (1.0 - mean / 64.0).clamp(0.0, 1.0) + } + } + }; db.with_write(move |conn| { let tx = conn.transaction()?; tx.execute( - "INSERT INTO dupe_groups(kind, created_at) VALUES (?1, ?2)", - rusqlite::params![kind_val, now_ms()], + "INSERT INTO dupe_groups(kind, score, created_at) VALUES (?1, ?2, ?3)", + rusqlite::params![kind_val, score, now_ms()], )?; let gid = tx.last_insert_rowid(); { let mut stmt = tx.prepare_cached( - "INSERT INTO dupe_members(group_id, file_id, is_keeper, distance) VALUES (?1, ?2, ?3, ?4)", + "INSERT INTO dupe_members(group_id, file_id, is_keeper, distance, offset_ms, seg_start_ms, seg_end_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", )?; - for (i, (id, dist)) in dists.iter().enumerate() { - stmt.execute(rusqlite::params![gid, id, if i == 0 { 1 } else { 0 }, dist])?; + for (i, (id, f)) in rows.iter().enumerate() { + stmt.execute(rusqlite::params![ + gid, + id, + if i == 0 { 1 } else { 0 }, + f.distance.map(|d| d as i64), + f.offset_ms, + f.seg_ms.map(|(s, _)| s as i64), + f.seg_ms.map(|(_, e)| e as i64), + ])?; } } tx.commit() @@ -179,24 +471,70 @@ fn persist_group( } /// 이전 미해결 그룹을 지운다 (재탐지 시). 해결/무시(dismissed)는 보존. -fn clear_open_groups(db: &Db) -> Result<()> { - db.with_write(|conn| { - conn.execute("DELETE FROM dupe_groups WHERE status = 'open'", [])?; +/// +/// **스코프를 존중한다** — 폴더 하나만 다시 훑었는데 다른 폴더의 검토 대기 목록이 사라지면 +/// 사용자가 하던 작업이 조용히 지워진다. +fn clear_open_groups(db: &Db, scope: Scope) -> Result<()> { + db.with_write(move |conn| { + let scoped = |tail: &str| { + format!( + "DELETE FROM dupe_groups WHERE status = 'open' AND id IN ( + SELECT dm.group_id FROM dupe_members dm + JOIN files f ON f.id = dm.file_id + JOIN folders fo ON fo.id = f.folder_id + WHERE 1=1{tail})" + ) + }; + match scope { + Scope::All => { + conn.execute("DELETE FROM dupe_groups WHERE status = 'open'", [])?; + } + Scope::Source(sid) => { + conn.execute(&scoped(" AND f.source_id = ?1"), [sid])?; + } + Scope::Folder { id, recursive: false } => { + conn.execute(&scoped(" AND f.folder_id = ?1"), [id])?; + } + Scope::Folder { id, recursive: true } => { + let (src, path, prefix) = folder_prefix(conn, id)?; + conn.execute( + &scoped(" AND fo.source_id = ?1 AND (fo.path = ?2 OR fo.path LIKE ?3)"), + rusqlite::params![src, path, prefix], + )?; + } + } Ok(()) })?; Ok(()) } -fn is_dismissed(dismissed: &std::collections::HashSet<(i64, i64)>, a: i64, b: i64) -> bool { +/// 유사 패스에서 빼야 할 것들 — 둘은 항상 같이 다니므로 함께 넘긴다. +struct Excludes<'a> { + /// 사용자가 "이건 중복 아니다"라고 한 쌍 + dismissed: &'a HashSet<(i64, i64)>, + /// 이미 완전 동일 그룹으로 묶인 파일 (keeper는 제외되지 않는다) + grouped: &'a HashSet, +} + +impl Excludes<'_> { + fn dismissed_pair(&self, a: i64, b: i64) -> bool { + is_dismissed(self.dismissed, a, b) + } + fn already_grouped(&self, id: i64) -> bool { + self.grouped.contains(&id) + } +} + +fn is_dismissed(dismissed: &HashSet<(i64, i64)>, a: i64, b: i64) -> bool { let key = if a < b { (a, b) } else { (b, a) }; dismissed.contains(&key) } -fn load_dismissed(db: &Db) -> Result> { +fn load_dismissed(db: &Db) -> Result> { Ok(db.with_read(|conn| { let mut stmt = conn.prepare("SELECT file_a, file_b FROM dupe_dismissed")?; let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?; - rows.collect::>>() + rows.collect::>>() })?) } @@ -204,18 +542,18 @@ fn load_dismissed(db: &Db) -> Result> { /// 그룹으로 묶인 파일 id는 `grouped`에 누적된다 (유사 패스에서 제외용). fn detect_exact( db: &Db, - files: &[FileMeta], - grouped: &mut std::collections::HashSet, + files: &[&FileMeta], + dismissed: &HashSet<(i64, i64)>, + grouped: &mut HashSet, cancel: &AtomicBool, mut progress: impl FnMut(u64, u64), ) -> Result { // 1. size로 그룹핑 let mut by_size: HashMap> = HashMap::new(); - for f in files { + for &f in files { by_size.entry(f.size).or_default().push(f); } - let candidates: Vec> = - by_size.into_values().filter(|g| g.len() >= 2).collect(); + let candidates: Vec> = by_size.into_values().filter(|g| g.len() >= 2).collect(); let total = candidates.iter().map(|g| g.len()).sum::() as u64; let mut processed = 0u64; @@ -225,11 +563,13 @@ fn detect_exact( if cancel.load(Ordering::Relaxed) { return Err(DedupeError::Cancelled); } - // 2. quick_hash로 세분화 - let with_qh: Vec<(u64, &FileMeta)> = size_group - .par_iter() - .filter_map(|f| hash::quick_hash(&f.path).ok().map(|h| (h, *f))) - .collect(); + // 2. quick_hash로 세분화 — 저우선순위 풀에서 (전역 rayon 풀을 쓰면 UI가 버벅인다) + let with_qh: Vec<(u64, &FileMeta)> = crate::prio::bg_pool().install(|| { + size_group + .par_iter() + .filter_map(|f| hash::quick_hash(&f.path).ok().map(|h| (h, *f))) + .collect() + }); processed += size_group.len() as u64; progress(processed, total); @@ -239,23 +579,44 @@ fn detect_exact( } for qh_group in by_qh.into_values().filter(|g| g.len() >= 2) { + if cancel.load(Ordering::Relaxed) { + return Err(DedupeError::Cancelled); + } // 3. BLAKE3 전체 해시로 확정 - let with_fh: Vec<([u8; 32], &FileMeta)> = qh_group - .par_iter() - .filter_map(|f| hash::full_hash(&f.path).ok().map(|h| (h, *f))) - .collect(); + let with_fh: Vec<([u8; 32], &FileMeta)> = crate::prio::bg_pool().install(|| { + qh_group + .par_iter() + .filter_map(|f| hash::full_hash(&f.path).ok().map(|h| (h, *f))) + .collect() + }); let mut by_fh: HashMap<[u8; 32], Vec<&FileMeta>> = HashMap::new(); for (fh, f) in with_fh { by_fh.entry(fh).or_default().push(f); } for exact in by_fh.into_values().filter(|g| g.len() >= 2) { - // keeper(첫 요소)는 유사 패스에 남겨 교차 유사도를 잡되, - // 나머지 완전 동일본은 제외해 중복 표시를 막는다. + // 사용자가 "이건 중복 아니다"라고 한 쌍은 존중한다 — keeper와 dismissed된 + // 파일은 그룹에서 뺀다(바이트가 같아도 사용자 판단이 우선). let ranked = rank_keeper(&exact); - for id in ranked.iter().skip(1) { - grouped.insert(*id); + let keeper = ranked[0]; + let kept: Vec<&FileMeta> = exact + .iter() + .copied() + .filter(|f| f.id == keeper || !is_dismissed(dismissed, keeper, f.id)) + .collect(); + if kept.len() < 2 { + continue; } - persist_group(db, GroupKind::Exact, &exact, &HashMap::new())?; + // keeper는 유사 패스에 남겨 교차 유사도를 잡되, 나머지 완전 동일본은 제외해 + // 같은 파일이 두 그룹에 나오는 것을 막는다. + for f in kept.iter().filter(|f| f.id != keeper) { + grouped.insert(f.id); + } + // 거리 0 · 오프셋 0 — 바이트가 같으니 비교 재생도 그대로 겹친다 + let facts: HashMap = kept + .iter() + .map(|f| (f.id, MemberFacts { distance: Some(0), offset_ms: Some(0), seg_ms: None })) + .collect(); + persist_group(db, GroupKind::Exact, &kept, &facts)?; group_count += 1; } } @@ -263,150 +624,505 @@ fn detect_exact( Ok(group_count) } -/// 유사 이미지 탐지: dHash → 브루트포스 → 클러스터. +/// 유사 이미지 탐지 — 저장된 dHash 브루트포스 → 클러스터. /// `exclude`에 속한 파일(이미 완전 동일 그룹으로 처리됨)은 제외한다. fn detect_similar_images( db: &Db, files: &[FileMeta], + index: &[(i64, u64, [u64; 3])], threshold: u32, - dismissed: &std::collections::HashSet<(i64, i64)>, - exclude: &std::collections::HashSet, + allow_orient: bool, + // orients: 8변형 해시 — allow_orient가 켜졌을 때만 채워져 온다 + orients: &[(i64, [u64; 8])], + ex: &Excludes<'_>, cancel: &AtomicBool, mut progress: impl FnMut(u64, u64), ) -> Result { - let total = files.len() as u64; - // dHash 병렬 계산 (썸네일 우선 — thumbs 캐시가 있으면 그것으로, 없으면 원본) - let hashes: Vec<(i64, u64)> = files - .par_iter() - .filter(|f| !exclude.contains(&f.id)) - .filter_map(|f| phash::dhash_file(&f.path).map(|h| (f.id, h))) + let usable: Vec<(i64, u64)> = index + .iter() + .filter(|(id, _, _)| !ex.already_grouped(*id)) + .map(|(id, dhash, _)| (*id, *dhash)) .collect(); + let total = usable.len() as u64; + progress(0, total); + if cancel.load(Ordering::Relaxed) { + return Err(DedupeError::Cancelled); + } + + // 규모에 따라 두 경로를 쓴다: + // - 작은 라이브러리: 상삼각 브루트포스. 임계값과 무관하게 정확하고 10만 개면 1~2초다. + // - 큰 라이브러리: 밴드 색인. 100만 개 브루트포스는 5×10¹¹ 비교라 분 단위가 되고 + // 페어를 Vec으로 모으면 메모리도 터진다. 어느 쪽이든 **페어를 모으지 않고** + // union-find로 바로 병합해 상주 메모리를 O(n)으로 유지한다. + let ids: Vec = usable.iter().map(|(id, _)| *id).collect(); + let mut clusters = phash::Clusters::new(&ids); + let banded = usable.len() > BRUTE_MAX; + // 후보를 모은다. dHash가 가까운 쌍은 대부분 진짜 중복이지만, 그대로 묶으면 + // '가림이 커서 남은 정보가 비슷해진 서로 다른 사진'까지 들어온다(실측된 오탐). + // 그래서 후보에 대해서만 풍부한 지문을 읽어 다시 본다. + let mut candidates: Vec<(i64, i64)> = Vec::new(); + let skipped = crate::prio::bg_pool().install(|| { + if banded { + phash::for_each_near_pair_banded(&usable, threshold, |a, b, _| { + if !ex.dismissed_pair(a, b) { + candidates.push((a, b)); + } + }) + } else { + for (a, b, _) in phash::find_near_pairs(&usable, threshold) { + if !ex.dismissed_pair(a, b) { + candidates.push((a, b)); + } + } + 0 + } + }); + // 크롭·큰 가림 사본은 전체 dHash가 임계를 넘어 위 색인으로는 후보가 되지 않는다 + // (실측: 하단 30% 가림본이 자기 원본과 거리 7). 중앙 크롭 해시를 식별 색인에 질의해 + // 후보를 보탠다 — 파일당 3개라 비용은 1.5배 수준이다. + let probes: Vec<(i64, Vec)> = index + .iter() + .map(|(id, dhash, scales)| { + let mut list: Vec = scales.iter().copied().filter(|s| s != dhash).collect(); + list.sort_unstable(); + list.dedup(); + (*id, list) + }) + .filter(|(_, l)| !l.is_empty()) + .collect(); + // 질의도 검증과 같은 엄격한 임계로 — 느슨하면 쓸모없는 후보만 늘어난다. + // 임계가 2면 밴드 4개(거리 ≤3 정확)라서 이 패스들은 사실상 무료다. + let crop_max = threshold.min(sigmatch::CROP_MAX_DIST); + let add = |a: i64, b: i64, out: &mut Vec<(i64, i64)>| { + if !ex.dismissed_pair(a, b) { + out.push(if a < b { (a, b) } else { (b, a) }); + } + }; + if !probes.is_empty() { + // ① 크롭 해시 ↔ 다른 파일의 전체 해시 — "B는 A를 잘라낸 것" + crate::prio::bg_pool().install(|| { + phash::for_each_probe_pair(&usable, &probes, crop_max, |a, b, _| { + add(a, b, &mut candidates) + }) + }); + // ② 같은 배율끼리 — "둘 다 비슷하게 잘렸거나 가려졌다". + // 실측: 하단 30% 가림본이 자기 원본과 이 경로로만 만난다(중앙 70%끼리 거리 1). + for k in 0..3usize { + let level: Vec<(i64, u64)> = index + .iter() + .filter(|(id, _, _)| !ex.already_grouped(*id)) + .map(|(id, _, scales)| (*id, scales[k])) + .collect(); + crate::prio::bg_pool().install(|| { + phash::for_each_near_pair_banded(&level, crop_max, |a, b, _| { + add(a, b, &mut candidates) + }) + }); + } + } + // 회전·반전본은 dHash가 완전히 달라 어떤 크롭 해시로도 안 잡힌다. + // 켜져 있으면 8변형(항등 제외)을 질의한다(비용 약 2배 더). + if allow_orient && !orients.is_empty() { + let rot: Vec<(i64, Vec)> = orients + .iter() + .map(|(id, o)| (*id, o.iter().skip(1).copied().collect())) + .collect(); + crate::prio::bg_pool().install(|| { + phash::for_each_probe_pair(&usable, &rot, threshold, |a, b, _| { + if !ex.dismissed_pair(a, b) { + candidates.push(if a < b { (a, b) } else { (b, a) }); + } + }) + }); + } + // 같은 쌍이 여러 밴드에서 올 수 있다 — 검증을 중복으로 하지 않는다 + candidates.sort_unstable(); + candidates.dedup(); + if cancel.load(Ordering::Relaxed) { + return Err(DedupeError::Cancelled); + } + + // 검증 — 후보 파일의 풍부한 지문을 캐시하며 읽는다(쌍마다 두 번 읽지 않게) + let mut rich: HashMap> = HashMap::new(); + let mut verdicts: HashMap<(i64, i64), sigmatch::Verdict> = HashMap::new(); + let mut rejected = 0u64; + for (n, (a, b)) in candidates.iter().enumerate() { + if n % 512 == 0 && cancel.load(Ordering::Relaxed) { + return Err(DedupeError::Cancelled); + } + for id in [*a, *b] { + rich.entry(id).or_insert_with(|| load_rich(db, id)); + } + let (Some(ra), Some(rb)) = ( + rich.get(a).and_then(|x| x.as_ref()), + rich.get(b).and_then(|x| x.as_ref()), + ) else { + // 풍부한 지문이 없다(구버전 지문) — dHash 판정을 그대로 믿는다 + clusters.union(*a, *b); + continue; + }; + match sigmatch::verify_pair(ra, rb, threshold, allow_orient) { + Some(v) => { + verdicts.insert((*a, *b), v); + clusters.union(*a, *b); + } + None => rejected += 1, + } + } + tracing::info!( + candidates = candidates.len(), + rejected, + "유사 이미지: 후보 검증(타일·크롭·색)" + ); + if banded { + // 조용히 자르지 않는다 — 무엇을 덜 봤는지 남긴다 + tracing::info!( + count = usable.len(), + bands = phash::band_count(threshold), + exact = phash::banding_is_exact(threshold), + skipped_comparisons = skipped, + "유사 이미지: 밴드 색인 경로" + ); + } progress(total, total); if cancel.load(Ordering::Relaxed) { return Err(DedupeError::Cancelled); } - let pairs: Vec<(i64, i64, u32)> = phash::find_near_pairs(&hashes, threshold) - .into_iter() - .filter(|(a, b, _)| !is_dismissed(dismissed, *a, *b)) - .collect(); + let groups = clusters.groups(); - let ids: Vec = hashes.iter().map(|(id, _)| *id).collect(); - let groups = phash::cluster(&ids, &pairs); - - // 거리 맵 (keeper 대비 근사 — 그룹 내 최소 거리) let by_id: HashMap = files.iter().map(|f| (f.id, f)).collect(); + let dhash_of: HashMap = usable.iter().copied().collect(); let mut group_count = 0u64; for group in groups { let members: Vec<&FileMeta> = group.iter().filter_map(|id| by_id.get(id).copied()).collect(); if members.len() < 2 { continue; } - persist_group(db, GroupKind::Image, &members, &HashMap::new())?; + // keeper 기준 거리. 검증에서 나온 판정 거리를 우선 쓴다 — 타일·크롭으로 맞은 쌍은 + // 전체 dHash 거리가 크게 나오므로 그걸 보여주면 "왜 묶였는지"와 어긋난다. + // 정지 이미지는 시간축이 없으므로 오프셋 0 (비교 뷰가 그대로 겹쳐 보여준다). + let keeper = rank_keeper(&members)[0]; + let kh = dhash_of.get(&keeper).copied().unwrap_or(0); + let facts: HashMap = members + .iter() + .filter_map(|f| { + let key = if keeper < f.id { (keeper, f.id) } else { (f.id, keeper) }; + let d = verdicts + .get(&key) + .map(|v| v.distance) + .or_else(|| dhash_of.get(&f.id).map(|h| phash::hamming(kh, *h)))?; + Some((f.id, MemberFacts { distance: Some(d), offset_ms: Some(0), seg_ms: None })) + }) + .collect(); + persist_group(db, GroupKind::Image, &members, &facts)?; group_count += 1; } Ok(group_count) } -/// 영상 프레임 시그니처: 5/15/…/95% 10프레임 dHash. -fn video_signature(tools: &FfTools, path: &Path) -> Option> { - let offsets = [0.05, 0.15, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85, 0.95]; - let dir = std::env::temp_dir().join(format!("archive-vsig-{}", std::process::id())); - std::fs::create_dir_all(&dir).ok()?; - let info = crate::ffmpeg::probe(tools, path).ok()?; - let dur = info.duration_ms? as f64 / 1000.0; - if dur <= 0.0 { - return None; - } - let mut sig = Vec::with_capacity(offsets.len()); - for (i, off) in offsets.iter().enumerate() { - let frame = dir.join(format!("f{i}.jpg")); - if crate::ffmpeg::extract_frame_jpeg(tools, path, &frame, dur * off, 64).is_ok() { - if let Some(h) = phash::dhash_file(&frame) { - sig.push(h); +/// 앵커 역색인으로 후보 쌍을 만든다. 반환: (i, j, 공통 앵커 수) — i < j (sigs 인덱스). +/// +/// **한계**: 토큰이 해시 값 그대로라 후보가 되려면 프레임 해시가 비트까지 같아야 한다. +/// 재인코딩·컨테이너 변경·fps 변경은 dHash가 그대로라 잡히지만, 화면을 다시 표본화하는 +/// 변형(레터박스·리스케일·크롭)은 값이 밀려서 못 만난다. 실측(`tests/letterbox_evidence.rs`): +/// 60% 축소+검정 패딩 사본은 검정 띠를 정확히 잘라내도 원본과 거리 7~16(중앙값 9.5), 공유 토큰 0개. +/// 밴드 LSH로 내리려면 거리 9를 보장하는 데 10밴드(6비트)가 필요해 버킷이 64개뿐 — +/// 무관한 영상이 전부 한 버킷에 몰려 후보가 폭발한다. 값 기반 토큰으로는 풀리지 않는다. +/// → 그 축은 `audio_candidates`가 맡는다(오디오는 화면 변형에 영향받지 않는다). +fn video_candidates(sigs: &[&VideoSig]) -> Vec<(usize, usize, u32)> { + candidates_from_tokens(sigs.len(), |i| sigs[i].anchors.iter().map(|(_, h)| *h)) +} + +/// 오디오 지문 앵커로 후보 쌍을 만든다 — 프레임이 원리상 못 잡는 축을 메운다. +/// +/// 레터박스·리스케일은 화면을 다시 표본화하지만 오디오는 그대로다(실측: 레터박스 사본이 +/// 221/221 일치). 대신 **배경음악만 같은 남남**도 똑같이 붙으므로(실측: 그것도 221/221) +/// 여기서 나온 후보는 반드시 프레임으로 확인해야 한다 — 그 판정은 `confirm_pair`가 한다. +fn audio_candidates(sigs: &[&VideoSig]) -> Vec<(usize, usize, u32)> { + candidates_from_tokens(sigs.len(), |i| sigs[i].audio.iter().map(|(_, v)| *v as u64)) +} + +/// 값 토큰 역색인 → 공통 토큰이 충분한 쌍. 프레임·오디오가 같은 규칙을 쓴다. +fn candidates_from_tokens>( + len: usize, + tokens: impl Fn(usize) -> I, +) -> Vec<(usize, usize, u32)> { + let mut posting: HashMap> = HashMap::new(); + for i in 0..len { + let mut own: HashSet = HashSet::new(); + for h in tokens(i) { + // 같은 영상이 같은 토큰을 여러 번 내도 포스팅은 1회 (정지 화면·무음 구간 방어) + if own.insert(h) { + posting.entry(h).or_default().push(i); } - let _ = std::fs::remove_file(&frame); } } - let _ = std::fs::remove_dir_all(&dir); - if sig.len() >= 5 { - Some(sig) - } else { - None + let n = len as u32; + let mut shared: HashMap<(usize, usize), u32> = HashMap::new(); + for list in posting.values() { + // 흔한 토큰은 아예 버린다 — IDF가 0에 가까운데 조합 수는 df²로 늘어난다 + if list.len() < 2 || list.len() > MAX_DF || sigmatch::idf(list.len() as u32, n) <= 0.0 { + continue; + } + for a in 0..list.len() { + for b in (a + 1)..list.len() { + *shared.entry((list[a], list[b])).or_insert(0) += 1; + } + } } + let mut out: Vec<(usize, usize, u32)> = shared + .into_iter() + .filter(|(_, c)| *c >= MIN_SHARED_ANCHORS) + .map(|((a, b), c)| (a, b, c)) + .collect(); + // 조밀 시퀀스 캐시 적중률을 위해 a 기준으로 모아 처리한다 + out.sort_unstable(); + out } -/// 두 시그니처의 프레임별 해밍 중앙값. -fn signature_distance(a: &[u64], b: &[u64]) -> u32 { - let n = a.len().min(b.len()); - if n == 0 { - return u32::MAX; +/// 오프셋으로 정렬한 뒤 겹치는 프레임쌍의 **절단 평균**(상하 25% 절단) 해밍. +/// 중앙값보다 낫다 — 광고 프레임 몇 장이 섞여도 견디고, 절반이 다르면 확실히 벌어진다. +fn aligned_distance(a: &[(u32, u64)], b: &[(u32, u64)], offset_ms: i64, tol_ms: i64) -> Option { + let mut dists: Vec = Vec::new(); + let mut j = 0usize; + for &(ta, ha) in a { + // b의 대응 시각 = ta - offset + let target = ta as i64 - offset_ms; + while j + 1 < b.len() && (b[j].0 as i64) < target - tol_ms { + j += 1; + } + let mut k = j; + while k < b.len() && (b[k].0 as i64) <= target + tol_ms { + if ((b[k].0 as i64) - target).abs() <= tol_ms { + dists.push(sigmatch::hamming(ha, b[k].1)); + break; + } + k += 1; + } + } + if dists.len() < 3 { + return None; } - let mut dists: Vec = (0..n).map(|i| phash::hamming(a[i], b[i])).collect(); dists.sort_unstable(); - dists[n / 2] + let cut = dists.len() / 4; + let mid = &dists[cut..dists.len() - cut]; + if mid.is_empty() { + return None; + } + Some(mid.iter().copied().sum::() / mid.len() as u32) } +/// 후보 한 쌍을 조밀 시퀀스로 확정한다. 반환: (일치 구간, keeper 기준 거리, 배속). +/// +/// 배속은 시간축을 늘리므로 절대 시각 투표가 무너진다 — 후보 배속들로 b의 시간축을 되돌려 +/// 가장 많은 표를 받은 해석을 고른다(1.0이 이기면 평범한 동일 속도 사본이다). +/// `via`가 `Audio`면 임계값을 넓히고 **레터박스 트림 축**까지 시도한다. 오디오 후보는 +/// 프레임이 원리상 못 잡는 변형(검정 띠·리스케일)이라 전체 축만 보면 확인이 불가능하다. +fn confirm_pair(a: &Dense, b: &Dense, via: Via) -> Option<(SegMatch, u32, f32)> { + let max_dist = match via { + Via::Frames => VOTE_MAX_DIST, + Via::Audio => AUDIO_VOTE_MAX_DIST, + }; + // 전체↔전체는 항상. 오디오 후보는 트림 축 조합도 본다 — 레터박스 사본은 전체 축에서 + // 20~36까지 벌어지지만 트림 축에서는 9다(배경음만 같은 남남은 29라 여기서 걸러진다). + // 트림↔트림도 넣는다: 같은 원본을 각각 4:3·16:9로 맞춘 두 사본은 양쪽 다 띠가 있다. + let combos: &[(&[(u32, u64)], &[(u32, u64)])] = match via { + Via::Frames => &[(&a.whole, &b.whole)], + Via::Audio => { + &[(&a.whole, &b.whole), (&a.lb, &b.whole), (&a.whole, &b.lb), (&a.lb, &b.lb)] + } + }; + let mut best: Option<(SegMatch, u32, f32)> = None; + for (x, y) in combos.iter() { + // 정지 화면·VFR 중복 프레임이 표를 몰아주지 않게 접는다 + let sx = sigmatch::dedup_consecutive(x); + let sy = sigmatch::dedup_consecutive(y); + let Some((seg, speed)) = + sigmatch::offset_vote_speed(&sx, &sy, max_dist, VOTE_BUCKET_MS, VOTE_MIN_SUPPORT) + else { + continue; + }; + if seg.a_span_ms() < MIN_SEGMENT_MS { + continue; + } + // 거리도 이 조합·같은 배속으로 되돌린 시간축에서 재야 짝이 맞는다 + let scaled: Vec<(u32, u64)> = + y.iter().map(|(t, h)| (((*t as f32) * speed) as u32, *h)).collect(); + let Some(dist) = aligned_distance(x, &scaled, seg.offset_ms, VOTE_BUCKET_MS) else { + continue; + }; + // 표가 많은 쪽, 표가 같으면 **가까운 쪽**. 레터박스 사본은 전체 축과 트림 축이 + // 둘 다 임계값 안에 들어 표가 같아지는데, 그때 보고하는 거리가 근거로 쓰인다 + // (등급 배지·비교 뷰) — 나쁜 쪽을 고르면 왜 묶였는지와 어긋난다. + let better = best + .as_ref() + .map(|(m, d, _)| (seg.support, *d) > (m.support, dist)) + .unwrap_or(true); + if better { + best = Some((seg, dist, speed)); + } + } + best +} + +/// 이 후보가 어디서 나왔는가 — 확인 강도를 다르게 준다. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Via { + Frames, + Audio, +} + +/// 한 영상의 조밀 시퀀스 두 축 (전체 해시 / 레터박스 트림 해시). +struct Dense { + whole: Vec<(u32, u64)>, + lb: Vec<(u32, u64)>, +} + +/// 유사 영상 탐지 — 앵커 역색인으로 후보, 조밀 시퀀스 오프셋 투표로 확정. +/// +/// `duration` ±5% 프리필터는 없앴다. 그게 "긴 영상의 일부"와 "광고 삽입본"을 원천 차단하던 +/// 원인이다. 대신 일치 **구간 길이**와 지지 프레임 수로 판정한다. fn detect_similar_videos( db: &Db, files: &[FileMeta], - tools: &FfTools, - threshold: u32, - dismissed: &std::collections::HashSet<(i64, i64)>, + sigs: &[VideoSig], + ex: &Excludes<'_>, cancel: &AtomicBool, mut progress: impl FnMut(u64, u64), ) -> Result { - // duration 있는 영상만 - let with_dur: Vec<&FileMeta> = files.iter().filter(|f| f.duration_ms.unwrap_or(0) > 0).collect(); - let total = with_dur.len() as u64; - - // 시그니처 계산 (동시 2 — 각 ffmpeg가 ~1코어) - let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().ok(); - let sigs: Vec<(i64, i64, Vec)> = match &pool { - Some(p) => p.install(|| { - with_dur - .par_iter() - .filter_map(|f| video_signature(tools, &f.path).map(|s| (f.id, f.duration_ms.unwrap_or(0), s))) - .collect() - }), - None => with_dur - .iter() - .filter_map(|f| video_signature(tools, &f.path).map(|s| (f.id, f.duration_ms.unwrap_or(0), s))) - .collect(), - }; - progress(total, total); - if cancel.load(Ordering::Relaxed) { - return Err(DedupeError::Cancelled); + let usable: Vec<&VideoSig> = sigs + .iter() + .filter(|s| !ex.already_grouped(s.id) && !s.anchors.is_empty()) + .collect(); + // 프레임 후보가 먼저다 — 오디오 후보는 프레임이 원리상 못 잡는 것만 남기고, + // 확인도 더 넓은 임계값으로 하므로 이미 확정된 쌍을 다시 볼 이유가 없다. + let frame_cands = video_candidates(&usable); + let seen: HashSet<(usize, usize)> = frame_cands.iter().map(|(i, j, _)| (*i, *j)).collect(); + let mut cands: Vec<(usize, usize, u32, Via)> = + frame_cands.into_iter().map(|(i, j, c)| (i, j, c, Via::Frames)).collect(); + let raw_audio = audio_candidates(&usable); + let audio_only: Vec<(usize, usize, u32, Via)> = raw_audio + .iter() + .filter(|(i, j, _)| !seen.contains(&(*i, *j))) + .map(|(i, j, c)| (*i, *j, *c, Via::Audio)) + .collect(); + if !audio_only.is_empty() { + tracing::debug!( + total = raw_audio.len(), + new = audio_only.len(), + "오디오로 만난 후보 (프레임이 못 만든 쌍)" + ); } + cands.extend(audio_only); + // 조밀 시퀀스 캐시 적중률을 위해 a 기준으로 모아 처리한다 + cands.sort_unstable_by_key(|(i, j, _, _)| (*i, *j)); + let total = cands.len() as u64; + progress(0, total); - // duration ±5% 사전필터 후 시그니처 비교 - let mut pairs: Vec<(i64, i64, u32)> = Vec::new(); - for i in 0..sigs.len() { - for j in (i + 1)..sigs.len() { - let (ida, da, ref sa) = sigs[i]; - let (idb, db_dur, ref sb) = sigs[j]; - let ratio = da as f64 / db_dur.max(1) as f64; - if !(0.95..=1.05).contains(&ratio) { - continue; - } - let dist = signature_distance(sa, sb); - if dist <= threshold && !is_dismissed(dismissed, ida, idb) { - let (lo, hi) = if ida < idb { (ida, idb) } else { (idb, ida) }; - pairs.push((lo, hi, dist)); + // 조밀 시퀀스 캐시 — 후보가 a 기준으로 모여 있어 적중률이 높다 + let mut cache: HashMap> = HashMap::new(); + let mut fifo: Vec = Vec::new(); + let mut dense = |id: i64| -> Arc { + if let Some(v) = cache.get(&id) { + return v.clone(); + } + let v = Arc::new(load_dense(db, id)); + if fifo.len() >= DENSE_CACHE { + if let Some(old) = fifo.first().copied() { + fifo.remove(0); + cache.remove(&old); } } - } + fifo.push(id); + cache.insert(id, v.clone()); + v + }; - let ids: Vec = sigs.iter().map(|(id, _, _)| *id).collect(); + let mut pairs: Vec<(i64, i64, u32)> = Vec::new(); + let mut segs: HashMap<(i64, i64), SegMatch> = HashMap::new(); + for (n, (i, j, _shared, via)) in cands.iter().enumerate() { + if n % 32 == 0 { + if cancel.load(Ordering::Relaxed) { + return Err(DedupeError::Cancelled); + } + progress(n as u64, total); + } + let (ida, idb) = (usable[*i].id, usable[*j].id); + if ex.dismissed_pair(ida, idb) { + continue; + } + let da = dense(ida); + let db_seq = dense(idb); + let Some((seg, dist, speed)) = confirm_pair(&da, &db_seq, *via) else { continue }; + // 짧은 쪽을 얼마나 덮었는지로 판정한다 (길이가 비슷한지는 보지 않는다). + // 배속본은 자기 시간축이 짧으므로 그 길이도 같은 배율로 되돌려 비교한다 — + // 안 그러면 1.25배속본이 '덜 덮었다'고 잘못 판정된다. + let scaled_b = ((usable[*j].duration_ms as f32) * speed) as i64; + let shorter = usable[*i].duration_ms.min(scaled_b).max(1); + if (seg.a_span_ms() as f64) / (shorter as f64) < MIN_COVERAGE { + continue; + } + if (speed - 1.0).abs() > 0.01 { + tracing::debug!(a = ida, b = idb, speed, "배속 사본으로 판정"); + } + let (lo, hi) = if ida < idb { (ida, idb) } else { (idb, ida) }; + pairs.push((lo, hi, dist)); + // **투표에 넣은 순서 그대로** 저장한다 — offset_ms는 (a 시각 − b 시각)이라 + // 방향을 잃으면 비교 재생이 반대로 어긋난다. 조회할 때 양쪽을 다 본다. + segs.insert((ida, idb), seg); + } + progress(total, total); + + let ids: Vec = usable.iter().map(|s| s.id).collect(); let groups = phash::cluster(&ids, &pairs); let by_id: HashMap = files.iter().map(|f| (f.id, f)).collect(); + // 쌍 거리 조회용 + let pair_dist: HashMap<(i64, i64), u32> = pairs.iter().map(|(a, b, d)| ((*a, *b), *d)).collect(); + let mut group_count = 0u64; for group in groups { let members: Vec<&FileMeta> = group.iter().filter_map(|id| by_id.get(id).copied()).collect(); - if members.len() >= 2 { - persist_group(db, GroupKind::Video, &members, &HashMap::new())?; - group_count += 1; + if members.len() < 2 { + continue; } + let keeper = rank_keeper(&members)[0]; + let mut facts: HashMap = HashMap::new(); + facts.insert(keeper, MemberFacts { distance: Some(0), offset_ms: Some(0), seg_ms: None }); + for f in members.iter().filter(|f| f.id != keeper) { + let key = if keeper < f.id { (keeper, f.id) } else { (f.id, keeper) }; + // keeper와 직접 확정된 쌍이 없으면(전이 병합) 이 멤버의 최소 거리로 대체한다 + let distance = pair_dist.get(&key).copied().or_else(|| { + pairs + .iter() + .filter(|(a, b, _)| *a == f.id || *b == f.id) + .map(|(_, _, d)| *d) + .min() + }); + // keeper 시간축 기준으로 방향을 맞춘다. (keeper, member)로 투표했으면 그대로, + // (member, keeper)로 투표했으면 부호를 뒤집고 구간도 반대쪽 범위를 쓴다. + let (offset_ms, seg_ms) = match (segs.get(&(keeper, f.id)), segs.get(&(f.id, keeper))) { + (Some(s), _) => (Some(s.offset_ms), Some((s.a_start_ms, s.a_end_ms))), + (None, Some(s)) => (Some(-s.offset_ms), Some((s.b_start_ms, s.b_end_ms))), + // 전이 병합으로 들어온 멤버 — 직접 확인한 정렬이 없다. 0으로 두면 + // 비교 재생이 '맞춰졌다'고 거짓말하므로 아예 비워 둔다(UI가 안내한다). + (None, None) => (None, None), + }; + facts.insert(f.id, MemberFacts { distance, offset_ms, seg_ms }); + } + persist_group(db, GroupKind::Video, &members, &facts)?; + group_count += 1; + } + if let Some((&(a, b), seg)) = segs.iter().next() { + tracing::debug!( + confirmed = segs.len(), + file_a = a, + file_b = b, + offset_ms = seg.offset_ms, + support = seg.support, + span_ms = seg.a_span_ms(), + "영상 일치 구간(예시)" + ); } Ok(group_count) } @@ -414,39 +1130,59 @@ fn detect_similar_videos( /// 전체 중복 탐지 실행. 블로킹 — 전용 스레드에서 호출할 것. pub fn run( db: &Arc, - tools: Option<&FfTools>, scope: Scope, image_threshold: u32, include_video: bool, + // include_rotated: 회전·반전본까지 찾는다. 후보 생성이 몇 배 비싸므로 기본은 꺼둔다. + include_rotated: bool, cancel: &AtomicBool, mut on_progress: impl FnMut(DedupeProgress), ) -> Result { - clear_open_groups(db)?; + clear_open_groups(db, scope)?; let dismissed = load_dismissed(db)?; let mut stats = DedupeStats::default(); - let mut grouped: std::collections::HashSet = std::collections::HashSet::new(); + let mut grouped: HashSet = HashSet::new(); - // 완전 동일 (이미지+영상 모두) - let mut all_files = load_files(db, 0, scope)?; - all_files.extend(load_files(db, 1, scope)?); - stats.exact_groups = detect_exact(db, &all_files, &mut grouped, cancel, |c, t| { + let images = load_files(db, 0, scope)?; + let videos = load_files(db, 1, scope)?; + + // 완전 동일 (이미지+영상 모두) — 여기만 원본을 읽는다. + // 참조로 엮는다: 같은 목록을 두 번 질의하지 않고 복제도 하지 않는다. + let all_files: Vec<&FileMeta> = images.iter().chain(videos.iter()).collect(); + stats.exact_groups = detect_exact(db, &all_files, &dismissed, &mut grouped, cancel, |c, t| { on_progress(DedupeProgress { phase: "exact".into(), current: c, total: t }); })?; - // 유사 이미지 (완전 동일 그룹에 속한 파일은 제외) - let images = load_files(db, 0, scope)?; - stats.image_groups = detect_similar_images(db, &images, image_threshold, &dismissed, &grouped, cancel, |c, t| { - on_progress(DedupeProgress { phase: "image".into(), current: c, total: t }); - })?; + let ex = Excludes { dismissed: &dismissed, grouped: &grouped }; - // 유사 영상 (opt-in + ffmpeg 필요) + // 유사 이미지 — 저장된 지문만 쓴다 + let img_index = load_image_index(db, scope)?; + stats.skipped_no_sig += (images.len() as u64).saturating_sub(img_index.len() as u64); + // 회전·반전을 켰을 때만 8변형을 읽는다 (파일당 64B — 켜지 않았으면 0) + let orients = if include_rotated { + load_image_orients(db, scope)? + } else { + Vec::new() + }; + stats.image_groups = detect_similar_images( + db, + &images, + &img_index, + image_threshold, + include_rotated, + &orients, + &ex, + cancel, + |c, t| on_progress(DedupeProgress { phase: "image".into(), current: c, total: t }), + )?; + + // 유사 영상 (opt-in) — 지문이 없으면 조용히 빠지지 않고 개수로 보고된다 if include_video { - if let Some(tools) = tools { - let videos = load_files(db, 1, scope)?; - stats.video_groups = detect_similar_videos(db, &videos, tools, 8, &dismissed, cancel, |c, t| { - on_progress(DedupeProgress { phase: "video".into(), current: c, total: t }); - })?; - } + let vsigs = load_video_sigs(db, scope)?; + stats.skipped_no_sig += (videos.len() as u64).saturating_sub(vsigs.len() as u64); + stats.video_groups = detect_similar_videos(db, &videos, &vsigs, &ex, cancel, |c, t| { + on_progress(DedupeProgress { phase: "video".into(), current: c, total: t }) + })?; } stats.total_dupes = db.with_read(|conn| { @@ -460,10 +1196,12 @@ pub fn run( #[cfg(test)] mod tests { use super::*; + use std::time::Duration; - fn setup() -> (tempfile::TempDir, tempfile::TempDir, Arc, i64) { + fn setup() -> (tempfile::TempDir, tempfile::TempDir, tempfile::TempDir, Arc, i64) { let dbdir = tempfile::tempdir().unwrap(); let media = tempfile::tempdir().unwrap(); + let thumbs = tempfile::tempdir().unwrap(); let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap()); let root = media.path().to_string_lossy().into_owned(); let sid = db @@ -472,12 +1210,54 @@ mod tests { Ok(conn.last_insert_rowid()) }) .unwrap(); - (dbdir, media, db, sid) + (dbdir, media, thumbs, db, sid) + } + + /// 실제 순서대로 지문을 채운다: 스캔 → 메타 → 썸네일 → 이미지 지문. + /// (이미지 지문은 썸네일에서 뽑으므로 이 의존 순서 자체가 검증 대상이다) + fn build_sigs(db: &Arc, sid: i64, media: &Path, thumb_root: &Path) { + let cancel = AtomicBool::new(false); + let pause = AtomicBool::new(false); + crate::pipeline::scan_source(db, sid, media, &cancel, |_| {}).unwrap(); + crate::meta::extract_image_meta(db, sid, &cancel).unwrap(); + + let want: i64 = db + .with_read(|c| { + c.query_row( + "SELECT count(*) FROM files WHERE kind=0 AND deleted_at IS NULL", + [], + |r| r.get(0), + ) + }) + .unwrap(); + // 완료를 이벤트로 기다린다 — 폴링+짧은 마감은 워커가 저우선순위라 부하에서 흔들린다. + // (마감은 '진전이 없다'를 감지하는 안전장치일 뿐이다) + let (tx, rx) = crossbeam_channel::unbounded::(); + let done = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let d2 = done.clone(); + let q = crate::thumbq::ThumbQueue::start( + db.clone(), + thumb_root.to_path_buf(), + None, + 2, + move |ids| { + let n = d2.fetch_add(ids.len(), Ordering::SeqCst) + ids.len(); + let _ = tx.send(n); + }, + ); + q.enqueue_pending(Some(sid)); + while (done.load(Ordering::SeqCst) as i64) < want { + if rx.recv_timeout(Duration::from_secs(60)).is_err() { + break; + } + } + let n = crate::sigq::run_image_sigs(db, thumb_root, &cancel, &pause).unwrap(); + assert!(n > 0, "이미지 지문이 하나도 생성되지 않았다"); } #[test] fn detects_exact_and_resized_duplicates() { - let (_d, media, db, sid) = setup(); + let (_d, media, thumbs, db, sid) = setup(); let root = media.path(); // 원본 이미지 @@ -497,16 +1277,16 @@ mod tests { .save(root.join("unrelated.png")) .unwrap(); + build_sigs(&db, sid, root, thumbs.path()); let cancel = AtomicBool::new(false); - crate::pipeline::scan_source(&db, sid, root, &cancel, |_| {}).unwrap(); - crate::meta::extract_image_meta(&db, sid, &cancel).unwrap(); - - let stats = run(&db, None, Scope::Source(sid), 5, false, &cancel, |_| {}).unwrap(); + let stats = run(&db, Scope::Source(sid), 5, false, false, &cancel, |_| {}).unwrap(); // 완전 동일 그룹 1개 (original + exact_copy) assert_eq!(stats.exact_groups, 1, "완전 동일 그룹"); - // 유사 이미지 그룹: original/exact_copy/resized 모두 시각적으로 같음 + // 유사 이미지 그룹: original(keeper)/resized 는 시각적으로 같다 assert!(stats.image_groups >= 1, "유사 이미지 그룹 {}", stats.image_groups); + // 지문이 전부 준비됐으므로 빠진 파일이 없어야 한다 + assert_eq!(stats.skipped_no_sig, 0, "지문 없이 빠진 파일"); // 완전 동일 그룹에 keeper가 정확히 1개 let keepers: i64 = db @@ -521,11 +1301,46 @@ mod tests { }) .unwrap(); assert_eq!(keepers, 1); + + // distance/score가 채워진다 (예전에는 항상 NULL이라 UI 표시가 죽은 코드였다) + let (null_dist, null_score): (i64, i64) = db + .with_read(|c| { + c.query_row( + "SELECT (SELECT count(*) FROM dupe_members WHERE distance IS NULL), + (SELECT count(*) FROM dupe_groups WHERE score IS NULL)", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + }) + .unwrap(); + assert_eq!((null_dist, null_score), (0, 0), "distance/score가 비어 있다"); + } + + /// 지문이 없으면 유사 탐지는 조용히 0을 내지 않고 '빠진 개수'를 보고한다. + #[test] + fn reports_files_without_signatures() { + let (_d, media, _t, db, sid) = setup(); + let root = media.path(); + let img = image::RgbImage::from_fn(300, 200, |x, y| { + image::Rgb([(x % 256) as u8, (y % 256) as u8, 30]) + }); + img.save(root.join("a.png")).unwrap(); + std::fs::copy(root.join("a.png"), root.join("b.png")).unwrap(); + + let cancel = AtomicBool::new(false); + crate::pipeline::scan_source(&db, sid, root, &cancel, |_| {}).unwrap(); + crate::meta::extract_image_meta(&db, sid, &cancel).unwrap(); + // 썸네일·지문 단계를 일부러 건너뛴다 + let stats = run(&db, Scope::Source(sid), 5, false, false, &cancel, |_| {}).unwrap(); + + assert_eq!(stats.exact_groups, 1, "완전 동일은 지문 없이도 잡힌다"); + assert_eq!(stats.image_groups, 0, "지문이 없으니 유사 그룹은 없다"); + assert_eq!(stats.skipped_no_sig, 2, "지문 없는 파일 2개가 보고되어야 한다"); } #[test] fn folder_scope_restricts_detection() { - let (_d, media, db, sid) = setup(); + let (_d, media, _t, db, sid) = setup(); let root = media.path(); std::fs::create_dir_all(root.join("folderA")).unwrap(); std::fs::create_dir_all(root.join("folderB")).unwrap(); @@ -552,7 +1367,7 @@ mod tests { .unwrap(); // folderA 범위: 완전 동일 그룹 1개 + folderA 외 파일 없음 - let sa = run(&db, None, Scope::Folder { id: fa, recursive: true }, 5, false, &cancel, |_| {}).unwrap(); + let sa = run(&db, Scope::Folder { id: fa, recursive: true }, 5, false, false, &cancel, |_| {}).unwrap(); assert_eq!(sa.exact_groups, 1, "folderA 범위는 그룹 1개"); let outside: i64 = db .with_read(|c| { @@ -569,14 +1384,135 @@ mod tests { assert_eq!(outside, 0, "folderA 범위인데 다른 폴더 파일이 포함됨"); // 전체 범위: 그룹 2개 (folderA + folderB) - let sall = run(&db, None, Scope::All, 5, false, &cancel, |_| {}).unwrap(); + let sall = run(&db, Scope::All, 5, false, false, &cancel, |_| {}).unwrap(); assert_eq!(sall.exact_groups, 2, "전체 범위는 그룹 2개"); } + /// 스코프 재탐지가 **범위 밖** 열린 그룹을 지우지 않는다. #[test] - fn signature_distance_median() { - let a = vec![0u64, 0, 0, 0, 0]; - let b = vec![0u64, 0, 0xFF, 0, 0]; // 한 프레임만 다름 - assert_eq!(signature_distance(&a, &b), 0); // 중앙값은 0 + fn rescan_of_one_folder_keeps_other_groups() { + let (_d, media, _t, db, sid) = setup(); + let root = media.path(); + std::fs::create_dir_all(root.join("A")).unwrap(); + std::fs::create_dir_all(root.join("B")).unwrap(); + for (dir, tint) in [("A", 10u8), ("B", 200u8)] { + let img = image::RgbImage::from_fn(160, 120, |x, y| { + image::Rgb([(x % 256) as u8, (y % 256) as u8, tint]) + }); + img.save(root.join(format!("{dir}/1.png"))).unwrap(); + std::fs::copy(root.join(format!("{dir}/1.png")), root.join(format!("{dir}/2.png"))).unwrap(); + } + let cancel = AtomicBool::new(false); + crate::pipeline::scan_source(&db, sid, root, &cancel, |_| {}).unwrap(); + crate::meta::extract_image_meta(&db, sid, &cancel).unwrap(); + + // 전체 탐지 → 그룹 2개 + assert_eq!(run(&db, Scope::All, 5, false, false, &cancel, |_| {}).unwrap().exact_groups, 2); + let fa: i64 = db + .with_read(|c| c.query_row("SELECT id FROM folders WHERE name='A'", [], |r| r.get(0))) + .unwrap(); + // A만 재탐지 → A 그룹만 새로 만들고 B 그룹은 그대로 남아야 한다 + run(&db, Scope::Folder { id: fa, recursive: true }, 5, false, false, &cancel, |_| {}).unwrap(); + let total: i64 = db + .with_read(|c| c.query_row("SELECT count(*) FROM dupe_groups WHERE status='open'", [], |r| r.get(0))) + .unwrap(); + assert_eq!(total, 2, "B 폴더의 열린 그룹이 지워졌다"); + } + + #[test] + fn aligned_distance_trims_outliers() { + // 10프레임 중 2장만 완전히 다르다 → 절단 평균은 0에 가깝다 + let a: Vec<(u32, u64)> = (0..10).map(|i| (i * 1000, i as u64)).collect(); + let mut b = a.clone(); + b[3].1 = !b[3].1; + b[7].1 = !b[7].1; + let d = aligned_distance(&a, &b, 0, 500).unwrap(); + assert_eq!(d, 0, "이상치 2장이 절단되어야 한다: {d}"); + + // 오프셋이 있어도 정렬된다 (b가 2초 늦게 시작) + let shifted: Vec<(u32, u64)> = a.iter().map(|(t, h)| (t + 2000, *h)).collect(); + assert_eq!(aligned_distance(&a, &shifted, -2000, 500), Some(0)); + } + + #[test] + fn video_candidates_skips_common_tokens() { + // 앵커 0xAA는 모든 영상에 공통(공통 인트로) → 후보를 만들지 못한다. + // 0xBB·0xCC는 0·1번만 공유 → 그 쌍만 후보가 된다. + let mk = |id: i64, hs: &[u64]| VideoSig { + id, + anchors: hs.iter().enumerate().map(|(i, h)| ((i as u32) * 1000, *h)).collect(), + audio: Vec::new(), + duration_ms: 10_000, + }; + let sigs: Vec = (0..8) + .map(|i| { + if i < 2 { + mk(i, &[0xAA, 0xBB, 0xCC]) + } else { + mk(i, &[0xAA]) + } + }) + .collect(); + let refs: Vec<&VideoSig> = sigs.iter().collect(); + let cands = video_candidates(&refs); + assert_eq!(cands.len(), 1, "후보는 (0,1) 하나여야 한다: {cands:?}"); + assert_eq!((cands[0].0, cands[0].1), (0, 1)); + assert_eq!(cands[0].2, 2, "공통 앵커 2개(0xBB, 0xCC)"); + } + + #[test] + fn audio_candidates_need_distinct_shared_values() { + // 오디오 후보도 프레임과 같은 규칙을 쓴다. 중요한 건 **서로 다른 값**이 겹쳐야 한다는 + // 점이다 — 고정 사인파처럼 같은 값만 반복되는 소리는 영상당 토큰 1개로 접혀 + // 후보가 되지 않는다(실측으로 픽스처를 고치게 만든 지점이다). + let mk = |id: i64, vs: &[u32]| VideoSig { + id, + anchors: vec![(0, 1)], + audio: vs.iter().enumerate().map(|(i, v)| ((i as u32) * 125, *v)).collect(), + duration_ms: 10_000, + }; + // 0·1번은 서로 다른 값 3개를 공유, 2번은 같은 값만 반복(=토큰 1개), 3번은 남남 + let sigs = vec![ + mk(0, &[11, 22, 33]), + mk(1, &[11, 22, 33]), + mk(2, &[11, 11, 11, 11]), + mk(3, &[91, 92, 93]), + ]; + let refs: Vec<&VideoSig> = sigs.iter().collect(); + let cands = audio_candidates(&refs); + assert_eq!(cands.len(), 1, "(0,1)만 후보여야 한다: {cands:?}"); + assert_eq!((cands[0].0, cands[0].1, cands[0].2), (0, 1, 3)); + } + + /// 오디오 후보는 프레임보다 넓은 임계값을 쓴다 — 그래야 레터박스 사본을 확인할 수 있다. + /// 동시에 그 완화가 남남까지 통과시키면 안 되므로, 두 경계가 다 지켜지는지 고정한다. + #[test] + fn audio_relaxes_visual_threshold_but_still_rejects_strangers() { + let seq = |bits: u64| -> Vec<(u32, u64)> { + (0..20u32).map(|i| (i * 1000, (i as u64) << 40 | bits)).collect() + }; + let base = Dense { whole: seq(0), lb: seq(0) }; + // 전체 축은 12비트 어긋나고(프레임 임계값 8 초과, 오디오 임계값 16 이내), + // 트림 축은 원본과 같다 — 레터박스 사본이 딱 이 모양이다. + let far = 0xFFFu64; // 12비트 + let letterboxed = Dense { whole: seq(far), lb: seq(0) }; + assert!(confirm_pair(&base, &letterboxed, Via::Frames).is_none(), "프레임만으로는 못 잡는다"); + let (_, dist, _) = + confirm_pair(&base, &letterboxed, Via::Audio).expect("오디오 후보면 트림 축으로 확인된다"); + assert_eq!(dist, 0, "이긴 조합은 트림 축이라 거리 0"); + + // 양쪽 다 레터박스인 경우(같은 원본을 4:3·16:9로 각각 맞춤) — 트림↔트림이 답이다 + let boxed_a = Dense { whole: seq(0xF0F0F), lb: seq(0) }; + let boxed_b = Dense { whole: seq(far), lb: seq(0) }; + let (_, d2, _) = confirm_pair(&boxed_a, &boxed_b, Via::Audio) + .expect("양쪽 다 띠가 있어도 트림↔트림으로 만난다"); + assert_eq!(d2, 0); + + // 배경음만 같은 남남 — 어느 축도 가깝지 않다(32비트 차이) + let stranger = Dense { whole: seq(0xFFFF_FFFF), lb: seq(0xFFFF_FFFF) }; + assert!( + confirm_pair(&base, &stranger, Via::Audio).is_none(), + "완화해도 남남은 통과하면 안 된다" + ); } } diff --git a/src-tauri/crates/archive-indexer/src/edit.rs b/src-tauri/crates/archive-indexer/src/edit.rs new file mode 100644 index 0000000..fd39ccd --- /dev/null +++ b/src-tauri/crates/archive-indexer/src/edit.rs @@ -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 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 { + 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 { + 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 { + // TIFF(리틀엔디언) + IFD0 항목 1개 + 다음 IFD 없음 + let mut tiff: Vec = 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, value: u8) -> Option> { + 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, + /// 새 회전 각도 (영상만, 0/90/180/270) + pub rotation: Option, + /// 가로·세로가 바뀌었는가 + 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 { + 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 { + 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 { + 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::().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 { + 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 = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, +} + +/// 영상 전체를 고르게 훑어 `count`장을 뽑는다. +/// +/// **`-ss`를 `-i` 앞에 둔다.** 뒤에 두면 그 지점까지 전부 디코드하므로 30분짜리에서 +/// 한 장에 수십 초가 걸린다. 앞에 두면 키프레임까지 건너뛰어 한 장에 수십 밀리초다. +/// 그 대신 위치가 키프레임 단위로 어긋나는데, 타임라인 배경으로는 문제가 되지 않는다. +pub fn filmstrip( + tools: &FfTools, + src: &Path, + duration_ms: i64, + count: u32, + height: u32, +) -> Vec { + 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 +} diff --git a/src-tauri/crates/archive-indexer/src/ffmpeg.rs b/src-tauri/crates/archive-indexer/src/ffmpeg.rs index f9b0403..4bf7598 100644 --- a/src-tauri/crates/archive-indexer/src/ffmpeg.rs +++ b/src-tauri/crates/archive-indexer/src/ffmpeg.rs @@ -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); + +/// 균등 간격으로 그레이 프레임을 뽑는다. 시간(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, 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, Vec), FfError> { + let sample_ms = sample_ms.max(1); + let read_fp = |p: Option<&Path>| -> Vec { + 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, 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, 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::*; diff --git a/src-tauri/crates/archive-indexer/src/fileops.rs b/src-tauri/crates/archive-indexer/src/fileops.rs index 9f2aec4..751f6e1 100644 --- a/src-tauri/crates/archive-indexer/src/fileops.rs +++ b/src-tauri/crates/archive-indexer/src/fileops.rs @@ -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> { 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> { 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 { + 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 { + 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> { 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> { })?) } -/// 배치 undo — 저널 역순 재생. trash 항목은 건너뛴다. -pub fn undo_batch(db: &Db, batch_id: &str) -> Result { +/// 배치 undo — 저널 역순 재생. trash/delete 항목은 건너뛴다. +/// 되돌리기. `tools`가 없으면 영상 편집 되돌리기만 실패로 남고 나머지는 정상 처리된다. +pub fn undo_batch( + db: &Db, + batch_id: &str, + tools: Option<&crate::ffmpeg::FfTools>, + encoder: &str, +) -> Result { struct JEntry { id: i64, op: String, @@ -305,11 +399,13 @@ pub fn undo_batch(db: &Db, batch_id: &str) -> Result { src_path: Option, dst_path: Option, tag_id: Option, + /// 편집 되돌리기용 역연산 이름 (op = "edit"일 때만 채워진다) + payload: Option, } let bid = batch_id.to_string(); let entries: Vec = 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 { 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 { } } } + // 편집 되돌리기 = 역연산을 다시 적용. 원본 백업이 없으므로 + // 파일이 그새 다른 도구로 바뀌었으면 그 위에 역회전이 걸린다 — + // 회전은 무손실이라 그래도 화질 손해는 없다. + "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 { 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, + /// 다시 인코딩된 파일 수 (화질 손실이 있었던 것들) + pub reencoded: u64, +} + +/// 편집 전 수정시각을 기억했다가 편집 뒤 되돌려 준다. +/// +/// **편집은 촬영 시각을 바꾸는 일이 아니다.** 기본 정렬은 `COALESCE(taken_at, mtime_ms)`라 +/// EXIF 촬영일이 없는 파일(대부분의 영상)은 mtime으로 줄을 선다. 회전 한 번에 mtime이 +/// 지금으로 바뀌면 그 항목이 목록 맨 앞으로 튀어 사용자가 방금 보던 파일을 다시 찾아야 한다. +fn with_preserved_mtime(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 { + 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 { + 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 { + 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(()) +} diff --git a/src-tauri/crates/archive-indexer/src/lib.rs b/src-tauri/crates/archive-indexer/src/lib.rs index 282154f..880817d 100644 --- a/src-tauri/crates/archive-indexer/src/lib.rs +++ b/src-tauri/crates/archive-indexer/src/lib.rs @@ -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; diff --git a/src-tauri/crates/archive-indexer/src/maintenance.rs b/src-tauri/crates/archive-indexer/src/maintenance.rs index 17f13c1..30054ba 100644 --- a/src-tauri/crates/archive-indexer/src/maintenance.rs +++ b/src-tauri/crates/archive-indexer/src/maintenance.rs @@ -110,6 +110,96 @@ pub fn remove_source(db: &Db, thumb_root: &Path, source_id: i64) -> Result 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 { + 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 = 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(); diff --git a/src-tauri/crates/archive-indexer/src/meta.rs b/src-tauri/crates/archive-indexer/src/meta.rs index ea9d3bf..40de806 100644 --- a/src-tauri/crates/archive-indexer/src/meta.rs +++ b/src-tauri/crates/archive-indexer/src/meta.rs @@ -18,14 +18,52 @@ struct MetaResult { ok: bool, } -fn exif_of(path: &Path) -> (u32, Option) { - 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) { + 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)> { + 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: &mut R) -> Option<(u32, Option)> { + 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) { _ => 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(); diff --git a/src-tauri/crates/archive-indexer/src/model.rs b/src-tauri/crates/archive-indexer/src/model.rs index 5813c58..bdc9fcd 100644 --- a/src-tauri/crates/archive-indexer/src/model.rs +++ b/src-tauri/crates/archive-indexer/src/model.rs @@ -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::*; diff --git a/src-tauri/crates/archive-indexer/src/phash.rs b/src-tauri/crates/archive-indexer/src/phash.rs index 7c9b0da..fc0f1b8 100644 --- a/src-tauri/crates/archive-indexer/src/phash.rs +++ b/src-tauri/crates/archive-indexer/src/phash.rs @@ -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)], + 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![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, + parent: Vec, + ids: Vec, +} + +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> { + let mut by_root: std::collections::HashMap> = 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> { 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::>()); + 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 = 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::>(); + 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::>(); + 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가 거의 같아야 한다 diff --git a/src-tauri/crates/archive-indexer/src/pipeline.rs b/src-tauri/crates/archive-indexer/src/pipeline.rs index 605274e..afcfe20 100644 --- a/src-tauri/crates/archive-indexer/src/pipeline.rs +++ b/src-tauri/crates/archive-indexer/src/pipeline.rs @@ -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, } -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, 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, source_id: i64) -> Result { + 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 = 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, ScanError> { + let rows: Vec = 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, 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 = 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>, + order: std::collections::VecDeque, + 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 { + // 루트를 읽을 수 없으면(드라이브 미마운트·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 = Vec::new(); let mut folder_cache: HashMap = 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 = 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 = 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(); diff --git a/src-tauri/crates/archive-indexer/src/scan.rs b/src-tauri/crates/archive-indexer/src/scan.rs index 2060a2c..8c1bd44 100644 --- a/src-tauri/crates/archive-indexer/src/scan.rs +++ b/src-tauri/crates/archive-indexer/src/scan.rs @@ -40,7 +40,7 @@ pub fn enumerate_items(root: &Path) -> impl Iterator { } 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()?; diff --git a/src-tauri/crates/archive-indexer/src/sigmatch.rs b/src-tauri/crates/archive-indexer/src/sigmatch.rs new file mode 100644 index 0000000..a02b262 --- /dev/null +++ b/src-tauri/crates/archive-indexer/src/sigmatch.rs @@ -0,0 +1,1407 @@ +//! 지문(signature) 파생과 비교 — **전부 순수 함수**다. 파일·프로세스·DB를 건드리지 않으므로 +//! 단위 테스트로 동작을 고정할 수 있고, 알고리즘을 바꿀 때 회귀를 즉시 잡는다. +//! +//! 설계의 핵심: 프레임을 **32×32 그레이로 한 번만** 뽑고(`ffmpeg::frame_gray_sequence`, +//! 이미지는 썸네일을 같은 크기로 축소) 이후 모든 지문을 여기서 파생한다. +//! 그래서 새 탐지 방법을 추가할 때 무거운 재추출이 필요 없다. +//! +//! - `dhash` : 9×8 인접 밝기 비교 64비트 — 리사이즈·재인코딩·색보정에 강건 +//! - `phash` : 32×32 DCT 저주파 8×8 64비트 — 압축 노이즈·블러에 강건 +//! - `orient` : 8변형(회전 4 × 반전 2)의 dhash — 질의 때 8개를 다 조회해 회전·반전본을 찾는다 +//! - `tiles` : 4×4 타일별 64비트 — 일부 영역만 바뀐 경우(워터마크·자막)를 다수결로 판정 +//! - `scales` : 중앙 70%/50% 크롭 + 레터박스 트림 — 크롭본 대응 +//! - `stdev` : 저분산(검정·단색) 프레임 판별 — 이런 프레임은 수천 파일에 공통이라 색인에서 뺀다 + +use std::sync::OnceLock; + +/// 지문 알고리즘 버전. 올리면 해당 파일만 다시 계산된다(`files.sig_state` 리셋). +/// +/// 2: 타일 평평함 마스크(`tile_flat`)와 색차 지문(`color`) 추가 — 그레이 dHash가 보지 못하는 +/// 두 축(가려진 영역·색만 다른 그림)을 후보 검증에서 쓰기 위해. +/// 3: 영상 오디오 지문(`audio`) 추가 + 프레임 레코드 3번째 필드를 **phash → 레터박스 트림 +/// dHash**로 교체. phash는 저장만 되고 매칭에 쓰인 적이 없다. 오디오로 찾은 후보를 +/// 시각으로 확인할 때 검정 띠를 잘라낸 축이 필요하다 — 실측: 레터박스 사본은 전체 축에서 +/// 거리 20~36이지만 트림 축에서는 9다(배경음만 같은 남남은 29라 깨끗이 갈린다). +pub const SIG_VERSION: i64 = 3; + +/// 프레임 추출·파생 해상도 (정사각 그레이) +pub const FRAME: usize = 32; +pub const FRAME_LEN: usize = FRAME * FRAME; + +/// 저분산 임계 — 이 미만이면 단색/검정 프레임으로 보고 색인에서 제외한다. +pub const LOW_VARIANCE_STDEV: f32 = 6.0; + +/// 프레임 하나에서 파생한 지문 묶음. +#[derive(Debug, Clone, PartialEq)] +pub struct FrameSig { + pub dhash: u64, + pub phash: u64, + /// 8변형 dhash. `orient[0]`은 항상 `dhash`와 같다. + pub orient: [u64; 8], + /// 4×4 타일 해시 (행 우선) + pub tiles: [u64; 16], + /// [중앙 70%, 중앙 50%, 레터박스 트림] + pub scales: [u64; 3], + /// 타일별 평평함(정보 없음) 비트마스크 — 후보 검증에서 가려진 영역을 빼는 데 쓴다 + pub tile_flat: u16, + pub stdev: f32, +} + +impl FrameSig { + /// 색인에 넣을 가치가 있는 프레임인가 (단색·검정 프레임 배제) + pub fn indexable(&self) -> bool { + self.stdev >= LOW_VARIANCE_STDEV + } +} + +/// 32×32 그레이 한 장에서 모든 지문을 파생한다. +/// +/// `gray.len()`이 `FRAME_LEN`이 아니면 `None`. +pub fn derive(gray: &[u8]) -> Option { + if gray.len() != FRAME_LEN { + return None; + } + let dhash = dhash_of(gray, FRAME, FRAME); + let mut orient = [0u64; 8]; + for v in 0..8u8 { + let t = transform(gray, FRAME, v); + orient[v as usize] = dhash_of(&t, FRAME, FRAME); + } + // transform(0)은 항등이므로 orient[0] == dhash 여야 한다 + debug_assert_eq!(orient[0], dhash); + + let (lx0, ly0, lx1, ly1) = letterbox(gray); + let letter = if (lx1 - lx0) >= 8 && (ly1 - ly0) >= 8 && (lx1 - lx0, ly1 - ly0) != (FRAME, FRAME) { + let (sub, w, h) = crop(gray, FRAME, lx0, ly0, lx1, ly1); + dhash_of(&sub, w, h) + } else { + dhash + }; + + Some(FrameSig { + dhash, + phash: phash_of(gray), + orient, + tiles: tile_hashes(gray), + scales: [crop_dhash(gray, 0.70), crop_dhash(gray, 0.50), letter], + tile_flat: tile_flat_mask(gray), + stdev: stdev_of(gray), + }) +} + +// ── 기본 해시 ──────────────────────────────────────────── + +/// 임의 크기 그레이를 9×8로 줄여 인접 밝기 비교로 64비트를 만든다 (dHash). +pub fn dhash_of(gray: &[u8], w: usize, h: usize) -> u64 { + let g = box_resize(gray, w, h, 9, 8); + let mut v = 0u64; + let mut bit = 0; + for y in 0..8 { + for x in 0..8 { + if g[y * 9 + x] > g[y * 9 + x + 1] { + v |= 1u64 << bit; + } + bit += 1; + } + } + v +} + +/// 32×32 DCT-II의 저주파 8×8 블록을 중앙값과 비교해 64비트를 만든다 (pHash). +pub fn phash_of(gray: &[u8]) -> u64 { + let t = cos_table(); + // 행 방향 DCT + let mut rows = [0.0f64; FRAME_LEN]; + for y in 0..FRAME { + for k in 0..8 { + // 저주파 8열만 필요하다 + let mut s = 0.0; + for n in 0..FRAME { + s += gray[y * FRAME + n] as f64 * t[k * FRAME + n]; + } + rows[y * FRAME + k] = s; + } + } + // 열 방향 DCT (저주파 8×8만) + let mut block = [0.0f64; 64]; + for k in 0..8 { + for x in 0..8 { + let mut s = 0.0; + for n in 0..FRAME { + s += rows[n * FRAME + x] * t[k * FRAME + n]; + } + block[k * 8 + x] = s; + } + } + // DC(0,0)는 전체 밝기라 제외하고 중앙값을 잡는다 + let mut rest: Vec = block.iter().copied().skip(1).collect(); + rest.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let median = rest[rest.len() / 2]; + let mut v = 0u64; + for (i, c) in block.iter().enumerate() { + if *c > median { + v |= 1u64 << i; + } + } + v +} + +/// 4×4 타일(타일당 8×8)마다 타일 평균 대비 밝기로 64비트. +pub fn tile_hashes(gray: &[u8]) -> [u64; 16] { + let mut out = [0u64; 16]; + let ts = FRAME / 4; // 8 + for ty in 0..4 { + for tx in 0..4 { + let mut sum = 0u32; + for y in 0..ts { + for x in 0..ts { + sum += gray[(ty * ts + y) * FRAME + tx * ts + x] as u32; + } + } + let mean = sum / (ts * ts) as u32; + let mut v = 0u64; + let mut bit = 0; + for y in 0..ts { + for x in 0..ts { + if gray[(ty * ts + y) * FRAME + tx * ts + x] as u32 > mean { + v |= 1u64 << bit; + } + bit += 1; + } + } + out[ty * 4 + tx] = v; + } + } + out +} + +/// 타일이 '평평하다'고 볼 표준편차 상한. 이하면 정보가 없다고 본다 +/// (검정 바·단색 하늘). JPEG 링잉을 감안해 완전 0은 쓰지 않는다. +pub const TILE_FLAT_STDEV: f32 = 4.0; +/// 한 밝기대(8단계 폭)에 이 비율 이상이 몰리면 그 타일은 정보가 없다고 본다. +/// +/// 분산만 보면 놓친다: **가림 블록이 80%를 덮은 타일**은 블록과 내용이 섞여 분산이 높지만 +/// 해시는 블록이 지배한다. 실측에서 그런 타일이 서로 다른 두 사진에 **거리 0**으로 나타나 +/// 오탐을 만들었다(가림이 같으니 해시도 같다). +/// +/// 절대값(0/255)으로 판정하면 안 된다 — ffmpeg `color=white`는 실제로 ~230, `black`은 ~15로 +/// 렌더된다(실측). 그래서 '어느 값이든 한 곳에 몰렸는가'만 본다. +pub const TILE_DOMINANT_RATIO: f32 = 0.6; + +/// 타일별 '정보 없음' 비트마스크 — 비트 i가 1이면 i번째 타일은 판정에서 뺀다. +/// 거의 단색이거나, 단색 블록(검정 바·흰 로고)이 절반 이상을 덮은 타일이 대상이다. +pub fn tile_flat_mask(gray: &[u8]) -> u16 { + let ts = FRAME / 4; + let n = (ts * ts) as f32; + let mut mask = 0u16; + for ty in 0..4 { + for tx in 0..4 { + let mut sum = 0f32; + let mut sq = 0f32; + // 32개 밝기대(8단계 폭) 히스토그램 — 한 곳에 몰렸는지 본다 + let mut hist = [0u32; 32]; + for y in 0..ts { + for x in 0..ts { + let v = gray[(ty * ts + y) * FRAME + tx * ts + x]; + hist[(v >> 3) as usize] += 1; + let f = v as f32; + sum += f; + sq += f * f; + } + } + let var = (sq / n - (sum / n) * (sum / n)).max(0.0); + // 인접 두 밴드를 합쳐 본다 — 가림 블록 값이 경계에 걸쳐 있을 수 있다 + let dominant = (0..32) + .map(|i| hist[i] + if i + 1 < 32 { hist[i + 1] } else { 0 }) + .max() + .unwrap_or(0); + let uninformative = + var.sqrt() <= TILE_FLAT_STDEV || (dominant as f32 / n) >= TILE_DOMINANT_RATIO; + if uninformative { + mask |= 1 << (ty * 4 + tx); + } + } + } + mask +} + +/// 4×4 셀의 색차 지문 — 셀마다 (u, v)를 i8로 담아 32바이트. +/// +/// 그레이 dHash가 **구조만** 보기 때문에 색만 다른 그림을 구별하지 못한다(실측: 초록 +/// 그라디언트와 파랑→자홍 그라디언트가 거리 0). 밝기를 뺀 색차만 저장하면 32바이트로 +/// 그 축을 되찾을 수 있다. JPEG 재인코딩·밝기 변화에는 둔감하다(색상은 거의 보존된다). +/// +/// `rgb`는 4×4 RGB8 (48바이트)여야 한다. 아니면 None. +pub fn color_of(rgb: &[u8]) -> Option<[i8; 32]> { + if rgb.len() != 4 * 4 * 3 { + return None; + } + let mut out = [0i8; 32]; + for c in 0..16 { + let (r, g, b) = (rgb[c * 3] as f32, rgb[c * 3 + 1] as f32, rgb[c * 3 + 2] as f32); + // BT.601 색차. ±112 범위를 i8로 자른다(포화는 극단 색에서만 발생) + let u = -0.168_736 * r - 0.331_264 * g + 0.5 * b; + let v = 0.5 * r - 0.418_688 * g - 0.081_312 * b; + out[c * 2] = u.clamp(-127.0, 127.0) as i8; + out[c * 2 + 1] = v.clamp(-127.0, 127.0) as i8; + } + Some(out) +} + +/// 색 지문 32바이트를 BLOB로 (i8 → u8 그대로). +pub fn pack_color(c: &[i8; 32]) -> Vec { + c.iter().map(|v| *v as u8).collect() +} + +pub fn unpack_color(b: &[u8]) -> Option<[i8; 32]> { + if b.len() != 32 { + return None; + } + let mut out = [0i8; 32]; + for (i, v) in b.iter().enumerate() { + out[i] = *v as i8; + } + Some(out) +} + +fn crop_dhash(gray: &[u8], ratio: f32) -> u64 { + let keep = ((FRAME as f32) * ratio).round() as usize; + let keep = keep.clamp(8, FRAME); + let m = (FRAME - keep) / 2; + let (sub, w, h) = crop(gray, FRAME, m, m, m + keep, m + keep); + dhash_of(&sub, w, h) +} + +// ── 기하 유틸 ──────────────────────────────────────────── + +/// [x0,x1) × [y0,y1) 잘라내기. 반환: (픽셀, 폭, 높이) +pub fn crop(gray: &[u8], w: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> (Vec, usize, usize) { + let cw = x1.saturating_sub(x0); + let ch = y1.saturating_sub(y0); + let mut out = Vec::with_capacity(cw * ch); + for y in y0..y1 { + for x in x0..x1 { + out.push(gray[y * w + x]); + } + } + (out, cw, ch) +} + +/// 박스 평균 다운샘플 (업스케일은 최근접). +pub fn box_resize(src: &[u8], sw: usize, sh: usize, dw: usize, dh: usize) -> Vec { + let mut out = vec![0u8; dw * dh]; + for dy in 0..dh { + let mut y0 = dy * sh / dh; + let mut y1 = (dy + 1) * sh / dh; + if y1 <= y0 { + y1 = (y0 + 1).min(sh); + } + if y0 >= sh { + y0 = sh - 1; + y1 = sh; + } + for dx in 0..dw { + let mut x0 = dx * sw / dw; + let mut x1 = (dx + 1) * sw / dw; + if x1 <= x0 { + x1 = (x0 + 1).min(sw); + } + if x0 >= sw { + x0 = sw - 1; + x1 = sw; + } + let mut sum = 0u32; + let mut n = 0u32; + for y in y0..y1 { + for x in x0..x1 { + sum += src[y * sw + x] as u32; + n += 1; + } + } + out[dy * dw + dx] = (sum / n.max(1)) as u8; + } + } + out +} + +/// 정사각 프레임의 8변형. `v` 비트: 1=전치, 2=좌우반전, 4=상하반전. +/// v=0은 항등이다. +pub fn transform(src: &[u8], n: usize, v: u8) -> Vec { + let mut out = vec![0u8; n * n]; + for y in 0..n { + for x in 0..n { + let (mut sx, mut sy) = (x, y); + if v & 1 != 0 { + std::mem::swap(&mut sx, &mut sy); + } + if v & 2 != 0 { + sx = n - 1 - sx; + } + if v & 4 != 0 { + sy = n - 1 - sy; + } + out[y * n + x] = src[sy * n + sx]; + } + } + out +} + +/// 검정 레터박스/필러박스 경계를 찾는다. 반환: [x0,x1) × [y0,y1) +pub fn letterbox(gray: &[u8]) -> (usize, usize, usize, usize) { + const DARK: u32 = 24; + let row_dark = |y: usize| -> bool { + let mut max = 0u32; + let mut sum = 0u32; + for x in 0..FRAME { + let p = gray[y * FRAME + x] as u32; + sum += p; + if p > max { + max = p; + } + } + max <= DARK * 2 && sum / FRAME as u32 <= DARK + }; + let col_dark = |x: usize| -> bool { + let mut max = 0u32; + let mut sum = 0u32; + for y in 0..FRAME { + let p = gray[y * FRAME + x] as u32; + sum += p; + if p > max { + max = p; + } + } + max <= DARK * 2 && sum / FRAME as u32 <= DARK + }; + let mut y0 = 0; + while y0 < FRAME && row_dark(y0) { + y0 += 1; + } + let mut y1 = FRAME; + while y1 > y0 && row_dark(y1 - 1) { + y1 -= 1; + } + let mut x0 = 0; + while x0 < FRAME && col_dark(x0) { + x0 += 1; + } + let mut x1 = FRAME; + while x1 > x0 && col_dark(x1 - 1) { + x1 -= 1; + } + (x0, y0, x1, y1) +} + +fn stdev_of(gray: &[u8]) -> f32 { + let n = gray.len() as f64; + let mean = gray.iter().map(|p| *p as f64).sum::() / n; + let var = gray.iter().map(|p| { let d = *p as f64 - mean; d * d }).sum::() / n; + var.sqrt() as f32 +} + +fn cos_table() -> &'static [f64; FRAME * FRAME] { + static T: OnceLock<[f64; FRAME * FRAME]> = OnceLock::new(); + T.get_or_init(|| { + let mut t = [0.0f64; FRAME * FRAME]; + for k in 0..FRAME { + for n in 0..FRAME { + t[k * FRAME + n] = + ((std::f64::consts::PI / FRAME as f64) * (n as f64 + 0.5) * k as f64).cos(); + } + } + t + }) +} + +// ── 비교 ───────────────────────────────────────────────── + +#[inline] +pub fn hamming(a: u64, b: u64) -> u32 { + (a ^ b).count_ones() +} + +/// 해밍 밴드 — 64비트를 16비트 4조각으로. 거리 ≤3이면 **최소 한 밴드가 완전 일치**하므로 +/// (비둘기집 원리) 밴드 역색인으로 미탐 없이 후보를 좁힐 수 있다. +#[inline] +pub fn bands(h: u64) -> [u16; 4] { + [ + (h & 0xFFFF) as u16, + ((h >> 16) & 0xFFFF) as u16, + ((h >> 32) & 0xFFFF) as u16, + ((h >> 48) & 0xFFFF) as u16, + ] +} + +/// 회전·반전본까지 포함한 최소 거리. `orient`는 한쪽 파일의 8변형. +pub fn min_orient_distance(orient: &[u64; 8], other: u64) -> u32 { + orient.iter().map(|h| hamming(*h, other)).min().unwrap_or(64) +} + +/// 타일 다수결 — 근접한 타일 수를 센다. 워터마크·자막은 일부 타일만 망가뜨린다. +/// 하단 두 줄(자막 위치)은 가중을 낮추기 위해 호출자가 결과를 해석할 때 쓰도록 별도로 센다. +/// 반환: (전체 근접 타일 수, 하단 8타일 중 근접 수) +pub fn tile_agreement(a: &[u64; 16], b: &[u64; 16], per_tile: u32) -> (u32, u32) { + let mut all = 0; + let mut bottom = 0; + for i in 0..16 { + if hamming(a[i], b[i]) <= per_tile { + all += 1; + if i >= 8 { + bottom += 1; + } + } + } + (all, bottom) +} + +// ── 후보 검증 (단계 3) ──────────────────────────────────── +// +// dHash 하나로는 두 가지를 못 한다: +// ① **놓친다** — 화면을 크게 가린 사본(자막·로고), 중간 강도 크롭은 dHash가 멀어진다. +// ② **잘못 잡는다** — 가림이 크면 남는 정보가 비슷해져 *서로 다른* 원본이 닮아 보인다. +// (픽스처 실측: 하단 30%+로고를 씌운 서로 다른 두 사진이 한 그룹이 됐다) +// 그래서 후보로 올라온 쌍만 타일·크롭 해시로 다시 본다. 여기 드는 비용은 후보 수에만 비례한다. + +/// 한 파일의 '풍부한' 지문 — 후보로 올라온 것만 DB에서 지연 로드한다. +#[derive(Debug, Clone, PartialEq)] +pub struct RichSig { + pub dhash: u64, + /// 8변형(회전·반전) dHash. `orient[0]`은 항등이라 `dhash`와 같다. + pub orient: [u64; 8], + /// 4×4 타일 해시 — 일부만 망가지는 변형(자막·로고)을 국소화한다 + pub tiles: [u64; 16], + /// [중앙 70%, 중앙 50%, 레터박스 트림] dHash + pub scales: [u64; 3], + /// 타일별 '평평함' 비트마스크 — 1이면 그 타일은 거의 단색이라 **정보가 없다**. + /// 검정 바·흰 로고·하늘처럼 가려진(또는 비어 있는) 영역을 판정에서 빼는 데 쓴다. + pub tile_flat: u16, + /// 4×4 셀의 색차 (u,v) 쌍 32바이트. 영상은 그레이 파이프라 없다(None). + pub color: Option<[i8; 32]>, +} + +/// 판정 근거 — UI·로그가 "왜 같다고 봤는지"를 말할 수 있게 남긴다. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MatchVia { + /// 전체 dHash가 가깝다 (가장 흔한 경우) + Whole, + /// 회전·반전 변형이 맞았다 + Orient, + /// 타일 다수결 — 일부가 가려졌지만 나머지가 일치 + Tiles, + /// 중앙 크롭 해시가 맞았다 + Crop, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Verdict { + pub via: MatchVia, + /// 대표 거리(작을수록 닮음). 근거에 따라 의미가 달라도 UI 표시는 같은 축을 쓴다. + pub distance: u32, +} + +/// 타일 하나가 '근접'으로 인정되는 해밍 상한 (타일 해시는 64비트) +pub const TILE_PER_TILE: u32 = 12; +/// 정보 있는 타일이 이만큼 이상 맞아야 '가려졌지만 같은 그림'으로 본다. +pub const TILE_MIN_AGREE: u32 = 8; +/// 타일 다수결로 인정할 때 요구하는 엄격 상한 +pub const TILE_STRICT_PER_TILE: u32 = 8; +/// 거부권을 행사하려면 **양쪽 모두 정보 있는** 타일이 이만큼은 있어야 한다. +/// 정보가 이보다 적으면 판단 근거가 없다는 뜻이므로 기각하지 않는다. +pub const VETO_MIN_INFORMATIVE: u32 = 5; +/// 정보 있는 타일 중 맞는 비율이 이보다 낮으면 '다른 그림'으로 본다. +/// +/// 픽스처 실측(엄격 임계 기준)으로 경계를 잡았다 — 진짜 중복의 최저가 0.67, +/// 오탐의 최고가 0.375다: 저품질 재인코딩 1.00 · 50% 축소 1.00 · 밝기변경 1.00 · +/// 하단 자막 0.79 · 5% 크롭 0.69 · 원본↔자기 큰가림 0.67 ‖ **다른 원본+같은 가림 0.375** · +/// 무관한 컬러바 0.14. 느슨한 임계(≤12)로 보면 0.625 vs 0.667로 붙어 구분되지 않는다. +pub const VETO_AGREE_RATIO: f32 = 0.5; +/// 4×4 색차 평균이 이 값을 넘으면 색이 확실히 다르다 (u,v는 ±127 축) +pub const COLOR_MAX_DIST: u32 = 24; +/// 크롭 해시로 인정할 최대 거리 — 전체 해시보다 **엄격해야 한다**. +/// +/// 중앙 70%/50% 해시는 원본보다 구조가 적어 서로 충돌하기 쉽다. 전체 해시와 같은 임계값을 +/// 쓰면 '중앙이 닮았을 뿐인' 그림들이 사슬로 엮인다(실측: 확대 애니메이션 스틸 3장이 +/// 한 그룹으로 뭉쳤다 — 프레임 N의 중앙 50%가 프레임 N+k와 비슷하기 때문). +pub const CROP_MAX_DIST: u32 = 2; + +/// 두 색 지문의 평균 색차 (셀당 |Δu|+|Δv|의 평균). +pub fn color_distance(a: &[i8; 32], b: &[i8; 32]) -> u32 { + let mut sum = 0u32; + for i in 0..32 { + sum += (a[i] as i32 - b[i] as i32).unsigned_abs(); + } + sum / 16 // 셀 16개 +} + +/// 양쪽 모두 정보가 있는(평평하지 않은) 타일들만 보고 (맞은 수, 본 수)를 센다. +pub fn informative_agreement(a: &RichSig, b: &RichSig, per_tile: u32) -> (u32, u32) { + let mut agree = 0; + let mut seen = 0; + for i in 0..16 { + let flat = (a.tile_flat >> i) & 1 == 1 || (b.tile_flat >> i) & 1 == 1; + if flat { + continue; + } + seen += 1; + if hamming(a.tiles[i], b.tiles[i]) <= per_tile { + agree += 1; + } + } + (agree, seen) +} + +/// **거부권** — 전체 해시가 가까워도 '다른 그림'이라는 적극적 증거가 있으면 기각한다. +/// +/// 실측된 오탐이 근거다: 화면 30%를 검정 바로 덮고 로고를 얹으면 *서로 다른 두 사진*의 +/// dHash가 임계값 안으로 들어온다(남은 정보가 비슷해지기 때문). 이때 가려진 타일은 양쪽 다 +/// 평평하므로 판정에서 빼고, **정보가 있는 타일들이 얼마나 맞는지**만 본다. +/// 색이 확실히 다르면 기각 — 색은 어떤 변형에도 대체로 보존되므로 항상 적용한다. +/// 한쪽이라도 색 지문이 없으면(영상·구버전 지문) 이 축으로 판단하지 않는다. +fn color_vetoed(a: &RichSig, b: &RichSig) -> bool { + match (&a.color, &b.color) { + (Some(ca), Some(cb)) => color_distance(ca, cb) > COLOR_MAX_DIST, + _ => false, + } +} + +/// 회전·반전을 고려한 색 거부권. 색 지문은 4×4 **공간** 격자라 회전하면 셀이 뒤바뀐다 — +/// 그대로 비교하면 회전본이 색이 다르다고 기각된다(실측 색차 60). +fn color_vetoed_oriented(a: &RichSig, b: &RichSig) -> bool { + let (Some(ca), Some(cb)) = (&a.color, &b.color) else { + return false; + }; + // 4×4 격자의 8변형(회전 4 × 반전 2) 중 가장 가까운 것으로 본다 + let cell = |c: &[i8; 32], i: usize| (c[i * 2], c[i * 2 + 1]); + let mut best = u32::MAX; + for v in 0..8u8 { + let mut sum = 0u32; + for r in 0..4usize { + for col in 0..4usize { + // transform()과 같은 규칙: 비트0=전치, 비트1=좌우반전, 비트2=상하반전 + let (mut sr, mut sc) = (r, col); + if v & 1 == 1 { + std::mem::swap(&mut sr, &mut sc); + } + if v & 2 == 2 { + sc = 3 - sc; + } + if v & 4 == 4 { + sr = 3 - sr; + } + let (au, av) = cell(ca, sr * 4 + sc); + let (bu, bv) = cell(cb, r * 4 + col); + sum += (au as i32 - bu as i32).unsigned_abs() + (av as i32 - bv as i32).unsigned_abs(); + } + } + best = best.min(sum / 16); + } + best > COLOR_MAX_DIST +} + +/// 타일 거부권 — **같은 방향·같은 프레이밍**이라는 가설에만 쓴다. +/// +/// 회전·반전본은 타일이 애초에 대응하지 않아 전부 어긋나고, 큰 크롭도 타일 경계가 밀린다. +/// 그 경우엔 맞은 해시 자체가 근거이므로 이 거부권을 적용하면 정탐을 잃는다. +fn tile_vetoed(a: &RichSig, b: &RichSig) -> bool { + // 엄격 임계로 센다 — 느슨하게 세면 오탐과 정탐이 같은 비율로 붙는다(위 주석의 실측값). + let (agree, seen) = informative_agreement(a, b, TILE_STRICT_PER_TILE); + seen >= VETO_MIN_INFORMATIVE && (agree as f32) < seen as f32 * VETO_AGREE_RATIO +} + +/// 후보 쌍을 검증한다. `None`이면 기각(오탐). +/// +/// 순서: 거부권 → 전체 dHash → 크롭 → 타일. 회전은 `allow_orient`일 때만 본다 +/// (8변형 조회는 색인 비용이 커서 사용자가 켤 때만 쓴다). +pub fn verify_pair(a: &RichSig, b: &RichSig, threshold: u32, allow_orient: bool) -> Option { + // 회전·반전은 먼저 본다 — 맞으면 타일 거부권을 적용하지 않는다(타일이 대응하지 않으므로). + // **항등 변형(orient[0])은 뺀다** — 넣으면 그냥 같은 그림이 '회전본'으로 승인되면서 + // 타일 거부권을 우회한다(그 경로로 실측 오탐이 되살아났다). + if allow_orient { + let d = a + .orient + .iter() + .skip(1) + .map(|h| hamming(*h, b.dhash)) + .min() + .unwrap_or(64); + if d <= threshold && !color_vetoed_oriented(a, b) { + return Some(Verdict { via: MatchVia::Orient, distance: d }); + } + } + if color_vetoed(a, b) { + return None; + } + let whole = hamming(a.dhash, b.dhash); + if whole <= threshold { + // 여기서만 타일 거부권 — '같은 방향·같은 프레이밍인데 정말 같은 그림인가'를 되묻는다 + return if tile_vetoed(a, b) { + None + } else { + Some(Verdict { via: MatchVia::Whole, distance: whole }) + }; + } + // 크롭: 한쪽의 중앙 70%/50%가 다른 쪽 전체와, 또는 서로의 같은 배율끼리 맞는지. + // 잘린 만큼 8×8 격자가 밀리므로 전체 해시로는 잡히지 않는다. + // 임계는 전체 해시보다 엄격하다(CROP_MAX_DIST) — 이유는 그 상수 주석 참조. + let crop_max = threshold.min(CROP_MAX_DIST); + let mut best_crop = u32::MAX; + for (i, &sa) in a.scales.iter().enumerate() { + best_crop = best_crop.min(hamming(sa, b.dhash)); + best_crop = best_crop.min(hamming(sa, b.scales[i])); + } + for &sb in b.scales.iter() { + best_crop = best_crop.min(hamming(a.dhash, sb)); + } + if best_crop <= crop_max { + return Some(Verdict { via: MatchVia::Crop, distance: best_crop }); + } + // 타일 다수결 — **정보 있는** 타일만 세고, 그것들이 확실히 맞을 때만 인정한다. + // 평평한 타일(검정 바·흰 로고)을 세면 '가림이 같아서' 다른 사진이 붙는다(실측된 오탐). + let (agree, seen) = informative_agreement(a, b, TILE_STRICT_PER_TILE); + if seen >= VETO_MIN_INFORMATIVE && agree >= TILE_MIN_AGREE.min(seen) { + // 대표 거리는 맞은 타일들의 평균으로 환산한다 (0~64 축을 유지) + let mut sum = 0u32; + let mut n = 0u32; + for i in 0..16 { + let flat = (a.tile_flat >> i) & 1 == 1 || (b.tile_flat >> i) & 1 == 1; + if flat { + continue; + } + let d = hamming(a.tiles[i], b.tiles[i]); + if d <= TILE_STRICT_PER_TILE { + sum += d; + n += 1; + } + } + return Some(Verdict { via: MatchVia::Tiles, distance: if n == 0 { 64 } else { sum / n } }); + } + None +} + +// ── 콘텐츠 고정 앵커 (위상 문제 해결) ───────────────────── +// +// 부분 구간 매칭에서 "고정 간격으로 뽑은 샘플"은 두 파일이 서로 다른 원본 프레임을 고르게 만든다. +// B = 인트로(3.37초) + A 라면 B의 1초 격자는 A 기준 0.63, 1.63… 에 떨어진다. +// 해밍 허용으로 구제하려 해도, 실측상 오디오 지문 항목은 0.5초만 어긋나도 무작위쌍과 구별되지 않는다 +// (chromaprint raw 항목의 무작위쌍 평균 해밍이 6.3 — 고유 엔트로피가 낮다). +// +// 해법: **선택 여부를 위치가 아니라 값에 의존시킨다.** 값이 살아남으면 두 파일이 오프셋과 무관하게 +// 같은 항목을 고른다. 콘텐츠 정의 청킹(content-defined chunking)과 같은 원리다. + +/// 값 기반 표본 선택. `rate_log2`가 5면 대략 1/32이 선택된다. +/// splitmix64 finalizer를 써서 값의 하위 비트 편향을 없앤다. +#[inline] +pub fn selected(v: u64, rate_log2: u32) -> bool { + if rate_log2 == 0 { + return true; + } + let mut x = v.wrapping_mul(0xff51_afd7_ed55_8ccd); + x ^= x >> 33; + x = x.wrapping_mul(0xc4ce_b9fe_1a85_ec53); + x ^= x >> 33; + (x >> (64 - rate_log2.min(63))) == 0 +} + +/// 조밀 시퀀스에서 콘텐츠 고정 앵커만 남긴다. 색인에 넣을 토큰 집합이 된다. +/// (검증은 조밀 시퀀스로 하고, 색인은 이 앵커로 한다 — 용량과 위상 문제를 동시에 해결) +pub fn anchors(seq: &[(u32, u64)], rate_log2: u32) -> Vec<(u32, u64)> { + seq.iter().filter(|(_, h)| selected(*h, rate_log2)).copied().collect() +} + +/// 연속 동일 해시를 접는다 — 정지 화면과 VFR 중복 프레임이 표를 몰아주는 것을 막는다. +pub fn dedup_consecutive(seq: &[(u32, u64)]) -> Vec<(u32, u64)> { + let mut out: Vec<(u32, u64)> = Vec::with_capacity(seq.len()); + for &(t, h) in seq { + if out.last().map(|(_, p)| *p) != Some(h) { + out.push((t, h)); + } + } + out +} + +/// 역문서빈도 — 여러 파일에 흔한 토큰의 표 가치를 낮춘다. +/// 검정 프레임·페이드·공통 인트로가 오프셋 투표를 지배하는 것을 막는 가장 값싼 방어다. +#[inline] +pub fn idf(df: u32, n: u32) -> f32 { + if df == 0 { + return 0.0; + } + (((n as f32 + 1.0) / (df as f32 + 1.0)).ln()).max(0.0) +} + +/// 두 시퀀스의 부분 구간 일치. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SegMatch { + /// t_a - t_b (양수면 a가 뒤쪽) + pub offset_ms: i64, + /// 이 오프셋을 지지한 프레임쌍 수 + pub support: u32, + pub a_start_ms: u32, + pub a_end_ms: u32, + pub b_start_ms: u32, + pub b_end_ms: u32, +} + +impl SegMatch { + pub fn a_span_ms(&self) -> u32 { + self.a_end_ms.saturating_sub(self.a_start_ms) + } +} + +/// 오프셋 투표로 부분 구간 일치를 찾는다. +/// +/// 두 프레임 시퀀스에서 해밍거리 `max_dist` 이하인 쌍을 모아 시간차 히스토그램을 만들고, +/// 가장 많은 표를 받은 오프셋의 구간을 돌려준다. 긴 영상의 일부, 광고·인트로 삽입, +/// FPS 차이가 모두 이 한 메커니즘으로 처리된다(샘플링이 시간 기준이므로). +/// +/// `bucket_ms`는 오프셋 허용 오차(샘플 간격의 절반 정도가 적당하다). +/// `min_support` 미만이면 `None`. +pub fn offset_vote( + a: &[(u32, u64)], + b: &[(u32, u64)], + max_dist: u32, + bucket_ms: i64, + min_support: u32, +) -> Option { + if a.is_empty() || b.is_empty() || bucket_ms <= 0 { + return None; + } + use std::collections::HashMap; + // 버킷 → (표, a범위, b범위) + let mut hist: HashMap = HashMap::new(); + for &(ta, ha) in a { + for &(tb, hb) in b { + if hamming(ha, hb) > max_dist { + continue; + } + let d = ta as i64 - tb as i64; + // 반올림 나눗셈 (음수 포함) + let bucket = if d >= 0 { + (d + bucket_ms / 2) / bucket_ms + } else { + -((-d + bucket_ms / 2) / bucket_ms) + }; + let e = hist.entry(bucket).or_insert((0, u32::MAX, 0, u32::MAX, 0)); + e.0 += 1; + e.1 = e.1.min(ta); + e.2 = e.2.max(ta); + e.3 = e.3.min(tb); + e.4 = e.4.max(tb); + } + } + let (bucket, e) = hist.into_iter().max_by_key(|(_, e)| e.0)?; + if e.0 < min_support { + return None; + } + Some(SegMatch { + offset_ms: bucket * bucket_ms, + support: e.0, + a_start_ms: e.1, + a_end_ms: e.2, + b_start_ms: e.3, + b_end_ms: e.4, + }) +} + +/// 배속 후보 — 사람이 실제로 쓰는 값들. 이 밖의 임의 배속은 다루지 않는다 +/// (연속 탐색은 비용이 크고, 실무에서 나오는 건 대부분 이 목록이다). +pub const SPEED_FACTORS: [f32; 6] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; + +/// 배속을 고려한 부분 구간 일치. +/// +/// `atempo`로 속도를 바꾸면 시간축 자체가 늘어나므로 **절대 시각 오프셋 투표가 무너진다** +/// (실측: 1.25배속본이 9프레임만 우연히 지지해 커버리지 0.375로 '의심'에 머물렀다). +/// b의 시각에 배속 후보를 곱해 a의 시간축으로 되돌린 뒤 같은 투표를 돌린다. +/// +/// 반환: (일치 구간, 적용한 배속). 배속 1.0이 이기면 평범한 동일 속도 사본이다. +pub fn offset_vote_speed( + a: &[(u32, u64)], + b: &[(u32, u64)], + max_dist: u32, + bucket_ms: i64, + min_support: u32, +) -> Option<(SegMatch, f32)> { + let mut best: Option<(SegMatch, f32)> = None; + for f in SPEED_FACTORS { + // b가 a보다 f배 빠르면 b의 t에 f를 곱해야 a의 시간축이 된다 + let scaled: Vec<(u32, u64)> = b.iter().map(|(t, h)| (((*t as f32) * f) as u32, *h)).collect(); + let Some(m) = offset_vote(a, &scaled, max_dist, bucket_ms, min_support) else { + continue; + }; + // 지지 프레임이 더 많은 쪽을 고른다. 같으면 배속 1.0을 우선한다 — + // 배속을 억지로 맞춰 우연히 표를 조금 더 얻는 해석보다 '그대로'가 옳을 확률이 높다. + let better = match &best { + None => true, + Some((bm, bf)) => m.support > bm.support || (m.support == bm.support && (f - 1.0).abs() < (bf - 1.0).abs()), + }; + if better { + best = Some((m, f)); + } + } + best +} + +/// 장면 대표 프레임 인덱스 — 직전 대표와의 평균절대차(MAD)가 임계를 넘으면 새 장면으로 본다. +/// 색인에는 대표만 넣고(용량), 검증은 조밀 시퀀스로 한다(정확도). +/// 항상 0번을 포함하고, `max_reps`를 넘으면 균등 간격으로 솎아낸다. +pub fn scene_reps(frames: &[Vec], mad_thresh: u32, max_reps: usize) -> Vec { + if frames.is_empty() { + return Vec::new(); + } + let mut reps = vec![0usize]; + let mut last = 0usize; + for i in 1..frames.len() { + if mad(&frames[last], &frames[i]) >= mad_thresh { + reps.push(i); + last = i; + } + } + if max_reps > 0 && reps.len() > max_reps { + let step = reps.len() as f64 / max_reps as f64; + let thinned: Vec = (0..max_reps) + .map(|k| reps[((k as f64) * step).floor() as usize]) + .collect(); + return thinned; + } + reps +} + +/// 평균절대차 (0~255). 길이가 다르면 짧은 쪽까지만 본다. +pub fn mad(a: &[u8], b: &[u8]) -> u32 { + let n = a.len().min(b.len()); + if n == 0 { + return 0; + } + let mut sum = 0u32; + for i in 0..n { + sum += (a[i] as i32 - b[i] as i32).unsigned_abs(); + } + sum / n as u32 +} + +// ── BLOB 패킹 (t_ms u32 + dhash u64 + 레터박스 트림 dhash u64 = 20바이트) ── +// +// 세 번째 필드는 sig_version 3부터 **레터박스를 잘라낸 dHash**다. 검정 띠를 붙이거나 +// 축소해 채워 넣은 사본은 전체 dHash가 크게 밀리는데, 띠를 잘라낸 해시는 원본의 전체 +// 해시와 그대로 맞는다. (이전에는 phash를 넣었지만 매칭에 쓰이지 않았다) + +pub const FRAME_REC: usize = 20; + +/// 오디오 앵커 (t_ms, chromaprint 값) → 8바이트 레코드 나열 +pub fn pack_audio(seq: &[(u32, u32)]) -> Vec { + let mut out = Vec::with_capacity(seq.len() * 8); + for (t, v) in seq { + out.extend_from_slice(&t.to_le_bytes()); + out.extend_from_slice(&v.to_le_bytes()); + } + out +} + +pub fn unpack_audio(b: &[u8]) -> Vec<(u32, u32)> { + b.chunks_exact(8) + .map(|c| { + ( + u32::from_le_bytes([c[0], c[1], c[2], c[3]]), + u32::from_le_bytes([c[4], c[5], c[6], c[7]]), + ) + }) + .collect() +} + +pub fn pack_frames(seq: &[(u32, u64, u64)]) -> Vec { + let mut out = Vec::with_capacity(seq.len() * FRAME_REC); + for (t, d, p) in seq { + out.extend_from_slice(&t.to_le_bytes()); + out.extend_from_slice(&d.to_le_bytes()); + out.extend_from_slice(&p.to_le_bytes()); + } + out +} + +pub fn unpack_frames(b: &[u8]) -> Vec<(u32, u64, u64)> { + let n = b.len() / FRAME_REC; + let mut out = Vec::with_capacity(n); + for i in 0..n { + let o = i * FRAME_REC; + let t = u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); + let mut d = [0u8; 8]; + d.copy_from_slice(&b[o + 4..o + 12]); + let mut p = [0u8; 8]; + p.copy_from_slice(&b[o + 12..o + 20]); + out.push((t, u64::from_le_bytes(d), u64::from_le_bytes(p))); + } + out +} + +pub fn pack_u64s(v: &[u64]) -> Vec { + let mut out = Vec::with_capacity(v.len() * 8); + for x in v { + out.extend_from_slice(&x.to_le_bytes()); + } + out +} + +pub fn unpack_u64s(b: &[u8]) -> Vec { + let n = b.len() / 8; + let mut out = Vec::with_capacity(n); + for i in 0..n { + let mut x = [0u8; 8]; + x.copy_from_slice(&b[i * 8..i * 8 + 8]); + out.push(u64::from_le_bytes(x)); + } + out +} + +/// 프레임 시퀀스에서 (t_ms, dhash)만 뽑는다 (오프셋 투표 입력). +pub fn dhash_seq(seq: &[(u32, u64, u64)]) -> Vec<(u32, u64)> { + seq.iter().map(|(t, d, _)| (*t, *d)).collect() +} + +/// 레터박스를 잘라낸 dHash 시퀀스. 검정 띠가 없으면 `dhash_seq`와 같은 값이 나온다. +/// +/// **후보 생성에는 쓰지 않는다** — 트림은 다시 표본화하므로 값이 원본과 정확히 같아지지 않는다 +/// (실측: 거리 7~16, 정확히 같은 값 0개). 오디오로 이미 좁혀진 쌍을 확인할 때만 쓴다. +pub fn lb_seq(seq: &[(u32, u64, u64)]) -> Vec<(u32, u64)> { + seq.iter().map(|(t, _, lb)| (*t, *lb)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 좌→우 밝기 그라디언트 (구조가 뚜렷해 해시가 안정적이다) + fn gradient() -> Vec { + (0..FRAME_LEN) + .map(|i| { + let x = i % FRAME; + let y = i / FRAME; + ((x * 200 / FRAME) + (y * 40 / FRAME)) as u8 + }) + .collect() + } + + fn solid(v: u8) -> Vec { + vec![v; FRAME_LEN] + } + + #[test] + fn derive_requires_exact_frame_size() { + assert!(derive(&vec![0u8; FRAME_LEN - 1]).is_none()); + assert!(derive(&gradient()).is_some()); + } + + #[test] + fn identity_transform_matches_dhash() { + let g = gradient(); + let s = derive(&g).unwrap(); + assert_eq!(s.orient[0], s.dhash, "v=0은 항등 변형이어야 한다"); + } + + #[test] + fn flipped_image_is_found_via_orient_set() { + let g = gradient(); + let flipped = transform(&g, FRAME, 2); // 좌우 반전 + let a = derive(&g).unwrap(); + let b = derive(&flipped).unwrap(); + // 전역 dhash는 다르지만 + assert!(hamming(a.dhash, b.dhash) > 4, "반전본은 전역 해시가 달라야 한다"); + // 8변형 집합으로는 찾아진다 + assert!( + min_orient_distance(&a.orient, b.dhash) <= 2, + "8변형 중 하나는 반전본과 일치해야 한다 (최소거리 {})", + min_orient_distance(&a.orient, b.dhash) + ); + } + + #[test] + fn rotations_are_covered_by_orient_set() { + let g = gradient(); + let a = derive(&g).unwrap(); + for v in 0..8u8 { + let t = transform(&g, FRAME, v); + let b = derive(&t).unwrap(); + assert!( + min_orient_distance(&a.orient, b.dhash) <= 2, + "변형 v={v}를 8변형 집합이 못 찾았다" + ); + } + } + + #[test] + fn solid_frames_are_not_indexable() { + let s = derive(&solid(0)).unwrap(); + assert!(!s.indexable(), "검정 프레임은 색인에서 빠져야 한다 (stdev {})", s.stdev); + let s2 = derive(&solid(200)).unwrap(); + assert!(!s2.indexable(), "단색 프레임도 빠져야 한다"); + assert!(derive(&gradient()).unwrap().indexable()); + } + + #[test] + fn bands_reconstruct_hash() { + let h = 0x1234_5678_9ABC_DEF0u64; + let b = bands(h); + let back = (b[0] as u64) | ((b[1] as u64) << 16) | ((b[2] as u64) << 32) | ((b[3] as u64) << 48); + assert_eq!(back, h); + } + + /// 거리 ≤3이면 최소 한 밴드가 일치한다 (밴드 색인의 미탐 없음 근거) + #[test] + fn band_pigeonhole_holds_for_distance_3() { + let base = 0xDEAD_BEEF_1234_5678u64; + for bits in [ + 0b1u64, + 0b11, + 0b111, + 1 << 17, + (1 << 3) | (1 << 20) | (1 << 40), + (1 << 15) | (1 << 16) | (1 << 63), + ] { + assert!(bits.count_ones() <= 3); + let other = base ^ bits; + let (a, b) = (bands(base), bands(other)); + assert!( + (0..4).any(|i| a[i] == b[i]), + "거리 {}에서 일치 밴드가 없다", + bits.count_ones() + ); + } + } + + /// 어느 테두리도 어둡지 않은 밝은 그라디언트 — 레터박스 판정 테스트의 기준 영상. + /// (기본 `gradient()`는 좌측이 어두워 세로 기둥으로 오인될 수 있다 — 그것도 정상 동작이다) + fn bright() -> Vec { + (0..FRAME_LEN) + .map(|i| (60 + (i % FRAME) * 150 / FRAME) as u8) + .collect() + } + + #[test] + fn letterbox_is_detected_and_trimmed() { + // 위아래 6줄이 검정인 프레임 + let mut g = bright(); + for y in 0..6 { + for x in 0..FRAME { + g[y * FRAME + x] = 0; + g[(FRAME - 1 - y) * FRAME + x] = 0; + } + } + let (x0, y0, x1, y1) = letterbox(&g); + assert_eq!((y0, y1), (6, FRAME - 6), "상하 레터박스를 찾아야 한다"); + assert_eq!((x0, x1), (0, FRAME), "좌우는 잘리지 않아야 한다"); + + // 테두리가 어둡지 않으면 아무것도 자르지 않는다 + assert_eq!(letterbox(&bright()), (0, 0, FRAME, FRAME)); + + // 레터박스 있는 것과 없는 것의 트림 해시가 전역 해시보다 나쁘지 않아야 한다 + let full = derive(&bright()).unwrap(); + let boxed = derive(&g).unwrap(); + assert!( + hamming(full.scales[2], boxed.scales[2]) <= hamming(full.dhash, boxed.dhash) + 2, + "트림 해시 거리 {} vs 전역 {}", + hamming(full.scales[2], boxed.scales[2]), + hamming(full.dhash, boxed.dhash) + ); + } + + #[test] + fn tile_agreement_localizes_change() { + let g = gradient(); + let mut watermarked = g.clone(); + // 우하단 타일 하나만 덮어쓴다 (워터마크 흉내) + for y in 24..32 { + for x in 24..32 { + watermarked[y * FRAME + x] = 255; + } + } + let a = derive(&g).unwrap(); + let b = derive(&watermarked).unwrap(); + let (all, _bottom) = tile_agreement(&a.tiles, &b.tiles, 6); + assert!(all >= 14, "16타일 중 14개 이상은 그대로여야 한다 (실제 {all})"); + } + + /// 두 개의 구별되는 '사진' — 그레이 구조가 서로 다르다. + fn photo(seed: u64) -> Vec { + let mut s = seed; + // 저주파 패턴 — 실사처럼 부드럽지만 구조가 뚜렷하다 + let mut out = vec![0u8; FRAME_LEN]; + let (a, b, c) = ((s % 7 + 1) as f32, ((s >> 3) % 5 + 1) as f32, ((s >> 6) % 11) as f32); + for y in 0..FRAME { + for x in 0..FRAME { + let fx = x as f32 / FRAME as f32; + let fy = y as f32 / FRAME as f32; + let v = (fx * a * 3.1).sin() * 60.0 + (fy * b * 3.1).cos() * 60.0 + c * 5.0 + 128.0; + out[y * FRAME + x] = v.clamp(0.0, 255.0) as u8; + } + } + s = s.wrapping_add(1); + let _ = s; + out + } + + /// 하단 `rows`줄 + 우상단 로고를 덮는다 (`wmbig` 픽스처와 같은 변형) + fn occlude(src: &[u8], rows: usize) -> Vec { + let mut out = src.to_vec(); + for y in (FRAME - rows)..FRAME { + for x in 0..FRAME { + out[y * FRAME + x] = 0; + } + } + for y in 0..(FRAME / 5) { + for x in (FRAME * 3 / 5)..FRAME { + out[y * FRAME + x] = 255; + } + } + out + } + + /// 가림이 큰 사본은 **자기 원본과** 맞아야 한다 (타일 다수결). + #[test] + fn verify_accepts_heavily_occluded_copy_of_same_photo() { + let p = photo(3); + let a = derive(&p).unwrap(); + let b = derive(&occlude(&p, FRAME * 3 / 10)).unwrap(); + let rich = |s: &FrameSig| RichSig { + dhash: s.dhash, + orient: s.orient, + tiles: s.tiles, + scales: s.scales, + tile_flat: s.tile_flat, + color: None, + }; + let v = verify_pair(&rich(&a), &rich(&b), 5, false).expect("자기 원본과는 맞아야 한다"); + // dHash로 이미 맞으면 Whole, 멀어졌으면 타일이 받아야 한다 + assert!( + matches!(v.via, MatchVia::Whole | MatchVia::Tiles | MatchVia::Crop), + "예상 밖 근거: {:?}", + v.via + ); + } + + /// **서로 다른** 사진에 같은 큰 가림을 씌운 것은 기각해야 한다. + /// (픽스처에서 실제로 잡힌 오탐 — 가림이 크면 남는 정보가 비슷해진다) + #[test] + fn verify_rejects_different_photos_with_same_occlusion() { + let (p, q) = (photo(3), photo(19)); + let rows = FRAME * 3 / 10; + let a = derive(&occlude(&p, rows)).unwrap(); + let b = derive(&occlude(&q, rows)).unwrap(); + let rich = |s: &FrameSig| RichSig { + dhash: s.dhash, + orient: s.orient, + tiles: s.tiles, + scales: s.scales, + tile_flat: s.tile_flat, + color: None, + }; + // 원본끼리는 확실히 다르다 (전제 확인) + let (oa, ob) = (derive(&p).unwrap(), derive(&q).unwrap()); + assert!(hamming(oa.dhash, ob.dhash) > 5, "테스트 전제: 두 사진은 서로 달라야 한다"); + assert_eq!( + verify_pair(&rich(&a), &rich(&b), 5, false), + None, + "가림이 크다고 서로 다른 사진을 같다고 하면 안 된다" + ); + } + + /// 회전본은 `allow_orient`일 때만 맞는다 — 기본은 후보 비용을 물지 않는다. + #[test] + fn verify_finds_rotation_only_when_enabled() { + let p = photo(5); + let rot = transform(&p, FRAME, 1); // 전치 + let a = derive(&p).unwrap(); + let b = derive(&rot).unwrap(); + let rich = |s: &FrameSig| RichSig { + dhash: s.dhash, + orient: s.orient, + tiles: s.tiles, + scales: s.scales, + tile_flat: s.tile_flat, + color: None, + }; + assert!(hamming(a.dhash, b.dhash) > 5, "회전하면 전체 해시는 멀어진다"); + assert_eq!(verify_pair(&rich(&a), &rich(&b), 5, false), None); + let v = verify_pair(&rich(&a), &rich(&b), 5, true).expect("변형 조회를 켜면 맞아야 한다"); + assert_eq!(v.via, MatchVia::Orient); + } + + #[test] + fn offset_vote_finds_shifted_subsequence() { + // b는 a의 10초~20초 구간 (오프셋 -10000: t_a = t_b + 10000) + let a: Vec<(u32, u64)> = (0..30).map(|i| (i * 1000, 0x1000_0000_0000_0000u64 + i as u64 * 0x11)).collect(); + let b: Vec<(u32, u64)> = (10..20) + .map(|i| ((i - 10) * 1000, 0x1000_0000_0000_0000u64 + i as u64 * 0x11)) + .collect(); + let m = offset_vote(&a, &b, 2, 500, 5).expect("부분 구간을 찾아야 한다"); + assert_eq!(m.offset_ms, 10_000, "오프셋 10초"); + assert!(m.support >= 8, "지지 프레임 {}", m.support); + assert_eq!(m.a_start_ms, 10_000); + assert_eq!(m.b_start_ms, 0); + } + + /// 배속본 — 절대 시각 투표로는 무너지고, 배속 후보를 넣으면 제대로 잡힌다. + #[test] + fn speed_changed_copy_is_found_with_scale_candidates() { + // a: 0~29초 매초 서로 다른 프레임 + let a: Vec<(u32, u64)> = + (0..30).map(|i| (i * 1000, 0x2000_0000_0000_0000u64 + i as u64 * 0x1357)).collect(); + // b: a를 1.25배속 — 같은 내용이 1/1.25 = 0.8배 시각에 나타난다 + let b: Vec<(u32, u64)> = a.iter().map(|(t, h)| (((*t as f32) * 0.8) as u32, *h)).collect(); + + // 배속을 모르면 구간이 크게 줄어든다 + let plain = offset_vote(&a, &b, 8, 500, 4).expect("어떻게든 표는 모인다"); + // 배속 후보를 주면 거의 전 구간이 맞는다 + let (scaled, f) = offset_vote_speed(&a, &b, 8, 500, 4).expect("배속본을 찾지 못했다"); + assert!((f - 1.25).abs() < 0.01, "배속을 1.25로 판정해야 한다 (실제 {f})"); + assert!( + scaled.support > plain.support * 2, + "배속을 맞추면 지지가 크게 늘어야 한다 ({} vs {})", + scaled.support, + plain.support + ); + assert!(scaled.a_span_ms() >= 25_000, "구간 {}ms", scaled.a_span_ms()); + } + + /// 같은 속도 사본에는 배속 1.0이 이겨야 한다 — 억지 해석을 우선하지 않는다. + #[test] + fn same_speed_copy_prefers_factor_one() { + let a: Vec<(u32, u64)> = + (0..30).map(|i| (i * 1000, 0x9000_0000_0000_0000u64 + i as u64 * 0x2468)).collect(); + let (m, f) = offset_vote_speed(&a, &a, 8, 500, 4).unwrap(); + assert_eq!(f, 1.0, "같은 속도인데 배속 {f}로 봤다"); + assert_eq!(m.offset_ms, 0); + } + + #[test] + fn offset_vote_rejects_unrelated_sequences() { + let a: Vec<(u32, u64)> = (0..20).map(|i| (i * 1000, 0xAAAA_AAAA_AAAA_AAAAu64 ^ (i as u64))).collect(); + let b: Vec<(u32, u64)> = (0..20).map(|i| (i * 1000, 0x5555_5555_5555_5555u64 ^ (i as u64))).collect(); + assert!(offset_vote(&a, &b, 4, 500, 5).is_none(), "무관한 시퀀스는 매칭되면 안 된다"); + } + + #[test] + fn scene_reps_pick_changes_only() { + // 같은 장면 5프레임 → 다른 장면 5프레임 + let mut frames: Vec> = (0..5).map(|_| solid(30)).collect(); + frames.extend((0..5).map(|_| solid(200))); + let reps = scene_reps(&frames, 20, 0); + assert_eq!(reps, vec![0, 5], "장면 전환 지점만 대표로 뽑아야 한다"); + } + + #[test] + fn scene_reps_are_capped() { + let frames: Vec> = (0..50).map(|i| solid((i * 5) as u8)).collect(); + let reps = scene_reps(&frames, 1, 8); + assert_eq!(reps.len(), 8, "상한을 넘으면 솎아내야 한다"); + assert_eq!(reps[0], 0); + } + + /// 값 기반 선택의 핵심 성질 — **오프셋이 밀려도 같은 항목이 선택된다.** + /// 고정 간격 샘플링은 이 성질이 없어 부분 구간 매칭이 무너진다. + #[test] + fn value_selection_is_offset_invariant() { + // 원본 시퀀스 + let full: Vec<(u32, u64)> = (0..400u32) + .map(|i| (i * 250, 0x9E37_79B9_7F4A_7C15u64.wrapping_mul(i as u64 + 1))) + .collect(); + // 사본은 3370ms 밀려 있고 100..300 구간만 담고 있다 + let clip: Vec<(u32, u64)> = full[100..300] + .iter() + .map(|(t, h)| (t - full[100].0 + 3370, *h)) + .collect(); + + let a = anchors(&full, 5); + let b = anchors(&clip, 5); + assert!(!a.is_empty() && !b.is_empty(), "앵커가 뽑혀야 한다 a={} b={}", a.len(), b.len()); + + // clip 구간에 해당하는 앵커는 양쪽에서 동일한 해시로 선택돼야 한다 + let a_set: std::collections::HashSet = a.iter().map(|(_, h)| *h).collect(); + let matched = b.iter().filter(|(_, h)| a_set.contains(h)).count(); + assert_eq!(matched, b.len(), "사본의 앵커는 전부 원본 앵커에 포함돼야 한다"); + + // 선택률이 대략 1/32 (표본이 작으니 넉넉하게) + let rate = a.len() as f64 / full.len() as f64; + assert!((0.005..0.12).contains(&rate), "선택률 {rate}"); + } + + #[test] + fn value_selection_is_deterministic_and_rate_scales() { + let vals: Vec = (0..20_000u64).map(|i| i.wrapping_mul(0x9E37_79B9_7F4A_7C15)).collect(); + let c4 = vals.iter().filter(|v| selected(**v, 4)).count(); + let c6 = vals.iter().filter(|v| selected(**v, 6)).count(); + assert!(c4 > c6, "선택률이 rate_log2에 따라 줄어야 한다 ({c4} vs {c6})"); + // 1/16 ≈ 1250, 1/64 ≈ 312 (±40%) + assert!((750..2100).contains(&c4), "1/16 선택 수 {c4}"); + assert!((180..520).contains(&c6), "1/64 선택 수 {c6}"); + // 결정적 + assert_eq!(selected(12345, 5), selected(12345, 5)); + assert!(selected(999, 0), "rate_log2=0이면 전부 선택"); + } + + #[test] + fn dedup_consecutive_folds_static_scenes() { + let seq = vec![(0u32, 7u64), (100, 7), (200, 7), (300, 9), (400, 9), (500, 7)]; + assert_eq!(dedup_consecutive(&seq), vec![(0, 7), (300, 9), (500, 7)]); + } + + #[test] + fn idf_downweights_common_tokens() { + let n = 10_000; + let rare = idf(1, n); + let common = idf(3_000, n); + assert!(rare > common * 3.0, "흔한 토큰의 가중이 충분히 낮아야 한다 ({rare} vs {common})"); + assert_eq!(idf(0, n), 0.0); + } + + #[test] + fn frame_blob_roundtrip() { + let seq = vec![(0u32, 1u64, 2u64), (1000, u64::MAX, 0), (2000, 42, 43)]; + let packed = pack_frames(&seq); + assert_eq!(packed.len(), seq.len() * FRAME_REC); + assert_eq!(unpack_frames(&packed), seq); + assert_eq!(dhash_seq(&seq), vec![(0u32, 1u64), (1000, u64::MAX), (2000, 42)]); + } + + #[test] + fn u64_blob_roundtrip() { + let v = vec![0u64, 1, u64::MAX, 0xDEAD_BEEF]; + assert_eq!(unpack_u64s(&pack_u64s(&v)), v); + } + + #[test] + fn box_resize_averages_blocks() { + // 32×32를 8×8로 → 각 출력 픽셀은 4×4 블록 평균 + let src: Vec = (0..FRAME_LEN).map(|i| (i % 256) as u8).collect(); + let out = box_resize(&src, FRAME, FRAME, 8, 8); + assert_eq!(out.len(), 64); + // 좌상단 블록 평균을 직접 계산해 비교 + let mut sum = 0u32; + for y in 0..4 { + for x in 0..4 { + sum += src[y * FRAME + x] as u32; + } + } + assert_eq!(out[0], (sum / 16) as u8); + } + + #[test] + fn resized_source_keeps_close_dhash() { + // 64×64 원본과 그것을 32×32로 줄인 것의 dhash가 가까워야 한다 + let big: Vec = (0..64 * 64) + .map(|i| { + let x = i % 64; + let y = i / 64; + ((x * 200 / 64) + (y * 40 / 64)) as u8 + }) + .collect(); + let small = box_resize(&big, 64, 64, FRAME, FRAME); + let d = hamming(dhash_of(&big, 64, 64), dhash_of(&small, FRAME, FRAME)); + assert!(d <= 4, "리사이즈 후 해밍 거리 {d}"); + } + + #[test] + fn phash_differs_for_different_structure() { + let a = phash_of(&gradient()); + let b = phash_of(&transform(&gradient(), FRAME, 1)); // 전치 + assert!(hamming(a, b) > 4, "구조가 다르면 pHash도 달라야 한다"); + } +} diff --git a/src-tauri/crates/archive-indexer/src/sigq.rs b/src-tauri/crates/archive-indexer/src/sigq.rs new file mode 100644 index 0000000..905a6bb --- /dev/null +++ b/src-tauri/crates/archive-indexer/src/sigq.rs @@ -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, +} + +struct Computed { + rep: FrameSig, + /// 조밀 프레임 시퀀스 (이미지는 비어 있다) + frames: Vec<(u32, u64, u64)>, + /// 값 기반으로 고른 앵커 부분집합 + anchors: Vec<(u32, u64, u64)>, + duration_ms: Option, + /// 4×4 색차 지문 — 이미지만. 영상은 그레이 파이프라 없다. + color: Option<[i8; 32]>, + /// 오디오 지문 앵커 (t_ms, chromaprint 값) — 오디오가 있는 영상만. + audio: Vec<(u32, u32)>, +} + +/// 32×32 그레이로 눌러 담는다 (종횡비 무시 — 모든 파일에 같은 규칙이라 비교에 문제없다). +fn gray32(img: &image::DynamicImage) -> Vec { + 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) -> Result { + let cur = sigmatch::SIG_VERSION; + let seen: Option = 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, + thumb_root: &Path, + cancel: &AtomicBool, + pause: &AtomicBool, +) -> Result { + 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 = 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 { + 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, + tools: &FfTools, + hwaccel: Option<&str>, + cancel: &AtomicBool, + pause: &AtomicBool, +) -> Result { + let mut total = 0u64; + loop { + if !wait_if_paused(cancel, pause) { + break; + } + // `meta_state <> 0`을 요구한다 — 오디오 유무(`acodec`)를 메타가 채우기 때문이다. + // 메타보다 먼저 돌면 오디오 지문 없이 sig_state=1이 박혀 영영 안 만들어진다. + // 실패(2)도 통과시킨다: 메타를 못 읽은 파일이라도 프레임 지문은 만들 수 있다. + let batch: Vec = 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>(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 { + 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 = match &pool { + Some(p) => p.install(compute), + None => compute(), + }; + + // kind=-1(취소로 건너뜀)은 쓰지 않는다 + let results: Vec = 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, + has_audio: bool, +} + +fn video_sig( + tools: &FfTools, + file_id: i64, + path: &Path, + duration_ms: Option, + has_audio: bool, + hwaccel: Option<&str>, +) -> Option { + // 손상 파일에서 워커가 영구 점유되지 않도록 길이에 비례한 상한을 둔다 + 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 = 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 = + 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, rows: Vec) -> 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) { + 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, Vec) = 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 = 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::, _>>()?; + 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 + ); + } +} diff --git a/src-tauri/crates/archive-indexer/src/tags.rs b/src-tauri/crates/archive-indexer/src/tags.rs index 6ba47ac..217b0b3 100644 --- a/src-tauri/crates/archive-indexer/src/tags.rs +++ b/src-tauri/crates/archive-indexer/src/tags.rs @@ -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 { + // 대부분의 호출은 이미 존재하는 경우 — 쓰기 락을 잡지 않도록 읽기 먼저. + let existing: Option = 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> { + 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> { 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, 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, +} + +pub fn file_tags_bulk(db: &Db, file_ids: Vec) -> Result> { + 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::>() + .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> { db.with_read(|conn| { let mut stmt = conn.prepare_cached( @@ -169,6 +268,143 @@ pub fn export_json(db: &Db) -> Result { 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, + /// 이 세트에 담긴 태그 (이름·색까지 — 화면이 칩으로 그린다) + pub tags: Vec, +} + +pub fn list_tag_sets(db: &Db) -> Result> { + db.with_read(|conn| { + let mut sets: Vec = 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::>()?; + // 세트 수는 많아야 수십 개다 — 한 번에 읽어 메모리에서 묶는다(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::>()?; + 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) -> Result { + 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, + hotkey: Option>, + tag_ids: Option>, +) -> 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, set_id: i64) -> Result { + let batch_id = batch_id.to_string(); + db.with_write(move |conn| { + let tx = conn.transaction()?; + let tag_ids: Vec = tx + .prepare("SELECT tag_id FROM tag_set_items WHERE set_id = ?1")? + .query_map([set_id], |r| r.get(0))? + .collect::>()?; + 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 = 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(); diff --git a/src-tauri/crates/archive-indexer/src/thumbq.rs b/src-tauri/crates/archive-indexer/src/thumbq.rs index 7111165..186d464 100644 --- a/src-tauri/crates/archive-indexer/src/thumbq.rs +++ b/src-tauri/crates/archive-indexer/src/thumbq.rs @@ -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, lo_tx: Sender, + ctx: Arc, + /// 피더 세대 — 새 피더가 시작되면 이전 피더는 스스로 물러난다 + feed_gen: Arc, } +/// ── 동시 처리 게이트 ──────────────────────────────────────────────────────── +/// 디스크 특성에 맞는 동시 읽기 개수는 미리 알 수 없다: 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, thumb_root: PathBuf, tools: Option, in_flight: Mutex>, - done_tx: Sender, + /// 결과를 쓰기 합치기 스레드로 보낸다 (워커는 writer를 기다리지 않는다) + write_tx: Sender, + gate: Gate, + /// 처리 시도 누적 — 게이트 자동조정의 유일한 입력 + processed: AtomicUsize, } impl ThumbQueue { /// 워커 풀을 시작한다. `on_done`은 완료된 file_id 배치를 받는다. /// `tools`가 None이면 영상 썸네일은 건너뛴다 (이미지는 정상 처리). + /// + /// `max_workers`는 스레드 수이자 동시 처리 상한이다. 실제 동시 처리 개수는 + /// 처리율을 보며 1..=max_workers 사이에서 자동으로 오르내린다([`Gate`]). pub fn start( db: Arc, thumb_root: PathBuf, tools: Option, - workers: usize, + max_workers: usize, on_done: impl Fn(Vec) + Send + 'static, ) -> ThumbQueue { let (hi_tx, hi_rx) = unbounded::(); let (lo_tx, lo_rx) = unbounded::(); + let (write_tx, write_rx) = unbounded::(); let (done_tx, done_rx) = unbounded::(); + 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 = 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) { - let ids: Vec = 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) { + 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, _> = 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) { + 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, done_tx: &Sender) { + if batch.is_empty() { + return; + } + let ok_ids: Vec = 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, hi_rx: Receiver, lo_rx: Receiver) { // 썸네일 디코드/리사이즈는 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, hi_rx: Receiver, lo_rx: Receiver) { 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 { } }; + // 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::(); 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); diff --git a/src-tauri/crates/archive-indexer/src/thumbs.rs b/src-tauri/crates/archive-indexer/src/thumbs.rs index 0f22291..73b1d62 100644 --- a/src-tauri/crates/archive-indexer/src/thumbs.rs +++ b/src-tauri/crates/archive-indexer/src/thumbs.rs @@ -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: &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 { - 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 { + decode_jpeg_scaled_reader(std::io::Cursor::new(bytes), target) +} + +fn decode_jpeg_scaled_reader(r: R, target: u32) -> Option { + 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> { + 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 { + 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 섞인 데이터에서 큰 것을 고른다 diff --git a/src-tauri/crates/archive-indexer/tests/edit_files.rs b/src-tauri/crates/archive-indexer/tests/edit_files.rs new file mode 100644 index 0000000..8cc9d54 --- /dev/null +++ b/src-tauri/crates/archive-indexer/tests/edit_files.rs @@ -0,0 +1,490 @@ +//! 회전·반전을 **실제 파일에** 적용해 본다. +//! +//! 단위 테스트는 바이트 조작과 방향값 계산까지만 본다. 실제로는 +//! 앱의 EXIF 리더가 내가 쓴 값을 못 읽거나, ffmpeg 회전 부호가 반대이거나, +//! 임시 파일 바꿔치기가 실패하는 식으로 죽는다. +//! +//! 그리고 **무손실이라는 주장을 실제로 검산한다** — 화상 데이터가 한 바이트도 +//! 안 바뀌었는지, 영상은 스트림 복사가 맞는지. + +use archive_indexer::edit::*; +use archive_indexer::ffmpeg::{self, FfTools}; +use archive_indexer::thumbs; +use std::path::{Path, PathBuf}; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap() +} + +fn tools() -> Option { + let bin = repo_root().join("src-tauri/binaries"); + let triple = if cfg!(all(windows, target_arch = "x86_64")) { + "x86_64-pc-windows-msvc" + } else { + "aarch64-pc-windows-msvc" + }; + let ext = if cfg!(windows) { ".exe" } else { "" }; + let (a, b) = ( + bin.join(format!("ffmpeg-{triple}{ext}")), + bin.join(format!("ffprobe-{triple}{ext}")), + ); + (a.is_file() && b.is_file()).then_some(FfTools { ffmpeg: a, ffprobe: b }) +} + +fn workdir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join("archive-edit-test").join(name); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// 사진 회전은 **앱이 실제로 쓰는 EXIF 리더**가 읽을 수 있어야 한다. +/// 내가 쓴 바이트를 나만 읽을 수 있으면 썸네일이 안 돌아간다. +#[test] +fn jpeg_rotation_is_lossless_and_readable() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let dir = workdir("jpeg"); + let src = dir.join("photo.jpg"); + let out = ffmpeg::command(&t.ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=800x600:rate=1:d=1"]) + .args(["-frames:v", "1", "-q:v", "3"]) + .arg(&src) + .output() + .expect("ffmpeg 실행 실패"); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + + let before = std::fs::read(&src).unwrap(); + let start = thumbs::read_orientation(&src); + println!("원본 방향 = {start}"); + + // 시계 90° + let r = rotate_image(&src, start as u8, EditOp::RotateCw).expect("회전 실패"); + assert!(!r.reencoded, "JPEG인데 다시 인코딩했다"); + assert!(r.swapped, "90° 회전은 가로·세로가 바뀐다"); + + // 앱의 리더가 새 값을 읽는가 + let now = thumbs::read_orientation(&src); + assert_eq!(now as u8, apply_orientation(start as u8, EditOp::RotateCw), "EXIF 값이 안 맞는다"); + println!("회전 후 방향 = {now}"); + + // 화상 데이터는 그대로인가 — 길이가 같고 바뀐 바이트가 한 개여야 한다 + let after = std::fs::read(&src).unwrap(); + if before.len() == after.len() { + let diff = before.iter().zip(&after).filter(|(a, b)| a != b).count(); + assert_eq!(diff, 1, "바뀐 바이트가 {diff}개 — 무손실이 아니다"); + println!("바뀐 바이트 1개 (EXIF 방향 태그) — 무손실 확인"); + } else { + // EXIF가 없던 파일이면 세그먼트가 삽입되어 길어진다. 그래도 화상 데이터는 그대로여야 한다. + let grew = after.len() - before.len(); + assert_eq!(&after[after.len() - (before.len() - 2)..], &before[2..], "화상 데이터가 바뀌었다"); + println!("EXIF {grew}바이트 삽입 — 화상 데이터는 그대로"); + } + + // 이미 한 번 돌렸으니 세 번 더 = 네 바퀴 → 원래 방향으로 돌아와야 한다 + for _ in 0..3 { + let cur = thumbs::read_orientation(&src) as u8; + rotate_image(&src, cur, EditOp::RotateCw).expect("회전 실패"); + } + assert_eq!(thumbs::read_orientation(&src), start, "네 바퀴 돌렸는데 제자리가 아니다"); + println!("네 바퀴 → 원래 방향 {start} 복귀"); +} + +/// 영상 회전은 스트림 복사여야 한다 — 다시 인코딩하면 30분짜리가 몇 분씩 걸린다. +#[test] +fn video_rotation_is_stream_copy() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let dir = workdir("video"); + let src = dir.join("clip.mp4"); + let out = ffmpeg::command(&t.ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=640x480:rate=10:d=3"]) + .args(["-f", "lavfi", "-i", "sine=frequency=440:d=3"]) + .args(["-c:v", "libopenh264", "-b:v", "1M", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"]) + .arg(&src) + .output() + .expect("ffmpeg 실행 실패"); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let size_before = std::fs::metadata(&src).unwrap().len(); + assert_eq!(video_rotation(&t, &src), 0, "새로 만든 영상은 회전이 0이어야 한다"); + + // 90° — 메타데이터만 + let r = rotate_video(&t, &src, EditOp::RotateCw, "libopenh264").expect("회전 실패"); + assert!(!r.reencoded, "스트림 복사가 아니다"); + assert_eq!(r.rotation, Some(90)); + assert_eq!(video_rotation(&t, &src), 90, "파일에 90°가 안 적혔다"); + let size_after = std::fs::metadata(&src).unwrap().len(); + // 회전 행렬만 바뀌므로 크기 차이는 무시할 만해야 한다 + let delta = (size_after as i64 - size_before as i64).abs(); + assert!(delta < 4096, "크기가 {delta}바이트 바뀌었다 — 재인코딩된 것 같다"); + println!("영상 90° 회전 — 크기 차이 {delta}바이트 (스트림 복사)"); + + // 한 번 더 돌리면 180°가 되어야 한다 (덮어쓰기가 아니라 누적) + rotate_video(&t, &src, EditOp::RotateCw, "libopenh264").expect("회전 실패"); + assert_eq!(video_rotation(&t, &src), 180, "두 번 돌렸는데 누적되지 않았다"); + println!("두 번째 회전 후 180° — 누적 확인"); + + // 소리는 살아 있는가 + let info = ffmpeg::probe(&t, &src).unwrap(); + assert!(info.acodec.is_some(), "소리가 사라졌다"); +} + +/// 반전은 회전 행렬로 표현할 수 없어 다시 인코딩한다 — 그래도 결과가 나와야 한다. +#[test] +fn video_flip_reencodes_but_works() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let dir = workdir("flip"); + let src = dir.join("clip.mp4"); + let out = ffmpeg::command(&t.ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=10:d=2"]) + .args(["-c:v", "libopenh264", "-b:v", "500k", "-pix_fmt", "yuv420p"]) + .arg(&src) + .output() + .expect("ffmpeg 실행 실패"); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + + let r = rotate_video(&t, &src, EditOp::FlipH, "libopenh264").expect("반전 실패"); + assert!(r.reencoded, "반전은 다시 인코딩해야 한다"); + let info = ffmpeg::probe(&t, &src).unwrap(); + assert_eq!((info.width, info.height), (Some(320), Some(240)), "반전은 크기를 안 바꾼다"); + println!("영상 좌우반전 — 재인코딩 (회전 행렬로는 표현 불가)"); +} + +/// 실패해도 원본이 반쯤 쓰이면 안 된다 — 쓸 수 없는 경로에 대고 확인한다. +#[test] +fn failure_leaves_original_intact() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let dir = workdir("fail"); + // 영상이 아닌 파일을 영상 회전에 넘긴다 — ffmpeg이 거부해야 한다 + let src = dir.join("notvideo.mp4"); + std::fs::write(&src, b"this is not a video").unwrap(); + let before = std::fs::read(&src).unwrap(); + + let r = rotate_video(&t, &src, EditOp::RotateCw, "libopenh264"); + assert!(r.is_err(), "잘못된 입력인데 성공했다"); + assert_eq!(std::fs::read(&src).unwrap(), before, "실패했는데 원본이 바뀌었다"); + // 임시 파일도 남으면 안 된다 + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n.contains("tmp-edit")) + .collect(); + assert!(leftovers.is_empty(), "임시 파일이 남았다: {leftovers:?}"); + println!("실패 시 원본 보존 · 임시 파일 없음 확인"); +} + +/// **"시계 방향"이 정말 시계 방향인가.** +/// +/// 회전 각도를 읽고 쓰는 것만 확인하면 부호가 반대여도 테스트가 통과한다 — +/// 그러면 사용자가 오른쪽 회전을 눌렀는데 왼쪽으로 돈다. 그래서 실제 화소로 검산한다. +/// 기준은 ffmpeg의 `transpose=1`(= 시계 90°)로 직접 돌린 결과다. +#[test] +fn clockwise_actually_turns_clockwise() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let dir = workdir("direction"); + // 네 모서리 색이 다른 영상 — 어느 쪽으로 돌았는지 화소로 구별된다 + let src = dir.join("corners.mp4"); + let lavfi = "color=c=red:s=64x64,pad=128:128:0:0:black[a];\ + color=c=lime:s=64x64[b];[a][b]overlay=64:0"; + let out = ffmpeg::command(&t.ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", &format!("{lavfi},format=yuv420p,trim=duration=1")]) + .args(["-c:v", "libopenh264", "-b:v", "500k", "-r", "10"]) + .arg(&src) + .output() + .expect("ffmpeg 실행 실패"); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + + // 기준: transpose=1 로 실제 화소를 시계 90° 돌린 결과 + let want = dir.join("want.png"); + let o = ffmpeg::command(&t.ffmpeg) + .args(["-v", "error", "-y", "-noautorotate"]) + .arg("-i").arg(&src) + .args(["-vf", "transpose=1", "-frames:v", "1"]) + .arg(&want) + .output() + .expect("ffmpeg 실행 실패"); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + + // 우리 코드로 메타데이터만 회전 + rotate_video(&t, &src, EditOp::RotateCw, "libopenh264").expect("회전 실패"); + + // 회전을 적용해(기본 autorotate) 한 프레임 뽑는다 + let got = dir.join("got.png"); + let o = ffmpeg::command(&t.ffmpeg) + .args(["-v", "error", "-y"]) + .arg("-i").arg(&src) + .args(["-frames:v", "1"]) + .arg(&got) + .output() + .expect("ffmpeg 실행 실패"); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + + let a = image::open(&want).unwrap().to_rgb8(); + let b = image::open(&got).unwrap().to_rgb8(); + assert_eq!(a.dimensions(), b.dimensions(), "회전 후 크기가 기준과 다르다"); + + // 압축 손실이 있으므로 완전 일치는 못 한다 — 평균 차이로 본다 + let n = (a.width() * a.height() * 3) as f64; + let diff: f64 = a.as_raw().iter().zip(b.as_raw()).map(|(x, y)| (*x as f64 - *y as f64).abs()).sum::() / n; + println!("시계 90° — 기준(transpose=1) 대비 평균 화소 차이 {diff:.1}"); + assert!(diff < 12.0, "시계 방향이 아니다 — 평균 차이 {diff:.1}"); +} + +/// **회전된 사진을 자를 때 좌표가 맞는가.** +/// +/// 사용자는 화면에서 사각형을 끈다. 그런데 EXIF 방향이 붙어 있으면 저장된 화소 배치와 +/// 화면 배치가 다르다 — 그대로 자르면 엉뚱한 데가 잘린다. +/// 기준은 ffmpeg이다: ffmpeg은 EXIF를 적용해 디코드하므로, 거기서 같은 좌표로 자른 결과와 +/// 우리 결과가 같아야 한다. +#[test] +fn crop_uses_display_coordinates_on_rotated_image() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let dir = workdir("crop-img"); + // 좌우가 확실히 다른 그림 — 어디를 잘랐는지 화소로 구별된다 + let src = dir.join("p.jpg"); + let lavfi = "color=c=red:s=200x100,pad=400:100:0:0:blue"; + let out = ffmpeg::command(&t.ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", &format!("{lavfi},format=yuvj420p")]) + .args(["-frames:v", "1", "-q:v", "2"]) + .arg(&src) + .output() + .expect("ffmpeg 실행 실패"); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + + // 시계 90° 회전 → 화면상 100×400 이 된다 + rotate_image(&src, 1, EditOp::RotateCw).expect("회전 실패"); + let o = thumbs::read_orientation(&src) as u8; + assert_eq!(o, 6, "EXIF 방향이 6(시계90°)이어야 한다"); + + // 기준: ffmpeg으로 같은 영역을 자른다 (ffmpeg은 EXIF를 적용해 디코드한다) + let want = dir.join("want.png"); + let r = ffmpeg::command(&t.ffmpeg) + .args(["-v", "error", "-y"]) + .arg("-i").arg(&src) + .args(["-vf", "crop=100:120:0:0", "-frames:v", "1"]) + .arg(&want) + .output() + .expect("ffmpeg 실행 실패"); + assert!(r.status.success(), "{}", String::from_utf8_lossy(&r.stderr)); + let wi = image::open(&want).unwrap().to_rgb8(); + assert_eq!(wi.dimensions(), (100, 120), "기준이 화면 좌표로 잘리지 않았다"); + + // 우리 코드로 같은 영역을 자른다 + let res = crop_image(&src, CropRect { x: 0, y: 0, w: 100, h: 120 }, o, None).expect("자르기 실패"); + assert_eq!(res.new_size, Some((100, 120))); + assert_eq!(res.orientation, Some(1), "방향을 화소에 구워 넣었으면 태그는 1이어야 한다"); + let gi = image::open(&src).unwrap().to_rgb8(); + assert_eq!(gi.dimensions(), (100, 120), "잘린 파일 크기가 다르다"); + + let n = (100 * 120 * 3) as f64; + let diff: f64 = wi.as_raw().iter().zip(gi.as_raw()).map(|(a, b)| (*a as f64 - *b as f64).abs()).sum::() / n; + println!("회전된 사진 자르기 — ffmpeg 기준 대비 평균 화소 차이 {diff:.1}"); + assert!(diff < 12.0, "엉뚱한 곳을 잘랐다 — 평균 차이 {diff:.1}"); +} + +/// 영상 영역 자르기 — 회전 메타가 있어도 화면 좌표로 자르고, 결과에는 회전이 남지 않는다. +#[test] +fn video_crop_uses_display_coordinates() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let dir = workdir("crop-vid"); + let src = dir.join("v.mp4"); + let out = ffmpeg::command(&t.ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=640x480:rate=10:d=1"]) + .args(["-c:v", "libopenh264", "-b:v", "500k", "-pix_fmt", "yuv420p"]) + .arg(&src) + .output() + .expect("ffmpeg 실행 실패"); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + // 회전을 걸어 화면상 480×640으로 만든다 + rotate_video(&t, &src, EditOp::RotateCw, "libopenh264").expect("회전 실패"); + + // 400×600 은 화면(480×640)에만 들어간다 — 저장(640×480) 기준이면 실패할 크기 + let r = crop_video(&t, &src, CropRect { x: 0, y: 0, w: 400, h: 600 }, "libopenh264", 800, None) + .expect("자르기 실패"); + assert_eq!(r.new_size, Some((400, 600))); + let info = ffmpeg::probe(&t, &src).unwrap(); + assert_eq!((info.width, info.height), (Some(400), Some(600)), "화면 좌표로 잘리지 않았다"); + assert_eq!(video_rotation(&t, &src), 0, "회전이 남아 있으면 재생기가 한 번 더 돌린다"); + println!("영상 영역 자르기 400×600 — 회전 메타 제거됨"); +} + +/// 구간 자르기는 스트림 복사 — 무손실이고 소리도 함께 잘린다. +#[test] +fn trim_is_lossless_and_keeps_audio() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let dir = workdir("trim"); + let src = dir.join("v.mp4"); + // 4초 · 소리 있음 — 구간 자르기가 소리도 같이 자르는지 봐야 한다 + let out = ffmpeg::command(&t.ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=10:d=4"]) + .args(["-f", "lavfi", "-i", "sine=frequency=440:d=4"]) + .args(["-c:v", "libopenh264", "-b:v", "500k", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"]) + .arg(&src) + .output() + .expect("ffmpeg 실행 실패"); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let before = ffmpeg::probe(&t, &src).unwrap(); + assert!(before.duration_ms.unwrap_or(0) > 3000); + + let r = trim_video(&t, &src, 1000, 3000, None).expect("구간 자르기 실패"); + assert!(!r.reencoded, "구간 자르기는 무손실이어야 한다"); + let after = ffmpeg::probe(&t, &src).unwrap(); + let d = after.duration_ms.unwrap_or(0); + assert!((d - 2000).abs() < 300, "2초를 요청했는데 {d}ms 다"); + assert!(after.acodec.is_some(), "소리가 사라졌다"); + assert_eq!(after.vcodec, before.vcodec, "영상이 재인코딩됐다"); + assert_eq!(after.acodec, before.acodec, "소리가 재인코딩됐다"); + println!("구간 자르기 {d}ms — 스트림 복사, 소리 보존"); + + // 끝이 시작보다 앞이면 거부 + assert!(trim_video(&t, &src, 2000, 1000, None).is_err()); +} + +/// 자를 영역은 이미지 밖으로 나가지 않고 항상 짝수다. +#[test] +fn crop_rect_is_clamped_and_even() { + for (iw, ih) in [(640u32, 480u32), (101, 103), (4, 4)] { + for r in [ + CropRect { x: 0, y: 0, w: 99999, h: 99999 }, + CropRect { x: 99999, y: 99999, w: 10, h: 10 }, + CropRect { x: 3, y: 5, w: 7, h: 9 }, + ] { + if let Some(c) = r.clamp_to(iw, ih) { + assert_eq!(c.w % 2, 0, "{c:?}"); + assert_eq!(c.h % 2, 0, "{c:?}"); + assert!(c.x + c.w <= iw, "{c:?} > {iw}"); + assert!(c.y + c.h <= ih, "{c:?} > {ih}"); + } + } + } +} + +/// 자르기는 되돌릴 수 없다 — 그래서 원본을 **복사하지 않고 옮겨** 보관한다. +/// 백업이 있으면 원본 바이트가 그대로 남아 있어야 하고, 없으면 그냥 덮어써야 한다. +#[test] +fn crop_moves_original_to_backup() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let dir = workdir("backup"); + let src = dir.join("p.jpg"); + let out = ffmpeg::command(&t.ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=400x300:rate=1:d=1"]) + .args(["-frames:v", "1", "-q:v", "2"]) + .arg(&src) + .output() + .expect("ffmpeg 실행 실패"); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let original = std::fs::read(&src).unwrap(); + + let bk = dir.join(".trash").join("p.jpg"); + crop_image(&src, CropRect { x: 10, y: 10, w: 200, h: 100 }, 1, Some(&bk)).expect("자르기 실패"); + + assert!(bk.is_file(), "백업이 없다"); + assert_eq!(std::fs::read(&bk).unwrap(), original, "백업이 원본과 다르다"); + let cropped = image::open(&src).unwrap(); + assert_eq!((cropped.width(), cropped.height()), (200, 100)); + println!("자르기 → 원본은 백업으로 이동, 새 파일 200×100"); + + // 백업에서 되돌리면 원본 그대로여야 한다 + std::fs::rename(&bk, &src).unwrap(); + assert_eq!(std::fs::read(&src).unwrap(), original, "되돌린 파일이 원본과 다르다"); + println!("백업 복원 — 바이트 단위로 원본과 동일"); +} + +/// 새 파일을 못 앉히면 원본이 제자리로 돌아와야 한다 — 사라지면 안 된다. +#[test] +fn failed_placement_restores_the_original() { + let dir = workdir("place-fail"); + let src = dir.join("p.jpg"); + std::fs::write(&src, b"original bytes").unwrap(); + // 백업 경로를 폴더로 만들어 두면 rename(dst → backup)이 실패한다 + let bk = dir.join("busy"); + std::fs::create_dir_all(bk.join("sub")).unwrap(); + + let r = crop_image(&src, CropRect { x: 0, y: 0, w: 10, h: 10 }, 1, Some(&bk)); + assert!(r.is_err(), "잘못된 백업 경로인데 성공했다"); + assert_eq!(std::fs::read(&src).unwrap(), b"original bytes", "원본이 사라졌다"); + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n.contains("tmp-edit")) + .collect(); + assert!(leftovers.is_empty(), "임시 파일이 남았다: {leftovers:?}"); + println!("배치 실패 시 원본 보존 · 임시 파일 없음"); +} + +/// **편집한 파일이 실제로 디코드되는가.** +/// +/// ffprobe는 컨테이너만 읽는다 — 헤더가 멀쩡하면 비트스트림이 깨져도 정상으로 보인다. +/// 실제로 반전 경로가 `-b:v 0k`로 재인코딩해 **재생 불가능한 파일**을 만든 적이 있는데, +/// 크기·코덱·길이를 확인하는 테스트는 전부 통과했다. 그래서 화소까지 뽑아 본다. +fn decodes_ok(t: &FfTools, p: &Path) -> bool { + let out = ffmpeg::command(&t.ffmpeg) + .args(["-v", "error", "-nostdin", "-xerror"]) + .arg("-i") + .arg(p) + .args(["-an", "-sn", "-vf", "fps=2,scale=32:32,format=gray", "-f", "rawvideo", "-"]) + .output(); + match out { + Ok(o) => o.status.success() && !o.stdout.is_empty() && o.stderr.is_empty(), + Err(_) => false, + } +} + +#[test] +fn every_edit_leaves_a_decodable_file() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let ops: [(&str, EditOp); 5] = [ + ("시계90", EditOp::RotateCw), + ("반시계90", EditOp::RotateCcw), + ("180", EditOp::Rotate180), + ("좌우반전", EditOp::FlipH), + ("상하반전", EditOp::FlipV), + ]; + for (name, op) in ops { + let dir = workdir(&format!("decodable-{}", op as u8 as char as u32)); + let src = dir.join("v.mp4"); + let out = ffmpeg::command(&t.ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=10:d=2"]) + .args(["-c:v", "libopenh264", "-b:v", "500k", "-pix_fmt", "yuv420p"]) + .arg(&src) + .output() + .expect("ffmpeg 실행 실패"); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + + // 라이브러리가 실제로 고르는 인코더로 — 하드웨어 경로에서만 깨질 수도 있다 + let enc = h264_encoder(&t); + let r = rotate_video(&t, &src, op, enc).unwrap_or_else(|e| panic!("{name} 실패: {e}")); + assert!(decodes_ok(&t, &src), "{name} 뒤 파일이 디코드되지 않는다 (인코더 {enc})"); + println!("{name:<10} 재인코딩={} · 디코드 정상", r.reencoded); + } +} diff --git a/src-tauri/crates/archive-indexer/tests/ffmpeg_integration.rs b/src-tauri/crates/archive-indexer/tests/ffmpeg_integration.rs index ed17935..6729f70 100644 --- a/src-tauri/crates/archive-indexer/tests/ffmpeg_integration.rs +++ b/src-tauri/crates/archive-indexer/tests/ffmpeg_integration.rs @@ -2,6 +2,7 @@ //! (없으면 조용히 통과 — CI에서 fetch-ffmpeg 후 실행하면 전체 커버) use archive_indexer::ffmpeg::{self, FfTools}; +use archive_indexer::sigmatch; use std::path::{Path, PathBuf}; fn tools() -> Option { @@ -66,3 +67,205 @@ fn probe_and_thumbnail_roundtrip() { assert!(thumb.is_file()); assert!(w == 256 || h == 256, "긴 변이 256이어야 함: {w}x{h}"); } + +// ── 지문 추출 (단일 패스) ───────────────────────────────── + +const ENC: &str = if cfg!(windows) { "h264_mf" } else { "h264_videotoolbox" }; + +/// 합성 영상을 만든다. `extra`는 입력 뒤에 붙는 추가 인자 +/// (두 번째 입력을 붙이는 호출이 있으므로 여기서 `-t` 같은 입력 옵션을 끼워 넣으면 안 된다). +fn make_video(t: &FfTools, out: &Path, secs: u32, extra: &[&str]) { + let src = format!("testsrc2=duration={secs}:size=320x240:rate=25"); + let mut cmd = ffmpeg::command(&t.ffmpeg); + cmd.args(["-y", "-v", "error", "-f", "lavfi", "-i", &src]); + cmd.args(extra); + cmd.args(["-c:v", ENC, "-b:v", "300k"]).arg(out); + let status = cmd.status().expect("ffmpeg 실행 실패"); + assert!(status.success(), "테스트 영상 생성 실패: {}", out.display()); +} + +/// 프레임마다 구조가 크게 달라지는 영상 — **오프셋 투표를 검증할 때는 이걸 써야 한다.** +/// +/// testsrc2는 320×240을 32×32 그레이로 줄여 9×8 dHash를 뽑으면 프레임끼리 전부 해밍 8 안에 +/// 들어온다(실측: 24프레임 중 고유 19개인데 **모든 오프셋 버킷이 똑같이 10표**). 즉 정렬을 +/// 판별할 정보가 남지 않아 어느 오프셋이 1위가 되는지는 우연이다. +/// 확대되는 mandelbrot은 매 프레임이 달라 오프셋이 유일하게 정해진다(4000ms×10 vs 3500ms×9). +fn make_diverse_video(t: &FfTools, out: &Path, secs: u32) { + let mut cmd = ffmpeg::command(&t.ffmpeg); + // mandelbrot은 무한 소스라 duration이 없다 → -t로 끊는다 + cmd.args(["-y", "-v", "error", "-f", "lavfi", "-i", "mandelbrot=size=320x240:rate=25"]); + cmd.args(["-t", &secs.to_string()]); + cmd.args(["-c:v", ENC, "-b:v", "300k"]).arg(out); + let status = cmd.status().expect("ffmpeg 실행 실패"); + assert!(status.success(), "테스트 영상 생성 실패: {}", out.display()); +} + +#[test] +fn gray_sequence_is_single_pass_and_uniformly_sampled() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let dir = tempfile::tempdir().unwrap(); + let video = dir.path().join("샘플.mp4"); + make_video(&t, &video, 6, &[]); + + let frames = ffmpeg::frame_gray_sequence( + &t, + &video, + 1000, // 1초 간격 + sigmatch::FRAME as u32, + std::time::Duration::from_secs(60), + None, + ) + .expect("프레임 시퀀스 추출 실패"); + + // 6초 영상에서 1초 간격 → 6장 안팎 + assert!((5..=7).contains(&frames.len()), "프레임 수 {}", frames.len()); + for (i, (t_ms, px)) in frames.iter().enumerate() { + assert_eq!(px.len(), sigmatch::FRAME_LEN, "프레임 크기가 32×32여야 한다"); + assert_eq!(*t_ms, i as u32 * 1000, "시각은 균등 간격이어야 한다"); + assert!(sigmatch::derive(px).is_some(), "파생 지문 계산 실패"); + } + + // testsrc2는 시간에 따라 화면이 변한다 → 연속 프레임의 해시가 달라야 한다 + let a = sigmatch::derive(&frames[0].1).unwrap(); + let b = sigmatch::derive(&frames[frames.len() - 1].1).unwrap(); + assert!( + sigmatch::hamming(a.dhash, b.dhash) > 0, + "서로 다른 장면인데 해시가 같다" + ); +} + +/// 이번 고도화의 핵심 검증 — **긴 영상에서 잘라낸 구간을 재인코딩해도 찾아내는지.** +/// (예전 구현은 duration ±5% 프리필터 때문에 이 케이스를 원천적으로 놓쳤다) +/// +/// 소재는 mandelbrot이어야 한다 — 이유는 `make_diverse_video` 주석 참조. +#[test] +fn clipped_segment_is_matched_with_correct_offset() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let dir = tempfile::tempdir().unwrap(); + let full = dir.path().join("원본.mp4"); + make_diverse_video(&t, &full, 12); + + // 원본의 4초~9초 구간을 다시 인코딩 (바이트는 완전히 다르다) + let clip = dir.path().join("구간.mp4"); + let status = ffmpeg::command(&t.ffmpeg) + .args(["-y", "-v", "error", "-ss", "4", "-t", "5"]) + .arg("-i") + .arg(&full) + .args(["-c:v", ENC, "-b:v", "200k"]) + .arg(&clip) + .status() + .expect("ffmpeg 실행 실패"); + assert!(status.success(), "구간 추출 실패"); + + let seq = |p: &Path| -> Vec<(u32, u64)> { + ffmpeg::frame_gray_sequence(&t, p, 500, sigmatch::FRAME as u32, std::time::Duration::from_secs(120), None) + .expect("프레임 시퀀스 추출 실패") + .iter() + .filter_map(|(ms, px)| sigmatch::derive(px).map(|s| (*ms, s.dhash))) + .collect() + }; + let a = seq(&full); + let b = seq(&clip); + assert!(a.len() >= 20 && b.len() >= 8, "a={} b={}", a.len(), b.len()); + + let m = sigmatch::offset_vote(&a, &b, 8, 500, 4).expect("부분 구간을 찾지 못했다"); + assert!( + (3500..=4500).contains(&m.offset_ms), + "오프셋이 4초 근처여야 한다 (실제 {}ms, 지지 {})", + m.offset_ms, + m.support + ); + assert!(m.a_span_ms() >= 3000, "일치 구간이 3초 이상이어야 한다 ({}ms)", m.a_span_ms()); +} + +/// 오디오 지문 — 번들 ffmpeg의 chromaprint로 새 의존성 없이 얻는다. +#[test] +fn audio_fingerprint_is_available_and_partial_matchable() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let dir = tempfile::tempdir().unwrap(); + let with_audio = dir.path().join("소리있음.mp4"); + make_video( + &t, + &with_audio, + 20, + &["-f", "lavfi", "-i", "sine=frequency=440:duration=20", "-c:a", "aac", "-shortest"], + ); + + let fp = ffmpeg::audio_fingerprint(&t, &with_audio, std::time::Duration::from_secs(60)) + .expect("오디오 지문 실패"); + // 실측 기준 약 7.9개/초 + assert!(fp.len() >= 100, "20초 오디오의 지문 항목 수 {}", fp.len()); + + // 오디오가 없는 영상은 빈 지문 (오류가 아니어야 한다) + let silent = dir.path().join("소리없음.mp4"); + make_video(&t, &silent, 2, &[]); + let empty = ffmpeg::audio_fingerprint(&t, &silent, std::time::Duration::from_secs(60)) + .expect("무성 영상에서 오류가 나면 안 된다"); + assert!(empty.is_empty(), "무성 영상은 빈 지문이어야 한다 ({}개)", empty.len()); +} + +/// 프레임과 오디오 지문을 **한 번의 디코드**로 받는다 — NAS에서 파일을 두 번 읽지 않기 위해서다. +/// +/// 여기서 고정하는 실패 모드: 오디오 스트림이 없는 파일에 chromaprint 출력을 붙이면 ffmpeg가 +/// `Output file does not contain any stream`으로 **출력 초기화에서** 죽어 프레임까지 0장이 된다. +/// 그래서 호출부는 오디오가 있다고 아는 파일에만 켜고, 그래도 실패하면 오디오 없이 재시도한다. +#[test] +fn one_decode_yields_both_frames_and_audio_fingerprint() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let dir = tempfile::tempdir().unwrap(); + let timeout = std::time::Duration::from_secs(120); + + let with_audio = dir.path().join("소리있음.mp4"); + make_video( + &t, + &with_audio, + 10, + &["-f", "lavfi", "-i", "sine=frequency=440:duration=10", "-c:a", "aac", "-shortest"], + ); + let fp_out = dir.path().join("fp.raw"); + let (frames, fp) = + ffmpeg::frame_gray_sequence_with_audio(&t, &with_audio, 1000, 32, timeout, None, Some(&fp_out)) + .expect("한 패스 추출 실패"); + assert!(frames.len() >= 8, "10초에서 프레임 {}장", frames.len()); + assert!(fp.len() >= 50, "10초 오디오 지문 {}개", fp.len()); + assert!(!fp_out.exists(), "임시 지문 파일은 읽은 뒤 지워야 한다"); + + // 오디오가 없는 파일에 지문 출력을 붙여도 **프레임은 살아야 한다** (폴백 경로) + let silent = dir.path().join("소리없음.mp4"); + make_video(&t, &silent, 4, &[]); + let fp_out2 = dir.path().join("fp2.raw"); + let (frames2, fp2) = + ffmpeg::frame_gray_sequence_with_audio(&t, &silent, 1000, 32, timeout, None, Some(&fp_out2)) + .expect("무성 영상에서도 프레임은 나와야 한다"); + assert!(frames2.len() >= 3, "무성 영상 프레임 {}장", frames2.len()); + assert!(fp2.is_empty(), "무성 영상은 빈 지문"); +} + +/// 손상된 입력에서 워커가 영구 점유되지 않아야 한다 (타임아웃/오류 반환). +#[test] +fn broken_input_fails_fast() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let dir = tempfile::tempdir().unwrap(); + let junk = dir.path().join("깨진파일.mp4"); + std::fs::write(&junk, b"this is not a video").unwrap(); + + let started = std::time::Instant::now(); + let r = ffmpeg::frame_gray_sequence(&t, &junk, 1000, 32, std::time::Duration::from_secs(20), None); + assert!(r.is_err() || r.as_ref().unwrap().is_empty(), "깨진 파일에서 프레임이 나왔다"); + assert!(started.elapsed().as_secs() < 20, "즉시 실패해야 한다"); +} diff --git a/src-tauri/crates/archive-indexer/tests/fixture_dedupe.rs b/src-tauri/crates/archive-indexer/tests/fixture_dedupe.rs new file mode 100644 index 0000000..73ca78d --- /dev/null +++ b/src-tauri/crates/archive-indexer/tests/fixture_dedupe.rs @@ -0,0 +1,464 @@ +//! 픽스처 실기 통합 테스트 — `.fixtures\manifest.json`의 기대값을 **실제 파이프라인**으로 검증한다. +//! +//! 스캔 → 메타 → 썸네일 → 지문 → 중복 탐지를 순서대로 돌리고, manifest가 "잡혀야 한다"고 +//! 말한 변형이 정말 같은 그룹에 들어갔는지 / "잡히면 안 된다"는 것이 안 묶였는지 확인한다. +//! 이 순서 자체가 검증 대상이다 — 이미지 지문은 썸네일에서 뽑으므로 앞 단계가 빠지면 못 나온다. +//! +//! 픽스처가 없으면 조용히 통과한다 (`.\scripts\gen-fixtures.ps1`로 생성). +//! manifest의 expect/stage 의미는 그 스크립트 헤더에 적혀 있다. + +use archive_db::Db; +use archive_indexer::{dedupe, ffmpeg::FfTools, meta, pipeline, sigq, thumbq::ThumbQueue}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use std::time::Duration; + +fn repo_root() -> PathBuf { + // crates/archive-indexer → src-tauri → 리포 루트 + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap() +} + +fn tools() -> Option { + let bin = repo_root().join("src-tauri/binaries"); + let triple = if cfg!(all(windows, target_arch = "x86_64")) { + "x86_64-pc-windows-msvc" + } else if cfg!(all(windows, target_arch = "aarch64")) { + "aarch64-pc-windows-msvc" + } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + "aarch64-apple-darwin" + } else { + "x86_64-apple-darwin" + }; + let ext = if cfg!(windows) { ".exe" } else { "" }; + let (ffmpeg, ffprobe) = ( + bin.join(format!("ffmpeg-{triple}{ext}")), + bin.join(format!("ffprobe-{triple}{ext}")), + ); + (ffmpeg.is_file() && ffprobe.is_file()).then_some(FfTools { ffmpeg, ffprobe }) +} + +/// manifest 한 항목. +struct Entry { + /// 파일명만 (경로 제외) + name: String, + /// 같은 원본에서 나온 것들의 공통 키 (음성은 None) + group: Option, + variant: String, + expect: String, + stage: i64, +} + +fn load_manifest(kind: &str) -> Option<(PathBuf, Vec)> { + let fixtures = repo_root().join(".fixtures"); + let text = std::fs::read_to_string(fixtures.join("manifest.json")).ok()?; + let json: serde_json::Value = serde_json::from_str(&text).expect("manifest.json 파싱 실패"); + let arr = json.get(kind)?.as_array()?.clone(); + let entries: Vec = arr + .iter() + .map(|e| Entry { + name: e["file"].as_str().unwrap().rsplit('/').next().unwrap().to_string(), + group: e["group"].as_str().map(|s| s.to_string()), + variant: e["variant"].as_str().unwrap_or("").to_string(), + expect: e["expect"].as_str().unwrap_or("").to_string(), + stage: e["stage"].as_i64().unwrap_or(1), + }) + .collect(); + let dir = fixtures.join(if kind == "images" { "images" } else { "videos" }); + dir.is_dir().then_some((dir, entries)) +} + +fn open_db(dir: &Path) -> Arc { + Arc::new(Db::open(&dir.join("t.db")).unwrap()) +} + +fn add_source(db: &Arc, root: &Path) -> i64 { + let r = root.to_string_lossy().into_owned(); + db.with_write(move |conn| { + conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','fx',?1)", [&r])?; + Ok(conn.last_insert_rowid()) + }) + .unwrap() +} + +/// file_id → 파일명 +fn names(db: &Arc) -> HashMap { + db.with_read(|conn| { + let mut stmt = conn.prepare("SELECT id, name FROM files WHERE deleted_at IS NULL")?; + let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?; + rows.collect() + }) + .unwrap() +} + +/// 탐지된 그룹 — (kind, 파일명 집합). kind 0=완전 동일 1=유사 이미지 2=유사 영상. +#[derive(Debug)] +struct Group { + kind: i64, + members: HashSet, +} + +fn groups_by_name(db: &Arc, names: &HashMap) -> Vec { + let rows: Vec<(i64, i64, i64)> = db + .with_read(|conn| { + let mut stmt = conn.prepare( + "SELECT dm.group_id, dg.kind, dm.file_id FROM dupe_members dm + JOIN dupe_groups dg ON dg.id = dm.group_id WHERE dg.status = 'open'", + )?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?; + rows.collect() + }) + .unwrap(); + let mut by_gid: HashMap = HashMap::new(); + for (gid, kind, fid) in rows { + if let Some(n) = names.get(&fid) { + by_gid + .entry(gid) + .or_insert_with(|| Group { kind, members: HashSet::new() }) + .members + .insert(n.clone()); + } + } + by_gid.into_values().collect() +} + +/// 탐지 결과를 사람이 읽을 형태로 찍는다 (`--nocapture`). 점수·거리가 실제로 채워지는지도 보인다. +fn dump(db: &Arc, groups: &[Group]) { + let scores: Vec<(i64, i64, Option)> = db + .with_read(|conn| { + let mut stmt = conn.prepare( + "SELECT id, kind, score FROM dupe_groups WHERE status='open' ORDER BY kind, id", + )?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?; + rows.collect() + }) + .unwrap(); + let kind_name = |k: i64| match k { + 0 => "완전동일", + 1 => "유사이미지", + _ => "유사영상", + }; + println!("── 탐지 결과: 그룹 {}개", groups.len()); + for (_, kind, score) in &scores { + let _ = (kind, score); + } + let mut sorted: Vec<&Group> = groups.iter().collect(); + sorted.sort_by_key(|g| (g.kind, g.members.len())); + for g in sorted { + let mut m: Vec<&str> = g.members.iter().map(|s| s.as_str()).collect(); + m.sort_unstable(); + println!(" [{}] {}", kind_name(g.kind), m.join(", ")); + } + let dists: Vec = db + .with_read(|conn| { + let mut stmt = conn.prepare( + "SELECT distance FROM dupe_members WHERE distance IS NOT NULL AND is_keeper = 0", + )?; + let rows = stmt.query_map([], |r| r.get(0))?; + rows.collect() + }) + .unwrap(); + let sc: Vec = scores.iter().filter_map(|(_, _, s)| *s).collect(); + println!( + " 거리 채워진 멤버 {}개(최대 {:?}) · 점수 채워진 그룹 {}/{}개", + dists.len(), + dists.iter().max(), + sc.len(), + scores.len() + ); +} + +/// 썸네일이 다 만들어질 때까지 기다린다 (없으면 이미지 지문이 안 나온다). +fn build_thumbs(db: &Arc, sid: i64, thumb_root: &Path, tools: Option) { + let want: i64 = db + .with_read(|c| { + c.query_row( + "SELECT count(*) FROM files WHERE kind IN (0,1) AND deleted_at IS NULL", + [], + |r| r.get(0), + ) + }) + .unwrap(); + // 완료를 이벤트로 기다린다 — 썸네일 워커는 저우선순위라 폴링+마감은 부하에서 흔들린다. + // 마감(60초 무진전)은 정말 멈춘 경우를 위한 안전장치다. + let (tx, rx) = std::sync::mpsc::channel::(); + let done = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let d2 = done.clone(); + let q = ThumbQueue::start(db.clone(), thumb_root.to_path_buf(), tools, 4, move |ids| { + let n = d2.fetch_add(ids.len(), std::sync::atomic::Ordering::SeqCst) + ids.len(); + let _ = tx.send(n); + }); + q.enqueue_pending(Some(sid)); + while (done.load(std::sync::atomic::Ordering::SeqCst) as i64) < want { + if rx.recv_timeout(Duration::from_secs(60)).is_err() { + break; + } + } +} + +/// manifest 기대값과 실제 그룹을 대조한다. 반환: 실패 메시지들. +/// +/// 판정 기준은 "**같은 원본 계열의 다른 파일과 한 그룹에 있는가**"다. 원본 파일 자신과 +/// 직접 묶일 필요는 없다 — 완전 동일 그룹의 keeper만 유사 패스에 남기는 설계(같은 파일이 +/// 두 그룹에 중복 표시되는 것을 막는다) 때문에, 바이트 동일 사본을 경유해 연결되는 것이 정상이다. +/// `rotation`은 회전·반전 탐지를 켠 실행인지. 꺼져 있으면 rot90/flip은 단정하지 않는다 +/// (그 두 변형만 8변형 질의를 필요로 한다 — 나머지 단계 3 항목은 기본 경로에서 잡힌다). +fn check(entries: &[Entry], groups: &[Group], rotation: bool) -> Vec { + let mut fails = Vec::new(); + let mut member_of: HashMap<&str, Vec> = HashMap::new(); + for (i, g) in groups.iter().enumerate() { + for n in &g.members { + member_of.entry(n.as_str()).or_default().push(i); + } + } + // manifest 그룹 → 그 계열의 파일명들 + let mut family: HashMap<&str, Vec<&str>> = HashMap::new(); + for e in entries { + if let Some(g) = e.group.as_deref() { + family.entry(g).or_default().push(e.name.as_str()); + } + } + + // 이 실행에서 단정할 단계인가. 단계 1·3·4 모두 구현됐다 — + // 단 rot90/flip은 회전 옵션을 켠 실행에서만 본다. + let asserted = |e: &Entry| match e.stage { + 1 | 4 => true, + 3 => rotation || !(e.variant == "rot90" || e.variant == "flip"), + _ => false, + }; + for e in entries { + let mine = member_of.get(e.name.as_str()).cloned().unwrap_or_default(); + match e.expect.as_str() { + "exact" | "similar" | "segment" if asserted(e) => { + let Some(g) = e.group.as_deref() else { continue }; + let siblings: Vec<&str> = family + .get(g) + .map(|v| v.iter().copied().filter(|n| *n != e.name.as_str()).collect()) + .unwrap_or_default(); + let together = mine + .iter() + .any(|i| siblings.iter().any(|s| groups[*i].members.contains(*s))); + if !together { + fails.push(format!( + "{} ({}): {g} 계열과 같은 그룹에 없다 (소속 그룹 {}개)", + e.name, + e.variant, + mine.len() + )); + } + // 바이트까지 같은 사본은 '완전 동일' 그룹(kind 0)이어야 한다 + if e.expect == "exact" && !mine.iter().any(|i| groups[*i].kind == 0) { + fails.push(format!("{}: 바이트 동일인데 완전 동일 그룹이 아니다", e.name)); + } + } + // 묶이면 안 되는 것 + "none" if e.stage == 1 => { + if !mine.is_empty() { + let others: Vec<&str> = groups[mine[0]] + .members + .iter() + .filter(|n| n.as_str() != e.name) + .map(|s| s.as_str()) + .collect(); + fails.push(format!("{}: 묶이면 안 되는데 묶였다 — {:?}", e.name, others)); + } + } + _ => {} // 단계 3·4 대상은 지금 무관 (잡혀도 실패 아님) + } + } + fails +} + +#[test] +fn fixture_images_group_as_manifest_expects() { + let Some((dir, entries)) = load_manifest("images") else { + eprintln!("픽스처 없음 — 건너뜀 (scripts\\gen-fixtures.ps1 실행)"); + return; + }; + let tmp = tempfile::tempdir().unwrap(); + let thumbs = tempfile::tempdir().unwrap(); + let db = open_db(tmp.path()); + let sid = add_source(&db, &dir); + let cancel = AtomicBool::new(false); + let pause = AtomicBool::new(false); + + pipeline::scan_source(&db, sid, &dir, &cancel, |_| {}).unwrap(); + meta::extract_image_meta(&db, sid, &cancel).unwrap(); + build_thumbs(&db, sid, thumbs.path(), None); + let n = sigq::run_image_sigs(&db, thumbs.path(), &cancel, &pause).unwrap(); + assert!(n > 0, "이미지 지문이 하나도 생성되지 않았다"); + + let stats = dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, false, &cancel, |_| {}).unwrap(); + assert_eq!(stats.skipped_no_sig, 0, "지문 없이 빠진 파일이 있다"); + + let names = names(&db); + let groups = groups_by_name(&db, &names); + dump(&db, &groups); // cargo test -- --nocapture 로 실제 결과를 본다 + let fails = check(&entries, &groups, false); + assert!( + fails.is_empty(), + "manifest 기대와 다름 ({}건):\n {}\n그룹 {}개: {:?}", + fails.len(), + fails.join("\n "), + groups.len(), + groups + ); + + // 정지 이미지는 시간축이 없다 — 비교 뷰가 그대로 겹칠 수 있게 오프셋 0이어야 한다 + let bad: i64 = db + .with_read(|c| { + c.query_row( + "SELECT count(*) FROM dupe_members WHERE offset_ms IS NULL OR offset_ms <> 0", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(bad, 0, "이미지 멤버의 offset_ms가 0이 아니다"); +} + +/// 회전·반전본은 8변형 질의를 켰을 때만 잡힌다 — 켜면 잡히고, 무관한 그림은 여전히 안 묶인다. +#[test] +fn fixture_rotated_copies_found_when_enabled() { + let Some((dir, entries)) = load_manifest("images") else { + eprintln!("픽스처 없음 — 건너뜀"); + return; + }; + let tmp = tempfile::tempdir().unwrap(); + let thumbs = tempfile::tempdir().unwrap(); + let db = open_db(tmp.path()); + let sid = add_source(&db, &dir); + let cancel = AtomicBool::new(false); + let pause = AtomicBool::new(false); + + pipeline::scan_source(&db, sid, &dir, &cancel, |_| {}).unwrap(); + meta::extract_image_meta(&db, sid, &cancel).unwrap(); + build_thumbs(&db, sid, thumbs.path(), None); + sigq::run_image_sigs(&db, thumbs.path(), &cancel, &pause).unwrap(); + + // 먼저 꺼진 상태 — 회전본이 자기 계열과 묶이지 않아야 한다(그게 옵션의 존재 이유다) + dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, false, &cancel, |_| {}).unwrap(); + let names = names(&db); + let off_groups = groups_by_name(&db, &names); + let rot_grouped_off = off_groups.iter().any(|g| { + g.members.iter().any(|m| m.contains("_rot90")) && g.members.len() >= 2 + }); + assert!(!rot_grouped_off, "옵션이 꺼졌는데 회전본이 묶였다"); + + // 켜고 다시 — 이번엔 잡혀야 한다 + dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, true, &cancel, |_| {}).unwrap(); + let groups = groups_by_name(&db, &names); + dump(&db, &groups); + let fails = check(&entries, &groups, true); + assert!( + fails.is_empty(), + "회전 옵션을 켠 결과가 manifest 기대와 다름 ({}건):\n {}", + fails.len(), + fails.join("\n ") + ); +} + +/// 영상 경로 — 프레임 지문까지 만들어야 하므로 느리다(영상 8편 × 30초). +#[test] +fn fixture_videos_group_as_manifest_expects() { + let Some(t) = tools() else { + eprintln!("사이드카 없음 — 건너뜀"); + return; + }; + let Some((dir, entries)) = load_manifest("videos") else { + eprintln!("픽스처 없음 — 건너뜀 (scripts\\gen-fixtures.ps1 실행)"); + return; + }; + let tmp = tempfile::tempdir().unwrap(); + let thumbs = tempfile::tempdir().unwrap(); + let db = open_db(tmp.path()); + let sid = add_source(&db, &dir); + let cancel = AtomicBool::new(false); + let pause = AtomicBool::new(false); + + pipeline::scan_source(&db, sid, &dir, &cancel, |_| {}).unwrap(); + // 영상 길이가 있어야 커버리지 판정을 할 수 있다 + meta::extract_video_meta(&db, sid, &cancel, &t).unwrap(); + build_thumbs(&db, sid, thumbs.path(), Some(t.clone())); + let n = sigq::run_video_sigs(&db, &t, None, &cancel, &pause).unwrap(); + assert!(n > 0, "영상 지문이 하나도 생성되지 않았다"); + + let stats = dedupe::run(&db, dedupe::Scope::Source(sid), 5, true, false, &cancel, |_| {}).unwrap(); + assert_eq!(stats.skipped_no_sig, 0, "지문 없이 빠진 영상이 있다"); + + let names = names(&db); + let groups = groups_by_name(&db, &names); + dump(&db, &groups); // cargo test -- --nocapture 로 실제 결과를 본다 + let fails = check(&entries, &groups, false); + assert!( + fails.is_empty(), + "manifest 기대와 다름 ({}건):\n {}\n그룹 {}개: {:?}", + fails.len(), + fails.join("\n "), + groups.len(), + groups + ); + + // ── 비교 재생용 정렬값 ──────────────────────────────────────────── + // 비교 뷰는 `offset_ms`(keeper 시각 − 멤버 시각)로 두 영상을 맞춘다. 이 값이 틀리면 + // 나란히 놓고도 계속 다른 장면을 보게 되므로, 아는 답과 대조한다. + // manifest의 expectOffsetMs는 vidA 기준이고 keeper는 vidA가 아닐 수 있으므로 + // (rank_keeper: 해상도 → 파일크기순 → vidA_reenc가 가장 크다) **간격**만 본다. + let rows: Vec<(String, Option, Option, Option)> = db + .with_read(|conn| { + let mut stmt = conn.prepare( + "SELECT f.name, dm.offset_ms, dm.seg_start_ms, dm.seg_end_ms + FROM dupe_members dm JOIN files f ON f.id = dm.file_id + JOIN dupe_groups dg ON dg.id = dm.group_id + WHERE dg.kind = 2", + )?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?; + rows.collect() + }) + .unwrap(); + assert!(!rows.is_empty(), "영상 그룹 멤버가 없다"); + let by_name: HashMap<&str, (Option, Option, Option)> = + rows.iter().map(|(n, o, s, e)| (n.as_str(), (*o, *s, *e))).collect(); + println!("── 비교 재생 정렬값 (keeper 기준)"); + for (n, o, s, e) in &rows { + println!(" {n:<22} offset={o:?} seg={s:?}..{e:?}"); + } + + let keeper_name: &str = rows + .iter() + .map(|(n, _, _, _)| n.as_str()) + .find(|n| by_name[*n].0 == Some(0)) + .expect("offset 0인 keeper가 있어야 한다"); + let off = |n: &str| by_name.get(n).and_then(|(o, _, _)| *o); + + // 같은 원본을 그대로 재인코딩/워터마크/fps변환한 것은 keeper와 시간축이 같다 + for n in ["vidA_reenc.mp4", "vidA_wm.mp4", "vidA_fps24.mp4", "vidA.mp4"] { + if n == keeper_name { + continue; + } + if let Some(o) = off(n) { + assert!(o.abs() <= 1000, "{n}: 시간축이 같아야 한다 (offset {o}ms)"); + } + } + // 인트로 5초가 붙은 사본과 10초 지점을 잘라낸 사본은 그만큼 어긋나 있어야 한다. + // keeper가 vidA 계열 원본(오프셋 0)이라는 전제 아래 간격을 확인한다. + if let (Some(intro), Some(clip)) = (off("vidA_intro.mp4"), off("vidA_clip.mp4")) { + assert!( + (intro.abs() - 5000).abs() <= 1000, + "인트로 삽입본은 5초 어긋나야 한다 (offset {intro}ms)" + ); + assert!( + (clip.abs() - 10000).abs() <= 1000, + "10초 지점 구간 추출본은 10초 어긋나야 한다 (offset {clip}ms)" + ); + assert_eq!(intro.signum(), -clip.signum(), "두 사본은 반대 방향으로 어긋난다"); + } + // 일치 구간은 keeper 시간축 안에 있어야 한다 (비교 뷰가 '구간으로 이동'에 쓴다) + for (n, _, s, e) in &rows { + if let (Some(s), Some(e)) = (s, e) { + assert!(s < e, "{n}: 구간이 뒤집혔다 ({s}..{e})"); + assert!(*s >= 0, "{n}: 구간 시작이 음수 ({s})"); + } + } +} diff --git a/src-tauri/crates/archive-indexer/tests/letterbox_evidence.rs b/src-tauri/crates/archive-indexer/tests/letterbox_evidence.rs new file mode 100644 index 0000000..ed09245 --- /dev/null +++ b/src-tauri/crates/archive-indexer/tests/letterbox_evidence.rs @@ -0,0 +1,137 @@ +//! 근거 측정 — 레터박스 사본을 어떻게 잡는지, 그리고 왜 프레임만으로는 못 잡는지. +//! `cargo test -p archive-indexer --test letterbox_evidence -- --ignored --nocapture` +//! +//! 측정 결과(2026-08)와 그로부터 나온 설계: +//! +//! 1. 검정 띠 트림 자체는 정확하다 — 32×32에서 x6..26 y6..26, 딱 안쪽 콘텐츠다. +//! 2. 그런데 20×20을 9×8로 다시 표본화한 해시는 원본(32×32→9×8)과 **거리 7~16**(중앙값 9.5)이고 +//! **정확히 같은 값은 0개**다. 후보 생성이 값 기반 토큰이라 두 사본은 영영 만나지 못한다. +//! → 그래서 트림 해시를 후보 토큰으로 쓰려던 시도는 폐기했다(`dedupe::video_candidates` 주석). +//! 3. 오디오는 화면 변형에 영향받지 않는다 — 레터박스 사본이 chromaprint 221/221 일치. +//! → 오디오가 후보를 만든다. +//! 4. 다만 **배경음만 같은 남남**도 221/221로 붙는다(`vidC_sharedaudio`). 둘을 가르는 건 시각뿐: +//! 진짜 사본은 트림 축 프레임 거리 9, 남남은 29. → `AUDIO_VOTE_MAX_DIST = 16`이 그 사이다. +//! +//! 이 파일은 그 네 숫자를 다시 뽑아본다. 임계값을 손댈 일이 생기면 여기부터 돌려라. + +use archive_indexer::{ffmpeg::{self, FfTools}, sigmatch}; +use std::path::{Path, PathBuf}; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap() +} + +fn tools() -> Option { + let bin = repo_root().join("src-tauri/binaries"); + let triple = if cfg!(all(windows, target_arch = "x86_64")) { + "x86_64-pc-windows-msvc" + } else { + "aarch64-pc-windows-msvc" + }; + let ext = if cfg!(windows) { ".exe" } else { "" }; + let (a, b) = (bin.join(format!("ffmpeg-{triple}{ext}")), bin.join(format!("ffprobe-{triple}{ext}"))); + (a.is_file() && b.is_file()).then_some(FfTools { ffmpeg: a, ffprobe: b }) +} + +#[test] +#[ignore = "근거 측정용"] +fn print_letterbox_evidence() { + let Some(t) = tools() else { + eprintln!("사이드카 없음"); + return; + }; + let dir = repo_root().join(".fixtures/videos"); + if !dir.is_dir() { + eprintln!("픽스처 없음"); + return; + } + let grab = |name: &str| -> Vec<(u32, Vec)> { + ffmpeg::frame_gray_sequence( + &t, + &dir.join(name), + 1000, + sigmatch::FRAME as u32, + std::time::Duration::from_secs(120), + None, + ) + .unwrap_or_default() + }; + let a = grab("vidA.mp4"); + let l = grab("vidA_letterbox.mp4"); + println!("프레임 수: vidA {} · letterbox {}", a.len(), l.len()); + if a.is_empty() || l.is_empty() { + return; + } + for i in [0usize, 5, 10] { + let (Some((_, pa)), Some((_, pl))) = (a.get(i), l.get(i)) else { continue }; + let (sa, sl) = (sigmatch::derive(pa).unwrap(), sigmatch::derive(pl).unwrap()); + let bounds = sigmatch::letterbox(pl); + // 32×32 픽셀에서 검정(값 <= 16) 비율 — 패딩이 얼마나 되는지 + let black = pl.iter().filter(|v| **v <= 16).count() * 100 / pl.len(); + println!("── 프레임 {i}"); + println!( + " letterbox 판정 x{}..{} y{}..{} (검정 픽셀 {black}%)", + bounds.0, bounds.2, bounds.1, bounds.3 + ); + println!( + " 전체↔전체 {} · 트림(레터박스)↔전체(원본) {} · 전체↔트림 {}", + sigmatch::hamming(sa.dhash, sl.dhash), + sigmatch::hamming(sl.scales[2], sa.dhash), + sigmatch::hamming(sa.dhash, sl.scales[2]) + ); + println!( + " 원본의 scales[2]는 전체와 같은가: {} (거리 {})", + sa.scales[2] == sa.dhash, + sigmatch::hamming(sa.scales[2], sa.dhash) + ); + } + + // 후보 생성은 '값이 정확히 같은 토큰'을 요구한다 — 공유 토큰이 몇 개인지가 결정적이다 + let sig_of = |frames: &[(u32, Vec)]| -> (Vec, Vec) { + let mut whole = Vec::new(); + let mut lb = Vec::new(); + for (_, px) in frames { + if let Some(s) = sigmatch::derive(px) { + whole.push(s.dhash); + lb.push(s.scales[2]); + } + } + (whole, lb) + }; + let (aw, _al) = sig_of(&a); + let (lw, ll) = sig_of(&l); + let set: std::collections::HashSet = aw.iter().copied().collect(); + println!("\n── 후보 토큰 공유 (정확히 일치하는 값의 개수)"); + println!(" 원본 전체해시 {}개 중", set.len()); + println!(" 레터박스본 전체해시와 공유: {}", lw.iter().filter(|h| set.contains(h)).count()); + println!(" 레터박스본 트림해시와 공유: {}", ll.iter().filter(|h| set.contains(h)).count()); + // 거리 분포 — 몇 비트나 벌어지는지 + let mut d: Vec = ll + .iter() + .zip(aw.iter()) + .map(|(x, y)| sigmatch::hamming(*x, *y)) + .collect(); + d.sort_unstable(); + println!(" 트림↔전체 프레임별 거리(정렬): {d:?}"); + + // ── 오디오가 후보를 만들 때 시각으로 진짜/남남을 가를 수 있는가 ────────── + // 오디오는 vidA_letterbox(진짜 사본)와 vidC_sharedaudio(배경음만 같은 남남)를 + // 똑같이 221/221 일치로 붙인다. 둘을 가르는 건 시각뿐이므로 그 간격을 잰다. + let median = |mut v: Vec| -> u32 { + v.sort_unstable(); + v.get(v.len() / 2).copied().unwrap_or(0) + }; + println!("\n── 오디오 후보를 시각으로 거를 때의 간격 (중앙값, 64비트 중)"); + for (name, use_lb) in [("vidA_letterbox.mp4", true), ("vidC_sharedaudio.mp4", false)] { + let f = grab(name); + if f.is_empty() { + println!(" {name}: 프레임 없음"); + continue; + } + let (w, lb) = sig_of(&f); + let seq = if use_lb { &lb } else { &w }; + let n = seq.len().min(aw.len()); + let dm = median((0..n).map(|i| sigmatch::hamming(seq[i], aw[i])).collect()); + println!(" vidA ↔ {name:<22} {dm} {}", if use_lb { "(트림 축)" } else { "(전체 축)" }); + } +} diff --git a/src-tauri/crates/archive-indexer/tests/wmbig_evidence.rs b/src-tauri/crates/archive-indexer/tests/wmbig_evidence.rs new file mode 100644 index 0000000..4251b6c --- /dev/null +++ b/src-tauri/crates/archive-indexer/tests/wmbig_evidence.rs @@ -0,0 +1,102 @@ +//! 진단용 — `wmbig` 오탐의 실제 근거 수치를 찍는다. +//! `cargo test -p archive-indexer --test wmbig_evidence -- --nocapture` + +use archive_indexer::sigmatch::{self, FrameSig}; +use std::path::{Path, PathBuf}; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap() +} + +fn sig_of(p: &Path) -> Option { + let img = image::ImageReader::open(p).ok()?.with_guessed_format().ok()?.decode().ok()?; + let gray = img + .resize_exact( + sigmatch::FRAME as u32, + sigmatch::FRAME as u32, + image::imageops::FilterType::Triangle, + ) + .to_luma8() + .into_raw(); + sigmatch::derive(&gray) +} + +fn color_of(p: &Path) -> Option<[i8; 32]> { + let img = image::ImageReader::open(p).ok()?.with_guessed_format().ok()?.decode().ok()?; + let small = img.resize_exact(4, 4, image::imageops::FilterType::Triangle).to_rgb8().into_raw(); + sigmatch::color_of(&small) +} + +/// 임계값을 다시 손볼 때 근거표를 뽑는 용도. 단정이 없으므로 기본 실행에서는 뺀다. +/// `sigmatch`의 VETO_AGREE_RATIO·CROP_MAX_DIST 주석에 적힌 수치가 여기서 나온 것이다. +#[test] +#[ignore = "근거 측정용 — cargo test --test wmbig_evidence -- --ignored --nocapture"] +fn print_wmbig_evidence() { + let dir = repo_root().join(".fixtures/images"); + if !dir.is_dir() { + eprintln!("픽스처 없음 — 건너뜀"); + return; + } + let pairs = [ + ("pos_still3_wmbig.jpg", "pos_still4_wmbig.jpg", "오탐: 서로 다른 원본 + 같은 큰 가림"), + ("pos_still3_orig.jpg", "pos_still4_orig.jpg", "그 두 원본 자체"), + ("pos_still3_orig.jpg", "pos_still3_wmbig.jpg", "정탐: 원본 ↔ 자기 가림본"), + ("pos_still3_orig.jpg", "pos_still3_rot90.jpg", "회전본"), + ("pos_still3_orig.jpg", "pos_still3_crop95.jpg", "5% 크롭"), + ("pos_still3_orig.jpg", "pos_still3_q12.jpg", "저품질 재인코딩 (거부권에 걸리면 안 된다)"), + ("pos_still3_orig.jpg", "pos_still3_half.jpg", "50% 축소"), + ("pos_still3_orig.jpg", "pos_still3_bright.jpg", "밝기·대비 변경"), + ("pos_still3_orig.jpg", "pos_still3_wm.jpg", "하단 자막 바"), + ("neg_bars.jpg", "neg_hdbars.jpg", "무관: 컬러바 두 종"), + ]; + for (a, b, label) in pairs { + let (pa, pb) = (dir.join(a), dir.join(b)); + let (Some(sa), Some(sb)) = (sig_of(&pa), sig_of(&pb)) else { + eprintln!("{label}: 파일 없음"); + continue; + }; + let ra = sigmatch::RichSig { + dhash: sa.dhash, + orient: sa.orient, + tiles: sa.tiles, + scales: sa.scales, + tile_flat: sa.tile_flat, + color: color_of(&pa), + }; + let rb = sigmatch::RichSig { + dhash: sb.dhash, + orient: sb.orient, + tiles: sb.tiles, + scales: sb.scales, + tile_flat: sb.tile_flat, + color: color_of(&pb), + }; + let whole = sigmatch::hamming(ra.dhash, rb.dhash); + let (agree_loose, seen) = sigmatch::informative_agreement(&ra, &rb, sigmatch::TILE_PER_TILE); + let (agree_strict, _) = + sigmatch::informative_agreement(&ra, &rb, sigmatch::TILE_STRICT_PER_TILE); + let cdist = match (&ra.color, &rb.color) { + (Some(x), Some(y)) => sigmatch::color_distance(x, y) as i64, + _ => -1, + }; + let verdict = sigmatch::verify_pair(&ra, &rb, 5, true); + println!("── {label}"); + println!(" 전체 dHash 거리 {whole} · 색차 {cdist} · 판정 {verdict:?}"); + println!( + " 정보타일 {seen}개 중 근접 {agree_loose}(느슨 ≤{})/{agree_strict}(엄격 ≤{})", + sigmatch::TILE_PER_TILE, + sigmatch::TILE_STRICT_PER_TILE + ); + // 타일별로 거리와 '정보 없음' 여부를 같이 본다 (X = 판정에서 제외) + let cells: Vec = (0..16) + .map(|i| { + let d = sigmatch::hamming(ra.tiles[i], rb.tiles[i]); + let skip = (ra.tile_flat >> i) & 1 == 1 || (rb.tile_flat >> i) & 1 == 1; + format!("{}{d:>2}", if skip { "X" } else { " " }) + }) + .collect(); + for row in 0..4 { + println!(" {}", cells[row * 4..row * 4 + 4].join(" ")); + } + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9ec020a..170593f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2,6 +2,7 @@ use crate::AppState; use archive_indexer::pipeline::ScanProgress; +use rusqlite::OptionalExtension; use std::path::Path; use std::sync::atomic::Ordering; use tauri::ipc::{Channel, Response}; @@ -23,6 +24,10 @@ pub struct AppInfoDto { pub portable: bool, pub media_port: u16, pub media_token: String, + /// ffmpeg 사이드카를 찾았는가. 없으면 **영상은 썸네일도 지문도 만들지 못한다** — + /// 그런데 큐는 그냥 건너뛰므로 대기 수가 줄지 않고 멈춘 것처럼 보인다(실제 신고). + /// 화면이 이 사실을 말할 수 있도록 내보낸다. + pub has_ffmpeg: bool, } #[tauri::command] @@ -33,6 +38,7 @@ pub fn app_info(app: tauri::AppHandle, state: tauri::State<'_, AppState>) -> App portable: state.portable, media_port: state.media.port, media_token: state.media.token.clone(), + has_ffmpeg: state.tools.is_some(), } } @@ -55,12 +61,23 @@ pub struct PendingCounts { pub thumbs: i64, /// 메타데이터 분석 대기(meta_state=0) 수 pub meta: i64, + /// 중복 탐지용 지문 생성 대기(sig_state=0) 수 — 이미지는 썸네일이 준비된 것만 센다 + pub sigs: i64, + /// 이번 백필의 기준선 — '가장 많이 밀려 있던 순간'의 대기 수. 진행률의 분모다. + /// + /// 대기 수만으로는 진행률을 말할 수 없어(남은 수는 알아도 총량을 모른다) 기준선이 필요한데, + /// 이걸 화면 메모리에만 두면 **앱을 껐다 켤 때마다 0%로 돌아간다** — 남은 수가 새 기준선이 + /// 되기 때문이다(사용자 신고). 그래서 DB에 남긴다. + /// + /// 전체 대상 수를 세는 방법도 있지만 그건 매 폴링마다 풀스캔이다(실측: 5만 행 27.9ms → + /// 100만 건이면 2초 폴링마다 0.56초). 기준선은 값 하나라 읽기·쓰기가 사실상 공짜다. + pub baseline: i64, } /// 백그라운드 처리 진행 표시용 — 대기 중인 썸네일/메타 개수. #[tauri::command] pub fn pending_counts(state: tauri::State<'_, AppState>) -> CmdResult { - state + let counts = state .db .with_read(|conn| { let thumbs: i64 = conn.query_row( @@ -73,9 +90,45 @@ pub fn pending_counts(state: tauri::State<'_, AppState>) -> CmdResult(0), + ) + .optional()? + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + Ok(PendingCounts { thumbs, meta, sigs, baseline }) }) - .map_err(err_str) + .map_err(err_str)?; + + // 기준선 갱신 — 더 많이 밀리면 올리고, 다 끝나면 버린다(다음 사이클은 처음부터 센다). + // **바뀔 때만** 쓴다: 이 명령은 2초마다 불리므로 매번 쓰면 writer 스레드를 헛돌린다. + let remain = counts.thumbs + counts.meta + counts.sigs; + let next = if remain == 0 { 0 } else { remain.max(counts.baseline) }; + if next != counts.baseline { + state + .db + .with_write(move |conn| { + conn.execute( + "INSERT OR REPLACE INTO settings(key, value) VALUES ('backfill_baseline', ?1)", + [next.to_string()], + )?; + Ok(()) + }) + .map_err(err_str)?; + } + Ok(PendingCounts { baseline: next, ..counts }) } #[tauri::command] @@ -102,8 +155,65 @@ pub fn list_sources(state: tauri::State<'_, AppState>) -> CmdResult CmdResult> { + state + .db + .with_read(|conn| { + let mut stmt = + conn.prepare("SELECT id, name, root FROM sources WHERE kind = 'local'")?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?; + rows.collect() + }) + .map_err(err_str) +} + +/// 같은 폴더(또는 상·하위 폴더)가 이미 등록돼 있는지 본다. +/// 반환: `Ok(Some(id))` 동일 경로 → 재사용, `Err` 겹침 → 거절, `Ok(None)` 새로 추가 가능. +fn check_source_overlap( + sources: &[(i64, String, String)], + path: &str, + skip_id: Option, +) -> Result, String> { + use archive_indexer::maintenance::{is_within, norm_path}; + let target = norm_path(path); + for (id, name, root) in sources { + if Some(*id) == skip_id { + continue; + } + let existing = norm_path(root); + if existing == target { + return Ok(Some(*id)); + } + // 상·하위 관계면 같은 파일이 두 번 인덱싱된다 — 항목이 중복으로 보인다 + if is_within(&target, &existing) { + return Err(format!( + "이미 등록된 소스 \"{name}\" 안에 있는 폴더입니다.\n{root}\n\n같은 파일이 두 번 등록되는 것을 막기 위해 추가하지 않았습니다." + )); + } + if is_within(&existing, &target) { + return Err(format!( + "이미 등록된 소스 \"{name}\"를 포함하는 상위 폴더입니다.\n{root}\n\n같은 파일이 두 번 등록되는 것을 막기 위해 추가하지 않았습니다.\n하위 소스를 먼저 제외한 뒤 다시 추가하세요." + )); + } + } + Ok(None) +} + #[tauri::command] -pub fn add_local_source(state: tauri::State<'_, AppState>, path: String) -> CmdResult { +pub fn add_local_source( + state: tauri::State<'_, AppState>, + path: String, +) -> CmdResult { let p = Path::new(&path); if !p.is_dir() { return Err(format!("폴더가 아닙니다: {path}")); @@ -113,6 +223,28 @@ pub fn add_local_source(state: tauri::State<'_, AppState>, path: String) -> CmdR .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| path.clone()); + // 이미 매핑된 폴더를 다시 추가하면 새 소스를 만들지 않는다. + // (경로를 못 읽어 목록이 비었을 때 다시 매핑하다 이중 등록되는 사고를 막는다) + let existing = check_source_overlap(&local_sources(&state)?, &path, None)?; + if let Some(id) = existing { + let (kind, name, root, file_count): (String, String, String, i64) = state + .db + .with_read(move |conn| { + conn.query_row( + "SELECT s.kind, s.name, s.root, + (SELECT count(*) FROM files f WHERE f.source_id = s.id AND f.deleted_at IS NULL) + FROM sources s WHERE s.id = ?1", + [id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + }) + .map_err(err_str)?; + return Ok(AddSourceResult { + source: SourceDto { id, kind, name, root, file_count }, + existing: true, + }); + } + let (id, name, root) = state .db .with_write({ @@ -133,15 +265,256 @@ pub fn add_local_source(state: tauri::State<'_, AppState>, path: String) -> CmdR w.refresh(&state.db); } - Ok(SourceDto { - id, - kind: "local".into(), - name, - root, - file_count: 0, + Ok(AddSourceResult { + source: SourceDto { + id, + kind: "local".into(), + name, + root, + file_count: 0, + }, + existing: false, }) } +/// 소스 상태 — 경로에 접근할 수 있는지, 다른 소스와 경로가 겹치는지. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SourceStatusDto { + pub source_id: i64, + /// 로컬 경로를 읽을 수 있는지 (원격 소스는 항상 true — 연결은 별도로 다룬다) + pub available: bool, + /// 경로가 겹치는 다른 소스 id (중복 인덱싱 경고용) + pub overlaps_with: Option, +} + +/// 소스 경로 점검. 죽은 네트워크 경로는 응답이 몇 초씩 걸릴 수 있어 +/// 전용 스레드 + 타임아웃으로 확인한다 — UI를 멈추지 않는 것이 목적이다. +#[tauri::command] +pub async fn source_status( + state: tauri::State<'_, AppState>, +) -> CmdResult> { + use archive_indexer::maintenance::{is_within, norm_path}; + + let sources: Vec<(i64, String, String)> = state + .db + .with_read(|conn| { + let mut stmt = conn.prepare("SELECT id, kind, root FROM sources ORDER BY id")?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?; + rows.collect() + }) + .map_err(err_str)?; + + // 경로 겹침은 문자열 비교만으로 판정한다 (I/O 없음) + let mut overlaps: Vec> = vec![None; sources.len()]; + for i in 0..sources.len() { + for j in 0..sources.len() { + if i == j { + continue; + } + let a = norm_path(&sources[i].2); + let b = norm_path(&sources[j].2); + if is_within(&a, &b) || is_within(&b, &a) { + overlaps[i] = Some(sources[j].0); + break; + } + } + } + + let probe: Vec<(i64, String, String)> = sources.clone(); + let availability = tauri::async_runtime::spawn_blocking(move || { + probe + .into_iter() + .map(|(id, kind, root)| { + if kind != "local" { + return (id, true); + } + // 매달린 SMB 핸들에 갇히지 않도록 별도 스레드에 맡기고 기다린다 + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(archive_indexer::pipeline::root_readable(Path::new(&root))); + }); + let ok = rx + .recv_timeout(std::time::Duration::from_secs(3)) + .unwrap_or(false); + (id, ok) + }) + .collect::>() + }) + .await + .map_err(err_str)?; + + Ok(sources + .iter() + .enumerate() + // availability는 sources와 같은 순서 (into_iter().map()이 순서를 지킨다) + .map(|(i, (id, _, _))| SourceStatusDto { + source_id: *id, + available: availability.get(i).map(|(_, ok)| *ok).unwrap_or(false), + overlaps_with: overlaps[i], + }) + .collect()) +} + +/// 소스 이름·경로 수정. 경로를 바꾸면 폴더 경로 접두사를 통째로 갈아 끼워 +/// 태그·썸네일을 유지한 채 새 위치로 재매핑한다 (드라이브 문자 변경·폴더 이동 복구). +/// 반환: 경로가 바뀐 폴더 행 수. +#[tauri::command] +pub async fn update_source( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + source_id: i64, + name: Option, + root: Option, +) -> CmdResult { + if state.scan_running.load(Ordering::SeqCst) { + return Err("스캔이 진행 중입니다. 완료 후 다시 시도하세요".into()); + } + let (kind, old_root): (String, String) = state + .db + .with_read(move |conn| { + conn.query_row("SELECT kind, root FROM sources WHERE id = ?1", [source_id], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + }) + .map_err(|_| "소스를 찾을 수 없습니다".to_string())?; + + if let Some(n) = name.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) { + state + .db + .with_write(move |conn| { + conn.execute( + "UPDATE sources SET name = ?2 WHERE id = ?1", + rusqlite::params![source_id, n], + )?; + Ok(()) + }) + .map_err(err_str)?; + } + + let mut changed = 0u64; + if let Some(new_root) = root.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) { + if kind != "local" { + return Err("네트워크 소스는 경로를 바꿀 수 없습니다. 소스를 다시 연결하세요".into()); + } + // 새 경로가 실제로 읽히는지 먼저 확인 (죽은 경로로 바꾸면 상황이 더 나빠진다) + let probe = new_root.clone(); + let readable = tauri::async_runtime::spawn_blocking(move || { + archive_indexer::pipeline::root_readable(Path::new(&probe)) + }) + .await + .map_err(err_str)?; + if !readable { + return Err(format!("폴더를 읽을 수 없습니다: {new_root}")); + } + let others = local_sources(&state)?; + if let Some(other) = check_source_overlap(&others, &new_root, Some(source_id))? { + let name = others + .iter() + .find(|(id, _, _)| *id == other) + .map(|(_, n, _)| n.clone()) + .unwrap_or_default(); + return Err(format!( + "이미 다른 소스 \"{name}\"가 이 폴더를 쓰고 있습니다. 그 소스를 제외한 뒤 다시 시도하세요." + )); + } + + let db = state.db.clone(); + let old = old_root.clone(); + let new = new_root.clone(); + changed = tauri::async_runtime::spawn_blocking(move || { + archive_indexer::maintenance::repoint_source(&db, source_id, &old, &new) + }) + .await + .map_err(err_str)? + .map_err(err_str)?; + + // 워치 경로 갱신 + 새 위치와 인덱스를 맞추는 재스캔 + if let Some(w) = state.watchers.get() { + w.refresh(&state.db); + } + if !state.scan_running.swap(true, Ordering::SeqCst) { + state.scan_cancel.store(false, Ordering::SeqCst); + if let Err(e) = spawn_scan_thread(app.clone(), &state, source_id, new_root, None) { + state.scan_running.store(false, Ordering::SeqCst); + return Err(e); + } + } + } + + let _ = app.emit("library-changed", source_id); + Ok(changed) +} + +/// 안전장치가 보류한 '사라진 파일'을 사용자 확인 후 목록에서 정리한다. +/// +/// 대량 삭제 안전장치는 한 번 걸리면 계속 걸린다(파일이 여전히 없으므로). 정말로 지운 +/// 경우엔 이 커맨드로 확정한다 — 디스크를 다시 확인해 **실제로 없는 행만** 지우므로, +/// 연결이 끊겼던 것뿐이라면 아무것도 지워지지 않는다. +/// 반환: 목록에서 지운 항목 수 (파일은 이미 없으므로 디스크는 건드리지 않는다). +#[tauri::command] +pub async fn purge_missing( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + source_id: i64, +) -> CmdResult { + if state.scan_running.load(Ordering::SeqCst) { + return Err("스캔이 진행 중입니다. 완료 후 다시 시도하세요".into()); + } + let db = state.db.clone(); + let n = tauri::async_runtime::spawn_blocking(move || { + archive_indexer::pipeline::purge_missing(&db, source_id) + }) + .await + .map_err(err_str)? + .map_err(err_str)?; + let _ = app.emit("library-changed", source_id); + Ok(n) +} + +/// 소스 하나를 다시 불러온다 (증분 재스캔). 경로에 접근할 수 없으면 +/// 인덱스를 건드리지 않고 오류로 알린다 — 스캔이 라이브러리를 비우는 사고를 막는다. +#[tauri::command] +pub async fn reload_source( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + source_id: i64, +) -> CmdResult<()> { + let (kind, root): (String, String) = state + .db + .with_read(move |conn| { + conn.query_row("SELECT kind, root FROM sources WHERE id = ?1", [source_id], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + }) + .map_err(|_| "소스를 찾을 수 없습니다".to_string())?; + if kind != "local" { + return Err("네트워크 소스는 '연결 및 스캔'으로 다시 불러오세요".into()); + } + + let probe = root.clone(); + let readable = tauri::async_runtime::spawn_blocking(move || { + archive_indexer::pipeline::root_readable(Path::new(&probe)) + }) + .await + .map_err(err_str)?; + if !readable { + return Err(format!( + "경로에 접근할 수 없습니다: {root}\n\n연결(드라이브·네트워크)을 확인하거나, 폴더가 옮겨졌다면 '경로 바꾸기'로 다시 매핑하세요." + )); + } + + if state.scan_running.swap(true, Ordering::SeqCst) { + return Err("이미 스캔이 진행 중입니다".into()); + } + state.scan_cancel.store(false, Ordering::SeqCst); + let started = spawn_scan_thread(app, &state, source_id, root, None); + if started.is_err() { + state.scan_running.store(false, Ordering::SeqCst); + } + started +} + /// 소스를 인덱스에서 제외한다 — **디스크의 실제 파일은 삭제하지 않는다.** /// 워처 해제, (원격이면) 연결 해제·자격증명 삭제, 썸네일 캐시 정리 포함. #[tauri::command] @@ -224,6 +597,10 @@ pub fn start_scan( } /// 스캔+메타+썸네일 파이프라인 본체 (블로킹). scan_running 관리는 호출자 책임. +/// +/// 반환: 열거 단계의 통계. `None`이면 스캔이 취소되거나 실패한 것이다. +/// 워처의 폴링 백오프가 "이번에 정말 바뀐 게 있었나"를 이 값으로 판단한다 — +/// 없으면 다음 재스캔을 늦춘다(조용한 대규모 NAS를 계속 훑지 않기 위해). pub fn run_scan_pipeline( db: &std::sync::Arc, tools: &Option, @@ -233,14 +610,14 @@ pub fn run_scan_pipeline( cancel: &std::sync::atomic::AtomicBool, mut on_progress: impl FnMut(ScanProgress), on_refresh: impl Fn(), -) { +) -> Option { let result = archive_indexer::pipeline::scan_source(db, source_id, root, cancel, &mut on_progress); match result { Ok(stats) => { tracing::info!(?stats, source_id, "스캔(열거) 완료 — 즉시 표시 후 메타 진행"); // 열거가 끝나면 파일 행은 이미 DB에 있다. 느린 메타(특히 영상 ffprobe)를 // 기다리지 않고 ① 썸네일 생성을 시작하고 ② 그리드를 바로 띄운다. - thumbs.enqueue_pending(db, Some(source_id)); + thumbs.enqueue_pending(Some(source_id)); on_refresh(); // 이미지 메타는 헤더만 읽어 빠름 → 끝나면 사진 종횡비 재배치. match archive_indexer::meta::extract_image_meta(db, source_id, cancel) { @@ -256,11 +633,23 @@ pub fn run_scan_pipeline( Err(e) => tracing::error!("영상 메타 추출 오류: {e}"), } } + Some(stats) + } + Err(e) => { + tracing::warn!("스캔 중단: {e}"); + None } - Err(e) => tracing::warn!("스캔 중단: {e}"), } } +/// `scan-guarded` 페이로드 — 어느 소스에서 몇 건이 보류됐는지. +#[derive(serde::Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct ScanGuarded { + source_id: i64, + count: u64, +} + /// 스캔 스레드 — start_scan 커맨드, 자동 소스 추가(dev), 워처가 공유한다. pub fn spawn_scan_thread( app: tauri::AppHandle, @@ -281,14 +670,32 @@ pub fn spawn_scan_thread( // 스캔/메타 오케스트레이션 스레드도 저우선순위로 — UI 응답성 우선 archive_indexer::prio::set_current_thread_low(); let app_refresh = app.clone(); - run_scan_pipeline( + // ── 열거 중 점진 갱신 ──────────────────────────────────────────── + // 예전에는 열거가 **전부** 끝난 뒤에야 그리드가 떴다. 10TB·100만 건 SMB 소스에서는 + // 그게 10분 이상이고, 그 동안 화면은 빈 채로 숫자만 올라가 "멈춘 것처럼" 보인다. + // flush_batch가 1000행마다 커밋하므로 중간 상태도 이미 조회 가능하다 — + // 시간(2초)과 건수(5000) 양쪽 스로틀로 갱신을 걸어 그리드가 차오르게 한다. + // (스로틀이 없으면 folder_snapshot이 100만 항목을 초당 여러 번 다시 만든다) + let progress_refresh = app.clone(); + let mut last_emit = std::time::Instant::now(); + let mut last_seen: u64 = 0; + let stats = run_scan_pipeline( &db, &tools, &thumbs, source_id, Path::new(&root), &cancel, - |p| { + move |p| { + // 갱신 판단은 send 전에 — send가 p를 소비한다 + if p.phase == "enumerate" + && p.seen >= last_seen + 5_000 + && last_emit.elapsed() >= std::time::Duration::from_secs(2) + { + last_emit = std::time::Instant::now(); + last_seen = p.seen; + let _ = progress_refresh.emit("library-changed", source_id); + } if let Some(ch) = &on_progress { let _ = ch.send(p); } @@ -299,6 +706,19 @@ pub fn spawn_scan_thread( }, ); running.store(false, Ordering::SeqCst); + // 대량 삭제를 안전장치가 막았다면 조용히 넘기지 않는다 — + // 사용자는 "왜 사라진 파일이 목록에 남아 있는지" 알아야 하고, + // 반대로 정말 지운 경우엔 확인 후 다시 스캔할 수 있어야 한다. + // source_id를 함께 보낸다 — 사용자가 '사라진 파일 정리'로 확정할 때 + // 어느 소스를 정리할지 UI가 알아야 한다. + if let Some(s) = &stats { + if s.guarded_deletes > 0 { + let _ = app.emit( + "scan-guarded", + ScanGuarded { source_id, count: s.guarded_deletes }, + ); + } + } // 영상 메타까지 끝난 뒤 최종 재배치 let _ = app.emit("library-changed", source_id); }) @@ -306,6 +726,61 @@ pub fn spawn_scan_thread( Ok(()) } +/// 수동 새로고침 — 로컬 소스 전체를 증분 재스캔한다. +/// 워처가 못 미치는 환경(변경 알림을 안 주는 NAS/SMB 서버)에서 F5로 강제 동기화하는 경로. +#[tauri::command] +pub fn rescan_all(app: tauri::AppHandle, state: tauri::State<'_, AppState>) -> CmdResult<()> { + if state.scan_running.swap(true, Ordering::SeqCst) { + return Err("이미 스캔이 진행 중입니다".into()); + } + state.scan_cancel.store(false, Ordering::SeqCst); + + let sources: Vec<(i64, String)> = state + .db + .with_read(|conn| { + let mut stmt = conn.prepare("SELECT id, root FROM sources WHERE kind = 'local'")?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; + rows.collect() + }) + .map_err(err_str)?; + + let db = state.db.clone(); + let cancel = state.scan_cancel.clone(); + let running = state.scan_running.clone(); + let thumbs = state.thumbs.clone(); + let tools = state.tools.clone(); + std::thread::Builder::new() + .name("rescan-all".into()) + .spawn(move || { + archive_indexer::prio::set_current_thread_low(); + for (source_id, root) in sources { + if cancel.load(Ordering::SeqCst) { + break; + } + let app_refresh = app.clone(); + run_scan_pipeline( + &db, + &tools, + &thumbs, + source_id, + Path::new(&root), + &cancel, + |_| {}, + move || { + let _ = app_refresh.emit("library-changed", source_id); + }, + ); + let _ = app.emit("library-changed", source_id); + } + running.store(false, Ordering::SeqCst); + }) + .map_err(|e| { + state.scan_running.store(false, Ordering::SeqCst); + err_str(e) + })?; + Ok(()) +} + #[tauri::command] pub fn cancel_scan(state: tauri::State<'_, AppState>) { state.scan_cancel.store(true, Ordering::SeqCst); @@ -347,9 +822,9 @@ pub fn folder_tree(state: tauri::State<'_, AppState>, source_id: i64) -> CmdResu // ── 바이너리 스냅샷 (항목당 10바이트: id u32, w u16, h u16, kind u8, flags u8) ── -fn pack_snapshot(rows: Vec<(i64, Option, Option, Option, i64, i64)>) -> Vec { +fn pack_snapshot(rows: Vec) -> Vec { let mut buf = Vec::with_capacity(rows.len() * 10); - for (id, w, h, orientation, kind, thumb_state) in rows { + for (id, w, h, orientation, kind, thumb_state, rating) in rows { let (mut w, mut h) = (w.unwrap_or(0), h.unwrap_or(0)); // EXIF 회전 5–8은 표시 치수가 뒤바뀐다 if matches!(orientation, Some(5..=8)) { @@ -361,47 +836,62 @@ fn pack_snapshot(rows: Vec<(i64, Option, Option, Option, i64, i64 buf.push(kind as u8); // 비트0 = 썸네일 준비됨(state 1), 비트1 = 생성 시도했으나 실패(state 2). // 둘 다 0이면 생성 대기(state 0) — 프론트에서 스켈레톤/실패표시 구분용. - let flags = match thumb_state { + // 비트2~4 = 별점(0~5). 남는 비트에 얹었으므로 **항목당 10바이트가 그대로다** — + // 100만 건 스냅샷이 10MB에서 커지지 않는다. + let mut flags = match thumb_state { 1 => 1u8, 2 => 2u8, _ => 0u8, }; + flags |= ((rating.clamp(0, 5) as u8) & 0b111) << 2; buf.push(flags); } buf } -type SnapRow = (i64, Option, Option, Option, i64, i64); -const SNAP_COLS: &str = "f.id, f.width, f.height, f.orientation, f.kind, f.thumb_state"; +type SnapRow = (i64, Option, Option, Option, i64, i64, i64); +const SNAP_COLS: &str = + "f.id, f.width, f.height, f.orientation, f.kind, f.thumb_state, f.rating"; /// 스냅샷 공통 옵션 — 종류 필터 + 정렬. 프론트 툴바에서 전달. #[derive(Default)] pub struct SnapOpts { - pub sort_key: Option, // taken(기본)|name|size|mtime + pub sort_key: Option, // taken(기본)|name|size|mtime|rating pub sort_desc: Option, // 기본 내림차순 pub kind: Option, // 0=사진, 1=영상, 그 외/None=전체 + /// 이 별점 **이상**만. 1~5, 그 외/None이면 전체. + pub min_rating: Option, } -/// WHERE 꼬리(종류 필터) + ORDER BY 절을 만든다. -/// sort_key/kind는 고정 집합에서만 해석하므로 SQL 주입 안전(kind는 i64 리터럴). +/// WHERE 꼬리(종류·별점 필터) + ORDER BY 절을 만든다. +/// sort_key/kind/min_rating은 고정 집합에서만 해석하므로 SQL 주입 안전(전부 리터럴로 만든다). fn snap_tail(o: &SnapOpts) -> String { let kind_clause = match o.kind { Some(0) => " AND f.kind = 0", Some(1) => " AND f.kind = 1", _ => "", }; + let rating_clause = match o.min_rating { + Some(n) if (1..=5).contains(&n) => format!(" AND f.rating >= {n}"), + _ => String::new(), + }; let dir = if o.sort_desc.unwrap_or(true) { "DESC" } else { "ASC" }; let order = match o.sort_key.as_deref() { Some("name") => format!("ORDER BY f.name COLLATE NOCASE {dir}, f.id {dir}"), Some("size") => format!("ORDER BY f.size {dir}, f.id {dir}"), Some("mtime") => format!("ORDER BY f.mtime_ms {dir}, f.id {dir}"), + // 별점이 같은 것끼리는 최신 촬영일이 위로 — 별점만으로는 순서가 거의 안 정해진다 + // (대부분 0이거나 같은 값이라, id 순으로 두면 사실상 무작위로 보인다) + Some("rating") => { + format!("ORDER BY f.rating {dir}, COALESCE(f.taken_at, f.mtime_ms) DESC, f.id DESC") + } _ => format!("ORDER BY COALESCE(f.taken_at, f.mtime_ms) {dir}, f.id {dir}"), }; - format!("{kind_clause} {order}") + format!("{kind_clause}{rating_clause} {order}") } fn map_snap_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)) } #[tauri::command] @@ -413,8 +903,9 @@ pub fn folder_snapshot( sort_key: Option, sort_desc: Option, kind: Option, + min_rating: Option, ) -> CmdResult { - let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind }); + let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind, min_rating }); let rows: Vec = state .db .with_read(|conn| { @@ -480,12 +971,13 @@ pub fn search_snapshot( sort_key: Option, sort_desc: Option, kind: Option, + min_rating: Option, ) -> CmdResult { let q = query.trim().to_string(); if q.is_empty() { return Ok(Response::new(Vec::new())); } - let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind }); + let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind, min_rating }); let use_fts = state.db.fts_enabled && q.chars().count() >= 3; let rows: Vec = state .db @@ -531,6 +1023,8 @@ pub struct FileDetails { pub duration_ms: Option, pub kind: i64, pub taken_at: Option, + /// 0=없음 ~ 5 + pub rating: i64, } #[tauri::command] @@ -541,7 +1035,7 @@ pub fn file_details(state: tauri::State<'_, AppState>, ids: Vec) -> CmdResu .with_read(|conn| { let mut stmt = conn.prepare_cached( "SELECT f.id, f.name, fo.path, f.size, f.mtime_ms, f.width, f.height, - f.duration_ms, f.kind, f.taken_at + f.duration_ms, f.kind, f.taken_at, f.rating FROM files f JOIN folders fo ON fo.id = f.folder_id WHERE f.id = ?1", )?; let mut out = Vec::with_capacity(ids.len()); @@ -558,6 +1052,7 @@ pub fn file_details(state: tauri::State<'_, AppState>, ids: Vec) -> CmdResu duration_ms: r.get(7)?, kind: r.get(8)?, taken_at: r.get(9)?, + rating: r.get(10)?, }) }) { out.push(d); @@ -598,13 +1093,27 @@ pub fn create_tag( tags::create_tag(&state.db, name, color).map_err(err_str) } +/// 시스템 '삭제 예정' 태그는 이름 변경·삭제를 막는다 (비우기 파이프라인이 이름에 의존). +fn guard_system_tag(state: &AppState, tag_id: i64) -> CmdResult<()> { + let trash_id = tags::ensure_trash_tag(&state.db).map_err(err_str)?; + if tag_id == trash_id { + return Err(format!("'{}'은(는) 시스템 태그라 변경할 수 없습니다", tags::TRASH_TAG_NAME)); + } + Ok(()) +} + #[tauri::command] pub fn rename_tag(state: tauri::State<'_, AppState>, tag_id: i64, name: String) -> CmdResult<()> { + guard_system_tag(&state, tag_id)?; + if name.trim() == tags::TRASH_TAG_NAME { + return Err(format!("'{}'은(는) 예약된 이름입니다", tags::TRASH_TAG_NAME)); + } tags::rename_tag(&state.db, tag_id, name).map_err(err_str) } #[tauri::command] pub fn delete_tag(state: tauri::State<'_, AppState>, tag_id: i64) -> CmdResult<()> { + guard_system_tag(&state, tag_id)?; tags::delete_tag(&state.db, tag_id).map_err(err_str) } @@ -642,6 +1151,267 @@ pub fn file_tags_of(state: tauri::State<'_, AppState>, file_id: i64) -> CmdResul tags::file_tags(&state.db, file_id).map_err(err_str) } +/// 그리드 가시 범위의 태그를 한 번에 — 썸네일 위 태그 표시용. +#[tauri::command] +pub fn file_tags_bulk( + state: tauri::State<'_, AppState>, + file_ids: Vec, +) -> CmdResult> { + tags::file_tags_bulk(&state.db, file_ids).map_err(err_str) +} + +// ── 별점 ────────────────────────────────────────────────── + +/// 선택한 파일들의 별점을 정한다 (0=없음 ~ 5). 같은 값을 다시 주면 그대로다. +/// +/// 저널에 남기지 않는다 — 값 하나짜리 되돌리기는 별을 다시 누르는 것이 더 빠르고, +/// 파일을 옮기거나 지우는 조작과 같은 무게로 실행 취소 목록에 끼면 오히려 방해가 된다. +#[tauri::command] +pub fn set_rating( + state: tauri::State<'_, AppState>, + file_ids: Vec, + rating: i64, +) -> CmdResult { + if file_ids.is_empty() { + return Ok(0); + } + let r = rating.clamp(0, 5); + state + .db + .with_write(move |conn| { + let tx = conn.transaction()?; + let mut n = 0u64; + { + let mut up = tx.prepare_cached("UPDATE files SET rating = ?2 WHERE id = ?1")?; + for id in &file_ids { + n += up.execute(rusqlite::params![id, r])? as u64; + } + } + tx.commit()?; + Ok(n) + }) + .map_err(err_str) +} + +// ── 태그 세트 ───────────────────────────────────────────── + +#[tauri::command] +pub fn list_tag_sets(state: tauri::State<'_, AppState>) -> CmdResult> { + tags::list_tag_sets(&state.db).map_err(err_str) +} + +#[tauri::command] +pub fn create_tag_set( + state: tauri::State<'_, AppState>, + name: String, + tag_ids: Vec, +) -> CmdResult { + let name = name.trim().to_string(); + if name.is_empty() { + return Err("세트 이름이 비어 있습니다".into()); + } + tags::create_tag_set(&state.db, name, tag_ids).map_err(err_str) +} + +/// 이름·단축키·구성 태그를 바꾼다. 보내지 않은 항목은 그대로 둔다. +/// `hotkey`는 3상태다: 생략(그대로) / `Some(None)`(해제) / `Some(Some(k))`(지정). +#[tauri::command] +pub fn update_tag_set( + state: tauri::State<'_, AppState>, + set_id: i64, + name: Option, + hotkey: Option>, + tag_ids: Option>, +) -> CmdResult<()> { + tags::update_tag_set(&state.db, set_id, name, hotkey, tag_ids).map_err(err_str) +} + +#[tauri::command] +pub fn delete_tag_set(state: tauri::State<'_, AppState>, set_id: i64) -> CmdResult<()> { + tags::delete_tag_set(&state.db, set_id).map_err(err_str) +} + +/// 세트의 태그를 파일들에 일괄 부착. 개별 태그 부착과 같은 저널을 남긴다(실행 취소 동일). +#[tauri::command] +pub fn apply_tag_set( + state: tauri::State<'_, AppState>, + file_ids: Vec, + set_id: i64, +) -> CmdResult { + let batch_id = fileops::new_batch_id(); + let count = tags::apply_tag_set(&state.db, &batch_id, file_ids, set_id).map_err(err_str)?; + Ok(TagOpResult { batch_id, count }) +} + +// ── 삭제 대기열 ('삭제 예정' 태그 → 비우기) ────────────── + +/// 시스템 '삭제 예정' 태그 id — 프런트가 사이드바에서 특별 취급하기 위해 필요. +#[tauri::command] +pub fn trash_tag_id(state: tauri::State<'_, AppState>) -> CmdResult { + tags::ensure_trash_tag(&state.db).map_err(err_str) +} + +/// 삭제 예정으로 표시 (되돌리기 가능 — ops_journal에 'tag'로 기록됨). +#[tauri::command] +pub fn mark_delete( + state: tauri::State<'_, AppState>, + file_ids: Vec, +) -> CmdResult { + let tag_id = tags::ensure_trash_tag(&state.db).map_err(err_str)?; + let batch_id = fileops::new_batch_id(); + let count = tags::assign(&state.db, &batch_id, file_ids, tag_id).map_err(err_str)?; + Ok(TagOpResult { batch_id, count }) +} + +/// 삭제 예정 표시 해제. +#[tauri::command] +pub fn unmark_delete( + state: tauri::State<'_, AppState>, + file_ids: Vec, +) -> CmdResult { + let tag_id = tags::ensure_trash_tag(&state.db).map_err(err_str)?; + let batch_id = fileops::new_batch_id(); + let count = tags::unassign(&state.db, &batch_id, file_ids, tag_id).map_err(err_str)?; + Ok(TagOpResult { batch_id, count }) +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingDelete { + pub count: i64, + pub bytes: i64, +} + +/// 비우기 확인창용 통계 (개수 / 총 용량). +#[tauri::command] +pub fn pending_delete_stats(state: tauri::State<'_, AppState>) -> CmdResult { + let (count, bytes) = tags::pending_delete_stats(&state.db).map_err(err_str)?; + Ok(PendingDelete { count, bytes }) +} + +struct PendingRow { + file_id: i64, + source_id: i64, + source_kind: String, + dir: String, + name: String, +} + +/// 삭제 예정 항목을 소스 종류까지 붙여 읽는다 (로컬/원격 처리 경로가 다르다). +fn load_pending_rows(state: &AppState) -> CmdResult> { + let tag_id = tags::ensure_trash_tag(&state.db).map_err(err_str)?; + state + .db + .with_read(move |conn| { + let mut stmt = conn.prepare( + "SELECT f.id, f.source_id, s.kind, fo.path, f.name FROM files f + JOIN file_tags ft ON ft.file_id = f.id + JOIN folders fo ON fo.id = f.folder_id + JOIN sources s ON s.id = f.source_id + WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL", + )?; + let rows = stmt.query_map([tag_id], |r| { + Ok(PendingRow { + file_id: r.get(0)?, + source_id: r.get(1)?, + source_kind: r.get(2)?, + dir: r.get(3)?, + name: r.get(4)?, + }) + })?; + rows.collect() + }) + .map_err(err_str) +} + +/// 비우기 — 삭제 예정 파일을 **영구 삭제**한다. 복구 불가. +/// 로컬은 디스크에서 바로 지우고, 원격은 프로토콜 delete를 쓴다(연결 필요). +/// 사용자 확인은 호출 측(UI)에서 이미 받은 것으로 간주한다. +#[tauri::command] +pub async fn empty_deleted( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + on_progress: Channel, +) -> CmdResult { + let rows = load_pending_rows(&state)?; + let total = rows.len() as u64; + let batch_id = fileops::new_batch_id(); + let progress = std::sync::Arc::new(on_progress); + let mut summary = OpSummary { + batch_id: batch_id.clone(), + ..Default::default() + }; + + // ① 로컬 — 블로킹 스레드에서 일괄 처리 + let local_ids: Vec = rows + .iter() + .filter(|r| r.source_kind == "local") + .map(|r| r.file_id) + .collect(); + let mut handled = 0u64; + if !local_ids.is_empty() { + let db = state.db.clone(); + let bid = batch_id.clone(); + let ch = progress.clone(); + let local = tauri::async_runtime::spawn_blocking(move || { + fileops::delete_permanent(&db, &bid, &local_ids, |done, _| { + let _ = ch.send(OpProgress { done, total }); + }) + }) + .await + .map_err(err_str)? + .map_err(err_str)?; + handled = local.done + local.skipped + local.failed; + summary.done += local.done; + summary.skipped += local.skipped; + summary.failed += local.failed; + } + + // ② 원격 — 연결된 프로바이더의 delete 사용 (미연결 소스는 실패로 남긴다) + for row in rows.iter().filter(|r| r.source_kind != "local") { + handled += 1; + let _ = progress.send(OpProgress { done: handled, total }); + let Some(provider) = state.remote.get(row.source_id).await else { + tracing::warn!(source_id = row.source_id, "원격 소스 미연결 — 영구 삭제 건너뜀"); + summary.failed += 1; + continue; + }; + let rel = if row.dir == "/" || row.dir.is_empty() { + row.name.clone() + } else { + format!("{}/{}", row.dir.trim_start_matches('/'), row.name) + }; + match provider.delete(&rel).await { + Ok(()) => { + let (bid, fid, path) = (batch_id.clone(), row.file_id, rel); + state + .db + .with_write(move |conn| { + conn.execute( + "UPDATE files SET deleted_at = unixepoch('now','subsec')*1000 WHERE id = ?1", + [fid], + )?; + conn.execute( + "INSERT INTO ops_journal(batch_id, op, file_id, src_path) + VALUES (?1, 'delete', ?2, ?3)", + rusqlite::params![bid, fid, path], + )?; + Ok(()) + }) + .map_err(err_str)?; + summary.done += 1; + } + Err(e) => { + tracing::warn!(path = %rel, "원격 영구 삭제 실패: {e}"); + summary.failed += 1; + } + } + } + + let _ = app.emit("library-changed", 0i64); + Ok(summary) +} + /// 태그 JSON 내보내기 — 경로를 반환한다. #[tauri::command] pub fn export_tags(state: tauri::State<'_, AppState>, path: Option) -> CmdResult { @@ -661,8 +1431,9 @@ pub fn tag_snapshot( sort_key: Option, sort_desc: Option, kind: Option, + min_rating: Option, ) -> CmdResult { - let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind }); + let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind, min_rating }); let rows: Vec = state .db .with_read(|conn| { @@ -751,12 +1522,15 @@ pub async fn undo_last( state: tauri::State<'_, AppState>, ) -> CmdResult> { let db = state.db.clone(); + let tools = state.tools.clone(); let result = tauri::async_runtime::spawn_blocking(move || { let batches = fileops::list_batches(&db, 10)?; let Some(target) = batches.into_iter().find(|b| b.undoable) else { return Ok::, archive_indexer::fileops::OpError>(None); }; - Ok(Some(fileops::undo_batch(&db, &target.batch_id)?)) + // 편집 되돌리기(반전)는 재인코딩이 필요할 수 있어 인코더가 필요하다 + let enc = tools.as_ref().map(archive_indexer::edit::h264_encoder).unwrap_or("libopenh264"); + Ok(Some(fileops::undo_batch(&db, &target.batch_id, tools.as_ref(), enc)?)) }) .await .map_err(err_str)? @@ -846,8 +1620,9 @@ pub fn smart_snapshot( sort_key: Option, sort_desc: Option, kind: Option, + min_rating: Option, ) -> CmdResult { - let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind }); + let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind, min_rating }); let query: SmartQuery = state .db .with_read(|conn| { @@ -1145,6 +1920,7 @@ pub fn start_dedupe( recursive: Option, image_threshold: u32, include_video: bool, + include_rotated: bool, on_progress: Channel, ) -> CmdResult<()> { if state.dedupe_running.swap(true, Ordering::SeqCst) { @@ -1160,19 +1936,20 @@ pub fn start_dedupe( }; let db = state.db.clone(); - let tools = state.tools.clone(); let cancel = state.dedupe_cancel.clone(); let running = state.dedupe_running.clone(); + // ffmpeg는 더 이상 필요 없다 — 지문은 sigq가 미리 만들어 DB에 넣어두고, + // 탐지는 그 지문만 읽는다(영상 1편당 프로세스 11개를 띄우던 코드는 사라졌다). std::thread::Builder::new() .name("dedupe".into()) .spawn(move || { let result = dedupe::run( &db, - tools.as_ref(), scope, image_threshold, include_video, + include_rotated, &cancel, |p| { let _ = on_progress.send(p); @@ -1183,7 +1960,8 @@ pub fn start_dedupe( Err(e) => tracing::warn!("중복 탐지 중단: {e}"), } running.store(false, Ordering::SeqCst); - let _ = app.emit("dedupe-done", result.map(|s| s).ok()); + // 취소·오류여도 반드시 보낸다 — 안 보내면 프론트가 '실행 중'에 영구 고착된다 + let _ = app.emit("dedupe-done", result.ok()); }) .map_err(err_str)?; Ok(()) @@ -1200,6 +1978,9 @@ pub struct DupeMember { pub file_id: i64, pub name: String, pub dir: String, + /// 이 파일이 있는 폴더 — 검토 중 "이게 어디 있는 거지"를 바로 열어보기 위해 + pub folder_id: i64, + pub source_id: i64, pub size: i64, pub width: Option, pub height: Option, @@ -1207,6 +1988,12 @@ pub struct DupeMember { pub kind: i64, pub is_keeper: bool, pub distance: Option, + /// `keeper 시각 - 이 멤버 시각`(ms). 비교 재생이 두 영상을 같은 내용에 맞추는 값. + /// None이면 정렬을 확인하지 못한 멤버(전이 병합) — UI가 '맞춤 없음'으로 알린다. + pub offset_ms: Option, + /// keeper 시간축에서 일치가 확인된 구간 + pub seg_start_ms: Option, + pub seg_end_ms: Option, } #[derive(serde::Serialize)] @@ -1214,52 +2001,64 @@ pub struct DupeMember { pub struct DupeGroup { pub id: i64, pub kind: i64, + /// 유사도 0~1 (완전 동일은 1.0). UI가 등급(확실/의심)을 여기서 파생한다. + pub score: Option, pub members: Vec, } +/// 한 그룹의 멤버 조회. 목록 전체와 **같은 SQL·같은 정렬**을 쓴다 — 두 곳이 갈라지면 +/// 보관 항목을 바꾼 뒤의 화면과 다시 열었을 때의 화면이 달라진다. +const DUPE_MEMBER_SQL: &str = "SELECT dm.file_id, f.name, fo.path, f.size, f.width, f.height, + f.duration_ms, f.kind, dm.is_keeper, dm.distance, + dm.offset_ms, dm.seg_start_ms, dm.seg_end_ms, + f.folder_id, f.source_id + FROM dupe_members dm + JOIN files f ON f.id = dm.file_id + JOIN folders fo ON fo.id = f.folder_id + WHERE dm.group_id = ?1 AND f.deleted_at IS NULL + ORDER BY dm.is_keeper DESC, f.width * f.height DESC"; + +fn map_dupe_member(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(DupeMember { + file_id: r.get(0)?, + name: r.get(1)?, + dir: r.get(2)?, + size: r.get(3)?, + width: r.get(4)?, + height: r.get(5)?, + duration_ms: r.get(6)?, + kind: r.get(7)?, + is_keeper: r.get::<_, i64>(8)? != 0, + distance: r.get(9)?, + offset_ms: r.get(10)?, + seg_start_ms: r.get(11)?, + seg_end_ms: r.get(12)?, + folder_id: r.get(13)?, + source_id: r.get(14)?, + }) +} + #[tauri::command] pub fn list_dupe_groups(state: tauri::State<'_, AppState>) -> CmdResult> { state .db .with_read(|conn| { let mut gstmt = conn.prepare_cached( - "SELECT id, kind FROM dupe_groups WHERE status = 'open' ORDER BY kind, id", + "SELECT id, kind, score FROM dupe_groups WHERE status = 'open' ORDER BY kind, id", )?; - let group_rows: Vec<(i64, i64)> = gstmt - .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + let group_rows: Vec<(i64, i64, Option)> = gstmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? .collect::>()?; - let mut mstmt = conn.prepare_cached( - "SELECT dm.file_id, f.name, fo.path, f.size, f.width, f.height, f.duration_ms, - f.kind, dm.is_keeper, dm.distance - FROM dupe_members dm - JOIN files f ON f.id = dm.file_id - JOIN folders fo ON fo.id = f.folder_id - WHERE dm.group_id = ?1 AND f.deleted_at IS NULL - ORDER BY dm.is_keeper DESC, f.width * f.height DESC", - )?; - + let mut mstmt = conn.prepare_cached(DUPE_MEMBER_SQL)?; let mut out = Vec::with_capacity(group_rows.len()); - for (gid, kind) in group_rows { + for (gid, kind, score) in group_rows { let members: Vec = mstmt - .query_map([gid], |r| { - Ok(DupeMember { - file_id: r.get(0)?, - name: r.get(1)?, - dir: r.get(2)?, - size: r.get(3)?, - width: r.get(4)?, - height: r.get(5)?, - duration_ms: r.get(6)?, - kind: r.get(7)?, - is_keeper: r.get::<_, i64>(8)? != 0, - distance: r.get(9)?, - }) - })? + .query_map([gid], map_dupe_member)? .collect::>()?; // 멤버가 1개 이하로 줄면(이미 삭제됨) 스킵 if members.len() >= 2 { - out.push(DupeGroup { id: gid, kind, members }); + out.push(DupeGroup { id: gid, kind, score, members }); } } Ok(out) @@ -1279,6 +2078,104 @@ pub fn resolve_dupe_group(state: tauri::State<'_, AppState>, group_id: i64) -> C .map_err(err_str) } +/// 지문 생성 속도 읽기 — "slow" | "normal" | "fast". +#[tauri::command] +pub fn sig_speed(state: tauri::State<'_, AppState>) -> CmdResult { + Ok(archive_indexer::sigq::read_speed(&state.db).as_str().to_string()) +} + +/// 지문 생성 속도 설정. 백필 스레드가 **배치마다 다시 읽으므로** 재시작 없이 반영된다. +#[tauri::command] +pub fn set_sig_speed(state: tauri::State<'_, AppState>, speed: String) -> CmdResult<()> { + // 모르는 값이 들어와도 read_speed가 기본으로 떨어지지만, 저장 단계에서 정규화해 둔다. + let v = archive_indexer::sigq::SigSpeed::from_str(&speed).as_str(); + state + .db + .with_write(move |conn| { + conn.execute( + "INSERT OR REPLACE INTO settings(key, value) VALUES ('sig_speed', ?1)", + [v], + )?; + Ok(()) + }) + .map_err(err_str) +} + +/// 그룹에서 보관할 파일을 지정한다. +/// +/// 탐지가 고른 기본 보관 항목(해상도 → 파일 크기 순)이 늘 사용자가 원하는 것은 아니다 — +/// 파일명·폴더 위치·화질 판단은 사람만 할 수 있다. **삭제 대상을 바꾸는 값**이므로 +/// 화면에만 두지 않고 DB에 남긴다: 창을 닫았다 열어도, 다시 조회해도 선택이 유지된다. +/// +/// `offset_ms`·`seg_*_ms`는 **보관 항목 시간축 기준**으로 저장된 값이라 기준이 바뀌면 +/// 함께 옮겨야 한다. 안 옮기면 비교 재생이 통째로 어긋난다(실측: 5초 인트로가 붙은 사본을 +/// 보관으로 고르자 나머지 7개가 전부 5초씩 밀렸다). +/// off(m) = t_보관 − t_m 이므로, 새 보관 N에 대해 off_new(m) = off_old(m) − off_old(N). +/// 구간도 보관 시간축의 값이라 같은 양만큼 옮긴다. +/// 반환값은 **옮겨진 뒤의 멤버들**이다. 화면이 같은 계산을 다시 하면 두 구현이 갈라진다 — +/// 옮기는 규칙은 여기 한 곳에만 둔다. +#[tauri::command] +pub fn set_dupe_keeper( + state: tauri::State<'_, AppState>, + group_id: i64, + file_id: i64, +) -> CmdResult> { + state + .db + .with_write(move |conn| { + let tx = conn.transaction()?; + // 지정 대상이 이 그룹에 있는지부터 본다 — 없으면 전부 0이 되어 + // **보관 항목이 하나도 없는 그룹**이 되고, 그러면 '나머지 휴지통'이 전부를 지운다. + let shift: Option> = tx + .query_row( + "SELECT offset_ms FROM dupe_members WHERE group_id = ?1 AND file_id = ?2", + rusqlite::params![group_id, file_id], + |r| r.get(0), + ) + .optional()?; + let Some(shift) = shift else { + return Err(rusqlite::Error::QueryReturnedNoRows); + }; + + match shift { + // 새 보관 항목의 정렬을 아는 경우 — 그만큼 전체를 옮긴다. + // NULL(정렬 미확인 멤버)은 SQL에서 NULL로 남아 '모름'이 유지된다. + Some(s) => tx.execute( + "UPDATE dupe_members + SET offset_ms = offset_ms - ?2, + seg_start_ms = seg_start_ms - ?2, + seg_end_ms = seg_end_ms - ?2 + WHERE group_id = ?1", + rusqlite::params![group_id, s], + )?, + // 새 기준 자신의 정렬을 모르면 다른 값도 옮길 수 없다. 맞춰진 척하는 것보다 + // 모른다고 말하는 게 낫다 — 비교 뷰에 '맞춤 정보 없음'이 뜬다. + None => tx.execute( + "UPDATE dupe_members + SET offset_ms = NULL, seg_start_ms = NULL, seg_end_ms = NULL + WHERE group_id = ?1", + [group_id], + )?, + }; + + tx.execute("UPDATE dupe_members SET is_keeper = 0 WHERE group_id = ?1", [group_id])?; + // 보관 항목 자신은 기준이므로 오프셋 0 (위 계산으로도 0이 되지만 NULL 분기가 있다) + tx.execute( + "UPDATE dupe_members SET is_keeper = 1, offset_ms = 0 + WHERE group_id = ?1 AND file_id = ?2", + rusqlite::params![group_id, file_id], + )?; + + let mut stmt = tx.prepare(DUPE_MEMBER_SQL)?; + let members: Vec = + stmt.query_map([group_id], map_dupe_member)?.collect::>()?; + drop(stmt); + tx.commit()?; + Ok(members) + }) + .map_err(err_str) +} + /// 그룹의 특정 두 파일을 "다른 파일"로 표시 (재탐지 시 제외). #[tauri::command] pub fn dismiss_dupe_pair( @@ -1437,3 +2334,148 @@ pub fn start_stream( pub fn stop_stream(state: tauri::State<'_, AppState>, job_id: u64) { state.media.streams.stop(job_id); } + +// ── 간단 편집 (회전·반전) ──────────────────────────────── + +#[derive(serde::Serialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct EditSummaryDto { + pub batch_id: String, + pub done: u64, + pub failed: u64, + pub errors: Vec, + /// 재인코딩된 파일 수 — 0이면 화질 손실이 전혀 없었다는 뜻이다 + pub reencoded: u64, +} + +/// 고른 파일들을 회전·반전한다. **원본을 직접 고치고** Ctrl+Z로 되돌릴 수 있다. +/// +/// 사진(JPEG)은 EXIF 방향 태그만, 영상 회전은 컨테이너 회전 행렬만 바꾼다 — +/// 둘 다 화질 손실 0이고 파일 크기가 그대로다. 영상 좌우/상하 반전만 +/// 회전 행렬로 표현할 수 없어 다시 인코딩한다. +#[tauri::command] +pub async fn edit_files( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + file_ids: Vec, + op: archive_indexer::edit::EditOp, + on_progress: Channel, +) -> CmdResult { + let tools = state.tools.clone().ok_or("ffmpeg 사이드카를 찾을 수 없습니다")?; + let db = state.db.clone(); + let batch_id = fileops::new_batch_id(); + let ch = std::sync::Arc::new(on_progress); + + let bid = batch_id.clone(); + let sum = tauri::async_runtime::spawn_blocking(move || { + let enc = archive_indexer::edit::h264_encoder(&tools); + fileops::edit_files(&db, &tools, &bid, &file_ids, op, enc, |done, total| { + let _ = ch.send(OpProgress { done, total }); + }) + }) + .await + .map_err(err_str)? + .map_err(err_str)?; + + let _ = app.emit("library-changed", 0i64); + Ok(EditSummaryDto { + batch_id: sum.batch_id, + done: sum.done, + failed: sum.failed, + errors: sum.errors, + reencoded: sum.reencoded, + }) +} + +/// 자르기 · 구간 자르기 — **되돌릴 수 없는 편집**이라 원본을 백업으로 옮겨 둔다. +/// Ctrl+Z를 누르면 그 백업이 제자리로 돌아온다. +#[tauri::command] +pub async fn cut_file( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + file_id: i64, + cut: fileops::Cut, +) -> CmdResult { + let tools = state.tools.clone().ok_or("ffmpeg 사이드카를 찾을 수 없습니다")?; + let db = state.db.clone(); + let batch_id = fileops::new_batch_id(); + let bid = batch_id.clone(); + + let r = tauri::async_runtime::spawn_blocking(move || { + let enc = archive_indexer::edit::h264_encoder(&tools); + fileops::cut_file(&db, &tools, &bid, file_id, cut, enc) + }) + .await + .map_err(err_str)? + .map_err(err_str)?; + + let _ = app.emit("library-changed", 0i64); + Ok(EditSummaryDto { + batch_id, + done: 1, + failed: 0, + errors: Vec::new(), + reencoded: u64::from(r.reencoded), + }) +} + +#[derive(serde::Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct StripFrameDto { + pub at_ms: i64, + /// `data:image/jpeg;base64,…` — 파일이나 서버 라우트를 만들지 않는다. + /// 12장 × 3KB면 base64로도 50KB 남짓이라 IPC로 넘기는 편이 수명 관리보다 싸다. + pub url: String, +} + +/// 구간 자르기 타임라인에 깔 필름스트립. 영상만. +#[tauri::command] +pub async fn filmstrip( + state: tauri::State<'_, AppState>, + file_id: i64, + count: u32, +) -> CmdResult> { + let tools = state.tools.clone().ok_or("ffmpeg 사이드카를 찾을 수 없습니다")?; + let db = state.db.clone(); + let (dir, name, duration): (String, String, i64) = db + .with_read(move |conn| { + conn.query_row( + "SELECT fo.path, f.name, COALESCE(f.duration_ms, 0) 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)?)), + ) + }) + .map_err(err_str)?; + if duration <= 0 { + return Ok(Vec::new()); // 길이를 모르면 균등 분할을 할 수 없다 + } + let path = std::path::PathBuf::from(dir).join(name); + + tauri::async_runtime::spawn_blocking(move || { + archive_indexer::edit::filmstrip(&tools, &path, duration, count, 72) + .into_iter() + .map(|f| StripFrameDto { + at_ms: f.at_ms, + url: format!("data:image/jpeg;base64,{}", b64(&f.jpeg)), + }) + .collect() + }) + .await + .map_err(err_str) +} + +/// 의존성을 늘리지 않으려고 직접 적는다 — 표준 base64, 패딩 포함. +fn b64(bytes: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for c in bytes.chunks(3) { + let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)]; + let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32; + out.push(T[(n >> 18) as usize & 63] as char); + out.push(T[(n >> 12) as usize & 63] as char); + out.push(if c.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if c.len() > 2 { T[n as usize & 63] as char } else { '=' }); + } + out +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2abb3af..47cabbb 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -28,6 +28,10 @@ pub struct AppState { pub scan_running: Arc, pub dedupe_cancel: Arc, pub dedupe_running: Arc, + /// 지문 워커 중단 플래그 (앱 종료 시) + pub sig_cancel: Arc, + /// 하드웨어 디코드 사용 여부 — 기동 시 1회 실제 디코드로 판별한 결과 + pub hwaccel: Option<&'static str>, pub remote: Arc, /// 자격증명 저장소 (포터블 볼트 잠금 해제 후 교체 가능) pub creds: Arc>, @@ -40,13 +44,44 @@ fn now_ms() -> i64 { .unwrap_or(0) } -fn main() { +/// 로그를 **파일로도** 남긴다. 릴리스는 windows_subsystem="windows"라 콘솔이 없어서 +/// stdout만 쓰면 사용자 PC에서 무슨 일이 있었는지 확인할 방법이 아예 없다 +/// (실제로 "지문이 안 만들어진다"는 신고에서 로그 폴더가 비어 있어 원인을 못 찾았다). +/// +/// 가드를 돌려주는 이유: 드롭되면 백그라운드 기록 스레드가 멈춰 마지막 줄들이 날아간다. +/// main이 끝날 때까지 살아 있어야 한다. +fn init_logging() -> Option { + let filter = || { + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()) + }; + // 데이터 위치는 setup보다 먼저 알아야 한다(로그를 그 안에 쓰므로). 실패하면 stdout만. + let Ok(data) = paths::resolve_data_dir() else { + tracing_subscriber::fmt().with_env_filter(filter()).init(); + return None; + }; + let dir = data.path.join("logs"); + if std::fs::create_dir_all(&dir).is_err() { + tracing_subscriber::fmt().with_env_filter(filter()).init(); + return None; + } + // 하루 한 파일. 오래된 것은 남겨둔다 — 며칠 전 증상을 되짚어야 하는 일이 잦다. + let appender = tracing_appender::rolling::daily(&dir, "archive.log"); + let (writer, guard) = tracing_appender::non_blocking(appender); + // 파일 **과** stdout 둘 다. 파일만 쓰면 개발 중 콘솔에서 로그가 사라져 + // `scripts/dev.cmd > log`로 앱 동작을 들여다보던 길이 막힌다. + use tracing_subscriber::fmt::writer::MakeWriterExt; tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "info".into()), - ) + .with_env_filter(filter()) + .with_ansi(false) // 파일에 색 이스케이프가 섞이면 읽을 수 없다 + .with_writer(writer.and(std::io::stdout)) .init(); + tracing::info!(dir = %dir.display(), "로그 파일 기록 시작"); + Some(guard) +} + +fn main() { + // main이 끝날 때까지 유지 — 드롭되면 기록 스레드가 멈춘다 + let _log_guard = init_logging(); tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { @@ -89,33 +124,24 @@ fn main() { tools.as_ref().map(|t| t.ffmpeg.clone()), ))?; - // 썸네일 워커: 코어 수 - 1 - let workers = std::thread::available_parallelism() - .map(|n| n.get().saturating_sub(1).max(1)) - .unwrap_or(2); + // 썸네일 워커 상한: 코어×2를 4–16으로 클램프. 실제 동시 처리 개수는 큐가 + // 처리율을 보며 이 범위 안에서 스스로 맞춘다 — NVMe는 코어 수 근처까지, + // HDD는 하한(2)까지 내려가고, SMB는 지연을 가리려 코어 수보다 높이 올라간다. + let max_workers = std::thread::available_parallelism() + .map(|n| (n.get() * 2).clamp(4, 16)) + .unwrap_or(4); let emit_handle = app.handle().clone(); let thumbs = Arc::new(ThumbQueue::start( db.clone(), thumb_root, tools.clone(), - workers, + max_workers, move |ids| { let _ = emit_handle.emit("thumbs-ready", ids); }, )); - // 앱 시작 시 미처리 썸네일 백필 — 백그라운드로. - // (대용량/네트워크(SMB) 소스에서 시작 시 메인 스레드 블로킹 방지) - { - let thumbs = thumbs.clone(); - let db = db.clone(); - std::thread::Builder::new() - .name("thumb-backfill".into()) - .spawn(move || { - archive_indexer::prio::set_current_thread_low(); - thumbs.enqueue_pending(&db, None); - }) - .ok(); - } + // 앱 시작 시 미처리 썸네일 백필 — 전용 피더 스레드에서 커서로 흘려보낸다(즉시 반환). + thumbs.enqueue_pending(None); #[cfg(debug_assertions)] tracing::info!( @@ -124,6 +150,11 @@ fn main() { media.token ); + // 하드웨어 디코드 가용성 — 실제 디코드 1회로 판별한다(컴파일 지원만으로는 알 수 없다). + // 실패하면 소프트웨어 디코드로만 간다. + let hwaccel = tools.as_ref().and_then(archive_indexer::ffmpeg::detect_hwaccel); + tracing::info!(?hwaccel, "지문 추출 하드웨어 디코드"); + app.manage(AppState { db, data_dir: data.path, @@ -136,6 +167,8 @@ fn main() { scan_running: Arc::new(AtomicBool::new(false)), dedupe_cancel: Arc::new(AtomicBool::new(false)), dedupe_running: Arc::new(AtomicBool::new(false)), + sig_cancel: Arc::new(AtomicBool::new(false)), + hwaccel, remote, creds: Arc::new(std::sync::Mutex::new( archive_vfs::creds::CredStore::Keychain, @@ -270,6 +303,64 @@ fn main() { .ok(); } + // 지문 백필 — 썸네일처럼 상시 저우선순위로 흐른다. + // 이미지는 캐시된 썸네일에서 뽑으므로 썸네일 큐 뒤를 따라가고, 영상은 ffmpeg 한 패스다. + // `sig_state`가 곧 큐라서 커서가 없고, 앱을 껐다 켜도 남은 것부터 이어진다. + // 스캔 중에는 pause 플래그(=scan_running)로 양보한다 — 둘 다 원본 I/O를 태우기 때문. + { + let state = app.state::(); + let db = state.db.clone(); + let tools = state.tools.clone(); + let thumb_root = state.data_dir.join("thumbs"); + let cancel = state.sig_cancel.clone(); + let pause = state.scan_running.clone(); + let hw = state.hwaccel; + std::thread::Builder::new() + .name("sig-backfill".into()) + .spawn(move || { + archive_indexer::prio::set_current_thread_low(); + // 지문 알고리즘 버전이 바뀌었으면 먼저 무효화한다 — 안 하면 옛 버전 지문이 + // 남아 중복 탐지가 조용히 아무것도 못 찾는다. 버전이 그대로면 비용 0. + match archive_indexer::sigq::invalidate_stale_sigs(&db) { + Ok(n) if n > 0 => tracing::info!(count = n, "지문 버전 변경 — 재계산 예약"), + Err(e) => tracing::warn!("지문 무효화 오류: {e}"), + _ => {} + } + loop { + if cancel.load(std::sync::atomic::Ordering::Relaxed) { + break; + } + match archive_indexer::sigq::run_image_sigs( + &db, &thumb_root, &cancel, &pause, + ) { + Ok(n) if n > 0 => tracing::info!(count = n, "이미지 지문 생성"), + Err(e) => tracing::warn!("이미지 지문 오류: {e}"), + _ => {} + } + if let Some(t) = &tools { + match archive_indexer::sigq::run_video_sigs( + &db, t, hw, &cancel, &pause, + ) { + Ok(n) if n > 0 => tracing::info!(count = n, "영상 지문 생성"), + Err(e) => tracing::warn!("영상 지문 오류: {e}"), + _ => {} + } + } + // 새 스캔·썸네일 완료를 자연히 흡수한다 (이벤트 배선보다 단순하고 아이들 비용이 거의 0). + // 쉬는 시간도 속도 설정을 따른다 — 2만 건이 밀려 있는데 30초씩 노는 것은 + // 그 자체가 느린 것이다. + let idle = archive_indexer::sigq::read_speed(&db).idle_secs(); + for _ in 0..idle { + if cancel.load(std::sync::atomic::Ordering::Relaxed) { + break; + } + std::thread::sleep(std::time::Duration::from_secs(1)); + } + } + }) + .ok(); + } + // dev 편의: ARCHIVE_AUTO_SOURCE=<폴더> → 시작 시 소스 등록 + 스캔 #[cfg(debug_assertions)] if let Ok(auto) = std::env::var("ARCHIVE_AUTO_SOURCE") { @@ -314,7 +405,12 @@ fn main() { commands::list_sources, commands::add_local_source, commands::remove_source, + commands::update_source, + commands::reload_source, + commands::purge_missing, + commands::source_status, commands::start_scan, + commands::rescan_all, commands::cancel_scan, commands::folder_tree, commands::folder_snapshot, @@ -329,10 +425,25 @@ fn main() { commands::rename_tag, commands::delete_tag, commands::assign_tags, + commands::set_rating, + commands::edit_files, + commands::cut_file, + commands::filmstrip, + commands::list_tag_sets, + commands::create_tag_set, + commands::update_tag_set, + commands::delete_tag_set, + commands::apply_tag_set, commands::unassign_tags, commands::file_tags_of, + commands::file_tags_bulk, commands::export_tags, commands::tag_snapshot, + commands::trash_tag_id, + commands::mark_delete, + commands::unmark_delete, + commands::pending_delete_stats, + commands::empty_deleted, commands::move_files, commands::rename_file, commands::trash_files, @@ -345,6 +456,9 @@ fn main() { commands::cancel_dedupe, commands::list_dupe_groups, commands::resolve_dupe_group, + commands::set_dupe_keeper, + commands::sig_speed, + commands::set_sig_speed, commands::dismiss_dupe_pair, commands::add_remote_source, commands::connect_remote_source, diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs index 89eb228..f358fc0 100644 --- a/src-tauri/src/watcher.rs +++ b/src-tauri/src/watcher.rs @@ -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>>; + pub struct WatcherManager { watchers: Mutex>, + /// 워치 불가로 폴링에 의존하는 소스 (네트워크 공유 등) + polled: PollMap, dirty_tx: crossbeam_channel::Sender, } +/// 재스캔 결과를 폴링 간격에 반영한다. 변경이 있었으면 최소 간격으로 되돌리고, +/// 없었으면 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 { let (tx, rx) = crossbeam_channel::unbounded::(); + 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 = 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, + } + }); } } } diff --git a/src/App.tsx b/src/App.tsx index eb21950..9391d1c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { onCleanup, onMount, type Component } from "solid-js"; +import { onCleanup, onMount, Show, type Component } from "solid-js"; import { Sidebar, onLibraryChangedRefresh } from "./shell/Sidebar"; import { ContentArea } from "./shell/ContentArea"; import { DetailsPanel } from "./shell/DetailsPanel"; @@ -6,15 +6,29 @@ import { StatusBar } from "./shell/StatusBar"; import { Lightbox } from "./features/viewer/Lightbox"; import { ContextMenu } from "./features/fileops/ContextMenu"; import { MoveDialog } from "./features/fileops/MoveDialog"; +import { MultiPlayView } from "./features/viewer/MultiPlayView"; import { DedupePanel } from "./features/dedupe/DedupePanel"; +import { CompareView } from "./features/dedupe/CompareView"; import { RemoteDialog } from "./features/sources/RemoteDialog"; +import { SourceEditDialog } from "./features/sources/SourceEditDialog"; import { ShortcutsOverlay } from "./shell/ShortcutsOverlay"; -import { onLibraryChanged, onThumbsReady, type UnlistenFn } from "./ipc/commands"; -import { bumpThumbVersions, loadAppInfo, refreshPendingCounts, refreshSnapshot } from "./state/store"; +import { ko } from "./i18n/ko"; +import { onLibraryChanged, onScanGuarded, onThumbsReady, type UnlistenFn } from "./ipc/commands"; +import { + bumpThumbVersions, + detailsOpen, + loadAppInfo, + loadTrashTag, + noteGuarded, + refreshPendingCounts, + refreshSnapshot, + toast, +} from "./state/store"; const App: Component = () => { onMount(() => { void loadAppInfo(); + void loadTrashTag(); void refreshSnapshot(); refreshPendingCounts(); @@ -23,10 +37,18 @@ const App: Component = () => { bumpThumbVersions(ids); refreshPendingCounts(); }), + // 스캔 중에는 2초마다 온다 — 사이드바·스냅샷·삭제표시 갱신은 저 안에서 합쳐진다. onLibraryChanged(() => { onLibraryChangedRefresh(); refreshPendingCounts(); }), + // 대량 삭제 보류 — 조용히 지나가면 사용자는 "왜 없는 파일이 목록에 있는지" 알 수 없다. + // 토스트로 알리고, 사이드바 정리 그룹에 '사라진 파일 정리' 진입점을 띄운다 + // (토스트는 4초면 사라지지만 보류 상태는 사용자가 확정할 때까지 남는다). + onScanGuarded((g) => { + toast(ko.refresh.guarded(g.count)); + noteGuarded(g); + }), ]; onCleanup(() => { for (const u of unlisteners) void u.then((f) => f()); @@ -34,18 +56,32 @@ const App: Component = () => { }); return ( -
-
+
+ {/* minmax(0,1fr) 필수 — 1fr(=minmax(auto,1fr))이면 본문이 최소 콘텐츠 폭 아래로 줄지 못한다 */} +
- + + +
+ + {/* 비교 뷰는 중복 모달 '위'에 뜬다 — Esc는 위에 있는 것부터 닫힌다 */} + +
); diff --git a/src/features/browse/FolderChips.tsx b/src/features/browse/FolderChips.tsx new file mode 100644 index 0000000..c975e1c --- /dev/null +++ b/src/features/browse/FolderChips.tsx @@ -0,0 +1,117 @@ +// 본문 카드 안 '한 단계 아래 폴더' 밴드 — 트리를 열지 않고도 내려갈 수 있게 한다. +// 스크롤러 바깥(그리드/리스트 위)에 놓는다. 안에 넣으면 가상 스크롤 좌표계가 어긋난다. +// 보여줄 자식이 없으면 아무것도 렌더하지 않는다 — 높이를 예약하면 그리드가 아래로 튄다. +import { IconChevronDown, IconChevronRight, IconFolder, IconPlus } from "../../ui/icons"; +import { createEffect, createSignal, For, on, Show, type Component } from "solid-js"; +import { ko } from "../../i18n/ko"; +import { childrenOf, rootsOf, sourceIdOf, subtotalOf } from "../../state/folderIndex"; +import { chipsOpen, focusItemView, setView, toast, toggleChips, view } from "../../state/store"; +import type { FolderNode } from "../../ipc/commands"; +import { performMove } from "../fileops/MoveDialog"; + +/** 처음에 펼쳐 두는 칩 수. 나머지는 '+N개 더'로 접는다 */ +const SHOWN_MAX = 8; + +export const FolderChips: Component = () => { + const [showAll, setShowAll] = createSignal(false); + const [dropId, setDropId] = createSignal(null); + // 뷰가 바뀌면 펼침을 되돌린다 — 이전 폴더에서 펼친 상태가 새 폴더로 새지 않게 + createEffect(on(view, () => setShowAll(false))); + + const srcId = (): number | undefined => { + const v = view(); + if (v.type === "source") return v.sourceId; + if (v.type === "folder") return v.sourceId ?? sourceIdOf(v.folderId); + return undefined; + }; + + const children = (): FolderNode[] => { + const v = view(); + if (v.type === "source") return rootsOf(v.sourceId); + if (v.type === "folder") return childrenOf(v.folderId, v.sourceId); + return []; + }; + + const shown = () => (showAll() ? children() : children().slice(0, SHOWN_MAX)); + const hidden = () => children().length - shown().length; + + const onDragOver = (e: DragEvent, folderId: number) => { + if (!e.dataTransfer?.types.includes("application/x-archive-move")) return; + e.preventDefault(); + setDropId(folderId); + }; + + return ( + 0}> +
+ + +
+ + {(n) => ( + + )} + + 0}> + + +
+
+
+
+ ); +}; diff --git a/src/features/browse/ListView.tsx b/src/features/browse/ListView.tsx new file mode 100644 index 0000000..6574d6c --- /dev/null +++ b/src/features/browse/ListView.tsx @@ -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>({}); + let detTimer: ReturnType | 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>({}); + let tagTimer: ReturnType | 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 | 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) => ( + + ); + + return ( +
+ {/* 머리글 — 열 폭은 행과 같은 정의를 쓴다 */} +
+ + + {ko.list.tags} + {ko.list.kind} + + {ko.list.dims} + {ko.list.duration} + +
+ +
+ 0} + fallback={ +
+ {ko.grid.empty} +
+ } + > +
+ + {(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 ( +
{ + 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(고정 다크)를 쓰면 흰 카드 안에 검은 사각형이 박힌다. */} +
+ + { + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + +
+ + {/* 이름 (+ 삭제 예정 배지) */} +
+ + + + + + + {d()?.name ?? "…"} + +
+ + {/* 태그 */} +
+ + {(name) => ( + + {name} + + )} + + 3}> + + +{rowTags().length - 3} + + +
+ + + {isVideo() ? ko.filter.video : ko.filter.image} + + + {d() ? fmtSize(d()!.size) : ""} + + {/* 좁아지면 0폭까지 줄어드는 열 — 넘친 글자가 옆 열을 침범하지 않게 자른다 */} + + {w() > 0 && h() > 0 ? `${w()}×${h()}` : "—"} + + + {fmtDuration(d()?.durationMs)} + + + {fmtDate(dateOf(d()))} + +
+ ); + }} +
+
+
+
+
+ ); +}; diff --git a/src/features/browse/itemKeys.ts b/src/features/browse/itemKeys.ts new file mode 100644 index 0000000..04f81e0 --- /dev/null +++ b/src/features/browse/itemKeys.ts @@ -0,0 +1,148 @@ +// 격자·리스트 공통 키보드 처리 — 두 보기가 같은 키로 같은 일을 하도록 한곳에 모았다. +// 방향키만 보기마다 기하가 달라(격자는 행/열, 리스트는 위/아래) 이웃 계산을 위임받는다. +import { fileDetails, type Snapshot } from "../../ipc/commands"; +import { + anchorIndex, + dedupeOpen, + lightboxIndex, + openLightbox, + openShortcuts, + selected, + selectSingle, + setSelectionFromIds, + toggleViewMode, +} from "../../state/store"; +import { + openRenameDialog, + performMarkDelete, + performTrash, + performEdit, + performUndo, +} from "../fileops/ContextMenu"; +import { openMoveDialog } from "../fileops/MoveDialog"; +import { multiPlayIds } from "../viewer/MultiPlayView"; + +export type ArrowKey = "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown"; + +/** 방향키가 향할 인덱스. 더 갈 곳이 없으면 undefined. */ +export type Neighbor = (from: number, key: ArrowKey) => number | undefined; + +export interface ItemKeyOpts { + neighbor: Neighbor; + /** 키보드로 선택이 옮겨간 뒤 — 보기가 그 항목을 화면 안으로 끌어온다 */ + onMove?: (index: number) => void; +} + +export function handleItemKeys(e: KeyboardEvent, snap: Snapshot, opts: ItemKeyOpts): void { + if (snap.count === 0) return; + // 중복 검토 모달이 떠 있으면 그리드는 키를 받지 않는다. 평소에는 모달이 카드로 포커스를 + // 옮겨 이 핸들러까지 오지 않지만, 그 방어는 포커스 하나에만 걸려 있다 — focusItemView()가 + // 모달이 열린 사이에 불리면 방향키·Ctrl+A·Delete가 스크림 뒤에서 되살아난다. + if (dedupeOpen()) return; + // 동시 재생 오버레이가 떠 있으면 그리드는 키를 받지 않는다 — 뒤에서 선택이 바뀌면 + // 오버레이를 닫았을 때 엉뚱한 항목이 골라져 있다. + if (multiPlayIds().length > 0) return; + // 라이트박스가 떠 있으면 키는 전적으로 라이트박스가 처리한다. + // (뒤에서 선택을 바꾸면 라이트박스의 '현재 항목 = 선택' 전제가 깨진다) + if (lightboxIndex() != null) return; + const anchor = anchorIndex(); + + const move = (index: number) => { + selectSingle(snap.id(index), index); + opts.onMove?.(index); + }; + + 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; + } + // Ctrl+[ / Ctrl+] 회전 — 사진 뷰어들이 쓰는 관례를 따른다 + if ((e.ctrlKey || e.metaKey) && (e.key === "[" || e.key === "]")) { + if (selected().size === 0) return; + e.preventDefault(); + void performEdit(e.key === "]" ? "rotateCw" : "rotateCcw"); + return; + } + // Ctrl+M 폴더 이동 + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "m") { + if (selected().size === 0) return; + e.preventDefault(); + openMoveDialog(); + return; + } + // V 격자 ↔ 리스트 + if (!e.ctrlKey && !e.metaKey && !e.altKey && (e.key === "v" || e.key === "V")) { + e.preventDefault(); + toggleViewMode(); + return; + } + + switch (e.key) { + case "Enter": + case " ": + if (anchor >= 0) { + e.preventDefault(); + openLightbox(anchor); + } + break; + // Delete는 '삭제 표시'(되돌릴 수 있음), Shift+Delete만 즉시 OS 휴지통 + case "Delete": + if (selected().size > 0) { + e.preventDefault(); + if (e.shiftKey) void performTrash(); + else void performMarkDelete(); + } + 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 "Home": + e.preventDefault(); + move(0); + break; + case "End": + e.preventDefault(); + move(snap.count - 1); + break; + case "ArrowLeft": + case "ArrowRight": + case "ArrowUp": + case "ArrowDown": { + e.preventDefault(); + // 아직 아무것도 고르지 않았으면 첫 항목부터 — 키보드만으로 시작할 수 있게 + if (anchor < 0) { + move(0); + break; + } + const next = opts.neighbor(anchor, e.key); + if (next !== undefined) move(next); + break; + } + } +} diff --git a/src/features/dedupe/CompareView.tsx b/src/features/dedupe/CompareView.tsx new file mode 100644 index 0000000..925df77 --- /dev/null +++ b/src/features/dedupe/CompareView.tsx @@ -0,0 +1,731 @@ +// 중복 비교 — 두 파일을 나란히 놓고 **같은 내용이 같은 시각에 보이도록** 맞춰 재생한다. +// +// 그냥 0초부터 나란히 돌리면 인트로가 붙은 사본은 끝까지 어긋난 화면을 보여줘서 비교가 안 된다. +// 그래서 탐지 때 오프셋 투표로 알아낸 `offsetMs`(keeper 시각 − 멤버 시각)를 그대로 쓴다: +// member.currentTime = keeper.currentTime - offsetMs/1000 +// offsetMs가 null인 멤버(전이 병합으로 들어와 정렬을 직접 확인하지 못한 경우)는 맞추지 않고 +// 그 사실을 화면에 적는다 — 맞춰진 척하는 것보다 낫다. +// +// 시간축은 항상 **keeper 기준**이다(첫 패널). 시크·재생은 keeper 시각 하나로 관리하고 +// 나머지는 매 프레임 따라붙는다(오차 0.2초 넘으면 보정). +// +// 보기는 둘이다: +// 1:1 — keeper와 사본 하나. 세부를 크게 본다(기본). +// 전체 — 그룹의 모든 사본을 한 화면에서 동시에. 사본이 5개인데 1:1로 네 번 돌려보는 것보다 +// "어느 하나가 유독 흐리다/짧다"를 훨씬 빨리 찾는다. 대신 영상 N개가 동시에 돌고 +// 직접 재생이 안 되는 코덱은 각각 ffmpeg 리먹스를 띄우므로 비용이 N배다 — +// 그래서 기본이 아니라 선택이다. +import { + createEffect, + createMemo, + createSignal, + For, + onCleanup, + onMount, + Show, + type Component, +} from "solid-js"; +import { + decidePlayback, + mediaUrl, + playbackInfo, + startStream, + stopStream, + thumbUrl, + type DupeGroup, + type DupeMember, + type PlaybackPlan, +} from "../../ipc/commands"; +import { closeDedupe, info as appInfoSig, setView } from "../../state/store"; +import { fmtDuration } from "./format"; +import { ko } from "../../i18n/ko"; +import { + IconFolder, + IconPause, + IconPlay, + IconResetZoom, + IconRewind, + IconForward, + IconVolumeHigh, + IconVolumeMute, + IconX, +} from "../../ui/icons"; + +// ── 열림 상태 (다이얼로그 3종과 같은 방식: 모듈 시그널) ── +const [target, setTarget] = createSignal<{ group: DupeGroup; otherId: number } | undefined>(); +export const compareTarget = target; +export const compareOpen = (): boolean => target() !== undefined; +/** 그룹의 keeper와 `otherId`를 나란히 비교한다. */ +export function openCompare(group: DupeGroup, otherId: number): void { + setTarget({ group, otherId }); +} +export const closeCompare = (): void => setTarget(undefined); + +const SKIP = 5; +const DRIFT_TOLERANCE = 0.2; +/** 어느 패널의 소리도 듣지 않는 상태 */ +const MUTED = -1; +const ZOOM_MIN = 1; +const ZOOM_MAX = 8; + +/** 시크바·구간 표기 — 목록 타일과 같은 규칙을 쓴다(한 시간 넘는 영상도 h:mm:ss로 맞다). */ +const fmt = (sec: number): string => fmtDuration(sec * 1000); + +/** 부호 있는 초 표기 — "5.0초 늦게 / 2.5초 빠르게" */ +function fmtShift(ms: number): string { + const sec = Math.abs(ms) / 1000; + return sec < 0.05 ? ko.compare.aligned : ms < 0 ? ko.compare.later(sec) : ko.compare.earlier(sec); +} + +/** 한쪽 패널의 재생 제어 손잡이. 부모가 keeper 시각 하나로 양쪽을 움직인다. */ +interface PaneApi { + /** + * keeper 시각(초)으로 이동. + * `hard`는 **사용자가 위치를 옮겼다**는 뜻이다 — 리먹스 스트림은 그때만 다시 띄운다. + * 매 프레임 도는 드리프트 보정으로 ffmpeg를 재시작하면, 재시작에 걸리는 1초 사이 + * keeper가 또 움직여서 영원히 따라잡지 못하고 재시작만 반복한다(실측). + */ + seek: (keeperSec: number, hard?: boolean) => void; + play: () => void; + pause: () => void; + /** 현재 keeper 시각 환산값 */ + keeperTime: () => number; +} + +const VideoPane: Component<{ + member: DupeMember; + /** keeper 시각 − 이 멤버 시각(ms). keeper 자신은 0 */ + offsetMs: number | null; + muted: boolean; + /** 부모의 재생 상태 — 범위를 벗어났다 돌아왔을 때 스스로 재생을 되살린다 */ + playing: boolean; + /** 열릴 때의 keeper 시각(초) — 스트림 모드는 이 지점에서 ffmpeg를 띄운다 */ + startAtSec: number; + register: (api: PaneApi | undefined) => void; +}> = (props) => { + const app = appInfoSig(); + let el: HTMLVideoElement | undefined; + const [src, setSrc] = createSignal(); + // 스트림(리먹스) 재생은 시작 시각이 파일 0초가 아니다 — 환산에 쓴다 + const [base, setBase] = createSignal(0); + const [dur, setDur] = createSignal(0); + const [oor, setOor] = createSignal(false); + const [plan, setPlan] = createSignal(); + let jobId: number | undefined; + let restartTimer: ReturnType | undefined; + /** 예약된 재시작 지점(초). 타이머가 도는 동안에는 이 값만 갱신한다. */ + let restartTo: number | undefined; + const off = () => (props.offsetMs ?? 0) / 1000; + + const stop = () => { + clearTimeout(restartTimer); + restartTimer = undefined; + if (jobId !== undefined) { + void stopStream(jobId); + jobId = undefined; + } + }; + onCleanup(stop); + + /** 리먹스 스트림을 `atSec`부터 다시 시작한다 — 스트림은 버퍼 밖으로 시크할 수 없다. + (플레이어와 같은 방식: 위치를 바꾸면 ffmpeg를 그 지점에서 새로 띄운다) */ + const openStreamAt = async (atSec: number) => { + const p = plan(); + if (!p || p.mode !== "stream") return; + const prev = jobId; + try { + const r = await startStream(props.member.fileId, atSec, p.video, p.audio); + jobId = r.jobId; + setBase(atSec); + setSrc(r.url); + } catch { + return; + } + if (prev !== undefined) void stopStream(prev); + }; + + // 직접 재생이 가능하면 그대로, 아니면 ffmpeg 리먹스 스트림으로 (플레이어와 같은 판단). + // MKV·HEVC 같은 컨테이너/코덱도 이 경로로 비교할 수 있다. + onMount(() => { + void (async () => { + if (!app) return; + try { + const p = decidePlayback(await playbackInfo(props.member.fileId)); + setPlan(p); + if (p.mode === "direct") { + setSrc(mediaUrl(app, props.member.fileId)); + } else if (p.mode === "stream") { + // 열릴 때 이미 일치 구간으로 이동해 있을 수 있으니 그 지점에서 시작한다 + await openStreamAt(Math.max(0, props.startAtSec - off())); + } + } catch { + /* 재생 불가 — 썸네일만 보여준다 */ + } + })(); + }); + + createEffect(() => { + if (el) el.muted = props.muted; + }); + + props.register({ + seek: (keeperSec, hard = false) => { + if (!el) return; + const want = keeperSec - off(); + const streaming = plan()?.mode === "stream"; + const fileLen = (props.member.durationMs ?? 0) / 1000; + // 스트림 모드의 `el.duration`은 **지금까지 리먹스된 분량**이라 파일 길이가 아니다. + // 그걸 범위 기준으로 쓰면 멀쩡한 지점을 '상대 파일에 없음'으로 잘못 판정한다. + const max = streaming ? fileLen : dur() || fileLen; + const inRange = want >= -0.05 && (max === 0 || want <= max + 0.05); + setOor(!inRange); + if (!inRange) { + el.pause(); + return; + } + // 스트림은 버퍼 밖을 시크할 수 없다 — 크게 움직이면 그 지점에서 ffmpeg를 다시 띄운다. + // 작은 보정(부모의 0.2초 드리프트 교정)은 currentTime으로 처리해 재시작 폭주를 막는다. + if (streaming && hard && Math.abs(base() + el.currentTime - want) > 1.5) { + restartTo = Math.max(0, want); + // **타이머를 다시 걸지 않는다.** 사용자가 슬라이더를 끌면 seek이 연속으로 오는데 + // 매번 clearTimeout하면 250ms가 영원히 오지 않아 스트림 패널만 멈춰 있게 된다. + if (restartTimer === undefined) { + restartTimer = setTimeout(() => { + restartTimer = undefined; + if (restartTo !== undefined) void openStreamAt(restartTo); + }, 250); + } + return; + } + const t = Math.max(0, want - base()); + if (Math.abs(el.currentTime - t) > 0.05) el.currentTime = t; + // 범위를 벗어났다 돌아온 경우 — 부모가 재생 중이면 다시 돌려준다 + if (props.playing && el.paused) void el.play().catch(() => {}); + }, + play: () => { + if (el && !oor()) void el.play().catch(() => {}); + }, + pause: () => el?.pause(), + keeperTime: () => (el ? base() + el.currentTime + off() : 0), + }); + onCleanup(() => props.register(undefined)); + + return ( +
+ + } + > + + + {/* 한쪽에만 있는 구간 — 검은 화면을 그냥 보여주면 '깨진 것'으로 오해한다 */} +
{ko.compare.outOfRange}
+
+
+ ); +}; + +const ImagePane: Component<{ member: DupeMember; zoom: number; pan: [number, number] }> = ( + props, +) => { + const app = appInfoSig(); + return ( +
+ {props.member.name} +
+ ); +}; + +export const CompareView: Component = () => { + const [playing, setPlaying] = createSignal(false); + const [pos, setPos] = createSignal(0); // keeper 시각(초) + /** + * 소리를 듣는 패널의 인덱스. 여러 소리를 같이 내면 비교가 안 되므로 **하나 아니면 없음**이다. + * `MUTED`(-1)면 전부 음소거 — 화면만 보고 싶을 때(같은 소리가 계속 반복되면 방해가 된다). + */ + const [audioPane, setAudioPane] = createSignal(0); + /** 음소거를 풀 때 돌아갈 자리. 듣던 것으로 되돌아가야 '껐다 켜기'가 성립한다. */ + let lastAudio = 0; + const [showAll, setShowAll] = createSignal(false); + const [zoom, setZoom] = createSignal(1); + const [pan, setPan] = createSignal<[number, number]>([0, 0]); + let apis: (PaneApi | undefined)[] = []; + let raf = 0; + + const group = () => target()?.group; + const keeper = (): DupeMember | undefined => + group()?.members.find((m) => m.isKeeper) ?? group()?.members[0]; + const other = (): DupeMember | undefined => + group()?.members.find((m) => m.fileId === target()?.otherId); + const isVideo = () => (keeper()?.kind ?? 0) === 1; + /** 비교 대상으로 고를 수 있는 멤버들 (keeper 제외) */ + const others = () => group()?.members.filter((m) => !m.isKeeper) ?? []; + + /** + * 화면에 놓을 패널들. **0번은 언제나 keeper**다 — 시간축의 진실원이고, + * 나머지가 그 시각을 따라오기 때문에 순서가 곧 계약이다. + */ + const panes = createMemo(() => { + const k = keeper(); + if (!k) return []; + if (!showAll()) { + const o = other(); + return o ? [k, o] : [k]; + } + return [k, ...others()]; + }); + + /** 열 수 — 정사각에 가깝게, 최대 4열. 8개면 4×2, 5개면 3×2가 된다. */ + const cols = createMemo(() => Math.min(4, Math.ceil(Math.sqrt(panes().length)))); + + const totalSec = () => { + const k = keeper(); + return (k?.durationMs ?? 0) / 1000; + }; + + // 열릴 때·비교 대상을 바꿀 때 상태 초기화 + 일치 구간이 있으면 그 지점부터 + // (거기가 볼 만한 곳이다). `apis`는 비우지 않는다 — 패널이 스스로 등록/해제한다. + createEffect(() => { + const t = target(); + if (!t) return; + setZoom(1); + setPan([0, 0]); + setPlaying(false); + lastAudio = 0; + setAudioPane(0); + setPos((other()?.segStartMs ?? 0) / 1000); + }); + + // 보기를 바꾸면 패널 수가 달라진다 — 사라진 패널의 손잡이가 남아 있으면 + // seek/pause가 없는