영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
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 *.log
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# 로컬 크로스 컴파일 도구 (scripts/fetch-arm64-toolchain.ps1이 내려받는다 — 재생성 가능)
.toolchain/
# 포터블 빌드 산출물
dist-portable/
+10
View File
@@ -11,6 +11,7 @@
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-shell": "^2",
"lucide-solid": "^1.28.0",
"solid-js": "^1.9.0" "solid-js": "^1.9.0"
}, },
"devDependencies": { "devDependencies": {
@@ -2405,6 +2406,15 @@
"yallist": "^3.0.2" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "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/api": "^2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-shell": "^2",
"lucide-solid": "^1.28.0",
"solid-js": "^1.9.0" "solid-js": "^1.9.0"
}, },
"devDependencies": { "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. # WebView2 CDP (Chrome DevTools Protocol) E2E helper.
# Usage: . .\scripts\cdp.ps1; Invoke-Cdp "document.title" # 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 # 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). # 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 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 @echo off
rem Archive 개발 실행 — rustc가 라이브러리 없는 VS Community를 잡지 않도록 rem Archive dev run. rustc needs an MSVC toolchain env, so load vcvars64 first.
rem Build Tools vcvars64 환경에서 tauri dev를 띄운다. rem The install path differs per machine (Build Tools / Community / VS version),
call "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat" >nul 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.." 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 %* 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 @echo off
rem 코어 크레이트 헤드리스 테스트 (Build Tools vcvars64 환경) rem Headless tests for the core crates. Needs an MSVC toolchain env for linking.
call "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat" >nul 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" cd /d "%~dp0..\src-tauri"
cargo test --workspace %* 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", "tauri-plugin-window-state",
"tokio", "tokio",
"tracing", "tracing",
"tracing-appender",
"tracing-subscriber", "tracing-subscriber",
] ]
@@ -6260,6 +6261,12 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@@ -7085,6 +7092,19 @@ dependencies = [
"tracing-core", "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]] [[package]]
name = "tracing-attributes" name = "tracing-attributes"
version = "0.1.31" version = "0.1.31"
+2
View File
@@ -45,6 +45,8 @@ anyhow = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# 릴리스 GUI 빌드에는 콘솔이 없다 — 파일로 남기지 않으면 진단이 통째로 사라진다.
tracing-appender = "0.2"
dirs = "6" dirs = "6"
rusqlite = { version = "0.40", features = ["bundled"] } rusqlite = { version = "0.40", features = ["bundled"] }
notify-debouncer-full = "0.7" notify-debouncer-full = "0.7"
@@ -156,6 +156,177 @@ CREATE TABLE settings (
) WITHOUT ROWID; ) 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을 /// FTS5 trigram — 한글 파일명 부분일치 검색. bundled SQLite가 trigram을
/// 지원하지 않는 환경이면 생성 실패를 허용하고 LIKE 폴백을 쓴다. /// 지원하지 않는 환경이면 생성 실패를 허용하고 LIKE 폴백을 쓴다.
const V1_FTS_DDL: &str = r#" const V1_FTS_DDL: &str = r#"
@@ -183,6 +354,7 @@ END;
/// 마이그레이션을 적용하고 FTS 사용 가능 여부를 반환한다. /// 마이그레이션을 적용하고 FTS 사용 가능 여부를 반환한다.
pub fn migrate(conn: &mut Connection) -> rusqlite::Result<bool> { pub fn migrate(conn: &mut Connection) -> rusqlite::Result<bool> {
let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?; let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
let mut fresh_fts: Option<bool> = None;
if version < 1 { if version < 1 {
let tx = conn.transaction()?; let tx = conn.transaction()?;
@@ -199,6 +371,75 @@ pub fn migrate(conn: &mut Connection) -> rusqlite::Result<bool> {
if !fts_ok { if !fts_ok {
tracing::warn!("FTS5 trigram 사용 불가 — LIKE 검색으로 폴백"); 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); 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}"))) .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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+646 -26
View File
@@ -1,7 +1,9 @@
//! 파일 작업 계층 — 이동/이름변경/휴지통 + ops_journal 기반 undo. //! 파일 작업 계층 — 이동/이름변경/휴지통 + ops_journal 기반 undo.
//! //!
//! 원칙: ① 물리적 파일 변경 성공 후에만 DB 갱신 ② 모든 작업은 저널 기록 //! 원칙: ① 물리적 파일 변경 성공 후에만 DB 갱신 ② 모든 작업은 저널 기록
//! ③ 삭제는 OS 휴지통 경유만 (영구삭제 없음 — 계획서 §7). //! ③ 삭제는 2단계 — 평소엔 '삭제 예정' 태그로 표시(되돌리기 가능)하고,
//! 사용자가 명시적으로 '비우기'를 실행할 때만 [`delete_permanent`]로 디스크에서 지운다.
//! [`trash_files`](OS 휴지통 경유)는 Shift+Delete 즉시 삭제 경로로 남아 있다.
use archive_db::Db; use archive_db::Db;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -52,14 +54,17 @@ struct FileRow {
id: i64, id: i64,
dir: String, dir: String,
name: String, name: String,
/// 소스 루트 — OS 휴지통이 없는 경로에서 앱 휴지통 위치를 정하는 데 쓴다
root: String,
} }
fn load_rows(db: &Db, file_ids: &[i64]) -> Result<Vec<FileRow>> { fn load_rows(db: &Db, file_ids: &[i64]) -> Result<Vec<FileRow>> {
let ids = file_ids.to_vec(); let ids = file_ids.to_vec();
Ok(db.with_read(move |conn| { Ok(db.with_read(move |conn| {
let mut stmt = conn.prepare_cached( 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 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", WHERE f.id = ?1 AND f.deleted_at IS NULL",
)?; )?;
let mut out = Vec::with_capacity(ids.len()); 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)?, id: r.get(0)?,
dir: r.get(1)?, dir: r.get(1)?,
name: r.get(2)?, name: r.get(2)?,
root: r.get(3)?,
}) })
}) { }) {
out.push(row); out.push(row);
@@ -219,7 +225,55 @@ pub fn rename_file(db: &Db, batch_id: &str, file_id: i64, new_name: &str) -> Res
Ok(()) 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 휴지통이 복구 경로). /// 파일들을 OS 휴지통으로 보낸다 (인앱 undo 불가 — OS 휴지통이 복구 경로).
/// 네트워크 공유(UNC 등)에는 OS 휴지통이 없어 실패하므로, 그 경우
/// 소스 루트의 `.archive_trash/`로 옮기는 앱 휴지통으로 폴백한다.
pub fn trash_files( pub fn trash_files(
db: &Db, db: &Db,
batch_id: &str, batch_id: &str,
@@ -237,25 +291,59 @@ pub fn trash_files(
let src = Path::new(&row.dir).join(&row.name); let src = Path::new(&row.dir).join(&row.name);
match trash::delete(&src) { match trash::delete(&src) {
Ok(()) => { Ok(()) => {
let (bid, fid) = (batch_id.to_string(), row.id); record_removal(db, batch_id, row.id, &src, None, "trash")?;
let src_s = src.to_string_lossy().into_owned(); summary.done += 1;
db.with_write(move |conn| { }
let tx = conn.transaction()?; Err(os_err) => match app_trash_move(&row.root, row.id, &src) {
tx.execute( Ok(dst) => {
"UPDATE files SET deleted_at = unixepoch('now','subsec') * 1000 WHERE id = ?1", tracing::info!(
[fid], src = %src.display(),
)?; "OS 휴지통 불가({os_err}) — 앱 휴지통으로 이동: {}", dst.display()
tx.execute( );
"INSERT INTO ops_journal(batch_id, op, file_id, src_path) record_removal(db, batch_id, row.id, &src, Some(&dst), "trash")?;
VALUES (?1, 'trash', ?2, ?3)",
rusqlite::params![bid, fid, src_s],
)?;
tx.commit()
})?;
summary.done += 1; summary.done += 1;
} }
Err(e) => { Err(e) => {
tracing::warn!(src = %src.display(), "휴지통 이동 실패: {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}");
summary.failed += 1; summary.failed += 1;
} }
} }
@@ -279,7 +367,7 @@ pub fn list_batches(db: &Db, limit: i64) -> Result<Vec<UndoBatch>> {
Ok(db.with_read(move |conn| { Ok(db.with_read(move |conn| {
let mut stmt = conn.prepare_cached( let mut stmt = conn.prepare_cached(
"SELECT batch_id, count(*), group_concat(DISTINCT op), max(performed_at), "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 FROM ops_journal WHERE undone = 0
GROUP BY batch_id ORDER BY max(performed_at) DESC LIMIT ?1", 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 항목은 건너뛴다. /// 배치 undo — 저널 역순 재생. trash/delete 항목은 건너뛴다.
pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> { /// 되돌리기. `tools`가 없으면 영상 편집 되돌리기만 실패로 남고 나머지는 정상 처리된다.
pub fn undo_batch(
db: &Db,
batch_id: &str,
tools: Option<&crate::ffmpeg::FfTools>,
encoder: &str,
) -> Result<OpSummary> {
struct JEntry { struct JEntry {
id: i64, id: i64,
op: String, op: String,
@@ -305,11 +399,13 @@ pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
src_path: Option<String>, src_path: Option<String>,
dst_path: Option<String>, dst_path: Option<String>,
tag_id: Option<i64>, tag_id: Option<i64>,
/// 편집 되돌리기용 역연산 이름 (op = "edit"일 때만 채워진다)
payload: Option<String>,
} }
let bid = batch_id.to_string(); let bid = batch_id.to_string();
let entries: Vec<JEntry> = db.with_read(move |conn| { let entries: Vec<JEntry> = db.with_read(move |conn| {
let mut stmt = conn.prepare_cached( 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", WHERE batch_id = ?1 AND undone = 0 ORDER BY id DESC",
)?; )?;
let rows = stmt.query_map([&bid], |r| { 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)?, src_path: r.get(3)?,
dst_path: r.get(4)?, dst_path: r.get(4)?,
tag_id: r.get(5)?, tag_id: r.get(5)?,
payload: r.get(6)?,
}) })
})?; })?;
rows.collect() 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" => { "tag" => {
if let (Some(fid), Some(tid)) = (e.file_id, e.tag_id) { if let (Some(fid), Some(tid)) = (e.file_id, e.tag_id) {
db.with_write(move |conn| { db.with_write(move |conn| {
@@ -413,7 +545,7 @@ pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
false false
} }
} }
// trash는 인앱 undo 불가 (OS 휴지통에서 복원) // trash는 OS 휴지통에서, delete는 아예 복구 불가 — 둘 다 인앱 undo 대상 아님
_ => { _ => {
summary.skipped += 1; summary.skipped += 1;
false false
@@ -497,7 +629,7 @@ mod tests {
assert_eq!(file_id_of(&db, "사진1.jpg"), fid); assert_eq!(file_id_of(&db, "사진1.jpg"), fid);
// undo → 원위치 // undo → 원위치
let u = undo_batch(&db, &bid).unwrap(); let u = undo_batch(&db, &bid, None, "libopenh264").unwrap();
assert_eq!(u.done, 1); assert_eq!(u.done, 1);
assert!(media.path().join("a/사진1.jpg").is_file()); assert!(media.path().join("a/사진1.jpg").is_file());
assert!(!media.path().join("b/사진1.jpg").exists()); 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()); 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()); 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] #[test]
fn tag_undo_roundtrip() { fn tag_undo_roundtrip() {
let (_d, _m, db, _sid) = setup(); let (_d, _m, db, _sid) = setup();
@@ -542,7 +788,381 @@ mod tests {
crate::tags::assign(&db, &bid, vec![fid], tag).unwrap(); crate::tags::assign(&db, &bid, vec![fid], tag).unwrap();
assert_eq!(crate::tags::file_tags(&db, fid).unwrap().len(), 1); 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); 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 의존성 없음 — 헤드리스 테스트 가능. //! 스캔·해시·썸네일·중복탐지 엔진. Tauri 의존성 없음 — 헤드리스 테스트 가능.
pub mod dedupe; pub mod dedupe;
pub mod edit;
pub mod ffmpeg; pub mod ffmpeg;
pub mod fileops; pub mod fileops;
pub mod hash; pub mod hash;
@@ -11,6 +12,8 @@ pub mod phash;
pub mod pipeline; pub mod pipeline;
pub mod prio; pub mod prio;
pub mod scan; pub mod scan;
pub mod sigmatch;
pub mod sigq;
pub mod tags; pub mod tags;
pub mod thumbq; pub mod thumbq;
pub mod thumbs; pub mod thumbs;
@@ -110,6 +110,96 @@ pub fn remove_source(db: &Db, thumb_root: &Path, source_id: i64) -> Result<u64,
Ok(removed) 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 — 조각모음 겸용). /// DB를 백업 파일로 복제한다 (VACUUM INTO — 조각모음 겸용).
pub fn backup_db(db: &Db, backup_path: &Path) -> Result<(), String> { pub fn backup_db(db: &Db, backup_path: &Path) -> Result<(), String> {
let _ = std::fs::remove_file(backup_path); 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()); 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] #[test]
fn purge_old_deleted_rows() { fn purge_old_deleted_rows() {
let dbdir = tempfile::tempdir().unwrap(); let dbdir = tempfile::tempdir().unwrap();
+65 -12
View File
@@ -18,14 +18,52 @@ struct MetaResult {
ok: bool, ok: bool,
} }
fn exif_of(path: &Path) -> (u32, Option<i64>) { /// 헤더를 읽어둘 크기. 치수(SOF/IHDR)와 EXIF APP1은 사실상 항상 이 안에 있다.
let Ok(file) = std::fs::File::open(path) else { /// 네트워크에서는 open 왕복이 비용의 대부분이라, 256KB를 한 번에 읽는 편이
return (1, None); /// 몇 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 mut reader = std::io::BufReader::new(&file);
let Ok(ex) = exif::Reader::new().read_from_container(&mut reader) else { exif_from(&mut reader)
return (1, None); }
};
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 let orientation = ex
.get_field(exif::Tag::Orientation, exif::In::PRIMARY) .get_field(exif::Tag::Orientation, exif::In::PRIMARY)
.and_then(|f| f.value.get_uint(0)) .and_then(|f| f.value.get_uint(0))
@@ -38,7 +76,7 @@ fn exif_of(path: &Path) -> (u32, Option<i64>) {
_ => None, _ => None,
}) })
.and_then(|s| parse_exif_datetime(&s)); .and_then(|s| parse_exif_datetime(&s));
(orientation, taken_at) Some((orientation, taken_at))
} }
/// "YYYY:MM:DD HH:MM:SS" → epoch ms (로컬 시간대 무시, UTC로 근사 — 정렬 용도) /// "YYYY:MM:DD HH:MM:SS" → epoch ms (로컬 시간대 무시, UTC로 근사 — 정렬 용도)
@@ -100,12 +138,11 @@ pub fn extract_image_meta(
batch batch
.par_iter() .par_iter()
.map(|(id, path)| { .map(|(id, path)| {
let dims = imagesize::size(path).ok(); let (dims, orientation, taken_at) = meta_of(path);
let (orientation, taken_at) = exif_of(path);
MetaResult { MetaResult {
id: *id, id: *id,
width: dims.map(|d| d.width as u32), width: dims.map(|(w, _)| w),
height: dims.map(|d| d.height as u32), height: dims.map(|(_, h)| h),
orientation, orientation,
taken_at, taken_at,
ok: dims.is_some(), ok: dims.is_some(),
@@ -221,6 +258,22 @@ mod tests {
assert_eq!(parse_exif_datetime("2020:13:01 00:00:00"), None); 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] #[test]
fn extracts_dimensions_for_pending_images() { fn extracts_dimensions_for_pending_images() {
let dbdir = tempfile::tempdir().unwrap(); let dbdir = tempfile::tempdir().unwrap();
@@ -48,6 +48,21 @@ pub fn is_media_ext(ext: &str) -> bool {
classify_ext(ext) != FileKind::Other 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -58,6 +58,234 @@ pub fn find_near_pairs(items: &[(i64, u64)], threshold: u32) -> Vec<(i64, i64, u
.collect() .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로 근접쌍을 그룹으로 묶는다. /// Union-Find로 근접쌍을 그룹으로 묶는다.
pub fn cluster(ids: &[i64], pairs: &[(i64, i64, u32)]) -> Vec<Vec<i64>> { pub fn cluster(ids: &[i64], pairs: &[(i64, i64, u32)]) -> Vec<Vec<i64>> {
use std::collections::HashMap; use std::collections::HashMap;
@@ -133,6 +361,142 @@ mod tests {
assert_eq!(groups[0].len(), 3); 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] #[test]
fn resized_image_has_close_hash() { fn resized_image_has_close_hash() {
// 같은 이미지의 원본과 축소본은 dHash가 거의 같아야 한다 // 같은 이미지의 원본과 축소본은 dHash가 거의 같아야 한다
+338 -32
View File
@@ -19,6 +19,15 @@ pub enum ScanError {
Db(#[from] archive_db::DbError), Db(#[from] archive_db::DbError),
#[error("취소됨")] #[error("취소됨")]
Cancelled, 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)] #[derive(Debug, Clone, Default, serde::Serialize)]
@@ -28,6 +37,8 @@ pub struct ScanStats {
pub changed: u64, pub changed: u64,
pub removed: u64, pub removed: u64,
pub moved: u64, pub moved: u64,
/// 안전장치가 막아 **지우지 않은** 건수. 0이 아니면 사용자에게 알려야 한다.
pub guarded_deletes: u64,
} }
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
@@ -50,7 +61,10 @@ struct SnapRow {
quick_hash: Option<i64>, 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 { fn now_ms() -> i64 {
std::time::SystemTime::now() std::time::SystemTime::now()
@@ -99,34 +113,124 @@ fn ensure_folder(
Ok(id) 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( pub fn purge_missing(db: &Arc<Db>, source_id: i64) -> Result<u64, ScanError> {
"SELECT folder_id, id, size, mtime_ms, name, quick_hash let rows: Vec<(i64, String, String)> = db.with_read(|conn| {
FROM files WHERE source_id = ?1 AND deleted_at IS NULL", 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| { let rows = stmt.query_map([source_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
Ok(( rows.collect()
r.get::<_, i64>(0)?, })?;
SnapRow { let gone: Vec<i64> = rows
id: r.get(1)?, .into_iter()
size: r.get(2)?, .filter(|(_, dir, name)| !Path::new(dir).join(name).exists())
mtime_ms: r.get(3)?, .map(|(id, _, _)| id)
name: r.get(4)?, .collect();
quick_hash: r.get(5)?, 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() rows.collect()
})?; })?;
Ok(rows Ok(rows.into_iter().map(|r| (r.name.clone(), r)).collect())
.into_iter() }
.map(|(folder_id, row)| ((folder_id, row.name.clone()), row))
.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> { 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, THEN files.phash ELSE NULL END,
vhash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms vhash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
THEN files.vhash ELSE NULL END, 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, size = excluded.size,
mtime_ms = excluded.mtime_ms", mtime_ms = excluded.mtime_ms",
)?; )?;
@@ -353,6 +459,14 @@ pub fn scan_source(
cancel: &AtomicBool, cancel: &AtomicBool,
mut on_progress: impl FnMut(ScanProgress), mut on_progress: impl FnMut(ScanProgress),
) -> Result<ScanStats, ScanError> { ) -> Result<ScanStats, ScanError> {
// 루트를 읽을 수 없으면(드라이브 미마운트·NAS 오프라인·폴더 이동·이름변경) 아예 시작하지 않는다.
// 빈 열거는 "파일이 전부 사라졌다"와 구분할 수 없어 그대로 진행하면 소스의 라이브러리
// 전체가 소프트 삭제된다(워처 폴링이 1분마다 재스캔하므로 자동으로 그렇게 된다).
// 접근 불가는 인덱스를 손대지 않고 오류로 알리는 것이 유일하게 안전한 처리다.
if !root_readable(root) {
return Err(ScanError::RootUnavailable(root.display().to_string()));
}
let scan_start = now_ms(); let scan_start = now_ms();
let scan_id: i64 = db.with_write(move |conn| { let scan_id: i64 = db.with_write(move |conn| {
conn.execute( conn.execute(
@@ -363,7 +477,18 @@ pub fn scan_source(
})?; })?;
let mut stats = ScanStats::default(); 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 folder_cache: HashMap<PathBuf, i64> = HashMap::new();
let mut batch: Vec<(i64, RawEntry)> = Vec::with_capacity(BATCH); let mut batch: Vec<(i64, RawEntry)> = Vec::with_capacity(BATCH);
let mut last_emit = Instant::now(); let mut last_emit = Instant::now();
@@ -398,12 +523,16 @@ pub fn scan_source(
.map(|n| n.to_string_lossy().into_owned()) .map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(); .unwrap_or_default();
// 스냅샷 비교 — 무변경이면 DB 쓰기 생략 (remove로 '발견됨' 표시) // 기존 행 비교 — 무변경이면 DB 쓰기 생략한다.
match snapshot.remove(&(folder_id, name)) { // '발견됨' 표시는 맵에서 지우는 게 아니라 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 => { 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; stats.changed += 1;
batch.push((folder_id, entry)); batch.push((folder_id, entry));
} }
@@ -431,11 +560,57 @@ pub fn scan_source(
flush_batch(db, scan_id, std::mem::take(&mut batch))?; flush_batch(db, scan_id, std::mem::take(&mut batch))?;
} }
// 스냅샷에 남은 행 = 디스크에서 사라진 파일 → 이동 감지 후 소프트 삭제 // ── 사라진 행 찾기 ────────────────────────────────────────────────────
let missing: Vec<SnapRow> = snapshot.into_values().collect(); // 스캔 전에 있던 활성 행 중 이번에 만나지 못한 것. 소스 전체 맵을 메모리에 들고 있는
// 대신 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)?; let (moved, to_delete) = detect_moves(db, source_id, scan_id, scan_start, missing)?;
stats.moved = moved; stats.moved = moved;
stats.removed = soft_delete(db, to_delete)?; stats.removed = soft_delete(db, to_delete)?;
}
// 디스크에서 사라진 폴더 행 정리 (파일 참조가 없는 것만, 자식 먼저) // 디스크에서 사라진 폴더 행 정리 (파일 참조가 없는 것만, 자식 먼저)
prune_stale_folders(db, source_id, &folder_cache)?; prune_stale_folders(db, source_id, &folder_cache)?;
@@ -556,6 +731,137 @@ mod tests {
assert_eq!(count_active(&db), 1); 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] #[test]
fn soft_deleted_file_resurrects_on_return() { fn soft_deleted_file_resurrects_on_return() {
let (_d, media, db, sid) = setup(); 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 path = e.path();
let ext = crate::model::ext_of(&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; return None;
} }
let meta = e.metadata().ok()?; 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 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>> { pub fn list_tags(db: &Db) -> Result<Vec<Tag>> {
db.with_read(|conn| { db.with_read(|conn| {
let mut stmt = conn.prepare_cached( 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>> { pub fn file_tags(db: &Db, file_id: i64) -> Result<Vec<Tag>> {
db.with_read(|conn| { db.with_read(|conn| {
let mut stmt = conn.prepare_cached( 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())) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -224,6 +460,54 @@ mod tests {
assert_eq!(journal_count, 4); // tag 3 + untag 1 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] #[test]
fn export_contains_paths() { fn export_contains_paths() {
let (_d, db, ids) = setup(); let (_d, db, ids) = setup();
+398 -42
View File
@@ -6,45 +6,195 @@ use archive_db::Db;
use crossbeam_channel::{unbounded, Receiver, RecvTimeoutError, Sender}; use crossbeam_channel::{unbounded, Receiver, RecvTimeoutError, Sender};
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf; 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; use std::time::Duration;
pub struct ThumbQueue { pub struct ThumbQueue {
hi_tx: Sender<i64>, hi_tx: Sender<i64>,
lo_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 { struct Ctx {
db: Arc<Db>, db: Arc<Db>,
thumb_root: PathBuf, thumb_root: PathBuf,
tools: Option<crate::ffmpeg::FfTools>, tools: Option<crate::ffmpeg::FfTools>,
in_flight: Mutex<HashSet<i64>>, in_flight: Mutex<HashSet<i64>>,
done_tx: Sender<i64>, /// 결과를 쓰기 합치기 스레드로 보낸다 (워커는 writer를 기다리지 않는다)
write_tx: Sender<ThumbWrite>,
gate: Gate,
/// 처리 시도 누적 — 게이트 자동조정의 유일한 입력
processed: AtomicUsize,
} }
impl ThumbQueue { impl ThumbQueue {
/// 워커 풀을 시작한다. `on_done`은 완료된 file_id 배치를 받는다. /// 워커 풀을 시작한다. `on_done`은 완료된 file_id 배치를 받는다.
/// `tools`가 None이면 영상 썸네일은 건너뛴다 (이미지는 정상 처리). /// `tools`가 None이면 영상 썸네일은 건너뛴다 (이미지는 정상 처리).
///
/// `max_workers`는 스레드 수이자 동시 처리 상한이다. 실제 동시 처리 개수는
/// 처리율을 보며 1..=max_workers 사이에서 자동으로 오르내린다([`Gate`]).
pub fn start( pub fn start(
db: Arc<Db>, db: Arc<Db>,
thumb_root: PathBuf, thumb_root: PathBuf,
tools: Option<crate::ffmpeg::FfTools>, tools: Option<crate::ffmpeg::FfTools>,
workers: usize, max_workers: usize,
on_done: impl Fn(Vec<i64>) + Send + 'static, on_done: impl Fn(Vec<i64>) + Send + 'static,
) -> ThumbQueue { ) -> ThumbQueue {
let (hi_tx, hi_rx) = unbounded::<i64>(); let (hi_tx, hi_rx) = unbounded::<i64>();
let (lo_tx, lo_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 (done_tx, done_rx) = unbounded::<i64>();
let max_workers = max_workers.max(1);
let ctx = Arc::new(Ctx { let ctx = Arc::new(Ctx {
db, db,
thumb_root, thumb_root,
tools, tools,
in_flight: Mutex::new(HashSet::new()), 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 ctx = ctx.clone();
let hi_rx = hi_rx.clone(); let hi_rx = hi_rx.clone();
let lo_rx = lo_rx.clone(); let lo_rx = lo_rx.clone();
@@ -54,6 +204,16 @@ impl ThumbQueue {
.expect("썸네일 워커 생성 실패"); .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() std::thread::Builder::new()
.name("thumb-emit".into()) .name("thumb-emit".into())
@@ -83,7 +243,17 @@ impl ThumbQueue {
}) })
.expect("썸네일 이미터 생성 실패"); .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 /// 예전에는 대기 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 { .with_read(|conn| match source_id {
Some(sid) => { Some(sid) => {
let mut stmt = conn.prepare_cached( 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", "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),
)?; )?;
let rows = stmt.query_map([sid], |r| r.get(0))?;
rows.collect() rows.collect()
} }
None => { None => {
let mut stmt = conn.prepare_cached( let mut stmt = conn.prepare_cached(
"SELECT id FROM files WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL", "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),
)?; )?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.collect() 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(); .ok();
for id in ids { }
let _ = self.lo_tx.send(id); }
/// 처리율을 보며 동시 처리 개수를 언덕 오르기로 맞춘다.
/// 올려서 빨라지면 계속 올리고, 느려지면 방향을 뒤집는다. 평지면 그 자리에 머문다.
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")) .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>) { fn worker_loop(ctx: Arc<Ctx>, hi_rx: Receiver<i64>, lo_rx: Receiver<i64>) {
// 썸네일 디코드/리사이즈는 CPU 집약적 — UI 스레드가 굶지 않도록 우선순위를 낮춘다. // 썸네일 디코드/리사이즈는 CPU 집약적 — UI 스레드가 굶지 않도록 우선순위를 낮춘다.
crate::prio::set_current_thread_low(); crate::prio::set_current_thread_low();
loop { loop {
// hi 우선, 없으면 lo를 짧게 대기 // hi 우선, 없으면 lo를 짧게 대기
let id = match hi_rx.try_recv() { let (id, urgent) = match hi_rx.try_recv() {
Ok(id) => id, Ok(id) => (id, true),
Err(_) => match lo_rx.recv_timeout(Duration::from_millis(200)) { Err(_) => match lo_rx.recv_timeout(Duration::from_millis(200)) {
Ok(id) => id, Ok(id) => (id, false),
Err(RecvTimeoutError::Timeout) => { Err(RecvTimeoutError::Timeout) => {
// hi 채널이 닫혔으면 종료 // hi 채널이 닫혔으면 종료
if hi_rx.is_empty() && lo_rx.is_empty() && hi_rx.try_recv().is_err() { if hi_rx.is_empty() && lo_rx.is_empty() && hi_rx.try_recv().is_err() {
match hi_rx.recv_timeout(Duration::from_millis(1)) { match hi_rx.recv_timeout(Duration::from_millis(1)) {
Err(RecvTimeoutError::Disconnected) => break, Err(RecvTimeoutError::Disconnected) => break,
Ok(id) => id, Ok(id) => (id, true),
Err(RecvTimeoutError::Timeout) => continue, Err(RecvTimeoutError::Timeout) => continue,
} }
} else { } 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) { if !ctx.in_flight.lock().unwrap().insert(id) {
continue; // 다른 워커가 처리 중 continue; // 다른 워커가 처리 중
} }
let result = process_one(&ctx, id); if urgent {
ctx.in_flight.lock().unwrap().remove(&id); // 뷰포트 요청(hi)은 게이트를 통과하지 않는다. 사용자가 지금 보고 있는 화면이고
if let Ok(true) = result { // 한 화면 분량뿐이라 디스크를 흔들지 않는다. 게이트는 대량 백필을 위한 것이다.
let _ = ctx.done_tx.send(id); 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 { match generated {
Some((dw, dh)) => { Some((dw, dh)) => {
let bytes = std::fs::metadata(&dst).map(|m| m.len() as i64).unwrap_or(0); let bytes = std::fs::metadata(&dst).map(|m| m.len() as i64).unwrap_or(0);
ctx.db.with_write(move |conn| { let _ = ctx.write_tx.send(ThumbWrite {
let tx = conn.transaction()?; id,
tx.execute( made: Some((key, dw, dh, bytes)),
"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()
})?;
Ok(true) Ok(true)
} }
None => { None => {
ctx.db let _ = ctx.write_tx.send(ThumbWrite { id, made: None });
.with_write(move |conn| conn.execute("UPDATE files SET thumb_state = 2 WHERE id = ?1", [id]))?;
Ok(false) Ok(false)
} }
} }
@@ -286,6 +607,35 @@ mod tests {
use super::*; use super::*;
use std::sync::atomic::{AtomicUsize, Ordering}; 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] #[test]
fn generates_thumbs_for_scanned_images() { fn generates_thumbs_for_scanned_images() {
let dbdir = tempfile::tempdir().unwrap(); let dbdir = tempfile::tempdir().unwrap();
@@ -311,17 +661,23 @@ mod tests {
let cancel = std::sync::atomic::AtomicBool::new(false); let cancel = std::sync::atomic::AtomicBool::new(false);
crate::pipeline::scan_source(&db, sid, media.path(), &cancel, |_| {}).unwrap(); crate::pipeline::scan_source(&db, sid, media.path(), &cancel, |_| {}).unwrap();
// 완료를 **이벤트로** 기다린다. 폴링+짧은 마감은 부하가 걸리면 흔들린다 —
// 썸네일 워커는 BELOW_NORMAL이라 다른 테스트가 코어를 차지하면 뒤로 밀린다
// (전체 스위트 병렬 실행에서 실제로 한 번 마감을 넘겼다).
let done_count = Arc::new(AtomicUsize::new(0)); let done_count = Arc::new(AtomicUsize::new(0));
let (tx, rx) = unbounded::<usize>();
let dc = done_count.clone(); let dc = done_count.clone();
let q = ThumbQueue::start(db.clone(), thumb_root.path().to_path_buf(), None, 2, move |ids| { 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 {
while done_count.load(Ordering::SeqCst) < 5 && std::time::Instant::now() < deadline { if rx.recv_timeout(Duration::from_secs(60)).is_err() {
std::thread::sleep(Duration::from_millis(50)); break; // 60초 동안 아무 진전도 없으면 정말 멈춘 것이다
}
} }
assert_eq!(done_count.load(Ordering::SeqCst), 5); 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 PREVIEW_LONG_EDGE: u32 = 1280;
pub const JPEG_QUALITY: u8 = 80; pub const JPEG_QUALITY: u8 = 80;
/// 파일 전체를 메모리에 올려 처리할 상한. 이보다 크면 스트리밍 경로로 폴백한다.
/// (한 워커가 이만큼 쓰고, 워커는 최대 16개 → 최악 512MB. 실제로는 대부분 수 MB)
const INMEM_MAX: u64 = 32 * 1024 * 1024;
/// EXIF Orientation 값(18)을 읽는다. 없으면 1(정상). /// EXIF Orientation 값(18)을 읽는다. 없으면 1(정상).
pub fn read_orientation(path: &Path) -> u32 { pub fn read_orientation(path: &Path) -> u32 {
let Ok(file) = std::fs::File::open(path) else { let Ok(file) = std::fs::File::open(path) else {
return 1; return 1;
}; };
let mut reader = std::io::BufReader::new(&file); 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() exif::Reader::new()
.read_from_container(&mut reader) .read_from_container(r)
.ok() .ok()
.and_then(|ex| { .and_then(|ex| {
ex.get_field(exif::Tag::Orientation, exif::In::PRIMARY) ex.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
@@ -73,10 +87,18 @@ pub fn make_image_thumb(
long_edge: u32, long_edge: u32,
quality: u8, quality: u8,
) -> Result<(u32, u32, u32, u32)> { ) -> 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) // 상한 초과(대형 PNG/TIFF 등) — 스트리밍 경로. 여기서만 파일을 두 번 연다.
// 하지 않고 750×500(≈1MB)만 디코드해 CPU·메모리를 대폭 절감한다. 실패 시 아래로 폴백. let orientation = read_orientation(src);
if is_jpeg_ext(src) { if is_jpeg_ext(src) {
if let Some(scaled) = decode_jpeg_scaled(src, long_edge) { if let Some(scaled) = decode_jpeg_scaled(src, long_edge) {
let oriented = apply_orientation(scaled, orientation); let oriented = apply_orientation(scaled, orientation);
@@ -85,7 +107,6 @@ pub fn make_image_thumb(
return Ok((w, h, dw, dh)); return Ok((w, h, dw, dh));
} }
} }
let decoded = image::ImageReader::open(src)? let decoded = image::ImageReader::open(src)?
.with_guessed_format()? .with_guessed_format()?
.decode()?; .decode()?;
@@ -95,6 +116,35 @@ pub fn make_image_thumb(
Ok((w, h, dw, dh)) 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 { fn is_jpeg_ext(src: &Path) -> bool {
src.extension() src.extension()
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
@@ -105,9 +155,17 @@ fn is_jpeg_ext(src: &Path) -> bool {
/// jpeg-decoder의 DCT 스케일 기능으로 축소 디코드한다. 긴 변이 대략 `target` 이상이 되는 /// jpeg-decoder의 DCT 스케일 기능으로 축소 디코드한다. 긴 변이 대략 `target` 이상이 되는
/// 가장 큰 축소(1/1·1/2·1/4·1/8)를 고른다. RGB24/L8만 처리하고 그 외(CMYK 등)는 None(폴백). /// 가장 큰 축소(1/1·1/2·1/4·1/8)를 고른다. RGB24/L8만 처리하고 그 외(CMYK 등)는 None(폴백).
fn decode_jpeg_scaled(src: &Path, target: u32) -> Option<DynamicImage> { fn decode_jpeg_scaled(src: &Path, target: u32) -> Option<DynamicImage> {
use jpeg_decoder::PixelFormat;
let file = std::fs::File::open(src).ok()?; 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()?; dec.read_info().ok()?;
// 회전으로 가로/세로가 뒤바뀔 수 있으므로 두 축 모두 target으로 요청(축소만 하므로 안전). // 회전으로 가로/세로가 뒤바뀔 수 있으므로 두 축 모두 target으로 요청(축소만 하므로 안전).
let t = target.clamp(1, u16::MAX as u32) as u16; 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]) 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 파일에서 내장 JPEG 프리뷰를 추출해 썸네일을 만든다. 반환: (썸네일 w, h).
/// RAW 헤더/IFD 파싱 대신 파일에서 가장 큰 JPEG 스트림을 스캔한다(견고하고 포맷 불문). /// RAW 헤더/IFD 파싱 대신 파일에서 가장 큰 JPEG 스트림을 스캔한다(견고하고 포맷 불문).
pub fn make_raw_thumb(src: &Path, dst: &Path, long_edge: u32, quality: u8) -> Result<(u32, u32)> { pub fn make_raw_thumb(src: &Path, dst: &Path, long_edge: u32, quality: u8) -> Result<(u32, u32)> {
// RAW 프리뷰는 파일 뒷부분에 있을 수 있어 전체를 읽는다(보통 20–40MB). // 앞부분부터 단계적으로 읽는다. RAW는 보통 20–40MB인데 프리뷰 JPEG은 대개 헤더 근처에
let data = std::fs::read(src)?; // 있다 — 매번 전체를 읽으면 RAW 10TB짜리 라이브러리에서 10TB를 다 긁는다. 4MB로
let jpeg = scan_largest_jpeg(&data) // 쓸 만한 프리뷰(긴 변 ≥ 요청 크기)를 찾으면 거기서 멈춘다.
.ok_or_else(|| ThumbError::Resize("RAW 내장 JPEG 없음".into()))?; const STAGES: [u64; 3] = [4 << 20, 32 << 20, u64::MAX];
let img = image::load_from_memory(jpeg) let mut best: Option<(DynamicImage, u32)> = None;
.map_err(|e| ThumbError::Resize(format!("RAW 프리뷰 디코드 실패: {e}")))?; 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 적용 // RAW도 EXIF orientation 적용
let oriented = apply_orientation(img, read_orientation(src)); let oriented = apply_orientation(img, read_orientation(src));
resize_encode(&oriented, dst, long_edge, quality) resize_encode(&oriented, dst, long_edge, quality)
@@ -280,6 +366,64 @@ mod tests {
assert_eq!((w, h), (256, 192)); 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] #[test]
fn scan_largest_jpeg_picks_biggest() { fn scan_largest_jpeg_picks_biggest() {
// 작은 JPEG + 큰 JPEG 섞인 데이터에서 큰 것을 고른다 // 작은 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 후 실행하면 전체 커버) //! (없으면 조용히 통과 — CI에서 fetch-ffmpeg 후 실행하면 전체 커버)
use archive_indexer::ffmpeg::{self, FfTools}; use archive_indexer::ffmpeg::{self, FfTools};
use archive_indexer::sigmatch;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
fn tools() -> Option<FfTools> { fn tools() -> Option<FfTools> {
@@ -66,3 +67,205 @@ fn probe_and_thumbnail_roundtrip() {
assert!(thumb.is_file()); assert!(thumb.is_file());
assert!(w == 256 || h == 256, "긴 변이 256이어야 함: {w}x{h}"); 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(" "));
}
}
}
+1094 -52
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 scan_running: Arc<AtomicBool>,
pub dedupe_cancel: Arc<AtomicBool>, pub dedupe_cancel: Arc<AtomicBool>,
pub dedupe_running: Arc<AtomicBool>, pub dedupe_running: Arc<AtomicBool>,
/// 지문 워커 중단 플래그 (앱 종료 시)
pub sig_cancel: Arc<AtomicBool>,
/// 하드웨어 디코드 사용 여부 — 기동 시 1회 실제 디코드로 판별한 결과
pub hwaccel: Option<&'static str>,
pub remote: Arc<remote::RemotePool>, pub remote: Arc<remote::RemotePool>,
/// 자격증명 저장소 (포터블 볼트 잠금 해제 후 교체 가능) /// 자격증명 저장소 (포터블 볼트 잠금 해제 후 교체 가능)
pub creds: Arc<std::sync::Mutex<archive_vfs::creds::CredStore>>, pub creds: Arc<std::sync::Mutex<archive_vfs::creds::CredStore>>,
@@ -40,13 +44,44 @@ fn now_ms() -> i64 {
.unwrap_or(0) .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() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(filter())
tracing_subscriber::EnvFilter::try_from_default_env() .with_ansi(false) // 파일에 색 이스케이프가 섞이면 읽을 수 없다
.unwrap_or_else(|_| "info".into()), .with_writer(writer.and(std::io::stdout))
)
.init(); .init();
tracing::info!(dir = %dir.display(), "로그 파일 기록 시작");
Some(guard)
}
fn main() {
// main이 끝날 때까지 유지 — 드롭되면 기록 스레드가 멈춘다
let _log_guard = init_logging();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
@@ -89,33 +124,24 @@ fn main() {
tools.as_ref().map(|t| t.ffmpeg.clone()), tools.as_ref().map(|t| t.ffmpeg.clone()),
))?; ))?;
// 썸네일 워커: 코어 수 - 1 // 썸네일 워커 상한: 코어×2를 4–16으로 클램프. 실제 동시 처리 개수는 큐가
let workers = std::thread::available_parallelism() // 처리율을 보며 이 범위 안에서 스스로 맞춘다 — NVMe는 코어 수 근처까지,
.map(|n| n.get().saturating_sub(1).max(1)) // HDD는 하한(2)까지 내려가고, SMB는 지연을 가리려 코어 수보다 높이 올라간다.
.unwrap_or(2); 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 emit_handle = app.handle().clone();
let thumbs = Arc::new(ThumbQueue::start( let thumbs = Arc::new(ThumbQueue::start(
db.clone(), db.clone(),
thumb_root, thumb_root,
tools.clone(), tools.clone(),
workers, max_workers,
move |ids| { move |ids| {
let _ = emit_handle.emit("thumbs-ready", ids); let _ = emit_handle.emit("thumbs-ready", ids);
}, },
)); ));
// 앱 시작 시 미처리 썸네일 백필 — 백그라운드로. // 앱 시작 시 미처리 썸네일 백필 — 전용 피더 스레드에서 커서로 흘려보낸다(즉시 반환).
// (대용량/네트워크(SMB) 소스에서 시작 시 메인 스레드 블로킹 방지) thumbs.enqueue_pending(None);
{
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();
}
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
tracing::info!( tracing::info!(
@@ -124,6 +150,11 @@ fn main() {
media.token media.token
); );
// 하드웨어 디코드 가용성 — 실제 디코드 1회로 판별한다(컴파일 지원만으로는 알 수 없다).
// 실패하면 소프트웨어 디코드로만 간다.
let hwaccel = tools.as_ref().and_then(archive_indexer::ffmpeg::detect_hwaccel);
tracing::info!(?hwaccel, "지문 추출 하드웨어 디코드");
app.manage(AppState { app.manage(AppState {
db, db,
data_dir: data.path, data_dir: data.path,
@@ -136,6 +167,8 @@ fn main() {
scan_running: Arc::new(AtomicBool::new(false)), scan_running: Arc::new(AtomicBool::new(false)),
dedupe_cancel: Arc::new(AtomicBool::new(false)), dedupe_cancel: Arc::new(AtomicBool::new(false)),
dedupe_running: Arc::new(AtomicBool::new(false)), dedupe_running: Arc::new(AtomicBool::new(false)),
sig_cancel: Arc::new(AtomicBool::new(false)),
hwaccel,
remote, remote,
creds: Arc::new(std::sync::Mutex::new( creds: Arc::new(std::sync::Mutex::new(
archive_vfs::creds::CredStore::Keychain, archive_vfs::creds::CredStore::Keychain,
@@ -270,6 +303,64 @@ fn main() {
.ok(); .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=<폴더> → 시작 시 소스 등록 + 스캔 // dev 편의: ARCHIVE_AUTO_SOURCE=<폴더> → 시작 시 소스 등록 + 스캔
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
if let Ok(auto) = std::env::var("ARCHIVE_AUTO_SOURCE") { if let Ok(auto) = std::env::var("ARCHIVE_AUTO_SOURCE") {
@@ -314,7 +405,12 @@ fn main() {
commands::list_sources, commands::list_sources,
commands::add_local_source, commands::add_local_source,
commands::remove_source, commands::remove_source,
commands::update_source,
commands::reload_source,
commands::purge_missing,
commands::source_status,
commands::start_scan, commands::start_scan,
commands::rescan_all,
commands::cancel_scan, commands::cancel_scan,
commands::folder_tree, commands::folder_tree,
commands::folder_snapshot, commands::folder_snapshot,
@@ -329,10 +425,25 @@ fn main() {
commands::rename_tag, commands::rename_tag,
commands::delete_tag, commands::delete_tag,
commands::assign_tags, 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::unassign_tags,
commands::file_tags_of, commands::file_tags_of,
commands::file_tags_bulk,
commands::export_tags, commands::export_tags,
commands::tag_snapshot, commands::tag_snapshot,
commands::trash_tag_id,
commands::mark_delete,
commands::unmark_delete,
commands::pending_delete_stats,
commands::empty_deleted,
commands::move_files, commands::move_files,
commands::rename_file, commands::rename_file,
commands::trash_files, commands::trash_files,
@@ -345,6 +456,9 @@ fn main() {
commands::cancel_dedupe, commands::cancel_dedupe,
commands::list_dupe_groups, commands::list_dupe_groups,
commands::resolve_dupe_group, commands::resolve_dupe_group,
commands::set_dupe_keeper,
commands::sig_speed,
commands::set_sig_speed,
commands::dismiss_dupe_pair, commands::dismiss_dupe_pair,
commands::add_remote_source, commands::add_remote_source,
commands::connect_remote_source, commands::connect_remote_source,
+90 -4
View File
@@ -16,20 +16,89 @@ type Debouncer = notify_debouncer_full::Debouncer<
notify_debouncer_full::RecommendedCache, 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 { pub struct WatcherManager {
watchers: Mutex<HashMap<i64, Debouncer>>, watchers: Mutex<HashMap<i64, Debouncer>>,
/// 워치 불가로 폴링에 의존하는 소스 (네트워크 공유 등)
polled: PollMap,
dirty_tx: crossbeam_channel::Sender<i64>, 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 { impl WatcherManager {
/// 워커 스레드를 시작하고 매니저를 만든다. /// 워커 스레드를 시작하고 매니저를 만든다.
pub fn new(app: tauri::AppHandle) -> std::sync::Arc<WatcherManager> { pub fn new(app: tauri::AppHandle) -> std::sync::Arc<WatcherManager> {
let (tx, rx) = crossbeam_channel::unbounded::<i64>(); 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 { let manager = std::sync::Arc::new(WatcherManager {
watchers: Mutex::new(HashMap::new()), 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() std::thread::Builder::new()
.name("watch-rescan".into()) .name("watch-rescan".into())
.spawn(move || { .spawn(move || {
@@ -56,7 +125,7 @@ impl WatcherManager {
match root { match root {
Ok(root) => { Ok(root) => {
tracing::info!(source_id, "워처 트리거 재스캔"); tracing::info!(source_id, "워처 트리거 재스캔");
crate::commands::run_scan_pipeline( let stats = crate::commands::run_scan_pipeline(
&state.db, &state.db,
&state.tools, &state.tools,
&state.thumbs, &state.thumbs,
@@ -68,6 +137,12 @@ impl WatcherManager {
let _ = app.emit("library-changed", source_id); 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); state.scan_running.store(false, Ordering::SeqCst);
let _ = app.emit("library-changed", source_id); let _ = app.emit("library-changed", source_id);
} }
@@ -88,6 +163,7 @@ impl WatcherManager {
if self.watchers.lock().unwrap().remove(&source_id).is_some() { if self.watchers.lock().unwrap().remove(&source_id).is_some() {
tracing::info!(source_id, "파일 워처 해제"); tracing::info!(source_id, "파일 워처 해제");
} }
self.polled.lock().unwrap().remove(&source_id);
} }
/// DB의 로컬 소스 전체를 워치 대상으로 동기화한다 (시작 시 + 소스 추가 후 호출). /// DB의 로컬 소스 전체를 워치 대상으로 동기화한다 (시작 시 + 소스 추가 후 호출).
@@ -127,8 +203,18 @@ impl WatcherManager {
watchers.insert(source_id, debouncer); watchers.insert(source_id, debouncer);
} }
Err(e) => { 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,
}
});
} }
} }
} }
+41 -5
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 { Sidebar, onLibraryChangedRefresh } from "./shell/Sidebar";
import { ContentArea } from "./shell/ContentArea"; import { ContentArea } from "./shell/ContentArea";
import { DetailsPanel } from "./shell/DetailsPanel"; import { DetailsPanel } from "./shell/DetailsPanel";
@@ -6,15 +6,29 @@ import { StatusBar } from "./shell/StatusBar";
import { Lightbox } from "./features/viewer/Lightbox"; import { Lightbox } from "./features/viewer/Lightbox";
import { ContextMenu } from "./features/fileops/ContextMenu"; import { ContextMenu } from "./features/fileops/ContextMenu";
import { MoveDialog } from "./features/fileops/MoveDialog"; import { MoveDialog } from "./features/fileops/MoveDialog";
import { MultiPlayView } from "./features/viewer/MultiPlayView";
import { DedupePanel } from "./features/dedupe/DedupePanel"; import { DedupePanel } from "./features/dedupe/DedupePanel";
import { CompareView } from "./features/dedupe/CompareView";
import { RemoteDialog } from "./features/sources/RemoteDialog"; import { RemoteDialog } from "./features/sources/RemoteDialog";
import { SourceEditDialog } from "./features/sources/SourceEditDialog";
import { ShortcutsOverlay } from "./shell/ShortcutsOverlay"; import { ShortcutsOverlay } from "./shell/ShortcutsOverlay";
import { onLibraryChanged, onThumbsReady, type UnlistenFn } from "./ipc/commands"; import { ko } from "./i18n/ko";
import { bumpThumbVersions, loadAppInfo, refreshPendingCounts, refreshSnapshot } from "./state/store"; import { onLibraryChanged, onScanGuarded, onThumbsReady, type UnlistenFn } from "./ipc/commands";
import {
bumpThumbVersions,
detailsOpen,
loadAppInfo,
loadTrashTag,
noteGuarded,
refreshPendingCounts,
refreshSnapshot,
toast,
} from "./state/store";
const App: Component = () => { const App: Component = () => {
onMount(() => { onMount(() => {
void loadAppInfo(); void loadAppInfo();
void loadTrashTag();
void refreshSnapshot(); void refreshSnapshot();
refreshPendingCounts(); refreshPendingCounts();
@@ -23,10 +37,18 @@ const App: Component = () => {
bumpThumbVersions(ids); bumpThumbVersions(ids);
refreshPendingCounts(); refreshPendingCounts();
}), }),
// 스캔 중에는 2초마다 온다 — 사이드바·스냅샷·삭제표시 갱신은 저 안에서 합쳐진다.
onLibraryChanged(() => { onLibraryChanged(() => {
onLibraryChangedRefresh(); onLibraryChangedRefresh();
refreshPendingCounts(); refreshPendingCounts();
}), }),
// 대량 삭제 보류 — 조용히 지나가면 사용자는 "왜 없는 파일이 목록에 있는지" 알 수 없다.
// 토스트로 알리고, 사이드바 정리 그룹에 '사라진 파일 정리' 진입점을 띄운다
// (토스트는 4초면 사라지지만 보류 상태는 사용자가 확정할 때까지 남는다).
onScanGuarded((g) => {
toast(ko.refresh.guarded(g.count));
noteGuarded(g);
}),
]; ];
onCleanup(() => { onCleanup(() => {
for (const u of unlisteners) void u.then((f) => f()); for (const u of unlisteners) void u.then((f) => f());
@@ -34,18 +56,32 @@ const App: Component = () => {
}); });
return ( return (
<div class="grid h-full grid-rows-[1fr_auto]"> <div class="grid h-full grid-rows-[1fr_auto] bg-[var(--bg-app)]">
<div class="grid min-h-0 grid-cols-[240px_1fr_280px]"> {/* 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 /> <Sidebar />
<ContentArea /> <ContentArea />
<Show when={detailsOpen()}>
<DetailsPanel /> <DetailsPanel />
</Show>
</div> </div>
<StatusBar /> <StatusBar />
<Lightbox /> <Lightbox />
<ContextMenu /> <ContextMenu />
<MoveDialog /> <MoveDialog />
<MultiPlayView />
<DedupePanel /> <DedupePanel />
{/* 비교 뷰는 중복 모달 '위'에 뜬다 — Esc는 위에 있는 것부터 닫힌다 */}
<CompareView />
<RemoteDialog /> <RemoteDialog />
<SourceEditDialog />
<ShortcutsOverlay /> <ShortcutsOverlay />
</div> </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>
);
};
+488 -146
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 { import {
createEffect, createEffect,
createMemo, createMemo,
@@ -6,25 +18,38 @@ import {
createSignal, createSignal,
For, For,
on, on,
onCleanup,
onMount,
Show, Show,
type Component, type Component,
} from "solid-js"; } 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 { ko } from "../../i18n/ko";
import { import {
dismissDupePair, dismissDupePair,
listDupeGroups, listDupeGroups,
markDelete,
resolveDupeGroup, resolveDupeGroup,
setDupeKeeper,
setSigSpeed,
sigSpeed,
thumbUrl, thumbUrl,
trashFiles,
type DupeGroup, type DupeGroup,
type DupeMember,
type SigSpeed,
} from "../../ipc/commands"; } from "../../ipc/commands";
import { import {
closeDedupe, closeDedupe,
dedupeCancelPending,
dedupeJob,
dedupeOpen, dedupeOpen,
dedupeProgress, dedupeProgress,
dedupeRunning, dedupeRunning,
info, info,
pending,
refreshMarked,
refreshSidebar, refreshSidebar,
refreshSnapshot, refreshSnapshot,
requestCancelDedupe, requestCancelDedupe,
@@ -33,49 +58,129 @@ import {
view, view,
} from "../../state/store"; } from "../../state/store";
function fmtSize(bytes: number): string { /** 타일 부제 — 없는 값은 말하지 않는다. 거리는 ko.dedupe.distance가 문장을 만든다. */
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)}MB`; function metaLine(m: DupeMember): string {
if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)}KB`; const parts: string[] = [];
return `${bytes}B`; 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 = () => { export const DedupePanel: Component = () => {
const [includeVideo, setIncludeVideo] = createSignal(false); const [includeVideo, setIncludeVideo] = createSignal(false);
// 회전·반전본은 dHash가 완전히 달라 변형 8개를 따로 조회해야 한다 — 비용이 몇 배라 기본 꺼짐
const [includeRotated, setIncludeRotated] = createSignal(false);
const [threshold, setThreshold] = createSignal(5); 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); 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), () => (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< const currentScope = createMemo<
{ label: string; args: { folderId?: number; recursive?: boolean; sourceId?: number } } | null { label: string; args: { folderId?: number; recursive?: boolean; sourceId?: number } } | null
>(() => { >(() => {
const v = view(); 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 } }; if (v.type === "source") return { label: ko.dedupe.scopeSource, args: { sourceId: v.sourceId } };
return null; return null;
}); });
// 패널을 열 때: 범위를 좁힐 수 있으면 기본값을 '현재 위치'로. // 모달을 열 때: 범위를 좁힐 수 있으면 기본값을 '현재 위치'로.
const [useCurrentScope, setUseCurrentScope] = createSignal(true); const [useCurrentScope, setUseCurrentScope] = createSignal(true);
createEffect(() => { createEffect(() => {
if (dedupeOpen()) setUseCurrentScope(currentScope() != null); 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 runScan = async () => {
const scoped = useCurrentScope() ? currentScope() : null; const scoped = useCurrentScope() ? currentScope() : null;
try { 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) { } catch (e) {
toast(String(e)); toast(String(e));
} }
}; };
// 탐지 완료(실행중 true→false) 시 결과 목록 갱신 — 패널이 닫혀 있다 열려도 재조회. // 탐지 완료(실행중 true→false) 시 결과 목록 갱신 — 모달이 닫혀 있다 열려도 재조회된다.
createEffect( createEffect(
on( on(
dedupeRunning, dedupeRunning,
@@ -86,29 +191,66 @@ 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); * (`on(groups, …)`) .
setGroupsVersion((v) => v + 1); * .
return; */
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);
}; };
/**
* **'삭제 예정' **. .
*
* . :
* (`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) => { const keepAll = async (g: DupeGroup) => {
// 그룹 내 모든 쌍을 dismiss (재탐지 시 제외) + 해결 표시 if (busy()) return;
setBusyGroup(g.id);
try {
for (let i = 0; i < g.members.length; i++) { for (let i = 0; i < g.members.length; i++) {
for (let j = i + 1; j < g.members.length; j++) { for (let j = i + 1; j < g.members.length; j++) {
await dismissDupePair(g.members[i].fileId, g.members[j].fileId); await dismissDupePair(g.members[i].fileId, g.members[j].fileId);
@@ -116,172 +258,362 @@ export const DedupePanel: Component = () => {
} }
await resolveDupeGroup(g.id); await resolveDupeGroup(g.id);
setGroupsVersion((v) => v + 1); 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();
}
}; };
return ( return (
<Show when={dedupeOpen()}> <Show when={dedupeOpen()}>
<div class="fixed inset-0 z-40 flex flex-col bg-[var(--bg-app)]"> {/* 바깥 클릭으로 닫힘 — 카드는 stopPropagation으로 스크림 클릭을 삼킨다 */}
{/* 헤더 */} <div class="modal-scrim" onClick={closeDedupe}>
<div class="flex h-12 shrink-0 items-center gap-4 border-b border-[var(--border)] px-4"> <div
<span class="text-sm font-semibold">{ko.dedupe.title}</span> class="modal-card modal-card--wide"
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]"> tabindex={-1}
<label class="flex items-center gap-1.5"> ref={(el) => {
{ko.dedupe.threshold} cardEl = el;
<input // 열 때 포커스를 카드로 옮긴다 — 안 하면 뒤 그리드가 방향키·Ctrl+A·Delete를 먹는다.
type="range" // 마이크로태스크로 미루는 이유: ref는 DOM에 붙기 전에 불린다.
min={0} queueMicrotask(() => el.focus());
max={12}
value={threshold()}
class="accent-[var(--accent)]"
onInput={(e) => setThreshold(Number(e.currentTarget.value))}
/>
<span class="w-4 tabular-nums">{threshold()}</span>
</label>
<label class="flex items-center gap-1.5">
<input
type="checkbox"
checked={includeVideo()}
onChange={(e) => setIncludeVideo(e.currentTarget.checked)}
/>
{ko.dedupe.includeVideo}
</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)} onClick={(e) => e.stopPropagation()}
onKeyDown={trapTab}
> >
{currentScope()!.label} {/* 프레임 ① 헤더 — 제목 · 진행 한 줄 · 취소 · 닫기 */}
</button> <div class="modal-head">
<button <span class="modal-head__title">{ko.dedupe.title}</span>
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>
</div>
</Show>
</div>
<div class="ml-auto flex items-center gap-2">
<Show when={dedupeRunning()}> <Show when={dedupeRunning()}>
<span class="text-[11px] text-[var(--accent)]"> <span
{dedupeProgress() class="min-w-0 truncate text-[11px] tabular-nums text-[var(--accent-text)]"
? ko.dedupe.scanning(dedupeProgress()!.phase, dedupeProgress()!.current, dedupeProgress()!.total) title={statusText()}
: ko.dedupe.starting} >
{statusText()}
</span> </span>
</Show>
<div class="ml-auto flex flex-none items-center gap-2">
<Show when={dedupeRunning()}>
<button <button
class="rounded border border-[var(--border)] px-2 py-1 text-xs hover:bg-[var(--bg-hover)]" class="toolbar-btn"
disabled={dedupeCancelPending()}
onClick={requestCancelDedupe} onClick={requestCancelDedupe}
> >
{ko.common.cancel} {ko.common.cancel}
</button> </button>
</Show> </Show>
<Show when={!dedupeRunning()}> <button class="icon-btn" onClick={closeDedupe} title={ko.common.close}>
<button <IconX size={14} />
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}
>
</button> </button>
</div> </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> </div>
{/* 그룹 목록 */} {/* 프레임 ② 컨트롤 줄 — 960px에서도 한 줄 */}
<div class="min-h-0 flex-1 overflow-y-auto p-4"> <div class="modal-bar">
<Show <Show
when={groups() && groups()!.length > 0} when={runningJob()}
fallback={ fallback={
<div class="grid h-full place-items-center text-sm text-[var(--text-muted)]"> <>
<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()}
disabled={dedupeRunning()}
class="w-[88px] accent-[var(--accent)]"
onInput={(e) => setThreshold(Number(e.currentTarget.value))}
/>
<span class="w-4 text-right tabular-nums">{threshold()}</span>
</label>
<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)}
/>
<span>{ko.dedupe.includeVideo}</span>
</label>
{/* ** ** .
( , ). */}
<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>
</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>
{/* .
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} {dedupeRunning() ? ko.dedupe.starting : ko.dedupe.empty}
</div> </div>
} }
> >
<div class="mb-3 text-xs text-[var(--text-muted)]"> <div class="mb-2.5 flex items-center gap-1.5 text-[10px] leading-[14px] text-[var(--text-muted)]">
{ko.dedupe.groupCount(groups()!.length)} <span class="flex-none tabular-nums">{ko.dedupe.groupCount(groups()!.length)}</span>
</div> <span class="flex-none tabular-nums">
<div class="flex flex-col gap-4"> · {ko.dedupe.tierSummary(tierCounts().certain, tierCounts().suspect)}
<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>
<span class="text-[11px] text-[var(--text-muted)]"> <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)} {ko.dedupe.memberCount(g.members.length)}
</span> </span>
<div class="ml-auto flex gap-2"> <div class="ml-auto flex flex-none gap-2">
{/* .
. */}
<button <button
class="rounded border border-[var(--border)] px-2 py-0.5 text-[11px] hover:bg-[var(--bg-hover)]" 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)} onClick={() => void keepAll(g)}
> >
{ko.dedupe.keepAll} {ko.dedupe.keepAll}
</button> </button>
{/* ( ) .
'되돌릴 수 없음' . */}
<button <button
class="rounded bg-[var(--danger)] px-2 py-0.5 text-[11px] text-white" class="toolbar-btn"
disabled={busy()}
title={ko.dedupe.keepBestHint}
onClick={() => void resolveGroup(g)} onClick={() => void resolveGroup(g)}
> >
{ko.dedupe.keepBest} {ko.dedupe.keepBest}
</button> </button>
</div> </div>
</div> </div>
<div class="flex flex-wrap gap-3">
<div class="dupe-grid">
<For each={g.members}> <For each={g.members}>
{(m) => ( {(m) => (
<div <div
class="w-40 overflow-hidden rounded border" class="dupe-tile"
classList={{ classList={{ "is-keeper": m.isKeeper }}
"border-[var(--accent)]": m.isKeeper, // 타일을 누르면 그 사본을 보관 항목과 비교한다 (보관 항목 자신은 첫 사본과)
"border-[var(--border)]": !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="relative aspect-square bg-[var(--grid-cell-bg)]"> <div class="dupe-tile__thumb">
<Show when={info()}> <Show when={info()}>
{(i) => (
<img <img
src={thumbUrl(info()!, m.fileId)} src={thumbUrl(i(), m.fileId)}
class="h-full w-full object-cover"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
alt=""
/> />
)}
</Show> </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> <button
</Show> 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>
<div class="p-1.5"> <div class="dupe-tile__body">
<div class="truncate text-[11px]" title={`${m.dir}\\${m.name}`}> <div class="dupe-tile__name" title={`${m.dir}\\${m.name}`}>
{m.name} {m.name}
</div> </div>
<div class="text-[10px] text-[var(--text-muted)]"> <div class="dupe-tile__meta">{metaLine(m)}</div>
{m.width && m.height ? `${m.width}×${m.height} · ` : ""}
{fmtSize(m.size)}
{m.distance != null ? ` · 거리 ${m.distance}` : ""}
</div>
</div> </div>
</div> </div>
)} )}
@@ -291,7 +623,17 @@ export const DedupePanel: Component = () => {
)} )}
</For> </For>
</div> </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>
</Show>
</div>
</div> </div>
</div> </div>
</Show> </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 { ko } from "../../i18n/ko";
import { import {
assignTags, assignTags,
editFiles,
emptyDeleted,
listTags, listTags,
pendingDeleteStats,
renameFile, renameFile,
trashFiles, trashFiles,
undoLast, undoLast,
type EditOp,
} from "../../ipc/commands"; } from "../../ipc/commands";
import { import {
bumpTagVersion,
clearSelection, clearSelection,
markedIds,
refreshMarked,
refreshSidebar, refreshSidebar,
refreshSnapshot, refreshSnapshot,
selected, selected,
snapshot,
toast, toast,
toggleMarkDelete,
trashTag,
} from "../../state/store"; } from "../../state/store";
import { openMoveDialog } from "./MoveDialog"; 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 { interface MenuState {
x: number; x: number;
@@ -32,15 +50,17 @@ export const closeContextMenu = () => setMenu(undefined);
const [renameTarget, setRenameTarget] = createSignal<{ id: number; name: string } | undefined>(); const [renameTarget, setRenameTarget] = createSignal<{ id: number; name: string } | undefined>();
export const openRenameDialog = (id: number, name: string) => setRenameTarget({ id, name }); 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 { try {
const ids = [...selected()]; const ids = explicitIds ?? [...selected()];
if (ids.length === 0) return; if (ids.length === 0) return;
const yes = await ask(ko.ops.trashConfirm(ids.length), { title: "Archive", kind: "warning" }); const yes = await ask(ko.ops.trashConfirm(ids.length), { title: "Archive", kind: "warning" });
if (!yes) return; if (!yes) return;
const summary = await trashFiles(ids, (p) => toast(ko.ops.inProgress(p.done, p.total))); const summary = await trashFiles(ids, (p) => toast(ko.ops.inProgress(p.done, p.total)));
toast(ko.ops.trashDone(summary.done)); toast(ko.ops.trashDone(summary.done));
clearSelection(); clearSelection();
await refreshMarked();
refreshSidebar(); refreshSidebar();
await refreshSnapshot(); await refreshSnapshot();
} catch (e) { } 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> { export async function performUndo(): Promise<void> {
const summary = await undoLast(); const summary = await undoLast();
if (!summary) { if (!summary) {
@@ -57,6 +178,8 @@ export async function performUndo(): Promise<void> {
} }
toast(ko.ops.undone(summary.done)); toast(ko.ops.undone(summary.done));
refreshSidebar(); refreshSidebar();
// 삭제 표시(태그)도 undo 대상 — 배지가 남지 않도록 함께 갱신
await refreshMarked();
await refreshSnapshot(); await refreshSnapshot();
} }
@@ -77,11 +200,18 @@ export const ContextMenu: Component = () => {
() => (menu() ? 1 : undefined), () => (menu() ? 1 : undefined),
() => listTags(), () => 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 onAssign = async (tagId: number, tagName: string) => {
const ids = [...selected()]; const ids = [...selected()];
const r = await assignTags(ids, tagId); const r = await assignTags(ids, tagId);
toast(ko.tags.assignTo(r.count, tagName)); toast(ko.tags.assignTo(r.count, tagName));
bumpTagVersion();
refreshSidebar(); 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" 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` }} 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="px-3 pt-1 text-[10px] text-[var(--text-muted)]"></div>
<div class="max-h-36 overflow-y-auto"> <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)} />} {(t) => <MenuItem label={`# ${t.name}`} onClick={() => void onAssign(t.id, t.name)} />}
</For> </For>
</div> </div>
<div class="my-1 border-t border-[var(--border)]" /> <div class="my-1 border-t border-[var(--border)]" />
</Show> </Show>
<MenuItem label={ko.ops.moveTo} onClick={openMoveDialog} /> <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}> <Show when={selected().size === 1 && m().fileName}>
<MenuItem <MenuItem
label={ko.common.rename} label={ko.common.rename}
@@ -112,7 +264,14 @@ export const ContextMenu: Component = () => {
/> />
</Show> </Show>
<div class="my-1 border-t border-[var(--border)]" /> <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> </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 { ko } from "../../i18n/ko";
import { import {
folderTree, folderTree,
@@ -16,6 +25,7 @@ import {
} from "../../state/store"; } from "../../state/store";
const [open, setOpen] = createSignal(false); const [open, setOpen] = createSignal(false);
export { open as moveDialogOpen };
export const openMoveDialog = () => setOpen(true); export const openMoveDialog = () => setOpen(true);
interface TreeNode extends FolderNode { interface TreeNode extends FolderNode {
@@ -69,7 +79,7 @@ const FolderPickRow: Component<{
setExpanded(!expanded()); 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>
<span class="truncate">{props.node.name}</span> <span class="truncate">{props.node.name}</span>
</div> </div>
@@ -83,6 +93,18 @@ const FolderPickRow: Component<{
}; };
export const MoveDialog: 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( const [trees] = createResource(
() => open(), () => open(),
async (isOpen) => { async (isOpen) => {
+134 -97
View File
@@ -1,4 +1,5 @@
// 커스텀 justified 가상 그리드 — 레이아웃은 워커에서, 렌더는 뷰포트 ±1화면만. // 커스텀 justified 가상 그리드 — 레이아웃은 워커에서, 렌더는 뷰포트 ±1화면만.
import { IconImage, IconTrash } from "../../ui/icons";
import { import {
createEffect, createEffect,
createMemo, createMemo,
@@ -10,15 +11,15 @@ import {
Show, Show,
type Component, type Component,
} from "solid-js"; } 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 { computeLayout, type LayoutResult } from "./layout";
import { import {
anchorIndex,
clearSelection, clearSelection,
gridRowHeight, gridRowHeight,
info, info,
markedIds,
openLightbox, openLightbox,
openShortcuts, registerItemViewFocus,
selected, selected,
selectSingle, selectSingle,
setSelectionFromIds, setSelectionFromIds,
@@ -26,20 +27,29 @@ import {
toggleSelect, toggleSelect,
selectRange, selectRange,
snapshot, snapshot,
tagVersion,
thumbVersions, thumbVersions,
trashTag,
zoomGrid, zoomGrid,
} from "../../state/store"; } from "../../state/store";
import { fileDetails, setThumbPriority, thumbUrl, type Snapshot } from "../../ipc/commands";
import { ko } from "../../i18n/ko";
import { import {
openContextMenu, fileDetails,
openRenameDialog, fileTagsBulk,
performTrash, setThumbPriority,
performUndo, thumbUrl,
} from "../fileops/ContextMenu"; 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 OVERSCAN_PX = 800;
/** 이 폭 미만 셀에서는 태그 라벨을 감춘다 (썸네일을 가림) */
const TAG_MIN_CELL_W = 120;
function fmtSize(bytes: number): string { function fmtSize(bytes: number): string {
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)}MB`; if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)}MB`;
@@ -74,7 +84,7 @@ export const JustifiedGrid: Component = () => {
setLayout(undefined); setLayout(undefined);
return; return;
} }
const w = containerRef.clientWidth - 16; // 패딩 보정 const w = containerRef.clientWidth - PAD * 2; // 패딩 보정
if (w <= 0) return; if (w <= 0) return;
const ratios = snap.aspectRatios(); const ratios = snap.aspectRatios();
setLayout(computeLayout(ratios, w, gridRowHeight(), GAP)); setLayout(computeLayout(ratios, w, gridRowHeight(), GAP));
@@ -85,6 +95,8 @@ export const JustifiedGrid: Component = () => {
onMount(() => { onMount(() => {
const el = containerRef!; const el = containerRef!;
// 크롬(툴바·브레드크럼·폴더 칩·사이드바)이 클릭 뒤 방향키를 여기로 되돌린다
onCleanup(registerItemViewFocus(() => containerRef?.focus({ preventScroll: true })));
let lastW = -1; let lastW = -1;
let lastRh = -1; let lastRh = -1;
// 폭과 줌(gridRowHeight)을 함께 감시 — 어느 쪽이 바뀌든 재배치. // 폭과 줌(gridRowHeight)을 함께 감시 — 어느 쪽이 바뀌든 재배치.
@@ -165,6 +177,43 @@ export const JustifiedGrid: Component = () => {
}); });
onCleanup(() => clearTimeout(prioTimer)); 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 켜졌을 때 가시 범위만) // 정보 오버레이용 파일명/크기 지연 로드 (showInfo 켜졌을 때 가시 범위만)
const [detailsCache, setDetailsCache] = createStore<Record<number, { name: string; size: number }>>({}); const [detailsCache, setDetailsCache] = createStore<Record<number, { name: string; size: number }>>({});
let detTimer: ReturnType<typeof setTimeout> | undefined; let detTimer: ReturnType<typeof setTimeout> | undefined;
@@ -234,80 +283,29 @@ export const JustifiedGrid: Component = () => {
return best; 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 onKeyDown = (e: KeyboardEvent) => {
const snap = snapshot(); const snap = snapshot();
if (!snap || snap.count === 0) return; if (!snap) return;
const anchor = anchorIndex(); handleItemKeys(e, snap, {
neighbor: (from, key) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { if (key === "ArrowLeft") return from > 0 ? from - 1 : undefined;
e.preventDefault(); if (key === "ArrowRight") return from < snap.count - 1 ? from + 1 : undefined;
void performUndo(); return verticalNeighbor(from, key === "ArrowDown" ? 1 : -1);
return; },
} onMove: revealCell,
// 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;
}
}
}; };
const onCellContextMenu = (e: MouseEvent, snap: Snapshot, index: number) => { 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 toContent = (clientX: number, clientY: number): [number, number] => {
const rect = containerRef!.getBoundingClientRect(); const rect = containerRef!.getBoundingClientRect();
return [ return [
clientX - rect.left - 8, clientX - rect.left - PAD,
clientY - rect.top - 8 + containerRef!.scrollTop, clientY - rect.top - PAD + containerRef!.scrollTop,
]; ];
}; };
@@ -397,7 +395,8 @@ export const JustifiedGrid: Component = () => {
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onMouseDown={onBgMouseDown} onMouseDown={onBgMouseDown}
tabindex={0} 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 <Show
when={snapshot() && snapshot()!.count > 0} when={snapshot() && snapshot()!.count > 0}
@@ -415,7 +414,7 @@ export const JustifiedGrid: Component = () => {
<Show when={marqueeRect()}> <Show when={marqueeRect()}>
{(r) => ( {(r) => (
<div <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={{ style={{
left: `${r().left}px`, left: `${r().left}px`,
top: `${r().top}px`, top: `${r().top}px`,
@@ -453,11 +452,23 @@ export const JustifiedGrid: Component = () => {
const s = snapshot(); const s = snapshot();
return !!s && s.kind(i) === 1; 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 ( return (
<div <div
class="absolute cursor-pointer overflow-hidden rounded-[3px] bg-[var(--grid-cell-bg)] outline-offset-[-2px]" class="grid-cell"
classList={{ classList={{
"outline outline-2 outline-[var(--accent)]": selected().has(id()), "is-sel": selected().has(id()),
"is-marked": marked() && !selected().has(id()),
}} }}
style={{ style={{
transform: `translate(${box()[0]}px, ${box()[1]}px)`, transform: `translate(${box()[0]}px, ${box()[1]}px)`,
@@ -487,11 +498,11 @@ export const JustifiedGrid: Component = () => {
when={!failed()} when={!failed()}
fallback={ fallback={
<div class="grid h-full w-full place-items-center text-[var(--text-muted)]"> <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>
} }
> >
<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> </Show>
} }
> >
@@ -509,16 +520,42 @@ export const JustifiedGrid: Component = () => {
/> />
</Show> </Show>
<Show when={isVideo()}> <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> </span>
</Show> </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()}> <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="info-overlay">
<div class="truncate text-[10px] text-white/90">{detailsCache[id()]?.name ?? ""}</div> <div class="info-overlay__name">{detailsCache[id()]?.name ?? ""}</div>
<Show when={detailsCache[id()]}> <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> </Show>
</div> </div>
</Show> </Show>
+5 -2
View File
@@ -1,7 +1,10 @@
// Justified 레이아웃 — 그리디 행 채우기. 메인 스레드 동기 계산. // Justified 레이아웃 — 그리디 행 채우기. 메인 스레드 동기 계산.
// (프로덕션 Tauri 빌드에서 module worker가 첫 메시지 후 응답하지 않는 이슈를 피하기 위해 // (프로덕션 Tauri 빌드에서 module worker가 첫 메시지 후 응답하지 않는 이슈를 피하기 위해
// 워커 대신 동기 계산을 쓴다. 재계산은 리사이즈/줌/데이터 변경 시에만 발생하며 // 워커 대신 동기 계산을 쓴다. 재계산은 리사이즈/줌/데이터 변경 시에만 발생한다.)
// 5만 항목도 수십 ms 내에 끝나 스크롤 성능에 영향 없다.) //
// 워커로 옮길 이유가 없다 — V8 실측: 1만 0.8ms / 10만 3.1ms / **100만 18.4ms**
// (aspectRatios 언팩 포함 ~20ms). 스크롤 중에는 아예 돌지 않고, 워커로 보내면
// boxes 15MB를 매번 되돌려 받아야 해서 오히려 느려진다.
export interface LayoutResult { export interface LayoutResult {
/** n*4: [x, y, w, h] */ /** 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>
</>
);
};
+577 -10
View File
@@ -1,23 +1,65 @@
// 라이트박스 — 사진(줌/팬) + 영상 재생 오버레이. Esc 닫기, ←/→ 이동, 휠/+/- 줌. // 라이트박스 — 사진(줌/팬) + 영상 재생 오버레이. Esc 닫기, ←/→ 이동, 휠/+/- 줌.
// 재생·감상 중에도 정리 작업(태그·폴더 이동·삭제 표시)을 끊김 없이 할 수 있도록
// 그리드와 같은 파일 작업을 여기서도 버튼·단축키로 노출한다.
import {
IconCheck,
IconFlipH,
IconCrop,
IconFolder,
IconRotateLeft,
IconRotateRight,
IconTrash,
IconX,
} from "../../ui/icons";
import { import {
createEffect, createEffect,
createMemo, createMemo,
createResource, createResource,
createSignal, createSignal,
For,
onCleanup, onCleanup,
onMount, onMount,
Show, Show,
type Component, type Component,
} from "solid-js"; } from "solid-js";
import { import {
bumpTagVersion,
closeLightbox, closeLightbox,
dedupeOpen,
info, info,
lightboxIndex, lightboxIndex,
markedIds,
navLightbox, navLightbox,
openShortcuts,
refreshSidebar,
refreshSnapshot,
selectSingle,
shortcutsOpen,
snapshot, snapshot,
toast,
toggleMarkDelete,
trashTag,
} from "../../state/store"; } 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 { VideoPlayer } from "./VideoPlayer";
import { CropOverlay } from "./CropOverlay";
/** 웹뷰가 <img>로 직접 그릴 수 있는 확장자 (그 외는 썸네일 폴백 — HEIC/RAW 등) */ /** 웹뷰가 <img>로 직접 그릴 수 있는 확장자 (그 외는 썸네일 폴백 — HEIC/RAW 등) */
const IMG_NATIVE = new Set(["jpg", "jpeg", "jfif", "png", "gif", "webp", "bmp", "avif"]); 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) }; return { index: idx, id: snap.id(idx), kind: snap.kind(idx) };
}); });
const [details] = createResource( const [details, { refetch: refetchDetails }] = createResource(
() => item()?.id, () => item()?.id,
async (id) => (await fileDetails([id]))[0], 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 [scale, setScale] = createSignal(1);
const [panX, setPanX] = createSignal(0); const [panX, setPanX] = createSignal(0);
@@ -49,10 +313,18 @@ export const Lightbox: Component = () => {
setPanY(0); setPanY(0);
}; };
// 파일이 바뀌면 줌 리셋 // 파일이 바뀌면 줌 리셋 + 열려 있던 태그 메뉴 닫기
createEffect(() => { createEffect(() => {
item()?.id; item()?.id;
resetZoom(); resetZoom();
closeTagMenu();
});
// 보고 있는 항목 = 선택 항목으로 유지.
// 폴더 이동/휴지통 후 clearSelection()이 불려도 상단 버튼이 계속 동작해야 한다.
createEffect(() => {
const it = item();
if (it) selectSingle(it.id, it.index);
}); });
/** 커서 위치 기준 줌 (center 기준 상대좌표) */ /** 커서 위치 기준 줌 (center 기준 상대좌표) */
@@ -100,10 +372,95 @@ export const Lightbox: Component = () => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (lightboxIndex() == null) return; 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; 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) { switch (e.key) {
case "Escape": case "Escape":
e.preventDefault(); e.preventDefault();
if (tagMenuOpen()) {
closeTagMenu();
break;
}
closeLightbox(); closeLightbox();
break; break;
case "ArrowLeft": case "ArrowLeft":
@@ -153,32 +510,226 @@ export const Lightbox: Component = () => {
if (!it || !app) return undefined; if (!it || !app) return undefined;
const ext = d?.name.split(".").pop()?.toLowerCase() ?? ""; const ext = d?.name.split(".").pop()?.toLowerCase() ?? "";
// 네이티브 디코드 불가 형식(HEIC/RAW/TIFF)은 프리뷰 썸네일로 폴백 // 네이티브 디코드 불가 형식(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 ( return (
<Show when={item()}> <Show when={item()}>
{(it) => ( {(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"> <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 ?? ""} {details()?.name ?? ""}
</span> </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")} {it().index + 1} / {snapshot()?.count.toLocaleString("ko-KR")}
</span> </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}> <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)]"> <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)}% {Math.round(scale() * 100)}%
</span> </span>
</Show> </Show>
<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 <button
class="ml-auto rounded px-2 py-0.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]" 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} onClick={closeLightbox}
title="닫기 (Esc)" title="닫기 (Esc)"
> >
<IconX size={14} />
</button> </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>
<div class="min-h-0 flex-1 p-2"> <div class="min-h-0 flex-1 p-2">
<Show <Show
@@ -192,6 +743,7 @@ export const Lightbox: Component = () => {
onDblClick={() => (scale() > 1 ? resetZoom() : zoomCentered(2.5))} onDblClick={() => (scale() > 1 ? resetZoom() : zoomCentered(2.5))}
> >
<img <img
ref={imgRef}
src={imgSrc()} src={imgSrc()}
class="max-h-full max-w-full touch-none select-none object-contain" class="max-h-full max-w-full touch-none select-none object-contain"
style={{ style={{
@@ -210,7 +762,22 @@ export const Lightbox: Component = () => {
</div> </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> </Show>
</div> </div>
</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>
);
};
+564 -24
View File
@@ -1,9 +1,11 @@
// 미디어 플레이어 — 통일된 커스텀 컨트롤(직접 재생 + ffmpeg 스트림 공통). // 미디어 플레이어 — 통일된 커스텀 컨트롤(직접 재생 + ffmpeg 스트림 공통).
// 재생/정지·10초 스킵·시크바(버퍼)·볼륨·배속·전체화면·자동숨김·키보드 단축키. // 재생/정지·10초 스킵·시크바(버퍼)·볼륨·배속·전체화면·자동숨김·키보드 단축키.
import { IconChevronLeft, IconChevronRight, IconFolder, IconForward, IconMaximize, IconMinimize, IconPause, IconPlay, IconRepeat, IconResetZoom, IconRewind, IconScissors, IconTrash, IconVolumeHigh, IconVolumeLow, IconVolumeMute } from "../../ui/icons";
import { import {
createEffect, createEffect,
createResource, createResource,
createSignal, createSignal,
For,
onCleanup, onCleanup,
onMount, onMount,
Show, Show,
@@ -15,11 +17,25 @@ import {
playbackInfo, playbackInfo,
startStream, startStream,
stopStream, stopStream,
cutFile,
filmstrip,
type AudioMode, type AudioMode,
type PlaybackPlan, type PlaybackPlan,
type StripFrame,
type VideoMode, type VideoMode,
} from "../../ipc/commands"; } 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 { function fmtTime(sec: number): string {
const s = Math.max(0, Math.floor(sec)); 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 RATES = [0.5, 1, 1.25, 1.5, 2];
const VOL_KEY = "archive.player.volume"; 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 videoRef: HTMLVideoElement | undefined;
let containerRef: HTMLDivElement | undefined; let containerRef: HTMLDivElement | undefined;
let barRef: 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 [plan, setPlan] = createSignal<PlaybackPlan | undefined>();
const [src, setSrc] = createSignal<string | undefined>(); const [src, setSrc] = createSignal<string | undefined>();
const [seekBase, setSeekBase] = createSignal(0); const [seekBase, setSeekBase] = createSignal(0);
@@ -52,15 +156,139 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
const [rate, setRate] = createSignal(1); const [rate, setRate] = createSignal(1);
const [fullscreen, setFullscreen] = createSignal(false); const [fullscreen, setFullscreen] = createSignal(false);
const [controlsShown, setControlsShown] = createSignal(true); 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> 요소가 보고한 길이 (직접 재생 시 메타데이터보다 정확·항상 존재) // <video> 요소가 보고한 길이 (직접 재생 시 메타데이터보다 정확·항상 존재)
const [elDuration, setElDuration] = createSignal(0); const [elDuration, setElDuration] = createSignal(0);
// 스트림 시크 중 표시 위치 고정용 (null이면 실제 재생 위치 사용) // 스트림 시크 중 표시 위치 고정용 (null이면 실제 재생 위치 사용)
const [seekingTo, setSeekingTo] = createSignal<number | null>(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 jobId: number | undefined;
let hideTimer: ReturnType<typeof setTimeout> | undefined; let hideTimer: ReturnType<typeof setTimeout> | undefined;
let streamSeekTimer: ReturnType<typeof setTimeout> | undefined; let streamSeekTimer: ReturnType<typeof setTimeout> | undefined;
const isStream = () => plan()?.mode === "stream"; const isStream = () => plan()?.mode === "stream";
const marked = () => markedIds().has(props.fileId);
// 재생 길이: ffprobe 총 길이(메타)를 항상 우선. <video> 요소 duration은 스트림·일부 // 재생 길이: ffprobe 총 길이(메타)를 항상 우선. <video> 요소 duration은 스트림·일부
// 컨테이너(리먹스 fMP4 등)에서 재생 중 실시간으로 늘어나 재생바가 요동치므로, // 컨테이너(리먹스 fMP4 등)에서 재생 중 실시간으로 늘어나 재생바가 요동치므로,
// 메타가 있으면 그 고정 총길이를 쓰고, 없을 때만 요소 duration으로 폴백한다. // 메타가 있으면 그 고정 총길이를 쓰고, 없을 때만 요소 duration으로 폴백한다.
@@ -102,7 +330,9 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
const decided = decidePlayback(p); const decided = decidePlayback(p);
setPlan(decided); setPlan(decided);
if (decided.mode === "direct") { 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") { } else if (decided.mode === "stream") {
void startStreamAt(0, decided.video, decided.audio); void startStreamAt(0, decided.video, decided.audio);
} else { } else {
@@ -175,6 +405,97 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
localStorage.setItem(VOL_KEY, String(nv)); 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 = () => { const toggleFullscreen = () => {
if (!containerRef) return; if (!containerRef) return;
if (document.fullscreenElement) void document.exitFullscreen(); if (document.fullscreenElement) void document.exitFullscreen();
@@ -217,15 +538,27 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
setControlsShown(true); setControlsShown(true);
clearTimeout(hideTimer); clearTimeout(hideTimer);
hideTimer = setTimeout(() => { hideTimer = setTimeout(() => {
if (playing()) setControlsShown(false); // 편집 중에는 숨기지 않는다 — 필름스트립과 손잡이가 사라지면 작업 자체가 끊긴다.
// (구간 미리보기로 재생 중일 때가 특히 그렇다: 보고 있는 동안 도구가 없어진다)
if (playing() && !trimMode()) setControlsShown(false);
}, 2600); }, 2600);
}; };
/**
* .
*
* '감상 중' .
* .
*/
const barVisible = () => controlsShown() || trimMode();
// ── 키보드 단축키 (플레이어가 마운트된 동안) ── // ── 키보드 단축키 (플레이어가 마운트된 동안) ──
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
// 입력 요소에 포커스면 무시 // 입력 요소에 포커스면 무시
const tag = (e.target as HTMLElement)?.tagName; const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return; if (tag === "INPUT" || tag === "TEXTAREA") return;
// 위에 모달이 떠 있으면 재생 키를 먹지 않는다
if (moveDialogOpen()) return;
let handled = true; let handled = true;
switch (e.key) { switch (e.key) {
case " ": case " ":
@@ -236,12 +569,16 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
case "ArrowLeft": case "ArrowLeft":
case "j": case "j":
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; break;
case "ArrowRight": case "ArrowRight":
case "l": case "l":
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; break;
case "ArrowUp": case "ArrowUp":
changeVolume(volume() + 0.05); changeVolume(volume() + 0.05);
@@ -257,6 +594,22 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
case "F": case "F":
toggleFullscreen(); toggleFullscreen();
break; 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": case "PageUp":
navLightbox(-1); navLightbox(-1);
break; break;
@@ -289,6 +642,10 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
if (seekingTo() == null) { if (seekingTo() == null) {
setCurrent(seekBase() + (videoRef?.currentTime ?? 0)); setCurrent(seekBase() + (videoRef?.currentTime ?? 0));
} }
// 구간 미리보기 — 끝에 닿으면 시작으로 되돌린다. 적용 전에 결과를 반복해 확인할 수 있다.
if (rangePreview() && current() >= trimEndOr() - 0.05) {
seekTo(trimStart());
}
// 버퍼 끝(직접 모드) // 버퍼 끝(직접 모드)
if (videoRef && videoRef.buffered.length > 0 && !isStream()) { if (videoRef && videoRef.buffered.length > 0 && !isStream()) {
setBuffered(videoRef.buffered.end(videoRef.buffered.length - 1)); setBuffered(videoRef.buffered.end(videoRef.buffered.length - 1));
@@ -305,8 +662,9 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
return ( return (
<div <div
ref={containerRef} ref={containerRef}
class="relative flex h-full w-full items-center justify-center bg-black" class="media-surface relative flex h-full w-full items-center justify-center overflow-hidden bg-black"
classList={{ "cursor-none": !controlsShown() }} classList={{ "cursor-none": !barVisible() }}
onWheel={onWheel}
onMouseMove={showControls} onMouseMove={showControls}
onMouseLeave={() => playing() && setControlsShown(false)} onMouseLeave={() => playing() && setControlsShown(false)}
> >
@@ -321,7 +679,12 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
<video <video
ref={videoRef} ref={videoRef}
src={src()} 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 autoplay
playsinline playsinline
onTimeUpdate={onTimeUpdate} 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" 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} onClick={togglePlay}
> >
<IconPlay size={30} />
</button> </button>
</Show> </Show>
@@ -370,64 +733,193 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
<button <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" 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)} onClick={() => navLightbox(-1)}
title="이전 (PageUp)" title="이전 항목 (P / PageUp / Ctrl+←)"
> >
<IconChevronLeft size={22} />
</button> </button>
<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" 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)} onClick={() => navLightbox(1)}
title="다음 (PageDown)" title="다음 항목 (N / PageDown / Ctrl+→)"
> >
<IconChevronRight size={22} />
</button> </button>
</Show> </Show>
{/* 하단 컨트롤바 */} {/* 하단 컨트롤바 */}
<div <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" 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()} 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 <div
ref={barRef} 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} 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()}> <Show when={!isStream()}>
<div <div
class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-white/40" class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-white/40"
style={{ width: `${bufferedPct()}%` }} style={{ width: `${bufferedPct()}%` }}
/> />
</Show> </Show>
{/* 자르기 중에는 진행 채움을 숨긴다 — 필름스트립을 가리면 장면이 안 보인다 */}
<Show when={!trimMode()}>
<div <div
class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-[var(--accent)]" class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-[var(--accent)]"
style={{ width: `${progressPct()}%` }} 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 <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" 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()}%` }} 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>
{/* 버튼 행 */} {/* 버튼 행 */}
<div class="flex items-center gap-2 text-white"> <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)"> <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>
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={() => skip(-10)} title="10초 뒤로 (J)"> <button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={() => skip(-10)} title="10초 뒤로 (J)">
<IconRewind size={16} />
</button> </button>
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={() => skip(10)} title="10초 앞으로 (L)"> <button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={() => skip(10)} title="10초 앞으로 (L)">
<IconForward size={16} />
</button> </button>
{/* 볼륨 */} {/* 볼륨 */}
<div class="group/vol flex items-center gap-1"> <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)"> <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> </button>
<input <input
type="range" type="range"
@@ -445,6 +937,36 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
</span> </span>
<div class="ml-auto flex items-center gap-2"> <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 <button
class="rounded px-1 text-sm leading-none" class="rounded px-1 text-sm leading-none"
classList={{ classList={{
@@ -454,7 +976,7 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
onClick={toggleRepeat} onClick={toggleRepeat}
title={repeatMode() ? "반복 켜짐" : "반복 꺼짐 (끝나면 다음 항목)"} title={repeatMode() ? "반복 켜짐" : "반복 꺼짐 (끝나면 다음 항목)"}
> >
🔁 <IconRepeat size={16} />
</button> </button>
<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)]" 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()} {rateLabel()}
</button> </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()}> <Show when={isStream()}>
<span class="rounded bg-white/15 px-1.5 py-0.5 text-[10px] text-white/70"> <span class="rounded bg-white/15 px-1.5 py-0.5 text-[10px] text-white/70">
{(plan() as { video: string }).video === "transcode" ? "변환 재생" : "리먹스 재생"} {(plan() as { video: string }).video === "transcode" ? "변환 재생" : "리먹스 재생"}
</span> </span>
</Show> </Show>
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={toggleFullscreen} title="전체화면 (F)"> <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> </button>
</div> </div>
</div> </div>
+378 -6
View File
@@ -16,6 +16,96 @@ export const ko = {
`소스 "${name}"를 목록에서 제외할까요?\n\n인덱스·태그·썸네일만 제거되며, 디스크의 실제 파일은 삭제되지 않습니다.`, `소스 "${name}"를 목록에서 제외할까요?\n\n인덱스·태그·썸네일만 제거되며, 디스크의 실제 파일은 삭제되지 않습니다.`,
removed: (name: string, n: number) => removed: (name: string, n: number) =>
`"${name}" 제외됨 (${n.toLocaleString("ko-KR")}개 항목 인덱스에서 제거)`, `"${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: { onboarding: {
title: "Archive에 오신 것을 환영합니다", title: "Archive에 오신 것을 환영합니다",
@@ -29,6 +119,24 @@ export const ko = {
items: (n: number) => `${n.toLocaleString("ko-KR")}개 항목`, items: (n: number) => `${n.toLocaleString("ko-KR")}개 항목`,
selected: (n: number) => `${n.toLocaleString("ko-KR")}개 선택됨`, selected: (n: number) => `${n.toLocaleString("ko-KR")}개 선택됨`,
infoToggle: "썸네일에 파일명·크기 표시", infoToggle: "썸네일에 파일명·크기 표시",
zoomTitle: "썸네일 크기 (Ctrl+휠)",
},
viewMode: {
label: "보기",
grid: "격자",
list: "리스트",
title: "보기 방식 전환 (V)",
},
list: {
name: "이름",
tags: "태그",
kind: "종류",
size: "크기",
dims: "해상도",
duration: "길이",
taken: "촬영일",
modified: "수정일",
sortHint: (col: string) => `${col} 기준 정렬`,
}, },
filter: { filter: {
all: "전체", all: "전체",
@@ -36,6 +144,7 @@ export const ko = {
video: "영상", video: "영상",
}, },
sort: { sort: {
label: "정렬",
taken: "촬영일", taken: "촬영일",
name: "이름", name: "이름",
size: "크기", size: "크기",
@@ -43,6 +152,22 @@ export const ko = {
asc: "오름차순", asc: "오름차순",
desc: "내림차순", 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: { shortcuts: {
title: "키보드 단축키", title: "키보드 단축키",
close: "닫기 (Esc / ?)", close: "닫기 (Esc / ?)",
@@ -50,13 +175,24 @@ export const ko = {
["Space / K", "재생·일시정지 (영상)"], ["Space / K", "재생·일시정지 (영상)"],
["← →", "이전·다음 항목 (그리드) / 5초 이동 (영상)"], ["← →", "이전·다음 항목 (그리드) / 5초 이동 (영상)"],
["↑ ↓", "위·아래 줄 이동 (그리드) / 볼륨 (영상)"], ["↑ ↓", "위·아래 줄 이동 (그리드) / 볼륨 (영상)"],
["Home / End", "첫·마지막 항목"],
["Enter / Space", "선택 항목 크게 보기"], ["Enter / Space", "선택 항목 크게 보기"],
["V", "보기 방식 전환 (격자 ↔ 리스트)"],
["N / P", "다음·이전 항목 (보기 중, 영상 포함)"],
["Ctrl+← →", "다음·이전 항목 (영상 재생 중에도)"],
["T", "태그 메뉴 (보기 중) — 검색·새로 만들기, 1~9로 바로 지정"],
["W", "화면 크기 전환 (맞춤 / 채움 / 원본)"],
["휠, + , 0", "확대 (커서 기준) · 초기화 — 확대 후 드래그로 이동"],
["Ctrl+[ / Ctrl+]", "왼쪽·오른쪽 90도 회전 (원본이 바뀜)"],
["Ctrl+M", "폴더로 이동"],
["Delete", "삭제 표시 (파일은 그대로 — 비우기 전까지)"],
["Shift+Delete", "즉시 OS 휴지통으로"],
["Ctrl+A", "전체 선택"], ["Ctrl+A", "전체 선택"],
["Ctrl+Shift+A", "선택 반전"], ["Ctrl+Shift+A", "선택 반전"],
["Esc", "선택 해제 / 닫기"], ["Esc", "선택 해제 / 닫기"],
["Delete", "휴지통으로"], ["Ctrl+Z", "실행 취소 (삭제 표시 포함)"],
["Ctrl+Z", "실행 취소"],
["F2", "이름 바꾸기"], ["F2", "이름 바꾸기"],
["F5", "새로고침 (디스크 다시 읽기)"],
["Ctrl+휠", "썸네일 크기"], ["Ctrl+휠", "썸네일 크기"],
["?", "이 도움말"], ["?", "이 도움말"],
] as [string, string][], ] as [string, string][],
@@ -73,6 +209,15 @@ export const ko = {
takenAt: "촬영 일시", takenAt: "촬영 일시",
modifiedAt: "수정 일시", modifiedAt: "수정 일시",
tags: "태그", tags: "태그",
tabInfo: "정보",
tabTags: "태그",
close: "상세 패널 닫기",
toggle: "상세 정보 패널",
sectionMedia: "미디어 정보",
sectionBasic: "기본 정보",
kind: "종류",
bulkTag: "태그 편집",
selectedUnit: "개 선택됨", // [C] 다중 선택 타일 — 큰 숫자 아래 라벨
}, },
statusBar: { statusBar: {
ready: "준비됨", ready: "준비됨",
@@ -82,30 +227,149 @@ export const ko = {
installed: "일반 모드", installed: "일반 모드",
thumbPending: (n: number) => `썸네일 생성 중 ${n.toLocaleString("ko-KR")}`, thumbPending: (n: number) => `썸네일 생성 중 ${n.toLocaleString("ko-KR")}`,
metaPending: (n: number) => `메타 분석 중 ${n.toLocaleString("ko-KR")}`, metaPending: (n: number) => `메타 분석 중 ${n.toLocaleString("ko-KR")}`,
sigPending: (n: number) => `지문 생성 중 ${n.toLocaleString("ko-KR")}`,
sigPendingHint:
"중복 탐지용 지문을 백그라운드에서 만들고 있습니다. 앱을 계속 쓰셔도 되고, 껐다 켜면 이어서 진행됩니다.",
}, },
search: { search: {
placeholder: "파일명 검색…", placeholder: "파일명 검색…",
}, },
common: { common: {
cancel: "취소", cancel: "취소",
/** 취소를 눌렀지만 아직 멈추지 않은 구간 — 사이드바 값 칸과 모달 헤더가 같은 말을 한다 */
canceling: "취소 중…",
confirm: "확인", confirm: "확인",
delete: "삭제", delete: "삭제",
rename: "이름 바꾸기", rename: "이름 바꾸기",
move: "이동", move: "이동",
undo: "실행 취소", undo: "실행 취소",
create: "만들기", create: "만들기",
save: "저장",
close: "닫기", 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: { tags: {
add: "태그 추가…", add: "태그 추가…",
newPlaceholder: "새 태그 이름", newPlaceholder: "새 태그 이름",
searchOrCreate: "태그 검색 · 새 태그 이름",
createAssign: (name: string) => `"${name}" 새로 만들어 지정`,
createdAndAssigned: (name: string) => `"${name}" 태그를 만들어 지정했습니다`,
emptyAll: "태그가 없습니다 — 이름을 입력해 새로 만드세요",
pickerHint: "↑↓ 이동 · Enter 지정 · 1~9 빠른 지정 · Esc 닫기",
assignTo: (n: number, tag: string) => `${n}개 항목에 "${tag}" 태그 추가됨`, assignTo: (n: number, tag: string) => `${n}개 항목에 "${tag}" 태그 추가됨`,
removedFrom: (n: number, tag: string) => `${n}개 항목에서 "${tag}" 태그 제거됨`, removedFrom: (n: number, tag: string) => `${n}개 항목에서 "${tag}" 태그 제거됨`,
deleteConfirm: (tag: string) => `태그 "${tag}"를 삭제할까요? (파일은 삭제되지 않음)`, deleteConfirm: (tag: string) => `태그 "${tag}"를 삭제할까요? (파일은 삭제되지 않음)`,
exported: (path: string) => `태그를 내보냈습니다: ${path}`, exported: (path: string) => `태그를 내보냈습니다: ${path}`,
exportAction: "태그 JSON 내보내기", 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: { ops: {
trash: "휴지통으로 보내기",
moveTo: "폴더로 이동…", moveTo: "폴더로 이동…",
moveDone: (n: number) => `${n}개 이동 완료`, moveDone: (n: number) => `${n}개 이동 완료`,
moveFailed: (n: number) => `${n}개 이동 실패`, moveFailed: (n: number) => `${n}개 이동 실패`,
@@ -116,6 +380,24 @@ export const ko = {
nothingToUndo: "되돌릴 작업이 없습니다", nothingToUndo: "되돌릴 작업이 없습니다",
inProgress: (done: number, total: number) => `처리 중 ${done}/${total}`, 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: { smart: {
create: "스마트 폴더 만들기", create: "스마트 폴더 만들기",
name: "이름", name: "이름",
@@ -155,18 +437,108 @@ export const ko = {
return `${labels[phase] ?? phase} ${cur.toLocaleString("ko-KR")}/${total.toLocaleString("ko-KR")}`; return `${labels[phase] ?? phase} ${cur.toLocaleString("ko-KR")}/${total.toLocaleString("ko-KR")}`;
}, },
threshold: "유사도 임계값", threshold: "유사도 임계값",
/** 지문 생성 속도 — 영상은 ffmpeg 한 프로세스가 코어 하나를 쓰므로 동시 실행 수가 곧 속도다 */
speed: "지문 속도",
speedSlow: "느리게",
speedNormal: "보통",
speedFast: "빠르게",
speedHint:
"중복 탐지용 지문을 만드는 속도입니다. '빠르게'는 코어를 더 쓰므로 다른 작업이 느려질 수 있습니다. 바꾸면 바로 반영됩니다.",
includeVideo: "영상 포함", includeVideo: "영상 포함",
/** 회전·반전본 — 후보 생성이 몇 배 비싸므로 기본은 꺼둔다 */
includeRotated: "회전·반전본",
includeRotatedHint:
"90°·180°·좌우반전 사본도 찾습니다. 변형 8종을 따로 조회하므로 시간이 몇 배 걸립니다.",
scopeLabel: "범위:", scopeLabel: "범위:",
scopeFolder: "현재 폴더 (하위 포함)", scopeFolder: "현재 폴더 (하위 포함)",
scopeSource: "현재 소스", scopeSource: "현재 소스",
scopeAll: "전체 라이브러리", 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}개 항목`, 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: "보관", keeper: "보관",
keepBest: "최적 보관 (나머지 휴지통)", /** 배지가 곧 선택 손잡이다 — 지금 상태와 누르면 무슨 일이 나는지를 각각 말한다 */
keeperCurrent: "이 항목을 보관합니다 (나머지가 삭제 대상)",
keeperPick: "이 항목을 보관하도록 바꾸기",
/**
* '최적' .
* ** ** '삭제 예정' ,
* '정리 → 비우기' . '휴지통' .
*/
keepBest: "보관 항목만 남기기 (나머지 삭제 예정)",
keepBestHint:
"나머지를 '삭제 예정'으로 표시합니다. 파일은 아직 지워지지 않으며, 사이드바 '정리'에서 확인 후 비우면 삭제됩니다.",
keepAll: "모두 보관 (무시)", 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; } as const;
+213 -4
View File
@@ -10,6 +10,8 @@ export interface AppInfo {
portable: boolean; portable: boolean;
mediaPort: number; mediaPort: number;
mediaToken: string; mediaToken: string;
/** ffmpeg 사이드카 유무 — 없으면 영상은 썸네일도 지문도 만들 수 없다 */
hasFfmpeg: boolean;
} }
export interface Source { export interface Source {
@@ -38,6 +40,8 @@ export interface FileDetails {
durationMs: number | null; durationMs: number | null;
kind: number; kind: number;
takenAt: number | null; takenAt: number | null;
/** 0=없음 ~ 5 */
rating: number;
} }
export interface ScanProgress { export interface ScanProgress {
@@ -52,15 +56,60 @@ export const appInfo = () => invoke<AppInfo>("app_info");
export interface PendingCounts { export interface PendingCounts {
thumbs: number; thumbs: number;
meta: number; meta: number;
/** 중복 탐지용 지문 생성 대기 수 */
sigs: number;
/**
* '가장 많이 밀려 있던 순간' . DB에
* ( 0% ).
*/
baseline: number;
} }
export const pendingCounts = () => invoke<PendingCounts>("pending_counts"); export const pendingCounts = () => invoke<PendingCounts>("pending_counts");
export const listSources = () => invoke<Source[]>("list_sources"); export const listSources = () => invoke<Source[]>("list_sources");
/** add_local_source 결과 — existing이면 이미 등록된 소스를 그대로 돌려받은 것 */
export interface AddSourceResult {
source: Source;
existing: boolean;
}
export const addLocalSource = (path: string) => export const addLocalSource = (path: string) =>
invoke<Source>("add_local_source", { path }); invoke<AddSourceResult>("add_local_source", { path });
/** 소스를 인덱스에서 제외 (실제 파일은 삭제하지 않음). 반환: 제거된 항목 수 */ /** 소스를 인덱스에서 제외 (실제 파일은 삭제하지 않음). 반환: 제거된 항목 수 */
export const removeSource = (sourceId: number) => export const removeSource = (sourceId: number) =>
invoke<number>("remove_source", { sourceId }); 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"); export const cancelScan = () => invoke<void>("cancel_scan");
/** 수동 새로고침 — 로컬 소스 전체 증분 재스캔 (워처가 못 미치는 SMB/NAS용) */
export const rescanAll = () => invoke<void>("rescan_all");
export const folderTree = (sourceId: number) => export const folderTree = (sourceId: number) =>
invoke<FolderNode[]>("folder_tree", { sourceId }); invoke<FolderNode[]>("folder_tree", { sourceId });
export const fileDetails = (ids: number[]) => export const fileDetails = (ids: number[]) =>
@@ -113,6 +162,13 @@ export class Snapshot {
thumbFailed(i: number): boolean { thumbFailed(i: number): boolean {
return this.ok(i) && (this.view.getUint8(i * ITEM_BYTES + 9) & 2) !== 0; 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) */ /** 레이아웃 워커 입력용 종횡비 배열 (미지 치수는 1:1) */
aspectRatios(): Float32Array { aspectRatios(): Float32Array {
const out = new Float32Array(this.count); const out = new Float32Array(this.count);
@@ -125,18 +181,21 @@ export class Snapshot {
} }
} }
/** 정렬 키: taken(촬영/기본) | name | size | mtime */ /** 정렬 키: taken(촬영/기본) | name | size | mtime | rating */
export type SortKey = "taken" | "name" | "size" | "mtime"; export type SortKey = "taken" | "name" | "size" | "mtime" | "rating";
/** 스냅샷 공통 옵션 — 정렬/종류 필터 */ /** 스냅샷 공통 옵션 — 정렬/종류/별점 필터 */
export interface SnapOpts { export interface SnapOpts {
sortKey?: SortKey; sortKey?: SortKey;
sortDesc?: boolean; sortDesc?: boolean;
kind?: number | null; // 0=사진, 1=영상, null=전체 kind?: number | null; // 0=사진, 1=영상, null=전체
/** 이 별점 **이상**만 (1~5). null=전체 */
minRating?: number | null;
} }
const snapArgs = (o?: SnapOpts) => ({ const snapArgs = (o?: SnapOpts) => ({
sortKey: o?.sortKey ?? null, sortKey: o?.sortKey ?? null,
sortDesc: o?.sortDesc ?? null, sortDesc: o?.sortDesc ?? null,
kind: o?.kind ?? null, kind: o?.kind ?? null,
minRating: o?.minRating ?? null,
}); });
export async function folderSnapshot( export async function folderSnapshot(
@@ -181,6 +240,50 @@ export const assignTags = (fileIds: number[], tagId: number) =>
export const unassignTags = (fileIds: number[], tagId: number) => export const unassignTags = (fileIds: number[], tagId: number) =>
invoke<TagOpResult>("unassign_tags", { fileIds, tagId }); invoke<TagOpResult>("unassign_tags", { fileIds, tagId });
export const fileTagsOf = (fileId: number) => invoke<Tag[]>("file_tags_of", { fileId }); 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) => export const exportTags = (path?: string) =>
invoke<string>("export_tags", { path: path ?? null }); 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) })); 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 { export interface OpSummary {
@@ -286,6 +411,9 @@ export interface DupeMember {
fileId: number; fileId: number;
name: string; name: string;
dir: string; dir: string;
/** 이 파일이 있는 폴더 — 검토 중 그 위치로 바로 이동하는 데 쓴다 */
folderId: number;
sourceId: number;
size: number; size: number;
width: number | null; width: number | null;
height: number | null; height: number | null;
@@ -293,11 +421,22 @@ export interface DupeMember {
kind: number; kind: number;
isKeeper: boolean; isKeeper: boolean;
distance: number | null; 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 { export interface DupeGroup {
id: number; id: number;
kind: number; // 0 exact, 1 image, 2 video kind: number; // 0 exact, 1 image, 2 video
/** 유사도 0~1 (완전 동일은 1.0). 등급(확실/의심)을 여기서 파생한다. */
score: number | null;
members: DupeMember[]; members: DupeMember[];
} }
@@ -308,6 +447,8 @@ export function startDedupe(
recursive?: boolean; recursive?: boolean;
imageThreshold?: number; imageThreshold?: number;
includeVideo?: boolean; includeVideo?: boolean;
/** 회전·반전본까지 찾는다 — 후보 생성이 몇 배 비싸져 기본은 꺼둔다 */
includeRotated?: boolean;
}, },
onProgress: (p: DedupeProgress) => void, onProgress: (p: DedupeProgress) => void,
): Promise<void> { ): Promise<void> {
@@ -319,14 +460,28 @@ export function startDedupe(
recursive: args.recursive ?? null, recursive: args.recursive ?? null,
imageThreshold: args.imageThreshold ?? 5, imageThreshold: args.imageThreshold ?? 5,
includeVideo: args.includeVideo ?? false, includeVideo: args.includeVideo ?? false,
includeRotated: args.includeRotated ?? false,
onProgress: ch, 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 cancelDedupe = () => invoke<void>("cancel_dedupe");
export const listDupeGroups = () => invoke<DupeGroup[]>("list_dupe_groups"); export const listDupeGroups = () => invoke<DupeGroup[]>("list_dupe_groups");
export const resolveDupeGroup = (groupId: number) => export const resolveDupeGroup = (groupId: number) =>
invoke<void>("resolve_dupe_group", { groupId }); 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) => export const dismissDupePair = (fileA: number, fileB: number) =>
invoke<void>("dismiss_dupe_pair", { fileA, fileB }); 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> => export const onThumbsReady = (cb: (ids: number[]) => void): Promise<UnlistenFn> =>
listen<number[]>("thumbs-ready", (e) => cb(e.payload)); 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> => export const onLibraryChanged = (cb: (sourceId: number) => void): Promise<UnlistenFn> =>
listen<number>("library-changed", (e) => cb(e.payload)); 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 { export function mediaUrl(info: AppInfo, fileId: number): string {
return `http://127.0.0.1:${info.mediaPort}/media/${info.mediaToken}/${fileId}`; 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>
);
};
+286 -63
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 { open } from "@tauri-apps/plugin-dialog";
import { ko } from "../i18n/ko"; import { ko } from "../i18n/ko";
import { JustifiedGrid } from "../features/grid/JustifiedGrid"; import { JustifiedGrid } from "../features/grid/JustifiedGrid";
import { ListView } from "../features/browse/ListView";
import { FolderChips } from "../features/browse/FolderChips";
import { Breadcrumb } from "./Breadcrumb";
import { import {
GRID_ROW_MAX, GRID_ROW_MAX,
GRID_ROW_MIN, GRID_ROW_MIN,
clearScanProgress,
detailsOpen,
focusItemView,
gridRowHeight, gridRowHeight,
kindFilter, kindFilter,
refreshSidebar, refreshSidebar,
refreshSnapshot,
runSearch,
searchQuery,
setGridRowHeight, setGridRowHeight,
setKindFilter, setKindFilter,
setMinRating,
minRating,
setSort, setSort,
setView, setViewMode,
showInfo, showInfo,
sidebarVersion, sidebarVersion,
snapshot,
sortDesc, sortDesc,
sortKey, sortKey,
theme,
toast,
toggleDetails,
toggleShowInfo, toggleShowInfo,
toggleTheme,
updateScanProgress, updateScanProgress,
view, view,
viewMode,
type KindFilter, type KindFilter,
type SortKey, type SortKey,
type ViewMode,
} from "../state/store"; } 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"; import { openRemoteDialog } from "../features/sources/RemoteDialog";
export const ContentArea: Component = () => { export const ContentArea: Component = () => {
const [q, setQ] = createSignal(""); // 검색어는 store가 소유한다 — 폴더로 이동하면 자동으로 비워지고,
// ✕로 검색을 끝내면 검색 직전 위치로 돌아간다.
let debounce: ReturnType<typeof setTimeout> | undefined; let debounce: ReturnType<typeof setTimeout> | undefined;
const onInput = (value: string) => { const onInput = (value: string) => {
setQ(value);
clearTimeout(debounce); clearTimeout(debounce);
debounce = setTimeout(() => { debounce = setTimeout(() => void runSearch(value), 250);
const query = value.trim();
if (query.length > 0) void setView({ type: "search", query });
else if (view().type === "search") void setView({ type: "all" });
}, 250);
}; };
onCleanup(() => clearTimeout(debounce)); onCleanup(() => clearTimeout(debounce));
// 타이핑 직후(250ms 안에) 폴더를 클릭하면 대기 중이던 타이머가 뒤늦게 터져
// 방금 연 폴더를 검색 결과로 되돌린다. 뷰가 바뀌면 대기 중인 검색을 버린다.
// (자기 자신이 만든 검색 뷰 전환에서는 이미 실행된 타이머라 clearTimeout이 무해하다)
createEffect(on(view, () => clearTimeout(debounce)));
// 소스가 하나도 없으면 온보딩 표시. 소스 추가/제거(sidebarVersion) 시 재조회해 // 소스가 하나도 없으면 온보딩 표시. 소스 추가/제거(sidebarVersion) 시 재조회해
// 첫 소스를 추가하면 재시작 없이 즉시 그리드로 전환된다. // 첫 소스를 추가하면 재시작 없이 즉시 그리드로 전환된다.
@@ -49,39 +87,93 @@ export const ContentArea: Component = () => {
); );
const isEmpty = () => sources() && sources()!.length === 0; 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 addLocal = async () => {
const dir = await open({ directory: true, multiple: false, title: ko.onboarding.addLocal }); const dir = await open({ directory: true, multiple: false, title: ko.onboarding.addLocal });
if (typeof dir !== "string") return; if (typeof dir !== "string") return;
const src = await addLocalSource(dir); try {
const r = await addLocalSource(dir);
refreshSidebar(); refreshSidebar();
await startScan(src.id, updateScanProgress); // 이미 등록된 폴더면 다시 불러오기만 — 같은 폴더가 두 번 매핑되지 않게 한다
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 ( return (
<main class="flex min-h-0 flex-col bg-[var(--bg-app)]"> <main class="content-col flex min-h-0 min-w-0 flex-col gap-2">
<div class="flex h-11 shrink-0 items-center gap-3 border-b border-[var(--border)] px-3"> {/* . :
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 <input
type="search" type="search"
value={q()} class="field field--search field--block"
value={searchQuery()}
onInput={(e) => onInput(e.currentTarget.value)} onInput={(e) => onInput(e.currentTarget.value)}
placeholder={ko.search.placeholder} 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)]"> </div>
{snapshot() ? ko.grid.items(snapshot()!.count) : ""}
</span>
{/* 종류 필터: 전체 / 사진 / 영상 */} {/* 2. 종류 필터: 전체 / 사진 / 영상 */}
<div class="flex overflow-hidden rounded border border-[var(--border)] text-[11px]"> <div class="segmented">
<For each={[["all", ko.filter.all], ["image", ko.filter.image], ["video", ko.filter.video]] as [KindFilter, string][]}> <For
each={
[
["all", ko.filter.all],
["image", ko.filter.image],
["video", ko.filter.video],
] as [KindFilter, string][]
}
>
{([k, label]) => ( {([k, label]) => (
<button <button
class="px-2 py-0.5" classList={{ "is-on": kindFilter() === k }}
classList={{ tabindex={-1}
"bg-[var(--bg-active)] text-[var(--text-primary)]": kindFilter() === k, onClick={() => {
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": kindFilter() !== k, setKindFilter(k);
focusItemView();
}} }}
onClick={() => setKindFilter(k)}
> >
{label} {label}
</button> </button>
@@ -89,79 +181,210 @@ export const ContentArea: Component = () => {
</For> </For>
</div> </div>
{/* 정렬: 키 선택 + 방향 토글 */} {/* 3. 보기 방식: 격자 / 리스트 (V) */}
<div class="flex items-center gap-1 text-[11px] text-[var(--text-secondary)]"> <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][]
}
>
{([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>
{/* 3b. ''. N ,
"3점 넘는 것만 추려서 보기" . */}
<select <select
class="rounded border border-[var(--border)] bg-[var(--bg-panel)] px-1.5 py-0.5 outline-none" class="field field--sort"
title={ko.rating.label}
aria-label={ko.rating.label}
value={String(minRating())}
onChange={(e) => {
setMinRating(Number(e.currentTarget.value));
focusItemView();
}}
>
<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()} value={sortKey()}
onChange={(e) => setSort(e.currentTarget.value as SortKey, sortDesc())} 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]] as [SortKey, string][]}> <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>} {([k, label]) => <option value={k}>{label}</option>}
</For> </For>
</select> </select>
<button <button
class="rounded border border-[var(--border)] px-1.5 py-0.5 hover:bg-[var(--bg-hover)]" class="toolbar-btn toolbar-btn--icon"
onClick={() => setSort(sortKey(), !sortDesc())} tabindex={-1}
title={sortDesc() ? ko.sort.desc : ko.sort.asc} title={sortDesc() ? ko.sort.desc : ko.sort.asc}
> aria-label={sortDesc() ? ko.sort.desc : ko.sort.asc}
{sortDesc() ? "↓" : "↑"} onClick={() => {
</button> setSort(sortKey(), !sortDesc());
</div> focusItemView();
{/* 썸네일 정보 오버레이 토글 */}
<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(),
}} }}
onClick={toggleShowInfo}
title={ko.grid.infoToggle}
> >
{sortDesc() ? <IconArrowDown size={13} /> : <IconArrowUp size={13} />}
</button> </button>
{/* 그리드 줌 슬라이더 */} {/* 5. 수동 새로고침 — 워처가 변경 알림을 못 받는 SMB/NAS 대비 */}
<div class="ml-auto flex items-center gap-1.5 text-[var(--text-muted)]" title="썸네일 크기 (Ctrl+휠)"> <button
<span class="text-xs">🔍</span> 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 <input
type="range" type="range"
class="h-1 w-28 accent-[var(--accent)]" class="h-1 w-24 accent-[var(--accent)]"
min={GRID_ROW_MIN} min={GRID_ROW_MIN}
max={GRID_ROW_MAX} max={GRID_ROW_MAX}
step={5} step={5}
value={gridRowHeight()} value={gridRowHeight()}
aria-label={ko.grid.zoomTitle}
onInput={(e) => setGridRowHeight(Number(e.currentTarget.value))} onInput={(e) => setGridRowHeight(Number(e.currentTarget.value))}
/> />
</div> </div>
</div> </div>
</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 <Show
when={isEmpty()} when={isEmpty()}
fallback={<JustifiedGrid />} fallback={
<Show when={viewMode() === "list"} fallback={<JustifiedGrid />}>
<ListView />
</Show>
}
> >
<div class="grid flex-1 place-items-center"> <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="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-[var(--fs-xl)] leading-[var(--lh-xl)] font-semibold text-[var(--text-primary)]">
<div class="text-sm text-[var(--text-secondary)]">{ko.onboarding.subtitle}</div> {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"> <div class="mt-2 flex gap-2">
<button <button
class="rounded-md bg-[var(--accent)] px-4 py-2 text-xs text-white" class="toolbar-btn toolbar-btn--primary toolbar-btn--lg"
onClick={() => void addLocal()} onClick={() => void addLocal()}
> >
{ko.onboarding.addLocal} {ko.onboarding.addLocal}
</button> </button>
<button <button class="toolbar-btn toolbar-btn--lg" onClick={openRemoteDialog}>
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} {ko.onboarding.addRemote}
</button> </button>
</div> </div>
<div class="mt-1 text-[11px] text-[var(--text-muted)]">{ko.onboarding.hint}</div> <div class="mt-1 text-[var(--fs-xs)] text-[var(--text-muted)]">
{ko.onboarding.hint}
</div>
</div> </div>
</div> </div>
</Show> </Show>
</div>
</main> </main>
); );
}; };
+617 -61
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 { ko } from "../i18n/ko";
import { import {
applyTagSet,
assignTags, assignTags,
createTag, createTag,
createTagSet,
deleteTagSet,
fileDetails, fileDetails,
fileTagsOf, fileTagsOf,
listTags, listTags,
listTagSets,
setRating,
thumbUrl,
unassignTags, unassignTags,
updateTagSet,
type FileDetails, type FileDetails,
type Tag,
type TagSet,
} from "../ipc/commands"; } 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 { function fmtSize(bytes: number): string {
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(2)} GB`; if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(2)} GB`;
@@ -18,20 +57,268 @@ function fmtSize(bytes: number): string {
return `${bytes} B`; 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 "—"; 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) => ( /** 0:42 / 1:03:20 — 현행 "212초" 표기 폐기 */
<div class="px-3 py-1"> function fmtDuration(ms: number | null | undefined): string {
<div class="text-[10px] text-[var(--text-muted)]">{props.label}</div> if (!ms) return "";
<div class="break-all text-xs text-[var(--text-primary)]" title={props.value}> 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} {props.value}
</div> </div>
</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 TagEditor: Component = () => {
const [tagVersion, setTagVersion] = createSignal(0); const [tagVersion, setTagVersion] = createSignal(0);
@@ -59,6 +346,7 @@ const TagEditor: Component = () => {
const r = await assignTags(ids, tagId); const r = await assignTags(ids, tagId);
toast(ko.tags.assignTo(r.count, trimmed)); toast(ko.tags.assignTo(r.count, trimmed));
setTagVersion((v) => v + 1); setTagVersion((v) => v + 1);
bumpTagVersion();
refreshSidebar(); refreshSidebar();
} catch (e) { } catch (e) {
toast(String(e)); toast(String(e));
@@ -70,47 +358,268 @@ const TagEditor: Component = () => {
const r = await unassignTags(ids, tagId); const r = await unassignTags(ids, tagId);
toast(ko.tags.removedFrom(r.count, name)); toast(ko.tags.removedFrom(r.count, name));
setTagVersion((v) => v + 1); setTagVersion((v) => v + 1);
bumpTagVersion();
refreshSidebar(); 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 ( return (
<div class="px-3 py-1"> <div class="px-3 pt-2.5 pb-3">
<div class="text-[10px] text-[var(--text-muted)]">{ko.details.tags}</div> {/* 붙어 있는 태그 */}
<div class="mt-1 flex flex-wrap gap-1"> <div class="section-label">{ko.details.tags}</div>
<Show when={singleId() != null}> <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()}> <For each={currentTags()}>
{(t) => ( {(t) => (
<span class="flex items-center gap-1 rounded-full bg-[var(--accent-soft)] px-2 py-0.5 text-[11px] text-[var(--accent)]"> <span class="chip chip--accent">
{t.name} <span>{t.name}</span>
<button <button class="chip__x" onClick={() => void removeTag(t.id, t.name)}>
class="hover:text-[var(--danger)]" <IconX size={11} />
onClick={() => void removeTag(t.id, t.name)}
>
×
</button> </button>
</span> </span>
)} )}
</For> </For>
</Show> </Show>
</div> </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 <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)]" class="field field--block mt-1.5"
placeholder={ko.tags.add} placeholder={ko.tags.filterOrCreate}
list="all-tags-datalist" value={filter()}
onInput={(e) => setFilter(e.currentTarget.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key !== "Enter") return;
void addTag(e.currentTarget.value); void addTag(e.currentTarget.value);
e.currentTarget.value = ""; setFilter("");
}
}} }}
/> />
<datalist id="all-tags-datalist"> <div class="tag-pick mt-1.5">
<For each={allTags()}>{(t) => <option value={t.name} />}</For> <Show when={canCreate()}>
</datalist> <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> </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 = () => { export const DetailsPanel: Component = () => {
const singleId = () => { const singleId = () => {
const s = selected(); const s = selected();
@@ -120,46 +629,93 @@ export const DetailsPanel: Component = () => {
const list = await fileDetails([id]); const list = await fileDetails([id]);
return list[0] as FileDetails | undefined; 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 ( return (
<aside class="flex min-h-0 flex-col overflow-y-auto border-l border-[var(--border)] bg-[var(--bg-panel)]"> <aside class="surface-card surface-card--fill">
<div class="px-3 pt-4 pb-1.5 text-[11px] font-semibold tracking-wide text-[var(--text-muted)]"> {/* 헤더 h40 */}
{ko.details.title} <div class="flex h-10 flex-none items-center gap-2 border-b border-[var(--border)] px-3">
</div> <div class="h-6 w-6 flex-none overflow-hidden rounded-[var(--r-sm)] bg-[var(--bg-panel-raised)]">
<Show <Show when={thumbSrc()}>
when={details()} {(src) => (
fallback={ <img
<div class="px-3 py-2 text-xs leading-5 text-[var(--text-muted)]"> src={src()}
{selected().size > 1 class="h-full w-full object-cover"
? ko.grid.selected(selected().size) decoding="async"
: ko.details.noSelection} draggable={false}
</div> onError={() => setThumbErrId(singleId())}
}
>
{(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)}`}
/> />
</Show>
<Row label={ko.details.takenAt} value={fmtDate(d().takenAt)} />
<Row label={ko.details.modifiedAt} value={fmtDate(d().mtimeMs)} />
</>
)} )}
</Show> </Show>
<Show when={selected().size > 0}> </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 /> <TagEditor />
</Show> </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> </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 { ko } from "../i18n/ko";
import { listTagSets } from "../ipc/commands";
import { closeShortcuts, shortcutsOpen } from "../state/store"; import { closeShortcuts, shortcutsOpen } from "../state/store";
export const ShortcutsOverlay: Component = () => { export const ShortcutsOverlay: Component = () => {
// 열릴 때만 읽는다 — 세트는 자주 바뀌지 않고, 닫혀 있는 오버레이가 폴링할 이유가 없다
const [setKeys] = createResource(
() => (shortcutsOpen() ? 1 : undefined),
async () => (await listTagSets()).filter((s) => s.hotkey),
);
onMount(() => { onMount(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (!shortcutsOpen()) return; if (!shortcutsOpen()) return;
@@ -36,7 +44,7 @@ export const ShortcutsOverlay: Component = () => {
onClick={closeShortcuts} onClick={closeShortcuts}
title={ko.shortcuts.close} title={ko.shortcuts.close}
> >
<IconX size={14} />
</button> </button>
</div> </div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
@@ -50,6 +58,25 @@ export const ShortcutsOverlay: Component = () => {
</div> </div>
)} )}
</For> </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> </div>
</div> </div>
+787 -159
View File
File diff suppressed because it is too large Load Diff
+49 -62
View File
@@ -1,78 +1,65 @@
// 상태바 h26 — 페이지 배경 위(테두리 없음, 카드 아님).
//
// 역할 분담(사이드바 작업 존과의 계약). 요구는 '탐색·정리 중에 사이드바에서 진행을
// 볼 수 있게'였고, 그 답은 같은 정보를 두 곳에 복사하는 것이 아니라 나누는 것이다.
// · 사이드바 작업 존 = '지금 무엇이 얼마나' — 작업의 존재·수량·진행 막대·취소,
// 그리고 중복 모달로 들어가는 진입점(openDedupe는 거기 두 곳뿐이다).
// · 여기(footer) = '자세한 사정' — 토스트 한 줄, 지금 읽고 있는 폴더 경로, 데이터 위치.
//
// 그래서 여기서 사라진 것들:
// · 중복 탐지 진행/취소 — 사이드바 작업 행이 소유한다. 진입점도 두지 않는다.
// footer가 중복 탐지에 대해 말하는 것은 완료·취소 토스트 한 번뿐이다(store가 던진다).
// · 썸네일·메타·지문 대기 수 — 사이드바 백필 행이 소유한다. 지문 대기 수가 사라지는
// 것이 아니다: 그 행의 부제(ko.tasks.backfillDetail)가 '지문 N'까지 말한다.
// 항목수·선택수는 브레드크럼이, 포터블/버전은 사이드바 통계 카드가 이미 가져갔다.
import { Show, type Component } from "solid-js"; import { Show, type Component } from "solid-js";
import { ko } from "../i18n/ko"; import { ko } from "../i18n/ko";
import { import { dedupeRunning, info, pending, scanProgress, statusMessage } from "../state/store";
dedupeProgress,
dedupeRunning,
info,
openDedupe,
pending,
requestCancelDedupe,
scanProgress,
selected,
snapshot,
statusMessage,
} from "../state/store";
export const StatusBar: Component = () => { 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 ( 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)]"> <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={statusMessage()}> <Show when={line()}>
<span class="text-[var(--accent)]">{statusMessage()}</span> {(l) => (
</Show> <span
<Show class="min-w-0 truncate"
when={scanProgress()} classList={{
fallback={ "text-[var(--accent-text)]": l().accent,
<span> "text-[var(--text-muted)]": !l().accent,
{snapshot() ? ko.grid.items(snapshot()!.count) : ko.statusBar.ready} }}
<Show when={selected().size > 0}> title={l().text}
<span class="ml-2 text-[var(--accent)]">{ko.grid.selected(selected().size)}</span>
</Show>
</span>
}
> >
{(p) => ( {l().text}
<span class="text-[var(--accent)]">
{ko.statusBar.scanning(p().currentDir)} {p().seen.toLocaleString("ko-KR")}
</span> </span>
)} )}
</Show> </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()}> <Show when={info()}>
{(i) => ( {(i) => (
<> <span class="ml-auto min-w-0 truncate text-[var(--text-muted)]" title={i().dataDir}>
<span class="ml-auto truncate text-[var(--text-muted)]" title={i().dataDir}> {ko.statusBar.dataDir}: {i().dataDir}
{i().portable ? ko.statusBar.portable : ko.statusBar.installed}
</span> </span>
<span class="text-[var(--text-muted)]">v{i().version}</span>
</>
)} )}
</Show> </Show>
</footer> </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);
+484 -18
View File
@@ -1,23 +1,32 @@
// 전역 상태 — Solid 시그널 기반 단일 스토어 모듈. // 전역 상태 — Solid 시그널 기반 단일 스토어 모듈.
import { createSignal } from "solid-js"; 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 { import {
appInfo, appInfo,
cancelDedupe, cancelDedupe,
folderSnapshot, folderSnapshot,
markDelete,
onDedupeDone,
pendingCounts as fetchPendingCounts, pendingCounts as fetchPendingCounts,
purgeMissing,
searchSnapshot, searchSnapshot,
smartSnapshot, smartSnapshot,
startDedupe, startDedupe,
tagSnapshot, tagSnapshot,
trashTagId as fetchTrashTagId,
unmarkDelete,
Snapshot, Snapshot,
type AppInfo, type AppInfo,
type DedupeProgress, type DedupeProgress,
type PendingCounts, type PendingCounts,
type ScanGuarded,
type ScanProgress, type ScanProgress,
type SnapOpts, type SnapOpts,
type SortKey, type SortKey,
type UnlistenFn,
} from "../ipc/commands"; } from "../ipc/commands";
import { ko } from "../i18n/ko";
// ── 앱 정보 (미디어 서버 포트/토큰 포함) ───────────────── // ── 앱 정보 (미디어 서버 포트/토큰 포함) ─────────────────
const [info, setInfo] = createSignal<AppInfo | undefined>(); const [info, setInfo] = createSignal<AppInfo | undefined>();
@@ -35,7 +44,9 @@ export async function loadAppInfo(): Promise<void> {
export type View = export type View =
| { type: "all" } | { type: "all" }
| { type: "source"; sourceId: number } | { 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: "search"; query: string }
| { type: "tag"; tagId: number } | { type: "tag"; tagId: number }
| { type: "smart"; smartId: number }; | { type: "smart"; smartId: number };
@@ -50,7 +61,9 @@ const [sortKey, setSortKeySig] = createSignal<SortKey>("taken");
const [sortDesc, setSortDescSig] = createSignal(true); const [sortDesc, setSortDescSig] = createSignal(true);
export type KindFilter = "all" | "image" | "video"; export type KindFilter = "all" | "image" | "video";
const [kindFilter, setKindFilterSig] = createSignal<KindFilter>("all"); 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 }; export type { SortKey };
/** 정렬 변경 → 재조회 */ /** 정렬 변경 → 재조회 */
@@ -64,6 +77,28 @@ export function setKindFilter(k: KindFilter): void {
setKindFilterSig(k); setKindFilterSig(k);
void refreshSnapshot(); 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 유지) // 그리드 썸네일에 파일명·크기 오버레이 표시 (localStorage 유지)
const INFO_KEY = "archive.grid.infoOverlay"; const INFO_KEY = "archive.grid.infoOverlay";
@@ -75,14 +110,151 @@ export function toggleShowInfo(): void {
localStorage.setItem(INFO_KEY, n ? "1" : "0"); 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; let viewSeq = 0;
export async function setView(v: View): Promise<void> { 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); setViewSignal(v);
clearSelection(); clearSelection();
await refreshSnapshot(); 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> { export async function refreshSnapshot(): Promise<void> {
const v = view(); const v = view();
const seq = ++viewSeq; const seq = ++viewSeq;
@@ -91,6 +263,7 @@ export async function refreshSnapshot(): Promise<void> {
sortKey: sortKey(), sortKey: sortKey(),
sortDesc: sortDesc(), sortDesc: sortDesc(),
kind: kindFilter() === "image" ? 0 : kindFilter() === "video" ? 1 : null, kind: kindFilter() === "image" ? 0 : kindFilter() === "video" ? 1 : null,
minRating: minRating() > 0 ? minRating() : null,
}; };
try { try {
let snap: Snapshot; let snap: Snapshot;
@@ -114,12 +287,39 @@ export async function refreshSnapshot(): Promise<void> {
snap = await smartSnapshot(v.smartId, opts); snap = await smartSnapshot(v.smartId, opts);
break; 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 { } finally {
if (seq === viewSeq) setLoading(false); 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 [selected, setSelected] = createSignal<ReadonlySet<number>>(new Set());
const [anchorIndex, setAnchorIndex] = createSignal<number>(-1); const [anchorIndex, setAnchorIndex] = createSignal<number>(-1);
@@ -156,6 +356,70 @@ export function setSelectionFromIds(ids: number[]): void {
setSelected(new Set<number>(ids)); 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 이벤트로 증가) ──────── // ── 썸네일 갱신 버전 (thumbs-ready 이벤트로 증가) ────────
const [thumbVersions, setThumbVersions] = createStore<Record<number, number>>({}); const [thumbVersions, setThumbVersions] = createStore<Record<number, number>>({});
export { thumbVersions }; export { thumbVersions };
@@ -172,22 +436,90 @@ export function bumpThumbVersions(ids: number[]): void {
const [scanProgress, setScanProgress] = createSignal<ScanProgress | undefined>(); const [scanProgress, setScanProgress] = createSignal<ScanProgress | undefined>();
export { scanProgress }; export { scanProgress };
// 열거 단계는 총량을 모른다 — 다 돌아 봐야 몇 개인지 안다. 그래서 퍼센트를 지어낼 수 없다.
// 대신 '초당 몇 건'을 재서 진행이 살아 있음과 빠르기를 함께 전한다.
// 지수 평활(0.3)로 스파이크를 눌러 숫자가 튀지 않게 한다.
const [scanRate, setScanRate] = createSignal(0);
export { scanRate };
let rateSeen = 0;
let rateAt = 0;
export function updateScanProgress(p: ScanProgress): void { 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 }; export { pending };
/**
* '가장 많이 밀려 있던 순간' .
*
* ** DB에 **. ,
* ** 0% **( ).
* `thumb_state` .
*/
const [backfillTotal, setBackfillTotal] = createSignal(0);
export { backfillTotal };
let pendingTimer: ReturnType<typeof setTimeout> | undefined; let pendingTimer: ReturnType<typeof setTimeout> | undefined;
async function doRefreshPending(): Promise<void> { async function doRefreshPending(): Promise<void> {
try { 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); setPending(c);
setBackfillTotal(c.baseline);
clearTimeout(pendingTimer); clearTimeout(pendingTimer);
// 남은 작업이 있으면 계속 폴링(썸네일은 thumbs-ready로도 갱신되지만 메타 이벤트가 없어 폴링 필요) // 남은 작업이 있으면 계속 폴링(썸네일은 thumbs-ready로도 갱신되지만 메타·지문은 이벤트가 없어 폴링 필요)
if (c.thumbs > 0 || c.meta > 0) pendingTimer = setTimeout(() => void doRefreshPending(), 2000); if (c.thumbs > 0 || c.meta > 0 || c.sigs > 0)
pendingTimer = setTimeout(() => void doRefreshPending(), 2000);
} catch { } catch {
/* 무시 */ /* 무시 */
} }
@@ -199,11 +531,72 @@ export function refreshPendingCounts(): void {
pendingTimer = setTimeout(() => void doRefreshPending(), 400); 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); const [sidebarVersion, setSidebarVersion] = createSignal(0);
export { sidebarVersion }; export { sidebarVersion };
/** 사용자 동작(태그 추가·소스 제거 등) — 즉시 반영한다. */
export const refreshSidebar = () => setSidebarVersion((v) => v + 1); 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_MIN = 110;
export const GRID_ROW_MAX = 420; export const GRID_ROW_MAX = 420;
@@ -236,16 +629,35 @@ export { shortcutsOpen };
export const openShortcuts = () => setShortcutsOpen(true); export const openShortcuts = () => setShortcutsOpen(true);
export const closeShortcuts = () => setShortcutsOpen(false); export const closeShortcuts = () => setShortcutsOpen(false);
// ── 중복 검토 패널 ─────────────────────────────────────── // ── 중복 검토 모달 ───────────────────────────────────────
// 모달은 진행을 소유하지 않는다 — 열림/닫힘만 소유한다.
const [dedupeOpen, setDedupeOpen] = createSignal(false); const [dedupeOpen, setDedupeOpen] = createSignal(false);
export { dedupeOpen }; export { dedupeOpen };
/** 진입점은 사이드바 두 곳(정리 행 · 작업 행)뿐이다. footer에는 진입점을 두지 않는다. */
export const openDedupe = () => setDedupeOpen(true); 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 [dedupeProgress, setDedupeProgress] = createSignal<DedupeProgress | undefined>();
const [dedupeRunning, setDedupeRunning] = createSignal(false); 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 { export interface DedupeRunArgs {
sourceId?: number; sourceId?: number;
@@ -253,27 +665,79 @@ export interface DedupeRunArgs {
recursive?: boolean; recursive?: boolean;
imageThreshold?: number; imageThreshold?: number;
includeVideo?: boolean; 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> { export async function runDedupe(args: DedupeRunArgs): Promise<void> {
if (dedupeRunning()) return; if (dedupeRunning()) return;
setDedupeRunning(true); setDedupeJob({
scopeLabel: args.scopeLabel ?? ko.dedupe.scopeAll,
args,
startedAt: Date.now(),
});
setDedupeCancelAt(undefined);
setDedupeProgress(undefined); setDedupeProgress(undefined);
setDedupeRunning(true);
let unlisten: UnlistenFn | undefined;
try { try {
// 끝을 알리는 신호가 둘이다. 둘 다 들어야 한다.
// ① phase === "done" 진행 메시지 — 정상 완료 경로.
// ② dedupe-done 앱 이벤트 — 완료·취소·실패 전부. 취소되면 백엔드는 Err로 빠져나가
// "done"을 보내지 않으므로 ①만 기다리면 영원히 끝나지 않는다(취소해도 진행
// 표시가 남아 있던 원인이 이것이다). start_dedupe는 스레드를 띄우고 즉시
// Ok를 반환하므로 invoke의 resolve는 완료를 뜻하지 않는다 — 기다릴 수 없다.
// 아주 짧은 작업은 invoke보다 이벤트가 먼저 도착할 수 있어 등록을 먼저 끝낸다.
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
startDedupe(args, (p) => { onDedupeDone(resolve)
.then((un) => {
unlisten = un;
return startDedupe(args, (p) => {
setDedupeProgress(p); setDedupeProgress(p);
if (p.phase === "done") resolve(); if (p.phase === "done") resolve();
}).catch(reject);
}); });
})
.catch(reject);
});
toast(dedupeCancelAt() != null ? ko.dedupe.canceled : ko.dedupe.done);
} catch (e) {
// 취소 구간의 실패는 사용자가 의도한 결과다 — 오류가 아니라 '취소됨'으로 말한다
if (dedupeCancelAt() == null) throw e;
toast(ko.dedupe.canceled);
} finally { } finally {
unlisten?.();
// 진행 표시는 즉시 언마운트된다(잔상 없음). dedupeJob은 남긴다 — 지금 보이는
// 결과가 어떤 범위의 결과인지 계속 말해야 한다.
setDedupeRunning(false); setDedupeRunning(false);
setDedupeProgress(undefined); 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>(); const [statusMessage, setStatusMessage] = createSignal<string | undefined>();
@@ -294,6 +758,8 @@ export function openLightbox(index: number): void {
const snap = snapshot(); const snap = snapshot();
if (!snap || index < 0 || index >= snap.count) return; if (!snap || index < 0 || index >= snap.count) return;
setLightboxIndex(index); setLightboxIndex(index);
// 라이트박스의 이동·태그·삭제는 선택 항목에 작용한다 — 열 때 현재 항목만 선택으로 맞춘다.
selectSingle(snap.id(index), index);
} }
export function closeLightbox(): void { export function closeLightbox(): void {
+1283 -23
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);