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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ncakanghan
2026-08-03 16:18:24 +09:00
co-authored by Claude Opus 5
parent 67d2ddc59e
commit 8aa74c1a8d
67 changed files with 19747 additions and 1283 deletions
+6
View File
@@ -11,3 +11,9 @@ data/
*.log
.DS_Store
Thumbs.db
# 로컬 크로스 컴파일 도구 (scripts/fetch-arm64-toolchain.ps1이 내려받는다 — 재생성 가능)
.toolchain/
# 포터블 빌드 산출물
dist-portable/
+10
View File
@@ -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",
+1
View File
@@ -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": {
+145
View File
@@ -0,0 +1,145 @@
# Archive portable build (no installer). ASCII only - PS 5.1 reads BOM-less files as ANSI.
#
# Output: dist-portable\Archive-<arch>\ (exe + ffmpeg/ffprobe sidecars + portable.marker)
# The app already supports portable mode: src-tauri/src/paths.rs uses <exe>\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 "<host>_<target>", 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_<triple>, 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"
}
+56
View File
@@ -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
}
+38 -3
View File
@@ -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
+165
View File
@@ -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\<ver>\{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"
+270
View File
@@ -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 "썸네일·지문 백필이 끝난 뒤(사이드바 '진행 중'이 비면) 중복 찾기를 실행한다."
+41 -2
View File
@@ -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
+193
View File
@@ -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);
+20
View File
@@ -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"
+2
View File
@@ -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"
@@ -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<bool> {
let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
let mut fresh_fts: Option<bool> = None;
if version < 1 {
let tx = conn.transaction()?;
@@ -199,6 +371,75 @@ pub fn migrate(conn: &mut Connection) -> rusqlite::Result<bool> {
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);
}
File diff suppressed because it is too large Load Diff
@@ -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<std::io::Error> 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(&current) { 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<usize> {
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<usize> {
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<u8> {
// TIFF(리틀엔디언) + IFD0 항목 1개 + 다음 IFD 없음
let mut tiff: Vec<u8> = 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<u8>, value: u8) -> Option<Vec<u8>> {
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<u8>,
/// 새 회전 각도 (영상만, 0/90/180/270)
pub rotation: Option<i32>,
/// 가로·세로가 바뀌었는가
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<EditResult, EditError> {
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<EditResult, EditError> {
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<i32> {
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::<f64>().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<EditResult, EditError> {
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<usize> = 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<u8> {
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<u8> {
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<u8> {
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<CropRect> {
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<EditResult, EditError> {
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<EditResult, EditError> {
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<EditResult, EditError> {
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<u8>,
}
/// 영상 전체를 고르게 훑어 `count`장을 뽑는다.
///
/// **`-ss`를 `-i` 앞에 둔다.** 뒤에 두면 그 지점까지 전부 디코드하므로 30분짜리에서
/// 한 장에 수십 초가 걸린다. 앞에 두면 키프레임까지 건너뛰어 한 장에 수십 밀리초다.
/// 그 대신 위치가 키프레임 단위로 어긋나는데, 타임라인 배경으로는 문제가 되지 않는다.
pub fn filmstrip(
tools: &FfTools,
src: &Path,
duration_ms: i64,
count: u32,
height: u32,
) -> Vec<StripFrame> {
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
}
@@ -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<u8>);
/// 균등 간격으로 그레이 프레임을 뽑는다. 시간(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<Vec<GrayFrame>, 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<GrayFrame>, Vec<u32>), FfError> {
let sample_ms = sample_ms.max(1);
let read_fp = |p: Option<&Path>| -> Vec<u32> {
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<Vec<GrayFrame>, 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<Vec<u32>, 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::*;
+646 -26
View File
@@ -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<Vec<FileRow>> {
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<Vec<FileRow>> {
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<PathBuf> {
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<OpSummary> {
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<Vec<UndoBatch>> {
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<Vec<UndoBatch>> {
})?)
}
/// 배치 undo — 저널 역순 재생. trash 항목은 건너뛴다.
pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
/// 배치 undo — 저널 역순 재생. trash/delete 항목은 건너뛴다.
/// 되돌리기. `tools`가 없으면 영상 편집 되돌리기만 실패로 남고 나머지는 정상 처리된다.
pub fn undo_batch(
db: &Db,
batch_id: &str,
tools: Option<&crate::ffmpeg::FfTools>,
encoder: &str,
) -> Result<OpSummary> {
struct JEntry {
id: i64,
op: String,
@@ -305,11 +399,13 @@ pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
src_path: Option<String>,
dst_path: Option<String>,
tag_id: Option<i64>,
/// 편집 되돌리기용 역연산 이름 (op = "edit"일 때만 채워진다)
payload: Option<String>,
}
let bid = batch_id.to_string();
let entries: Vec<JEntry> = 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<OpSummary> {
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<OpSummary> {
}
}
}
// 편집 되돌리기 = 역연산을 다시 적용. 원본 백업이 없으므로
// 파일이 그새 다른 도구로 바뀌었으면 그 위에 역회전이 걸린다 —
// 회전은 무손실이라 그래도 화질 손해는 없다.
"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<OpSummary> {
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<String>,
/// 다시 인코딩된 파일 수 (화질 손실이 있었던 것들)
pub reencoded: u64,
}
/// 편집 전 수정시각을 기억했다가 편집 뒤 되돌려 준다.
///
/// **편집은 촬영 시각을 바꾸는 일이 아니다.** 기본 정렬은 `COALESCE(taken_at, mtime_ms)`라
/// EXIF 촬영일이 없는 파일(대부분의 영상)은 mtime으로 줄을 선다. 회전 한 번에 mtime이
/// 지금으로 바뀌면 그 항목이 목록 맨 앞으로 튀어 사용자가 방금 보던 파일을 다시 찾아야 한다.
fn with_preserved_mtime<T>(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<crate::edit::EditOp> {
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<EditSummary> {
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<crate::edit::EditResult> {
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(())
}
@@ -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;
@@ -110,6 +110,96 @@ pub fn remove_source(db: &Db, thumb_root: &Path, source_id: i64) -> Result<u64,
Ok(removed)
}
/// 경로 비교용 정규화 — 뒤쪽 구분자를 떼고, Windows에서는 구분자·대소문자를 통일한다.
pub fn norm_path(p: &str) -> 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<u64, String> {
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<String> = 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();
+65 -12
View File
@@ -18,14 +18,52 @@ struct MetaResult {
ok: bool,
}
fn exif_of(path: &Path) -> (u32, Option<i64>) {
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<i64>) {
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<i64>)> {
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: std::io::BufRead + std::io::Seek>(r: &mut R) -> Option<(u32, Option<i64>)> {
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<i64>) {
_ => 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();
@@ -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::*;
@@ -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<u64>)],
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<u32>> = 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<i64, usize>,
parent: Vec<usize>,
ids: Vec<i64>,
}
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<Vec<i64>> {
let mut by_root: std::collections::HashMap<usize, Vec<i64>> = 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<Vec<i64>> {
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::<Vec<_>>());
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<i64> = 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::<Vec<_>>();
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::<Vec<_>>();
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가 거의 같아야 한다
+341 -35
View File
@@ -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<i64>,
}
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<HashMap<(i64, String), SnapRow>, 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<Db>, source_id: i64) -> Result<u64, ScanError> {
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<i64> = 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<HashMap<String, SnapRow>, ScanError> {
let rows: Vec<SnapRow> = 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<Vec<SnapRow>, 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<SnapRow> = 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<i64, HashMap<String, SnapRow>>,
order: std::collections::VecDeque<i64>,
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<ScanStats, ScanError> {
// 루트를 읽을 수 없으면(드라이브 미마운트·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<i64> = Vec::new();
let mut folder_cache: HashMap<PathBuf, i64> = 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<SnapRow> = 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<i64> = 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();
+1 -1
View File
@@ -40,7 +40,7 @@ pub fn enumerate_items(root: &Path) -> impl Iterator<Item = WalkItem> {
}
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()?;
File diff suppressed because it is too large Load Diff
@@ -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<Computed>,
}
struct Computed {
rep: FrameSig,
/// 조밀 프레임 시퀀스 (이미지는 비어 있다)
frames: Vec<(u32, u64, u64)>,
/// 값 기반으로 고른 앵커 부분집합
anchors: Vec<(u32, u64, u64)>,
duration_ms: Option<i64>,
/// 4×4 색차 지문 — 이미지만. 영상은 그레이 파이프라 없다.
color: Option<[i8; 32]>,
/// 오디오 지문 앵커 (t_ms, chromaprint 값) — 오디오가 있는 영상만.
audio: Vec<(u32, u32)>,
}
/// 32×32 그레이로 눌러 담는다 (종횡비 무시 — 모든 파일에 같은 규칙이라 비교에 문제없다).
fn gray32(img: &image::DynamicImage) -> Vec<u8> {
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<Db>) -> Result<u64, archive_db::DbError> {
let cur = sigmatch::SIG_VERSION;
let seen: Option<i64> = 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<Db>,
thumb_root: &Path,
cancel: &AtomicBool,
pause: &AtomicBool,
) -> Result<u64, archive_db::DbError> {
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<SigRow> = 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<Computed> {
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<Db>,
tools: &FfTools,
hwaccel: Option<&str>,
cancel: &AtomicBool,
pause: &AtomicBool,
) -> Result<u64, archive_db::DbError> {
let mut total = 0u64;
loop {
if !wait_if_paused(cancel, pause) {
break;
}
// `meta_state <> 0`을 요구한다 — 오디오 유무(`acodec`)를 메타가 채우기 때문이다.
// 메타보다 먼저 돌면 오디오 지문 없이 sig_state=1이 박혀 영영 안 만들어진다.
// 실패(2)도 통과시킨다: 메타를 못 읽은 파일이라도 프레임 지문은 만들 수 있다.
let batch: Vec<VideoJob> = 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<String>>(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<SigRow> {
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<SigRow> = match &pool {
Some(p) => p.install(compute),
None => compute(),
};
// kind=-1(취소로 건너뜀)은 쓰지 않는다
let results: Vec<SigRow> = 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<i64>,
has_audio: bool,
}
fn video_sig(
tools: &FfTools,
file_id: i64,
path: &Path,
duration_ms: Option<i64>,
has_audio: bool,
hwaccel: Option<&str>,
) -> Option<Computed> {
// 손상 파일에서 워커가 영구 점유되지 않도록 길이에 비례한 상한을 둔다
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<FrameSig> = 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<u32> =
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<Db>, rows: Vec<SigRow>) -> 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<Db>) {
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<u8>, Vec<u8>) = 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<i64> = 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::<Result<Vec<i64>, _>>()?;
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
);
}
}
@@ -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<i64> {
// 대부분의 호출은 이미 존재하는 경우 — 쓰기 락을 잡지 않도록 읽기 먼저.
let existing: Option<i64> = 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<Vec<i64>> {
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<Vec<Tag>> {
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<i64>, 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<String>,
}
pub fn file_tags_bulk(db: &Db, file_ids: Vec<i64>) -> Result<Vec<FileTagLink>> {
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::<Vec<_>>()
.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<Vec<Tag>> {
db.with_read(|conn| {
let mut stmt = conn.prepare_cached(
@@ -169,6 +268,143 @@ pub fn export_json(db: &Db) -> Result<String> {
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<String>,
/// 이 세트에 담긴 태그 (이름·색까지 — 화면이 칩으로 그린다)
pub tags: Vec<Tag>,
}
pub fn list_tag_sets(db: &Db) -> Result<Vec<TagSet>> {
db.with_read(|conn| {
let mut sets: Vec<TagSet> = 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::<rusqlite::Result<_>>()?;
// 세트 수는 많아야 수십 개다 — 한 번에 읽어 메모리에서 묶는다(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::<rusqlite::Result<_>>()?;
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<i64>) -> Result<i64> {
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<String>,
hotkey: Option<Option<String>>,
tag_ids: Option<Vec<i64>>,
) -> 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<i64>, set_id: i64) -> Result<u64> {
let batch_id = batch_id.to_string();
db.with_write(move |conn| {
let tx = conn.transaction()?;
let tag_ids: Vec<i64> = tx
.prepare("SELECT tag_id FROM tag_set_items WHERE set_id = ?1")?
.query_map([set_id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
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<String> = 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();
+408 -52
View File
@@ -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<i64>,
lo_tx: Sender<i64>,
ctx: Arc<Ctx>,
/// 피더 세대 — 새 피더가 시작되면 이전 피더는 스스로 물러난다
feed_gen: Arc<AtomicU64>,
}
/// ── 동시 처리 게이트 ────────────────────────────────────────────────────────
/// 디스크 특성에 맞는 동시 읽기 개수는 미리 알 수 없다: 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<Db>,
thumb_root: PathBuf,
tools: Option<crate::ffmpeg::FfTools>,
in_flight: Mutex<HashSet<i64>>,
done_tx: Sender<i64>,
/// 결과를 쓰기 합치기 스레드로 보낸다 (워커는 writer를 기다리지 않는다)
write_tx: Sender<ThumbWrite>,
gate: Gate,
/// 처리 시도 누적 — 게이트 자동조정의 유일한 입력
processed: AtomicUsize,
}
impl ThumbQueue {
/// 워커 풀을 시작한다. `on_done`은 완료된 file_id 배치를 받는다.
/// `tools`가 None이면 영상 썸네일은 건너뛴다 (이미지는 정상 처리).
///
/// `max_workers`는 스레드 수이자 동시 처리 상한이다. 실제 동시 처리 개수는
/// 처리율을 보며 1..=max_workers 사이에서 자동으로 오르내린다([`Gate`]).
pub fn start(
db: Arc<Db>,
thumb_root: PathBuf,
tools: Option<crate::ffmpeg::FfTools>,
workers: usize,
max_workers: usize,
on_done: impl Fn(Vec<i64>) + Send + 'static,
) -> ThumbQueue {
let (hi_tx, hi_rx) = unbounded::<i64>();
let (lo_tx, lo_rx) = unbounded::<i64>();
let (write_tx, write_rx) = unbounded::<ThumbWrite>();
let (done_tx, done_rx) = unbounded::<i64>();
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<ThumbWrite> = 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<i64>) {
let ids: Vec<i64> = 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<i64>) {
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<Vec<i64>, _> = 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<Ctx>) {
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<ThumbWrite>, done_tx: &Sender<i64>) {
if batch.is_empty() {
return;
}
let ok_ids: Vec<i64> = 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<Ctx>, hi_rx: Receiver<i64>, lo_rx: Receiver<i64>) {
// 썸네일 디코드/리사이즈는 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<Ctx>, hi_rx: Receiver<i64>, lo_rx: Receiver<i64>) {
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<bool, archive_db::DbError> {
}
};
// 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::<usize>();
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);
+157 -13
View File
@@ -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 값(18)을 읽는다. 없으면 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: std::io::BufRead + std::io::Seek>(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<DynamicImage> {
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<DynamicImage> {
decode_jpeg_scaled_reader(std::io::Cursor::new(bytes), target)
}
fn decode_jpeg_scaled_reader<R: std::io::Read>(r: R, target: u32) -> Option<DynamicImage> {
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<Vec<u8>> {
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<u8> {
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 섞인 데이터에서 큰 것을 고른다
@@ -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<FfTools> {
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::<f64>() / 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::<f64>() / 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);
}
}
@@ -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<FfTools> {
@@ -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, "즉시 실패해야 한다");
}
@@ -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<FfTools> {
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<String>,
variant: String,
expect: String,
stage: i64,
}
fn load_manifest(kind: &str) -> Option<(PathBuf, Vec<Entry>)> {
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<Entry> = 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<Db> {
Arc::new(Db::open(&dir.join("t.db")).unwrap())
}
fn add_source(db: &Arc<Db>, 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<Db>) -> HashMap<i64, String> {
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<String>,
}
fn groups_by_name(db: &Arc<Db>, names: &HashMap<i64, String>) -> Vec<Group> {
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<i64, Group> = 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<Db>, groups: &[Group]) {
let scores: Vec<(i64, i64, Option<f64>)> = 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<i64> = 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<f64> = scores.iter().filter_map(|(_, _, s)| *s).collect();
println!(
" 거리 채워진 멤버 {}개(최대 {:?}) · 점수 채워진 그룹 {}/{}개",
dists.len(),
dists.iter().max(),
sc.len(),
scores.len()
);
}
/// 썸네일이 다 만들어질 때까지 기다린다 (없으면 이미지 지문이 안 나온다).
fn build_thumbs(db: &Arc<Db>, sid: i64, thumb_root: &Path, tools: Option<FfTools>) {
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::<usize>();
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<String> {
let mut fails = Vec::new();
let mut member_of: HashMap<&str, Vec<usize>> = 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<i64>, Option<i64>, Option<i64>)> = 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<i64>, Option<i64>, Option<i64>)> =
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})");
}
}
}
@@ -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<FfTools> {
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<u8>)> {
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<u8>)]| -> (Vec<u64>, Vec<u64>) {
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<u64> = 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<u32> = 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>| -> 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 { "(전체 축)" });
}
}
@@ -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<FrameSig> {
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<String> = (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(" "));
}
}
}
+1105 -63
View File
File diff suppressed because it is too large Load Diff
+137 -23
View File
@@ -28,6 +28,10 @@ pub struct AppState {
pub scan_running: Arc<AtomicBool>,
pub dedupe_cancel: Arc<AtomicBool>,
pub dedupe_running: Arc<AtomicBool>,
/// 지문 워커 중단 플래그 (앱 종료 시)
pub sig_cancel: Arc<AtomicBool>,
/// 하드웨어 디코드 사용 여부 — 기동 시 1회 실제 디코드로 판별한 결과
pub hwaccel: Option<&'static str>,
pub remote: Arc<remote::RemotePool>,
/// 자격증명 저장소 (포터블 볼트 잠금 해제 후 교체 가능)
pub creds: Arc<std::sync::Mutex<archive_vfs::creds::CredStore>>,
@@ -40,13 +44,44 @@ fn now_ms() -> i64 {
.unwrap_or(0)
}
fn main() {
/// 로그를 **파일로도** 남긴다. 릴리스는 windows_subsystem="windows"라 콘솔이 없어서
/// stdout만 쓰면 사용자 PC에서 무슨 일이 있었는지 확인할 방법이 아예 없다
/// (실제로 "지문이 안 만들어진다"는 신고에서 로그 폴더가 비어 있어 원인을 못 찾았다).
///
/// 가드를 돌려주는 이유: 드롭되면 백그라운드 기록 스레드가 멈춰 마지막 줄들이 날아간다.
/// main이 끝날 때까지 살아 있어야 한다.
fn init_logging() -> Option<tracing_appender::non_blocking::WorkerGuard> {
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::<AppState>();
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,
+90 -4
View File
@@ -16,20 +16,89 @@ type Debouncer = notify_debouncer_full::Debouncer<
notify_debouncer_full::RecommendedCache,
>;
/// 워치 불가 소스의 **첫** 재스캔 간격. 네트워크 공유(SMB/NAS)는 변경 알림을 안 주는
/// 서버가 있어 폴백이 필요하다.
const POLL_MIN: Duration = Duration::from_secs(60);
/// 변경이 계속 없을 때 늘어나는 상한.
///
/// 고정 60초는 대규모 소스에서 치명적이다 — 100만 건 열거가 60초 안에 끝나지 않으므로
/// 재스캔이 끝나기 전에 다음 재스캔이 큐에 쌓이고, 앱이 켜져 있는 동안 영구히 디스크를 간다.
/// 그래서 변경이 없으면 간격을 2배씩 늘리고(상한 30분), 변경이 있으면 즉시 되돌린다.
const POLL_MAX: Duration = Duration::from_secs(30 * 60);
/// 만기 검사 주기 — 간격 자체가 아니라 '지금 만기된 소스가 있는지' 보는 틱.
const POLL_TICK: Duration = Duration::from_secs(5);
/// 폴링 소스 한 개의 상태. 소스별로 따로 늘어난다(느린 NAS 하나가 다른 소스를 늦추지 않는다).
struct PollState {
/// 다음 재스캔 예정 시각
due: std::time::Instant,
/// 현재 간격 (POLL_MIN..=POLL_MAX)
interval: Duration,
}
type PollMap = std::sync::Arc<Mutex<HashMap<i64, PollState>>>;
pub struct WatcherManager {
watchers: Mutex<HashMap<i64, Debouncer>>,
/// 워치 불가로 폴링에 의존하는 소스 (네트워크 공유 등)
polled: PollMap,
dirty_tx: crossbeam_channel::Sender<i64>,
}
/// 재스캔 결과를 폴링 간격에 반영한다. 변경이 있었으면 최소 간격으로 되돌리고,
/// 없었으면 2배로 늘린다. 폴링 대상이 아닌 소스(워처가 붙은 소스)는 아무 일도 하지 않는다.
fn report_scan(polled: &PollMap, source_id: i64, changed: bool) {
let mut map = polled.lock().unwrap();
if let Some(st) = map.get_mut(&source_id) {
st.interval = if changed {
POLL_MIN
} else {
(st.interval * 2).min(POLL_MAX)
};
st.due = std::time::Instant::now() + st.interval;
tracing::debug!(source_id, secs = st.interval.as_secs(), changed, "폴링 간격 갱신");
}
}
impl WatcherManager {
/// 워커 스레드를 시작하고 매니저를 만든다.
pub fn new(app: tauri::AppHandle) -> std::sync::Arc<WatcherManager> {
let (tx, rx) = crossbeam_channel::unbounded::<i64>();
let polled: PollMap = std::sync::Arc::new(Mutex::new(HashMap::new()));
let manager = std::sync::Arc::new(WatcherManager {
watchers: Mutex::new(HashMap::new()),
dirty_tx: tx,
polled: polled.clone(),
dirty_tx: tx.clone(),
});
// 워치 불가 소스 폴링 — 이벤트를 못 받는 공유도 결국 최신화된다.
// 만기된 소스만 보낸다. 보낼 때 다음 만기를 미리 밀어 두므로, 재스캔이 오래 걸려도
// 그 사이에 같은 소스가 다시 큐에 쌓이지 않는다(간격 갱신은 report_scan이 한다).
{
let polled = polled.clone();
std::thread::Builder::new()
.name("watch-poll".into())
.spawn(move || loop {
std::thread::sleep(POLL_TICK);
let now = std::time::Instant::now();
let mut due: Vec<i64> = Vec::new();
{
let mut map = polled.lock().unwrap();
for (id, st) in map.iter_mut() {
if st.due <= now {
due.push(*id);
st.due = now + st.interval;
}
}
}
for id in due {
let _ = tx.send(id);
}
})
.expect("watch-poll 스레드 생성 실패");
}
let poll_report = polled;
std::thread::Builder::new()
.name("watch-rescan".into())
.spawn(move || {
@@ -56,7 +125,7 @@ impl WatcherManager {
match root {
Ok(root) => {
tracing::info!(source_id, "워처 트리거 재스캔");
crate::commands::run_scan_pipeline(
let stats = crate::commands::run_scan_pipeline(
&state.db,
&state.tools,
&state.thumbs,
@@ -68,6 +137,12 @@ impl WatcherManager {
let _ = app.emit("library-changed", source_id);
},
);
// 아무것도 바뀌지 않았으면 다음 폴링을 늦춘다 —
// 조용한 대규모 NAS를 60초마다 다시 훑지 않게 하는 핵심이다.
let changed = stats
.map(|s| s.added + s.changed + s.removed + s.moved > 0)
.unwrap_or(false);
report_scan(&poll_report, source_id, changed);
state.scan_running.store(false, Ordering::SeqCst);
let _ = app.emit("library-changed", source_id);
}
@@ -88,6 +163,7 @@ impl WatcherManager {
if self.watchers.lock().unwrap().remove(&source_id).is_some() {
tracing::info!(source_id, "파일 워처 해제");
}
self.polled.lock().unwrap().remove(&source_id);
}
/// DB의 로컬 소스 전체를 워치 대상으로 동기화한다 (시작 시 + 소스 추가 후 호출).
@@ -127,8 +203,18 @@ impl WatcherManager {
watchers.insert(source_id, debouncer);
}
Err(e) => {
// 네트워크 드라이브 등 워치 불가 — 수동 새로고침으로 폴백
tracing::warn!(source_id, "워치 불가(수동 새로고침 사용): {e}");
// 네트워크 드라이브 등 워치 불가 — 주기적 재스캔으로 폴백
tracing::warn!(
source_id,
secs = POLL_MIN.as_secs(),
"워치 불가 — 주기 재스캔으로 폴백(변경 없으면 간격 증가): {e}"
);
self.polled.lock().unwrap().entry(source_id).or_insert_with(|| {
PollState {
due: std::time::Instant::now() + POLL_MIN,
interval: POLL_MIN,
}
});
}
}
}
+42 -6
View File
@@ -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 (
<div class="grid h-full grid-rows-[1fr_auto]">
<div class="grid min-h-0 grid-cols-[240px_1fr_280px]">
<div class="grid h-full grid-rows-[1fr_auto] bg-[var(--bg-app)]">
{/* minmax(0,1fr) 필수 — 1fr(=minmax(auto,1fr))이면 본문이 최소 콘텐츠 폭 아래로 줄지 못한다 */}
<div
class="grid min-h-0 gap-2 px-2 pt-2 pb-1"
style={{
"grid-template-columns": detailsOpen()
? "248px minmax(0,1fr) 300px"
: "248px minmax(0,1fr)",
}}
>
<Sidebar />
<ContentArea />
<DetailsPanel />
<Show when={detailsOpen()}>
<DetailsPanel />
</Show>
</div>
<StatusBar />
<Lightbox />
<ContextMenu />
<MoveDialog />
<MultiPlayView />
<DedupePanel />
{/* 비교 뷰는 중복 모달 '위'에 뜬다 — Esc는 위에 있는 것부터 닫힌다 */}
<CompareView />
<RemoteDialog />
<SourceEditDialog />
<ShortcutsOverlay />
</div>
);
+117
View File
@@ -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<number | null>(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 (
<Show when={children().length > 0}>
<div class="chips-band">
<div class="section-label">
{ko.browse.folders}
<span class="nav-item__count">{children().length.toLocaleString("ko-KR")}</span>
<button
class="section-label__action"
tabindex={-1}
title={chipsOpen() ? ko.browse.collapse : ko.browse.expand}
aria-label={chipsOpen() ? ko.browse.collapse : ko.browse.expand}
onClick={() => {
toggleChips();
focusItemView();
}}
>
{chipsOpen() ? <IconChevronDown size={13} /> : <IconChevronRight size={13} />}
</button>
</div>
<Show when={chipsOpen()}>
<div class="chips-grid">
<For each={shown()}>
{(n) => (
<button
class="chip--folder"
classList={{ "is-drop": dropId() === n.id }}
tabindex={-1}
onClick={() => {
void setView({
type: "folder",
folderId: n.id,
recursive: true,
sourceId: srcId(),
});
focusItemView();
}}
onDragOver={(e) => onDragOver(e, n.id)}
onDragLeave={() => setDropId(null)}
onDrop={(e) => {
e.preventDefault();
setDropId(null);
void performMove(n.id).catch((err) => toast(String(err)));
}}
>
<span class="chip-folder__icon"><IconFolder size={13} /></span>
<span class="chip-folder__name" title={n.name}>
{n.name}
</span>
<span class="chip-folder__count">
{subtotalOf(n.id, srcId()).toLocaleString("ko-KR")}
</span>
</button>
)}
</For>
<Show when={hidden() > 0}>
<button
class="chip--folder"
tabindex={-1}
onClick={() => {
setShowAll(true);
focusItemView();
}}
>
<span class="chip-folder__icon"></span>
<span class="chip-folder__icon"><IconPlus size={11} /></span>
<span class="chip-folder__name">{ko.browse.more(hidden())}</span>
</button>
</Show>
</div>
</Show>
</div>
</Show>
);
};
+439
View File
@@ -0,0 +1,439 @@
// 리스트(표) 보기 — 고정 행 높이 가상 스크롤. 이름·크기·날짜를 나란히 비교하고
// 열 머리글로 바로 정렬한다. 선택·정렬·필터·파일 작업은 격자 보기와 완전히 공유한다.
import { IconArrowDown, IconArrowUp, IconTrash } from "../../ui/icons";
import {
createEffect,
createMemo,
createSignal,
For,
on,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import { createStore, produce, reconcile } from "solid-js/store";
import {
anchorIndex,
info,
markedIds,
openLightbox,
registerItemViewFocus,
selected,
selectRange,
selectSingle,
setSort,
snapshot,
sortDesc,
sortKey,
tagVersion,
thumbVersions,
toggleSelect,
trashTag,
} from "../../state/store";
import {
fileDetails,
fileTagsBulk,
setThumbPriority,
thumbUrl,
type FileDetails,
type Snapshot,
type SortKey,
} from "../../ipc/commands";
import { ko } from "../../i18n/ko";
import { openContextMenu } from "../fileops/ContextMenu";
import { handleItemKeys } from "./itemKeys";
const ROW_H = 38;
/** 화면 위·아래로 더 그려 두는 행 수 (스크롤 시 빈칸 방지) */
const OVERSCAN = 10;
/** 한 번에 메타/태그를 채워 넣는 최대 행 수 (백엔드 file_details 상한 500 이내) */
const BATCH_MAX = 400;
/**
* .
* (··) minmax(0,)
* ·· .
*/
const COLS =
"2.75rem minmax(5rem,1fr) minmax(0,11rem) 3rem 4.75rem minmax(0,5.5rem) minmax(0,3.75rem) 8.5rem";
function fmtSize(bytes: number): string {
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(2)} GB`;
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)} MB`;
if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)} KB`;
return `${bytes} B`;
}
function fmtDate(ms: number | null | undefined): string {
if (!ms) return "—";
const d = new Date(ms);
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function fmtDuration(ms: number | null | undefined): string {
if (!ms) return "";
const s = Math.round(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
return h > 0
? `${h}:${String(m % 60).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`
: `${m}:${String(s % 60).padStart(2, "0")}`;
}
export const ListView: Component = () => {
let containerRef: HTMLDivElement | undefined;
const [scrollTop, setScrollTop] = createSignal(0);
const [viewportH, setViewportH] = createSignal(600);
const count = () => snapshot()?.count ?? 0;
// ── 가상 스크롤 ──
const visibleRange = createMemo<[number, number]>(() => {
const n = count();
if (n === 0) return [0, -1];
const first = Math.max(0, Math.floor(scrollTop() / ROW_H) - OVERSCAN);
const last = Math.min(n - 1, Math.ceil((scrollTop() + viewportH()) / ROW_H) + OVERSCAN);
return [first, last];
});
const indices = createMemo(() => {
const [first, last] = visibleRange();
const out: number[] = [];
for (let i = first; i <= last; i++) out.push(i);
return out;
});
let rafPending = false;
const onScroll = () => {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
if (containerRef) setScrollTop(containerRef.scrollTop);
});
};
/** 행을 화면 안으로 (키보드 이동·보기 전환용) */
const revealRow = (index: number) => {
const el = containerRef;
if (!el) return;
const top = index * ROW_H;
if (top < el.scrollTop) el.scrollTop = top;
else if (top + ROW_H > el.scrollTop + el.clientHeight)
el.scrollTop = top + ROW_H - el.clientHeight;
};
onMount(() => {
const el = containerRef!;
// 크롬(툴바·브레드크럼·폴더 칩·사이드바)이 클릭 뒤 방향키를 여기로 되돌린다
onCleanup(registerItemViewFocus(() => containerRef?.focus({ preventScroll: true })));
// 높이는 폴링을 단일 진실원으로 — 값이 같으면 시그널이 갱신되지 않으므로 비용은 없다
const tick = () => setViewportH(el.clientHeight);
tick();
const ro = new ResizeObserver(tick);
ro.observe(el);
const poll = setInterval(tick, 250);
// 격자에서 넘어왔을 때 보고 있던 항목을 그대로 보여준다
const anchor = anchorIndex();
if (anchor >= 0) requestAnimationFrame(() => revealRow(anchor));
onCleanup(() => {
ro.disconnect();
clearInterval(poll);
});
});
// ── 지연 로드: 파일 메타 (이름·크기·날짜·길이) ──
// 이름/경로는 이름변경·이동으로 바뀌므로 스냅샷이 갈릴 때 캐시를 버린다.
const [detailsCache, setDetailsCache] = createStore<Record<number, FileDetails>>({});
let detTimer: ReturnType<typeof setTimeout> | undefined;
createEffect(on(snapshot, () => setDetailsCache(reconcile({})), { defer: true }));
createEffect(() => {
const [first, last] = visibleRange();
const snap = snapshot();
if (!snap || last < first) return;
clearTimeout(detTimer);
detTimer = setTimeout(() => {
const need: number[] = [];
for (let i = first; i <= last && need.length < BATCH_MAX; i++) {
const id = snap.id(i);
if (detailsCache[id] === undefined) need.push(id);
}
if (need.length === 0) return;
void fileDetails(need)
.then((ds) =>
setDetailsCache(
produce((s) => {
for (const d of ds) s[d.id] = d;
}),
),
)
.catch(() => {});
}, 120);
});
onCleanup(() => clearTimeout(detTimer));
// ── 지연 로드: 태그 (격자 오버레이와 같은 배치 조회) ──
const [tagCache, setTagCache] = createStore<Record<number, string[]>>({});
let tagTimer: ReturnType<typeof setTimeout> | undefined;
createEffect(on(tagVersion, () => setTagCache(reconcile({})), { defer: true }));
createEffect(() => {
const [first, last] = visibleRange();
const snap = snapshot();
tagVersion(); // 태그가 바뀌면 다시 읽는다
if (!snap || last < first) return;
clearTimeout(tagTimer);
tagTimer = setTimeout(() => {
const need: number[] = [];
for (let i = first; i <= last && need.length < BATCH_MAX; i++) {
const id = snap.id(i);
if (tagCache[id] === undefined) need.push(id);
}
if (need.length === 0) return;
void fileTagsBulk(need)
.then((links) =>
setTagCache(
produce((s) => {
for (const id of need) s[id] = [];
for (const l of links) {
if (l.tagId === trashTag()) continue; // 삭제 예정은 🗑 배지가 담당
(s[l.fileId] ??= []).push(l.name);
}
}),
),
)
.catch(() => {});
}, 150);
});
onCleanup(() => clearTimeout(tagTimer));
// 화면에 보이는데 썸네일이 없는 항목 우선 생성 요청
let prioTimer: ReturnType<typeof setTimeout> | undefined;
createEffect(() => {
const [first, last] = visibleRange();
const snap = snapshot();
if (!snap || last < first) return;
clearTimeout(prioTimer);
prioTimer = setTimeout(() => {
const ids: number[] = [];
for (let i = first; i <= last && ids.length < BATCH_MAX; i++) {
if (!snap.hasThumb(i)) ids.push(snap.id(i));
}
if (ids.length > 0) void setThumbPriority(ids);
}, 200);
});
onCleanup(() => clearTimeout(prioTimer));
// ── 상호작용 ──
const onRowClick = (e: MouseEvent, snap: Snapshot, index: number) => {
const id = snap.id(index);
if (e.shiftKey) selectRange(snap, index);
else if (e.ctrlKey || e.metaKey) toggleSelect(id, index);
else selectSingle(id, index);
};
const onRowContextMenu = (e: MouseEvent, snap: Snapshot, index: number) => {
e.preventDefault();
const id = snap.id(index);
if (!selected().has(id)) selectSingle(id, index);
void fileDetails([id]).then((d) => {
openContextMenu({ x: e.clientX, y: e.clientY, fileId: id, fileName: d[0]?.name });
});
};
const onRowDragStart = (e: DragEvent, snap: Snapshot, index: number) => {
const id = snap.id(index);
if (!selected().has(id)) selectSingle(id, index);
e.dataTransfer?.setData("application/x-archive-move", "1");
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
};
const onKeyDown = (e: KeyboardEvent) => {
const snap = snapshot();
if (!snap) return;
handleItemKeys(e, snap, {
neighbor: (from, key) =>
key === "ArrowUp" || key === "ArrowLeft"
? from > 0
? from - 1
: undefined
: from < snap.count - 1
? from + 1
: undefined,
onMove: revealRow,
});
};
// ── 날짜 열: 정렬이 '수정일'이면 수정일을, 그 밖에는 촬영일을 보여준다 ──
const dateKey = (): SortKey => (sortKey() === "mtime" ? "mtime" : "taken");
const dateLabel = () => (sortKey() === "mtime" ? ko.list.modified : ko.list.taken);
const dateOf = (d: FileDetails | undefined) =>
d ? (sortKey() === "mtime" ? d.mtimeMs : (d.takenAt ?? d.mtimeMs)) : undefined;
/** 열 머리글 — 누르면 그 열로 정렬, 다시 누르면 방향 반전 */
const SortHead: Component<{ label: string; col: SortKey; right?: boolean }> = (props) => (
<button
class="flex min-w-0 items-center gap-1 hover:text-[var(--text-primary)]"
classList={{
"justify-end": props.right,
"text-[var(--text-primary)]": sortKey() === props.col,
}}
onClick={() => setSort(props.col, sortKey() === props.col ? !sortDesc() : true)}
title={ko.list.sortHint(props.label)}
>
<span class="truncate">{props.label}</span>
<Show when={sortKey() === props.col}>
<span class="shrink-0">
{sortDesc() ? <IconArrowDown size={12} /> : <IconArrowUp size={12} />}
</span>
</Show>
</button>
);
return (
<div class="flex min-h-0 flex-1 flex-col">
{/* 머리글 — 열 폭은 행과 같은 정의를 쓴다 */}
<div
class="grid shrink-0 items-center gap-2 border-b border-[var(--border)] px-2 py-1.5 text-[11px] text-[var(--text-muted)]"
style={{ "grid-template-columns": COLS }}
>
<span />
<SortHead label={ko.list.name} col="name" />
<span class="truncate">{ko.list.tags}</span>
<span class="truncate">{ko.list.kind}</span>
<SortHead label={ko.list.size} col="size" right />
<span class="truncate text-right">{ko.list.dims}</span>
<span class="truncate text-right">{ko.list.duration}</span>
<SortHead label={dateLabel()} col={dateKey()} right />
</div>
<div
ref={containerRef}
onScroll={onScroll}
onKeyDown={onKeyDown}
tabindex={0}
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden outline-none"
>
<Show
when={count() > 0}
fallback={
<div class="grid h-full place-items-center text-sm text-[var(--text-muted)]">
{ko.grid.empty}
</div>
}
>
<div class="relative" style={{ height: `${count() * ROW_H}px` }}>
<For each={indices()}>
{(i) => {
// 모든 파생값은 반응형으로 — For가 같은 인덱스의 DOM을 재사용해도
// 뷰 전환으로 스냅샷이 바뀌면 즉시 갱신되도록 한다.
const id = () => snapshot()?.id(i) ?? -1;
const d = () => detailsCache[id()];
const isSel = () => selected().has(id());
const marked = () => markedIds().has(id());
const isVideo = () => snapshot()?.kind(i) === 1;
const w = () => snapshot()?.width(i) ?? 0;
const h = () => snapshot()?.height(i) ?? 0;
const version = () => thumbVersions[id()] ?? 0;
const showImg = () => {
const s = snapshot();
return !!s && (s.hasThumb(i) || version() > 0);
};
const rowTags = () => tagCache[id()] ?? [];
return (
<div
class="list-row absolute inset-x-0 grid cursor-pointer items-center gap-2 px-2 text-xs"
classList={{ "is-sel": isSel() }}
style={{
transform: `translateY(${i * ROW_H}px)`,
height: `${ROW_H}px`,
"grid-template-columns": COLS,
}}
onClick={(e) => {
const s = snapshot();
if (s) onRowClick(e, s, i);
}}
onDblClick={() => openLightbox(i)}
onContextMenu={(e) => {
const s = snapshot();
if (s) onRowContextMenu(e, s, i);
}}
draggable={true}
onDragStart={(e) => {
const s = snapshot();
if (s) onRowDragStart(e, s, i);
}}
>
{/* 28×36 .
--grid-cell-bg( ) . */}
<div class="h-7 w-9 shrink-0 overflow-hidden rounded-[var(--r-xs)] bg-[var(--bg-panel-raised)]">
<Show when={showImg() && info()}>
<img
src={`${thumbUrl(info()!, id())}&v=${version()}`}
class="h-full w-full object-cover"
decoding="async"
draggable={false}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
</Show>
</div>
{/* 이름 (+ 삭제 예정 배지) */}
<div class="flex min-w-0 items-center gap-1.5">
<Show when={marked()}>
<span class="chip chip--danger chip--xs shrink-0" title={ko.trash.badge}>
<IconTrash size={11} />
</span>
</Show>
<span
class="truncate text-[var(--text-primary)]"
title={d() ? `${d()!.dir}\\${d()!.name}` : undefined}
>
{d()?.name ?? "…"}
</span>
</div>
{/* 태그 */}
<div class="flex min-w-0 items-center gap-1 overflow-hidden">
<For each={rowTags().slice(0, 3)}>
{(name) => (
<span class="chip chip--accent chip--xs min-w-0" title={name}>
<span>{name}</span>
</span>
)}
</For>
<Show when={rowTags().length > 3}>
<span class="shrink-0 text-[10px] text-[var(--text-muted)]">
+{rowTags().length - 3}
</span>
</Show>
</div>
<span class="truncate text-[var(--text-muted)]">
{isVideo() ? ko.filter.video : ko.filter.image}
</span>
<span class="text-right tabular-nums text-[var(--text-secondary)]">
{d() ? fmtSize(d()!.size) : ""}
</span>
{/* 좁아지면 0폭까지 줄어드는 열 — 넘친 글자가 옆 열을 침범하지 않게 자른다 */}
<span class="truncate text-right tabular-nums text-[var(--text-muted)]">
{w() > 0 && h() > 0 ? `${w()}×${h()}` : "—"}
</span>
<span class="truncate text-right tabular-nums text-[var(--text-muted)]">
{fmtDuration(d()?.durationMs)}
</span>
<span class="text-right tabular-nums text-[var(--text-muted)]">
{fmtDate(dateOf(d()))}
</span>
</div>
);
}}
</For>
</div>
</Show>
</div>
</div>
);
};
+148
View File
@@ -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;
}
}
}
+731
View File
@@ -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<string | undefined>();
// 스트림(리먹스) 재생은 시작 시각이 파일 0초가 아니다 — 환산에 쓴다
const [base, setBase] = createSignal(0);
const [dur, setDur] = createSignal(0);
const [oor, setOor] = createSignal(false);
const [plan, setPlan] = createSignal<PlaybackPlan | undefined>();
let jobId: number | undefined;
let restartTimer: ReturnType<typeof setTimeout> | 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 (
<div class="compare-pane">
<Show
when={src()}
fallback={
<img
class="compare-media"
src={app ? thumbUrl(app, props.member.fileId, 1) : undefined}
alt=""
/>
}
>
<video
class="compare-media"
ref={(e) => (el = e)}
src={src()}
preload="auto"
playsinline
muted={props.muted}
onLoadedMetadata={() => {
setDur(el?.duration && isFinite(el.duration) ? el.duration : 0);
// 새로 만들어진 패널은 자기 0초에서 시작한다 — 보기를 바꾸거나 비교 대상을
// 갈아끼우면 재생바는 10초를 가리키는데 화면만 0초인 상태가 된다.
// 스트림 모드는 애초에 그 지점에서 띄우므로(openStreamAt) 여기서 손대지 않는다.
if (el && plan()?.mode !== "stream") {
const want = Math.max(0, props.startAtSec - off());
if (Math.abs(el.currentTime - want) > 0.05) el.currentTime = want;
}
}}
/>
</Show>
<Show when={oor()}>
{/* 한쪽에만 있는 구간 — 검은 화면을 그냥 보여주면 '깨진 것'으로 오해한다 */}
<div class="compare-pane__note">{ko.compare.outOfRange}</div>
</Show>
</div>
);
};
const ImagePane: Component<{ member: DupeMember; zoom: number; pan: [number, number] }> = (
props,
) => {
const app = appInfoSig();
return (
<div class="compare-pane">
<img
class="compare-media"
style={{
transform: `translate(${props.pan[0]}px, ${props.pan[1]}px) scale(${props.zoom})`,
}}
src={app ? mediaUrl(app, props.member.fileId) : undefined}
alt={props.member.name}
draggable={false}
/>
</div>
);
};
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<DupeMember[]>(() => {
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가 없는 <video>를 향한다. 소리 인덱스가 범위를 벗어나면 첫 패널로 되돌리되,
// **전체 음소거는 그대로 둔다**(-1은 범위 밖이 아니라 사용자가 고른 상태다).
createEffect(() => {
const n = panes().length;
apis.length = n;
if (audioPane() >= n) setAudio(0);
if (lastAudio >= n) lastAudio = 0;
});
/** 사용자가 위치를 옮겼다 — 스트림 패널은 여기서만 다시 띄운다(hard). */
/** 소리 대상을 바꾼다. 끄기 전에 듣던 자리를 기억해 둔다(다시 켤 때 그리로 돌아간다). */
const setAudio = (i: number) => {
if (i !== MUTED) lastAudio = i;
setAudioPane(i);
};
/** 전체 음소거 ↔ 마지막으로 듣던 패널 */
const toggleMuteAll = () => setAudio(audioPane() === MUTED ? lastAudio : MUTED);
/** 사용자가 위치를 옮겼다 — 스트림 패널은 여기서만 다시 띄운다(hard). */
const seekAll = (t: number) => {
const clamped = Math.max(0, totalSec() > 0 ? Math.min(t, totalSec()) : t);
setPos(clamped);
for (const a of apis) a?.seek(clamped, true);
};
const setPlay = (on: boolean) => {
setPlaying(on);
if (on) {
// 재생을 시작하는 순간 한 번 맞춘다(hard). 리먹스 스트림 패널은 자기가 열린 지점부터
// 독립적으로 흐르므로, 여기서 안 맞추면 처음부터 어긋난 채로 나란히 돈다.
// 재생 중에는 다시 맞추지 않는다 — 재시작에 걸리는 1초 사이 keeper가 또 움직여
// 따라잡지 못하고 재시작만 반복하기 때문이다.
for (const a of apis) a?.seek(pos(), true);
}
for (const a of apis) (on ? a?.play : a?.pause)?.();
};
// keeper를 진실원으로 삼아 진행을 읽고, 벌어진 패널을 다시 맞춘다
onMount(() => {
const tick = () => {
raf = requestAnimationFrame(tick);
if (!playing() || !isVideo()) return;
const k = apis[0];
if (!k) return;
const t = k.keeperTime();
setPos(t);
// 0번(keeper)은 기준이므로 건드리지 않는다. 나머지는 각자 자기 오프셋으로 따라온다.
for (let i = 1; i < apis.length; i++) {
const a = apis[i];
if (a && Math.abs(a.keeperTime() - t) > DRIFT_TOLERANCE) a.seek(t);
}
};
raf = requestAnimationFrame(tick);
onCleanup(() => cancelAnimationFrame(raf));
const onKey = (e: KeyboardEvent) => {
if (!compareOpen()) return;
const k = e.key;
if (k === "Escape") {
e.preventDefault();
e.stopPropagation();
closeCompare();
return;
}
if (!isVideo() && (k === "0" || k === "r")) {
setZoom(1);
setPan([0, 0]);
return;
}
if (!isVideo()) return;
if (k === " ") {
e.preventDefault();
e.stopPropagation();
setPlay(!playing());
} else if (k === "ArrowLeft") {
e.preventDefault();
e.stopPropagation();
seekAll(pos() - SKIP);
} else if (k === "ArrowRight") {
e.preventDefault();
e.stopPropagation();
seekAll(pos() + SKIP);
} else if (k === "m") {
// 순환에 '전체 음소거'도 넣는다 — M을 계속 누르면 각 패널을 거쳐 무음까지 간다.
// (0 → 1 → … → n-1 → 없음 → 0)
const n = Math.max(1, panes().length);
const cur = audioPane();
setAudio(cur === MUTED ? 0 : cur + 1 >= n ? MUTED : cur + 1);
} else if (k === "M") {
// Shift+M — 순환을 거치지 않고 바로 껐다 켠다(패널이 많을 때)
toggleMuteAll();
}
};
window.addEventListener("keydown", onKey, { capture: true });
onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true }));
});
// 이미지 확대/이동 — 두 패널이 같은 값을 쓰므로 항상 같은 부분을 본다
const onWheel = (e: WheelEvent) => {
if (isVideo()) return;
e.preventDefault();
setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z * (e.deltaY < 0 ? 1.15 : 1 / 1.15))));
if (zoom() <= ZOOM_MIN) setPan([0, 0]);
};
let drag: { x: number; y: number; px: number; py: number } | undefined;
const onDown = (e: PointerEvent) => {
if (isVideo() || zoom() <= ZOOM_MIN) return;
drag = { x: e.clientX, y: e.clientY, px: pan()[0], py: pan()[1] };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const onMove = (e: PointerEvent) => {
if (!drag) return;
setPan([drag.px + (e.clientX - drag.x), drag.py + (e.clientY - drag.y)]);
};
const onUp = () => (drag = undefined);
const seg = createMemo(() => {
const o = other();
if (o?.segStartMs == null || o.segEndMs == null) return undefined;
return { start: o.segStartMs / 1000, end: o.segEndMs / 1000 };
});
/**
* . keeper "이 사본의 몇 분 몇 초인가"
* · , .
*
* ** ** ( keeper seekAll).
* , .
*/
const paneTimeline = (m: DupeMember, side: number) => {
const off = () => (side === 0 ? 0 : (m.offsetMs ?? 0) / 1000);
const dur = () => (m.durationMs ?? 0) / 1000;
/** 이 사본의 현재 시각 (keeper 시각 − 오프셋) */
const at = () => pos() - off();
/** keeper 위치가 이 파일에 없는 구간 — 값을 끝에 붙여두고 흐리게 표시한다 */
const oor = () => dur() > 0 && (at() < -0.05 || at() > dur() + 0.05);
const clamped = () => Math.min(Math.max(at(), 0), Math.max(dur(), 0.1));
const pct = () => (dur() > 0 ? (clamped() / dur()) * 100 : 0);
/** 일치 구간을 이 사본의 시간축으로 옮긴 띠 (keeper 자신은 구간 기록이 없다) */
const band = createMemo(() => {
if (m.segStartMs == null || m.segEndMs == null || dur() <= 0) return undefined;
const a = (m.segStartMs / 1000 - off()) / dur();
const b = (m.segEndMs / 1000 - off()) / dur();
const lo = Math.max(0, Math.min(a, b));
const hi = Math.min(1, Math.max(a, b));
return hi > lo ? { left: `${lo * 100}%`, width: `${(hi - lo) * 100}%` } : undefined;
});
return (
<div class="compare-time" classList={{ "is-oor": oor() }}>
<div class="compare-time__wrap">
<Show when={band()}>
{(b) => <div class="compare-time__seg" style={{ left: b().left, width: b().width }} />}
</Show>
<input
type="range"
class="compare-time__bar"
style={{ "--p": `${pct()}%` }}
min={0}
max={Math.max(dur(), 0.1)}
step={0.05}
value={clamped()}
aria-label={m.name}
onInput={(e) => seekAll(Number(e.currentTarget.value) + off())}
/>
</div>
<span class="compare-time__label">
{oor() ? ko.compare.outOfRangeShort : fmt(at())} / {fmt(dur())}
</span>
</div>
);
};
const paneMeta = (m: DupeMember, side: number) => (
<div class="compare-head">
<span class="compare-head__name" title={`${m.dir}\\${m.name}`}>
{m.name}
</span>
<Show when={m.isKeeper}>
<span class="chip chip--xs">{ko.dedupe.keeper}</span>
</Show>
<span class="compare-head__meta">
{m.width && m.height ? `${m.width}×${m.height}` : ""}
{/* 길이는 여기서도 말한다 — 전체 보기에서 '이것만 짧다'가 한눈에 보인다 */}
{m.durationMs != null && m.durationMs > 0 ? ` · ${fmtDuration(m.durationMs)}` : ""}
{m.distance != null ? ` · ${ko.dedupe.distance(m.distance)}` : ""}
</span>
<span class="ml-auto flex flex-none items-center gap-1">
{/* "이게 대체 어디 있는 파일이지" — 검토 중 가장 자주 나오는 질문이다 */}
<button
class="toolbar-btn toolbar-btn--icon"
title={ko.compare.reveal}
onClick={() => {
void setView({
type: "folder",
folderId: m.folderId,
recursive: false,
sourceId: m.sourceId,
});
closeCompare();
closeDedupe();
}}
>
<IconFolder size={14} />
</button>
<Show when={isVideo()}>
{/* 듣고 있는 쪽을 다시 누르면 전부 끈다 — 켠 버튼이 곧 끄는 버튼이다 */}
<button
class="toolbar-btn toolbar-btn--icon"
title={audioPane() === side ? ko.compare.audioOn : ko.compare.audioOff}
onClick={() => setAudio(audioPane() === side ? MUTED : side)}
>
{audioPane() === side ? <IconVolumeHigh size={14} /> : <IconVolumeMute size={14} />}
</button>
</Show>
</span>
</div>
);
return (
<Show when={target() && keeper() && other()}>
<div class="modal-scrim" onClick={closeCompare}>
<div
class="modal-card compare-card"
tabindex={-1}
onClick={(e) => e.stopPropagation()}
ref={(el) => queueMicrotask(() => el.focus())}
>
<div class="modal-head">
<span class="modal-head__title">{ko.compare.title}</span>
{/*
, */}
<span class="modal-head__sub">
<Show
when={!showAll()}
fallback={ko.compare.allCount(panes().length)}
>
{other()!.offsetMs == null
? ko.compare.noAlignment
: fmtShift(other()!.offsetMs as number)}
<Show when={seg()}>
{(s) => ` · ${ko.compare.segment(fmt(s().start), fmt(s().end))}`}
</Show>
</Show>
</span>
<button class="icon-btn ml-auto" onClick={closeCompare} title={ko.common.close}>
<IconX size={14} />
</button>
</div>
{/* 사본이 2개 이상이면 보기를 고를 수 있다: 1:1(크게) 또는 전체(한눈에) */}
<Show when={others().length > 1}>
<div class="modal-bar">
<div class="segmented flex-none">
<button classList={{ "is-on": !showAll() }} onClick={() => setShowAll(false)}>
{ko.compare.modePair}
</button>
<button
classList={{ "is-on": showAll() }}
title={ko.compare.modeAllHint(others().length + 1)}
onClick={() => setShowAll(true)}
>
{ko.compare.modeAll}
</button>
</div>
{/* 1:1일 때만 상대를 고른다 — 전체 보기에서는 고를 것이 없다 */}
<Show when={!showAll()}>
<span class="flex-none text-[var(--text-muted)]">{ko.compare.pick}</span>
<For each={others()}>
{(m) => (
<button
class="chip chip--xs"
classList={{ "chip--accent": m.fileId === target()!.otherId }}
onClick={() => setTarget({ group: group()!, otherId: m.fileId })}
>
{m.name}
</button>
)}
</For>
</Show>
</div>
</Show>
<div
class="compare-grid"
style={{ "--compare-cols": String(cols()) }}
onWheel={onWheel}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
classList={{ "is-pannable": !isVideo() && zoom() > ZOOM_MIN }}
>
<For each={panes()}>
{(m, i) => (
<div class="compare-col">
{paneMeta(m, i())}
<Show
when={isVideo()}
fallback={<ImagePane member={m} zoom={zoom()} pan={pan()} />}
>
{/* keyed ** **.
<video> src onMount에서 . */}
<Show when={m} keyed>
{(mm) => (
<VideoPane
member={mm}
// keeper는 기준이므로 오프셋 0. 나머지는 자기 값으로 맞춘다.
offsetMs={i() === 0 ? 0 : mm.offsetMs}
muted={audioPane() !== i()}
playing={playing()}
startAtSec={pos()}
register={(a) => (apis[i()] = a)}
/>
)}
</Show>
</Show>
{/* 자기 시간축 — 영상에만. 사진은 시간이 없다 */}
<Show when={isVideo()}>{paneTimeline(m, i())}</Show>
</div>
)}
</For>
</div>
{/* 컨트롤 — 영상은 재생/시크(keeper 기준), 이미지는 확대 */}
<div class="modal-bar">
<Show
when={isVideo()}
fallback={
<>
<span class="flex-none text-[var(--text-muted)]">{ko.compare.zoomHint}</span>
<span class="flex-none tabular-nums">{Math.round(zoom() * 100)}%</span>
<button
class="toolbar-btn toolbar-btn--icon ml-auto"
title={ko.compare.reset}
onClick={() => {
setZoom(1);
setPan([0, 0]);
}}
>
<IconResetZoom size={14} />
</button>
</>
}
>
<button
class="toolbar-btn toolbar-btn--icon"
title={ko.compare.back(SKIP)}
onClick={() => seekAll(pos() - SKIP)}
>
<IconRewind size={14} />
</button>
<button
class="toolbar-btn toolbar-btn--primary"
onClick={() => setPlay(!playing())}
title={playing() ? ko.compare.pause : ko.compare.play}
>
{playing() ? <IconPause size={14} /> : <IconPlay size={14} />}
</button>
<button
class="toolbar-btn toolbar-btn--icon"
title={ko.compare.fwd(SKIP)}
onClick={() => seekAll(pos() + SKIP)}
>
<IconForward size={14} />
</button>
<input
type="range"
class="compare-seek"
min={0}
max={Math.max(totalSec(), 0.1)}
step={0.05}
value={pos()}
onInput={(e) => seekAll(Number(e.currentTarget.value))}
/>
<span class="flex-none tabular-nums text-[var(--text-muted)]">
{fmt(pos())} / {fmt(totalSec())}
</span>
{/* 8 ' '
*/}
<button
class="toolbar-btn toolbar-btn--icon flex-none"
classList={{ "is-on": audioPane() === MUTED }}
title={audioPane() === MUTED ? ko.compare.unmuteAll : ko.compare.muteAll}
onClick={toggleMuteAll}
>
{audioPane() === MUTED ? <IconVolumeMute size={14} /> : <IconVolumeHigh size={14} />}
</button>
<Show when={seg()}>
{(s) => (
<button class="toolbar-btn flex-none" onClick={() => seekAll(s().start)}>
{ko.compare.toSegment}
</button>
)}
</Show>
</Show>
</div>
</div>
</div>
</Show>
);
};
+532 -190
View File
@@ -1,4 +1,16 @@
// 중복 검토 패널 — 전체화면 오버레이. 그룹별 나란히 비교 + keeper 외 휴지통.
// 중복 검토 — 모달. 기존 모달 3종(MoveDialog·RemoteDialog·SourceEditDialog)과 같은 언어를
// 쓴다: 스크림 + 카드 + Esc + 바깥 클릭. 전에는 불투명 전체화면(fixed inset-0 bg-[--bg-app])
// 이라 '중복 찾기'를 누르면 사용자에게는 창이 바뀐 것처럼 보였다 — 그게 이 변경의 이유다.
// 폭을 좁히는 것은 목적이 아니다. 그룹 목록은 길고 썸네일이 많아 작업 면적이 필요하므로
// 카드는 .modal-card--wide(min(1080px, 100vw64px))이고, 960px 창에서 896px로 줄어
// 가로 스크롤이 0이 된다.
//
// 진행은 이 컴포넌트가 소유하지 않는다. dedupeRunning / dedupeProgress / dedupeJob /
// dedupeCancelPending은 전부 store(모듈 시그널) 소유다. 그래서 모달을 닫아도 탐지는
// 계속되고, 사이드바 작업 행에서 계속 보이며 거기서도 취소된다(커밋 ac3b527의 성질).
// 여기서는 자체 진행 시그널도, 타이머도 만들지 않는다 — 읽기만 한다.
// 취소는 사이드바와 똑같이 requestCancelDedupe() 하나를 부른다(파괴적이지 않으므로 확인 없음).
import { IconCheck, IconWarning, IconX } from "../../ui/icons";
import {
createEffect,
createMemo,
@@ -6,25 +18,38 @@ import {
createSignal,
For,
on,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import { ask } from "@tauri-apps/plugin-dialog";
import { compareOpen, openCompare } from "./CompareView";
import { byTier, tierOf } from "./dupeTier";
import { fmtDuration, fmtSize } from "./format";
import { ko } from "../../i18n/ko";
import {
dismissDupePair,
listDupeGroups,
markDelete,
resolveDupeGroup,
setDupeKeeper,
setSigSpeed,
sigSpeed,
thumbUrl,
trashFiles,
type DupeGroup,
type DupeMember,
type SigSpeed,
} from "../../ipc/commands";
import {
closeDedupe,
dedupeCancelPending,
dedupeJob,
dedupeOpen,
dedupeProgress,
dedupeRunning,
info,
pending,
refreshMarked,
refreshSidebar,
refreshSnapshot,
requestCancelDedupe,
@@ -33,49 +58,129 @@ import {
view,
} from "../../state/store";
function fmtSize(bytes: number): string {
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`;
/** 타일 부제 — 없는 값은 말하지 않는다. 거리는 ko.dedupe.distance가 문장을 만든다. */
function metaLine(m: DupeMember): string {
const parts: string[] = [];
if (m.width && m.height) parts.push(`${m.width}×${m.height}`);
// 길이는 해상도 바로 뒤 — 영상 그룹에서 '잘린 사본'을 고르는 가장 빠른 단서다
if (m.durationMs != null && m.durationMs > 0) parts.push(fmtDuration(m.durationMs));
parts.push(fmtSize(m.size));
if (m.distance != null) parts.push(ko.dedupe.distance(m.distance));
return parts.join(" · ");
}
const KIND_LABEL: Record<number, string> = { 0: "완전 동일", 1: "유사 이미지", 2: "유사 영상" };
/** 한 번에 그리는 그룹 수. 수백 그룹에서 썸네일 수천 장을 한꺼번에 붙이지 않기 위한 상한. */
const PAGE = 20;
const FOCUSABLE =
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
export const DedupePanel: Component = () => {
const [includeVideo, setIncludeVideo] = createSignal(false);
// 회전·반전본은 dHash가 완전히 달라 변형 8개를 따로 조회해야 한다 — 비용이 몇 배라 기본 꺼짐
const [includeRotated, setIncludeRotated] = createSignal(false);
const [threshold, setThreshold] = createSignal(5);
// 지문 생성 속도 — 값의 진실원은 DB다(백필 스레드가 거기서 읽는다). 여기서는 비추기만 한다.
const [speed, setSpeed] = createSignal<SigSpeed>("normal");
createEffect(() => {
if (dedupeOpen()) void sigSpeed().then(setSpeed).catch(() => {});
});
const changeSpeed = async (s: SigSpeed) => {
const prev = speed();
setSpeed(s); // 낙관적 — 버튼이 즉시 반응해야 한다
try {
await setSigSpeed(s);
} catch (e) {
setSpeed(prev);
toast(String(e));
}
};
const [groupsVersion, setGroupsVersion] = createSignal(0);
/** 그룹 액션 중인 그룹 id. 진행 중에는 모든 그룹의 두 버튼을 잠근다(중복 실행 방지). */
const [busyGroup, setBusyGroup] = createSignal<number | undefined>();
const busy = () => busyGroup() != null;
const [shown, setShown] = createSignal(PAGE);
const [groups] = createResource(
let cardEl: HTMLDivElement | undefined;
// 목록 조회 실패를 여기서 삼킨다 — Solid 리소스는 읽는 순간 오류를 다시 던지므로
// 삼키지 않으면 렌더 도중 터진다. 사용자에게는 토스트로 알린다.
const [groups, { mutate: mutateGroups }] = createResource(
() => (dedupeOpen() ? groupsVersion() : undefined),
() => listDupeGroups(),
() =>
listDupeGroups().catch((e: unknown) => {
toast(String(e));
return [] as DupeGroup[];
}),
);
// 목록이 바뀌면(열기·재조회·탐지 완료) 페이징을 처음으로 되돌린다.
createEffect(on(groups, () => setShown(PAGE)));
// 확실한 것을 앞으로 — 몰아서 정리하고 의심스러운 것만 눈으로 보게 한다
const sorted = createMemo(() => [...(groups() ?? [])].sort(byTier));
const visible = () => sorted().slice(0, shown());
const restCount = () => Math.max(0, sorted().length - shown());
const tierCounts = createMemo(() => {
let certain = 0;
for (const g of sorted()) if (tierOf(g) === "certain") certain += 1;
return { certain, suspect: sorted().length - certain };
});
// 현재 뷰가 폴더/소스면 그 범위로 좁힐 수 있다 (그 외 전체/검색/태그/스마트는 전체만).
const currentScope = createMemo<
{ label: string; args: { folderId?: number; recursive?: boolean; sourceId?: number } } | null
>(() => {
const v = view();
if (v.type === "folder") return { label: ko.dedupe.scopeFolder, args: { folderId: v.folderId, recursive: v.recursive } };
if (v.type === "folder")
return { label: ko.dedupe.scopeFolder, args: { folderId: v.folderId, recursive: v.recursive } };
if (v.type === "source") return { label: ko.dedupe.scopeSource, args: { sourceId: v.sourceId } };
return null;
});
// 패널을 열 때: 범위를 좁힐 수 있으면 기본값을 '현재 위치'로.
// 모달을 열 때: 범위를 좁힐 수 있으면 기본값을 '현재 위치'로.
const [useCurrentScope, setUseCurrentScope] = createSignal(true);
createEffect(() => {
if (dedupeOpen()) setUseCurrentScope(currentScope() != null);
});
/** 실행 중이면 '시작 시점에 캡처된 작업' — 그 사이 다른 폴더로 이동해도 이 말은 바뀌지 않는다. */
const runningJob = () => (dedupeRunning() ? dedupeJob() : undefined);
/** 끝난 뒤에는 같은 캡처가 '지금 보이는 결과의 조건'을 말한다. */
const resultJob = () => (dedupeRunning() ? undefined : dedupeJob());
/** 진행률(%) — 총량을 모르는 구간은 undefined이고, 그때 막대는 셔틀(is-indet)로 돈다. */
const pct = (): number | undefined => {
const p = dedupeProgress();
if (!p || p.phase === "done" || p.total <= 0) return undefined;
return Math.min(100, Math.max(0, Math.round((100 * p.current) / p.total)));
};
/** 헤더 한 줄 — 취소를 누른 뒤에는 사이드바 값 칸과 똑같이 '취소 중…' 하나로 통일한다. */
const statusText = (): string => {
if (dedupeCancelPending()) return ko.common.canceling;
const p = dedupeProgress();
return p && p.phase !== "done"
? ko.dedupe.scanning(p.phase, p.current, p.total)
: ko.dedupe.starting;
};
const runScan = async () => {
const scoped = useCurrentScope() ? currentScope() : null;
try {
await runDedupe({ ...(scoped?.args ?? {}), imageThreshold: threshold(), includeVideo: includeVideo() });
// scopeLabel은 IPC로 가지 않는다 — store가 '지금 도는 조건'을 말하기 위한 캡처다.
await runDedupe({
...(scoped?.args ?? {}),
imageThreshold: threshold(),
includeVideo: includeVideo(),
includeRotated: includeRotated(),
scopeLabel: scoped?.label ?? ko.dedupe.scopeAll,
});
} catch (e) {
toast(String(e));
}
};
// 탐지 완료(실행중 true→false) 시 결과 목록 갱신 — 패널이 닫혀 있다 열려도 재조회.
// 탐지 완료(실행중 true→false) 시 결과 목록 갱신 — 모달이 닫혀 있다 열려도 재조회된다.
createEffect(
on(
dedupeRunning,
@@ -86,212 +191,449 @@ export const DedupePanel: Component = () => {
),
);
/** keeper 외 전부 휴지통 */
const resolveGroup = async (g: DupeGroup) => {
const losers = g.members.filter((m) => !m.isKeeper).map((m) => m.fileId);
if (losers.length === 0) {
await resolveDupeGroup(g.id);
setGroupsVersion((v) => v + 1);
return;
/**
* '나머지 휴지통' .
*
* 고친다: 재조회는
* (`on(groups, …)`) .
* .
*/
const chooseKeeper = async (g: DupeGroup, m: DupeMember) => {
if (busy() || m.isKeeper) return;
try {
// 정렬값(offsetMs·구간)이 새 기준으로 옮겨진 멤버들을 그대로 받아 갈아끼운다.
// 여기서 isKeeper만 고치면 비교 재생이 옛 기준 오프셋으로 어긋난다(실측: 5초).
const members = await setDupeKeeper(g.id, m.fileId);
mutateGroups((prev) => (prev ?? []).map((x) => (x.id === g.id ? { ...x, members } : x)));
} catch (e) {
toast(String(e));
}
const yes = await ask(ko.dedupe.trashLosersConfirm(losers.length), {
title: "Archive",
kind: "warning",
});
if (!yes) return;
await trashFiles(losers);
await resolveDupeGroup(g.id);
toast(ko.ops.trashDone(losers.length));
refreshSidebar();
await refreshSnapshot();
setGroupsVersion((v) => v + 1);
};
const keepAll = async (g: DupeGroup) => {
// 그룹 내 모든 쌍을 dismiss (재탐지 시 제외) + 해결 표시
for (let i = 0; i < g.members.length; i++) {
for (let j = i + 1; j < g.members.length; j++) {
await dismissDupePair(g.members[i].fileId, g.members[j].fileId);
/**
* **'삭제 예정' **. .
*
* . :
* (`OpSummary`) "N개를 휴지통으로 보냈습니다" ,
* OS (· ) .
* ,
* . '정리' .
*
* '정리 → 비우기' .
*/
const resolveGroup = async (g: DupeGroup) => {
if (busy()) return;
const losers = g.members.filter((m) => !m.isKeeper).map((m) => m.fileId);
setBusyGroup(g.id);
try {
if (losers.length > 0) {
// 파일을 건드리지 않고 태그만 붙이므로 확인창을 두지 않는다 — 되돌리기는
// 항목을 골라 표시를 해제하거나 실행 취소로 한다.
const r = await markDelete(losers);
toast(ko.dedupe.markedForDelete(r.count));
}
await resolveDupeGroup(g.id);
if (losers.length > 0) {
refreshSidebar();
await refreshMarked();
await refreshSnapshot();
}
setGroupsVersion((v) => v + 1);
} catch (e) {
toast(String(e));
} finally {
setBusyGroup(undefined);
}
};
/** 그룹 내 모든 쌍을 dismiss(재탐지 시 제외) + 해결 표시. 멤버가 많으면 오래 걸린다. */
const keepAll = async (g: DupeGroup) => {
if (busy()) return;
setBusyGroup(g.id);
try {
for (let i = 0; i < g.members.length; i++) {
for (let j = i + 1; j < g.members.length; j++) {
await dismissDupePair(g.members[i].fileId, g.members[j].fileId);
}
}
await resolveDupeGroup(g.id);
setGroupsVersion((v) => v + 1);
} catch (e) {
toast(String(e));
} finally {
setBusyGroup(undefined);
}
};
// Esc — 모달만 닫는다. 탐지는 계속되고 취소도 계속 가능하다.
// 그룹 액션(busy) 중에도 닫힌다: 백엔드 작업은 창이 떠 있는지와 무관하게 끝난다.
onMount(() => {
const onKey = (e: KeyboardEvent) => {
// 비교 뷰가 위에 떠 있으면 그쪽이 먼저 닫힌다 — 아래 모달까지 같이 닫으면
// 사용자는 결과 목록을 잃는다. (둘 다 capture 단계라 등록 순서로 이 핸들러가 먼저 온다)
if (compareOpen()) return;
if (!dedupeOpen() || e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
closeDedupe();
};
window.addEventListener("keydown", onKey, { capture: true });
onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true }));
});
// Tab 트랩 — 카드 안을 순환한다. 빠져나가면 그리드 스크롤러(tabindex=0)에 착지해
// 방향키·Delete가 뒤에서 살아나므로 반드시 막는다.
const trapTab = (e: KeyboardEvent) => {
if (e.key !== "Tab" || !cardEl) return;
const stops = [...cardEl.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
(el) => el.offsetParent !== null,
);
if (stops.length === 0) {
e.preventDefault();
cardEl.focus();
return;
}
const active = document.activeElement as HTMLElement | null;
const inside = active != null && active !== cardEl && cardEl.contains(active);
const edge = e.shiftKey ? stops[0] : stops[stops.length - 1];
if (!inside || active === edge) {
e.preventDefault();
(e.shiftKey ? stops[stops.length - 1] : stops[0]).focus();
}
await resolveDupeGroup(g.id);
setGroupsVersion((v) => v + 1);
};
return (
<Show when={dedupeOpen()}>
<div class="fixed inset-0 z-40 flex flex-col bg-[var(--bg-app)]">
{/* 헤더 */}
<div class="flex h-12 shrink-0 items-center gap-4 border-b border-[var(--border)] px-4">
<span class="text-sm font-semibold">{ko.dedupe.title}</span>
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
<label class="flex items-center gap-1.5">
{ko.dedupe.threshold}
{/* 바깥 클릭으로 닫힘 — 카드는 stopPropagation으로 스크림 클릭을 삼킨다 */}
<div class="modal-scrim" onClick={closeDedupe}>
<div
class="modal-card modal-card--wide"
tabindex={-1}
ref={(el) => {
cardEl = el;
// 열 때 포커스를 카드로 옮긴다 — 안 하면 뒤 그리드가 방향키·Ctrl+A·Delete를 먹는다.
// 마이크로태스크로 미루는 이유: ref는 DOM에 붙기 전에 불린다.
queueMicrotask(() => el.focus());
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={trapTab}
>
{/* 프레임 ① 헤더 — 제목 · 진행 한 줄 · 취소 · 닫기 */}
<div class="modal-head">
<span class="modal-head__title">{ko.dedupe.title}</span>
<Show when={dedupeRunning()}>
<span
class="min-w-0 truncate text-[11px] tabular-nums text-[var(--accent-text)]"
title={statusText()}
>
{statusText()}
</span>
</Show>
<div class="ml-auto flex flex-none items-center gap-2">
<Show when={dedupeRunning()}>
<button
class="toolbar-btn"
disabled={dedupeCancelPending()}
onClick={requestCancelDedupe}
>
{ko.common.cancel}
</button>
</Show>
<button class="icon-btn" onClick={closeDedupe} title={ko.common.close}>
<IconX size={14} />
</button>
</div>
{/* 헤더 하단 border 위에 겹치는 진행 하드라인 */}
<Show when={dedupeRunning()}>
<div class="meter modal-head__progress" classList={{ "is-indet": pct() === undefined }}>
<div
class="meter__fill"
style={pct() !== undefined ? { width: `${pct()!}%` } : undefined}
/>
</div>
</Show>
</div>
{/* 프레임 ② 컨트롤 줄 — 960px에서도 한 줄 */}
<div class="modal-bar">
<Show
when={runningJob()}
fallback={
<>
<span class="flex-none text-[var(--text-muted)]">{ko.dedupe.scopeLabel}</span>
{/* 범위를 좁힐 수 있을 때만 선택 가능, 아니면 전체 라이브러리 고정 */}
<Show
when={currentScope()}
fallback={<span class="flex-none">{ko.dedupe.scopeAll}</span>}
>
{(sc) => (
<div class="segmented">
<button
classList={{ "is-on": useCurrentScope() }}
onClick={() => setUseCurrentScope(true)}
>
{sc().label}
</button>
<button
classList={{ "is-on": !useCurrentScope() }}
onClick={() => setUseCurrentScope(false)}
>
{ko.dedupe.scopeAll}
</button>
</div>
)}
</Show>
</>
}
>
{/*
'현재 폴더' */}
{(j) => <span class="chip chip--accent flex-none">{ko.dedupe.scopeRunning(j().scopeLabel)}</span>}
</Show>
<label
class="flex flex-none items-center gap-1.5"
classList={{ "opacity-50": dedupeRunning() }}
>
<span>{ko.dedupe.threshold}</span>
<input
type="range"
min={0}
max={12}
value={threshold()}
class="accent-[var(--accent)]"
disabled={dedupeRunning()}
class="w-[88px] accent-[var(--accent)]"
onInput={(e) => setThreshold(Number(e.currentTarget.value))}
/>
<span class="w-4 tabular-nums">{threshold()}</span>
<span class="w-4 text-right tabular-nums">{threshold()}</span>
</label>
<label class="flex items-center gap-1.5">
<label
class="flex flex-none items-center gap-1.5"
classList={{ "opacity-50": dedupeRunning() }}
>
<input
type="checkbox"
checked={includeVideo()}
disabled={dedupeRunning()}
class="accent-[var(--accent)]"
onChange={(e) => setIncludeVideo(e.currentTarget.checked)}
/>
{ko.dedupe.includeVideo}
<span>{ko.dedupe.includeVideo}</span>
</label>
{/* 범위: 현재 뷰가 폴더/소스일 때만 선택 가능, 아니면 전체 라이브러리 고정 */}
<Show
when={currentScope()}
fallback={<span class="text-[var(--text-muted)]">{ko.dedupe.scopeAll}</span>}
>
<div class="flex items-center gap-1.5">
<span>{ko.dedupe.scopeLabel}</span>
<div class="flex overflow-hidden rounded border border-[var(--border)]">
<button
class="px-2 py-0.5"
classList={{
"bg-[var(--bg-active)] text-[var(--text-primary)]": useCurrentScope(),
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": !useCurrentScope(),
}}
onClick={() => setUseCurrentScope(true)}
>
{currentScope()!.label}
</button>
<button
class="border-l border-[var(--border)] px-2 py-0.5"
classList={{
"bg-[var(--bg-active)] text-[var(--text-primary)]": !useCurrentScope(),
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": useCurrentScope(),
}}
onClick={() => setUseCurrentScope(false)}
>
{ko.dedupe.scopeAll}
</button>
</div>
{/* ** ** .
( , ). */}
<label class="flex flex-none items-center gap-1.5" title={ko.dedupe.speedHint}>
<span>{ko.dedupe.speed}</span>
<div class="segmented">
<button
classList={{ "is-on": speed() === "slow" }}
onClick={() => void changeSpeed("slow")}
>
{ko.dedupe.speedSlow}
</button>
<button
classList={{ "is-on": speed() === "normal" }}
onClick={() => void changeSpeed("normal")}
>
{ko.dedupe.speedNormal}
</button>
<button
classList={{ "is-on": speed() === "fast" }}
onClick={() => void changeSpeed("fast")}
>
{ko.dedupe.speedFast}
</button>
</div>
</Show>
</div>
<div class="ml-auto flex items-center gap-2">
<Show when={dedupeRunning()}>
<span class="text-[11px] text-[var(--accent)]">
{dedupeProgress()
? ko.dedupe.scanning(dedupeProgress()!.phase, dedupeProgress()!.current, dedupeProgress()!.total)
: ko.dedupe.starting}
</span>
<button
class="rounded border border-[var(--border)] px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
onClick={requestCancelDedupe}
>
{ko.common.cancel}
</button>
</Show>
<Show when={!dedupeRunning()}>
<button
class="rounded bg-[var(--accent)] px-3 py-1 text-xs text-white"
onClick={() => void runScan()}
>
{ko.dedupe.scan}
</button>
</Show>
<button
class="rounded px-2 py-1 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
onClick={closeDedupe}
title={ko.common.close}
</label>
<label
class="flex flex-none items-center gap-1.5"
classList={{ "opacity-50": dedupeRunning() }}
title={ko.dedupe.includeRotatedHint}
>
<input
type="checkbox"
checked={includeRotated()}
disabled={dedupeRunning()}
class="accent-[var(--accent)]"
onChange={(e) => setIncludeRotated(e.currentTarget.checked)}
/>
<span>{ko.dedupe.includeRotated}</span>
</label>
{/* 진행 중 재실행 방지 — store도 막지만 버튼에서 먼저 막아 눌리지 않게 한다 */}
<button
class="toolbar-btn toolbar-btn--primary ml-auto"
disabled={dedupeRunning()}
onClick={() => void runScan()}
>
{resultJob() ? ko.dedupe.rescan : ko.dedupe.scan}
</button>
</div>
</div>
{/* 그룹 목록 */}
<div class="min-h-0 flex-1 overflow-y-auto p-4">
<Show
when={groups() && groups()!.length > 0}
fallback={
<div class="grid h-full place-items-center text-sm text-[var(--text-muted)]">
{dedupeRunning() ? ko.dedupe.starting : ko.dedupe.empty}
</div>
}
>
<div class="mb-3 text-xs text-[var(--text-muted)]">
{ko.dedupe.groupCount(groups()!.length)}
</div>
<div class="flex flex-col gap-4">
<For each={groups()}>
{(g) => (
<div class="rounded-lg border border-[var(--border)] bg-[var(--bg-panel)] p-3">
<div class="mb-2 flex items-center gap-2">
<span class="rounded bg-[var(--bg-panel-raised)] px-2 py-0.5 text-[10px] text-[var(--text-secondary)]">
{KIND_LABEL[g.kind]}
</span>
<span class="text-[11px] text-[var(--text-muted)]">
{ko.dedupe.memberCount(g.members.length)}
</span>
<div class="ml-auto flex gap-2">
<button
class="rounded border border-[var(--border)] px-2 py-0.5 text-[11px] hover:bg-[var(--bg-hover)]"
onClick={() => void keepAll(g)}
>
{ko.dedupe.keepAll}
</button>
<button
class="rounded bg-[var(--danger)] px-2 py-0.5 text-[11px] text-white"
onClick={() => void resolveGroup(g)}
>
{ko.dedupe.keepBest}
</button>
</div>
</div>
<div class="flex flex-wrap gap-3">
<For each={g.members}>
{(m) => (
<div
class="w-40 overflow-hidden rounded border"
classList={{
"border-[var(--accent)]": m.isKeeper,
"border-[var(--border)]": !m.isKeeper,
}}
>
<div class="relative aspect-square bg-[var(--grid-cell-bg)]">
<Show when={info()}>
<img
src={thumbUrl(info()!, m.fileId)}
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</Show>
<Show when={m.isKeeper}>
<span class="absolute left-1 top-1 rounded bg-[var(--accent)] px-1.5 py-0.5 text-[10px] text-white">
{ko.dedupe.keeper}
</span>
</Show>
</div>
<div class="p-1.5">
<div class="truncate text-[11px]" title={`${m.dir}\\${m.name}`}>
{m.name}
</div>
<div class="text-[10px] text-[var(--text-muted)]">
{m.width && m.height ? `${m.width}×${m.height} · ` : ""}
{fmtSize(m.size)}
{m.distance != null ? ` · 거리 ${m.distance}` : ""}
</div>
</div>
</div>
)}
</For>
</div>
</div>
)}
</For>
{/* .
0 .
. */}
<Show when={pending().sigs > 0}>
<div class="modal-bar text-[var(--text-muted)]">
<IconWarning size={12} />
<span class="min-w-0 truncate">{ko.dedupe.sigPending(pending().sigs)}</span>
</div>
</Show>
{/* 프레임 ③ 본문 우물 — 여기만 스크롤한다 */}
<div class="modal-body">
<Show
when={(groups()?.length ?? 0) > 0}
fallback={
// min-h가 없으면 결과 0에서 카드가 헤더+바 높이로 쪼그라들어
// 결과가 있을 때와 창 크기가 튄다
<div class="grid h-full min-h-[160px] place-items-center px-6 text-center text-[12px] leading-5 text-[var(--text-muted)]">
{dedupeRunning() ? ko.dedupe.starting : ko.dedupe.empty}
</div>
}
>
<div class="mb-2.5 flex items-center gap-1.5 text-[10px] leading-[14px] text-[var(--text-muted)]">
<span class="flex-none tabular-nums">{ko.dedupe.groupCount(groups()!.length)}</span>
<span class="flex-none tabular-nums">
· {ko.dedupe.tierSummary(tierCounts().certain, tierCounts().suspect)}
</span>
<Show when={resultJob()}>
{(j) => <span class="min-w-0 truncate">· {ko.dedupe.scopeResult(j().scopeLabel)}</span>}
</Show>
</div>
<div class="flex flex-col gap-2.5">
<For each={visible()}>
{(g) => (
<div class="dupe-group">
<div class="dupe-group__head">
{/* 등급이 먼저 온다 — 사용자가 카드에서 가장 먼저 봐야 하는 값이다 */}
<span
class="chip chip--xs flex-none"
classList={{
"chip--accent": tierOf(g) === "certain",
"chip--danger": tierOf(g) === "suspect",
}}
title={
tierOf(g) === "certain"
? ko.dedupe.tierCertainWhy
: ko.dedupe.tierSuspectWhy
}
>
{tierOf(g) === "certain" ? ko.dedupe.tierCertain : ko.dedupe.tierSuspect}
</span>
<span class="chip chip--xs flex-none">{ko.dedupe.kindLabel(g.kind)}</span>
<span class="flex-none text-[10px] tabular-nums text-[var(--text-muted)]">
{ko.dedupe.memberCount(g.members.length)}
</span>
<div class="ml-auto flex flex-none gap-2">
{/* .
. */}
<button
class="toolbar-btn"
title={ko.dedupe.compareHint}
onClick={() => {
const other = g.members.find((m) => !m.isKeeper);
if (other) openCompare(g, other.fileId);
}}
>
{ko.dedupe.compare}
</button>
<button
class="toolbar-btn"
disabled={busy()}
onClick={() => void keepAll(g)}
>
{ko.dedupe.keepAll}
</button>
{/* ( ) .
'되돌릴 수 없음' . */}
<button
class="toolbar-btn"
disabled={busy()}
title={ko.dedupe.keepBestHint}
onClick={() => void resolveGroup(g)}
>
{ko.dedupe.keepBest}
</button>
</div>
</div>
<div class="dupe-grid">
<For each={g.members}>
{(m) => (
<div
class="dupe-tile"
classList={{ "is-keeper": m.isKeeper }}
// 타일을 누르면 그 사본을 보관 항목과 비교한다 (보관 항목 자신은 첫 사본과)
onClick={() => {
const other = m.isKeeper
? g.members.find((x) => !x.isKeeper)
: m;
if (other) openCompare(g, other.fileId);
}}
title={ko.dedupe.compareHint}
>
<div class="dupe-tile__thumb">
<Show when={info()}>
{(i) => (
<img
src={thumbUrl(i(), m.fileId)}
loading="lazy"
decoding="async"
alt=""
/>
)}
</Show>
{/* **** .
'표시' '선택' .
() . */}
<button
class="dupe-tile__badge"
classList={{ "is-off": !m.isKeeper }}
// 보이는 상태(칠해짐/흐림)를 보조기술에도 그대로 전한다 —
// 안 그러면 모든 타일이 똑같이 "보관"이라고만 읽힌다
aria-pressed={m.isKeeper}
disabled={busy()}
title={
m.isKeeper ? ko.dedupe.keeperCurrent : ko.dedupe.keeperPick
}
onClick={(e) => {
e.stopPropagation();
void chooseKeeper(g, m);
}}
>
<IconCheck size={11} /> {ko.dedupe.keeper}
</button>
</div>
<div class="dupe-tile__body">
<div class="dupe-tile__name" title={`${m.dir}\\${m.name}`}>
{m.name}
</div>
<div class="dupe-tile__meta">{metaLine(m)}</div>
</div>
</div>
)}
</For>
</div>
</div>
)}
</For>
</div>
<Show when={restCount() > 0}>
<button
class="toolbar-btn mt-2.5 w-full"
onClick={() => setShown((n) => n + PAGE)}
>
{ko.dedupe.moreGroups(restCount())}
</button>
</Show>
</Show>
</div>
</div>
</div>
</Show>
+62
View File
@@ -0,0 +1,62 @@
// 중복 그룹 등급 — '확실'과 '의심' 둘로만 나눈다.
//
// 등급은 저장하지 않고 **근거에서 파생**한다(거리·정렬 오프셋·일치 구간). 규칙이 바뀔 때
// 마이그레이션이 필요 없고, 저장된 값이 낡아 근거와 어긋나는 일도 없다.
//
// 왜 두 단계인가: 사용자가 할 결정은 "그냥 정리해도 되나 / 눈으로 봐야 하나" 둘뿐이다.
// 세 단계 이상은 경계에서 무슨 뜻인지 아무도 모른다.
import type { DupeGroup, DupeMember } from "../../ipc/commands";
export type Tier = "certain" | "suspect";
/** 이미지: 평균 거리가 이 점수 이상이면 확실 (score = 1 평균거리/64) */
const IMAGE_CERTAIN_SCORE = 0.95;
/** 영상: 짧은 쪽을 이 비율 이상 덮었으면 확실 */
const VIDEO_CERTAIN_COVERAGE = 0.6;
/** 일치 구간이 짧은 쪽 재생시간의 몇 %를 덮는가. 근거가 없으면 undefined. */
export function coverageOf(keeper: DupeMember, m: DupeMember): number | undefined {
if (m.segStartMs == null || m.segEndMs == null) return undefined;
const shorter = Math.min(keeper.durationMs ?? 0, m.durationMs ?? 0);
if (shorter <= 0) return undefined;
return (m.segEndMs - m.segStartMs) / shorter;
}
/**
* .
* - (kind 0): .
* - 이미지: 평균 .
* (=score) .
* - 영상: 프레임 .
* ** (offsetMs) (/)** .
*/
export function tierOf(g: DupeGroup): Tier {
if (g.kind === 0) return "certain";
const keeper = g.members.find((m) => m.isKeeper) ?? g.members[0];
const others = g.members.filter((m) => m.fileId !== keeper.fileId);
if (others.length === 0) return "suspect";
if (g.kind === 1) {
// score가 없으면(v5 이전에 만들어진 그룹) 거리 평균으로 직접 계산한다
const s =
g.score ??
(() => {
const ds = others.map((m) => m.distance).filter((d): d is number => d != null);
if (ds.length !== others.length) return 0;
return 1 - ds.reduce((a, b) => a + b, 0) / ds.length / 64;
})();
return s >= IMAGE_CERTAIN_SCORE ? "certain" : "suspect";
}
// 영상: 전이 병합으로 들어와 정렬을 확인하지 못한 멤버가 하나라도 있으면 의심
const allAligned = others.every((m) => m.offsetMs != null);
const covered = others.every((m) => (coverageOf(keeper, m) ?? 0) >= VIDEO_CERTAIN_COVERAGE);
return allAligned && covered ? "certain" : "suspect";
}
/** 확실을 먼저, 그 안에서 종류 → 멤버 많은 순. 확실한 것부터 몰아서 처리할 수 있게. */
export function byTier(a: DupeGroup, b: DupeGroup): number {
const ta = tierOf(a) === "certain" ? 0 : 1;
const tb = tierOf(b) === "certain" ? 0 : 1;
return ta - tb || a.kind - b.kind || b.members.length - a.members.length;
}
+21
View File
@@ -0,0 +1,21 @@
// 중복 검토 화면의 수치 서식. UI 문장이 아니라 숫자 표기이므로 ko를 거치지 않는다.
//
// 목록 타일과 비교 뷰가 같은 값을 다르게 적으면(한쪽 "1:05", 다른 쪽 "65초") 같은 파일을
// 두 화면에서 볼 때 사용자가 다른 파일로 읽는다 — 그래서 한 곳에 둔다.
/** 재생시간 m:ss (한 시간 넘으면 h:mm:ss). 영상 사본은 길이가 곧 '잘렸는지'의 단서다. */
export function fmtDuration(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = String(s % 60).padStart(2, "0");
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
}
/** 크기 표기 — 타일 부제가 좁아 단위 사이를 띄우지 않는다. */
export 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`;
}
+164 -5
View File
@@ -4,19 +4,37 @@ import { ask } from "@tauri-apps/plugin-dialog";
import { ko } from "../../i18n/ko";
import {
assignTags,
editFiles,
emptyDeleted,
listTags,
pendingDeleteStats,
renameFile,
trashFiles,
undoLast,
type EditOp,
} from "../../ipc/commands";
import {
bumpTagVersion,
clearSelection,
markedIds,
refreshMarked,
refreshSidebar,
refreshSnapshot,
selected,
snapshot,
toast,
toggleMarkDelete,
trashTag,
} from "../../state/store";
import { openMoveDialog } from "./MoveDialog";
import { MAX_PANES, openMultiPlay } from "../viewer/MultiPlayView";
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`;
}
interface MenuState {
x: number;
@@ -32,15 +50,17 @@ export const closeContextMenu = () => setMenu(undefined);
const [renameTarget, setRenameTarget] = createSignal<{ id: number; name: string } | undefined>();
export const openRenameDialog = (id: number, name: string) => setRenameTarget({ id, name });
export async function performTrash(): Promise<void> {
/** 즉시 OS 휴지통으로 (Shift+Delete). ids 미지정 시 현재 선택 항목. */
export async function performTrash(explicitIds?: number[]): Promise<void> {
try {
const ids = [...selected()];
const ids = explicitIds ?? [...selected()];
if (ids.length === 0) return;
const yes = await ask(ko.ops.trashConfirm(ids.length), { title: "Archive", kind: "warning" });
if (!yes) return;
const summary = await trashFiles(ids, (p) => toast(ko.ops.inProgress(p.done, p.total)));
toast(ko.ops.trashDone(summary.done));
clearSelection();
await refreshMarked();
refreshSidebar();
await refreshSnapshot();
} catch (e) {
@@ -49,6 +69,107 @@ export async function performTrash(): Promise<void> {
}
}
/** 선택 항목 삭제 표시 토글 (Delete) — 파일은 그대로, Ctrl+Z로 되돌릴 수 있다. */
export async function performMarkDelete(): Promise<void> {
const ids = [...selected()];
if (ids.length === 0) return;
await toggleMarkDelete(ids);
}
/**
* .
* · .
*/
export async function performEmptyDeleted(): Promise<void> {
try {
const stats = await pendingDeleteStats();
if (stats.count === 0) {
toast(ko.trash.emptyNothing);
return;
}
const yes = await ask(ko.trash.emptyConfirm(stats.count, fmtSize(stats.bytes)), {
title: ko.trash.emptyTitle,
kind: "warning",
});
if (!yes) return;
const summary = await emptyDeleted((p) => toast(ko.ops.inProgress(p.done, p.total)));
toast(
summary.skipped > 0 || summary.failed > 0
? ko.trash.emptyPartial(summary.done, summary.skipped, summary.failed)
: ko.trash.emptyDone(summary.done),
);
clearSelection();
await refreshMarked();
refreshSidebar();
await refreshSnapshot();
} catch (e) {
toast(String(e));
console.error("performEmptyDeleted", e);
}
}
/**
* · ** **. ids .
*
* 이유: 회전은 Ctrl+Z로 .
* .
*/
export async function performEdit(op: EditOp, explicitIds?: number[]): Promise<void> {
const ids = explicitIds ?? [...selected()];
if (ids.length === 0) {
toast(ko.edit.selectFirst);
return;
}
try {
const s = await editFiles(ids, op, (p) => {
// 한 장짜리는 즉시 끝나므로 알리지 않는다 — 토스트만 깜빡인다
if (p.total > 3) toast(ko.edit.working(p.done, p.total));
});
if (s.failed > 0) toast(ko.edit.doneWithFail(s.done, s.failed));
else if (s.reencoded > 0) toast(ko.edit.doneReencoded(s.done, s.reencoded));
else toast(ko.edit.done(s.done));
for (const e of s.errors.slice(0, 3)) toast(e);
await refreshSnapshot();
} catch (e) {
toast(String(e));
}
}
/**
* .
*
* '재생'
* .
*/
export function performMultiPlay(): void {
const snap = snapshot();
if (!snap) return;
const sel = selected();
const videos: number[] = [];
for (let i = 0; i < snap.count; i++) {
const id = snap.id(i);
if (sel.has(id) && snap.kind(i) === 1) videos.push(id);
}
if (videos.length < 2) {
toast(ko.multi.needTwo);
return;
}
if (videos.length > MAX_PANES) toast(ko.multi.tooMany(MAX_PANES));
openMultiPlay(videos);
}
/** 우클릭 메뉴에 '동시 재생'을 보일지 — 고른 것 중 영상이 둘 이상일 때만 */
export function selectedVideoCount(): number {
const snap = snapshot();
if (!snap) return 0;
const sel = selected();
let n = 0;
for (let i = 0; i < snap.count; i++) {
if (sel.has(snap.id(i)) && snap.kind(i) === 1) n++;
}
return n;
}
export async function performUndo(): Promise<void> {
const summary = await undoLast();
if (!summary) {
@@ -57,6 +178,8 @@ export async function performUndo(): Promise<void> {
}
toast(ko.ops.undone(summary.done));
refreshSidebar();
// 삭제 표시(태그)도 undo 대상 — 배지가 남지 않도록 함께 갱신
await refreshMarked();
await refreshSnapshot();
}
@@ -77,11 +200,18 @@ export const ContextMenu: Component = () => {
() => (menu() ? 1 : undefined),
() => listTags(),
);
// 시스템 '삭제 예정' 태그는 아래 전용 메뉴로 처리 — 태그 목록에서는 감춘다
const userTags = () => (tags() ?? []).filter((t) => t.id !== trashTag());
const allSelectedMarked = () => {
const ids = [...selected()];
return ids.length > 0 && ids.every((id) => markedIds().has(id));
};
const onAssign = async (tagId: number, tagName: string) => {
const ids = [...selected()];
const r = await assignTags(ids, tagId);
toast(ko.tags.assignTo(r.count, tagName));
bumpTagVersion();
refreshSidebar();
};
@@ -95,16 +225,38 @@ export const ContextMenu: Component = () => {
class="fixed z-50 w-52 rounded-md border border-[var(--border-strong)] bg-[var(--bg-panel-raised)] p-1 shadow-xl"
style={{ left: `${Math.min(m().x, window.innerWidth - 220)}px`, top: `${Math.min(m().y, window.innerHeight - 260)}px` }}
>
<Show when={tags() && tags()!.length > 0}>
<Show when={userTags().length > 0}>
<div class="px-3 pt-1 text-[10px] text-[var(--text-muted)]"></div>
<div class="max-h-36 overflow-y-auto">
<For each={tags()}>
<For each={userTags()}>
{(t) => <MenuItem label={`# ${t.name}`} onClick={() => void onAssign(t.id, t.name)} />}
</For>
</div>
<div class="my-1 border-t border-[var(--border)]" />
</Show>
<MenuItem label={ko.ops.moveTo} onClick={openMoveDialog} />
<Show when={selectedVideoCount() >= 2}>
<MenuItem
label={ko.multi.open(selectedVideoCount())}
onClick={performMultiPlay}
/>
</Show>
<div class="my-1 border-t border-[var(--border)]" />
<div class="px-3 pt-1 text-[10px] text-[var(--text-muted)]">
{ko.edit.menu} · {ko.edit.hint}
</div>
<MenuItem
label={`${ko.edit.rotateCw} (Ctrl+])`}
onClick={() => void performEdit("rotateCw")}
/>
<MenuItem
label={`${ko.edit.rotateCcw} (Ctrl+[)`}
onClick={() => void performEdit("rotateCcw")}
/>
<MenuItem label={ko.edit.rotate180} onClick={() => void performEdit("rotate180")} />
<MenuItem label={ko.edit.flipH} onClick={() => void performEdit("flipH")} />
<MenuItem label={ko.edit.flipV} onClick={() => void performEdit("flipV")} />
<div class="my-1 border-t border-[var(--border)]" />
<Show when={selected().size === 1 && m().fileName}>
<MenuItem
label={ko.common.rename}
@@ -112,7 +264,14 @@ export const ContextMenu: Component = () => {
/>
</Show>
<div class="my-1 border-t border-[var(--border)]" />
<MenuItem label={`${ko.common.delete} (휴지통)`} onClick={() => void performTrash()} />
<MenuItem
label={allSelectedMarked() ? ko.trash.unmark : `${ko.trash.mark} (Delete)`}
onClick={() => void performMarkDelete()}
/>
<MenuItem
label={`${ko.common.delete} (휴지통, Shift+Delete)`}
onClick={() => void performTrash()}
/>
</div>
</>
)}
+24 -2
View File
@@ -1,5 +1,14 @@
// 폴더 선택 모달 — 선택 항목을 인덱싱된 폴더로 이동한다.
import { createResource, createSignal, For, Show, type Component } from "solid-js";
import { IconChevronDown, IconChevronRight } from "../../ui/icons";
import {
createResource,
createSignal,
For,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import { ko } from "../../i18n/ko";
import {
folderTree,
@@ -16,6 +25,7 @@ import {
} from "../../state/store";
const [open, setOpen] = createSignal(false);
export { open as moveDialogOpen };
export const openMoveDialog = () => setOpen(true);
interface TreeNode extends FolderNode {
@@ -69,7 +79,7 @@ const FolderPickRow: Component<{
setExpanded(!expanded());
}}
>
<Show when={props.node.children.length > 0}>{expanded() ? "▾" : "▸"}</Show>
<Show when={props.node.children.length > 0}>{expanded() ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}</Show>
</span>
<span class="truncate">{props.node.name}</span>
</div>
@@ -83,6 +93,18 @@ const FolderPickRow: Component<{
};
export const MoveDialog: Component = () => {
// Esc로 닫기 — 라이트박스 위에 떠 있을 때도 이 모달이 먼저 닫혀야 한다
onMount(() => {
const onKey = (e: KeyboardEvent) => {
if (!open() || e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
setOpen(false);
};
window.addEventListener("keydown", onKey, { capture: true });
onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true }));
});
const [trees] = createResource(
() => open(),
async (isOpen) => {
+135 -98
View File
@@ -1,4 +1,5 @@
// 커스텀 justified 가상 그리드 — 레이아웃은 워커에서, 렌더는 뷰포트 ±1화면만.
import { IconImage, IconTrash } from "../../ui/icons";
import {
createEffect,
createMemo,
@@ -10,15 +11,15 @@ import {
Show,
type Component,
} from "solid-js";
import { createStore, produce } from "solid-js/store";
import { createStore, produce, reconcile } from "solid-js/store";
import { computeLayout, type LayoutResult } from "./layout";
import {
anchorIndex,
clearSelection,
gridRowHeight,
info,
markedIds,
openLightbox,
openShortcuts,
registerItemViewFocus,
selected,
selectSingle,
setSelectionFromIds,
@@ -26,20 +27,29 @@ import {
toggleSelect,
selectRange,
snapshot,
tagVersion,
thumbVersions,
trashTag,
zoomGrid,
} from "../../state/store";
import { fileDetails, setThumbPriority, thumbUrl, type Snapshot } from "../../ipc/commands";
import { ko } from "../../i18n/ko";
import {
openContextMenu,
openRenameDialog,
performTrash,
performUndo,
} from "../fileops/ContextMenu";
fileDetails,
fileTagsBulk,
setThumbPriority,
thumbUrl,
type Snapshot,
} from "../../ipc/commands";
import { ko } from "../../i18n/ko";
import { openContextMenu } from "../fileops/ContextMenu";
import { handleItemKeys } from "../browse/itemKeys";
const GAP = 6;
const GAP = 8;
/** . ·
. */
const PAD = 8;
const OVERSCAN_PX = 800;
/** 이 폭 미만 셀에서는 태그 라벨을 감춘다 (썸네일을 가림) */
const TAG_MIN_CELL_W = 120;
function fmtSize(bytes: number): string {
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)}MB`;
@@ -74,7 +84,7 @@ export const JustifiedGrid: Component = () => {
setLayout(undefined);
return;
}
const w = containerRef.clientWidth - 16; // 패딩 보정
const w = containerRef.clientWidth - PAD * 2; // 패딩 보정
if (w <= 0) return;
const ratios = snap.aspectRatios();
setLayout(computeLayout(ratios, w, gridRowHeight(), GAP));
@@ -85,6 +95,8 @@ export const JustifiedGrid: Component = () => {
onMount(() => {
const el = containerRef!;
// 크롬(툴바·브레드크럼·폴더 칩·사이드바)이 클릭 뒤 방향키를 여기로 되돌린다
onCleanup(registerItemViewFocus(() => containerRef?.focus({ preventScroll: true })));
let lastW = -1;
let lastRh = -1;
// 폭과 줌(gridRowHeight)을 함께 감시 — 어느 쪽이 바뀌든 재배치.
@@ -165,6 +177,43 @@ export const JustifiedGrid: Component = () => {
});
onCleanup(() => clearTimeout(prioTimer));
// 썸네일 위 태그 라벨 — 가시 범위만 배치 조회. 태그가 바뀌면(tagVersion) 캐시를 버린다.
const [tagCache, setTagCache] = createStore<Record<number, string[]>>({});
let tagTimer: ReturnType<typeof setTimeout> | undefined;
createEffect(
on(tagVersion, () => setTagCache(reconcile({})), { defer: true }),
);
createEffect(() => {
const [first, last] = visibleRange();
const snap = snapshot();
tagVersion(); // 태그 변경 후에도 다시 읽도록 구독
if (!snap || last < first) return;
clearTimeout(tagTimer);
tagTimer = setTimeout(() => {
const need: number[] = [];
for (let i = first; i <= last && need.length < 512; i++) {
const id = snap.id(i);
if (tagCache[id] === undefined) need.push(id);
}
if (need.length === 0) return;
void fileTagsBulk(need)
.then((links) =>
setTagCache(
produce((s) => {
// 조회한 id는 태그가 없어도 빈 배열로 채워 재조회를 막는다
for (const id of need) s[id] = [];
for (const l of links) {
if (l.tagId === trashTag()) continue; // 삭제 예정은 🗑 배지가 담당
(s[l.fileId] ??= []).push(l.name);
}
}),
),
)
.catch(() => {});
}, 150);
});
onCleanup(() => clearTimeout(tagTimer));
// 정보 오버레이용 파일명/크기 지연 로드 (showInfo 켜졌을 때 가시 범위만)
const [detailsCache, setDetailsCache] = createStore<Record<number, { name: string; size: number }>>({});
let detTimer: ReturnType<typeof setTimeout> | undefined;
@@ -234,80 +283,29 @@ export const JustifiedGrid: Component = () => {
return best;
};
/** 선택된 셀을 화면 안으로 (키보드 이동용) */
const revealCell = (index: number) => {
const l = layout();
const el = containerRef;
if (!l || !el) return;
const top = l.boxes[index * 4 + 1];
const h = l.boxes[index * 4 + 3];
if (top < el.scrollTop) el.scrollTop = top;
else if (top + h > el.scrollTop + el.clientHeight)
el.scrollTop = top + h - el.clientHeight;
};
const onKeyDown = (e: KeyboardEvent) => {
const snap = snapshot();
if (!snap || snap.count === 0) return;
const anchor = anchorIndex();
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
void performUndo();
return;
}
// Ctrl+A 전체 선택 / Ctrl+Shift+A 선택 반전
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
e.preventDefault();
const all: number[] = [];
for (let i = 0; i < snap.count; i++) all.push(snap.id(i));
if (e.shiftKey) {
const cur = selected();
setSelectionFromIds(all.filter((id) => !cur.has(id)));
} else {
setSelectionFromIds(all);
}
return;
}
// ? 단축키 도움말
if (e.key === "?") {
e.preventDefault();
openShortcuts();
return;
}
switch (e.key) {
case "Enter":
case " ":
if (anchor >= 0) {
e.preventDefault();
openLightbox(anchor);
}
break;
case "Delete":
if (selected().size > 0) {
e.preventDefault();
void performTrash();
}
break;
case "F2":
if (anchor >= 0 && selected().size === 1) {
e.preventDefault();
const id = snap.id(anchor);
void fileDetails([id]).then((d) => {
if (d[0]) openRenameDialog(id, d[0].name);
});
}
break;
case "ArrowLeft":
if (anchor > 0) {
e.preventDefault();
selectSingle(snap.id(anchor - 1), anchor - 1);
}
break;
case "ArrowRight":
if (anchor >= 0 && anchor < snap.count - 1) {
e.preventDefault();
selectSingle(snap.id(anchor + 1), anchor + 1);
}
break;
case "ArrowUp":
case "ArrowDown": {
if (anchor < 0) break;
e.preventDefault();
const next = verticalNeighbor(anchor, e.key === "ArrowDown" ? 1 : -1);
if (next !== undefined) selectSingle(snap.id(next), next);
break;
}
}
if (!snap) return;
handleItemKeys(e, snap, {
neighbor: (from, key) => {
if (key === "ArrowLeft") return from > 0 ? from - 1 : undefined;
if (key === "ArrowRight") return from < snap.count - 1 ? from + 1 : undefined;
return verticalNeighbor(from, key === "ArrowDown" ? 1 : -1);
},
onMove: revealCell,
});
};
const onCellContextMenu = (e: MouseEvent, snap: Snapshot, index: number) => {
@@ -327,12 +325,12 @@ export const JustifiedGrid: Component = () => {
};
// ── 마퀴(드래그 사각형) 선택 ──
// 콘텐츠 좌표 = clientX - 컨테이너rect - 패딩(8) (+ y는 scrollTop)
// 콘텐츠 좌표 = clientX - 컨테이너rect - 패딩(PAD) (+ y는 scrollTop)
const toContent = (clientX: number, clientY: number): [number, number] => {
const rect = containerRef!.getBoundingClientRect();
return [
clientX - rect.left - 8,
clientY - rect.top - 8 + containerRef!.scrollTop,
clientX - rect.left - PAD,
clientY - rect.top - PAD + containerRef!.scrollTop,
];
};
@@ -397,7 +395,8 @@ export const JustifiedGrid: Component = () => {
onKeyDown={onKeyDown}
onMouseDown={onBgMouseDown}
tabindex={0}
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 outline-none"
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden outline-none"
style={{ padding: `${PAD}px` }}
>
<Show
when={snapshot() && snapshot()!.count > 0}
@@ -415,7 +414,7 @@ export const JustifiedGrid: Component = () => {
<Show when={marqueeRect()}>
{(r) => (
<div
class="pointer-events-none absolute z-10 border border-[var(--accent)] bg-[var(--accent-soft)]"
class="marquee pointer-events-none absolute z-10"
style={{
left: `${r().left}px`,
top: `${r().top}px`,
@@ -453,11 +452,23 @@ export const JustifiedGrid: Component = () => {
const s = snapshot();
return !!s && s.kind(i) === 1;
};
const marked = () => markedIds().has(id());
// 별점은 스냅샷 flags 비트2~4에서 온다(추가 조회 없음)
const rating = () => {
const s = snapshot();
return s ? s.rating(i) : 0;
};
// 좁은 셀일수록 적게 — 나머지는 +N으로 접는다
const cellTags = () => tagCache[id()] ?? [];
const tagLimit = () => (box()[2] >= 260 ? 3 : box()[2] >= 180 ? 2 : 1);
const visibleTags = () => cellTags().slice(0, tagLimit());
const hiddenTagCount = () => Math.max(0, cellTags().length - tagLimit());
return (
<div
class="absolute cursor-pointer overflow-hidden rounded-[3px] bg-[var(--grid-cell-bg)] outline-offset-[-2px]"
class="grid-cell"
classList={{
"outline outline-2 outline-[var(--accent)]": selected().has(id()),
"is-sel": selected().has(id()),
"is-marked": marked() && !selected().has(id()),
}}
style={{
transform: `translate(${box()[0]}px, ${box()[1]}px)`,
@@ -487,11 +498,11 @@ export const JustifiedGrid: Component = () => {
when={!failed()}
fallback={
<div class="grid h-full w-full place-items-center text-[var(--text-muted)]">
<span class="text-base opacity-60">🖼</span>
<IconImage size={18} class="opacity-60" />
</div>
}
>
<div class="h-full w-full animate-pulse bg-[var(--bg-hover)]" />
<div class="h-full w-full animate-pulse bg-[var(--grid-skeleton)]" />
</Show>
}
>
@@ -509,16 +520,42 @@ export const JustifiedGrid: Component = () => {
/>
</Show>
<Show when={isVideo()}>
<span class="absolute bottom-1 right-1 rounded bg-black/60 px-1 text-[10px] text-white">
<span class="chip--on-media absolute bottom-1 right-1">{ko.filter.video}</span>
</Show>
{/* . 0 5
( ). */}
<Show when={rating() > 0}>
<span class="cell-stars" title={ko.rating.set(rating())}>
{"★".repeat(rating())}
</span>
</Show>
{/* + .
. */}
<div class="pointer-events-none absolute inset-x-1 top-1 flex flex-wrap gap-0.5">
<Show when={marked()}>
<span class="chip--on-media chip--on-media--danger" title={ko.trash.badge}>
<IconTrash size={11} />
</span>
</Show>
<Show when={box()[2] >= TAG_MIN_CELL_W}>
<For each={visibleTags()}>
{(name) => (
<span class="chip--on-media chip--on-media--tag" title={name}>
{name}
</span>
)}
</For>
<Show when={hiddenTagCount() > 0}>
<span class="chip--on-media chip--on-media--dim">+{hiddenTagCount()}</span>
</Show>
</Show>
</div>
{/* 파일명·크기 오버레이 (툴바 ⓘ 토글) */}
<Show when={showInfo()}>
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-black/55 px-1 py-0.5 leading-tight">
<div class="truncate text-[10px] text-white/90">{detailsCache[id()]?.name ?? ""}</div>
<div class="info-overlay">
<div class="info-overlay__name">{detailsCache[id()]?.name ?? ""}</div>
<Show when={detailsCache[id()]}>
<div class="text-[9px] text-white/60">{fmtSize(detailsCache[id()]!.size)}</div>
<div class="info-overlay__sub">{fmtSize(detailsCache[id()]!.size)}</div>
</Show>
</div>
</Show>
+5 -2
View File
@@ -1,7 +1,10 @@
// Justified 레이아웃 — 그리디 행 채우기. 메인 스레드 동기 계산.
// (프로덕션 Tauri 빌드에서 module worker가 첫 메시지 후 응답하지 않는 이슈를 피하기 위해
// 워커 대신 동기 계산을 쓴다. 재계산은 리사이즈/줌/데이터 변경 시에만 발생하며
// 5만 항목도 수십 ms 내에 끝나 스크롤 성능에 영향 없다.)
// 워커 대신 동기 계산을 쓴다. 재계산은 리사이즈/줌/데이터 변경 시에만 발생한다.)
//
// 워커로 옮길 이유가 없다 — V8 실측: 1만 0.8ms / 10만 3.1ms / **100만 18.4ms**
// (aspectRatios 언팩 포함 ~20ms). 스크롤 중에는 아예 돌지 않고, 워커로 보내면
// boxes 15MB를 매번 되돌려 받아야 해서 오히려 느려진다.
export interface LayoutResult {
/** n*4: [x, y, w, h] */
+138
View File
@@ -0,0 +1,138 @@
// 소스 수정 — 이름 변경 + 경로 재매핑.
// 폴더를 옮겼거나 드라이브 문자가 바뀌어 '경로 없음'이 된 소스를 되살리는 창구다.
// 경로를 바꾸면 백엔드가 폴더 경로 접두사만 갈아 끼우므로 태그·썸네일이 유지된다.
import { createEffect, createSignal, Show, type Component } from "solid-js";
import { open } from "@tauri-apps/plugin-dialog";
import { ko } from "../../i18n/ko";
import { updateSource, type Source } from "../../ipc/commands";
import { refreshSidebar, refreshSnapshot, toast } from "../../state/store";
const [target, setTarget] = createSignal<Source | undefined>();
export const openSourceEdit = (s: Source) => setTarget(s);
export const SourceEditDialog: Component = () => {
const [name, setName] = createSignal("");
const [root, setRoot] = createSignal("");
const [busy, setBusy] = createSignal(false);
// 창이 열릴 때 현재 값으로 채운다
createEffect(() => {
const s = target();
if (!s) return;
setName(s.name);
setRoot(s.root);
});
const close = () => {
if (busy()) return;
setTarget(undefined);
};
const pickFolder = async () => {
const dir = await open({ directory: true, multiple: false, title: ko.source.browse });
if (typeof dir === "string") setRoot(dir);
};
const submit = async () => {
const s = target();
if (!s) return;
const nextName = name().trim();
const nextRoot = root().trim();
const nameChanged = nextName.length > 0 && nextName !== s.name;
const rootChanged = nextRoot.length > 0 && nextRoot !== s.root;
if (!nameChanged && !rootChanged) {
close();
return;
}
setBusy(true);
try {
const moved = await updateSource(s.id, {
name: nameChanged ? nextName : undefined,
root: rootChanged ? nextRoot : undefined,
});
toast(rootChanged ? ko.source.remapDone(moved) : ko.source.renameDone);
refreshSidebar();
await refreshSnapshot();
setTarget(undefined);
} catch (e) {
toast(String(e));
} finally {
setBusy(false);
}
};
const inputClass =
"w-full rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1.5 text-xs outline-none focus:border-[var(--accent)]";
return (
<Show when={target()}>
{(s) => (
<div class="fixed inset-0 z-50 grid place-items-center bg-black/60" onClick={close}>
<div
class="w-[460px] rounded-lg border border-[var(--border-strong)] bg-[var(--bg-panel)] p-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div class="mb-3 text-sm font-semibold">{ko.source.editTitle}</div>
<label class="mb-2 block">
<div class="mb-0.5 text-[11px] text-[var(--text-muted)]">{ko.source.name}</div>
<input
class={inputClass}
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
if (e.key === "Escape") close();
}}
/>
</label>
<label class="mb-1 block">
<div class="mb-0.5 text-[11px] text-[var(--text-muted)]">{ko.source.root}</div>
<div class="flex gap-2">
<input
class={inputClass}
value={root()}
disabled={s().kind !== "local"}
onInput={(e) => setRoot(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
if (e.key === "Escape") close();
}}
/>
<button
class="shrink-0 rounded border border-[var(--border)] px-2 py-1 text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] disabled:opacity-40"
onClick={() => void pickFolder()}
disabled={s().kind !== "local"}
>
</button>
</div>
</label>
<div class="mt-2 text-[10px] leading-4 text-[var(--text-muted)]">
{s().kind === "local" ? ko.source.remapHint : ko.source.remoteNoRoot}
</div>
<div class="mt-3 flex justify-end gap-2">
<button
class="rounded border border-[var(--border)] px-3 py-1 text-xs hover:bg-[var(--bg-hover)]"
onClick={close}
disabled={busy()}
>
{ko.common.cancel}
</button>
<button
class="rounded bg-[var(--accent)] px-3 py-1 text-xs text-white disabled:opacity-50"
onClick={() => void submit()}
disabled={busy()}
>
{busy() ? ko.app.loading : ko.common.confirm}
</button>
</div>
</div>
</div>
)}
</Show>
);
};
+208
View File
@@ -0,0 +1,208 @@
// 자르기 오버레이 — 화면에서 사각형을 끌면 원본 화소 좌표로 바꿔 돌려준다.
//
// **좌표 변환이 이 파일의 전부다.** 미디어는 `object-contain`으로 그려지므로 요소 상자와
// 실제 그림 영역이 다르다(위아래나 좌우에 여백이 생긴다). 그 여백을 빼지 않으면
// 사용자가 고른 자리와 실제로 잘리는 자리가 어긋난다 — 되돌릴 수 없는 작업에서 가장 나쁜 실수다.
import { createSignal, For, Show, onMount, onCleanup, type Component } from "solid-js";
import { ko } from "../../i18n/ko";
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
/** 요소 안에서 `object-contain`으로 그려진 **그림 영역**(여백 제외)의 화면 좌표. */
export function contentRect(el: HTMLElement, natW: number, natH: number): DOMRect {
const box = el.getBoundingClientRect();
if (natW <= 0 || natH <= 0) return box;
const scale = Math.min(box.width / natW, box.height / natH);
const w = natW * scale;
const h = natH * scale;
return new DOMRect(box.left + (box.width - w) / 2, box.top + (box.height - h) / 2, w, h);
}
/** 화면 사각형 → 원본 화소 사각형. 원본 밖으로는 나가지 않는다. */
export function toSourceRect(screen: Rect, content: DOMRect, natW: number, natH: number): Rect {
const sx = natW / content.width;
const sy = natH / content.height;
const x = Math.round((screen.x - content.left) * sx);
const y = Math.round((screen.y - content.top) * sy);
const w = Math.round(screen.w * sx);
const h = Math.round(screen.h * sy);
const cx = Math.max(0, Math.min(x, natW - 2));
const cy = Math.max(0, Math.min(y, natH - 2));
return { x: cx, y: cy, w: Math.min(w, natW - cx), h: Math.min(h, natH - cy) };
}
/** 비율 고정 — 끈 사각형을 주어진 가로:세로에 맞춰 줄인다(늘리지 않는다). */
export function fitAspect(r: Rect, aspect: number | undefined): Rect {
if (!aspect || aspect <= 0) return r;
const cur = r.w / Math.max(1, r.h);
if (cur > aspect) return { ...r, w: Math.round(r.h * aspect) };
return { ...r, h: Math.round(r.w / aspect) };
}
const PRESETS: { label: string; a?: number }[] = [
{ label: "자유" },
{ label: "1:1", a: 1 },
{ label: "4:3", a: 4 / 3 },
{ label: "3:4", a: 3 / 4 },
{ label: "16:9", a: 16 / 9 },
{ label: "9:16", a: 9 / 16 },
];
export const CropOverlay: Component<{
/** 자를 대상 요소 (img 또는 video) */
target: () => HTMLElement | undefined;
/** 원본 화소 크기 */
natural: () => { w: number; h: number } | undefined;
busy: boolean;
onCancel: () => void;
onApply: (r: Rect) => void;
}> = (props) => {
const [drag, setDrag] = createSignal<Rect | undefined>();
const [aspect, setAspect] = createSignal<number | undefined>();
let start: { x: number; y: number } | undefined;
const content = () => {
const el = props.target();
const n = props.natural();
return el && n ? contentRect(el, n.w, n.h) : undefined;
};
const onDown = (e: PointerEvent) => {
const c = content();
if (!c) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
start = { x: clampX(e.clientX, c), y: clampY(e.clientY, c) };
setDrag({ x: start.x, y: start.y, w: 0, h: 0 });
};
const onMove = (e: PointerEvent) => {
const c = content();
if (!start || !c) return;
const x2 = clampX(e.clientX, c);
const y2 = clampY(e.clientY, c);
const raw = {
x: Math.min(start.x, x2),
y: Math.min(start.y, y2),
w: Math.abs(x2 - start.x),
h: Math.abs(y2 - start.y),
};
setDrag(fitAspect(raw, aspect()));
};
const onUp = () => {
start = undefined;
};
const clampX = (v: number, c: DOMRect) => Math.max(c.left, Math.min(v, c.right));
const clampY = (v: number, c: DOMRect) => Math.max(c.top, Math.min(v, c.bottom));
/** 지금 고른 영역을 원본 화소로 — 표시용이자 적용값이다(둘이 다르면 안 된다) */
const sourceRect = () => {
const d = drag();
const c = content();
const n = props.natural();
if (!d || !c || !n || d.w < 4 || d.h < 4) return undefined;
return toSourceRect(d, c, n.w, n.h);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
props.onCancel();
}
if (e.key === "Enter") {
const r = sourceRect();
if (r) {
e.preventDefault();
props.onApply(r);
}
}
};
onMount(() => window.addEventListener("keydown", onKey, true));
onCleanup(() => window.removeEventListener("keydown", onKey, true));
return (
<>
{/* 끌기 판 — 미디어 위를 덮는다 */}
<div
class="absolute inset-0 z-20 cursor-crosshair"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
>
<Show when={drag()}>
{(d) => (
<>
{/* 고른 영역 밖을 어둡게 — 무엇이 남는지 한눈에 보이게 */}
<div
class="pointer-events-none fixed inset-0 bg-black/60"
style={{
"clip-path": `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 0, ${d().x}px ${d().y}px, ${d().x}px ${d().y + d().h}px, ${d().x + d().w}px ${d().y + d().h}px, ${d().x + d().w}px ${d().y}px, ${d().x}px ${d().y}px)`,
}}
/>
<div
class="pointer-events-none fixed border-2 border-white/90"
style={{
left: `${d().x}px`,
top: `${d().y}px`,
width: `${d().w}px`,
height: `${d().h}px`,
}}
/>
</>
)}
</Show>
</div>
{/* 조작 막대 */}
<div class="absolute inset-x-0 bottom-0 z-30 flex items-center gap-2 border-t border-[var(--border)] bg-[var(--bg-panel)] px-4 py-2">
<span class="text-[11px] text-[var(--text-muted)]">{ko.edit.cropHint}</span>
<div class="flex gap-1">
<For each={PRESETS}>
{(p) => (
<button
class="rounded border px-2 py-0.5 text-[11px]"
classList={{
"border-[var(--accent)] bg-[var(--accent)] text-white": aspect() === p.a,
"border-[var(--border)] hover:bg-[var(--bg-hover)]": aspect() !== p.a,
}}
onClick={() => {
setAspect(p.a);
const d = drag();
if (d) setDrag(fitAspect(d, p.a));
}}
>
{p.label}
</button>
)}
</For>
</div>
<span class="ml-auto text-[11px] tabular-nums text-[var(--text-secondary)]">
<Show when={sourceRect()} fallback={ko.edit.cropNone}>
{(r) => `${r().w} × ${r().h}`}
</Show>
</span>
<button
class="rounded border border-[var(--border)] px-3 py-1 text-xs hover:bg-[var(--bg-hover)]"
onClick={props.onCancel}
>
{ko.common.cancel}
</button>
<button
class="rounded bg-[var(--accent)] px-3 py-1 text-xs text-white disabled:opacity-40"
disabled={props.busy || !sourceRect()}
onClick={() => {
const r = sourceRect();
if (r) props.onApply(r);
}}
>
{props.busy ? ko.edit.cropping : ko.edit.cropApply}
</button>
</div>
</>
);
};
+582 -15
View File
@@ -1,23 +1,65 @@
// 라이트박스 — 사진(줌/팬) + 영상 재생 오버레이. Esc 닫기, ←/→ 이동, 휠/+/- 줌.
// 재생·감상 중에도 정리 작업(태그·폴더 이동·삭제 표시)을 끊김 없이 할 수 있도록
// 그리드와 같은 파일 작업을 여기서도 버튼·단축키로 노출한다.
import {
IconCheck,
IconFlipH,
IconCrop,
IconFolder,
IconRotateLeft,
IconRotateRight,
IconTrash,
IconX,
} from "../../ui/icons";
import {
createEffect,
createMemo,
createResource,
createSignal,
For,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import {
bumpTagVersion,
closeLightbox,
dedupeOpen,
info,
lightboxIndex,
markedIds,
navLightbox,
openShortcuts,
refreshSidebar,
refreshSnapshot,
selectSingle,
shortcutsOpen,
snapshot,
toast,
toggleMarkDelete,
trashTag,
} from "../../state/store";
import { fileDetails, mediaUrl, thumbUrl } from "../../ipc/commands";
import {
assignTags,
createTag,
cutFile,
fileDetails,
fileTagsOf,
listTags,
mediaUrl,
setRating,
thumbUrl,
unassignTags,
type EditOp,
type Tag,
} from "../../ipc/commands";
import { ko } from "../../i18n/ko";
import { moveDialogOpen, openMoveDialog } from "../fileops/MoveDialog";
import { performEdit, performTrash, performUndo } from "../fileops/ContextMenu";
import { StarRating } from "../../ui/StarRating";
import { VideoPlayer } from "./VideoPlayer";
import { CropOverlay } from "./CropOverlay";
/** 웹뷰가 <img>로 직접 그릴 수 있는 확장자 (그 외는 썸네일 폴백 — HEIC/RAW 등) */
const IMG_NATIVE = new Set(["jpg", "jpeg", "jfif", "png", "gif", "webp", "bmp", "avif"]);
@@ -32,11 +74,233 @@ export const Lightbox: Component = () => {
return { index: idx, id: snap.id(idx), kind: snap.kind(idx) };
});
const [details] = createResource(
const [details, { refetch: refetchDetails }] = createResource(
() => item()?.id,
async (id) => (await fileDetails([id]))[0],
);
// ── 파일 작업 (그리드와 동일한 동작, 현재 항목 대상) ──
const [tagMenuOpen, setTagMenuOpen] = createSignal(false);
// 태그를 붙이거나 뗄 때마다 올려서 현재 파일 태그를 다시 읽는다
const [tagVersion, setTagVersion] = createSignal(0);
const [tags, { refetch: refetchTags }] = createResource(
() => (lightboxIndex() != null ? tagVersion() : undefined),
() => listTags(),
);
// 지금 보고 있는 파일에 실제로 붙어 있는 태그 — 나갔다 확인할 필요 없게 상단에 상주시킨다
const [fileTags] = createResource(
() => {
const id = item()?.id;
return id != null ? `${id}:${tagVersion()}` : undefined;
},
async (k) => fileTagsOf(Number(k.split(":")[0])),
);
/** 삭제 예정(시스템)은 🗑 배지가 따로 보여주므로 태그 칩에서는 뺀다 */
const shownTags = () => (fileTags() ?? []).filter((t) => t.id !== trashTag());
const assignedIds = () => new Set((fileTags() ?? []).map((t) => t.id));
// 시스템 '삭제 예정' 태그는 🗑 버튼이 담당 — 태그 목록에서는 뺀다
const userTags = () => (tags() ?? []).filter((t) => t.id !== trashTag());
/** 1~9 빠른 지정 대상 — 메뉴를 열지 않아도 쓰는 앞쪽 9개 */
const quickTags = () => userTags().slice(0, 9);
const isMarked = () => {
const it = item();
return !!it && markedIds().has(it.id);
};
const markCurrent = () => {
const it = item();
if (it) void toggleMarkDelete([it.id]);
};
// 회전·반전 — 원본을 직접 고친다.
//
// 고친 뒤 화면을 새로 그리려면 **URL을 바꿔야 한다**. 경로가 그대로면 브라우저가
// 캐시에 있는 옛 바이트를 그대로 쓰고, 사진은 돌지 않은 채로 남는다.
const [editing, setEditing] = createSignal(false);
const [editVersion, setEditVersion] = createSignal(0);
// 자르기 — 되돌릴 수 없으므로 원본은 백업으로 옮겨진다(Ctrl+Z로 복귀).
const [cropping, setCropping] = createSignal(false);
let imgRef: HTMLImageElement | undefined;
const applyCrop = async (r: { x: number; y: number; w: number; h: number }) => {
const it = item();
if (!it || editing()) return;
setEditing(true);
try {
await cutFile(it.id, { kind: "crop", ...r });
toast(ko.edit.cropDone(r.w, r.h));
setCropping(false);
setEditVersion((v) => v + 1);
refetchDetails();
await refreshSnapshot();
} catch (e) {
toast(String(e));
} finally {
setEditing(false);
}
};
const rotateCurrent = async (op: EditOp) => {
const it = item();
if (!it || editing()) return;
setEditing(true);
try {
await performEdit(op, [it.id]);
setEditVersion((v) => v + 1);
// 치수가 바뀌었을 수 있다(90° 회전) — 상세 정보도 다시 읽는다
refetchDetails();
} finally {
setEditing(false);
}
};
/** 태그 토글 — 이미 붙어 있으면 뗀다. 메뉴는 연달아 쓰도록 열어 둔다. */
const toggleTag = async (tagId: number, name: string) => {
const it = item();
if (!it) return;
try {
if (assignedIds().has(tagId)) {
const r = await unassignTags([it.id], tagId);
toast(ko.tags.removedFrom(r.count, name));
} else {
const r = await assignTags([it.id], tagId);
toast(ko.tags.assignTo(r.count, name));
}
setTagVersion((v) => v + 1);
bumpTagVersion(); // 그리드 썸네일 태그 오버레이도 갱신
refreshSidebar();
} catch (e) {
toast(String(e));
}
};
/**
* ( ).
* , `Ctrl+0` .
*/
const [ratingVersion, setRatingVersion] = createSignal(0);
const [rating] = createResource(
() => (item() ? `${item()!.id}:${ratingVersion()}` : undefined),
async () => (await fileDetails([item()!.id]))[0]?.rating ?? 0,
);
const applyRating = async (next: number) => {
const it = item();
if (!it) return;
try {
// 같은 별을 다시 누르면 해제 — 위젯과 단축키가 같은 규칙을 쓴다
const value = rating() === next ? 0 : next;
await setRating([it.id], value);
setRatingVersion((v) => v + 1);
await refreshSnapshot(); // 그리드 셀의 별도 갱신
} catch (e) {
toast(String(e));
}
};
// ── 태그 고르기 (전체 목록 + 검색 + 새로 만들기) ──
// 태그가 열 개를 넘어가면 1~9 빠른 키만으로는 부족하다. 검색으로 좁혀 고르고,
// 없는 이름이면 그 자리에서 만들어 지정한다 — 감상을 멈추고 사이드바로 갈 필요가 없다.
const [tagQuery, setTagQuery] = createSignal("");
const [tagHighlight, setTagHighlight] = createSignal(0);
let tagInputRef: HTMLInputElement | undefined;
let tagListRef: HTMLDivElement | undefined;
const filteredTags = () => {
const q = tagQuery().trim().toLowerCase();
return q ? userTags().filter((t) => t.name.toLowerCase().includes(q)) : userTags();
};
const exactTag = () => {
const q = tagQuery().trim().toLowerCase();
return q ? userTags().find((t) => t.name.toLowerCase() === q) : undefined;
};
/** 이미 있는 이름과 겹치지 않을 때만 '새로 만들기'를 제안한다 */
const canCreateTag = () => tagQuery().trim().length > 0 && !exactTag();
/** 목록 마지막 줄이 '새로 만들기' — 키보드 이동 인덱스를 함께 센다 */
const tagRowCount = () => filteredTags().length + (canCreateTag() ? 1 : 0);
const isCreateRow = (i: number) => canCreateTag() && i === filteredTags().length;
const openTagMenu = () => {
void refetchTags();
setTagQuery("");
setTagHighlight(0);
setTagMenuOpen(true);
};
const closeTagMenu = () => {
setTagMenuOpen(false);
setTagQuery("");
};
const toggleTagMenu = () => (tagMenuOpen() ? closeTagMenu() : openTagMenu());
/** 목록에서 고른 태그를 토글하고, 이어서 다른 태그를 찾을 수 있게 검색어를 비운다 */
const pickTag = async (t: Tag) => {
await toggleTag(t.id, t.name);
setTagQuery("");
setTagHighlight(0);
tagInputRef?.focus();
};
/** 없는 태그면 만들어서 바로 지정 (이름이 이미 있으면 그 태그를 쓴다) */
const createAndAssignTag = async (raw: string) => {
const it = item();
const name = raw.trim();
if (!it || !name) return;
try {
const existing = userTags().find((t) => t.name.toLowerCase() === name.toLowerCase());
const tagId = existing ? existing.id : await createTag(name);
const r = await assignTags([it.id], tagId);
toast(existing ? ko.tags.assignTo(r.count, name) : ko.tags.createdAndAssigned(name));
setTagQuery("");
setTagHighlight(0);
tagInputRef?.focus();
setTagVersion((v) => v + 1);
bumpTagVersion();
refreshSidebar();
} catch (e) {
toast(String(e));
}
};
/** Enter — 강조된 줄을 실행 (태그 토글 또는 새로 만들기) */
const commitTagRow = (i: number) => {
if (isCreateRow(i)) {
void createAndAssignTag(tagQuery());
return;
}
const t = filteredTags()[i];
if (t) void pickTag(t);
};
const onTagInputKey = (e: KeyboardEvent) => {
// 입력 중에는 라이트박스 전역 키가 오지 않으므로(INPUT은 건너뛴다) 여기서 처리한다
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
closeTagMenu();
return;
}
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
const n = tagRowCount();
if (n === 0) return;
setTagHighlight((h) => (h + (e.key === "ArrowDown" ? 1 : n - 1)) % n);
return;
}
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
commitTagRow(tagHighlight());
}
};
// 강조된 줄을 목록 안에서 보이게 유지
createEffect(() => {
const i = tagHighlight();
if (!tagMenuOpen() || !tagListRef) return;
(tagListRef.children[i] as HTMLElement | undefined)?.scrollIntoView({ block: "nearest" });
});
// ── 사진 줌/팬 상태 ──
const [scale, setScale] = createSignal(1);
const [panX, setPanX] = createSignal(0);
@@ -49,10 +313,18 @@ export const Lightbox: Component = () => {
setPanY(0);
};
// 파일이 바뀌면 줌 리셋
// 파일이 바뀌면 줌 리셋 + 열려 있던 태그 메뉴 닫기
createEffect(() => {
item()?.id;
resetZoom();
closeTagMenu();
});
// 보고 있는 항목 = 선택 항목으로 유지.
// 폴더 이동/휴지통 후 clearSelection()이 불려도 상단 버튼이 계속 동작해야 한다.
createEffect(() => {
const it = item();
if (it) selectSingle(it.id, it.index);
});
/** 커서 위치 기준 줌 (center 기준 상대좌표) */
@@ -100,10 +372,95 @@ export const Lightbox: Component = () => {
const onKey = (e: KeyboardEvent) => {
if (lightboxIndex() == null) return;
// 위에 뜬 모달이 키를 먼저 가진다 (각자 Esc를 처리한다).
// 중복 검토 모달도 이 목록에 있어야 한다 — 라이트박스가 App.tsx에서 먼저 마운트돼
// capture 리스너가 먼저 등록되므로, 양보하지 않으면 Esc가 모달이 아니라 라이트박스를 닫는다.
if (moveDialogOpen() || shortcutsOpen() || dedupeOpen()) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
const isVideo = item()?.kind === 1;
if (e.key === "?") {
e.preventDefault();
e.stopPropagation();
openShortcuts();
return;
}
// ── 미디어 종류와 무관한 파일 작업 ──
// (이 핸들러가 VideoPlayer보다 먼저 등록돼 capture 단계에서 우선 처리된다)
if (e.key === "Delete") {
e.preventDefault();
e.stopPropagation();
// Shift+Delete만 즉시 OS 휴지통, 기본은 되돌릴 수 있는 삭제 표시
const it = item();
if (!it) return;
if (e.shiftKey) void performTrash([it.id]);
else markCurrent();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "m") {
e.preventDefault();
e.stopPropagation();
openMoveDialog();
return;
}
// 보기 중에 표시했다가 바로 무를 수 있어야 한다 (그리드 핸들러는 여기선 꺼져 있다)
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
e.stopPropagation();
void performUndo();
return;
}
// Ctrl+0~5: 별점. 맨숫자는 이미 태그 빠른 지정이 쓰고 있어서 수식키를 붙였다 —
// 태그 쪽 손버릇을 바꾸는 것보다 새 기능이 비켜서는 편이 낫다.
// (다른 Ctrl 단축키들과 같은 높이에 둔다 — 아래 `!e.ctrlKey` 블록 안에 두면 절대 안 걸린다)
if ((e.ctrlKey || e.metaKey) && e.key >= "0" && e.key <= "5") {
e.preventDefault();
e.stopPropagation();
void applyRating(Number(e.key));
return;
}
// Ctrl+←/→ 는 영상에서도 항상 이전·다음 파일 (←/→ 는 시크에 쓰이므로)
if ((e.ctrlKey || e.metaKey) && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
e.preventDefault();
e.stopPropagation();
navLightbox(e.key === "ArrowRight" ? 1 : -1);
return;
}
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
// 다음/이전 항목 — 영상 재생 중에도 손이 안 꼬이도록 N/P 제공
if (e.key === "n" || e.key === "N" || e.key === "p" || e.key === "P") {
e.preventDefault();
e.stopPropagation();
navLightbox(e.key === "n" || e.key === "N" ? 1 : -1);
return;
}
if (e.key === "t" || e.key === "T") {
e.preventDefault();
e.stopPropagation();
toggleTagMenu();
return;
}
// 1~9: 태그 목록 n번째를 현재 항목에 토글 (빠른 분류 — 잘못 눌러도 같은 키로 취소)
if (e.key >= "1" && e.key <= "9") {
const t = quickTags()[Number(e.key) - 1];
if (t) {
e.preventDefault();
e.stopPropagation();
void toggleTag(t.id, t.name);
return;
}
}
}
switch (e.key) {
case "Escape":
e.preventDefault();
if (tagMenuOpen()) {
closeTagMenu();
break;
}
closeLightbox();
break;
case "ArrowLeft":
@@ -153,32 +510,226 @@ export const Lightbox: Component = () => {
if (!it || !app) return undefined;
const ext = d?.name.split(".").pop()?.toLowerCase() ?? "";
// 네이티브 디코드 불가 형식(HEIC/RAW/TIFF)은 프리뷰 썸네일로 폴백
return IMG_NATIVE.has(ext) ? mediaUrl(app, it.id) : thumbUrl(app, it.id, 0);
const base = IMG_NATIVE.has(ext) ? mediaUrl(app, it.id) : thumbUrl(app, it.id, 0);
// 회전한 뒤에는 URL이 달라야 브라우저가 다시 받아온다 — 경로가 같으면 옛 화면이 그대로 남는다
const v = editVersion();
if (v === 0) return base;
return `${base}${base.includes("?") ? "&" : "?"}e=${v}`;
};
return (
<Show when={item()}>
{(it) => (
<div class="fixed inset-0 z-50 flex flex-col bg-black/90">
// media-surface: 배경이 항상 검으므로 라이트 테마에서도 크롬 색은 다크 값을 쓴다
<div class="media-surface fixed inset-0 z-50 flex flex-col bg-black/90">
<div class="flex h-10 shrink-0 items-center gap-3 px-3">
<span class="truncate text-xs text-[var(--text-primary)]">
{/* min-w-0 flex
(min-width:auto) .
max-w로 이유: 이름이 . */}
<span
class="min-w-0 max-w-[42%] shrink truncate text-xs text-[var(--text-primary)]"
title={details()?.name ?? ""}
>
{details()?.name ?? ""}
</span>
<span class="text-[11px] text-[var(--text-muted)]">
<span class="shrink-0 whitespace-nowrap text-[11px] text-[var(--text-muted)]">
{it().index + 1} / {snapshot()?.count.toLocaleString("ko-KR")}
</span>
{/* 별점 — 제목 옆. 감상 중 손이 가장 자주 가는 조작이라 숨기지 않는다 */}
<span class="shrink-0" title={ko.rating.shortcut}>
<StarRating value={rating() ?? 0} size={13} onChange={(n) => void applyRating(n)} />
</span>
<Show when={it().kind !== 1 && scale() !== 1}>
<span class="rounded bg-[var(--bg-panel-raised)] px-1.5 py-0.5 text-[10px] tabular-nums text-[var(--text-secondary)]">
{Math.round(scale() * 100)}%
</span>
</Show>
<button
class="ml-auto rounded px-2 py-0.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
onClick={closeLightbox}
title="닫기 (Esc)"
>
</button>
<Show when={isMarked()}>
<span class="rounded bg-[var(--danger)] px-1.5 py-0.5 text-[10px] text-white">
{ko.trash.badge}
</span>
</Show>
{/* 현재 항목에 붙은 태그 — 보기를 벗어나지 않고 바로 확인·해제 */}
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
<For each={shownTags()}>
{(t) => (
<span class="group/tag flex shrink-0 items-center gap-1 rounded-full bg-[var(--accent-soft)] px-2 py-0.5 text-[11px] text-[var(--accent)]">
# {t.name}
<button
class="opacity-0 transition-opacity hover:text-[var(--danger)] group-hover/tag:opacity-100"
onClick={() => void toggleTag(t.id, t.name)}
title={`"${t.name}" 태그 제거`}
>
×
</button>
</span>
)}
</For>
</div>
{/* · .
shrink-0: .
2 ( ). . */}
<div class="relative ml-auto flex shrink-0 items-center gap-1">
{/* inline-flex + whitespace-nowrap: .
. */}
<button
class="shrink-0 whitespace-nowrap rounded px-2 py-0.5 text-xs hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]"
classList={{
"text-[var(--accent)]": tagMenuOpen(),
"text-[var(--text-secondary)]": !tagMenuOpen(),
}}
onClick={toggleTagMenu}
title={`${ko.tags.add} (T)`}
>
#
</button>
{/* · .
Ctrl+Z로 . */}
<div class="mx-1 flex shrink-0 items-center">
<button
class="shrink-0 rounded px-1.5 py-0.5 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)] disabled:opacity-40"
disabled={editing()}
onClick={() => void rotateCurrent("rotateCcw")}
title={`${ko.edit.rotateCcw} (Ctrl+[) · ${ko.edit.hint}`}
>
<IconRotateLeft size={13} />
</button>
<button
class="shrink-0 rounded px-1.5 py-0.5 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)] disabled:opacity-40"
disabled={editing()}
onClick={() => void rotateCurrent("rotateCw")}
title={`${ko.edit.rotateCw} (Ctrl+]) · ${ko.edit.hint}`}
>
<IconRotateRight size={13} />
</button>
<button
class="shrink-0 rounded px-1.5 py-0.5 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)] disabled:opacity-40"
disabled={editing()}
onClick={() => void rotateCurrent("flipH")}
title={`${ko.edit.flipH} · ${ko.edit.hint}`}
>
<IconFlipH size={13} />
</button>
<Show when={item()?.kind !== 1}>
<button
class="shrink-0 rounded px-1.5 py-0.5 hover:bg-[var(--bg-hover)] disabled:opacity-40"
classList={{
"text-[var(--accent)]": cropping(),
"text-[var(--text-secondary)] hover:text-[var(--text-primary)]": !cropping(),
}}
disabled={editing()}
onClick={() => setCropping((v) => !v)}
title={`${ko.edit.crop} · ${ko.edit.cropWarn}`}
>
<IconCrop size={13} />
</button>
</Show>
</div>
<button
class="inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded px-2 py-0.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]"
onClick={openMoveDialog}
title={`${ko.ops.moveTo} (Ctrl+M)`}
>
<IconFolder size={13} />
</button>
<button
class="inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded px-2 py-0.5 text-xs hover:bg-[var(--bg-hover)]"
classList={{
"text-[var(--danger)]": isMarked(),
"text-[var(--text-secondary)] hover:text-[var(--text-primary)]": !isMarked(),
}}
onClick={markCurrent}
title={`${isMarked() ? ko.trash.unmark : ko.trash.mark} (Delete)`}
>
<IconTrash size={13} /> {isMarked() ? ko.trash.unmark : ko.trash.mark}
</button>
<button
class="inline-flex shrink-0 items-center rounded px-2 py-0.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
onClick={closeLightbox}
title="닫기 (Esc)"
>
<IconX size={14} />
</button>
{/* .
9 1~9 . . */}
<Show when={tagMenuOpen()}>
<div class="absolute right-0 top-8 z-10 w-72 rounded-md border border-[var(--border-strong)] bg-[var(--bg-panel-raised)] p-1.5 shadow-xl">
<input
ref={(el) => {
tagInputRef = el;
queueMicrotask(() => el.focus());
}}
class="mb-1 w-full rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1 text-xs text-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--accent)]"
placeholder={ko.tags.searchOrCreate}
value={tagQuery()}
onInput={(e) => {
setTagQuery(e.currentTarget.value);
setTagHighlight(0);
}}
onKeyDown={onTagInputKey}
/>
<div ref={tagListRef} class="max-h-64 overflow-y-auto">
<For each={filteredTags()}>
{(t, idx) => (
<div
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs"
classList={{
"bg-[var(--bg-hover)]": tagHighlight() === idx(),
"text-[var(--accent)]": assignedIds().has(t.id),
}}
onMouseEnter={() => setTagHighlight(idx())}
onClick={() => void pickTag(t)}
>
<kbd
class="w-4 shrink-0 rounded border px-1 text-center font-mono text-[10px]"
classList={{
"border-[var(--border)] text-[var(--text-muted)]":
!tagQuery().trim() && idx() < 9,
"border-transparent text-transparent":
!!tagQuery().trim() || idx() >= 9,
}}
>
{idx() < 9 ? idx() + 1 : ""}
</kbd>
<span class="w-3 shrink-0 text-center">
<Show when={assignedIds().has(t.id)}><IconCheck size={12} /></Show>
</span>
<span class="truncate"># {t.name}</span>
<span class="ml-auto shrink-0 text-[10px] text-[var(--text-muted)]">
{t.fileCount.toLocaleString("ko-KR")}
</span>
</div>
)}
</For>
<Show when={canCreateTag()}>
<div
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-[var(--accent)]"
classList={{
"bg-[var(--bg-hover)]": tagHighlight() === filteredTags().length,
}}
onMouseEnter={() => setTagHighlight(filteredTags().length)}
onClick={() => void createAndAssignTag(tagQuery())}
>
<span class="w-4 shrink-0 text-center">+</span>
<span class="truncate">{ko.tags.createAssign(tagQuery().trim())}</span>
</div>
</Show>
{/* 태그가 하나도 없을 때만 — 검색어가 있으면 위의 '새로 만들기'가 답이다 */}
<Show when={tagRowCount() === 0}>
<div class="px-2 py-2 text-[11px] text-[var(--text-muted)]">
{ko.tags.emptyAll}
</div>
</Show>
</div>
<div class="mt-1 border-t border-[var(--border)] px-2 pt-1 text-[10px] text-[var(--text-muted)]">
{ko.tags.pickerHint}
</div>
</div>
</Show>
</div>
</div>
<div class="min-h-0 flex-1 p-2">
<Show
@@ -192,6 +743,7 @@ export const Lightbox: Component = () => {
onDblClick={() => (scale() > 1 ? resetZoom() : zoomCentered(2.5))}
>
<img
ref={imgRef}
src={imgSrc()}
class="max-h-full max-w-full touch-none select-none object-contain"
style={{
@@ -210,7 +762,22 @@ export const Lightbox: Component = () => {
</div>
}
>
<VideoPlayer fileId={it().id} />
<VideoPlayer fileId={it().id} version={editVersion()} />
</Show>
{/* .
( UI만 ). */}
<Show when={cropping() && it().kind !== 1}>
<CropOverlay
target={() => imgRef}
natural={() =>
imgRef ? { w: imgRef.naturalWidth, h: imgRef.naturalHeight } : undefined
}
busy={editing()}
onCancel={() => setCropping(false)}
onApply={(r) => void applyCrop(r)}
/>
</Show>
</div>
</div>
+286
View File
@@ -0,0 +1,286 @@
// 동시 재생 — 고른 영상 여러 개를 격자에 놓고 함께 재생한다 (최대 9개).
//
// **시간을 맞추지 않는다.** 길이가 서로 다른 영상을 같은 타임라인에 묶으면 대부분의 칸이
// 빈 화면이 된다(그건 중복 비교의 문제이고 CompareView가 따로 한다). 여기서는 각자 자기
// 0초부터 흐르고, 재생/정지만 함께 움직인다.
//
// **소리는 한 칸만 낸다.** 아홉 개가 동시에 울면 아무것도 들리지 않는다. 칸을 누르면
// 그 칸으로 소리가 옮겨간다.
//
// **변환이 필요한 코덱은 상한을 둔다.** 브라우저가 못 읽는 코덱은 ffmpeg가 실시간 변환하는데
// 아홉 개를 동시에 돌리면 기계가 멈춘다. 상한을 넘는 칸은 썸네일과 이유를 보여준다.
import {
createEffect,
createMemo,
createResource,
createSignal,
For,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import { ko } from "../../i18n/ko";
import {
decidePlayback,
fileDetails,
mediaUrl,
playbackInfo,
startStream,
stopStream,
thumbUrl,
type PlaybackPlan,
} from "../../ipc/commands";
import { info as appInfoSig, openLightbox, snapshot } from "../../state/store";
import { IconPause, IconPlay, IconX } from "../../ui/icons";
/** 한 화면에 놓을 수 있는 최대 개수 — 3×3을 넘으면 각 칸이 알아볼 수 없이 작아진다 */
export const MAX_PANES = 9;
/**
* .
*
* CPU .
* ( ) .
*/
const MAX_STREAMS = 2;
const [ids, setIds] = createSignal<number[]>([]);
export const multiPlayIds = ids;
export const openMultiPlay = (fileIds: number[]) => setIds(fileIds.slice(0, MAX_PANES));
export const closeMultiPlay = () => setIds([]);
/** 개수에 맞는 열 수 — 칸이 너무 납작해지지 않는 배치 */
function columnsFor(n: number): number {
if (n <= 1) return 1;
if (n <= 4) return 2;
return 3;
}
interface PaneState {
/** 이 칸이 실시간 변환을 쓰는가 — 상한 판정에 쓴다 */
needsStream: boolean;
}
const Pane: Component<{
fileId: number;
name: string;
/** 소리를 이 칸에서 내는가 */
audio: boolean;
playing: boolean;
/** 변환이 필요한데 상한에 걸려 재생하지 않는 칸 */
blocked: boolean;
onPick: () => void;
onSolo: () => void;
register: (s: PaneState | undefined) => void;
}> = (props) => {
const app = appInfoSig();
const [el, setEl] = createSignal<HTMLVideoElement | undefined>();
const [pb] = createResource(() => props.fileId, playbackInfo);
const plan = createMemo<PlaybackPlan | undefined>(() => {
const p = pb();
return p ? decidePlayback(p) : undefined;
});
let jobId: number | undefined;
const [src, setSrc] = createSignal<string | undefined>();
// 재생 계획이 정해지면 소스를 문다. 변환이 필요한데 막힌 칸은 아무것도 열지 않는다.
//
// `createEffect`여야 한다 — memo는 렌더 도중에 돌아서 아래 `<video>` ref가 아직 없다.
// (부수효과를 memo에 넣는 것은 그 자체로 잘못이기도 하다)
createEffect(() => {
const p = plan();
const a = app;
if (!p || !a) return;
props.register({ needsStream: p.mode === "stream" });
if (props.blocked) {
setSrc(undefined);
return;
}
if (p.mode === "direct") {
setSrc(mediaUrl(a, props.fileId));
} else if (p.mode === "stream") {
void startStream(props.fileId, 0, p.video, p.audio).then((r) => {
jobId = r.jobId;
setSrc(r.url);
});
}
});
onCleanup(() => {
props.register(undefined);
if (jobId != null) void stopStream(jobId);
});
// 부모의 재생/정지를 따른다
createEffect(() => {
const v = el();
if (!v) return;
if (props.playing) void v.play().catch(() => {});
else v.pause();
});
return (
<div
class="group relative min-h-0 cursor-pointer overflow-hidden rounded border bg-black"
classList={{
"border-[var(--accent)]": props.audio,
"border-[var(--border)]": !props.audio,
}}
onClick={props.onPick}
onDblClick={props.onSolo}
title={props.name}
>
<Show
when={src()}
fallback={
// 변환 상한에 걸렸거나 아직 준비 중 — 검은 화면만 두면 고장으로 보인다
<div class="grid h-full w-full place-items-center">
<img
class="absolute inset-0 h-full w-full object-contain opacity-40"
src={app ? thumbUrl(app, props.fileId, 1) : undefined}
alt=""
/>
<span class="relative rounded bg-black/70 px-2 py-1 text-[10px] text-white">
{props.blocked ? ko.multi.blocked : ko.multi.preparing}
</span>
</div>
}
>
<video
ref={setEl}
class="h-full w-full object-contain"
src={src()}
preload="auto"
playsinline
loop
muted={!props.audio}
/>
</Show>
<span class="absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-black/80 to-transparent px-2 pb-1 pt-3 text-[10px] text-white/90">
{props.name}
</span>
<Show when={props.audio}>
<span class="absolute right-1 top-1 rounded bg-[var(--accent)] px-1.5 py-0.5 text-[9px] text-white">
{ko.multi.audioHere}
</span>
</Show>
</div>
);
};
export const MultiPlayView: Component = () => {
const [playing, setPlaying] = createSignal(true);
const [audioAt, setAudioAt] = createSignal(0);
const panes = new Map<number, PaneState>();
const [paneVersion, setPaneVersion] = createSignal(0);
const [details] = createResource(
() => (ids().length > 0 ? ids() : undefined),
(list) => fileDetails(list),
);
const nameOf = (id: number) => details()?.find((d) => d.id === id)?.name ?? "";
/**
* .
*
*
* .
*/
const blockedIds = createMemo(() => {
paneVersion();
const streamers = ids().filter((id) => panes.get(id)?.needsStream);
return new Set(streamers.slice(MAX_STREAMS));
});
const onKey = (e: KeyboardEvent) => {
if (ids().length === 0) return;
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
closeMultiPlay();
}
if (e.key === " ") {
e.preventDefault();
e.stopPropagation();
setPlaying((p) => !p);
}
// 1~9 로 소리를 옮긴다
const n = Number(e.key);
if (Number.isInteger(n) && n >= 1 && n <= ids().length) {
e.preventDefault();
setAudioAt(n - 1);
}
};
onMount(() => window.addEventListener("keydown", onKey, true));
onCleanup(() => window.removeEventListener("keydown", onKey, true));
/** 한 칸만 크게 — 라이트박스로 넘긴다 */
const solo = (id: number) => {
const snap = snapshot();
if (!snap) return;
for (let i = 0; i < snap.count; i++) {
if (snap.id(i) === id) {
closeMultiPlay();
openLightbox(i);
return;
}
}
};
return (
<Show when={ids().length > 0}>
<div class="fixed inset-0 z-50 flex flex-col bg-black/95">
<div class="flex items-center gap-3 border-b border-[var(--border)] px-4 py-2 text-white">
<span class="text-sm font-semibold">{ko.multi.title(ids().length)}</span>
<Show when={blockedIds().size > 0}>
<span class="text-[11px] text-[var(--warn,#f0b429)]">
{ko.multi.blockedNote(blockedIds().size, MAX_STREAMS)}
</span>
</Show>
<span class="ml-auto text-[11px] text-white/60">{ko.multi.hint}</span>
<button
class="rounded border border-white/30 px-2 py-1 text-xs hover:bg-white/15"
onClick={() => setPlaying((p) => !p)}
>
<span class="inline-flex items-center gap-1">
{playing() ? <IconPause size={13} /> : <IconPlay size={13} />}
{playing() ? ko.multi.pauseAll : ko.multi.playAll}
</span>
</button>
<button
class="rounded px-2 py-1 text-white/80 hover:bg-white/15"
onClick={closeMultiPlay}
title={ko.common.close}
>
<IconX size={16} />
</button>
</div>
<div
class="grid min-h-0 flex-1 gap-2 p-2"
style={{ "grid-template-columns": `repeat(${columnsFor(ids().length)}, minmax(0, 1fr))` }}
>
<For each={ids()}>
{(id, i) => (
<Pane
fileId={id}
name={`${i() + 1}. ${nameOf(id)}`}
audio={audioAt() === i()}
playing={playing()}
blocked={blockedIds().has(id)}
onPick={() => setAudioAt(i())}
onSolo={() => solo(id)}
register={(s) => {
if (s) panes.set(id, s);
else panes.delete(id);
setPaneVersion((v) => v + 1);
}}
/>
)}
</For>
</div>
</div>
</Show>
);
};
+572 -32
View File
@@ -1,9 +1,11 @@
// 미디어 플레이어 — 통일된 커스텀 컨트롤(직접 재생 + ffmpeg 스트림 공통).
// 재생/정지·10초 스킵·시크바(버퍼)·볼륨·배속·전체화면·자동숨김·키보드 단축키.
import { IconChevronLeft, IconChevronRight, IconFolder, IconForward, IconMaximize, IconMinimize, IconPause, IconPlay, IconRepeat, IconResetZoom, IconRewind, IconScissors, IconTrash, IconVolumeHigh, IconVolumeLow, IconVolumeMute } from "../../ui/icons";
import {
createEffect,
createResource,
createSignal,
For,
onCleanup,
onMount,
Show,
@@ -15,11 +17,25 @@ import {
playbackInfo,
startStream,
stopStream,
cutFile,
filmstrip,
type AudioMode,
type PlaybackPlan,
type StripFrame,
type VideoMode,
} from "../../ipc/commands";
import { info as appInfoSig, navLightbox, repeatMode, toggleRepeat } from "../../state/store";
import {
info as appInfoSig,
markedIds,
navLightbox,
repeatMode,
refreshSnapshot,
toast,
toggleMarkDelete,
toggleRepeat,
} from "../../state/store";
import { ko } from "../../i18n/ko";
import { moveDialogOpen, openMoveDialog } from "../fileops/MoveDialog";
function fmtTime(sec: number): string {
const s = Math.max(0, Math.floor(sec));
@@ -33,13 +49,101 @@ function fmtTime(sec: number): string {
const RATES = [0.5, 1, 1.25, 1.5, 2];
const VOL_KEY = "archive.player.volume";
const FIT_KEY = "archive.player.fit";
export const VideoPlayer: Component<{ fileId: number }> = (props) => {
/**
* .
* - fit : 비율 ( )
* - fill : 화면을
* - none : 원본 1:1
*/
type FitMode = "fit" | "fill" | "none";
const FIT_LABEL: Record<FitMode, string> = {
fit: "맞춤",
fill: "채움",
none: "원본",
};
const FIT_ORDER: FitMode[] = ["fit", "fill", "none"];
const ZOOM_MIN = 1;
const ZOOM_MAX = 8;
/**
* .
*
* . ,
* .
*/
const TimeField: Component<{
label: string;
value: number;
active: boolean;
fmt: (s: number) => string;
onFocus: () => void;
onCommit: (sec: number) => void;
}> = (props) => {
const [editing, setEditing] = createSignal<string | undefined>();
const commit = (raw: string) => {
const parts = raw.trim().split(":");
const ok = parts.length > 0 && parts.every((p) => p !== "" && !Number.isNaN(Number(p)));
if (ok) {
const n = parts.reduce((acc, p) => acc * 60 + Number(p), 0);
if (Number.isFinite(n) && n >= 0) props.onCommit(n);
}
setEditing(undefined);
};
return (
<label class="flex items-center gap-1">
<span class="text-white/60">{props.label}</span>
<input
class="w-16 rounded border bg-black/40 px-1 py-0.5 text-center tabular-nums outline-none"
classList={{
"border-[var(--accent)]": props.active,
"border-white/25": !props.active,
}}
value={editing() ?? props.fmt(props.value)}
onFocus={props.onFocus}
onInput={(e) => setEditing(e.currentTarget.value)}
onBlur={(e) => commit(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit((e.currentTarget as HTMLInputElement).value);
(e.currentTarget as HTMLInputElement).blur();
}
if (e.key === "Escape") {
e.preventDefault();
setEditing(undefined);
(e.currentTarget as HTMLInputElement).blur();
}
}}
/>
</label>
);
};
export const VideoPlayer: Component<{
fileId: number;
/** 라이트박스에서 회전·반전한 횟수 — 올라오면 파일이 바뀐 것이므로 다시 읽는다 */
version?: number;
}> = (props) => {
let videoRef: HTMLVideoElement | undefined;
let containerRef: HTMLDivElement | undefined;
let barRef: HTMLDivElement | undefined;
const [pb] = createResource(() => props.fileId, playbackInfo);
/**
* .
*
* id는 7
* 30 , URL이라 .
* .
*/
const [trimVersion, setTrimVersion] = createSignal(0);
/** 이 재생기가 아는 '파일 내용 세대' — 구간 자르기(내부)와 회전·반전(라이트박스)을 합친다 */
const fileVersion = () => trimVersion() + (props.version ?? 0);
const [pb] = createResource(
() => `${props.fileId}:${fileVersion()}`,
() => playbackInfo(props.fileId),
);
const [plan, setPlan] = createSignal<PlaybackPlan | undefined>();
const [src, setSrc] = createSignal<string | undefined>();
const [seekBase, setSeekBase] = createSignal(0);
@@ -52,15 +156,139 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
const [rate, setRate] = createSignal(1);
const [fullscreen, setFullscreen] = createSignal(false);
const [controlsShown, setControlsShown] = createSignal(true);
// ── 구간 자르기 ──
// 남길 구간만 두고 나머지를 잘라낸다. 스트림 복사라 화질 손실이 없고 30분짜리도 몇 초다.
// 되돌릴 수 없으므로(잘라낸 시간은 되살릴 수 없다) 원본은 백업으로 옮겨 두고
// Ctrl+Z로 돌아온다 — 백엔드가 그 처리를 한다.
const [trimMode, setTrimMode] = createSignal(false);
const [trimStart, setTrimStart] = createSignal(0);
const [trimEnd, setTrimEnd] = createSignal<number | undefined>();
const [trimBusy, setTrimBusy] = createSignal(false);
/** 지금 미세 조정 중인 손잡이 — ← → 키가 이쪽을 움직인다 */
const [activeHandle, setActiveHandle] = createSignal<"start" | "end">("start");
/** 고른 구간만 반복 재생 중인가 */
const [rangePreview, setRangePreview] = createSignal(false);
/**
* .
*
* . 12
* .
*/
const [strip] = createResource(
() => (trimMode() ? props.fileId : undefined),
(id) => filmstrip(id, 12).catch(() => [] as StripFrame[]),
);
/** 끝을 아직 안 찍었으면 영상 끝이 기본값 */
const trimEndOr = () => trimEnd() ?? durationSec();
/**
* (). fps는 DB에 30fps로
* .
*/
const frameSec = () => 1 / 30;
/** 손잡이를 옮기고 그 지점으로 화면도 옮긴다 — 어디를 자르는지 프레임으로 봐야 한다. */
const moveHandle = (which: "start" | "end", t: number) => {
const d = durationSec();
const gap = 0.1;
if (which === "start") {
const v = Math.max(0, Math.min(t, trimEndOr() - gap));
setTrimStart(v);
seekTo(v);
} else {
const v = Math.min(d || t, Math.max(t, trimStart() + gap));
setTrimEnd(v);
seekTo(v);
}
setActiveHandle(which);
};
const nudge = (deltaFrames: number) => {
const which = activeHandle();
const base = which === "start" ? trimStart() : trimEndOr();
moveHandle(which, base + deltaFrames * frameSec());
};
/** 손잡이 끌기 — 재생바 위에서 직접 구간을 잡는다. */
const onHandleDown = (which: "start" | "end") => (e: PointerEvent) => {
if (durationSec() <= 0) return;
e.preventDefault();
e.stopPropagation(); // 재생바의 시크 처리로 넘어가지 않게
setRangePreview(false);
moveHandle(which, posToTime(e.clientX));
const move = (me: PointerEvent) => moveHandle(which, posToTime(me.clientX));
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
/** 고른 구간만 반복 재생 — 적용하기 전에 결과를 확인한다. */
const toggleRangePreview = () => {
if (rangePreview()) {
setRangePreview(false);
videoRef?.pause();
return;
}
setRangePreview(true);
seekTo(trimStart());
queueMicrotask(() => videoRef?.play().catch(() => {}));
};
/** 편집 가능한 시간 표기 — 소수 첫째 자리까지 보여 정밀 입력이 가능하다 */
const fmtPrecise = (sec: number) => {
const m = Math.floor(sec / 60);
const s = sec - m * 60;
return `${m}:${s.toFixed(1).padStart(4, "0")}`;
};
const applyTrim = async () => {
const [s, e] = [trimStart(), trimEndOr()];
if (e - s < 0.5) return toast(ko.edit.trimTooShort);
setTrimBusy(true);
try {
await cutFile(props.fileId, {
kind: "trim",
startMs: Math.round(s * 1000),
endMs: Math.round(e * 1000),
});
toast(ko.edit.trimDone(e - s));
setTrimMode(false);
setTrimStart(0);
setTrimEnd(undefined);
setRangePreview(false);
// 파일이 바뀌었다 — 재생 정보를 다시 읽고 소스를 새로 문다.
// 이걸 빼먹어서 "자른 뒤 재생이 안 된다"는 신고가 나왔다.
setTrimVersion((v) => v + 1);
await refreshSnapshot();
} catch (err) {
toast(String(err));
} finally {
setTrimBusy(false);
}
};
// <video> 요소가 보고한 길이 (직접 재생 시 메타데이터보다 정확·항상 존재)
const [elDuration, setElDuration] = createSignal(0);
// 스트림 시크 중 표시 위치 고정용 (null이면 실제 재생 위치 사용)
const [seekingTo, setSeekingTo] = createSignal<number | null>(null);
// ── 화면 크기 / 확대 ──
const storedFit = localStorage.getItem(FIT_KEY) as FitMode | null;
const [fit, setFitRaw] = createSignal<FitMode>(
storedFit && FIT_ORDER.includes(storedFit) ? storedFit : "fit",
);
const [zoom, setZoom] = createSignal(1);
const [panX, setPanX] = createSignal(0);
const [panY, setPanY] = createSignal(0);
let jobId: number | undefined;
let hideTimer: ReturnType<typeof setTimeout> | undefined;
let streamSeekTimer: ReturnType<typeof setTimeout> | undefined;
const isStream = () => plan()?.mode === "stream";
const marked = () => markedIds().has(props.fileId);
// 재생 길이: ffprobe 총 길이(메타)를 항상 우선. <video> 요소 duration은 스트림·일부
// 컨테이너(리먹스 fMP4 등)에서 재생 중 실시간으로 늘어나 재생바가 요동치므로,
// 메타가 있으면 그 고정 총길이를 쓰고, 없을 때만 요소 duration으로 폴백한다.
@@ -102,7 +330,9 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
const decided = decidePlayback(p);
setPlan(decided);
if (decided.mode === "direct") {
setSrc(mediaUrl(app, props.fileId));
const base = mediaUrl(app, props.fileId);
// 경로가 같으면 웹뷰가 캐시된 옛 파일을 그대로 쓴다
setSrc(fileVersion() === 0 ? base : `${base}?v=${fileVersion()}`);
} else if (decided.mode === "stream") {
void startStreamAt(0, decided.video, decided.audio);
} else {
@@ -175,6 +405,97 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
localStorage.setItem(VOL_KEY, String(nv));
};
// ── 화면 크기 / 확대 ──
const resetView = () => {
setZoom(1);
setPanX(0);
setPanY(0);
};
const setFit = (m: FitMode) => {
setFitRaw(m);
localStorage.setItem(FIT_KEY, m);
resetView();
};
const cycleFit = () => setFit(FIT_ORDER[(FIT_ORDER.indexOf(fit()) + 1) % FIT_ORDER.length]);
/** 커서 위치를 기준으로 확대 — 보고 싶은 부분이 그 자리에 남는다 */
const zoomAt = (factor: number, cx: number, cy: number) => {
const old = zoom();
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, old * factor));
if (next === old) return;
if (next === 1) {
resetView();
return;
}
const ratio = next / old;
setPanX(cx * (1 - ratio) + panX() * ratio);
setPanY(cy * (1 - ratio) + panY() * ratio);
setZoom(next);
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = containerRef?.getBoundingClientRect();
const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0;
const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0;
zoomAt(e.deltaY < 0 ? 1.15 : 1 / 1.15, cx, cy);
showControls();
};
/**
* . (/)
* , click을 .
*/
const onVideoPointerDown = (e: PointerEvent) => {
if (zoom() <= 1 || e.button !== 0) return;
const startX = e.clientX - panX();
const startY = e.clientY - panY();
const originX = e.clientX;
const originY = e.clientY;
let dragged = false;
const move = (me: PointerEvent) => {
if (Math.abs(me.clientX - originX) + Math.abs(me.clientY - originY) > 4) dragged = true;
if (!dragged) return;
me.preventDefault();
setPanX(me.clientX - startX);
setPanY(me.clientY - startY);
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
if (dragged) {
// 드래그 직후의 click(=재생/정지 토글)을 한 번만 취소
const swallow = (ce: MouseEvent) => {
ce.stopPropagation();
ce.preventDefault();
};
window.addEventListener("click", swallow, { capture: true, once: true });
}
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
const OBJECT_FIT: Record<FitMode, "contain" | "cover" | "none"> = {
fit: "contain",
fill: "cover",
none: "none",
};
/** object-fit은 모드가, transform은 확대/이동이 담당한다 */
const videoStyle = () => ({
"object-fit": OBJECT_FIT[fit()],
transform: `translate(${panX()}px, ${panY()}px) scale(${zoom()})`,
transition: zoom() === 1 ? "transform 0.15s ease-out" : "none",
});
// 파일이 바뀌면 확대 상태 초기화 (모드는 유지 — 사용자가 고른 설정이다)
createEffect(() => {
props.fileId;
resetView();
});
const toggleFullscreen = () => {
if (!containerRef) return;
if (document.fullscreenElement) void document.exitFullscreen();
@@ -217,15 +538,27 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
setControlsShown(true);
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
if (playing()) setControlsShown(false);
// 편집 중에는 숨기지 않는다 — 필름스트립과 손잡이가 사라지면 작업 자체가 끊긴다.
// (구간 미리보기로 재생 중일 때가 특히 그렇다: 보고 있는 동안 도구가 없어진다)
if (playing() && !trimMode()) setControlsShown(false);
}, 2600);
};
/**
* .
*
* '감상 중' .
* .
*/
const barVisible = () => controlsShown() || trimMode();
// ── 키보드 단축키 (플레이어가 마운트된 동안) ──
const onKey = (e: KeyboardEvent) => {
// 입력 요소에 포커스면 무시
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
// 위에 모달이 떠 있으면 재생 키를 먹지 않는다
if (moveDialogOpen()) return;
let handled = true;
switch (e.key) {
case " ":
@@ -236,12 +569,16 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
case "ArrowLeft":
case "j":
case "J":
skip(e.key === "ArrowLeft" ? -5 : -10);
// 구간 자르기 중이면 방향키는 **고른 손잡이를 한 프레임씩** 움직인다.
// 그때 5초씩 건너뛰면 정밀 조정이 불가능하다.
if (trimMode() && e.key === "ArrowLeft") nudge(-1);
else skip(e.key === "ArrowLeft" ? -5 : -10);
break;
case "ArrowRight":
case "l":
case "L":
skip(e.key === "ArrowRight" ? 5 : 10);
if (trimMode() && e.key === "ArrowRight") nudge(1);
else skip(e.key === "ArrowRight" ? 5 : 10);
break;
case "ArrowUp":
changeVolume(volume() + 0.05);
@@ -257,6 +594,22 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
case "F":
toggleFullscreen();
break;
// 화면 크기: 모드 전환 / 확대 / 초기화
case "w":
case "W":
cycleFit();
break;
case "+":
case "=":
zoomAt(1.25, 0, 0);
break;
case "-":
case "_":
zoomAt(1 / 1.25, 0, 0);
break;
case "0":
resetView();
break;
case "PageUp":
navLightbox(-1);
break;
@@ -289,6 +642,10 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
if (seekingTo() == null) {
setCurrent(seekBase() + (videoRef?.currentTime ?? 0));
}
// 구간 미리보기 — 끝에 닿으면 시작으로 되돌린다. 적용 전에 결과를 반복해 확인할 수 있다.
if (rangePreview() && current() >= trimEndOr() - 0.05) {
seekTo(trimStart());
}
// 버퍼 끝(직접 모드)
if (videoRef && videoRef.buffered.length > 0 && !isStream()) {
setBuffered(videoRef.buffered.end(videoRef.buffered.length - 1));
@@ -305,8 +662,9 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
return (
<div
ref={containerRef}
class="relative flex h-full w-full items-center justify-center bg-black"
classList={{ "cursor-none": !controlsShown() }}
class="media-surface relative flex h-full w-full items-center justify-center overflow-hidden bg-black"
classList={{ "cursor-none": !barVisible() }}
onWheel={onWheel}
onMouseMove={showControls}
onMouseLeave={() => playing() && setControlsShown(false)}
>
@@ -321,7 +679,12 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
<video
ref={videoRef}
src={src()}
class="max-h-full max-w-full outline-none"
// 컨테이너를 가득 쓰고 비율은 object-fit이 처리한다.
// (max-*만 쓰면 작은 영상이 화면 한가운데 원본 크기로 조그맣게 나온다)
class="h-full w-full outline-none"
classList={{ "cursor-grab": zoom() > 1 }}
style={videoStyle()}
onPointerDown={onVideoPointerDown}
autoplay
playsinline
onTimeUpdate={onTimeUpdate}
@@ -361,7 +724,7 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
class="pointer-events-auto absolute grid h-16 w-16 place-items-center rounded-full bg-black/50 text-3xl text-white backdrop-blur transition hover:bg-black/70"
onClick={togglePlay}
>
<IconPlay size={30} />
</button>
</Show>
@@ -370,64 +733,193 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
<button
class="absolute left-2 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-full bg-black/40 text-xl text-white/80 hover:bg-black/60 hover:text-white"
onClick={() => navLightbox(-1)}
title="이전 (PageUp)"
title="이전 항목 (P / PageUp / Ctrl+←)"
>
<IconChevronLeft size={22} />
</button>
<button
class="absolute right-2 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-full bg-black/40 text-xl text-white/80 hover:bg-black/60 hover:text-white"
onClick={() => navLightbox(1)}
title="다음 (PageDown)"
title="다음 항목 (N / PageDown / Ctrl+→)"
>
<IconChevronRight size={22} />
</button>
</Show>
{/* 하단 컨트롤바 */}
<div
class="absolute inset-x-0 bottom-0 flex flex-col gap-1 bg-gradient-to-t from-black/80 to-transparent px-3 pb-2 pt-6 transition-opacity duration-200"
classList={{ "opacity-0": !controlsShown(), "opacity-100": controlsShown() }}
classList={{ "opacity-0": !barVisible(), "opacity-100": barVisible() }}
onClick={(e) => e.stopPropagation()}
>
{/* 시크바 */}
{/* 구간 자르기 줄 — 재생바의 손잡이를 끌어 잡고, 여기서 미세 조정한다. */}
<Show when={trimMode()}>
<div class="mb-1 flex items-center gap-2 rounded bg-black/60 px-2 py-1 text-[11px] text-white">
<TimeField
label={ko.edit.trimStart}
value={trimStart()}
active={activeHandle() === "start"}
fmt={fmtPrecise}
onFocus={() => setActiveHandle("start")}
onCommit={(t) => moveHandle("start", t)}
/>
<TimeField
label={ko.edit.trimEnd}
value={trimEndOr()}
active={activeHandle() === "end"}
fmt={fmtPrecise}
onFocus={() => setActiveHandle("end")}
onCommit={(t) => moveHandle("end", t)}
/>
{/* 프레임 단위 미세 조정 — 재생바 클릭으로는 초 단위도 거칠다 */}
<div class="flex items-center gap-0.5">
<button
class="rounded border border-white/30 px-1.5 py-0.5 hover:bg-white/15"
onClick={() => nudge(-1)}
title={ko.edit.nudgeBack}
>
</button>
<button
class="rounded border border-white/30 px-1.5 py-0.5 hover:bg-white/15"
onClick={() => nudge(1)}
title={ko.edit.nudgeFwd}
>
</button>
</div>
<button
class="rounded border px-1.5 py-0.5"
classList={{
"border-[var(--accent)] bg-[var(--accent)]": rangePreview(),
"border-white/30 hover:bg-white/15": !rangePreview(),
}}
onClick={toggleRangePreview}
title={ko.edit.rangePreviewHint}
>
{rangePreview() ? ko.edit.rangePreviewStop : ko.edit.rangePreview}
</button>
<span class="tabular-nums text-white/70">
{ko.edit.trimLength((trimEndOr() - trimStart()) || 0)}
</span>
<button
class="ml-auto rounded bg-[var(--accent)] px-2 py-0.5 disabled:opacity-40"
disabled={trimBusy() || trimEndOr() - trimStart() < 0.5}
onClick={() => void applyTrim()}
>
{trimBusy() ? ko.edit.cropping : ko.edit.trimApply}
</button>
</div>
</Show>
<div
ref={barRef}
class="group relative h-4 cursor-pointer"
class="group relative cursor-pointer transition-[height]"
classList={{ "h-4": !trimMode(), "h-14": trimMode() }}
onPointerDown={onBarPointerDown}
>
<div class="absolute top-1/2 h-1 w-full -translate-y-1/2 rounded bg-white/25" />
{/* .
( ). */}
<Show when={trimMode()}>
<div class="absolute inset-0 flex overflow-hidden rounded bg-black/40">
<For each={strip() ?? []}>
{(f) => (
<img
src={f.url}
class="h-full min-w-0 flex-1 object-cover"
draggable={false}
alt=""
/>
)}
</For>
</div>
</Show>
<div
class="absolute top-1/2 w-full -translate-y-1/2 rounded bg-white/25"
classList={{ "h-1": !trimMode(), "h-0": trimMode() }}
/>
<Show when={!isStream()}>
<div
class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-white/40"
style={{ width: `${bufferedPct()}%` }}
/>
</Show>
<div
class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-[var(--accent)]"
style={{ width: `${progressPct()}%` }}
/>
<div
class="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 shadow transition-opacity group-hover:opacity-100"
style={{ left: `${progressPct()}%` }}
/>
{/* 자르기 중에는 진행 채움을 숨긴다 — 필름스트립을 가리면 장면이 안 보인다 */}
<Show when={!trimMode()}>
<div
class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-[var(--accent)]"
style={{ width: `${progressPct()}%` }}
/>
</Show>
{/* , .
. */}
<Show when={trimMode() && durationSec() > 0}>
<div
class="pointer-events-none absolute inset-y-0 left-0 rounded-l bg-black/70"
style={{ width: `${(trimStart() / durationSec()) * 100}%` }}
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 rounded-r bg-black/70"
style={{ left: `${(trimEndOr() / durationSec()) * 100}%` }}
/>
<div
class="pointer-events-none absolute inset-y-0 rounded border-y-2 border-[var(--warn,#f0b429)]"
style={{
left: `${(trimStart() / durationSec()) * 100}%`,
width: `${((trimEndOr() - trimStart()) / durationSec()) * 100}%`,
}}
/>
{/* 양 끝 손잡이 — 띠 높이만큼 세워야 잡기 쉽다 */}
<For each={["start", "end"] as const}>
{(which) => (
<div
class="absolute inset-y-0 z-10 flex w-4 -translate-x-1/2 cursor-ew-resize items-center justify-center rounded bg-[var(--warn,#f0b429)] shadow"
classList={{ "ring-2 ring-white": activeHandle() === which }}
style={{
left: `${((which === "start" ? trimStart() : trimEndOr()) / durationSec()) * 100}%`,
}}
onPointerDown={onHandleDown(which)}
title={which === "start" ? ko.edit.trimStart : ko.edit.trimEnd}
>
<span class="pointer-events-none h-4 w-0.5 rounded bg-black/40" />
</div>
)}
</For>
</Show>
{/* hover , .
. */}
<Show
when={trimMode()}
fallback={
<div
class="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 shadow transition-opacity group-hover:opacity-100"
style={{ left: `${progressPct()}%` }}
/>
}
>
<div
class="pointer-events-none absolute inset-y-0 z-20 w-0.5 -translate-x-1/2 bg-white shadow"
style={{ left: `${progressPct()}%` }}
/>
</Show>
</div>
{/* 버튼 행 */}
<div class="flex items-center gap-2 text-white">
<button class="rounded px-1 text-lg leading-none hover:text-[var(--accent)]" onClick={togglePlay} title="재생/정지 (Space)">
{playing() ? "⏸" : "▶"}
{playing() ? <IconPause size={17} /> : <IconPlay size={17} />}
</button>
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={() => skip(-10)} title="10초 뒤로 (J)">
<IconRewind size={16} />
</button>
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={() => skip(10)} title="10초 앞으로 (L)">
<IconForward size={16} />
</button>
{/* 볼륨 */}
<div class="group/vol flex items-center gap-1">
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={toggleMute} title="음소거 (M)">
{muted() || volume() === 0 ? "🔇" : volume() < 0.5 ? "🔉" : "🔊"}
{muted() || volume() === 0 ? <IconVolumeMute size={16} /> : volume() < 0.5 ? <IconVolumeLow size={16} /> : <IconVolumeHigh size={16} />}
</button>
<input
type="range"
@@ -445,6 +937,36 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
</span>
<div class="ml-auto flex items-center gap-2">
<button
class="rounded px-1 text-sm leading-none"
classList={{
"text-[var(--accent)]": trimMode(),
"text-white hover:text-[var(--accent)]": !trimMode(),
}}
onClick={() => setTrimMode((v) => !v)}
title={`${ko.edit.trim} · ${ko.edit.trimHint}`}
>
<IconScissors size={16} />
</button>
{/* 정리 작업 — 전체화면에서도 재생을 멈추지 않고 쓸 수 있게 컨트롤바에 둔다 */}
<button
class="rounded px-1 text-sm leading-none"
classList={{
"text-[var(--danger)]": marked(),
"text-white hover:text-[var(--accent)]": !marked(),
}}
onClick={() => void toggleMarkDelete([props.fileId])}
title={`${marked() ? ko.trash.unmark : ko.trash.mark} (Delete)`}
>
<IconTrash size={16} />
</button>
<button
class="rounded px-1 text-sm leading-none text-white hover:text-[var(--accent)]"
onClick={openMoveDialog}
title={`${ko.ops.moveTo} (Ctrl+M)`}
>
<IconFolder size={16} />
</button>
<button
class="rounded px-1 text-sm leading-none"
classList={{
@@ -454,7 +976,7 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
onClick={toggleRepeat}
title={repeatMode() ? "반복 켜짐" : "반복 꺼짐 (끝나면 다음 항목)"}
>
🔁
<IconRepeat size={16} />
</button>
<button
class="rounded border border-white/30 px-1.5 py-0.5 text-[10px] leading-none hover:border-[var(--accent)] hover:text-[var(--accent)]"
@@ -463,13 +985,31 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
>
{rateLabel()}
</button>
{/* 화면 크기 모드 — 휠로 확대한 배율도 같이 보여준다 */}
<button
class="rounded border border-white/30 px-1.5 py-0.5 text-[10px] leading-none hover:border-[var(--accent)] hover:text-[var(--accent)]"
onClick={cycleFit}
title="화면 크기 (맞춤 → 채움 → 원본). 휠로 확대, 드래그로 이동, 0으로 초기화"
>
{FIT_LABEL[fit()]}
{zoom() > 1 ? ` ${Math.round(zoom() * 100)}%` : ""}
</button>
<Show when={zoom() > 1}>
<button
class="rounded border border-white/30 px-1.5 py-0.5 text-[10px] leading-none hover:border-[var(--accent)] hover:text-[var(--accent)]"
onClick={resetView}
title="확대 초기화 (0)"
>
<IconResetZoom size={14} />
</button>
</Show>
<Show when={isStream()}>
<span class="rounded bg-white/15 px-1.5 py-0.5 text-[10px] text-white/70">
{(plan() as { video: string }).video === "transcode" ? "변환 재생" : "리먹스 재생"}
</span>
</Show>
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={toggleFullscreen} title="전체화면 (F)">
{fullscreen() ? "🗗" : "⛶"}
{fullscreen() ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
</button>
</div>
</div>
+378 -6
View File
@@ -16,6 +16,96 @@ export const ko = {
`소스 "${name}"를 목록에서 제외할까요?\n\n인덱스·태그·썸네일만 제거되며, 디스크의 실제 파일은 삭제되지 않습니다.`,
removed: (name: string, n: number) =>
`"${name}" 제외됨 (${n.toLocaleString("ko-KR")}개 항목 인덱스에서 제거)`,
allItems: "전체 항목",
collections: "컬렉션",
cleanup: "정리",
// 전각 더하기 글리프는 아이콘이 아니다 — 모양은 IconPlus가 맡고 여기엔 말만 둔다
addSourcePrimary: "폴더 추가",
collapse: "접기",
expand: "펼치기",
},
// 사이드바 하단 라이브러리 통계 카드
stats: {
title: "라이브러리",
modeLine: (mode: string, version: string) => `${mode} · v${version}`,
},
// 사이드바 작업 존 — '지금 무엇이 얼마나'까지만 말한다.
// 자세한 사정(어느 폴더를 읽는 중인지, 데이터 위치 등)은 footer(상태바)가 말한다.
tasks: {
zone: "진행 중",
scan: "폴더 읽는 중",
dedupe: "중복 찾는 중",
/**
* ** ** . '
* ' ,
* "미리보기가 멈췄다" ( ). .
*/
backfillTitle: (thumbs: number, meta: number, sigs: number) => {
const kinds = (thumbs > 0 ? 1 : 0) + (meta > 0 ? 1 : 0) + (sigs > 0 ? 1 : 0);
if (kinds > 1) return "라이브러리 준비 중";
if (thumbs > 0) return "미리보기 만드는 중";
if (meta > 0) return "파일 정보 읽는 중";
return "중복 탐지 지문 만드는 중";
},
/** 백필 부제 — 0인 항목은 말하지 않는다. 지문만 남은 상태에서도 '지문 N'이 반드시 보인다. */
backfillDetail: (thumbs: number, meta: number, sigs: number) => {
const p: string[] = [];
if (thumbs > 0) p.push(`썸네일 ${thumbs.toLocaleString("ko-KR")}`);
if (meta > 0) p.push(`메타 ${meta.toLocaleString("ko-KR")}`);
if (sigs > 0) p.push(`지문 ${sigs.toLocaleString("ko-KR")}`);
return p.join(" · ");
},
/** ffmpeg가 없을 때 — 큐가 조용히 건너뛰므로 대기 수만 보면 멈춘 것과 구별되지 않는다 */
needFfmpegTitle: "영상 처리 불가",
needFfmpeg: "ffmpeg.exe가 프로그램 폴더에 없습니다",
more: (n: number) => `작업 ${n.toLocaleString("ko-KR")}개 더`,
cancel: "이 작업 취소",
/** 열거 단계는 총량을 모른다 — 퍼센트 대신 처리 속도를 말한다 */
rate: (perSec: number) =>
perSec >= 10
? `초당 ${Math.round(perSec).toLocaleString("ko-KR")}`
: `초당 ${perSec.toFixed(1)}`,
/** 백필 처리율 — 남은 수 대신 '몇 개 중 몇 개' */
doneOf: (done: number, total: number) =>
`${done.toLocaleString("ko-KR")} / ${total.toLocaleString("ko-KR")}`,
footerHint: "진행 중인 작업 — 자세한 내용은 창 아래 상태바에 있습니다",
},
// 본문 카드 안 폴더 칩 밴드
browse: {
folders: "폴더",
more: (n: number) => `${n}개 더`,
collapse: "폴더 목록 접기",
expand: "폴더 목록 펼치기",
},
// 본문 카드 상단 브레드크럼 밴드
crumb: {
searchResult: (q: string) => `"${q}" 검색 결과`,
searchScope: "전체 라이브러리에서",
exitSearch: "검색 끝내고 이전 위치로",
},
theme: {
label: "테마",
toLight: "밝은 테마로 전환",
toDark: "어두운 테마로 전환",
},
source: {
reload: "다시 불러오기",
reloadStarted: (name: string) => `"${name}" 다시 불러오는 중…`,
edit: "이름·경로 바꾸기",
editTitle: "소스 수정",
name: "이름",
root: "폴더 경로",
browse: "폴더 선택…",
remove: "소스 제외 (파일은 삭제되지 않음)",
unavailable: "경로에 접근할 수 없습니다 — 연결을 확인하거나 '경로 바꾸기'로 다시 매핑하세요",
unavailableBadge: "연결 없음",
overlap: "다른 소스와 경로가 겹칩니다 — 같은 파일이 두 번 보일 수 있으니 하나를 제외하세요",
overlapBadge: "경로 겹침",
alreadyMapped: (name: string) => `이미 등록된 폴더입니다 — "${name}"를 다시 불러옵니다`,
remapHint: "폴더를 옮겼거나 드라이브 문자가 바뀐 경우, 경로만 바꾸면 태그·썸네일이 그대로 유지됩니다.",
remapDone: (n: number) => `경로 재매핑 완료 (폴더 ${n.toLocaleString("ko-KR")}개) — 다시 확인 중…`,
renameDone: "소스 이름을 바꿨습니다",
remoteNoRoot: "네트워크 소스는 경로를 바꿀 수 없습니다",
},
onboarding: {
title: "Archive에 오신 것을 환영합니다",
@@ -29,6 +119,24 @@ export const ko = {
items: (n: number) => `${n.toLocaleString("ko-KR")}개 항목`,
selected: (n: number) => `${n.toLocaleString("ko-KR")}개 선택됨`,
infoToggle: "썸네일에 파일명·크기 표시",
zoomTitle: "썸네일 크기 (Ctrl+휠)",
},
viewMode: {
label: "보기",
grid: "격자",
list: "리스트",
title: "보기 방식 전환 (V)",
},
list: {
name: "이름",
tags: "태그",
kind: "종류",
size: "크기",
dims: "해상도",
duration: "길이",
taken: "촬영일",
modified: "수정일",
sortHint: (col: string) => `${col} 기준 정렬`,
},
filter: {
all: "전체",
@@ -36,6 +144,7 @@ export const ko = {
video: "영상",
},
sort: {
label: "정렬",
taken: "촬영일",
name: "이름",
size: "크기",
@@ -43,6 +152,22 @@ export const ko = {
asc: "오름차순",
desc: "내림차순",
},
refresh: {
action: "새로고침",
title: "디스크를 다시 읽어 목록을 맞춥니다 (F5)",
started: "새로고침 중… 변경된 항목만 반영됩니다",
busy: "이미 스캔이 진행 중입니다",
purgeMissing: "사라진 파일 정리",
purgeMissingConfirm: (n: number) =>
`디스크에 없는 ${n.toLocaleString("ko-KR")}개를 목록에서 지울까요?
, · .`,
purgeMissingDone: (n: number) => `${n.toLocaleString("ko-KR")}개를 목록에서 정리했습니다`,
purgeMissingNone: "디스크에 없는 파일이 없습니다 — 정리할 것이 없습니다",
/** 대량 삭제 보류 — 사라진 것으로 보이는 파일이 절반을 넘어 지우지 않았다 */
guarded: (n: number) =>
`파일 ${n.toLocaleString("ko-KR")}개가 사라진 것으로 보이지만, 안전을 위해 목록에서 지우지 않았습니다. 드라이브·네트워크 연결을 확인하세요.`,
},
shortcuts: {
title: "키보드 단축키",
close: "닫기 (Esc / ?)",
@@ -50,13 +175,24 @@ export const ko = {
["Space / K", "재생·일시정지 (영상)"],
["← →", "이전·다음 항목 (그리드) / 5초 이동 (영상)"],
["↑ ↓", "위·아래 줄 이동 (그리드) / 볼륨 (영상)"],
["Home / End", "첫·마지막 항목"],
["Enter / Space", "선택 항목 크게 보기"],
["V", "보기 방식 전환 (격자 ↔ 리스트)"],
["N / P", "다음·이전 항목 (보기 중, 영상 포함)"],
["Ctrl+← →", "다음·이전 항목 (영상 재생 중에도)"],
["T", "태그 메뉴 (보기 중) — 검색·새로 만들기, 1~9로 바로 지정"],
["W", "화면 크기 전환 (맞춤 / 채움 / 원본)"],
["휠, + , 0", "확대 (커서 기준) · 초기화 — 확대 후 드래그로 이동"],
["Ctrl+[ / Ctrl+]", "왼쪽·오른쪽 90도 회전 (원본이 바뀜)"],
["Ctrl+M", "폴더로 이동"],
["Delete", "삭제 표시 (파일은 그대로 — 비우기 전까지)"],
["Shift+Delete", "즉시 OS 휴지통으로"],
["Ctrl+A", "전체 선택"],
["Ctrl+Shift+A", "선택 반전"],
["Esc", "선택 해제 / 닫기"],
["Delete", "휴지통으로"],
["Ctrl+Z", "실행 취소"],
["Ctrl+Z", "실행 취소 (삭제 표시 포함)"],
["F2", "이름 바꾸기"],
["F5", "새로고침 (디스크 다시 읽기)"],
["Ctrl+휠", "썸네일 크기"],
["?", "이 도움말"],
] as [string, string][],
@@ -73,6 +209,15 @@ export const ko = {
takenAt: "촬영 일시",
modifiedAt: "수정 일시",
tags: "태그",
tabInfo: "정보",
tabTags: "태그",
close: "상세 패널 닫기",
toggle: "상세 정보 패널",
sectionMedia: "미디어 정보",
sectionBasic: "기본 정보",
kind: "종류",
bulkTag: "태그 편집",
selectedUnit: "개 선택됨", // [C] 다중 선택 타일 — 큰 숫자 아래 라벨
},
statusBar: {
ready: "준비됨",
@@ -82,30 +227,149 @@ export const ko = {
installed: "일반 모드",
thumbPending: (n: number) => `썸네일 생성 중 ${n.toLocaleString("ko-KR")}`,
metaPending: (n: number) => `메타 분석 중 ${n.toLocaleString("ko-KR")}`,
sigPending: (n: number) => `지문 생성 중 ${n.toLocaleString("ko-KR")}`,
sigPendingHint:
"중복 탐지용 지문을 백그라운드에서 만들고 있습니다. 앱을 계속 쓰셔도 되고, 껐다 켜면 이어서 진행됩니다.",
},
search: {
placeholder: "파일명 검색…",
},
common: {
cancel: "취소",
/** 취소를 눌렀지만 아직 멈추지 않은 구간 — 사이드바 값 칸과 모달 헤더가 같은 말을 한다 */
canceling: "취소 중…",
confirm: "확인",
delete: "삭제",
rename: "이름 바꾸기",
move: "이동",
undo: "실행 취소",
create: "만들기",
save: "저장",
close: "닫기",
},
rating: {
label: "별점",
set: (n: number) => `별점 ${n}`,
clearHint: "같은 별을 다시 누르면 해제됩니다",
none: "없음",
/** 툴바 필터 — '이상'이라는 점을 분명히 한다(정확히 N점이 아니다) */
filterAll: "전체",
filterAtLeast: (n: number) => `${n}★ 이상`,
sort: "별점",
/** 여러 항목을 함께 골랐는데 값이 다를 때 */
mixed: "여러 값",
applied: (n: number, stars: number) =>
stars === 0 ? `${n}개 별점 해제` : `${n}개에 별점 ${stars}`,
shortcut: "Ctrl+0~5 로 별점",
},
multi: {
/** 우클릭 메뉴 — 영상을 2개 이상 골랐을 때만 보인다 */
open: (n: number) => `동시 재생 (${n}개)`,
tooMany: (max: number) => `동시 재생은 최대 ${max}개까지입니다 — 앞 ${max}개만 엽니다`,
needTwo: "영상을 2개 이상 고르세요",
title: (n: number) => `동시 재생 · ${n}`,
playAll: "모두 재생",
pauseAll: "모두 정지",
hint: "칸을 누르면 소리가 그 칸으로 · 1~9 키로도 이동 · 두 번 누르면 크게 · Space 재생/정지 · Esc 닫기",
audioHere: "소리",
preparing: "준비 중…",
/** 실시간 변환 상한에 걸린 칸 */
blocked: "변환 필요 — 동시 재생 제외",
blockedNote: (n: number, max: number) =>
`${n}개는 코덱 변환이 필요해 제외했습니다 (동시 변환은 ${max}개까지 — 그 이상은 재생이 끊깁니다)`,
},
edit: {
menu: "회전·반전",
rotateCw: "오른쪽으로 90°",
rotateCcw: "왼쪽으로 90°",
rotate180: "180° 뒤집기",
flipH: "좌우 반전",
flipV: "상하 반전",
/** 원본을 고친다는 걸 메뉴에서부터 알린다 */
hint: "원본이 바뀝니다 · Ctrl+Z로 되돌리기",
working: (done: number, total: number) => `편집 중 ${done}/${total}`,
done: (n: number) => `${n}개 편집 완료`,
/** 재인코딩이 섞였을 때 — 화질 손실이 있었다는 뜻이라 조용히 넘기면 안 된다 */
doneReencoded: (n: number, re: number) =>
`${n}개 완료 (${re}개는 다시 인코딩 — 영상 반전은 무손실이 안 됩니다)`,
doneWithFail: (n: number, f: number) => `${n}개 완료 · ${f}개 실패`,
lossless: "화질 손실 없음",
selectFirst: "먼저 항목을 고르세요",
shortcut: "Ctrl+[ 왼쪽 · Ctrl+] 오른쪽 회전",
crop: "자르기",
cropHint: "남길 부분을 드래그하세요 (Enter 적용 · Esc 취소)",
cropNone: "영역을 고르세요",
cropApply: "잘라내기",
cropping: "자르는 중…",
/** 자르기는 역연산이 없다 — 원본을 옮겨 보관한다는 걸 분명히 말한다 */
cropWarn: "잘라낸 부분은 되살릴 수 없습니다. 원본은 보관되며 Ctrl+Z로 되돌아옵니다.",
cropDone: (w: number, h: number) => `${w}×${h}로 잘랐습니다`,
trim: "구간 자르기",
trimStart: "시작",
trimEnd: "끝",
trimSetStart: "여기를 시작으로",
trimSetEnd: "여기를 끝으로",
trimApply: "이 구간만 남기기",
trimHint: "남길 구간만 두고 나머지는 잘라냅니다 — 화질 손실 없음",
trimLength: (s: number) => `${s.toFixed(1)}`,
/** fps를 모르므로 1/30초 고정 — "한 프레임"이라 적으면 24·60fps 영상에서 거짓말이 된다 */
nudgeBack: "0.03초 뒤로 (←) · 정확한 값은 왼쪽 칸에 직접 입력",
nudgeFwd: "0.03초 앞으로 (→) · 정확한 값은 왼쪽 칸에 직접 입력",
rangePreview: "구간 재생",
rangePreviewStop: "정지",
rangePreviewHint: "고른 구간만 반복 재생해 확인합니다",
trimDone: (secs: number) => `${secs.toFixed(1)}초 구간만 남겼습니다`,
trimTooShort: "구간이 너무 짧습니다",
imageOnly: "사진에만 쓸 수 있습니다",
videoOnly: "영상에만 쓸 수 있습니다",
},
tags: {
add: "태그 추가…",
newPlaceholder: "새 태그 이름",
searchOrCreate: "태그 검색 · 새 태그 이름",
createAssign: (name: string) => `"${name}" 새로 만들어 지정`,
createdAndAssigned: (name: string) => `"${name}" 태그를 만들어 지정했습니다`,
emptyAll: "태그가 없습니다 — 이름을 입력해 새로 만드세요",
pickerHint: "↑↓ 이동 · Enter 지정 · 1~9 빠른 지정 · Esc 닫기",
assignTo: (n: number, tag: string) => `${n}개 항목에 "${tag}" 태그 추가됨`,
removedFrom: (n: number, tag: string) => `${n}개 항목에서 "${tag}" 태그 제거됨`,
deleteConfirm: (tag: string) => `태그 "${tag}"를 삭제할까요? (파일은 삭제되지 않음)`,
exported: (path: string) => `태그를 내보냈습니다: ${path}`,
exportAction: "태그 JSON 내보내기",
// ── 클릭으로 붙이기 ────────────────────────────────────
// 매번 이름을 쳐서 찾는 대신 목록을 눌러 붙인다. 입력창은 '좁히기 + 새로 만들기'다.
noneYet: "붙은 태그 없음",
pick: "태그 고르기",
filterOrCreate: "좁히기 · 새 태그는 이름 입력 후 Enter",
noMatch: "일치하는 태그 없음",
countHint: (n: number) => `${n.toLocaleString("ko-KR")}개 항목에 사용 중`,
// ── 세트 ───────────────────────────────────────────────
sets: "태그 세트",
setHint: (names: string) => `한 번에 붙이기: ${names} (우클릭으로 편집)`,
setApplied: (name: string, n: number) => `"${name}" 세트 적용 — ${n}개 추가됨`,
newSet: "+ 세트",
editSet: "세트 편집",
setName: "세트 이름",
setNamePlaceholder: "예: 여행 정리",
setMembers: (n: number) => `포함 태그 ${n}`,
setHotkey: "단축키",
noHotkey: "없음",
pressKey: "키를 누르세요…",
clearHotkey: "해제",
hotkeyNote:
"항목을 선택한 상태에서 누르면 적용됩니다. 입력창에 커서가 있을 때는 동작하지 않습니다.",
// ── 이름 수정 ──────────────────────────────────────────
// file_tags가 tag_id로 연결돼 있어 이름만 바꾸면 붙어 있던 항목에 그대로 반영된다.
rename: "이름 수정",
renamePrompt: (old: string) => `"${old}"의 새 이름`,
renamed: (from: string, to: string) => `"${from}" → "${to}" (붙어 있던 항목에 모두 반영됨)`,
renameNote: "이미 이 태그가 붙어 있는 항목들도 함께 바뀝니다. 태그를 지웠다 새로 만들면 그 연결이 사라집니다.",
},
ops: {
trash: "휴지통으로 보내기",
moveTo: "폴더로 이동…",
moveDone: (n: number) => `${n}개 이동 완료`,
moveFailed: (n: number) => `${n}개 이동 실패`,
@@ -116,6 +380,24 @@ export const ko = {
nothingToUndo: "되돌릴 작업이 없습니다",
inProgress: (done: number, total: number) => `처리 중 ${done}/${total}`,
},
trash: {
tagName: "삭제 예정",
mark: "삭제 표시",
unmark: "삭제 표시 해제",
marked: (n: number) => `${n}개 항목에 삭제 표시`,
unmarked: (n: number) => `${n}개 항목의 삭제 표시 해제`,
badge: "삭제 예정",
empty: "비우기",
emptyTitle: "삭제 예정 항목 비우기",
emptyDanger: "디스크에서 영구 삭제 (복구 불가)",
emptyNothing: "삭제 예정 항목이 없습니다",
emptyConfirm: (n: number, size: string) =>
`삭제 예정 ${n.toLocaleString("ko-KR")}개(${size})를 디스크에서 영구 삭제합니다.\n\n` +
`휴지통을 거치지 않으므로 되돌릴 수 없습니다. 계속할까요?`,
emptyDone: (n: number) => `${n.toLocaleString("ko-KR")}개 영구 삭제됨`,
emptyPartial: (done: number, skipped: number, failed: number) =>
`${done}개 삭제, ${skipped}개 이미 없음, ${failed}개 실패`,
},
smart: {
create: "스마트 폴더 만들기",
name: "이름",
@@ -155,18 +437,108 @@ export const ko = {
return `${labels[phase] ?? phase} ${cur.toLocaleString("ko-KR")}/${total.toLocaleString("ko-KR")}`;
},
threshold: "유사도 임계값",
/** 지문 생성 속도 — 영상은 ffmpeg 한 프로세스가 코어 하나를 쓰므로 동시 실행 수가 곧 속도다 */
speed: "지문 속도",
speedSlow: "느리게",
speedNormal: "보통",
speedFast: "빠르게",
speedHint:
"중복 탐지용 지문을 만드는 속도입니다. '빠르게'는 코어를 더 쓰므로 다른 작업이 느려질 수 있습니다. 바꾸면 바로 반영됩니다.",
includeVideo: "영상 포함",
/** 회전·반전본 — 후보 생성이 몇 배 비싸므로 기본은 꺼둔다 */
includeRotated: "회전·반전본",
includeRotatedHint:
"90°·180°·좌우반전 사본도 찾습니다. 변형 8종을 따로 조회하므로 시간이 몇 배 걸립니다.",
scopeLabel: "범위:",
scopeFolder: "현재 폴더 (하위 포함)",
scopeSource: "현재 소스",
scopeAll: "전체 라이브러리",
empty: "중복 항목이 없습니다. 상단의 '중복 탐지 시작'을 눌러 검사하세요.",
groupCount: (n: number) => `${n}개 그룹`,
/** 탐지 중 밴드 — 시작 시점에 캡처한 범위를 말한다. 뷰가 바뀌어도 이 말은 바뀌지 않는다 */
scopeRunning: (label: string) => `${label} 기준으로 탐지 중`,
/** 결과 밴드 — 지금 보이는 그룹이 어떤 범위의 결과인지 */
scopeResult: (label: string) => `${label} 결과`,
rescan: "다시 탐지",
empty: "중복 항목이 없습니다. 위의 '중복 탐지 시작'을 눌러 검사하세요.",
groupCount: (n: number) => `${n.toLocaleString("ko-KR")}개 그룹`,
memberCount: (n: number) => `${n}개 항목`,
/** 그룹 종류 (0 완전 동일 · 1 유사 이미지 · 2 유사 영상) */
kindLabel: (kind: number) =>
kind === 0 ? "완전 동일" : kind === 1 ? "유사 이미지" : "유사 영상",
/** 해밍 거리 — 작을수록 닮았다 */
distance: (n: number) => `거리 ${n}`,
/**
* . ,
* "왜 안 잡히지" .
*/
sigPending: (n: number) =>
`지문 준비 중 ${n.toLocaleString("ko-KR")}개 — 이 항목들은 아직 유사 탐지에 포함되지 않습니다`,
moreGroups: (n: number) => `${n.toLocaleString("ko-KR")}개 그룹 더 보기`,
done: "중복 탐지 완료",
canceled: "중복 탐지 취소됨",
keeper: "보관",
keepBest: "최적 보관 (나머지 휴지통)",
/** 배지가 곧 선택 손잡이다 — 지금 상태와 누르면 무슨 일이 나는지를 각각 말한다 */
keeperCurrent: "이 항목을 보관합니다 (나머지가 삭제 대상)",
keeperPick: "이 항목을 보관하도록 바꾸기",
/**
* '최적' .
* ** ** '삭제 예정' ,
* '정리 → 비우기' . '휴지통' .
*/
keepBest: "보관 항목만 남기기 (나머지 삭제 예정)",
keepBestHint:
"나머지를 '삭제 예정'으로 표시합니다. 파일은 아직 지워지지 않으며, 사이드바 '정리'에서 확인 후 비우면 삭제됩니다.",
keepAll: "모두 보관 (무시)",
trashLosersConfirm: (n: number) => `보관 항목을 제외한 ${n}개를 휴지통으로 보낼까요?`,
/** 표시된 실제 개수를 말한다 — 요청 수가 아니라 처리된 수 */
markedForDelete: (n: number) => `${n}개를 '삭제 예정'으로 표시했습니다`,
/** 그룹 카드에서 비교 뷰를 여는 버튼 */
compare: "비교",
compareHint: "보관 항목과 나란히 놓고 비교합니다",
/**
* "그냥 정리해도 되나 / 눈으로 봐야 하나" .
* (··) .
*/
tierCertain: "확실",
tierSuspect: "의심",
tierCertainWhy: "바이트 동일이거나 근거가 뚜렷합니다 — 바로 정리해도 됩니다",
tierSuspectWhy: "근거가 약합니다 — 비교로 확인한 뒤 결정하세요",
/** 목록 머리 요약 — 확실한 것부터 몰아서 처리할 수 있게 개수를 먼저 말한다 */
tierSummary: (certain: number, suspect: number) =>
`확실 ${certain.toLocaleString("ko-KR")} · 의심 ${suspect.toLocaleString("ko-KR")}`,
},
// 중복 비교 — 두 파일을 나란히 놓고 같은 내용이 같은 시각에 보이도록 맞춰 재생한다.
compare: {
title: "나란히 비교",
pick: "비교 대상:",
/** 보기 전환 — 1:1은 크게, 전체는 한눈에 */
modePair: "1:1",
modeAll: "전체 동시",
modeAllHint: (n: number) =>
`${n}개를 한 화면에서 동시에 재생합니다. 영상이 ${n}개 동시에 돌아가므로 느려질 수 있습니다.`,
allCount: (n: number) => `${n}개 동시 재생 — 각 제목이 자기 맞춤값을 표시합니다`,
play: "재생 (Space)",
pause: "일시정지 (Space)",
back: (n: number) => `${n}초 뒤로 (←)`,
fwd: (n: number) => `${n}초 앞으로 (→)`,
/** 어느 쪽 소리를 들을지 — 두 소리를 같이 내면 비교가 안 된다 */
audioOn: "이 쪽 소리를 듣는 중 — 누르면 전체 음소거 (M으로 전환)",
audioOff: "이 쪽 소리 듣기 (M)",
/** 화면만 보고 싶을 때. 같은 소리가 반복되면 오히려 방해가 된다 */
muteAll: "전체 음소거 (Shift+M)",
unmuteAll: "음소거 해제 — 마지막에 듣던 쪽 (Shift+M)",
aligned: "같은 지점에서 시작",
later: (sec: number) => `이 사본이 ${sec.toFixed(1)}초 늦게 시작`,
earlier: (sec: number) => `이 사본이 ${sec.toFixed(1)}초 빠르게 시작`,
/** 정렬을 직접 확인하지 못한 멤버 — 맞춰진 척하지 않는다 */
noAlignment: "맞춤 정보 없음 — 둘 다 0초부터 재생합니다",
segment: (from: string, to: string) => `일치 구간 ${from}${to}`,
toSegment: "일치 구간으로",
outOfRange: "이 지점은 상대 파일에 없습니다",
/** 패널 시간축의 짧은 표기 — 자리가 좁아 문장을 못 쓴다 */
outOfRangeShort: "—",
zoomHint: "휠로 확대 · 드래그로 이동 (0으로 초기화)",
reset: "확대 초기화 (0)",
/** 검토 중 "이 파일이 어디 있지"에 답한다 — 그 폴더로 이동하고 모달을 닫는다 */
reveal: "이 파일이 있는 폴더 열기",
},
} as const;
+213 -4
View File
@@ -10,6 +10,8 @@ export interface AppInfo {
portable: boolean;
mediaPort: number;
mediaToken: string;
/** ffmpeg 사이드카 유무 — 없으면 영상은 썸네일도 지문도 만들 수 없다 */
hasFfmpeg: boolean;
}
export interface Source {
@@ -38,6 +40,8 @@ export interface FileDetails {
durationMs: number | null;
kind: number;
takenAt: number | null;
/** 0=없음 ~ 5 */
rating: number;
}
export interface ScanProgress {
@@ -52,15 +56,60 @@ export const appInfo = () => invoke<AppInfo>("app_info");
export interface PendingCounts {
thumbs: number;
meta: number;
/** 중복 탐지용 지문 생성 대기 수 */
sigs: number;
/**
* '가장 많이 밀려 있던 순간' . DB에
* ( 0% ).
*/
baseline: number;
}
export const pendingCounts = () => invoke<PendingCounts>("pending_counts");
export const listSources = () => invoke<Source[]>("list_sources");
/** add_local_source 결과 — existing이면 이미 등록된 소스를 그대로 돌려받은 것 */
export interface AddSourceResult {
source: Source;
existing: boolean;
}
export const addLocalSource = (path: string) =>
invoke<Source>("add_local_source", { path });
invoke<AddSourceResult>("add_local_source", { path });
/** 소스를 인덱스에서 제외 (실제 파일은 삭제하지 않음). 반환: 제거된 항목 수 */
export const removeSource = (sourceId: number) =>
invoke<number>("remove_source", { sourceId });
/** 소스 경로 점검 — 접근 가능 여부와 다른 소스와의 경로 겹침 */
export interface SourceStatus {
sourceId: number;
available: boolean;
overlapsWith: number | null;
}
export const sourceStatus = () => invoke<SourceStatus[]>("source_status");
/**
* · . ·
* . 반환: 경로가 .
*/
export const updateSource = (sourceId: number, patch: { name?: string; root?: string }) =>
invoke<number>("update_source", {
sourceId,
name: patch.name ?? null,
root: patch.root ?? null,
});
/** 소스 하나만 다시 불러오기(증분 재스캔). 경로에 접근 못 하면 오류로 알린다. */
export const reloadSource = (sourceId: number) =>
invoke<void>("reload_source", { sourceId });
/**
* .
* , .
*/
export const purgeMissing = (sourceId: number) =>
invoke<number>("purge_missing", { sourceId });
export const cancelScan = () => invoke<void>("cancel_scan");
/** 수동 새로고침 — 로컬 소스 전체 증분 재스캔 (워처가 못 미치는 SMB/NAS용) */
export const rescanAll = () => invoke<void>("rescan_all");
export const folderTree = (sourceId: number) =>
invoke<FolderNode[]>("folder_tree", { sourceId });
export const fileDetails = (ids: number[]) =>
@@ -113,6 +162,13 @@ export class Snapshot {
thumbFailed(i: number): boolean {
return this.ok(i) && (this.view.getUint8(i * ITEM_BYTES + 9) & 2) !== 0;
}
/**
* 0~5. flags ** (2~4)**
* 100 10MB .
*/
rating(i: number): number {
return this.ok(i) ? (this.view.getUint8(i * ITEM_BYTES + 9) >> 2) & 0b111 : 0;
}
/** 레이아웃 워커 입력용 종횡비 배열 (미지 치수는 1:1) */
aspectRatios(): Float32Array {
const out = new Float32Array(this.count);
@@ -125,18 +181,21 @@ export class Snapshot {
}
}
/** 정렬 키: taken(촬영/기본) | name | size | mtime */
export type SortKey = "taken" | "name" | "size" | "mtime";
/** 스냅샷 공통 옵션 — 정렬/종류 필터 */
/** 정렬 키: taken(촬영/기본) | name | size | mtime | rating */
export type SortKey = "taken" | "name" | "size" | "mtime" | "rating";
/** 스냅샷 공통 옵션 — 정렬/종류/별점 필터 */
export interface SnapOpts {
sortKey?: SortKey;
sortDesc?: boolean;
kind?: number | null; // 0=사진, 1=영상, null=전체
/** 이 별점 **이상**만 (1~5). null=전체 */
minRating?: number | null;
}
const snapArgs = (o?: SnapOpts) => ({
sortKey: o?.sortKey ?? null,
sortDesc: o?.sortDesc ?? null,
kind: o?.kind ?? null,
minRating: o?.minRating ?? null,
});
export async function folderSnapshot(
@@ -181,6 +240,50 @@ export const assignTags = (fileIds: number[], tagId: number) =>
export const unassignTags = (fileIds: number[], tagId: number) =>
invoke<TagOpResult>("unassign_tags", { fileIds, tagId });
export const fileTagsOf = (fileId: number) => invoke<Tag[]>("file_tags_of", { fileId });
/** 별점 0(없음)~5. 반환값은 실제로 바뀐 행 수. */
export const setRating = (fileIds: number[], rating: number) =>
invoke<number>("set_rating", { fileIds, rating });
/**
* . **** ,
* .
*/
export interface TagSet {
id: number;
name: string;
/** 예: "1", "Ctrl+2". 없으면 클릭으로만 쓴다. 한 키는 한 세트에만 붙는다. */
hotkey: string | null;
tags: Tag[];
}
export const listTagSets = () => invoke<TagSet[]>("list_tag_sets");
export const createTagSet = (name: string, tagIds: number[]) =>
invoke<number>("create_tag_set", { name, tagIds });
/** 보내지 않은 항목은 그대로 둔다. hotkey는 3상태: 생략=유지 / null=해제 / 문자열=지정 */
export const updateTagSet = (
setId: number,
patch: { name?: string; hotkey?: string | null; tagIds?: number[] },
) =>
invoke<void>("update_tag_set", {
setId,
name: patch.name ?? null,
// Rust 쪽이 Option<Option<String>>이라, 키가 없으면 '유지'가 되도록 undefined를 넘긴다
hotkey: "hotkey" in patch ? patch.hotkey : undefined,
tagIds: patch.tagIds ?? null,
});
export const deleteTagSet = (setId: number) => invoke<void>("delete_tag_set", { setId });
export const applyTagSet = (fileIds: number[], setId: number) =>
invoke<TagOpResult>("apply_tag_set", { fileIds, setId });
/** 파일-태그 연결 (그리드 오버레이용 배치 조회) */
export interface FileTagLink {
fileId: number;
tagId: number;
name: string;
color: string | null;
}
export const fileTagsBulk = (fileIds: number[]) =>
invoke<FileTagLink[]>("file_tags_bulk", { fileIds });
export const exportTags = (path?: string) =>
invoke<string>("export_tags", { path: path ?? null });
@@ -188,6 +291,28 @@ export async function tagSnapshot(tagId: number, opts?: SnapOpts): Promise<Snaps
return new Snapshot(await invoke<ArrayBuffer>("tag_snapshot", { tagId, ...snapArgs(opts) }));
}
// ── 삭제 대기열 ('삭제 예정' 태그 → 비우기) ─────────────
export interface PendingDelete {
count: number;
bytes: number;
}
/** 시스템 '삭제 예정' 태그 id (없으면 생성됨) */
export const trashTagId = () => invoke<number>("trash_tag_id");
export const markDelete = (fileIds: number[]) =>
invoke<TagOpResult>("mark_delete", { fileIds });
export const unmarkDelete = (fileIds: number[]) =>
invoke<TagOpResult>("unmark_delete", { fileIds });
export const pendingDeleteStats = () => invoke<PendingDelete>("pending_delete_stats");
/** 비우기 — 삭제 예정 파일을 디스크에서 영구 삭제한다. 복구 불가. */
export function emptyDeleted(onProgress?: (p: OpProgress) => void): Promise<OpSummary> {
const ch = new Channel<OpProgress>();
ch.onmessage = onProgress ?? (() => {});
return invoke<OpSummary>("empty_deleted", { onProgress: ch });
}
// ── 파일 작업 ───────────────────────────────────────────
export interface OpSummary {
@@ -286,6 +411,9 @@ export interface DupeMember {
fileId: number;
name: string;
dir: string;
/** 이 파일이 있는 폴더 — 검토 중 그 위치로 바로 이동하는 데 쓴다 */
folderId: number;
sourceId: number;
size: number;
width: number | null;
height: number | null;
@@ -293,11 +421,22 @@ export interface DupeMember {
kind: number;
isKeeper: boolean;
distance: number | null;
/**
* `keeper 시각 - 이 멤버 시각`(ms). :
* `member.currentTime = keeper.currentTime - offsetMs/1000`.
* null이면 ( ) 0 .
*/
offsetMs: number | null;
/** keeper 시간축에서 일치가 확인된 구간 */
segStartMs: number | null;
segEndMs: number | null;
}
export interface DupeGroup {
id: number;
kind: number; // 0 exact, 1 image, 2 video
/** 유사도 0~1 (완전 동일은 1.0). 등급(확실/의심)을 여기서 파생한다. */
score: number | null;
members: DupeMember[];
}
@@ -308,6 +447,8 @@ export function startDedupe(
recursive?: boolean;
imageThreshold?: number;
includeVideo?: boolean;
/** 회전·반전본까지 찾는다 — 후보 생성이 몇 배 비싸져 기본은 꺼둔다 */
includeRotated?: boolean;
},
onProgress: (p: DedupeProgress) => void,
): Promise<void> {
@@ -319,14 +460,28 @@ export function startDedupe(
recursive: args.recursive ?? null,
imageThreshold: args.imageThreshold ?? 5,
includeVideo: args.includeVideo ?? false,
includeRotated: args.includeRotated ?? false,
onProgress: ch,
});
}
/** 지문 생성 속도 — 백필 스레드가 배치마다 다시 읽으므로 재시작 없이 반영된다. */
export type SigSpeed = "slow" | "normal" | "fast";
export const sigSpeed = () => invoke<SigSpeed>("sig_speed");
export const setSigSpeed = (speed: SigSpeed) => invoke<void>("set_sig_speed", { speed });
export const cancelDedupe = () => invoke<void>("cancel_dedupe");
export const listDupeGroups = () => invoke<DupeGroup[]>("list_dupe_groups");
export const resolveDupeGroup = (groupId: number) =>
invoke<void>("resolve_dupe_group", { groupId });
/**
* DB에 .
*
* `offsetMs`·`segStartMs`·`segEndMs` .
* , ** **.
*/
export const setDupeKeeper = (groupId: number, fileId: number) =>
invoke<DupeMember[]>("set_dupe_keeper", { groupId, fileId });
export const dismissDupePair = (fileA: number, fileB: number) =>
invoke<void>("dismiss_dupe_pair", { fileA, fileB });
@@ -425,6 +580,19 @@ export function decidePlayback(info: PlaybackInfo): PlaybackPlan {
export const onThumbsReady = (cb: (ids: number[]) => void): Promise<UnlistenFn> =>
listen<number[]>("thumbs-ready", (e) => cb(e.payload));
/**
* .
* payload = .
*/
export interface ScanGuarded {
sourceId: number;
/** 지우지 않은 건수 */
count: number;
}
export const onScanGuarded = (cb: (g: ScanGuarded) => void): Promise<UnlistenFn> =>
listen<ScanGuarded>("scan-guarded", (e) => cb(e.payload));
export const onLibraryChanged = (cb: (sourceId: number) => void): Promise<UnlistenFn> =>
listen<number>("library-changed", (e) => cb(e.payload));
@@ -437,3 +605,44 @@ export function thumbUrl(info: AppInfo, fileId: number, sizeClass: 0 | 1 = 0): s
export function mediaUrl(info: AppInfo, fileId: number): string {
return `http://127.0.0.1:${info.mediaPort}/media/${info.mediaToken}/${fileId}`;
}
// ── 간단 편집 (회전·반전) ─────────────────────────────────
/** 원본을 직접 고친다. 사진(JPEG)·영상 회전은 무손실, 영상 반전만 재인코딩. */
export type EditOp = "rotateCw" | "rotateCcw" | "rotate180" | "flipH" | "flipV";
export interface EditSummary {
batchId: string;
done: number;
failed: number;
errors: string[];
/** 재인코딩된 파일 수 — 0이면 화질 손실이 전혀 없었다 */
reencoded: number;
}
export function editFiles(
fileIds: number[],
op: EditOp,
onProgress?: (p: OpProgress) => void,
): Promise<EditSummary> {
const ch = new Channel<OpProgress>();
if (onProgress) ch.onmessage = onProgress;
return invoke<EditSummary>("edit_files", { fileIds, op, onProgress: ch });
}
/** 되돌릴 수 없는 편집 — 원본은 백업으로 옮겨지고 Ctrl+Z로 돌아온다. */
export type Cut =
| { kind: "crop"; x: number; y: number; w: number; h: number }
| { kind: "trim"; startMs: number; endMs: number };
export const cutFile = (fileId: number, cut: Cut) =>
invoke<EditSummary>("cut_file", { fileId, cut });
/** 구간 자르기 타임라인에 깔 프레임 (데이터 URL — 서버 라우트 없음) */
export interface StripFrame {
atMs: number;
url: string;
}
export const filmstrip = (fileId: number, count: number) =>
invoke<StripFrame[]>("filmstrip", { fileId, count });
+255
View File
@@ -0,0 +1,255 @@
// 본문 카드 상단 경로 밴드 — 현재 위치를 보여주고 상위로 되돌아가는 유일한 경로다.
// 폴더 세그먼트는 드롭 타깃이라 '상위로 되돌리는 이동'이 드래그로도 된다.
// 세그먼트가 4개를 넘으면 [홈 › … › 뒤에서 2개]로 접는다 — 밴드는 절대 두 줄이 되지 않는다.
import {
IconChevronRight,
IconFolder,
IconHome,
IconSearch,
IconSmart,
IconTag,
IconX,
} from "../ui/icons";
import {
createMemo,
createResource,
createSignal,
For,
Show,
type Component,
type JSX,
} from "solid-js";
import { ko } from "../i18n/ko";
import { ancestry, sourceIdOf, sources } from "../state/folderIndex";
import {
exitSearch,
focusItemView,
selected,
setView,
sidebarVersion,
snapshot,
toast,
view,
} from "../state/store";
import { listSmartFolders, listTags } from "../ipc/commands";
import { performMove } from "../features/fileops/MoveDialog";
interface Seg {
icon?: JSX.Element;
label: string;
/** 없으면 클릭 불가 = 현재 위치 */
go?: () => void;
/** 폴더 세그먼트만 드롭 타깃이 된다 */
folderId?: number;
}
/** seg가 없는 노드 = 접힘 표시(…) */
interface Node {
seg?: Seg;
gapTitle?: string;
current: boolean;
}
/**
* ( ).
* `shrink` `.crumb.is-current` `flex:none`
* Tailwind @layer utilities라 @layer components를 .
* flex:none인 `.crumb-tail`(· )
* `overflow:hidden` '선택됨'
* .
* title로 .
*/
const Current: Component<{ label: string; children: JSX.Element }> = (p) => (
<span class="crumb is-current min-w-0 shrink" title={p.label}>
{p.children}
</span>
);
export const Breadcrumb: Component = () => {
// 태그·스마트 폴더는 뷰에 id만 들어 있어 이름을 여기서 직접 조회한다.
const [tags] = createResource(() => sidebarVersion(), listTags);
const [smarts] = createResource(() => sidebarVersion(), listSmartFolders);
const [dropId, setDropId] = createSignal<number | null>(null);
const searchView = () => {
const v = view();
return v.type === "search" ? v : undefined;
};
const segs = createMemo<Seg[]>(() => {
const v = view();
const srcName = (id: number) => sources().find((s) => s.id === id)?.name ?? "…";
const home: Seg = {
icon: <IconHome size={13} />,
label: ko.sidebar.allItems,
go: () => {
void setView({ type: "all" });
focusItemView();
},
};
switch (v.type) {
case "all":
return [{ ...home, go: undefined }];
case "source":
return [home, { icon: <IconFolder size={13} />, label: srcName(v.sourceId) }];
case "folder": {
const srcId = v.sourceId ?? sourceIdOf(v.folderId);
const out: Seg[] = [home];
if (srcId != null)
out.push({
icon: <IconFolder size={13} />,
label: srcName(srcId),
go: () => {
void setView({ type: "source", sourceId: srcId });
focusItemView();
},
});
for (const n of ancestry(v.folderId, srcId))
out.push({
label: n.name,
folderId: n.id,
go: () => {
void setView({ type: "folder", folderId: n.id, recursive: true, sourceId: srcId });
focusItemView();
},
});
return out;
}
case "tag":
return [
home,
{
icon: <IconTag size={13} />,
label: tags.latest?.find((t) => t.id === v.tagId)?.name ?? "…",
},
];
case "smart":
return [
home,
{
icon: <IconSmart size={13} />,
label: smarts.latest?.find((s) => s.id === v.smartId)?.name ?? "…",
},
];
case "search":
return []; // 검색은 아래에서 따로 그린다
}
});
const nodes = createMemo<Node[]>(() => {
const all = segs();
const last = all.length - 1;
const node = (i: number): Node => ({ seg: all[i], current: i === last });
if (all.length <= 4) return all.map((_, i) => node(i));
// 접어도 홈과 마지막 두 단계는 남긴다 — 되돌아갈 발판과 현재 위치는 항상 보인다
return [
node(0),
{ gapTitle: all.map((s) => s.label).join(" "), current: false },
node(last - 1),
node(last),
];
});
const inner = (seg: Seg) => (
<>
<Show when={seg.icon}>{(ic) => <span>{ic()}</span>}</Show>
<span class="min-w-0 truncate">{seg.label}</span>
</>
);
/** 한 세그먼트. 현재 위치는 클릭 불가, 폴더 세그먼트는 드롭 타깃이 된다. */
const CrumbSeg: Component<{ seg: Seg; current: boolean }> = (p) => (
<Show
when={!p.current && p.seg.go}
fallback={<Current label={p.seg.label}>{inner(p.seg)}</Current>}
>
<button
class="crumb"
classList={{ "is-drop": p.seg.folderId != null && dropId() === p.seg.folderId }}
tabindex={-1}
title={p.seg.label}
onClick={() => p.seg.go!()}
onDragOver={(e) => {
const fid = p.seg.folderId;
if (fid == null || !e.dataTransfer?.types.includes("application/x-archive-move")) return;
e.preventDefault();
setDropId(fid);
}}
onDragLeave={() => setDropId(null)}
onDrop={(e) => {
const fid = p.seg.folderId;
if (fid == null) return;
e.preventDefault();
setDropId(null);
void performMove(fid).catch((err) => toast(String(err)));
}}
>
{inner(p.seg)}
</button>
</Show>
);
return (
<div class="crumb-bar">
<Show
when={searchView()}
fallback={
<For each={nodes()}>
{(n, i) => (
<>
<Show when={i() > 0}>
<span class="crumb-sep"><IconChevronRight size={12} /></span>
</Show>
<Show
when={n.seg}
fallback={
<span class="crumb-sep" title={n.gapTitle}>
</span>
}
>
{(seg) => <CrumbSeg seg={seg()} current={n.current} />}
</Show>
</>
)}
</For>
}
>
{(sv) => (
<>
<Current label={sv().query}>
{/* icons.tsx(lucide)
currentColor를 */}
<IconSearch size={13} />
<span class="min-w-0 truncate">{ko.crumb.searchResult(sv().query)}</span>
</Current>
<span class="crumb-sep">·</span>
<span class="crumb-sep">{ko.crumb.searchScope}</span>
<button
class="icon-btn"
tabindex={-1}
title={ko.crumb.exitSearch}
aria-label={ko.crumb.exitSearch}
onClick={() => {
void exitSearch();
focusItemView();
}}
>
<IconX size={12} />
</button>
</>
)}
</Show>
<div class="crumb-tail">
<span>{ko.grid.items(snapshot()?.count ?? 0)}</span>
<Show when={selected().size > 0}>
<span class="chip chip--accent chip--xs">
<span>{ko.grid.selected(selected().size)}</span>
</span>
</Show>
</div>
</div>
);
};
+322 -99
View File
@@ -1,45 +1,83 @@
import { createResource, createSignal, For, onCleanup, Show, type Component } from "solid-js";
import {
createEffect,
createResource,
For,
on,
onCleanup,
onMount,
Show,
type Component,
type JSX,
} from "solid-js";
import {
IconArrowDown,
IconArrowUp,
IconGrid,
IconInfo,
IconList,
IconMoon,
IconPanelRight,
IconRefresh,
IconSearch,
IconSun,
IconZoom,
} from "../ui/icons";
import { open } from "@tauri-apps/plugin-dialog";
import { ko } from "../i18n/ko";
import { JustifiedGrid } from "../features/grid/JustifiedGrid";
import { ListView } from "../features/browse/ListView";
import { FolderChips } from "../features/browse/FolderChips";
import { Breadcrumb } from "./Breadcrumb";
import {
GRID_ROW_MAX,
GRID_ROW_MIN,
clearScanProgress,
detailsOpen,
focusItemView,
gridRowHeight,
kindFilter,
refreshSidebar,
refreshSnapshot,
runSearch,
searchQuery,
setGridRowHeight,
setKindFilter,
setMinRating,
minRating,
setSort,
setView,
setViewMode,
showInfo,
sidebarVersion,
snapshot,
sortDesc,
sortKey,
theme,
toast,
toggleDetails,
toggleShowInfo,
toggleTheme,
updateScanProgress,
view,
viewMode,
type KindFilter,
type SortKey,
type ViewMode,
} from "../state/store";
import { addLocalSource, listSources, startScan } from "../ipc/commands";
import { addLocalSource, listSources, reloadSource, rescanAll, startScan } from "../ipc/commands";
import { openRemoteDialog } from "../features/sources/RemoteDialog";
export const ContentArea: Component = () => {
const [q, setQ] = createSignal("");
// 검색어는 store가 소유한다 — 폴더로 이동하면 자동으로 비워지고,
// ✕로 검색을 끝내면 검색 직전 위치로 돌아간다.
let debounce: ReturnType<typeof setTimeout> | undefined;
const onInput = (value: string) => {
setQ(value);
clearTimeout(debounce);
debounce = setTimeout(() => {
const query = value.trim();
if (query.length > 0) void setView({ type: "search", query });
else if (view().type === "search") void setView({ type: "all" });
}, 250);
debounce = setTimeout(() => void runSearch(value), 250);
};
onCleanup(() => clearTimeout(debounce));
// 타이핑 직후(250ms 안에) 폴더를 클릭하면 대기 중이던 타이머가 뒤늦게 터져
// 방금 연 폴더를 검색 결과로 되돌린다. 뷰가 바뀌면 대기 중인 검색을 버린다.
// (자기 자신이 만든 검색 뷰 전환에서는 이미 실행된 타이머라 clearTimeout이 무해하다)
createEffect(on(view, () => clearTimeout(debounce)));
// 소스가 하나도 없으면 온보딩 표시. 소스 추가/제거(sidebarVersion) 시 재조회해
// 첫 소스를 추가하면 재시작 없이 즉시 그리드로 전환된다.
@@ -49,39 +87,93 @@ export const ContentArea: Component = () => {
);
const isEmpty = () => sources() && sources()!.length === 0;
// 수동 새로고침 — 디스크를 다시 읽는 증분 재스캔.
// 완료 갱신은 백엔드의 library-changed 이벤트가 담당한다.
const doRescan = async () => {
try {
await rescanAll();
toast(ko.refresh.started);
} catch (e) {
toast(String(e));
}
await refreshSnapshot();
};
// F5 — 입력 중이 아닐 때만
onMount(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "F5") return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
e.preventDefault();
void doRescan();
};
window.addEventListener("keydown", onKey);
onCleanup(() => window.removeEventListener("keydown", onKey));
});
const addLocal = async () => {
const dir = await open({ directory: true, multiple: false, title: ko.onboarding.addLocal });
if (typeof dir !== "string") return;
const src = await addLocalSource(dir);
refreshSidebar();
await startScan(src.id, updateScanProgress);
try {
const r = await addLocalSource(dir);
refreshSidebar();
// 이미 등록된 폴더면 다시 불러오기만 — 같은 폴더가 두 번 매핑되지 않게 한다
if (r.existing) {
toast(ko.source.alreadyMapped(r.source.name));
await reloadSource(r.source.id);
} else {
await startScan(r.source.id, updateScanProgress);
}
} catch (e) {
// 사이드바 onAddSource와 같은 이유 — startScan이 던지면 done 진행이 오지 않아
// 마지막 값이 그대로 남는다. 이제 작업 존이 스크롤 밖 고정 영역이라 그 유령 행은
// 스크롤로도 사라지지 않고 영구히 떠 있는다. 온보딩이 첫 소스를 추가하는 경로다.
clearScanProgress();
toast(String(e));
}
};
// min-w-0 — 없으면 그리드의 1fr 열이 툴바 내용 최소폭 아래로 줄지 못해
// 창이 좁을 때 오른쪽 상세 패널을 화면 밖으로 밀어낸다.
// content-col — 툴바 압축(@container toolbar)의 기준 컨테이너.
return (
<main class="flex min-h-0 flex-col bg-[var(--bg-app)]">
<div class="flex h-11 shrink-0 items-center gap-3 border-b border-[var(--border)] px-3">
<input
type="search"
value={q()}
onInput={(e) => onInput(e.currentTarget.value)}
placeholder={ko.search.placeholder}
class="h-7 w-64 rounded-md border border-[var(--border)] bg-[var(--bg-panel)] px-2.5 text-xs text-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--accent)]"
/>
<span class="text-[11px] text-[var(--text-muted)]">
{snapshot() ? ko.grid.items(snapshot()!.count) : ""}
</span>
<main class="content-col flex min-h-0 min-w-0 flex-col gap-2">
{/* . :
flex:none이라 ,
(min 120), · . */}
<div class="flex h-[var(--h-toolbar)] shrink-0 items-center gap-2 overflow-x-auto overflow-y-hidden px-1 whitespace-nowrap">
{/* 1. 검색 — 남는 폭을 전부 흡수한다 */}
<div class="field-wrap" style={{ flex: "1", "min-width": "120px" }}>
<span class="field-icon"><IconSearch size={14} /></span>
<input
type="search"
class="field field--search field--block"
value={searchQuery()}
onInput={(e) => onInput(e.currentTarget.value)}
placeholder={ko.search.placeholder}
/>
</div>
{/* 종류 필터: 전체 / 사진 / 영상 */}
<div class="flex overflow-hidden rounded border border-[var(--border)] text-[11px]">
<For each={[["all", ko.filter.all], ["image", ko.filter.image], ["video", ko.filter.video]] as [KindFilter, string][]}>
{/* 2. 종류 필터: 전체 / 사진 / 영상 */}
<div class="segmented">
<For
each={
[
["all", ko.filter.all],
["image", ko.filter.image],
["video", ko.filter.video],
] as [KindFilter, string][]
}
>
{([k, label]) => (
<button
class="px-2 py-0.5"
classList={{
"bg-[var(--bg-active)] text-[var(--text-primary)]": kindFilter() === k,
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": kindFilter() !== k,
classList={{ "is-on": kindFilter() === k }}
tabindex={-1}
onClick={() => {
setKindFilter(k);
focusItemView();
}}
onClick={() => setKindFilter(k)}
>
{label}
</button>
@@ -89,79 +181,210 @@ export const ContentArea: Component = () => {
</For>
</div>
{/* 정렬: 키 선택 + 방향 토글 */}
<div class="flex items-center gap-1 text-[11px] text-[var(--text-secondary)]">
<select
class="rounded border border-[var(--border)] bg-[var(--bg-panel)] px-1.5 py-0.5 outline-none"
value={sortKey()}
onChange={(e) => setSort(e.currentTarget.value as SortKey, sortDesc())}
{/* 3. 보기 방식: 격자 / 리스트 (V) */}
<div class="segmented" title={ko.viewMode.title}>
<For
each={
[
["grid", <IconGrid size={14} />, ko.viewMode.grid],
["list", <IconList size={14} />, ko.viewMode.list],
] as [ViewMode, JSX.Element, string][]
}
>
<For each={[["taken", ko.sort.taken], ["name", ko.sort.name], ["size", ko.sort.size], ["mtime", ko.sort.mtime]] as [SortKey, string][]}>
{([k, label]) => <option value={k}>{label}</option>}
</For>
</select>
<button
class="rounded border border-[var(--border)] px-1.5 py-0.5 hover:bg-[var(--bg-hover)]"
onClick={() => setSort(sortKey(), !sortDesc())}
title={sortDesc() ? ko.sort.desc : ko.sort.asc}
>
{sortDesc() ? "↓" : "↑"}
</button>
{([m, icon, label]) => (
<button
classList={{ "is-on": viewMode() === m }}
tabindex={-1}
aria-label={label}
onClick={() => {
setViewMode(m);
focusItemView();
}}
>
{icon}
<span class="seg-label">{label}</span>
</button>
)}
</For>
</div>
{/* 썸네일 정보 오버레이 토글 */}
<button
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[11px]"
classList={{
"bg-[var(--bg-active)] text-[var(--text-primary)]": showInfo(),
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": !showInfo(),
{/* 3b. ''. N ,
"3점 넘는 것만 추려서 보기" . */}
<select
class="field field--sort"
title={ko.rating.label}
aria-label={ko.rating.label}
value={String(minRating())}
onChange={(e) => {
setMinRating(Number(e.currentTarget.value));
focusItemView();
}}
onClick={toggleShowInfo}
title={ko.grid.infoToggle}
>
<option value="0">{`${ko.rating.filterAll}`}</option>
<For each={[1, 2, 3, 4, 5]}>
{(n) => <option value={String(n)}>{ko.rating.filterAtLeast(n)}</option>}
</For>
</select>
{/* 4. 정렬: 키 선택 + 방향 토글 */}
<select
class="field field--sort"
title={ko.sort.label}
aria-label={ko.sort.label}
value={sortKey()}
onChange={(e) => setSort(e.currentTarget.value as SortKey, sortDesc())}
>
<For
each={
[
["taken", ko.sort.taken],
["name", ko.sort.name],
["size", ko.sort.size],
["mtime", ko.sort.mtime],
["rating", ko.rating.sort],
] as [SortKey, string][]
}
>
{([k, label]) => <option value={k}>{label}</option>}
</For>
</select>
<button
class="toolbar-btn toolbar-btn--icon"
tabindex={-1}
title={sortDesc() ? ko.sort.desc : ko.sort.asc}
aria-label={sortDesc() ? ko.sort.desc : ko.sort.asc}
onClick={() => {
setSort(sortKey(), !sortDesc());
focusItemView();
}}
>
{sortDesc() ? <IconArrowDown size={13} /> : <IconArrowUp size={13} />}
</button>
{/* 그리드 줌 슬라이더 */}
<div class="ml-auto flex items-center gap-1.5 text-[var(--text-muted)]" title="썸네일 크기 (Ctrl+휠)">
<span class="text-xs">🔍</span>
<input
type="range"
class="h-1 w-28 accent-[var(--accent)]"
min={GRID_ROW_MIN}
max={GRID_ROW_MAX}
step={5}
value={gridRowHeight()}
onInput={(e) => setGridRowHeight(Number(e.currentTarget.value))}
/>
</div>
</div>
<Show
when={isEmpty()}
fallback={<JustifiedGrid />}
>
<div class="grid flex-1 place-items-center">
<div class="flex max-w-sm flex-col items-center gap-3 text-center">
<div class="text-lg font-semibold text-[var(--text-primary)]">{ko.onboarding.title}</div>
<div class="text-sm text-[var(--text-secondary)]">{ko.onboarding.subtitle}</div>
<div class="mt-2 flex gap-2">
<button
class="rounded-md bg-[var(--accent)] px-4 py-2 text-xs text-white"
onClick={() => void addLocal()}
>
{ko.onboarding.addLocal}
</button>
<button
class="rounded-md border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
onClick={openRemoteDialog}
>
{ko.onboarding.addRemote}
</button>
{/* 5. 수동 새로고침 — 워처가 변경 알림을 못 받는 SMB/NAS 대비 */}
<button
class="toolbar-btn toolbar-btn--icon"
tabindex={-1}
title={ko.refresh.title}
aria-label={ko.refresh.action}
onClick={() => {
void doRescan();
focusItemView();
}}
>
<IconRefresh size={14} />
</button>
{/* 6. 썸네일 정보 오버레이 토글 — 리스트 보기는 열로 이미 보여준다 */}
<Show when={viewMode() === "grid"}>
<button
class="toolbar-btn toolbar-btn--icon"
classList={{ "is-on": showInfo() }}
tabindex={-1}
title={ko.grid.infoToggle}
aria-label={ko.grid.infoToggle}
onClick={() => {
toggleShowInfo();
focusItemView();
}}
>
<IconInfo size={14} />
</button>
</Show>
{/* 7. ( ) . Ctrl+ .
.toolbar-zoom display :
Tailwind @layer utilities라 @layer components의
`@container … { .toolbar-zoom { display: none } }`
. flex는 . */}
<Show when={viewMode() === "grid"}>
<div class="toolbar-zoom shrink-0" title={ko.grid.zoomTitle}>
<div class="flex items-center gap-1.5 text-[var(--text-muted)]">
<IconZoom size={13} />
<input
type="range"
class="h-1 w-24 accent-[var(--accent)]"
min={GRID_ROW_MIN}
max={GRID_ROW_MAX}
step={5}
value={gridRowHeight()}
aria-label={ko.grid.zoomTitle}
onInput={(e) => setGridRowHeight(Number(e.currentTarget.value))}
/>
</div>
<div class="mt-1 text-[11px] text-[var(--text-muted)]">{ko.onboarding.hint}</div>
</div>
</div>
</Show>
</Show>
{/* 8. ' ' .
. */}
<button
class="toolbar-btn toolbar-btn--icon"
tabindex={-1}
aria-label={ko.theme.label}
title={theme() === "dark" ? ko.theme.toLight : ko.theme.toDark}
onClick={() => {
toggleTheme();
focusItemView();
}}
>
{theme() === "dark" ? <IconSun size={14} /> : <IconMoon size={14} />}
</button>
{/* 9. 상세 패널 토글 — 자신이 여닫는 패널과 물리적으로 인접해야 하므로 최우측 */}
<button
class="toolbar-btn toolbar-btn--icon"
classList={{ "is-on": detailsOpen() }}
tabindex={-1}
title={ko.details.toggle}
aria-label={ko.details.toggle}
onClick={() => {
toggleDetails();
focusItemView();
}}
>
<IconPanelRight size={14} />
</button>
</div>
<div class="surface-card surface-card--fill flex-1">
<Show when={!isEmpty()}>
<Breadcrumb />
<FolderChips />
</Show>
<Show
when={isEmpty()}
fallback={
<Show when={viewMode() === "list"} fallback={<JustifiedGrid />}>
<ListView />
</Show>
}
>
<div class="grid min-h-0 flex-1 place-items-center">
<div class="flex max-w-sm flex-col items-center gap-3 text-center">
<div class="text-[var(--fs-xl)] leading-[var(--lh-xl)] font-semibold text-[var(--text-primary)]">
{ko.onboarding.title}
</div>
<div class="text-[var(--fs-md)] text-[var(--text-secondary)]">
{ko.onboarding.subtitle}
</div>
<div class="mt-2 flex gap-2">
<button
class="toolbar-btn toolbar-btn--primary toolbar-btn--lg"
onClick={() => void addLocal()}
>
{ko.onboarding.addLocal}
</button>
<button class="toolbar-btn toolbar-btn--lg" onClick={openRemoteDialog}>
{ko.onboarding.addRemote}
</button>
</div>
<div class="mt-1 text-[var(--fs-xs)] text-[var(--text-muted)]">
{ko.onboarding.hint}
</div>
</div>
</div>
</Show>
</div>
</main>
);
};
+622 -66
View File
@@ -1,15 +1,54 @@
import { createResource, createSignal, For, Show, type Component } from "solid-js";
// 상세 패널 (300px) — 헤더(썸네일·이름·닫기) → 밑줄 탭(정보/태그) → 스크롤 본문.
// 색은 예외 없이 var() 경유 — 다크·라이트 두 팔레트에서 동시에 성립해야 한다.
// 헤더 썸네일 슬롯은 '사진 면'이 아니라 24px 크롬 슬롯이라 --bg-panel-raised를 쓴다
// (--grid-cell-bg는 테마 무관 고정 다크라 흰 카드 안에 검은 사각형이 박힌다).
import { IconCalendar, IconClock, IconFile, IconFolder, IconImage, IconLayers, IconTag, IconX } from "../ui/icons";
import {
createMemo,
createResource,
createSignal,
For,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import type { JSX } from "solid-js";
import { ko } from "../i18n/ko";
import {
applyTagSet,
assignTags,
createTag,
createTagSet,
deleteTagSet,
fileDetails,
fileTagsOf,
listTags,
listTagSets,
setRating,
thumbUrl,
unassignTags,
updateTagSet,
type FileDetails,
type Tag,
type TagSet,
} from "../ipc/commands";
import { refreshSidebar, selected, toast, view } from "../state/store";
import { StarRating } from "../ui/StarRating";
import { performMarkDelete, performTrash } from "../features/fileops/ContextMenu";
import { openMoveDialog } from "../features/fileops/MoveDialog";
import {
bumpTagVersion,
detailsTab,
focusItemView,
info,
refreshSidebar,
refreshSnapshot,
selected,
setDetailsOpen,
setDetailsTab,
thumbVersions,
toast,
} from "../state/store";
function fmtSize(bytes: number): string {
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(2)} GB`;
@@ -18,20 +57,268 @@ function fmtSize(bytes: number): string {
return `${bytes} B`;
}
function fmtDate(ms: number | null): string {
/** ListView와 같은 형식 — YYYY-MM-DD HH:mm */
function fmtDate(ms: number | null | undefined): string {
if (!ms) return "—";
return new Date(ms).toLocaleString("ko-KR");
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())}`;
}
const Row: Component<{ label: string; value: string }> = (props) => (
<div class="px-3 py-1">
<div class="text-[10px] text-[var(--text-muted)]">{props.label}</div>
<div class="break-all text-xs text-[var(--text-primary)]" title={props.value}>
{props.value}
/** 0:42 / 1:03:20 — 현행 "212초" 표기 폐기 */
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")}`;
}
/** 정보 타일 1칸 */
const Tile: Component<{
icon: JSX.Element;
label: string;
value: string;
clamp?: boolean;
title?: string;
}> = (props) => (
<div class="info-tile">
<span class="info-tile__icon">{props.icon}</span>
<div class="info-tile__body">
<div class="info-tile__label">{props.label}</div>
<div
class="info-tile__value"
classList={{ "info-tile__value--clamp": props.clamp }}
title={props.title}
>
{props.value}
</div>
</div>
</div>
);
/** 정보 타일 2분할 — 300px 폭에서 깨지지 않는 상한이 2칸이다 */
const SplitTile: Component<{
icon: JSX.Element;
labelA: string;
valueA: string;
labelB: string;
valueB: string;
}> = (props) => (
<div class="info-tile info-tile--split">
<span class="info-tile__icon">{props.icon}</span>
<div class="info-tile__body">
<div class="min-w-0">
<div class="info-tile__label">{props.labelA}</div>
<div class="info-tile__value">{props.valueA}</div>
</div>
<div class="min-w-0">
<div class="info-tile__label">{props.labelB}</div>
<div class="info-tile__value">{props.valueB}</div>
</div>
</div>
</div>
);
/** 선택 없음 — 두 탭이 공유한다 */
const EmptyState: Component = () => (
<div class="flex h-[180px] flex-col items-center justify-center gap-2 px-6 text-center">
<IconImage size={32} class="text-[var(--text-muted)] opacity-50" />
<span class="text-[11px] leading-4 text-[var(--text-muted)]">{ko.details.noSelection}</span>
</div>
);
/**
* ·. 3 ( + + Esc).
*
* ** **. "Ctrl+1" "ctrl+1" "^1" ,
* key로 .
*/
const TagSetEditor: Component<{
target: TagSet | "new";
allTags: Tag[];
onClose: () => void;
onSaved: () => void;
}> = (props) => {
const existing = () => (props.target === "new" ? undefined : props.target);
const [name, setName] = createSignal(existing()?.name ?? "");
const [hotkey, setHotkey] = createSignal<string | null>(existing()?.hotkey ?? null);
const [picked, setPicked] = createSignal<Set<number>>(
new Set((existing()?.tags ?? []).map((t) => t.id)),
);
const [capturing, setCapturing] = createSignal(false);
const toggle = (id: number) =>
setPicked((p) => {
const n = new Set(p);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
const save = async () => {
const n = name().trim();
if (!n) return;
try {
const ex = existing();
if (ex) {
await updateTagSet(ex.id, { name: n, hotkey: hotkey(), tagIds: [...picked()] });
} else {
const id = await createTagSet(n, [...picked()]);
if (hotkey()) await updateTagSet(id, { hotkey: hotkey() });
}
props.onSaved();
} catch (e) {
toast(String(e));
}
};
const remove = async () => {
const ex = existing();
if (!ex) return;
try {
await deleteTagSet(ex.id);
props.onSaved();
} catch (e) {
toast(String(e));
}
};
// 키 캡처 — 수식키만 눌린 상태는 무시한다(Ctrl을 누르는 중에 확정되면 안 된다)
const onCapture = (e: KeyboardEvent) => {
if (!capturing()) return;
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
setCapturing(false);
return;
}
if (["Control", "Shift", "Alt", "Meta"].includes(e.key)) return;
setHotkey(`${e.ctrlKey ? "Ctrl+" : ""}${e.shiftKey ? "Shift+" : ""}${e.key}`);
setCapturing(false);
};
onMount(() => {
window.addEventListener("keydown", onCapture, { capture: true });
onCleanup(() => window.removeEventListener("keydown", onCapture, { capture: true }));
});
return (
<div class="modal-scrim" onClick={props.onClose}>
<div class="modal-card w-[420px]" onClick={(e) => e.stopPropagation()}>
<div class="modal-head">
<span class="modal-head__title">
{existing() ? ko.tags.editSet : ko.tags.newSet}
</span>
<button class="icon-btn ml-auto" onClick={props.onClose} title={ko.common.close}>
<IconX size={14} />
</button>
</div>
<div class="modal-body">
<div class="section-label">{ko.tags.setName}</div>
<input
class="field field--block mt-1.5"
value={name()}
placeholder={ko.tags.setNamePlaceholder}
onInput={(e) => setName(e.currentTarget.value)}
/>
<div class="section-label mt-3">{ko.tags.setHotkey}</div>
<div class="mt-1.5 flex items-center gap-2">
<button
class="toolbar-btn"
classList={{ "is-on": capturing() }}
onClick={() => setCapturing(true)}
>
{capturing() ? ko.tags.pressKey : (hotkey() ?? ko.tags.noHotkey)}
</button>
<Show when={hotkey()}>
<button class="link-btn" onClick={() => setHotkey(null)}>
{ko.tags.clearHotkey}
</button>
</Show>
</div>
<div class="mt-1 text-[10px] leading-4 text-[var(--text-muted)]">
{ko.tags.hotkeyNote}
</div>
<div class="section-label mt-3">{ko.tags.setMembers(picked().size)}</div>
<div class="tag-pick mt-1.5">
<For each={props.allTags}>
{(t) => (
<button
class="chip chip--button"
classList={{ "is-on": picked().has(t.id) }}
onClick={() => toggle(t.id)}
>
{t.name}
</button>
)}
</For>
</div>
</div>
<div class="modal-bar">
<Show when={existing()}>
<button class="toolbar-btn toolbar-btn--danger-fill" onClick={() => void remove()}>
{ko.common.delete}
</button>
</Show>
<button
class="toolbar-btn toolbar-btn--primary ml-auto"
disabled={!name().trim()}
onClick={() => void save()}
>
{ko.common.save}
</button>
</div>
</div>
</div>
);
};
/**
* . ( ) .
*
* '여러 값' .
* , .
*/
const RatingEditor: Component = () => {
const [bump, setBump] = createSignal(0);
const ids = () => [...selected()];
const [details] = createResource(
() => (ids().length > 0 ? `${ids().join(",")}:${bump()}` : undefined),
async () => fileDetails(ids().slice(0, 500)),
);
const values = () => (details() ?? []).map((d) => d.rating);
const mixed = () => new Set(values()).size > 1;
const value = () => (mixed() ? 0 : (values()[0] ?? 0));
const apply = async (next: number) => {
const list = ids();
if (list.length === 0) return;
try {
const n = await setRating(list, next);
toast(ko.rating.applied(n, next));
setBump((v) => v + 1);
// 그리드 오버레이의 별은 스냅샷에서 온다 — 다시 읽어야 반영된다
await refreshSnapshot();
} catch (e) {
toast(String(e));
}
};
return (
<div class="flex items-center gap-2 px-3 pt-2.5">
<span class="section-label !mb-0">{ko.rating.label}</span>
<StarRating value={value()} mixed={mixed()} onChange={(n) => void apply(n)} />
<Show when={mixed()}>
<span class="text-[10px] text-[var(--text-muted)]">{ko.rating.mixed}</span>
</Show>
</div>
);
};
/** 선택 항목 태그 편집기 (단일/다중 선택 공용) */
const TagEditor: Component = () => {
const [tagVersion, setTagVersion] = createSignal(0);
@@ -59,6 +346,7 @@ const TagEditor: Component = () => {
const r = await assignTags(ids, tagId);
toast(ko.tags.assignTo(r.count, trimmed));
setTagVersion((v) => v + 1);
bumpTagVersion();
refreshSidebar();
} catch (e) {
toast(String(e));
@@ -70,47 +358,268 @@ const TagEditor: Component = () => {
const r = await unassignTags(ids, tagId);
toast(ko.tags.removedFrom(r.count, name));
setTagVersion((v) => v + 1);
bumpTagVersion();
refreshSidebar();
};
// ── 붙이기: 검색이 아니라 클릭 ──────────────────────────────
// 예전에는 입력창에 매번 이름을 쳐야 했다. 태그는 몇 개~수십 개고 그중 같은 것을 반복해서
// 붙이는 작업이라, 목록을 보여주고 누르게 하는 편이 훨씬 빠르다.
// 입력창은 **새 태그를 만들 때와 목록이 길 때 좁히는 용도**로만 남긴다.
const [filter, setFilter] = createSignal("");
const attachedIds = () => new Set((currentTags() ?? []).map((t) => t.id));
const pickable = createMemo(() => {
const q = filter().trim().toLowerCase();
const all = allTags() ?? [];
return q ? all.filter((t) => t.name.toLowerCase().includes(q)) : all;
});
/** 입력한 이름이 기존에 없으면 '새로 만들기'를 내민다 */
const canCreate = () => {
const q = filter().trim();
return q.length > 0 && !(allTags() ?? []).some((t) => t.name === q);
};
const toggleTag = async (t: Tag) => {
if (selected().size === 0) return;
if (attachedIds().has(t.id)) await removeTag(t.id, t.name);
else await addTag(t.name);
};
// ── 세트 ────────────────────────────────────────────────────
const [sets, { refetch: refetchSets }] = createResource(() => tagVersion(), listTagSets);
const [setEditor, setSetEditor] = createSignal<TagSet | "new" | undefined>();
const runSet = async (s: TagSet) => {
const ids = [...selected()];
if (ids.length === 0 || s.tags.length === 0) return;
try {
const r = await applyTagSet(ids, s.id);
toast(ko.tags.setApplied(s.name, r.count));
setTagVersion((v) => v + 1);
bumpTagVersion();
refreshSidebar();
} catch (e) {
toast(String(e));
}
};
// 세트 단축키 — 선택이 있고 입력창에 포커스가 없을 때만 듣는다.
// (입력 중에 '1'을 눌렀다고 세트가 적용되면 안 된다)
onMount(() => {
const onKey = (e: KeyboardEvent) => {
if (e.altKey || e.metaKey) return;
const el = document.activeElement;
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return;
if (selected().size === 0) return;
const combo = `${e.ctrlKey ? "Ctrl+" : ""}${e.shiftKey ? "Shift+" : ""}${e.key}`;
const hit = (sets() ?? []).find((s) => s.hotkey && s.hotkey === combo);
if (!hit) return;
e.preventDefault();
e.stopPropagation();
void runSet(hit);
};
window.addEventListener("keydown", onKey);
onCleanup(() => window.removeEventListener("keydown", onKey));
});
return (
<div class="px-3 py-1">
<div class="text-[10px] text-[var(--text-muted)]">{ko.details.tags}</div>
<div class="mt-1 flex flex-wrap gap-1">
<Show when={singleId() != null}>
<div class="px-3 pt-2.5 pb-3">
{/* 붙어 있는 태그 */}
<div class="section-label">{ko.details.tags}</div>
<div class="mt-1.5 flex flex-wrap gap-1.5">
<Show
when={(currentTags()?.length ?? 0) > 0}
fallback={<span class="text-[11px] text-[var(--text-muted)]">{ko.tags.noneYet}</span>}
>
<For each={currentTags()}>
{(t) => (
<span class="flex items-center gap-1 rounded-full bg-[var(--accent-soft)] px-2 py-0.5 text-[11px] text-[var(--accent)]">
{t.name}
<button
class="hover:text-[var(--danger)]"
onClick={() => void removeTag(t.id, t.name)}
>
×
<span class="chip chip--accent">
<span>{t.name}</span>
<button class="chip__x" onClick={() => void removeTag(t.id, t.name)}>
<IconX size={11} />
</button>
</span>
)}
</For>
</Show>
</div>
{/* 세트 — 한 번에 여러 개 */}
<Show when={(sets()?.length ?? 0) > 0}>
<div class="section-label mt-3">{ko.tags.sets}</div>
<div class="mt-1.5 flex flex-wrap gap-1.5">
<For each={sets()}>
{(s) => (
<button
class="chip chip--button"
disabled={selected().size === 0}
title={ko.tags.setHint(s.tags.map((t) => t.name).join(", "))}
onClick={() => void runSet(s)}
onContextMenu={(e) => {
e.preventDefault();
setSetEditor(s);
}}
>
{s.name}
<Show when={s.hotkey}>
<kbd class="chip__kbd">{s.hotkey}</kbd>
</Show>
</button>
)}
</For>
</div>
</Show>
{/* 태그 고르기 — 전체 목록을 눌러서 붙인다 */}
<div class="section-label mt-3 flex items-center justify-between">
<span>{ko.tags.pick}</span>
<button class="link-btn" onClick={() => setSetEditor("new")}>
{ko.tags.newSet}
</button>
</div>
<input
class="mt-1.5 w-full rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1 text-[11px] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--accent)]"
placeholder={ko.tags.add}
list="all-tags-datalist"
class="field field--block mt-1.5"
placeholder={ko.tags.filterOrCreate}
value={filter()}
onInput={(e) => setFilter(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
void addTag(e.currentTarget.value);
e.currentTarget.value = "";
}
if (e.key !== "Enter") return;
void addTag(e.currentTarget.value);
setFilter("");
}}
/>
<datalist id="all-tags-datalist">
<For each={allTags()}>{(t) => <option value={t.name} />}</For>
</datalist>
<div class="tag-pick mt-1.5">
<Show when={canCreate()}>
<button class="chip chip--button chip--new" onClick={() => void addTag(filter())}>
+ {filter().trim()}
</button>
</Show>
<For each={pickable()}>
{(t) => (
<button
class="chip chip--button"
classList={{ "is-on": attachedIds().has(t.id) }}
disabled={selected().size === 0}
onClick={() => void toggleTag(t)}
title={ko.tags.countHint(t.fileCount)}
>
{t.name}
</button>
)}
</For>
<Show when={(pickable().length ?? 0) === 0 && !canCreate()}>
<span class="text-[11px] text-[var(--text-muted)]">{ko.tags.noMatch}</span>
</Show>
</div>
<Show when={setEditor()}>
{(target) => (
<TagSetEditor
target={target()}
allTags={allTags() ?? []}
onClose={() => setSetEditor(undefined)}
onSaved={() => {
setSetEditor(undefined);
void refetchSets();
setTagVersion((v) => v + 1);
}}
/>
)}
</Show>
</div>
);
};
/** 정보 탭 — 단일 선택 */
const InfoTiles: Component<{ d: FileDetails }> = (props) => (
<>
<div class="section-label mx-3 mt-2.5 mb-1.5">{ko.details.sectionMedia}</div>
<div class="grid gap-1.5 px-3">
<Show
when={props.d.width && props.d.height}
fallback={
<Tile icon={<IconImage size={14} />} label={ko.details.fileSize} value={fmtSize(props.d.size)} />
}
>
<SplitTile
icon={<IconImage size={14} />}
labelA={ko.details.dimensions}
valueA={`${props.d.width} × ${props.d.height}`}
labelB={ko.details.fileSize}
valueB={fmtSize(props.d.size)}
/>
</Show>
<Show when={props.d.durationMs}>
<Tile icon={<IconClock size={14} />} label={ko.details.duration} value={fmtDuration(props.d.durationMs)} />
</Show>
<Show
when={props.d.takenAt}
fallback={
<Tile icon={<IconCalendar size={14} />} label={ko.details.modifiedAt} value={fmtDate(props.d.mtimeMs)} />
}
>
<SplitTile
icon={<IconCalendar size={14} />}
labelA={ko.details.takenAt}
valueA={fmtDate(props.d.takenAt)}
labelB={ko.details.modifiedAt}
valueB={fmtDate(props.d.mtimeMs)}
/>
</Show>
<Tile icon={<IconFolder size={14} />} label={ko.details.filePath} value={props.d.dir} clamp title={props.d.dir} />
</div>
<div class="section-label mx-3 mt-2.5 mb-1.5">{ko.details.sectionBasic}</div>
<div class="grid gap-1.5 px-3 pb-3">
<Tile
icon={<IconTag size={14} />}
label={ko.details.kind}
value={props.d.kind === 1 ? ko.filter.video : ko.filter.image}
/>
<Tile
icon={<IconFile size={14} />}
label={ko.details.fileName}
value={props.d.name}
clamp
title={props.d.name}
/>
</div>
</>
);
/** 정보 탭 — 다중 선택. 용량 합계·종류별 집계는 만들지 않는다(file_details 500 상한) */
const BulkPanel: Component = () => (
<div class="grid gap-1.5 px-3 py-3">
<div class="info-tile">
<span class="info-tile__icon"><IconLayers size={14} /></span>
<div class="info-tile__body">
<div class="info-tile__value info-tile__value--big tabular-nums">
{selected().size.toLocaleString("ko-KR")}
</div>
<div class="info-tile__label">{ko.details.selectedUnit}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-1.5">
<button class="toolbar-btn" tabindex={-1} onClick={() => setDetailsTab("tags")}>
{ko.details.bulkTag}
</button>
<button class="toolbar-btn" tabindex={-1} onClick={openMoveDialog}>
{ko.ops.moveTo}
</button>
<button class="toolbar-btn" tabindex={-1} onClick={() => void performMarkDelete()}>
{ko.trash.mark}
</button>
<button
class="toolbar-btn toolbar-btn--danger"
tabindex={-1}
onClick={() => void performTrash()}
>
{ko.ops.trash}
</button>
</div>
</div>
);
export const DetailsPanel: Component = () => {
const singleId = () => {
const s = selected();
@@ -120,46 +629,93 @@ export const DetailsPanel: Component = () => {
const list = await fileDetails([id]);
return list[0] as FileDetails | undefined;
});
// 뷰 변경 시 details 리소스가 재평가되도록 view 추적
void view;
// 썸네일이 없는 항목은 404가 난다 — 실패한 id만 기억해 두고 선택이 바뀌면 자동 해제된다.
const [thumbErrId, setThumbErrId] = createSignal<number | undefined>();
const thumbSrc = () => {
const id = singleId();
const i = info();
if (id == null || !i || thumbErrId() === id) return undefined;
return `${thumbUrl(i, id)}&v=${thumbVersions[id] ?? 0}`;
};
const headTitle = () =>
details()?.name ??
(selected().size > 1 ? ko.grid.selected(selected().size) : ko.details.title);
return (
<aside class="flex min-h-0 flex-col overflow-y-auto border-l border-[var(--border)] bg-[var(--bg-panel)]">
<div class="px-3 pt-4 pb-1.5 text-[11px] font-semibold tracking-wide text-[var(--text-muted)]">
{ko.details.title}
</div>
<Show
when={details()}
fallback={
<div class="px-3 py-2 text-xs leading-5 text-[var(--text-muted)]">
{selected().size > 1
? ko.grid.selected(selected().size)
: ko.details.noSelection}
</div>
}
>
{(d) => (
<>
<Row label={ko.details.fileName} value={d().name} />
<Row label={ko.details.filePath} value={d().dir} />
<Row label={ko.details.fileSize} value={fmtSize(d().size)} />
<Show when={d().width && d().height}>
<Row label={ko.details.dimensions} value={`${d().width} × ${d().height}`} />
</Show>
<Show when={d().durationMs}>
<Row
label={ko.details.duration}
value={`${Math.round((d().durationMs ?? 0) / 1000)}`}
<aside class="surface-card surface-card--fill">
{/* 헤더 h40 */}
<div class="flex h-10 flex-none items-center gap-2 border-b border-[var(--border)] px-3">
<div class="h-6 w-6 flex-none overflow-hidden rounded-[var(--r-sm)] bg-[var(--bg-panel-raised)]">
<Show when={thumbSrc()}>
{(src) => (
<img
src={src()}
class="h-full w-full object-cover"
decoding="async"
draggable={false}
onError={() => setThumbErrId(singleId())}
/>
)}
</Show>
</div>
<span
class="min-w-0 flex-1 truncate text-[13px] font-semibold text-[var(--text-primary)]"
title={headTitle()}
>
{headTitle()}
</span>
<button
class="icon-btn"
tabindex={-1}
title={ko.details.close}
onClick={() => {
setDetailsOpen(false);
focusItemView();
}}
>
<IconX size={13} />
</button>
</div>
<div class="tabs">
<button
classList={{ "is-on": detailsTab() === "info" }}
tabindex={-1}
onClick={() => setDetailsTab("info")}
>
{ko.details.tabInfo}
</button>
<button
classList={{ "is-on": detailsTab() === "tags" }}
tabindex={-1}
onClick={() => setDetailsTab("tags")}
>
{ko.details.tabTags}
</button>
</div>
{/* min-h-0을 빠뜨리면 body{overflow:hidden} 때문에 스크롤바 없이 조용히 잘린다 */}
<div class="min-h-0 flex-1 overflow-y-auto">
<Show
when={detailsTab() === "info"}
fallback={
<Show when={selected().size > 0} fallback={<EmptyState />}>
{/* ,
*/}
<RatingEditor />
<TagEditor />
</Show>
<Row label={ko.details.takenAt} value={fmtDate(d().takenAt)} />
<Row label={ko.details.modifiedAt} value={fmtDate(d().mtimeMs)} />
</>
)}
</Show>
<Show when={selected().size > 0}>
<TagEditor />
</Show>
}
>
<Show when={selected().size > 0} fallback={<EmptyState />}>
<Show when={singleId() != null} fallback={<BulkPanel />}>
<Show when={details()}>{(d) => <InfoTiles d={d()} />}</Show>
</Show>
</Show>
</Show>
</div>
</aside>
);
};
+29 -2
View File
@@ -1,9 +1,17 @@
// 키보드 단축키 도움말 오버레이 (? 키로 열기).
import { For, onCleanup, onMount, Show, type Component } from "solid-js";
import { IconX } from "../ui/icons";
import { createResource, For, onCleanup, onMount, Show, type Component } from "solid-js";
import { ko } from "../i18n/ko";
import { listTagSets } from "../ipc/commands";
import { closeShortcuts, shortcutsOpen } from "../state/store";
export const ShortcutsOverlay: Component = () => {
// 열릴 때만 읽는다 — 세트는 자주 바뀌지 않고, 닫혀 있는 오버레이가 폴링할 이유가 없다
const [setKeys] = createResource(
() => (shortcutsOpen() ? 1 : undefined),
async () => (await listTagSets()).filter((s) => s.hotkey),
);
onMount(() => {
const onKey = (e: KeyboardEvent) => {
if (!shortcutsOpen()) return;
@@ -36,7 +44,7 @@ export const ShortcutsOverlay: Component = () => {
onClick={closeShortcuts}
title={ko.shortcuts.close}
>
<IconX size={14} />
</button>
</div>
<div class="flex flex-col gap-1.5">
@@ -50,6 +58,25 @@ export const ShortcutsOverlay: Component = () => {
</div>
)}
</For>
{/* .
"내가 뭘 지정했더라" . */}
<Show when={(setKeys()?.length ?? 0) > 0}>
<div class="mt-1.5 border-t border-[var(--border)] pt-1.5 text-[10px] text-[var(--text-muted)]">
{ko.tags.sets}
</div>
<For each={setKeys()}>
{(s) => (
<div class="flex items-center justify-between gap-4 text-xs">
<kbd class="shrink-0 rounded border border-[var(--border)] bg-[var(--bg-app)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--text-secondary)]">
{s.hotkey}
</kbd>
<span class="text-right text-[var(--text-secondary)]">
{s.name} {s.tags.map((t) => t.name).join(", ")}
</span>
</div>
)}
</For>
</Show>
</div>
</div>
</div>
+846 -218
View File
File diff suppressed because it is too large Load Diff
+51 -64
View File
@@ -1,78 +1,65 @@
// 상태바 h26 — 페이지 배경 위(테두리 없음, 카드 아님).
//
// 역할 분담(사이드바 작업 존과의 계약). 요구는 '탐색·정리 중에 사이드바에서 진행을
// 볼 수 있게'였고, 그 답은 같은 정보를 두 곳에 복사하는 것이 아니라 나누는 것이다.
// · 사이드바 작업 존 = '지금 무엇이 얼마나' — 작업의 존재·수량·진행 막대·취소,
// 그리고 중복 모달로 들어가는 진입점(openDedupe는 거기 두 곳뿐이다).
// · 여기(footer) = '자세한 사정' — 토스트 한 줄, 지금 읽고 있는 폴더 경로, 데이터 위치.
//
// 그래서 여기서 사라진 것들:
// · 중복 탐지 진행/취소 — 사이드바 작업 행이 소유한다. 진입점도 두지 않는다.
// footer가 중복 탐지에 대해 말하는 것은 완료·취소 토스트 한 번뿐이다(store가 던진다).
// · 썸네일·메타·지문 대기 수 — 사이드바 백필 행이 소유한다. 지문 대기 수가 사라지는
// 것이 아니다: 그 행의 부제(ko.tasks.backfillDetail)가 '지문 N'까지 말한다.
// 항목수·선택수는 브레드크럼이, 포터블/버전은 사이드바 통계 카드가 이미 가져갔다.
import { Show, type Component } from "solid-js";
import { ko } from "../i18n/ko";
import {
dedupeProgress,
dedupeRunning,
info,
openDedupe,
pending,
requestCancelDedupe,
scanProgress,
selected,
snapshot,
statusMessage,
} from "../state/store";
import { dedupeRunning, info, pending, scanProgress, statusMessage } from "../state/store";
export const StatusBar: Component = () => {
/** '' .
'중복 찾는 중' footer가 '준비됨' . */
const anyTask = () =>
scanProgress() != null ||
dedupeRunning() ||
pending().thumbs > 0 ||
pending().meta > 0 ||
pending().sigs > 0;
/** : > > .
truncate . 4
. */
const line = (): { text: string; accent: boolean } | undefined => {
const msg = statusMessage();
if (msg) return { text: msg, accent: true };
const dir = scanProgress()?.currentDir;
if (dir) return { text: ko.statusBar.scanning(dir), accent: true };
return anyTask() ? undefined : { text: ko.statusBar.ready, accent: false };
};
return (
<footer class="flex h-6 items-center gap-3 border-t border-[var(--border)] bg-[var(--bg-panel)] px-3 text-[11px] text-[var(--text-secondary)]">
<Show when={statusMessage()}>
<span class="text-[var(--accent)]">{statusMessage()}</span>
</Show>
<Show
when={scanProgress()}
fallback={
<span>
{snapshot() ? ko.grid.items(snapshot()!.count) : ko.statusBar.ready}
<Show when={selected().size > 0}>
<span class="ml-2 text-[var(--accent)]">{ko.grid.selected(selected().size)}</span>
</Show>
</span>
}
>
{(p) => (
<span class="text-[var(--accent)]">
{ko.statusBar.scanning(p().currentDir)} {p().seen.toLocaleString("ko-KR")}
<footer class="flex h-[var(--h-status)] items-center gap-3 bg-[var(--bg-app)] px-3 text-[11px] text-[var(--text-secondary)]">
<Show when={line()}>
{(l) => (
<span
class="min-w-0 truncate"
classList={{
"text-[var(--accent-text)]": l().accent,
"text-[var(--text-muted)]": !l().accent,
}}
title={l().text}
>
{l().text}
</span>
)}
</Show>
{/* 중복 탐지 진행 — 패널을 닫아도 상태바에 표시 (클릭 시 패널 열기 / 취소) */}
<Show when={dedupeRunning()}>
<span class="flex items-center gap-1.5 text-[var(--accent)]">
<span class="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-[var(--accent)] border-t-transparent" />
<button class="hover:underline" onClick={openDedupe} title={ko.dedupe.open}>
{dedupeProgress()
? ko.dedupe.scanning(dedupeProgress()!.phase, dedupeProgress()!.current, dedupeProgress()!.total)
: ko.dedupe.starting}
</button>
<button
class="rounded px-1 text-[var(--text-muted)] hover:text-[var(--danger)]"
onClick={requestCancelDedupe}
title={ko.common.cancel}
>
</button>
</span>
</Show>
{/* 백그라운드 처리 진행 — 썸네일/메타 대기 수 (있을 때만) */}
<Show when={pending().thumbs > 0 || pending().meta > 0}>
<span class="flex items-center gap-1.5 text-[var(--text-muted)]">
<span class="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-[var(--text-muted)] border-t-transparent" />
<Show when={pending().thumbs > 0}>{ko.statusBar.thumbPending(pending().thumbs)}</Show>
<Show when={pending().meta > 0}>
<span class="ml-1">{ko.statusBar.metaPending(pending().meta)}</span>
</Show>
</span>
</Show>
{/* 데이터 위치 — 우측 고정 */}
<Show when={info()}>
{(i) => (
<>
<span class="ml-auto truncate text-[var(--text-muted)]" title={i().dataDir}>
{i().portable ? ko.statusBar.portable : ko.statusBar.installed}
</span>
<span class="text-[var(--text-muted)]">v{i().version}</span>
</>
<span class="ml-auto min-w-0 truncate text-[var(--text-muted)]" title={i().dataDir}>
{ko.statusBar.dataDir}: {i().dataDir}
</span>
)}
</Show>
</footer>
+140
View File
@@ -0,0 +1,140 @@
// 폴더 트리 캐시 — 사이드바 트리 / 브레드크럼 조상 체인 / 폴더 칩이 공유한다.
// folder_tree는 순수 DB 조회(commands.rs:745)라 죽은 네트워크 경로에서도 즉시 반환된다.
//
// **소스별 지연 로드**다. 예전에는 sidebarVersion이 오를 때마다 모든 소스의 folder_tree를
// 한꺼번에 불렀다. 100만 건 합성 DB 실측으로 folder_tree는 소스당 103ms(+5만 노드 IPC 직렬화)
// 이므로, 소스가 몇 개만 있어도 갱신 한 번이 수백 ms다. 스캔 중에는 그 갱신이 2초마다 온다.
// 이제는 **화면에 필요한 소스만** 부른다 — 접힌 소스는 비용이 0이고, 펼친 소스도 버전당 1회다.
import { createResource, createRoot, createSignal } from "solid-js";
import { folderTree, listSources, type FolderNode, type Source } from "../ipc/commands";
import { sidebarVersion } from "./store";
export interface FolderEntry {
node: FolderNode;
sourceId: number;
children: FolderNode[];
/** . folder_tree fileCount ''
"12개" 3,400 . */
subtotal: number;
}
interface SourceTree {
map: Map<number, FolderEntry>;
roots: FolderNode[]; // 최상위 폴더들
}
function buildTree(sourceId: number, nodes: FolderNode[]): SourceTree {
const map = new Map<number, FolderEntry>();
const ids = new Set(nodes.map((n) => n.id));
for (const n of nodes)
map.set(n.id, { node: n, sourceId, children: [], subtotal: n.fileCount });
const roots: FolderNode[] = [];
for (const n of nodes) {
if (n.parentId != null && ids.has(n.parentId)) map.get(n.parentId)!.children.push(n);
else roots.push(n); // Sidebar.buildTree와 동일한 루트 판정
}
// 하위 합계 — folder_tree는 ORDER BY fo.path라 부모가 항상 자식보다 앞선다
// (경로 접두사는 사전순으로 항상 선행). 역순 1패스면 충분하다.
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i];
if (n.parentId == null) continue;
const p = map.get(n.parentId);
if (p) p.subtotal += map.get(n.id)!.subtotal;
}
return { map, roots };
}
// 소스 목록은 가볍고(몇 행) 사이드바가 어차피 필요로 한다 — 항상 로드한다.
const sourcesRes = createRoot(() => createResource(() => sidebarVersion(), listSources)[0]);
// sourceId → 마지막으로 성공한 트리 + 그때의 버전. 값은 버전이 올라도 버리지 않는다:
// 재조회 중에도 이전 트리를 계속 보여줘야 트리·칩이 깜빡이지 않는다.
const trees = new Map<number, { version: number; tree: SourceTree }>();
const inflight = new Set<string>();
// 로드 완료 알림 — 이걸 읽는 계산은 트리가 도착하면 다시 평가된다.
const [loadTick, setLoadTick] = createSignal(0);
function load(sourceId: number, version: number): void {
const key = `${sourceId}:${version}`;
if (inflight.has(key)) return;
inflight.add(key);
void folderTree(sourceId)
.then((nodes) => trees.set(sourceId, { version, tree: buildTree(sourceId, nodes) }))
.catch(() => {
// 소스가 사라졌거나 조회 실패 — 빈 트리를 이 버전으로 캐시해 재시도 폭주를 막는다
trees.set(sourceId, { version, tree: { map: new Map(), roots: [] } });
})
.finally(() => {
inflight.delete(key);
setLoadTick((n) => n + 1);
});
}
/**
* . , ( )
* . sidebarVersion·loadTick을 .
*/
export function sourceTree(sourceId: number): SourceTree | undefined {
const version = sidebarVersion();
loadTick();
const cached = trees.get(sourceId);
if (!cached || cached.version !== version) load(sourceId, version);
return cached?.tree;
}
/** 최상위 폴더들 (사이드바 소스 행이 펼쳐질 때만 호출된다) */
export function rootsOf(sourceId: number): FolderNode[] {
return sourceTree(sourceId)?.roots ?? [];
}
/**
* . `sourceId` (
* · · ).
*
* ** ** ,
* ( 경로: 소스를 . ).
*/
export function entryOf(folderId: number, sourceId?: number): FolderEntry | undefined {
if (sourceId != null) return sourceTree(sourceId)?.map.get(folderId);
sidebarVersion();
loadTick();
for (const c of trees.values()) {
const hit = c.tree.map.get(folderId);
if (hit) return hit;
}
for (const s of sourcesRes.latest ?? []) sourceTree(s.id);
return undefined;
}
export function sourceIdOf(folderId: number): number | undefined {
return entryOf(folderId)?.sourceId;
}
export function subtotalOf(folderId: number, sourceId?: number): number {
return entryOf(folderId, sourceId)?.subtotal ?? 0;
}
export function childrenOf(folderId: number, sourceId?: number): FolderNode[] {
return entryOf(folderId, sourceId)?.children ?? [];
}
/** 브레드크럼용 조상 체인 (루트 → 현재) */
export function ancestry(folderId: number, sourceId?: number): FolderNode[] {
const start = entryOf(folderId, sourceId);
if (!start) return [];
const tree = trees.get(start.sourceId)?.tree;
if (!tree) return [];
const out: FolderNode[] = [];
let cur: FolderEntry | undefined = start;
const guard = new Set<number>();
while (cur && !guard.has(cur.node.id)) {
guard.add(cur.node.id);
out.unshift(cur.node);
cur = cur.node.parentId != null ? tree.map.get(cur.node.parentId) : undefined;
}
return out;
}
// `.latest`를 쓴다 — 재조회 중에도 이전 값을 유지해 소스 이름·개수가 깜빡이지 않게.
export const sources = (): Source[] => sourcesRes.latest ?? [];
/** 라이브러리 전체 항목 수 (소스 재귀 개수의 합) */
export const libraryTotal = (): number =>
(sourcesRes.latest ?? []).reduce((a, s) => a + s.fileCount, 0);
+486 -20
View File
@@ -1,23 +1,32 @@
// 전역 상태 — Solid 시그널 기반 단일 스토어 모듈.
import { createSignal } from "solid-js";
import { createStore, produce } from "solid-js/store";
import { createStore, produce, unwrap } from "solid-js/store";
import { ask } from "@tauri-apps/plugin-dialog";
import {
appInfo,
cancelDedupe,
folderSnapshot,
markDelete,
onDedupeDone,
pendingCounts as fetchPendingCounts,
purgeMissing,
searchSnapshot,
smartSnapshot,
startDedupe,
tagSnapshot,
trashTagId as fetchTrashTagId,
unmarkDelete,
Snapshot,
type AppInfo,
type DedupeProgress,
type PendingCounts,
type ScanGuarded,
type ScanProgress,
type SnapOpts,
type SortKey,
type UnlistenFn,
} from "../ipc/commands";
import { ko } from "../i18n/ko";
// ── 앱 정보 (미디어 서버 포트/토큰 포함) ─────────────────
const [info, setInfo] = createSignal<AppInfo | undefined>();
@@ -35,7 +44,9 @@ export async function loadAppInfo(): Promise<void> {
export type View =
| { type: "all" }
| { type: "source"; sourceId: number }
| { type: "folder"; folderId: number; recursive: boolean }
/** sourceId · .
(DedupePanel ) . */
| { type: "folder"; folderId: number; recursive: boolean; sourceId?: number }
| { type: "search"; query: string }
| { type: "tag"; tagId: number }
| { type: "smart"; smartId: number };
@@ -50,7 +61,9 @@ const [sortKey, setSortKeySig] = createSignal<SortKey>("taken");
const [sortDesc, setSortDescSig] = createSignal(true);
export type KindFilter = "all" | "image" | "video";
const [kindFilter, setKindFilterSig] = createSignal<KindFilter>("all");
export { sortKey, sortDesc, kindFilter };
/** 별점 하한 (0=전체, 1~5=그 점수 **이상**). 종류 필터와 함께 걸린다. */
const [minRating, setMinRatingSig] = createSignal(0);
export { sortKey, sortDesc, kindFilter, minRating };
export type { SortKey };
/** 정렬 변경 → 재조회 */
@@ -64,6 +77,28 @@ export function setKindFilter(k: KindFilter): void {
setKindFilterSig(k);
void refreshSnapshot();
}
/** 별점 하한 변경(0=전체) → 재조회 */
export function setMinRating(n: number): void {
setMinRatingSig(Math.max(0, Math.min(5, n)));
void refreshSnapshot();
}
// ── 보기 방식 (격자 / 리스트) ────────────────────────────
// 같은 스냅샷을 격자(썸네일 위주)와 리스트(속성 비교 위주) 중 하나로 그린다.
// 선택·정렬·필터는 공유하므로 전환해도 보고 있던 항목이 유지된다.
export type ViewMode = "grid" | "list";
const VIEW_MODE_KEY = "archive.viewMode";
const [viewMode, setViewModeRaw] = createSignal<ViewMode>(
localStorage.getItem(VIEW_MODE_KEY) === "list" ? "list" : "grid",
);
export { viewMode };
export function setViewMode(m: ViewMode): void {
setViewModeRaw(m);
localStorage.setItem(VIEW_MODE_KEY, m);
}
export const toggleViewMode = (): void => setViewMode(viewMode() === "grid" ? "list" : "grid");
// 그리드 썸네일에 파일명·크기 오버레이 표시 (localStorage 유지)
const INFO_KEY = "archive.grid.infoOverlay";
@@ -75,14 +110,151 @@ export function toggleShowInfo(): void {
localStorage.setItem(INFO_KEY, n ? "1" : "0");
}
// ── 상세 패널 / 폴더 칩 (전부 localStorage 유지) ─────────
const DETAILS_KEY = "archive.detailsOpen";
const [detailsOpen, setDetailsOpenRaw] = createSignal(localStorage.getItem(DETAILS_KEY) !== "0");
export { detailsOpen };
export function setDetailsOpen(b: boolean): void {
setDetailsOpenRaw(b);
localStorage.setItem(DETAILS_KEY, b ? "1" : "0");
}
export const toggleDetails = (): void => setDetailsOpen(!detailsOpen());
export type DetailsTab = "info" | "tags";
const [detailsTab, setDetailsTab] = createSignal<DetailsTab>("info");
export { detailsTab, setDetailsTab };
const CHIPS_KEY = "archive.folderChips";
const [chipsOpen, setChipsOpenRaw] = createSignal(localStorage.getItem(CHIPS_KEY) !== "0");
export { chipsOpen };
export function toggleChips(): void {
const n = !chipsOpen();
setChipsOpenRaw(n);
localStorage.setItem(CHIPS_KEY, n ? "1" : "0");
}
// ── 테마 (다크 기본 / 라이트 선택) ───────────────────────
// 앱 크롬만 뒤집는다. 사진 위에 얹히는 색(--ink-on-media 계열)은 app.css에서 고정이며
// Lightbox/VideoPlayer는 범위 밖이라 항상 검은 배경을 유지한다.
export type Theme = "dark" | "light";
const THEME_KEY = "archive.theme";
function readTheme(): Theme {
try {
return localStorage.getItem(THEME_KEY) === "light" ? "light" : "dark"; // 기본 = 다크
} catch {
return "dark"; // localStorage 차단 환경에서도 앱이 뜨는 게 우선
}
}
/** 문서 루트에 반영. CSS는 :root(다크) / :root[data-theme="light"] 두 벌로 재스코프된다. */
function applyTheme(t: Theme): void {
document.documentElement.dataset.theme = t;
}
const [theme, setThemeRaw] = createSignal<Theme>(readTheme());
export { theme };
// FOUC 방지: 모듈 평가 시점에 즉시 반영한다.
// main.tsx가 App을 import → App이 store를 import → 이 줄이 render() 이전에 실행된다.
applyTheme(theme());
export function setTheme(t: Theme): void {
if (t === theme()) return;
setThemeRaw(t);
applyTheme(t);
try {
localStorage.setItem(THEME_KEY, t);
} catch {
/* 무시 — 이번 세션에만 적용된다 */
}
}
export const toggleTheme = (): void => setTheme(theme() === "dark" ? "light" : "dark");
// ── 사이드바 트리 확장 상태 (재시작 후에도 유지) ──────────
// key 규약: 소스 = `s:{sourceId}` / 폴더 = `f:{folderId}`
// 사이드바 그룹 접힘 = `gc:{sources|collections|cleanup}`
// 그룹만 의미가 반대다 — 저장하는 값이 '접힘'이다. 기본(키 없음)이 펼침이어야 하므로
// 접힘을 true로 적는다. isExpanded("gc:sources") === true 는 '소스 그룹이 접혀 있다'는 뜻이다.
const TREE_KEY = "archive.tree";
const [treeOpen, setTreeOpen] = createStore<Record<string, boolean>>(
(() => {
try {
return JSON.parse(localStorage.getItem(TREE_KEY) ?? "{}") as Record<string, boolean>;
} catch {
return {};
}
})(),
);
export const isExpanded = (key: string): boolean => treeOpen[key] === true;
export function setExpanded(key: string, v: boolean): void {
setTreeOpen(key, v);
localStorage.setItem(TREE_KEY, JSON.stringify(unwrap(treeOpen)));
}
export const toggleExpanded = (key: string): void => setExpanded(key, !isExpanded(key));
/** 사이드바 그룹 접힘 — 위 `gc:` 규약의 얇은 래퍼. 폴라리티를 호출부가 뒤집지 않게 한다. */
export const isGroupCollapsed = (group: string): boolean => isExpanded(`gc:${group}`);
export const toggleGroupCollapsed = (group: string): void => toggleExpanded(`gc:${group}`);
// ── 아이템 뷰 포커스 복귀 계약 (단축키 보존의 핵심) ────────
// 격자/리스트 스크롤 컨테이너가 자신을 등록한다. 크롬(브레드크럼·칩·툴바·사이드바)의
// 클릭 핸들러는 끝에서 focusItemView()를 불러 방향키·Delete·Ctrl+A를 되살린다.
let itemViewFocus: (() => void) | undefined;
export function registerItemViewFocus(fn: () => void): () => void {
itemViewFocus = fn;
return () => {
if (itemViewFocus === fn) itemViewFocus = undefined;
};
}
export const focusItemView = (): void => itemViewFocus?.();
// ── 검색 (store가 소유) ──────────────────────────────────
// 폴더로 이동하면 검색창이 비워지고, 검색을 끝내면 검색 '직전 위치'로 돌아온다.
// search_snapshot에 범위 인자가 없으므로 폴더 내 검색은 만들지 않는다 — 항상 전체 라이브러리.
const [searchQuery, setSearchQueryRaw] = createSignal("");
const [prevView, setPrevView] = createSignal<View | undefined>();
export { searchQuery };
let viewSeq = 0;
export async function setView(v: View): Promise<void> {
if (v.type === "search") {
if (view().type !== "search") setPrevView(view());
} else {
setPrevView(undefined);
if (searchQuery() !== "") setSearchQueryRaw(""); // 다른 곳으로 가면 검색창도 비운다
}
setViewSignal(v);
clearSelection();
await refreshSnapshot();
}
/** 검색 실행. 빈 문자열이면 검색 종료(직전 뷰로 복귀). 디바운스는 호출자 책임. */
export async function runSearch(raw: string): Promise<void> {
setSearchQueryRaw(raw); // 화면에는 원문 그대로
const q = raw.trim();
if (q.length === 0) {
await exitSearch();
return;
}
if (view().type !== "search") setPrevView(view());
setViewSignal({ type: "search", query: q });
clearSelection();
await refreshSnapshot();
}
/** 검색 종료 — 검색 직전 위치로 복귀 (없으면 전체) */
export async function exitSearch(): Promise<void> {
const back = prevView() ?? ({ type: "all" } as View);
setSearchQueryRaw("");
setPrevView(undefined);
setViewSignal(back);
clearSelection();
await refreshSnapshot();
}
export async function refreshSnapshot(): Promise<void> {
const v = view();
const seq = ++viewSeq;
@@ -91,6 +263,7 @@ export async function refreshSnapshot(): Promise<void> {
sortKey: sortKey(),
sortDesc: sortDesc(),
kind: kindFilter() === "image" ? 0 : kindFilter() === "video" ? 1 : null,
minRating: minRating() > 0 ? minRating() : null,
};
try {
let snap: Snapshot;
@@ -114,12 +287,39 @@ export async function refreshSnapshot(): Promise<void> {
snap = await smartSnapshot(v.smartId, opts);
break;
}
if (seq === viewSeq) setSnapshot(snap);
if (seq === viewSeq) {
// 크게 보는 중이면 **같은 파일**을 계속 본다.
//
// 라이트박스는 인덱스를 들고 있는데 목록이 다시 정렬되면 그 자리에 다른 파일이 온다.
// 편집(회전·자르기)처럼 보는 중에 목록이 갱신되는 경우가 흔해서, 방금 손댄 파일이
// 화면에서 사라지고 엉뚱한 항목이 뜬다 — 다시 찾아 들어가야 한다.
const openId = lightboxFileId();
setSnapshot(snap);
if (openId != null) {
const at = indexOfId(snap, openId);
// 목록에서 빠졌으면(필터에 걸림 등) 있던 자리를 지킨다 — 닫아 버리면 더 혼란스럽다
if (at >= 0) setLightboxIndex(at);
}
}
} finally {
if (seq === viewSeq) setLoading(false);
}
}
/** 지금 크게 보고 있는 파일 id (안 열려 있으면 null) */
function lightboxFileId(): number | null {
const idx = lightboxIndex();
const snap = snapshot();
if (idx == null || !snap || idx >= snap.count) return null;
return snap.id(idx);
}
/** 스냅샷에서 id의 위치. 없으면 -1. */
function indexOfId(snap: Snapshot, id: number): number {
for (let i = 0; i < snap.count; i++) if (snap.id(i) === id) return i;
return -1;
}
// ── 선택 ────────────────────────────────────────────────
const [selected, setSelected] = createSignal<ReadonlySet<number>>(new Set());
const [anchorIndex, setAnchorIndex] = createSignal<number>(-1);
@@ -156,6 +356,70 @@ export function setSelectionFromIds(ids: number[]): void {
setSelected(new Set<number>(ids));
}
// ── 삭제 대기열 ('삭제 예정' 태그) ───────────────────────
// 삭제는 2단계다: ① 표시(태그) — 되돌릴 수 있고 파일은 그대로 ② 비우기 — 영구 삭제.
// 그리드/라이트박스가 표시 상태를 즉시 반영해야 해서 id 집합을 전역으로 캐시한다.
const [trashTag, setTrashTag] = createSignal<number | undefined>();
const [markedIds, setMarkedIds] = createSignal<ReadonlySet<number>>(new Set());
export { trashTag, markedIds };
export const isMarked = (id: number): boolean => markedIds().has(id);
/** 삭제 예정 태그 id 확보 (앱 시작 시 1회) */
export async function loadTrashTag(): Promise<void> {
try {
setTrashTag(await fetchTrashTagId());
await refreshMarked();
// 최초 실행 시 태그가 방금 만들어졌을 수 있다 — 사이드바가 이를 집어가게 한다
refreshSidebar();
} catch (e) {
console.error("trash_tag_id 실패", e);
}
}
/** 삭제 예정 표시 목록 재조회 (라이브러리 전체 기준) */
export async function refreshMarked(): Promise<void> {
const tag = trashTag();
if (tag == null) return;
try {
const snap = await tagSnapshot(tag);
const next = new Set<number>();
for (let i = 0; i < snap.count; i++) next.add(snap.id(i));
setMarkedIds(next);
} catch {
/* 무시 — 표시 배지가 잠깐 어긋날 뿐 */
}
}
/**
* . ,
* . (Ctrl+Z로 )
*/
export async function toggleMarkDelete(ids: number[]): Promise<void> {
if (ids.length === 0) return;
const cur = markedIds();
const allMarked = ids.every((id) => cur.has(id));
try {
const r = allMarked ? await unmarkDelete(ids) : await markDelete(ids);
toast(allMarked ? ko.trash.unmarked(r.count) : ko.trash.marked(r.count));
await refreshMarked();
bumpTagVersion();
refreshSidebar();
// 삭제 예정 뷰를 보고 있으면 목록 자체가 바뀐다
const v = view();
if (v.type === "tag" && v.tagId === trashTag()) await refreshSnapshot();
} catch (e) {
toast(String(e));
}
}
// ── 태그 변경 버전 ───────────────────────────────────────
// 어디서든 태그를 붙이거나 떼면 올린다. 그리드 썸네일의 태그 오버레이가
// 이 값을 구독해 캐시를 버리고 다시 읽는다.
const [tagVersion, setTagVersion] = createSignal(0);
export { tagVersion };
export const bumpTagVersion = () => setTagVersion((v) => v + 1);
// ── 썸네일 갱신 버전 (thumbs-ready 이벤트로 증가) ────────
const [thumbVersions, setThumbVersions] = createStore<Record<number, number>>({});
export { thumbVersions };
@@ -172,22 +436,90 @@ export function bumpThumbVersions(ids: number[]): void {
const [scanProgress, setScanProgress] = createSignal<ScanProgress | undefined>();
export { scanProgress };
// 열거 단계는 총량을 모른다 — 다 돌아 봐야 몇 개인지 안다. 그래서 퍼센트를 지어낼 수 없다.
// 대신 '초당 몇 건'을 재서 진행이 살아 있음과 빠르기를 함께 전한다.
// 지수 평활(0.3)로 스파이크를 눌러 숫자가 튀지 않게 한다.
const [scanRate, setScanRate] = createSignal(0);
export { scanRate };
let rateSeen = 0;
let rateAt = 0;
export function updateScanProgress(p: ScanProgress): void {
setScanProgress(p.done ? undefined : p);
if (p.done) {
setScanProgress(undefined);
setScanRate(0);
rateSeen = 0;
rateAt = 0;
return;
}
const now = Date.now();
if (rateAt > 0 && p.seen >= rateSeen) {
const dt = (now - rateAt) / 1000;
// 백엔드가 100ms 스로틀로 보내므로 0.2초 이상 간격만 표본으로 쓴다
if (dt >= 0.2) {
const inst = (p.seen - rateSeen) / dt;
setScanRate((prev) => (prev > 0 ? prev * 0.7 + inst * 0.3 : inst));
rateSeen = p.seen;
rateAt = now;
}
} else {
rateSeen = p.seen;
rateAt = now;
}
setScanProgress(p);
}
/**
* .
* startScan이 ( ·· ) done
* '유령 스캔' .
* catch에서 .
*/
export const clearScanProgress = (): void => {
setScanProgress(undefined);
setScanRate(0);
rateSeen = 0;
rateAt = 0;
};
// ── 백그라운드 처리 진행(썸네일/메타 대기 수) ────────────
const [pending, setPending] = createSignal<PendingCounts>({ thumbs: 0, meta: 0 });
const [pending, setPending] = createSignal<PendingCounts>({
thumbs: 0,
meta: 0,
sigs: 0,
baseline: 0,
});
export { pending };
/**
* '가장 많이 밀려 있던 순간' .
*
* ** DB에 **. ,
* ** 0% **( ).
* `thumb_state` .
*/
const [backfillTotal, setBackfillTotal] = createSignal(0);
export { backfillTotal };
let pendingTimer: ReturnType<typeof setTimeout> | undefined;
async function doRefreshPending(): Promise<void> {
try {
const c = await fetchPendingCounts();
// 필드를 하나라도 못 받으면(구 백엔드·필드 추가 시점 불일치) 합이 NaN이 되고
// NaN > 0 은 false라 작업 행이 **통째로 사라진다**. 경계에서 한 번 정규화해 그 실패를 막는다.
const raw = await fetchPendingCounts();
const c: PendingCounts = {
thumbs: raw.thumbs ?? 0,
meta: raw.meta ?? 0,
sigs: raw.sigs ?? 0,
// 기준선은 백엔드가 관리한다(재시작을 넘겨야 하므로). 구 백엔드가 안 주면 0 → 셔틀 표시.
baseline: raw.baseline ?? 0,
};
setPending(c);
setBackfillTotal(c.baseline);
clearTimeout(pendingTimer);
// 남은 작업이 있으면 계속 폴링(썸네일은 thumbs-ready로도 갱신되지만 메타 이벤트가 없어 폴링 필요)
if (c.thumbs > 0 || c.meta > 0) pendingTimer = setTimeout(() => void doRefreshPending(), 2000);
// 남은 작업이 있으면 계속 폴링(썸네일은 thumbs-ready로도 갱신되지만 메타·지문은 이벤트가 없어 폴링 필요)
if (c.thumbs > 0 || c.meta > 0 || c.sigs > 0)
pendingTimer = setTimeout(() => void doRefreshPending(), 2000);
} catch {
/* 무시 */
}
@@ -199,11 +531,72 @@ export function refreshPendingCounts(): void {
pendingTimer = setTimeout(() => void doRefreshPending(), 400);
}
// ── 대량 삭제 보류 ───────────────────────────────────────
// 한 스캔이 소스 절반 이상을 지우려 하면 백엔드가 보류한다(드라이브 미마운트·NAS 오프라인).
// 지우지 않았으므로 목록에는 없는 파일이 남는다 — 사용자가 드라이브를 확인한 뒤
// '사라진 파일 정리'로 직접 확정해야 지워진다. 그 진입점을 이 시그널이 만든다.
const [guarded, setGuarded] = createSignal<ScanGuarded | undefined>();
export { guarded };
export const noteGuarded = (g: ScanGuarded) => setGuarded(g);
/** 보류된 삭제를 확정한다. 디스크를 다시 확인하므로 연결이 돌아왔으면 아무것도 지워지지 않는다. */
export async function purgeGuarded(): Promise<void> {
const g = guarded();
if (!g) return;
const yes = await ask(ko.refresh.purgeMissingConfirm(g.count), {
title: "Archive",
kind: "warning",
});
if (!yes) return;
try {
const n = await purgeMissing(g.sourceId);
toast(n > 0 ? ko.refresh.purgeMissingDone(n) : ko.refresh.purgeMissingNone);
// 처리됐다 — 진입점은 사라진다. 다음 스캔에서 또 보류되면 다시 나타난다.
setGuarded(undefined);
refreshSidebar();
await refreshSnapshot();
} catch (e) {
toast(String(e));
}
}
// ── 사이드바 새로고침 트리거 ─────────────────────────────
const [sidebarVersion, setSidebarVersion] = createSignal(0);
export { sidebarVersion };
/** 사용자 동작(태그 추가·소스 제거 등) — 즉시 반영한다. */
export const refreshSidebar = () => setSidebarVersion((v) => v + 1);
// ── 스캔 중 갱신 폭주 합치기 ─────────────────────────────
// 열거 중에는 library-changed가 2초마다 온다(새 파일이 그리드에 보이도록 일부러 그렇게 한다).
// 그런데 그 한 번이 사이드바 전체 재조회 + 현재 뷰 스냅샷 재조회를 부른다. 100만 건 합성
// DB 실측으로 folder_tree 103ms/소스, 소스 개수 집계 99ms/소스, 스냅샷 수백 ms —
// 2초마다 이걸 다 하면 화면이 끊긴다. 이벤트발 갱신만 창(WINDOW)으로 합친다.
// 사용자 동작발 갱신은 이 경로를 타지 않으므로 여전히 즉시 반영된다.
const SOFT_WINDOW = 1500;
let softTimer: ReturnType<typeof setTimeout> | undefined;
let softLast = 0;
function runSoft(): void {
softLast = Date.now();
setSidebarVersion((v) => v + 1);
void refreshSnapshot();
void refreshMarked();
}
/** 이벤트발(library-changed) 갱신 — 선두는 즉시, 뒤따르는 것들은 창 하나로 합친다. */
export function refreshAfterLibraryChange(): void {
if (softTimer) return; // 이미 예약됨 — 창이 닫힐 때 한 번만 돈다
const since = Date.now() - softLast;
if (since >= SOFT_WINDOW) {
runSoft();
return;
}
softTimer = setTimeout(() => {
softTimer = undefined;
runSoft();
}, SOFT_WINDOW - since);
}
// ── 그리드 썸네일 크기(줌) ───────────────────────────────
export const GRID_ROW_MIN = 110;
export const GRID_ROW_MAX = 420;
@@ -236,16 +629,35 @@ export { shortcutsOpen };
export const openShortcuts = () => setShortcutsOpen(true);
export const closeShortcuts = () => setShortcutsOpen(false);
// ── 중복 검토 패널 ───────────────────────────────────────
// ── 중복 검토 모달 ───────────────────────────────────────
// 모달은 진행을 소유하지 않는다 — 열림/닫힘만 소유한다.
const [dedupeOpen, setDedupeOpen] = createSignal(false);
export { dedupeOpen };
/** 진입점은 사이드바 두 곳(정리 행 · 작업 행)뿐이다. footer에는 진입점을 두지 않는다. */
export const openDedupe = () => setDedupeOpen(true);
export const closeDedupe = () => setDedupeOpen(false);
/**
* .
* ·Delete·Ctrl+A가 .
*/
export function closeDedupe(): void {
setDedupeOpen(false);
focusItemView();
}
// ── 중복 탐지 실행(전역) — 패널을 닫아도 상태바에서 진행이 보이도록 store가 소유 ──
// ── 중복 탐지 실행(전역) ─────────────────────────────────
// 진행 상태의 단일 진실원은 이 모듈이다. 모달을 닫아도 진행이 유지되고 취소가 가능하다.
// 사이드바 작업 행과 footer는 여기 있는 시그널만 읽는다 — 어느 쪽도 자체 진행
// 시그널이나 타이머를 만들지 않는다. 두 곳이 각자 세면 같은 정보가 어긋난다.
const [dedupeProgress, setDedupeProgress] = createSignal<DedupeProgress | undefined>();
const [dedupeRunning, setDedupeRunning] = createSignal(false);
export { dedupeProgress, dedupeRunning };
/** 시작 시점에 확정된 작업. 실행 중에는 '지금 도는 조건', 끝난 뒤에는 '지금 보이는 결과의 조건'. */
const [dedupeJob, setDedupeJob] = createSignal<DedupeJob | undefined>();
/** 취소를 요청한 시각(ms). 요청은 즉시 멈추지 않는다 — 백엔드가 단계 경계에서 확인한다. */
const [dedupeCancelAt, setDedupeCancelAt] = createSignal<number | undefined>();
export { dedupeProgress, dedupeRunning, dedupeJob, dedupeCancelAt };
/** 취소를 눌렀지만 아직 멈추지 않은 구간. 이 구간의 표시는 '취소 중…' 하나로 통일한다. */
export const dedupeCancelPending = (): boolean => dedupeRunning() && dedupeCancelAt() != null;
export interface DedupeRunArgs {
sourceId?: number;
@@ -253,27 +665,79 @@ export interface DedupeRunArgs {
recursive?: boolean;
imageThreshold?: number;
includeVideo?: boolean;
/** 회전·반전본까지 찾는다 (후보 생성이 몇 배 비싸다) */
includeRotated?: boolean;
/** 시작 시점의 범위 라벨. 캡처용이며 IPC로는 가지 않는다. */
scopeLabel?: string;
}
/** 중복 탐지 실행. 진행 상태는 store에 유지되어 패널을 닫아도 상태바에서 보인다. */
/**
* .
* '현재 폴더' .
*/
export interface DedupeJob {
readonly scopeLabel: string;
/** 시작할 때 준 인자 그대로. '다시 탐지'가 같은 조건을 재현할 수 있다. */
readonly args: DedupeRunArgs;
readonly startedAt: number;
}
/** 중복 탐지 실행. 진행 상태는 store에 남아 모달을 닫아도 사이드바·footer에서 보인다. */
export async function runDedupe(args: DedupeRunArgs): Promise<void> {
if (dedupeRunning()) return;
setDedupeRunning(true);
setDedupeJob({
scopeLabel: args.scopeLabel ?? ko.dedupe.scopeAll,
args,
startedAt: Date.now(),
});
setDedupeCancelAt(undefined);
setDedupeProgress(undefined);
setDedupeRunning(true);
let unlisten: UnlistenFn | undefined;
try {
// 끝을 알리는 신호가 둘이다. 둘 다 들어야 한다.
// ① phase === "done" 진행 메시지 — 정상 완료 경로.
// ② dedupe-done 앱 이벤트 — 완료·취소·실패 전부. 취소되면 백엔드는 Err로 빠져나가
// "done"을 보내지 않으므로 ①만 기다리면 영원히 끝나지 않는다(취소해도 진행
// 표시가 남아 있던 원인이 이것이다). start_dedupe는 스레드를 띄우고 즉시
// Ok를 반환하므로 invoke의 resolve는 완료를 뜻하지 않는다 — 기다릴 수 없다.
// 아주 짧은 작업은 invoke보다 이벤트가 먼저 도착할 수 있어 등록을 먼저 끝낸다.
await new Promise<void>((resolve, reject) => {
startDedupe(args, (p) => {
setDedupeProgress(p);
if (p.phase === "done") resolve();
}).catch(reject);
onDedupeDone(resolve)
.then((un) => {
unlisten = un;
return startDedupe(args, (p) => {
setDedupeProgress(p);
if (p.phase === "done") resolve();
});
})
.catch(reject);
});
toast(dedupeCancelAt() != null ? ko.dedupe.canceled : ko.dedupe.done);
} catch (e) {
// 취소 구간의 실패는 사용자가 의도한 결과다 — 오류가 아니라 '취소됨'으로 말한다
if (dedupeCancelAt() == null) throw e;
toast(ko.dedupe.canceled);
} finally {
unlisten?.();
// 진행 표시는 즉시 언마운트된다(잔상 없음). dedupeJob은 남긴다 — 지금 보이는
// 결과가 어떤 범위의 결과인지 계속 말해야 한다.
setDedupeRunning(false);
setDedupeProgress(undefined);
setDedupeCancelAt(undefined);
}
}
export const requestCancelDedupe = () => void cancelDedupe();
/**
* . .
* .
*/
export function requestCancelDedupe(): void {
if (!dedupeRunning() || dedupeCancelAt() != null) return;
setDedupeCancelAt(Date.now());
void cancelDedupe().catch((e) => toast(String(e)));
}
// ── 상태 메시지 (토스트) ─────────────────────────────────
const [statusMessage, setStatusMessage] = createSignal<string | undefined>();
@@ -294,6 +758,8 @@ export function openLightbox(index: number): void {
const snap = snapshot();
if (!snap || index < 0 || index >= snap.count) return;
setLightboxIndex(index);
// 라이트박스의 이동·태그·삭제는 선택 항목에 작용한다 — 열 때 현재 항목만 선택으로 맞춘다.
selectSingle(snap.id(index), index);
}
export function closeLightbox(): void {
+1285 -25
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
// 별점 위젯 — 상세 패널과 라이트박스가 함께 쓴다.
//
// 규칙 하나가 이 컴포넌트의 전부다: **같은 별을 다시 누르면 0으로 지운다.**
// 별점은 "3점을 줬는데 취소하고 싶다"가 자주 나오는데, 0을 누를 자리를 따로 만들면
// 별 6개가 되어 무엇이 '없음'인지 헷갈린다. 누른 별이 이미 현재 값이면 해제로 읽는다.
import { For, type Component } from "solid-js";
import { IconStar } from "./icons";
import { ko } from "../i18n/ko";
export const StarRating: Component<{
value: number;
/** 0~5. 같은 값을 다시 누르면 0이 온다. */
onChange: (next: number) => void;
size?: number;
/** 여러 항목을 함께 고쳐서 값이 섞여 있을 때 — 채우지 않고 흐리게 둔다 */
mixed?: boolean;
disabled?: boolean;
}> = (props) => (
<div class="star-rating" classList={{ "is-mixed": props.mixed, "is-disabled": props.disabled }}>
<For each={[1, 2, 3, 4, 5]}>
{(n) => (
<button
class="star-rating__btn"
classList={{ "is-on": !props.mixed && props.value >= n }}
disabled={props.disabled}
aria-label={ko.rating.set(n)}
title={props.value === n ? ko.rating.clearHint : ko.rating.set(n)}
onClick={(e) => {
e.stopPropagation();
props.onChange(props.value === n ? 0 : n);
}}
>
<IconStar size={props.size ?? 14} />
</button>
)}
</For>
</div>
);
+141
View File
@@ -0,0 +1,141 @@
// 아이콘 — lucide-solid 단일 진입점.
//
// 두 가지 이유로 여기 한 파일을 거친다.
// ① 크기·굵기 기본값을 한곳에서 잡는다. lucide 기본은 24px/2인데 이 앱의 크롬은 22~28px 행이라
// 그대로 쓰면 아이콘이 버튼을 넘친다. 호출부마다 size를 적는 대신 여기서 16/1.75를 기본으로 준다.
// ② `lucide-solid` 루트가 아니라 `lucide-solid/icons/<name>` 개별 경로로 import 한다.
// 루트 배럴을 쓰면 개발 서버가 아이콘 수천 개를 전부 변환해 HMR이 느려진다.
//
// 아이콘은 장식이므로 `aria-hidden`을 붙인다 — 의미는 버튼의 title/텍스트가 전달한다.
import type { Component } from "solid-js";
import type { LucideProps } from "lucide-solid";
import ArrowDown from "lucide-solid/icons/arrow-down";
import ArrowUp from "lucide-solid/icons/arrow-up";
import Calendar from "lucide-solid/icons/calendar";
import Check from "lucide-solid/icons/check";
import Clock from "lucide-solid/icons/clock";
import FileText from "lucide-solid/icons/file-text";
import ChevronDown from "lucide-solid/icons/chevron-down";
import ChevronLeft from "lucide-solid/icons/chevron-left";
import ChevronRight from "lucide-solid/icons/chevron-right";
import FastForward from "lucide-solid/icons/fast-forward";
import Folder from "lucide-solid/icons/folder";
import FolderSearch from "lucide-solid/icons/folder-search";
import HardDrive from "lucide-solid/icons/hard-drive";
import Hourglass from "lucide-solid/icons/hourglass";
import House from "lucide-solid/icons/house";
import Image from "lucide-solid/icons/image";
import Info from "lucide-solid/icons/info";
import LayoutGrid from "lucide-solid/icons/layout-grid";
import Layers from "lucide-solid/icons/layers";
import Library from "lucide-solid/icons/library-big";
import ListIcon from "lucide-solid/icons/list";
import Maximize from "lucide-solid/icons/maximize";
import Minimize from "lucide-solid/icons/minimize";
import Moon from "lucide-solid/icons/moon";
import PanelRight from "lucide-solid/icons/panel-right";
import Pause from "lucide-solid/icons/pause";
import Pencil from "lucide-solid/icons/pencil";
import Play from "lucide-solid/icons/play";
import Plus from "lucide-solid/icons/plus";
import RefreshCw from "lucide-solid/icons/refresh-cw";
import Repeat from "lucide-solid/icons/repeat";
import Rewind from "lucide-solid/icons/rewind";
import RotateCcw from "lucide-solid/icons/rotate-ccw";
import RotateCw from "lucide-solid/icons/rotate-cw";
import FlipHorizontal from "lucide-solid/icons/flip-horizontal";
import Crop from "lucide-solid/icons/crop";
import Scissors from "lucide-solid/icons/scissors";
import Globe from "lucide-solid/icons/globe";
import Search from "lucide-solid/icons/search";
import Star from "lucide-solid/icons/star";
import Sun from "lucide-solid/icons/sun";
import Tag from "lucide-solid/icons/tag";
import Trash2 from "lucide-solid/icons/trash-2";
import TriangleAlert from "lucide-solid/icons/triangle-alert";
import Volume1 from "lucide-solid/icons/volume-1";
import Volume2 from "lucide-solid/icons/volume-2";
import VolumeX from "lucide-solid/icons/volume-x";
import X from "lucide-solid/icons/x";
import Copy from "lucide-solid/icons/copy";
import Zap from "lucide-solid/icons/zap";
import ZoomIn from "lucide-solid/icons/zoom-in";
export interface IconProps {
/** 기본 16. 행 안 소형 액션은 14, 뷰어 컨트롤은 18~20을 쓴다. */
size?: number;
/** 기본 1.75 — 작은 크기에서 2는 뭉개진다. */
strokeWidth?: number;
class?: string;
}
/** 기본값을 입힌 래퍼를 만든다. 색은 항상 `currentColor`라 부모의 text-* 클래스를 따른다. */
const make = (C: (p: LucideProps) => ReturnType<Component>): Component<IconProps> => {
return (p) => (
<C
size={p.size ?? 16}
strokeWidth={p.strokeWidth ?? 1.75}
class={p.class}
aria-hidden="true"
/>
);
};
export const IconSearch = make(Search);
export const IconZoom = make(ZoomIn);
export const IconRefresh = make(RefreshCw);
export const IconSun = make(Sun);
export const IconMoon = make(Moon);
export const IconPanelRight = make(PanelRight);
export const IconGrid = make(LayoutGrid);
export const IconList = make(ListIcon);
export const IconInfo = make(Info);
export const IconTrash = make(Trash2);
export const IconX = make(X);
export const IconPencil = make(Pencil);
export const IconWarning = make(TriangleAlert);
export const IconSmart = make(Zap);
export const IconFolder = make(Folder);
export const IconSource = make(HardDrive);
export const IconImage = make(Image);
export const IconStar = make(Star);
export const IconTag = make(Tag);
export const IconPlus = make(Plus);
export const IconCheck = make(Check);
export const IconChevronRight = make(ChevronRight);
export const IconChevronDown = make(ChevronDown);
export const IconChevronLeft = make(ChevronLeft);
export const IconArrowUp = make(ArrowUp);
export const IconArrowDown = make(ArrowDown);
export const IconHome = make(House);
export const IconClock = make(Clock);
export const IconCalendar = make(Calendar);
export const IconFile = make(FileText);
export const IconLayers = make(Layers);
export const IconLibrary = make(Library);
export const IconGlobe = make(Globe);
export const IconDuplicate = make(Copy);
/** 스캔(폴더를 읽는 중) 작업 행 */
export const IconFolderScan = make(FolderSearch);
/** 백그라운드 백필(썸네일·메타·지문) 작업 행 — '기다리는 일'이지 '보는 일'이 아니다 */
export const IconHourglass = make(Hourglass);
// 플레이어
export const IconPlay = make(Play);
export const IconPause = make(Pause);
export const IconRewind = make(Rewind);
export const IconForward = make(FastForward);
export const IconVolumeMute = make(VolumeX);
export const IconVolumeLow = make(Volume1);
export const IconVolumeHigh = make(Volume2);
export const IconRepeat = make(Repeat);
export const IconMaximize = make(Maximize);
export const IconMinimize = make(Minimize);
export const IconResetZoom = make(RotateCcw);
/** 편집: 왼쪽·오른쪽 90도 회전, 좌우 반전 */
export const IconRotateLeft = make(RotateCcw);
export const IconRotateRight = make(RotateCw);
export const IconFlipH = make(FlipHorizontal);
export const IconCrop = make(Crop);
export const IconScissors = make(Scissors);