영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
중복 탐지 - 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시) - 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(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:
co-authored by
Claude Opus 5
parent
67d2ddc59e
commit
8aa74c1a8d
@@ -11,3 +11,9 @@ data/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 로컬 크로스 컴파일 도구 (scripts/fetch-arm64-toolchain.ps1이 내려받는다 — 재생성 가능)
|
||||
.toolchain/
|
||||
|
||||
# 포터블 빌드 산출물
|
||||
dist-portable/
|
||||
|
||||
Generated
+10
@@ -11,6 +11,7 @@
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"lucide-solid": "^1.28.0",
|
||||
"solid-js": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2405,6 +2406,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-solid": {
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-solid/-/lucide-solid-1.28.0.tgz",
|
||||
"integrity": "sha512-zF/0H7eiZya+tgmtpJNGzKPUfr+7p+zjLyrIsOSUAPi0IHbEqdLJ+U8w41g+889s63eUoU6BrSLu0HxUx3RTIQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"solid-js": "^1.4.7"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"lucide-solid": "^1.28.0",
|
||||
"solid-js": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
# WebView2 CDP (Chrome DevTools Protocol) E2E helper.
|
||||
# Usage: . .\scripts\cdp.ps1; Invoke-Cdp "document.title"
|
||||
# . .\scripts\cdp.ps1; Save-CdpScreenshot shot.png
|
||||
# Prereq: app launched with WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9223
|
||||
# NOTE: ASCII only in this file (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
@@ -51,3 +52,58 @@ function Invoke-Cdp {
|
||||
}
|
||||
return $response.result.result.value
|
||||
}
|
||||
|
||||
# Call any CDP method and return the raw result object.
|
||||
# Invoke-Cdp is hardcoded to Runtime.evaluate; use this for Page.*, Emulation.*, etc.
|
||||
function Invoke-CdpMethod {
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$Method,
|
||||
[hashtable]$Params = @{},
|
||||
[int]$Port = 9223,
|
||||
[int]$TimeoutSec = 30
|
||||
)
|
||||
$targets = Invoke-RestMethod "http://127.0.0.1:$Port/json"
|
||||
$page = $targets | Where-Object { $_.type -eq 'page' -and $_.url -notlike 'devtools*' } | Select-Object -First 1
|
||||
if (-not $page) { throw "no CDP page target" }
|
||||
|
||||
$ws = New-Object System.Net.WebSockets.ClientWebSocket
|
||||
$ct = [System.Threading.CancellationToken]::None
|
||||
$ws.ConnectAsync([Uri]$page.webSocketDebuggerUrl, $ct).GetAwaiter().GetResult() | Out-Null
|
||||
|
||||
$msg = @{ id = 1; method = $Method; params = $Params } | ConvertTo-Json -Depth 6 -Compress
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($msg)
|
||||
$seg = New-Object System.ArraySegment[byte] -ArgumentList @(, $bytes)
|
||||
$ws.SendAsync($seg, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $ct).GetAwaiter().GetResult() | Out-Null
|
||||
|
||||
# Screenshots are large: keep reading frames until EndOfMessage, then match the id.
|
||||
$buffer = New-Object byte[] 1048576
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
$response = $null
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
do {
|
||||
$rseg = New-Object System.ArraySegment[byte] -ArgumentList @(, $buffer)
|
||||
$result = $ws.ReceiveAsync($rseg, $ct).GetAwaiter().GetResult()
|
||||
$sb.Append([System.Text.Encoding]::UTF8.GetString($buffer, 0, $result.Count)) | Out-Null
|
||||
} while (-not $result.EndOfMessage)
|
||||
$obj = $sb.ToString() | ConvertFrom-Json
|
||||
if ($obj.id -eq 1) { $response = $obj; break }
|
||||
}
|
||||
$ws.Abort()
|
||||
if (-not $response) { throw "CDP response timeout" }
|
||||
if ($response.error) { throw "CDP error: $($response.error.message)" }
|
||||
return $response.result
|
||||
}
|
||||
|
||||
# Save the page as a PNG (what the WebView actually renders, not the OS window).
|
||||
function Save-CdpScreenshot {
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$Path,
|
||||
[int]$Port = 9223,
|
||||
[int]$TimeoutSec = 30
|
||||
)
|
||||
$r = Invoke-CdpMethod -Method "Page.captureScreenshot" -Params @{ format = "png" } -Port $Port -TimeoutSec $TimeoutSec
|
||||
if (-not $r.data) { throw "no screenshot data" }
|
||||
[System.IO.File]::WriteAllBytes($Path, [System.Convert]::FromBase64String($r.data))
|
||||
return (Get-Item $Path).Length
|
||||
}
|
||||
|
||||
+38
-3
@@ -1,6 +1,41 @@
|
||||
@echo off
|
||||
rem Archive 개발 실행 — rustc가 라이브러리 없는 VS Community를 잡지 않도록
|
||||
rem Build Tools vcvars64 환경에서 tauri dev를 띄운다.
|
||||
call "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat" >nul
|
||||
rem Archive dev run. rustc needs an MSVC toolchain env, so load vcvars64 first.
|
||||
rem The install path differs per machine (Build Tools / Community / VS version),
|
||||
rem so probe the usual spots and fall back to vswhere instead of hardcoding one.
|
||||
rem NOTE: ASCII only in this file - cmd.exe reads it in the OEM codepage.
|
||||
setlocal
|
||||
cd /d "%~dp0.."
|
||||
|
||||
rem Probe vcvarsall.bat, not vcvars64.bat: a VS install without the C++ workload
|
||||
rem still ships the vcvars64 wrapper but not vcvarsall, and calling it just fails.
|
||||
set "VCVARS="
|
||||
for %%P in (
|
||||
"C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
"C:\Program Files\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
"C:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
"C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
) do if not defined VCVARS if exist %%P set "VCVARS=%%~P"
|
||||
|
||||
if defined VCVARS goto :have_vcvars
|
||||
|
||||
rem vswhere only reports installs that actually carry the x64 C++ tools
|
||||
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
if not exist "%VSWHERE%" goto :no_vcvars
|
||||
for /f "usebackq delims=" %%I in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VCVARS=%%I\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
if not exist "%VCVARS%" goto :no_vcvars
|
||||
|
||||
:have_vcvars
|
||||
call "%VCVARS%" x64 >nul
|
||||
where cargo >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo [dev] cargo not found on PATH - install Rust ^(https://rustup.rs^) and reopen the shell.
|
||||
exit /b 1
|
||||
)
|
||||
npm run tauri dev %*
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
:no_vcvars
|
||||
echo [dev] No MSVC toolchain found (vcvarsall.bat missing).
|
||||
echo [dev] Install "Desktop development with C++" (VS Build Tools or VS Community).
|
||||
exit /b 1
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -1,5 +1,44 @@
|
||||
@echo off
|
||||
rem 코어 크레이트 헤드리스 테스트 (Build Tools vcvars64 환경)
|
||||
call "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat" >nul
|
||||
rem Headless tests for the core crates. Needs an MSVC toolchain env for linking.
|
||||
rem The VS install path differs per machine (BuildTools / Community / VS version),
|
||||
rem so probe the usual spots and fall back to vswhere - same logic as dev.cmd.
|
||||
rem NOTE: ASCII only in this file - cmd.exe reads it in the OEM codepage.
|
||||
setlocal
|
||||
|
||||
set "VCVARS="
|
||||
for %%P in (
|
||||
"C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
"C:\Program Files\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
"C:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
"C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
"C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
) do if not defined VCVARS if exist %%P set "VCVARS=%%~P"
|
||||
|
||||
if defined VCVARS goto :have_vcvars
|
||||
|
||||
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
if not exist "%VSWHERE%" goto :no_vcvars
|
||||
for /f "usebackq delims=" %%I in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VCVARS=%%I\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
if not exist "%VCVARS%" goto :no_vcvars
|
||||
|
||||
:have_vcvars
|
||||
call "%VCVARS%" x64 >nul
|
||||
where cargo >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
if exist "%USERPROFILE%\.cargo\bin\cargo.exe" (
|
||||
set "PATH=%USERPROFILE%\.cargo\bin;%PATH%"
|
||||
) else (
|
||||
echo [test] cargo not found on PATH - install Rust ^(https://rustup.rs^) and reopen the shell.
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
cd /d "%~dp0..\src-tauri"
|
||||
cargo test --workspace %*
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
:no_vcvars
|
||||
echo [test] No MSVC toolchain found (vcvarsall.bat missing).
|
||||
echo [test] Install "Desktop development with C++" (VS Build Tools or VS Community),
|
||||
echo [test] or add: MSVC v14x x64/x86 build tools + Windows 11 SDK.
|
||||
exit /b 1
|
||||
|
||||
@@ -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);
|
||||
Generated
+20
@@ -143,6 +143,7 @@ dependencies = [
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
@@ -6260,6 +6261,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symlink"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -7085,6 +7092,19 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-appender"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"symlink",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
|
||||
@@ -45,6 +45,8 @@ anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
# 릴리스 GUI 빌드에는 콘솔이 없다 — 파일로 남기지 않으면 진단이 통째로 사라진다.
|
||||
tracing-appender = "0.2"
|
||||
dirs = "6"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
notify-debouncer-full = "0.7"
|
||||
|
||||
@@ -156,6 +156,177 @@ CREATE TABLE settings (
|
||||
) WITHOUT ROWID;
|
||||
"#;
|
||||
|
||||
/// 스키마 v2: ops_journal.op에 'delete'(영구 삭제) 추가.
|
||||
/// CHECK 제약은 ALTER로 못 바꾸므로 테이블을 재생성한다.
|
||||
const V2_DDL: &str = r#"
|
||||
CREATE TABLE ops_journal_v2 (
|
||||
id INTEGER PRIMARY KEY,
|
||||
batch_id TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('move','rename','trash','delete','tag','untag')),
|
||||
file_id INTEGER,
|
||||
src_path TEXT,
|
||||
dst_path TEXT,
|
||||
tag_id INTEGER,
|
||||
performed_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
|
||||
undone INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO ops_journal_v2(id, batch_id, op, file_id, src_path, dst_path, tag_id, performed_at, undone)
|
||||
SELECT id, batch_id, op, file_id, src_path, dst_path, tag_id, performed_at, undone FROM ops_journal;
|
||||
DROP TABLE ops_journal;
|
||||
ALTER TABLE ops_journal_v2 RENAME TO ops_journal;
|
||||
CREATE INDEX idx_ops_batch ON ops_journal(batch_id);
|
||||
"#;
|
||||
|
||||
/// 스키마 v3: 지문(signature) 영속화.
|
||||
///
|
||||
/// 중복 탐지가 매 실행마다 라이브러리 전체를 다시 계산하던 것을 없애기 위해, 파일당 지문을
|
||||
/// 한 번 계산해 저장한다. 재계산 단위는 `files.sig_state`(0 대기 / 1 완료 / 2 실패)이고,
|
||||
/// 이는 `thumb_state`와 같은 방식이다 — **행별 상태 컬럼 + 부분 인덱스**가 곧 작업 큐이자
|
||||
/// 재시작 후 이어하기 수단이다. `sig_version`을 올리면 해당 파일만 다시 계산된다.
|
||||
///
|
||||
/// `orient`/`tiles`/`scales`는 **대표 프레임 한 장**을 기술한다(이미지는 그 이미지,
|
||||
/// 영상은 첫 유효 프레임). 프레임 단위 상세는 `frames` BLOB에 들어간다.
|
||||
const V3_DDL: &str = r#"
|
||||
ALTER TABLE files ADD COLUMN sig_state INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX idx_files_pending_sig ON files(id) WHERE sig_state = 0 AND deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE file_sigs (
|
||||
file_id INTEGER PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
|
||||
sig_version INTEGER NOT NULL,
|
||||
kind INTEGER NOT NULL,
|
||||
dhash INTEGER NOT NULL,
|
||||
phash INTEGER NOT NULL,
|
||||
orient BLOB,
|
||||
tiles BLOB,
|
||||
scales BLOB,
|
||||
frames BLOB,
|
||||
keyframes BLOB,
|
||||
frame_count INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER,
|
||||
stdev REAL,
|
||||
flags INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000)
|
||||
);
|
||||
CREATE INDEX idx_file_sigs_dhash ON file_sigs(dhash);
|
||||
CREATE INDEX idx_file_sigs_version ON file_sigs(sig_version);
|
||||
"#;
|
||||
|
||||
/// 스키마 v4: 기본 정렬용 표현식 인덱스.
|
||||
///
|
||||
/// 그리드 스냅샷의 기본 정렬은 `COALESCE(taken_at, mtime_ms) DESC, id DESC`다. 표현식이라
|
||||
/// 어떤 컬럼 인덱스도 쓰이지 못해, SQLite가 매번 임시 B-tree로 **전체를 정렬**했다.
|
||||
/// 100만 건 합성 DB 실측: 첫 행까지 1,574ms(정렬 완료를 기다림) → 인덱스 추가 후 0ms,
|
||||
/// 스캔+정렬 239ms → 54ms. 임시 B-tree(100만 항목, 디스크로 흘러넘침)도 사라진다.
|
||||
///
|
||||
/// 두 개가 필요하다: 소스 뷰는 `source_id`가 선두여야 하고(작은 소스도 O(결과)),
|
||||
/// 라이브러리 전체 뷰는 선두 컬럼이 없어야 정렬된 순서로 훑을 수 있다.
|
||||
/// 대가는 삽입 시 인덱스 2개 유지 — 스캔이 조금 느려지는 대신 뷰 전환이 즉시 뜬다.
|
||||
const V4_DDL: &str = r#"
|
||||
CREATE INDEX idx_files_src_sort ON files(source_id, COALESCE(taken_at, mtime_ms) DESC, id DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_files_sort ON files(COALESCE(taken_at, mtime_ms) DESC, id DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
"#;
|
||||
|
||||
/// 스키마 v5: 중복 그룹의 **일치 구간**을 저장한다 (비교 재생용).
|
||||
///
|
||||
/// 영상 중복은 오프셋 투표로 "A의 10~20초가 B의 0~10초와 같다"까지 알아내는데, 지금까지는
|
||||
/// 그 값을 로그로만 흘려보냈다. 비교 재생에서 두 영상을 같은 내용에 맞춰 돌리려면 이 값이
|
||||
/// 필요하다 — 그냥 나란히 0초부터 재생하면 인트로가 붙은 사본은 계속 어긋난 화면을 보여준다.
|
||||
///
|
||||
/// 기준은 그룹의 keeper다: `offset_ms = (keeper 시각) - (이 멤버 시각)`.
|
||||
/// 즉 재생 위치를 맞출 때 `member.currentTime = keeper.currentTime - offset_ms/1000`.
|
||||
/// `seg_start_ms`/`seg_end_ms`는 keeper 시간축에서 일치가 확인된 구간이다.
|
||||
const V5_DDL: &str = r#"
|
||||
ALTER TABLE dupe_members ADD COLUMN offset_ms INTEGER;
|
||||
ALTER TABLE dupe_members ADD COLUMN seg_start_ms INTEGER;
|
||||
ALTER TABLE dupe_members ADD COLUMN seg_end_ms INTEGER;
|
||||
"#;
|
||||
|
||||
/// 스키마 v6: 후보 검증용 두 축을 저장한다 (`sig_version` 2와 짝).
|
||||
///
|
||||
/// - `tile_flat`: 4×4 타일별 '평평함'(정보 없음) 비트마스크. 검정 바·흰 로고로 가려진 영역을
|
||||
/// 판정에서 빼기 위해 필요하다. 이게 없으면 *가림이 같다는 이유로* 서로 다른 사진이
|
||||
/// 한 그룹이 된다(실측된 오탐).
|
||||
/// - `color`: 4×4 셀의 색차(u,v) 32바이트. 그레이 dHash는 구조만 보므로 색만 다른 그림을
|
||||
/// 구별하지 못한다(실측: 초록/자홍 그라디언트 거리 0). 영상은 그레이 파이프라 NULL이다.
|
||||
///
|
||||
/// 기존 행은 `sig_version`이 낮아 자연히 재계산 대상이 된다 — 별도 백필이 필요 없다.
|
||||
const V6_DDL: &str = r#"
|
||||
ALTER TABLE file_sigs ADD COLUMN tile_flat INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE file_sigs ADD COLUMN color BLOB;
|
||||
UPDATE files SET sig_state = 0 WHERE sig_state = 1;
|
||||
"#;
|
||||
|
||||
/// 스키마 v7: 영상 오디오 지문(chromaprint) 저장 (`sig_version` 3과 짝).
|
||||
///
|
||||
/// 화면을 다시 표본화하는 변형(레터박스·리스케일)은 프레임 해시가 밀려 값 기반 후보에
|
||||
/// 걸리지 않는다 — 실측: 검정 띠를 정확히 잘라내도 거리 중앙값 9.5, 정확히 같은 값 0개.
|
||||
/// 오디오는 그런 변형에 영향받지 않으므로(레터박스 사본은 221/221 일치) 후보를 만드는
|
||||
/// 두 번째 축으로 쓴다. 다만 오디오만 같은 남남도 똑같이 붙으므로(배경음악 공유)
|
||||
/// 후보는 반드시 프레임으로 확인한다 — 그 간격도 실측했다(진짜 9 vs 남남 29).
|
||||
///
|
||||
/// 재계산은 `sigq::invalidate_stale_sigs`가 `sig_version` 변화를 보고 알아서 예약한다.
|
||||
const V7_DDL: &str = r#"
|
||||
ALTER TABLE file_sigs ADD COLUMN audio BLOB;
|
||||
"#;
|
||||
|
||||
/// 스키마 v8: 태그 세트 — 자주 함께 붙이는 태그 묶음을 한 번에 적용한다.
|
||||
///
|
||||
/// 태그를 하나씩 검색해 붙이는 것이 정리 작업의 대부분을 차지한다는 지적에서 나왔다.
|
||||
/// `hotkey`는 화면에 매인 값이 아니라 사용자 설정이므로 DB에 둔다(기기를 옮겨도 따라간다).
|
||||
/// 태그가 지워지면 세트에서도 빠진다(CASCADE) — 세트는 태그의 묶음일 뿐 사본이 아니다.
|
||||
const V8_DDL: &str = r#"
|
||||
CREATE TABLE tag_sets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
hotkey TEXT,
|
||||
sort_order INTEGER
|
||||
);
|
||||
CREATE TABLE tag_set_items (
|
||||
set_id INTEGER NOT NULL REFERENCES tag_sets(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (set_id, tag_id)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX idx_tag_set_items_tag ON tag_set_items(tag_id);
|
||||
"#;
|
||||
|
||||
/// 스키마 v9: 별점(0~5).
|
||||
///
|
||||
/// 부분 인덱스를 쓰는 이유: 대부분의 파일은 별점이 없다(0). '별점 있는 것만' 걸러 정렬·필터할 때
|
||||
/// 0인 수십만 행을 훑지 않도록 0을 색인에서 빼둔다.
|
||||
const V9_DDL: &str = r#"
|
||||
ALTER TABLE files ADD COLUMN rating INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX idx_files_rating ON files(rating DESC, id DESC) WHERE rating > 0 AND deleted_at IS NULL;
|
||||
"#;
|
||||
|
||||
/// 스키마 v10: ops_journal에 'edit'(회전·반전) 추가 + payload 칸.
|
||||
///
|
||||
/// 회전은 되돌릴 때 **역연산을 다시 적용하면 된다** — 원본을 백업해 둘 필요가 없다.
|
||||
/// 그 역연산 이름을 담을 칸이 필요해서 payload를 만든다. src_path/dst_path에
|
||||
/// 억지로 끼워 넣으면 다음에 읽는 사람이 경로인 줄 안다.
|
||||
///
|
||||
/// SQLite는 CHECK 제약을 ALTER로 못 바꾸므로 v2와 같은 방식으로 표를 다시 만든다.
|
||||
const V10_DDL: &str = r#"
|
||||
CREATE TABLE ops_journal_v10 (
|
||||
id INTEGER PRIMARY KEY,
|
||||
batch_id TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('move','rename','trash','delete','tag','untag','edit')),
|
||||
file_id INTEGER,
|
||||
src_path TEXT,
|
||||
dst_path TEXT,
|
||||
tag_id INTEGER,
|
||||
payload TEXT,
|
||||
performed_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
|
||||
undone INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO ops_journal_v10(id, batch_id, op, file_id, src_path, dst_path, tag_id, performed_at, undone)
|
||||
SELECT id, batch_id, op, file_id, src_path, dst_path, tag_id, performed_at, undone FROM ops_journal;
|
||||
DROP TABLE ops_journal;
|
||||
ALTER TABLE ops_journal_v10 RENAME TO ops_journal;
|
||||
CREATE INDEX idx_ops_batch ON ops_journal(batch_id);
|
||||
"#;
|
||||
|
||||
/// FTS5 trigram — 한글 파일명 부분일치 검색. bundled SQLite가 trigram을
|
||||
/// 지원하지 않는 환경이면 생성 실패를 허용하고 LIKE 폴백을 쓴다.
|
||||
const V1_FTS_DDL: &str = r#"
|
||||
@@ -183,6 +354,7 @@ END;
|
||||
/// 마이그레이션을 적용하고 FTS 사용 가능 여부를 반환한다.
|
||||
pub fn migrate(conn: &mut Connection) -> rusqlite::Result<bool> {
|
||||
let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
|
||||
let mut fresh_fts: Option<bool> = None;
|
||||
|
||||
if version < 1 {
|
||||
let tx = conn.transaction()?;
|
||||
@@ -199,6 +371,75 @@ pub fn migrate(conn: &mut Connection) -> rusqlite::Result<bool> {
|
||||
if !fts_ok {
|
||||
tracing::warn!("FTS5 trigram 사용 불가 — LIKE 검색으로 폴백");
|
||||
}
|
||||
fresh_fts = Some(fts_ok);
|
||||
}
|
||||
|
||||
if version < 2 {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(V2_DDL)?;
|
||||
tx.pragma_update(None, "user_version", 2)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if version < 3 {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(V3_DDL)?;
|
||||
tx.pragma_update(None, "user_version", 3)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if version < 4 {
|
||||
// 기존 라이브러리에서는 인덱스 2개를 새로 만든다 — 100만 건에서 합쳐 3초 남짓,
|
||||
// 앱 시작이 한 번 그만큼 늦어지는 대신 이후 모든 뷰 전환이 즉시 뜬다.
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(V4_DDL)?;
|
||||
tx.pragma_update(None, "user_version", 4)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if version < 5 {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(V5_DDL)?;
|
||||
tx.pragma_update(None, "user_version", 5)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if version < 6 {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(V6_DDL)?;
|
||||
tx.pragma_update(None, "user_version", 6)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if version < 7 {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(V7_DDL)?;
|
||||
tx.pragma_update(None, "user_version", 7)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if version < 8 {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(V8_DDL)?;
|
||||
tx.pragma_update(None, "user_version", 8)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if version < 9 {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(V9_DDL)?;
|
||||
tx.pragma_update(None, "user_version", 9)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if version < 10 {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(V10_DDL)?;
|
||||
tx.pragma_update(None, "user_version", 10)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if let Some(fts_ok) = fresh_fts {
|
||||
return Ok(fts_ok);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,868 @@
|
||||
//! 간단 편집 — 회전·반전.
|
||||
//!
|
||||
//! **원본을 직접 고친다.** 그래서 두 가지를 지킨다.
|
||||
//!
|
||||
//! **① 될 수 있으면 픽셀을 다시 인코딩하지 않는다.**
|
||||
//! JPEG은 EXIF 방향 태그 2바이트만, MP4는 회전 행렬만 바꾼다. 둘 다 화질 손실이 0이고
|
||||
//! 몇 밀리초에 끝난다 — 4000×3000 사진을 다시 인코딩하면 매번 조금씩 뭉개지는데,
|
||||
//! 사진을 두 번 돌려 제자리로 돌아오면 화질만 두 번 잃는 꼴이 된다.
|
||||
//!
|
||||
//! **② 쓰기는 임시 파일에 하고 마지막에 바꿔치기한다.**
|
||||
//! 원본 위에 바로 쓰다가 중간에 죽으면 반쯤 쓰인 파일이 남는다 — 되돌릴 방법이 없다.
|
||||
//!
|
||||
//! EXIF 방향 값(1~8)은 8원소 군(群)을 이룬다. 회전·반전은 그 위의 연산이므로
|
||||
//! 전이표로 적는다. 표가 맞는지는 군의 성질(90°를 네 번 = 제자리)로 확인한다.
|
||||
|
||||
use crate::ffmpeg::{self, FfTools};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum EditOp {
|
||||
/// 시계 방향 90°
|
||||
RotateCw,
|
||||
/// 반시계 방향 90°
|
||||
RotateCcw,
|
||||
Rotate180,
|
||||
/// 좌우 반전 (거울)
|
||||
FlipH,
|
||||
/// 상하 반전
|
||||
FlipV,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum EditError {
|
||||
Io(std::io::Error),
|
||||
Ff(ffmpeg::FfError),
|
||||
/// 이 형식은 방향 정보를 담을 수 없다 — 다시 인코딩해야 한다
|
||||
NeedsReencode,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EditError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
EditError::Io(e) => write!(f, "입출력: {e}"),
|
||||
EditError::Ff(e) => write!(f, "ffmpeg: {e:?}"),
|
||||
EditError::NeedsReencode => write!(f, "이 형식은 회전 정보를 저장할 수 없습니다"),
|
||||
EditError::Failed(s) => write!(f, "{s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for EditError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
EditError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── EXIF 방향 (1~8) 위의 연산 ────────────────────────────
|
||||
//
|
||||
// 1=정상 2=좌우반전 3=180° 4=상하반전
|
||||
// 5=전치 6=시계90° 7=역전치 8=반시계90°
|
||||
//
|
||||
// 첨자는 방향값-1. 표를 손으로 적었으므로 아래 테스트가 군의 성질로 검산한다.
|
||||
|
||||
const CW: [u8; 8] = [6, 7, 8, 5, 2, 3, 4, 1];
|
||||
const FLIP_H: [u8; 8] = [2, 1, 4, 3, 6, 5, 8, 7];
|
||||
|
||||
/// 방향값에 연산을 적용한 새 방향값. 범위를 벗어난 입력은 1(정상)로 본다.
|
||||
pub fn apply_orientation(current: u8, op: EditOp) -> u8 {
|
||||
let cur = if (1..=8).contains(¤t) { current } else { 1 };
|
||||
let i = (cur - 1) as usize;
|
||||
match op {
|
||||
EditOp::RotateCw => CW[i],
|
||||
EditOp::RotateCcw => CW[(CW[(CW[i] - 1) as usize] - 1) as usize], // 시계 3번 = 반시계 1번
|
||||
EditOp::Rotate180 => CW[(CW[i] - 1) as usize],
|
||||
EditOp::FlipH => FLIP_H[i],
|
||||
// 상하반전 = 좌우반전 + 180°
|
||||
EditOp::FlipV => {
|
||||
let h = FLIP_H[i];
|
||||
CW[(CW[(h - 1) as usize] - 1) as usize]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 이 연산이 가로·세로를 맞바꾸는가 — DB의 width/height를 함께 고쳐야 한다.
|
||||
pub fn swaps_dimensions(op: EditOp) -> bool {
|
||||
matches!(op, EditOp::RotateCw | EditOp::RotateCcw)
|
||||
}
|
||||
|
||||
// ── JPEG: EXIF 방향 태그만 고친다 ────────────────────────
|
||||
|
||||
/// JPEG 바이트에서 EXIF 방향 태그를 찾아 값을 바꾼다. 성공하면 true.
|
||||
///
|
||||
/// APP1(0xFFE1) 안의 `Exif\0\0` 다음이 TIFF 헤더다. 거기서 IFD0을 훑어
|
||||
/// 태그 0x0112(Orientation)의 값 2바이트만 제자리에서 고친다.
|
||||
/// **다른 바이트는 건드리지 않는다** — 압축된 화상 데이터는 그대로 남는다.
|
||||
pub fn patch_jpeg_orientation(buf: &mut [u8], new_value: u8) -> bool {
|
||||
let Some(tiff) = find_exif_tiff(buf) else { return false };
|
||||
let Some(off) = find_orientation_offset(&buf[tiff..]) else { return false };
|
||||
let at = tiff + off;
|
||||
let le = buf[tiff] == b'I';
|
||||
// SHORT 값은 4바이트 칸의 **앞쪽 2바이트**에 들어간다(리틀엔디언이든 빅엔디언이든
|
||||
// 값 칸의 시작이 기준이다). 나머지 2바이트는 0으로 남겨둔다.
|
||||
if le {
|
||||
buf[at] = new_value;
|
||||
buf[at + 1] = 0;
|
||||
} else {
|
||||
buf[at] = 0;
|
||||
buf[at + 1] = new_value;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// APP1 Exif 세그먼트 안 TIFF 헤더의 시작 위치.
|
||||
fn find_exif_tiff(buf: &[u8]) -> Option<usize> {
|
||||
if buf.len() < 4 || buf[0] != 0xFF || buf[1] != 0xD8 {
|
||||
return None; // SOI가 아니면 JPEG이 아니다
|
||||
}
|
||||
let mut i = 2;
|
||||
while i + 4 <= buf.len() {
|
||||
if buf[i] != 0xFF {
|
||||
return None; // 마커 정렬이 깨졌다 — 더 뒤지지 않는다
|
||||
}
|
||||
let marker = buf[i + 1];
|
||||
// SOS(0xDA) 이후는 압축 데이터라 세그먼트 구조가 없다
|
||||
if marker == 0xDA || marker == 0xD9 {
|
||||
return None;
|
||||
}
|
||||
let len = u16::from_be_bytes([buf[i + 2], buf[i + 3]]) as usize;
|
||||
if len < 2 || i + 2 + len > buf.len() {
|
||||
return None;
|
||||
}
|
||||
if marker == 0xE1 && buf[i + 4..].starts_with(b"Exif\0\0") {
|
||||
return Some(i + 10);
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// TIFF 블록 시작을 기준으로, 방향 태그 **값 칸**의 상대 위치.
|
||||
fn find_orientation_offset(t: &[u8]) -> Option<usize> {
|
||||
if t.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let le = match &t[0..2] {
|
||||
b"II" => true,
|
||||
b"MM" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let u16at = |p: usize| -> u16 {
|
||||
let b = [t[p], t[p + 1]];
|
||||
if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) }
|
||||
};
|
||||
let u32at = |p: usize| -> u32 {
|
||||
let b = [t[p], t[p + 1], t[p + 2], t[p + 3]];
|
||||
if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) }
|
||||
};
|
||||
if u16at(2) != 42 {
|
||||
return None;
|
||||
}
|
||||
let ifd0 = u32at(4) as usize;
|
||||
if ifd0 + 2 > t.len() {
|
||||
return None;
|
||||
}
|
||||
let count = u16at(ifd0) as usize;
|
||||
for n in 0..count {
|
||||
let e = ifd0 + 2 + n * 12;
|
||||
if e + 12 > t.len() {
|
||||
return None;
|
||||
}
|
||||
if u16at(e) == 0x0112 {
|
||||
// 값 칸은 항목 시작 + 8. SHORT 1개는 칸 안에 직접 들어간다.
|
||||
return Some(e + 8);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// EXIF가 없는 JPEG에 붙일 최소 APP1 세그먼트. 방향 태그 하나만 담는다.
|
||||
///
|
||||
/// 카메라 사진에는 거의 항상 EXIF가 있지만, 스크린샷·편집본에는 없다.
|
||||
/// 없다고 다시 인코딩하면 그 파일만 화질을 잃으므로 껍데기를 만들어 붙인다.
|
||||
fn minimal_exif_app1(orientation: u8) -> Vec<u8> {
|
||||
// TIFF(리틀엔디언) + IFD0 항목 1개 + 다음 IFD 없음
|
||||
let mut tiff: Vec<u8> = Vec::new();
|
||||
tiff.extend_from_slice(b"II");
|
||||
tiff.extend_from_slice(&42u16.to_le_bytes());
|
||||
tiff.extend_from_slice(&8u32.to_le_bytes()); // IFD0 위치
|
||||
tiff.extend_from_slice(&1u16.to_le_bytes()); // 항목 수
|
||||
tiff.extend_from_slice(&0x0112u16.to_le_bytes()); // Orientation
|
||||
tiff.extend_from_slice(&3u16.to_le_bytes()); // SHORT
|
||||
tiff.extend_from_slice(&1u32.to_le_bytes()); // 개수 1
|
||||
tiff.extend_from_slice(&(orientation as u16).to_le_bytes());
|
||||
tiff.extend_from_slice(&[0, 0]); // 값 칸 나머지
|
||||
tiff.extend_from_slice(&0u32.to_le_bytes()); // 다음 IFD 없음
|
||||
|
||||
let body_len = 6 + tiff.len(); // "Exif\0\0" + TIFF
|
||||
let mut seg = vec![0xFF, 0xE1];
|
||||
seg.extend_from_slice(&((body_len + 2) as u16).to_be_bytes());
|
||||
seg.extend_from_slice(b"Exif\0\0");
|
||||
seg.extend_from_slice(&tiff);
|
||||
seg
|
||||
}
|
||||
|
||||
/// JPEG 바이트에 방향값을 적용한 새 바이트를 만든다. 이미 태그가 있으면 제자리 수정,
|
||||
/// 없으면 SOI 바로 뒤에 최소 EXIF를 끼워 넣는다.
|
||||
pub fn jpeg_with_orientation(mut buf: Vec<u8>, value: u8) -> Option<Vec<u8>> {
|
||||
if patch_jpeg_orientation(&mut buf, value) {
|
||||
return Some(buf);
|
||||
}
|
||||
if buf.len() < 2 || buf[0] != 0xFF || buf[1] != 0xD8 {
|
||||
return None;
|
||||
}
|
||||
let seg = minimal_exif_app1(value);
|
||||
let mut out = Vec::with_capacity(buf.len() + seg.len());
|
||||
out.extend_from_slice(&buf[0..2]);
|
||||
out.extend_from_slice(&seg);
|
||||
out.extend_from_slice(&buf[2..]);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
// ── 실제 파일에 적용 ─────────────────────────────────────
|
||||
|
||||
/// 편집 결과. 호출부가 DB(치수·방향)와 썸네일을 맞추는 데 쓴다.
|
||||
pub struct EditResult {
|
||||
/// 새 EXIF 방향값 (이미지만)
|
||||
pub orientation: Option<u8>,
|
||||
/// 새 회전 각도 (영상만, 0/90/180/270)
|
||||
pub rotation: Option<i32>,
|
||||
/// 가로·세로가 바뀌었는가
|
||||
pub swapped: bool,
|
||||
/// 픽셀을 다시 인코딩했는가 — false면 화질 손실 0
|
||||
pub reencoded: bool,
|
||||
/// 자르기처럼 크기가 달라졌으면 새 (가로, 세로). 회전은 None(swapped로 표현된다).
|
||||
pub new_size: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
const JPEG_EXT: [&str; 3] = ["jpg", "jpeg", "jfif"];
|
||||
|
||||
fn ext_of(p: &Path) -> String {
|
||||
p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 편집용 임시 경로 — `clip.mp4` → `clip.tmp-edit.mp4`.
|
||||
///
|
||||
/// **확장자를 뒤에 남긴다.** ffmpeg은 확장자로 출력 형식을 고르기 때문에
|
||||
/// `clip.mp4.tmp-edit`으로 두면 `Unable to choose an output format`으로 죽는다(실측).
|
||||
/// 대신 이름에 표식이 들어가므로 스캐너가 [`crate::model::is_edit_temp`]로 걸러낸다.
|
||||
fn temp_path(p: &Path) -> std::path::PathBuf {
|
||||
let ext = ext_of(p);
|
||||
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "edit".into());
|
||||
let mark = crate::model::EDIT_TMP_MARK;
|
||||
let name = if ext.is_empty() {
|
||||
format!("{stem}{mark}")
|
||||
} else {
|
||||
format!("{stem}{mark}.{ext}")
|
||||
};
|
||||
p.parent().unwrap_or(Path::new(".")).join(name)
|
||||
}
|
||||
|
||||
/// 임시 파일을 원본 자리에 앉힌다. `backup`이 있으면 원본을 **복사하지 않고 옮겨** 보관한다.
|
||||
///
|
||||
/// 자르기는 역연산이 없어서(잘라낸 화소는 되살릴 수 없다) 되돌리려면 원본이 필요하다.
|
||||
/// 그런데 30GB 영상을 복사해 두면 디스크가 남아나지 않는다 — 같은 볼륨 안에서는
|
||||
/// `rename`이 즉시 끝나므로 옮겨서 보관한다.
|
||||
///
|
||||
/// 중간에 실패하면 원본을 제자리로 되돌린다. 이 함수를 벗어날 때 원본은 언제나
|
||||
/// 제자리에 있거나 백업 자리에 있다 — 사라지는 경우는 없다.
|
||||
fn place(tmp: &Path, dst: &Path, backup: Option<&Path>) -> std::io::Result<()> {
|
||||
let Some(bk) = backup else {
|
||||
return std::fs::rename(tmp, dst).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
});
|
||||
};
|
||||
if let Some(parent) = bk.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::rename(dst, bk).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
})?;
|
||||
if let Err(e) = std::fs::rename(tmp, dst) {
|
||||
// 새 파일을 못 앉혔다 — 원본을 제자리로 돌려놓고 실패로 알린다
|
||||
let _ = std::fs::rename(bk, dst);
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 같은 폴더에 임시로 쓰고 원본 자리에 바꿔치기한다.
|
||||
///
|
||||
/// 같은 폴더를 쓰는 이유는 `rename`이 같은 볼륨 안에서만 원자적이기 때문이다.
|
||||
/// 임시 디렉터리(다른 드라이브일 수 있다)에 썼다가 옮기면 복사가 되어 중간 상태가 생긴다.
|
||||
fn write_replacing(dst: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let tmp = temp_path(dst);
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
match std::fs::rename(&tmp, dst) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 이미지 한 장을 회전·반전한다. JPEG이면 EXIF만 고쳐 화질 손실이 없다.
|
||||
pub fn rotate_image(path: &Path, current_orientation: u8, op: EditOp) -> Result<EditResult, EditError> {
|
||||
let next = apply_orientation(current_orientation, op);
|
||||
if JPEG_EXT.contains(&ext_of(path).as_str()) {
|
||||
let buf = std::fs::read(path)?;
|
||||
let out = jpeg_with_orientation(buf, next).ok_or(EditError::NeedsReencode)?;
|
||||
write_replacing(path, &out)?;
|
||||
return Ok(EditResult {
|
||||
orientation: Some(next),
|
||||
rotation: None,
|
||||
swapped: swaps_dimensions(op),
|
||||
reencoded: false,
|
||||
new_size: None,
|
||||
});
|
||||
}
|
||||
// JPEG이 아니면 픽셀을 실제로 돌린다. png·bmp는 무손실이라 결과가 같다.
|
||||
rotate_image_pixels(path, op)
|
||||
}
|
||||
|
||||
/// 픽셀을 실제로 돌려 다시 쓴다 (EXIF를 못 쓰는 형식용).
|
||||
fn rotate_image_pixels(path: &Path, op: EditOp) -> Result<EditResult, EditError> {
|
||||
let img = image::open(path).map_err(|e| EditError::Failed(format!("이미지를 열 수 없습니다: {e}")))?;
|
||||
let out = match op {
|
||||
EditOp::RotateCw => img.rotate90(),
|
||||
EditOp::RotateCcw => img.rotate270(),
|
||||
EditOp::Rotate180 => img.rotate180(),
|
||||
EditOp::FlipH => img.fliph(),
|
||||
EditOp::FlipV => img.flipv(),
|
||||
};
|
||||
// 같은 형식으로 다시 쓴다 — 확장자가 바뀌면 DB의 이름과 어긋난다
|
||||
let tmp = temp_path(path);
|
||||
out.save(&tmp).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EditError::Failed(format!("저장 실패: {e}"))
|
||||
})?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(EditResult {
|
||||
orientation: Some(1),
|
||||
rotation: None,
|
||||
swapped: swaps_dimensions(op),
|
||||
reencoded: true,
|
||||
new_size: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 반전에 쓸 H.264 인코더를 고른다 — 하드웨어 우선, 마지막은 항상 있는 소프트웨어.
|
||||
///
|
||||
/// **번들 ffmpeg에 x264는 없다**(GPL이라 빠졌다). 그리고 `-encoders` 목록에 있다고
|
||||
/// 되는 것도 아니다 — nvenc는 NVIDIA 드라이버가, qsv는 인텔 GPU가 있어야 한다.
|
||||
/// 그래서 후보마다 실제로 몇 프레임을 인코딩해 보고 고른다. 한 번만 찾고 기억한다.
|
||||
///
|
||||
/// 회전은 이 함수를 쓰지 않는다(스트림 복사라 인코더가 필요 없다).
|
||||
pub fn h264_encoder(tools: &FfTools) -> &'static str {
|
||||
static PICK: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
|
||||
PICK.get_or_init(|| {
|
||||
for enc in ["h264_nvenc", "h264_qsv", "h264_amf", "h264_mf", "libopenh264"] {
|
||||
let ok = ffmpeg::command(&tools.ffmpeg)
|
||||
.args(["-v", "error", "-nostdin", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=10:d=0.4"])
|
||||
.args(["-c:v", enc, "-b:v", "1M", "-pix_fmt", "yuv420p", "-f", "null", "-"])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
tracing::info!("편집용 영상 인코더: {enc}");
|
||||
return enc;
|
||||
}
|
||||
}
|
||||
"libopenh264"
|
||||
})
|
||||
}
|
||||
|
||||
/// 회전 각도에 연산을 적용 (영상). 결과는 항상 0/90/180/270.
|
||||
pub fn apply_rotation(current: i32, op: EditOp) -> Option<i32> {
|
||||
let d = match op {
|
||||
EditOp::RotateCw => 90,
|
||||
EditOp::RotateCcw => -90,
|
||||
EditOp::Rotate180 => 180,
|
||||
// 반전은 회전 행렬로 표현할 수 없다 — 호출부가 다시 인코딩해야 한다
|
||||
EditOp::FlipH | EditOp::FlipV => return None,
|
||||
};
|
||||
Some((current + d).rem_euclid(360))
|
||||
}
|
||||
|
||||
/// 영상의 현재 회전 각도를 **시계 방향 기준**으로 읽는다. 없으면 0.
|
||||
///
|
||||
/// ffprobe가 내주는 `rotation`은 반시계 기준이라 부호를 뒤집는다 —
|
||||
/// 쓰는 쪽([`rotate_video`])과 같은 규약을 써야 두 번 돌렸을 때 제대로 누적된다.
|
||||
pub fn video_rotation(tools: &FfTools, path: &Path) -> i32 {
|
||||
let out = ffmpeg::command(&tools.ffprobe)
|
||||
.args(["-v", "quiet", "-select_streams", "v:0", "-show_entries", "stream_side_data=rotation"])
|
||||
.args(["-of", "default=nw=1:nk=1"])
|
||||
.arg(path)
|
||||
.output();
|
||||
out.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.and_then(|s| s.trim().lines().next()?.trim().parse::<f64>().ok())
|
||||
.map(|v| (-(v.round() as i32)).rem_euclid(360))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 영상을 회전한다. 회전은 메타데이터만 바꿔 무손실·즉시,
|
||||
/// 반전은 회전 행렬로 표현할 수 없어 다시 인코딩한다.
|
||||
/// 재인코딩할 때 쓸 비트레이트(kbps). 화소당 0.1비트 · 30fps 기준.
|
||||
///
|
||||
/// **호출부에서 받지 않는다.** 예전에는 인자로 받았는데 회전 경로가 이 값을 안 쓴다는
|
||||
/// 이유로 호출부가 `0`을 넘겼고, 반전(재인코딩)에서 `-b:v 0k`가 되어 **디코드 불가능한
|
||||
/// 파일을 만들었다**. 컨테이너는 멀쩡해서 ffprobe로는 정상으로 보이고 재생만 안 됐다.
|
||||
/// 필요한 곳에서 직접 계산하면 그런 실수가 성립하지 않는다.
|
||||
pub fn reencode_kbps(w: u32, h: u32) -> u32 {
|
||||
let bits = (w.max(2) as f64) * (h.max(2) as f64) * 30.0 * 0.10;
|
||||
((bits / 1000.0).round() as i64).clamp(500, 80_000) as u32
|
||||
}
|
||||
|
||||
pub fn rotate_video(
|
||||
tools: &FfTools,
|
||||
path: &Path,
|
||||
op: EditOp,
|
||||
encoder: &str,
|
||||
) -> Result<EditResult, EditError> {
|
||||
let tmp = temp_path(path);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
|
||||
let mut cmd = ffmpeg::command(&tools.ffmpeg);
|
||||
cmd.args(["-v", "error", "-nostdin", "-y"]);
|
||||
|
||||
let result = match apply_rotation(video_rotation(tools, path), op) {
|
||||
Some(deg) => {
|
||||
// -display_rotation은 **절대값**이다(누적이 아니다 — 실측으로 확인).
|
||||
// 그래서 현재 각도에 더한 값을 통째로 준다.
|
||||
//
|
||||
// 부호는 뒤집는다. 컨테이너의 회전 행렬은 반시계 기준이라 그대로 넣으면
|
||||
// 오른쪽 회전을 눌렀는데 왼쪽으로 돈다 — 화소를 비교하는 테스트가 이걸 잡았다.
|
||||
// 이 모듈은 안팎 모두 **사용자가 보는 시계 방향 각도**로 통일한다.
|
||||
cmd.args(["-display_rotation", &(-deg).to_string()]);
|
||||
cmd.arg("-i").arg(path).args(["-c", "copy"]);
|
||||
EditResult { orientation: None, rotation: Some(deg), swapped: swaps_dimensions(op), reencoded: false, new_size: None }
|
||||
}
|
||||
None => {
|
||||
let vf = match op {
|
||||
EditOp::FlipH => "hflip",
|
||||
_ => "vflip",
|
||||
};
|
||||
// 원본 크기에서 비트레이트를 뽑는다. 못 읽으면 640×480으로 보수적으로 잡는다 —
|
||||
// 0을 넘기면 인코더가 깨진 스트림을 뱉는다(실측으로 파일 하나를 잃었다).
|
||||
let info = ffmpeg::probe(tools, path).unwrap_or_default();
|
||||
let kbps = reencode_kbps(info.width.unwrap_or(640), info.height.unwrap_or(480));
|
||||
cmd.arg("-i").arg(path).args(["-vf", vf]);
|
||||
cmd.args(["-c:v", encoder, "-pix_fmt", "yuv420p", "-b:v", &format!("{kbps}k")]);
|
||||
cmd.args(["-map", "0:v:0", "-map", "0:a?", "-c:a", "copy"]);
|
||||
EditResult { orientation: None, rotation: None, swapped: false, reencoded: true, new_size: None }
|
||||
}
|
||||
};
|
||||
if ext_of(path) == "mp4" || ext_of(path) == "mov" || ext_of(path) == "m4v" {
|
||||
cmd.args(["-movflags", "+faststart"]);
|
||||
}
|
||||
|
||||
let out = cmd.arg(&tmp).output()?;
|
||||
if !out.status.success() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(EditError::Failed(String::from_utf8_lossy(&out.stderr).trim().to_string()));
|
||||
}
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ALL: [EditOp; 5] = [
|
||||
EditOp::RotateCw,
|
||||
EditOp::RotateCcw,
|
||||
EditOp::Rotate180,
|
||||
EditOp::FlipH,
|
||||
EditOp::FlipV,
|
||||
];
|
||||
|
||||
/// 시계 90°를 네 번 하면 제자리 — 여덟 방향값 전부에 대해.
|
||||
/// 전이표를 손으로 적었으므로 이 성질로 검산한다.
|
||||
#[test]
|
||||
fn four_quarter_turns_return_to_start() {
|
||||
for o in 1u8..=8 {
|
||||
let mut v = o;
|
||||
for _ in 0..4 {
|
||||
v = apply_orientation(v, EditOp::RotateCw);
|
||||
}
|
||||
assert_eq!(v, o, "방향 {o}에서 90°×4가 제자리가 아니다");
|
||||
}
|
||||
}
|
||||
|
||||
/// 시계와 반시계는 서로를 되돌린다.
|
||||
#[test]
|
||||
fn cw_and_ccw_are_inverse() {
|
||||
for o in 1u8..=8 {
|
||||
assert_eq!(apply_orientation(apply_orientation(o, EditOp::RotateCw), EditOp::RotateCcw), o);
|
||||
assert_eq!(apply_orientation(apply_orientation(o, EditOp::RotateCcw), EditOp::RotateCw), o);
|
||||
}
|
||||
}
|
||||
|
||||
/// 반전과 180°는 자기 자신이 역연산이다.
|
||||
#[test]
|
||||
fn flips_and_half_turn_are_self_inverse() {
|
||||
for op in [EditOp::FlipH, EditOp::FlipV, EditOp::Rotate180] {
|
||||
for o in 1u8..=8 {
|
||||
assert_eq!(apply_orientation(apply_orientation(o, op), op), o, "{op:?} 두 번이 제자리가 아니다");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 180° = 시계 90° 두 번.
|
||||
#[test]
|
||||
fn half_turn_equals_two_quarter_turns() {
|
||||
for o in 1u8..=8 {
|
||||
let two = apply_orientation(apply_orientation(o, EditOp::RotateCw), EditOp::RotateCw);
|
||||
assert_eq!(apply_orientation(o, EditOp::Rotate180), two);
|
||||
}
|
||||
}
|
||||
|
||||
/// 상하반전 = 좌우반전 + 180°.
|
||||
#[test]
|
||||
fn vflip_equals_hflip_plus_half_turn() {
|
||||
for o in 1u8..=8 {
|
||||
let composed = apply_orientation(apply_orientation(o, EditOp::FlipH), EditOp::Rotate180);
|
||||
assert_eq!(apply_orientation(o, EditOp::FlipV), composed);
|
||||
}
|
||||
}
|
||||
|
||||
/// 어떤 연산도 1~8 밖으로 나가지 않는다.
|
||||
#[test]
|
||||
fn orientation_stays_in_range() {
|
||||
for o in 0u8..=10 {
|
||||
for op in ALL {
|
||||
let v = apply_orientation(o, op);
|
||||
assert!((1..=8).contains(&v), "방향 {o} + {op:?} → {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// EXIF가 있는 JPEG은 **그 2바이트만** 바뀐다 — 화상 데이터는 그대로다.
|
||||
#[test]
|
||||
fn patching_exif_changes_only_the_tag() {
|
||||
let orig = jpeg_with_exif(1);
|
||||
let mut patched = orig.clone();
|
||||
assert!(patch_jpeg_orientation(&mut patched, 6));
|
||||
assert_eq!(orig.len(), patched.len(), "길이가 바뀌면 안 된다");
|
||||
let diff: Vec<usize> = orig.iter().zip(&patched).enumerate()
|
||||
.filter(|(_, (a, b))| a != b).map(|(i, _)| i).collect();
|
||||
assert_eq!(diff.len(), 1, "바뀐 바이트가 {}개다: {diff:?}", diff.len());
|
||||
// 다시 읽어보면 6이어야 한다
|
||||
assert_eq!(read_orientation(&patched), Some(6));
|
||||
}
|
||||
|
||||
/// EXIF가 없는 JPEG에는 최소 EXIF를 끼워 넣는다 — 화상 데이터는 뒤로 밀릴 뿐 그대로다.
|
||||
#[test]
|
||||
fn jpeg_without_exif_gets_one_inserted() {
|
||||
let orig = jpeg_no_exif();
|
||||
let out = jpeg_with_orientation(orig.clone(), 8).expect("삽입 실패");
|
||||
assert!(out.len() > orig.len(), "세그먼트가 안 들어갔다");
|
||||
assert_eq!(read_orientation(&out), Some(8));
|
||||
// 원본 바이트가 순서 그대로 뒤에 남아 있어야 한다
|
||||
assert_eq!(&out[out.len() - (orig.len() - 2)..], &orig[2..]);
|
||||
}
|
||||
|
||||
/// JPEG이 아니면 건드리지 않는다.
|
||||
#[test]
|
||||
fn non_jpeg_is_refused() {
|
||||
let mut png = vec![0x89, b'P', b'N', b'G', 13, 10, 26, 10, 0, 0, 0, 0];
|
||||
assert!(!patch_jpeg_orientation(&mut png, 6));
|
||||
assert!(jpeg_with_orientation(png, 6).is_none());
|
||||
}
|
||||
|
||||
/// 잘린 파일에도 죽지 않는다 — 어떤 길이로 잘라도 패닉이 없어야 한다.
|
||||
#[test]
|
||||
fn truncated_input_does_not_panic() {
|
||||
let full = jpeg_with_exif(3);
|
||||
for n in 0..full.len() {
|
||||
let mut cut = full[..n].to_vec();
|
||||
let _ = patch_jpeg_orientation(&mut cut, 6);
|
||||
}
|
||||
}
|
||||
|
||||
/// 영상 회전은 0/90/180/270 안에 머문다.
|
||||
#[test]
|
||||
fn video_rotation_wraps() {
|
||||
assert_eq!(apply_rotation(270, EditOp::RotateCw), Some(0));
|
||||
assert_eq!(apply_rotation(0, EditOp::RotateCcw), Some(270));
|
||||
assert_eq!(apply_rotation(90, EditOp::Rotate180), Some(270));
|
||||
// 반전은 회전으로 표현할 수 없다
|
||||
assert_eq!(apply_rotation(0, EditOp::FlipH), None);
|
||||
}
|
||||
|
||||
/// 임시 파일은 **확장자를 유지하고**(ffmpeg이 형식을 정할 수 있게)
|
||||
/// **스캐너가 걸러낼 수 있어야** 한다. 둘 중 하나만 지키면 각각 다른 방식으로 깨진다.
|
||||
#[test]
|
||||
fn temp_path_keeps_extension_and_is_skipped() {
|
||||
for p in [r"D:\a\clip.mp4", r"D:\a\photo.JPG", "/x/y/movie.mkv"] {
|
||||
let t = temp_path(Path::new(p));
|
||||
assert_eq!(
|
||||
t.extension().map(|e| e.to_string_lossy().to_lowercase()),
|
||||
Path::new(p).extension().map(|e| e.to_string_lossy().to_lowercase()),
|
||||
"{p} → {t:?} 확장자가 바뀌었다"
|
||||
);
|
||||
assert!(crate::model::is_edit_temp(&t), "{t:?} 를 스캐너가 못 거른다");
|
||||
assert_eq!(t.parent(), Path::new(p).parent(), "같은 폴더여야 rename이 원자적이다");
|
||||
}
|
||||
// 확장자가 없는 파일도 죽지 않는다
|
||||
let t = temp_path(Path::new(r"D:\a\noext"));
|
||||
assert!(crate::model::is_edit_temp(&t), "{t:?}");
|
||||
}
|
||||
|
||||
// ── 테스트용 최소 JPEG ──
|
||||
|
||||
fn read_orientation(buf: &[u8]) -> Option<u8> {
|
||||
let t = find_exif_tiff(buf)?;
|
||||
let off = find_orientation_offset(&buf[t..])?;
|
||||
let at = t + off;
|
||||
Some(if buf[t] == b'I' { buf[at] } else { buf[at + 1] })
|
||||
}
|
||||
|
||||
/// SOI + APP1(Exif, 방향 태그) + 가짜 화상 데이터 + EOI
|
||||
fn jpeg_with_exif(orientation: u8) -> Vec<u8> {
|
||||
let mut v = vec![0xFF, 0xD8];
|
||||
v.extend_from_slice(&minimal_exif_app1(orientation));
|
||||
v.extend_from_slice(&[0xFF, 0xDA, 0x00, 0x02]); // SOS
|
||||
v.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
v.extend_from_slice(&[0xFF, 0xD9]);
|
||||
v
|
||||
}
|
||||
|
||||
fn jpeg_no_exif() -> Vec<u8> {
|
||||
let mut v = vec![0xFF, 0xD8];
|
||||
// APP0 (JFIF) — Exif가 아닌 세그먼트도 건너뛰는지 확인하는 역할도 한다
|
||||
v.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00]);
|
||||
v.extend_from_slice(&[0xFF, 0xDA, 0x00, 0x02]);
|
||||
v.extend_from_slice(&[9, 9, 9, 9]);
|
||||
v.extend_from_slice(&[0xFF, 0xD9]);
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
// ── 자르기 · 구간 자르기 ─────────────────────────────────
|
||||
|
||||
/// 잘라낼 영역 — **화면에 보이는 좌표** 기준이다.
|
||||
///
|
||||
/// 사용자는 화면에서 끈다. 그런데 EXIF 방향이나 영상 회전 메타가 붙어 있으면
|
||||
/// 저장된 화소 배치와 화면 배치가 다르다. 두 좌표계를 섞으면 엉뚱한 데가 잘리므로
|
||||
/// **이 구조체는 언제나 화면 기준**이고, 저장 좌표로 옮기는 일은 각 함수가 맡는다.
|
||||
///
|
||||
/// ffmpeg의 `crop` 필터도 화면 좌표를 쓴다(실측으로 확인 — 회전 메타가 붙은
|
||||
/// 640×480 영상에서 400×600 자르기가 성공하고 600×400은 실패했다).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CropRect {
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
}
|
||||
|
||||
impl CropRect {
|
||||
/// 이미지 밖으로 나가지 않게 다듬는다. 폭·높이는 짝수로 — 홀수는 yuv420p가 거부한다.
|
||||
pub fn clamp_to(self, iw: u32, ih: u32) -> Option<CropRect> {
|
||||
let x = self.x.min(iw.saturating_sub(2));
|
||||
let y = self.y.min(ih.saturating_sub(2));
|
||||
let w = self.w.min(iw - x) & !1;
|
||||
let h = self.h.min(ih - y) & !1;
|
||||
(w >= 2 && h >= 2).then_some(CropRect { x, y, w, h })
|
||||
}
|
||||
/// 원본 전체와 같은가 — 그러면 자를 이유가 없다(재인코딩만 하고 화질을 잃는다).
|
||||
pub fn is_whole(&self, iw: u32, ih: u32) -> bool {
|
||||
self.x == 0 && self.y == 0 && self.w >= iw && self.h >= ih
|
||||
}
|
||||
}
|
||||
|
||||
/// EXIF 방향을 화소에 실제로 적용한다 — 화면에 보이던 그대로의 이미지를 만든다.
|
||||
///
|
||||
/// 자르기는 어차피 다시 인코딩하므로 여기서 방향을 구워 넣고 태그를 1로 되돌린다.
|
||||
/// 그래야 잘린 좌표와 방향 태그가 서로 어긋나는 상태가 생기지 않는다.
|
||||
fn bake_orientation(img: image::DynamicImage, o: u8) -> image::DynamicImage {
|
||||
match o {
|
||||
2 => img.fliph(),
|
||||
3 => img.rotate180(),
|
||||
4 => img.flipv(),
|
||||
5 => img.rotate90().fliph(),
|
||||
6 => img.rotate90(),
|
||||
7 => img.rotate90().flipv(),
|
||||
8 => img.rotate270(),
|
||||
_ => img,
|
||||
}
|
||||
}
|
||||
|
||||
/// 사진을 자른다. **다시 인코딩한다** — JPEG 무손실 자르기는 8·16화소 격자에만
|
||||
/// 맞출 수 있어 사용자가 끈 자리와 어긋난다. 어긋난 무손실보다 정확한 재인코딩이 낫다.
|
||||
pub fn crop_image(
|
||||
path: &Path,
|
||||
rect: CropRect,
|
||||
orientation: u8,
|
||||
backup: Option<&Path>,
|
||||
) -> Result<EditResult, EditError> {
|
||||
let img = image::open(path).map_err(|e| EditError::Failed(format!("이미지를 열 수 없습니다: {e}")))?;
|
||||
let img = bake_orientation(img, orientation);
|
||||
let (iw, ih) = (img.width(), img.height());
|
||||
let r = rect.clamp_to(iw, ih).ok_or_else(|| EditError::Failed("자를 영역이 너무 작습니다".into()))?;
|
||||
if r.is_whole(iw, ih) && orientation == 1 {
|
||||
return Err(EditError::Failed("전체를 고른 상태라 자를 것이 없습니다".into()));
|
||||
}
|
||||
let out = img.crop_imm(r.x, r.y, r.w, r.h);
|
||||
|
||||
let tmp = temp_path(path);
|
||||
let ext = ext_of(path);
|
||||
if JPEG_EXT.contains(&ext.as_str()) {
|
||||
// 품질 92 — 눈에 보이는 손실 없이 파일이 지나치게 커지지 않는 지점
|
||||
let mut f = std::io::BufWriter::new(std::fs::File::create(&tmp)?);
|
||||
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut f, 92);
|
||||
out.write_with_encoder(enc).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EditError::Failed(format!("저장 실패: {e}"))
|
||||
})?;
|
||||
} else {
|
||||
out.save(&tmp).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EditError::Failed(format!("저장 실패: {e}"))
|
||||
})?;
|
||||
}
|
||||
place(&tmp, path, backup)?;
|
||||
Ok(EditResult {
|
||||
// 방향을 화소에 구워 넣었으므로 태그는 정상으로 되돌린다
|
||||
orientation: Some(1),
|
||||
rotation: None,
|
||||
swapped: false,
|
||||
reencoded: true,
|
||||
new_size: Some((r.w, r.h)),
|
||||
})
|
||||
}
|
||||
|
||||
/// 영상의 일부 영역만 남긴다. 회전 메타는 화소에 구워 넣고 0으로 만든다 —
|
||||
/// 안 그러면 재생기가 이미 돌아간 화면을 한 번 더 돌린다.
|
||||
pub fn crop_video(
|
||||
tools: &FfTools,
|
||||
path: &Path,
|
||||
rect: CropRect,
|
||||
encoder: &str,
|
||||
kbps: u32,
|
||||
backup: Option<&Path>,
|
||||
) -> Result<EditResult, EditError> {
|
||||
let tmp = temp_path(path);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let CropRect { x, y, w, h } = rect;
|
||||
|
||||
let mut cmd = ffmpeg::command(&tools.ffmpeg);
|
||||
cmd.args(["-v", "error", "-nostdin", "-y"]).arg("-i").arg(path);
|
||||
// crop은 자동 회전이 적용된 뒤의 화면에 걸린다(실측). 그래서 화면 좌표를 그대로 준다.
|
||||
cmd.args(["-vf", &format!("crop={w}:{h}:{x}:{y}")]);
|
||||
cmd.args(["-c:v", encoder, "-pix_fmt", "yuv420p", "-b:v", &format!("{kbps}k")]);
|
||||
cmd.args(["-map", "0:v:0", "-map", "0:a?", "-c:a", "copy"]);
|
||||
if matches!(ext_of(path).as_str(), "mp4" | "mov" | "m4v") {
|
||||
cmd.args(["-movflags", "+faststart"]);
|
||||
}
|
||||
let out = cmd.arg(&tmp).output()?;
|
||||
if !out.status.success() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(EditError::Failed(String::from_utf8_lossy(&out.stderr).trim().to_string()));
|
||||
}
|
||||
place(&tmp, path, backup)?;
|
||||
Ok(EditResult {
|
||||
orientation: None,
|
||||
rotation: Some(0),
|
||||
swapped: false,
|
||||
reencoded: true,
|
||||
new_size: Some((w, h)),
|
||||
})
|
||||
}
|
||||
|
||||
/// 영상에서 한 구간만 남긴다. **스트림 복사라 무손실이고 빠르다.**
|
||||
///
|
||||
/// 자를 지점은 키프레임에 맞춰지므로 몇십 밀리초 어긋날 수 있다. mp4는 편집 목록(edit list)
|
||||
/// 덕에 대체로 정확하지만(키프레임 10초 간격에서도 요청한 2.000초가 나왔다), 컨테이너에
|
||||
/// 따라서는 앞쪽이 조금 더 붙을 수 있다.
|
||||
pub fn trim_video(
|
||||
tools: &FfTools,
|
||||
path: &Path,
|
||||
start_ms: i64,
|
||||
end_ms: i64,
|
||||
backup: Option<&Path>,
|
||||
) -> Result<EditResult, EditError> {
|
||||
if end_ms <= start_ms {
|
||||
return Err(EditError::Failed("끝이 시작보다 뒤여야 합니다".into()));
|
||||
}
|
||||
let tmp = temp_path(path);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
|
||||
let mut cmd = ffmpeg::command(&tools.ffmpeg);
|
||||
cmd.args(["-v", "error", "-nostdin", "-y"]);
|
||||
cmd.args(["-ss", &format!("{:.3}", start_ms.max(0) as f64 / 1000.0)]);
|
||||
cmd.args(["-to", &format!("{:.3}", end_ms as f64 / 1000.0)]);
|
||||
cmd.arg("-i").arg(path);
|
||||
cmd.args(["-map", "0", "-c", "copy"]);
|
||||
if matches!(ext_of(path).as_str(), "mp4" | "mov" | "m4v") {
|
||||
cmd.args(["-movflags", "+faststart"]);
|
||||
}
|
||||
let out = cmd.arg(&tmp).output()?;
|
||||
if !out.status.success() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(EditError::Failed(String::from_utf8_lossy(&out.stderr).trim().to_string()));
|
||||
}
|
||||
place(&tmp, path, backup)?;
|
||||
Ok(EditResult {
|
||||
orientation: None,
|
||||
rotation: None,
|
||||
swapped: false,
|
||||
reencoded: false,
|
||||
new_size: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 필름스트립 (구간 자르기 타임라인) ────────────────────
|
||||
|
||||
/// 타임라인에 깔 프레임 한 장.
|
||||
pub struct StripFrame {
|
||||
pub at_ms: i64,
|
||||
/// JPEG 바이트. 호출부가 데이터 URL로 만들어 프런트에 넘긴다 —
|
||||
/// 파일로 떨구면 지울 책임이 생기고, 서버 라우트를 열면 그 수명을 또 관리해야 한다.
|
||||
pub jpeg: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 영상 전체를 고르게 훑어 `count`장을 뽑는다.
|
||||
///
|
||||
/// **`-ss`를 `-i` 앞에 둔다.** 뒤에 두면 그 지점까지 전부 디코드하므로 30분짜리에서
|
||||
/// 한 장에 수십 초가 걸린다. 앞에 두면 키프레임까지 건너뛰어 한 장에 수십 밀리초다.
|
||||
/// 그 대신 위치가 키프레임 단위로 어긋나는데, 타임라인 배경으로는 문제가 되지 않는다.
|
||||
pub fn filmstrip(
|
||||
tools: &FfTools,
|
||||
src: &Path,
|
||||
duration_ms: i64,
|
||||
count: u32,
|
||||
height: u32,
|
||||
) -> Vec<StripFrame> {
|
||||
let n = count.clamp(1, 40);
|
||||
let dur = duration_ms.max(1) as f64 / 1000.0;
|
||||
let mut out = Vec::with_capacity(n as usize);
|
||||
for i in 0..n {
|
||||
// 칸의 **가운데** 시각을 쓴다. 0초부터 균등하게 나누면 마지막 장이 영상 끝을 넘어
|
||||
// 빈 프레임이 나온다.
|
||||
let at = dur * (i as f64 + 0.5) / n as f64;
|
||||
let o = ffmpeg::command(&tools.ffmpeg)
|
||||
.args(["-v", "error", "-nostdin", "-ss", &format!("{at:.3}")])
|
||||
.arg("-i")
|
||||
.arg(src)
|
||||
.args(["-frames:v", "1", "-vf", &format!("scale=-2:{height}")])
|
||||
.args(["-q:v", "6", "-f", "image2pipe", "-vcodec", "mjpeg", "-"])
|
||||
.output();
|
||||
match o {
|
||||
Ok(o) if o.status.success() && !o.stdout.is_empty() => {
|
||||
out.push(StripFrame { at_ms: (at * 1000.0) as i64, jpeg: o.stdout });
|
||||
}
|
||||
// 한 장 실패는 치명적이지 않다 — 그 칸만 비고 나머지는 그대로 쓴다
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -230,6 +230,260 @@ fn extract_frame_input(
|
||||
.map_err(|e| FfError::Parse(format!("썸네일 치수 읽기 실패: {e}")))
|
||||
}
|
||||
|
||||
// ── 지문용 프레임 시퀀스 (단일 프로세스) ─────────────────
|
||||
//
|
||||
// 예전 방식은 오프셋마다 ffmpeg를 새로 띄워 64px JPEG를 임시 파일로 쓰고 다시 디코드했다
|
||||
// (영상 1개당 프로세스 11개 + 디스크 왕복 20회, 게다가 고정 임시 파일명 때문에 워커 간 경쟁 조건).
|
||||
// 여기서는 **한 프로세스**가 균등 간격 그레이 프레임을 stdout으로 흘려보내고, 파생 지문은
|
||||
// `sigmatch`가 메모리에서 계산한다. 디스크를 쓰지 않으므로 경쟁 조건 자체가 사라진다.
|
||||
|
||||
/// 프레임 추출 결과 한 장 — (표시 시각 ms, size×size 그레이 픽셀)
|
||||
pub type GrayFrame = (u32, Vec<u8>);
|
||||
|
||||
/// 균등 간격으로 그레이 프레임을 뽑는다. 시간(ms) 기준 샘플링이므로 원본 FPS와 무관하다.
|
||||
///
|
||||
/// - `sample_ms`: 샘플 간격 (1000이면 1초에 한 장). 필터는 `fps=1000/sample_ms`로 만든다.
|
||||
/// - `size`: 정사각 출력 한 변 (지문은 32를 쓴다). 비율은 무시하고 늘려 맞춘다 —
|
||||
/// 같은 규칙을 모든 파일에 적용하므로 비교에는 문제가 없고, 레터박스는 `sigmatch`가 따로 다룬다.
|
||||
/// - `timeout`: 이 시간을 넘기면 자식 프로세스를 죽인다. 손상된 파일·죽은 네트워크 경로에서
|
||||
/// 워커가 영구 점유되는 것을 막는다(기존 `.output()` 경로에는 이 보호가 없다).
|
||||
/// - `hwaccel`: `Some("auto")` 등. 실패하면 소프트웨어 디코드로 한 번 더 시도한다.
|
||||
pub fn frame_gray_sequence(
|
||||
tools: &FfTools,
|
||||
src: &Path,
|
||||
sample_ms: u32,
|
||||
size: u32,
|
||||
timeout: std::time::Duration,
|
||||
hwaccel: Option<&str>,
|
||||
) -> Result<Vec<GrayFrame>, FfError> {
|
||||
frame_gray_sequence_with_audio(tools, src, sample_ms, size, timeout, hwaccel, None)
|
||||
.map(|(f, _)| f)
|
||||
}
|
||||
|
||||
/// 프레임과 **오디오 지문을 한 번의 디코드로** 함께 뽑는다.
|
||||
///
|
||||
/// `audio_out`이 `Some`이면 chromaprint를 두 번째 출력으로 붙인다 — 파일을 한 번만 읽으므로
|
||||
/// NAS·외장 디스크에서 두 배의 I/O를 쓰지 않는다. 대신 **오디오 스트림이 없으면 ffmpeg가
|
||||
/// 출력 초기화에서 실패해 프레임까지 통째로 날아간다**(실측: `Output file does not contain
|
||||
/// any stream`, 프레임 0장). 그래서 오디오가 있다고 아는 파일에만 켜야 한다.
|
||||
/// 그래도 혹시 실패하면 오디오 없이 한 번 더 시도한다.
|
||||
pub fn frame_gray_sequence_with_audio(
|
||||
tools: &FfTools,
|
||||
src: &Path,
|
||||
sample_ms: u32,
|
||||
size: u32,
|
||||
timeout: std::time::Duration,
|
||||
hwaccel: Option<&str>,
|
||||
audio_out: Option<&Path>,
|
||||
) -> Result<(Vec<GrayFrame>, Vec<u32>), FfError> {
|
||||
let sample_ms = sample_ms.max(1);
|
||||
let read_fp = |p: Option<&Path>| -> Vec<u32> {
|
||||
let Some(p) = p else { return Vec::new() };
|
||||
let Ok(b) = std::fs::read(p) else { return Vec::new() };
|
||||
let _ = std::fs::remove_file(p);
|
||||
b.chunks_exact(4).map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()
|
||||
};
|
||||
match run_gray_pass(tools, src, sample_ms, size, timeout, hwaccel, audio_out) {
|
||||
Ok(frames) if !frames.is_empty() => Ok((frames, read_fp(audio_out))),
|
||||
// 하드웨어 디코더는 드라이버·코덱 조합에 따라 조용히 0프레임을 내기도 한다 → 소프트웨어로 재시도.
|
||||
// 오디오 출력이 붙어 있었다면 그것도 실패 원인일 수 있으므로 함께 뗀다.
|
||||
other => {
|
||||
let _ = read_fp(audio_out); // 실패한 부분 파일 정리
|
||||
if hwaccel.is_some() || audio_out.is_some() {
|
||||
run_gray_pass(tools, src, sample_ms, size, timeout, None, None)
|
||||
.map(|f| (f, Vec::new()))
|
||||
} else {
|
||||
other.map(|f| (f, Vec::new()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_gray_pass(
|
||||
tools: &FfTools,
|
||||
src: &Path,
|
||||
sample_ms: u32,
|
||||
size: u32,
|
||||
timeout: std::time::Duration,
|
||||
hwaccel: Option<&str>,
|
||||
audio_out: Option<&Path>,
|
||||
) -> Result<Vec<GrayFrame>, FfError> {
|
||||
use std::io::Read;
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let vf = format!("fps=1000/{sample_ms},scale={size}:{size},format=gray");
|
||||
let mut cmd = command(&tools.ffmpeg);
|
||||
cmd.args(["-v", "error", "-nostdin"]);
|
||||
if let Some(hw) = hwaccel {
|
||||
cmd.args(["-hwaccel", hw]);
|
||||
}
|
||||
cmd.arg("-i")
|
||||
.arg(src)
|
||||
// -vsync은 이 빌드에서 deprecated — -fps_mode를 쓴다
|
||||
.args(["-an", "-sn", "-dn", "-fps_mode", "passthrough", "-vf", &vf, "-f", "rawvideo", "-"]);
|
||||
if let Some(a) = audio_out {
|
||||
// 두 번째 출력 — 같은 디코드에서 오디오 지문까지. 프레임은 파이프(대용량),
|
||||
// 지문은 작은 임시 파일(수 KB)로 받는다. stdout은 하나뿐이라 둘 다 파이프로 못 받는다.
|
||||
cmd.args(["-vn", "-sn", "-dn", "-f", "chromaprint", "-fp_format", "raw", "-y"]).arg(a);
|
||||
}
|
||||
cmd.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stdin(Stdio::null());
|
||||
|
||||
let child = Arc::new(Mutex::new(cmd.spawn()?));
|
||||
let mut stdout = child
|
||||
.lock()
|
||||
.expect("ffmpeg child mutex")
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| FfError::Parse("stdout 파이프를 열지 못했습니다".into()))?;
|
||||
|
||||
// 워치독 — 읽기가 끝나면 신호를 보내고, 제한 시간을 넘기면 자식을 죽인다.
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
|
||||
let watchdog = {
|
||||
let child = Arc::clone(&child);
|
||||
std::thread::spawn(move || {
|
||||
if done_rx.recv_timeout(timeout).is_err() {
|
||||
if let Ok(mut c) = child.lock() {
|
||||
let _ = c.kill();
|
||||
}
|
||||
return true; // 타임아웃으로 죽였다
|
||||
}
|
||||
false
|
||||
})
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let read_err = stdout.read_to_end(&mut buf).err();
|
||||
let _ = done_tx.send(());
|
||||
let killed = watchdog.join().unwrap_or(false);
|
||||
|
||||
let (status, stderr) = {
|
||||
let mut guard = child.lock().expect("ffmpeg child mutex");
|
||||
let stderr = guard.stderr.take();
|
||||
let status = guard.wait()?;
|
||||
let mut msg = String::new();
|
||||
if let Some(mut e) = stderr {
|
||||
let _ = e.read_to_string(&mut msg);
|
||||
}
|
||||
(status, msg)
|
||||
};
|
||||
|
||||
if killed {
|
||||
return Err(FfError::Exit(-1, format!("제한 시간({}초) 초과로 중단", timeout.as_secs())));
|
||||
}
|
||||
if let Some(e) = read_err {
|
||||
return Err(FfError::Io(e));
|
||||
}
|
||||
if !status.success() && buf.is_empty() {
|
||||
return Err(FfError::Exit(status.code().unwrap_or(-1), stderr));
|
||||
}
|
||||
|
||||
let stride = (size as usize) * (size as usize);
|
||||
if stride == 0 {
|
||||
return Err(FfError::Parse("size가 0입니다".into()));
|
||||
}
|
||||
let count = buf.len() / stride;
|
||||
let mut out = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let start = i * stride;
|
||||
out.push((
|
||||
(i as u32).saturating_mul(sample_ms),
|
||||
buf[start..start + stride].to_vec(),
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 오디오 지문(chromaprint raw) — u32 시퀀스. 번들 ffmpeg가 `--enable-chromaprint`로
|
||||
/// 빌드돼 있어 별도 라이브러리 없이 얻을 수 있다. 대략 8개/초가 나온다.
|
||||
/// 오디오가 없거나 무음이면 빈 벡터.
|
||||
pub fn audio_fingerprint(
|
||||
tools: &FfTools,
|
||||
src: &Path,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<Vec<u32>, FfError> {
|
||||
use std::io::Read;
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let mut cmd = command(&tools.ffmpeg);
|
||||
cmd.args(["-v", "error", "-nostdin"])
|
||||
.arg("-i")
|
||||
.arg(src)
|
||||
.args(["-vn", "-sn", "-dn", "-f", "chromaprint", "-fp_format", "raw", "-"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.stdin(Stdio::null());
|
||||
|
||||
let child = Arc::new(Mutex::new(cmd.spawn()?));
|
||||
let mut stdout = child
|
||||
.lock()
|
||||
.expect("ffmpeg child mutex")
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| FfError::Parse("stdout 파이프를 열지 못했습니다".into()))?;
|
||||
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
|
||||
let watchdog = {
|
||||
let child = Arc::clone(&child);
|
||||
std::thread::spawn(move || {
|
||||
if done_rx.recv_timeout(timeout).is_err() {
|
||||
if let Ok(mut c) = child.lock() {
|
||||
let _ = c.kill();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
})
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let read_err = stdout.read_to_end(&mut buf).err();
|
||||
let _ = done_tx.send(());
|
||||
let killed = watchdog.join().unwrap_or(false);
|
||||
let status = {
|
||||
let mut guard = child.lock().expect("ffmpeg child mutex");
|
||||
guard.wait()?
|
||||
};
|
||||
|
||||
if killed {
|
||||
return Err(FfError::Exit(-1, "오디오 지문 제한 시간 초과".into()));
|
||||
}
|
||||
if let Some(e) = read_err {
|
||||
return Err(FfError::Io(e));
|
||||
}
|
||||
if !status.success() && buf.is_empty() {
|
||||
// 오디오 트랙이 없는 영상은 흔하다 — 오류가 아니라 빈 지문으로 다룬다
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let n = buf.len() / 4;
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
out.push(u32::from_le_bytes([buf[i * 4], buf[i * 4 + 1], buf[i * 4 + 2], buf[i * 4 + 3]]));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 하드웨어 디코드를 쓸 수 있는지 기동 시 1회 판별한다(합성 입력으로 실제 디코드).
|
||||
/// 실패하면 `None` — 이후 소프트웨어 디코드만 쓴다.
|
||||
pub fn detect_hwaccel(tools: &FfTools) -> Option<&'static str> {
|
||||
// `auto`만 본다 — ffmpeg가 플랫폼별 백엔드(d3d11va/dxva2/videotoolbox…)를 스스로 고른다.
|
||||
// 백엔드를 우리가 나열하면 빌드·드라이버 조합마다 목록을 관리해야 하는데 이득이 없다.
|
||||
const HW: &str = "auto";
|
||||
let out = command(&tools.ffmpeg)
|
||||
.args([
|
||||
"-v", "error", "-nostdin", "-hwaccel", HW, "-f", "lavfi", "-i",
|
||||
"testsrc=d=1:r=10", "-an", "-sn", "-frames:v", "1", "-vf",
|
||||
"scale=32:32,format=gray", "-f", "rawvideo", "-",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
// 성공 판정은 종료 코드만으로는 부족하다 — 프레임 1장(32×32 gray)이 실제로 나와야 한다
|
||||
(out.status.success() && out.stdout.len() == 32 * 32).then_some(HW)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! 파일 작업 계층 — 이동/이름변경/휴지통 + ops_journal 기반 undo.
|
||||
//!
|
||||
//! 원칙: ① 물리적 파일 변경 성공 후에만 DB 갱신 ② 모든 작업은 저널 기록
|
||||
//! ③ 삭제는 OS 휴지통 경유만 (영구삭제 없음 — 계획서 §7).
|
||||
//! ③ 삭제는 2단계 — 평소엔 '삭제 예정' 태그로 표시(되돌리기 가능)하고,
|
||||
//! 사용자가 명시적으로 '비우기'를 실행할 때만 [`delete_permanent`]로 디스크에서 지운다.
|
||||
//! [`trash_files`](OS 휴지통 경유)는 Shift+Delete 즉시 삭제 경로로 남아 있다.
|
||||
|
||||
use archive_db::Db;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -52,14 +54,17 @@ struct FileRow {
|
||||
id: i64,
|
||||
dir: String,
|
||||
name: String,
|
||||
/// 소스 루트 — OS 휴지통이 없는 경로에서 앱 휴지통 위치를 정하는 데 쓴다
|
||||
root: String,
|
||||
}
|
||||
|
||||
fn load_rows(db: &Db, file_ids: &[i64]) -> Result<Vec<FileRow>> {
|
||||
let ids = file_ids.to_vec();
|
||||
Ok(db.with_read(move |conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT f.id, fo.path, f.name FROM files f
|
||||
"SELECT f.id, fo.path, f.name, s.root FROM files f
|
||||
JOIN folders fo ON fo.id = f.folder_id
|
||||
JOIN sources s ON s.id = f.source_id
|
||||
WHERE f.id = ?1 AND f.deleted_at IS NULL",
|
||||
)?;
|
||||
let mut out = Vec::with_capacity(ids.len());
|
||||
@@ -69,6 +74,7 @@ fn load_rows(db: &Db, file_ids: &[i64]) -> Result<Vec<FileRow>> {
|
||||
id: r.get(0)?,
|
||||
dir: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
root: r.get(3)?,
|
||||
})
|
||||
}) {
|
||||
out.push(row);
|
||||
@@ -219,7 +225,55 @@ pub fn rename_file(db: &Db, batch_id: &str, file_id: i64, new_name: &str) -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 물리 삭제 성공 후 DB에 소프트삭제 + 저널 기록.
|
||||
fn record_removal(
|
||||
db: &Db,
|
||||
batch_id: &str,
|
||||
file_id: i64,
|
||||
src: &Path,
|
||||
dst: Option<&Path>,
|
||||
op: &'static str,
|
||||
) -> Result<()> {
|
||||
let (bid, src_s) = (batch_id.to_string(), src.to_string_lossy().into_owned());
|
||||
let dst_s = dst.map(|p| p.to_string_lossy().into_owned());
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"UPDATE files SET deleted_at = unixepoch('now','subsec') * 1000 WHERE id = ?1",
|
||||
[file_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, src_path, dst_path)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
rusqlite::params![bid, op, file_id, src_s, dst_s],
|
||||
)?;
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 앱 휴지통 폴더 이름 — 소스 루트 바로 아래. 점(.)으로 시작해 스캐너가 건너뛴다.
|
||||
pub const APP_TRASH_DIR: &str = ".archive_trash";
|
||||
|
||||
/// OS 휴지통을 쓸 수 없는 경로(UNC/네트워크 공유 등)용 폴백.
|
||||
/// 네트워크 공유에는 휴지통이 없어 `trash::delete`가 실패하므로, 소스 루트의
|
||||
/// `.archive_trash/`로 옮겨 복구 가능한 상태를 유지한다 (원격 소스와 같은 방식).
|
||||
fn app_trash_move(root: &str, file_id: i64, src: &Path) -> std::io::Result<PathBuf> {
|
||||
let dir = Path::new(root).join(APP_TRASH_DIR);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let name = src
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| format!("{file_id}"));
|
||||
// 충돌 방지로 file_id 접두 (원격 경로와 동일 규칙)
|
||||
let dst = dir.join(format!("{file_id}_{name}"));
|
||||
physical_move(src, &dst)?;
|
||||
Ok(dst)
|
||||
}
|
||||
|
||||
/// 파일들을 OS 휴지통으로 보낸다 (인앱 undo 불가 — OS 휴지통이 복구 경로).
|
||||
/// 네트워크 공유(UNC 등)에는 OS 휴지통이 없어 실패하므로, 그 경우
|
||||
/// 소스 루트의 `.archive_trash/`로 옮기는 앱 휴지통으로 폴백한다.
|
||||
pub fn trash_files(
|
||||
db: &Db,
|
||||
batch_id: &str,
|
||||
@@ -237,25 +291,59 @@ pub fn trash_files(
|
||||
let src = Path::new(&row.dir).join(&row.name);
|
||||
match trash::delete(&src) {
|
||||
Ok(()) => {
|
||||
let (bid, fid) = (batch_id.to_string(), row.id);
|
||||
let src_s = src.to_string_lossy().into_owned();
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"UPDATE files SET deleted_at = unixepoch('now','subsec') * 1000 WHERE id = ?1",
|
||||
[fid],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, src_path)
|
||||
VALUES (?1, 'trash', ?2, ?3)",
|
||||
rusqlite::params![bid, fid, src_s],
|
||||
)?;
|
||||
tx.commit()
|
||||
})?;
|
||||
record_removal(db, batch_id, row.id, &src, None, "trash")?;
|
||||
summary.done += 1;
|
||||
}
|
||||
Err(os_err) => match app_trash_move(&row.root, row.id, &src) {
|
||||
Ok(dst) => {
|
||||
tracing::info!(
|
||||
src = %src.display(),
|
||||
"OS 휴지통 불가({os_err}) — 앱 휴지통으로 이동: {}", dst.display()
|
||||
);
|
||||
record_removal(db, batch_id, row.id, &src, Some(&dst), "trash")?;
|
||||
summary.done += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(src = %src.display(), "휴지통 이동 실패: {os_err} / 폴백도 실패: {e}");
|
||||
summary.failed += 1;
|
||||
}
|
||||
},
|
||||
}
|
||||
on_progress(i as u64 + 1, total);
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// 파일들을 디스크에서 **영구 삭제**한다 — 복구 불가.
|
||||
/// '삭제 예정' 태그 비우기 경로 전용이며, 호출 전 사용자 확인은 UI 책임이다.
|
||||
/// 이미 사라진 파일은 실패가 아닌 skipped로 세고 인덱스만 정리한다.
|
||||
pub fn delete_permanent(
|
||||
db: &Db,
|
||||
batch_id: &str,
|
||||
file_ids: &[i64],
|
||||
mut on_progress: impl FnMut(u64, u64),
|
||||
) -> Result<OpSummary> {
|
||||
let rows = load_rows(db, file_ids)?;
|
||||
let total = rows.len() as u64;
|
||||
let mut summary = OpSummary {
|
||||
batch_id: batch_id.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let src = Path::new(&row.dir).join(&row.name);
|
||||
match std::fs::remove_file(&src) {
|
||||
Ok(()) => {
|
||||
record_removal(db, batch_id, row.id, &src, None, "delete")?;
|
||||
summary.done += 1;
|
||||
}
|
||||
// 이미 없는 파일 — 외부에서 지워졌을 뿐이니 인덱스만 정리한다
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
record_removal(db, batch_id, row.id, &src, None, "delete")?;
|
||||
summary.skipped += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(src = %src.display(), "휴지통 이동 실패: {e}");
|
||||
tracing::warn!(src = %src.display(), "영구 삭제 실패: {e}");
|
||||
summary.failed += 1;
|
||||
}
|
||||
}
|
||||
@@ -279,7 +367,7 @@ pub fn list_batches(db: &Db, limit: i64) -> Result<Vec<UndoBatch>> {
|
||||
Ok(db.with_read(move |conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT batch_id, count(*), group_concat(DISTINCT op), max(performed_at),
|
||||
sum(CASE WHEN op = 'trash' THEN 1 ELSE 0 END) = 0
|
||||
sum(CASE WHEN op IN ('trash','delete') THEN 1 ELSE 0 END) = 0
|
||||
FROM ops_journal WHERE undone = 0
|
||||
GROUP BY batch_id ORDER BY max(performed_at) DESC LIMIT ?1",
|
||||
)?;
|
||||
@@ -296,8 +384,14 @@ pub fn list_batches(db: &Db, limit: i64) -> Result<Vec<UndoBatch>> {
|
||||
})?)
|
||||
}
|
||||
|
||||
/// 배치 undo — 저널 역순 재생. trash 항목은 건너뛴다.
|
||||
pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
|
||||
/// 배치 undo — 저널 역순 재생. trash/delete 항목은 건너뛴다.
|
||||
/// 되돌리기. `tools`가 없으면 영상 편집 되돌리기만 실패로 남고 나머지는 정상 처리된다.
|
||||
pub fn undo_batch(
|
||||
db: &Db,
|
||||
batch_id: &str,
|
||||
tools: Option<&crate::ffmpeg::FfTools>,
|
||||
encoder: &str,
|
||||
) -> Result<OpSummary> {
|
||||
struct JEntry {
|
||||
id: i64,
|
||||
op: String,
|
||||
@@ -305,11 +399,13 @@ pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
|
||||
src_path: Option<String>,
|
||||
dst_path: Option<String>,
|
||||
tag_id: Option<i64>,
|
||||
/// 편집 되돌리기용 역연산 이름 (op = "edit"일 때만 채워진다)
|
||||
payload: Option<String>,
|
||||
}
|
||||
let bid = batch_id.to_string();
|
||||
let entries: Vec<JEntry> = db.with_read(move |conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT id, op, file_id, src_path, dst_path, tag_id FROM ops_journal
|
||||
"SELECT id, op, file_id, src_path, dst_path, tag_id, payload FROM ops_journal
|
||||
WHERE batch_id = ?1 AND undone = 0 ORDER BY id DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([&bid], |r| {
|
||||
@@ -320,6 +416,7 @@ pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
|
||||
src_path: r.get(3)?,
|
||||
dst_path: r.get(4)?,
|
||||
tag_id: r.get(5)?,
|
||||
payload: r.get(6)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
@@ -387,6 +484,41 @@ pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 편집 되돌리기 = 역연산을 다시 적용. 원본 백업이 없으므로
|
||||
// 파일이 그새 다른 도구로 바뀌었으면 그 위에 역회전이 걸린다 —
|
||||
// 회전은 무손실이라 그래도 화질 손해는 없다.
|
||||
"edit" => {
|
||||
let (Some(fid), Some(tok)) = (e.file_id, e.payload.as_deref()) else {
|
||||
summary.failed += 1;
|
||||
continue;
|
||||
};
|
||||
// 자르기는 역연산이 없다 — 옮겨 둔 원본을 제자리로 되돌린다
|
||||
if tok == "restore" {
|
||||
let Some(bk) = e.dst_path.as_deref() else {
|
||||
summary.failed += 1;
|
||||
continue;
|
||||
};
|
||||
match restore_from_backup(db, fid, bk) {
|
||||
Ok(()) => true,
|
||||
Err(_) => {
|
||||
summary.failed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let Some(inv) = op_from_token(tok) else {
|
||||
summary.failed += 1;
|
||||
continue;
|
||||
};
|
||||
match undo_edit(db, tools, fid, inv, encoder) {
|
||||
Ok(()) => true,
|
||||
Err(_) => {
|
||||
summary.failed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"tag" => {
|
||||
if let (Some(fid), Some(tid)) = (e.file_id, e.tag_id) {
|
||||
db.with_write(move |conn| {
|
||||
@@ -413,7 +545,7 @@ pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
|
||||
false
|
||||
}
|
||||
}
|
||||
// trash는 인앱 undo 불가 (OS 휴지통에서 복원)
|
||||
// trash는 OS 휴지통에서, delete는 아예 복구 불가 — 둘 다 인앱 undo 대상 아님
|
||||
_ => {
|
||||
summary.skipped += 1;
|
||||
false
|
||||
@@ -497,7 +629,7 @@ mod tests {
|
||||
assert_eq!(file_id_of(&db, "사진1.jpg"), fid);
|
||||
|
||||
// undo → 원위치
|
||||
let u = undo_batch(&db, &bid).unwrap();
|
||||
let u = undo_batch(&db, &bid, None, "libopenh264").unwrap();
|
||||
assert_eq!(u.done, 1);
|
||||
assert!(media.path().join("a/사진1.jpg").is_file());
|
||||
assert!(!media.path().join("b/사진1.jpg").exists());
|
||||
@@ -528,10 +660,124 @@ mod tests {
|
||||
|
||||
assert!(rename_file(&db, &bid, fid, "bad/name.jpg").is_err());
|
||||
|
||||
undo_batch(&db, &bid).unwrap();
|
||||
undo_batch(&db, &bid, None, "libopenh264").unwrap();
|
||||
assert!(media.path().join("a/사진2.jpg").is_file());
|
||||
}
|
||||
|
||||
/// OS 휴지통이 없는 경로(네트워크 공유)를 UNC 루프백으로 흉내낸다.
|
||||
/// 폴백으로 .archive_trash에 남아야 하고, 재스캔에 다시 잡히면 안 된다.
|
||||
#[test]
|
||||
fn trash_falls_back_to_app_trash_on_unc() {
|
||||
let media = tempfile::tempdir().unwrap();
|
||||
let local_root = media.path().to_string_lossy().into_owned();
|
||||
let unc_root = local_root.replace("C:\\", "\\\\localhost\\C$\\");
|
||||
if !std::path::Path::new(&unc_root).exists() {
|
||||
eprintln!("UNC 루프백 불가 — 테스트 건너뜀");
|
||||
return;
|
||||
}
|
||||
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
std::fs::write(media.path().join("사진1.jpg"), b"one").unwrap();
|
||||
|
||||
let root_for_db = unc_root.clone();
|
||||
let sid = db
|
||||
.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sources(kind,name,root) VALUES('local','unc',?1)",
|
||||
[&root_for_db],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.unwrap();
|
||||
let cancel = std::sync::atomic::AtomicBool::new(false);
|
||||
crate::pipeline::scan_source(&db, sid, std::path::Path::new(&unc_root), &cancel, |_| {})
|
||||
.unwrap();
|
||||
|
||||
let fid = file_id_of(&db, "사진1.jpg");
|
||||
let s = trash_files(&db, &new_batch_id(), &[fid], |_, _| {}).unwrap();
|
||||
assert_eq!((s.done, s.failed), (1, 0), "UNC에서도 삭제는 성공해야 한다");
|
||||
|
||||
// 원본은 사라지고 앱 휴지통에 남는다
|
||||
assert!(!media.path().join("사진1.jpg").exists());
|
||||
let trashed = media.path().join(APP_TRASH_DIR).join(format!("{fid}_사진1.jpg"));
|
||||
assert!(trashed.is_file(), "앱 휴지통에 보존돼야 한다: {}", trashed.display());
|
||||
|
||||
// 재스캔해도 .archive_trash는 인덱싱되지 않는다
|
||||
crate::pipeline::scan_source(&db, sid, std::path::Path::new(&unc_root), &cancel, |_| {})
|
||||
.unwrap();
|
||||
let alive: i64 = db
|
||||
.with_read(|c| {
|
||||
c.query_row(
|
||||
"SELECT count(*) FROM files WHERE deleted_at IS NULL",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(alive, 0, ".archive_trash가 다시 인덱싱되면 안 된다");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_permanent_removes_file_and_blocks_undo() {
|
||||
let (_d, media, db, _sid) = setup();
|
||||
let fid = file_id_of(&db, "사진1.jpg");
|
||||
let bid = new_batch_id();
|
||||
|
||||
let s = delete_permanent(&db, &bid, &[fid], |_, _| {}).unwrap();
|
||||
assert_eq!(s.done, 1);
|
||||
assert!(!media.path().join("a/사진1.jpg").exists());
|
||||
// 인덱스에서도 사라진다(소프트삭제)
|
||||
let alive: i64 = db
|
||||
.with_read(move |c| {
|
||||
c.query_row(
|
||||
"SELECT count(*) FROM files WHERE id = ?1 AND deleted_at IS NULL",
|
||||
[fid],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(alive, 0);
|
||||
|
||||
// 영구 삭제 배치는 undo 대상이 아니다
|
||||
let batch = list_batches(&db, 10)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|b| b.batch_id == bid)
|
||||
.unwrap();
|
||||
assert!(!batch.undoable);
|
||||
assert_eq!(undo_batch(&db, &bid, None, "libopenh264").unwrap().done, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_permanent_missing_file_counts_skipped() {
|
||||
let (_d, media, db, _sid) = setup();
|
||||
let fid = file_id_of(&db, "사진2.jpg");
|
||||
std::fs::remove_file(media.path().join("a/사진2.jpg")).unwrap();
|
||||
|
||||
let s = delete_permanent(&db, &new_batch_id(), &[fid], |_, _| {}).unwrap();
|
||||
assert_eq!((s.done, s.skipped, s.failed), (0, 1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trash_tag_marks_and_lists_pending() {
|
||||
let (_d, _m, db, _sid) = setup();
|
||||
let fid = file_id_of(&db, "사진1.jpg");
|
||||
let tag = crate::tags::ensure_trash_tag(&db).unwrap();
|
||||
// 재호출은 같은 id
|
||||
assert_eq!(crate::tags::ensure_trash_tag(&db).unwrap(), tag);
|
||||
|
||||
crate::tags::assign(&db, &new_batch_id(), vec![fid], tag).unwrap();
|
||||
assert_eq!(crate::tags::pending_delete_ids(&db).unwrap(), vec![fid]);
|
||||
let (count, bytes) = crate::tags::pending_delete_stats(&db).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(bytes, 3); // "one"
|
||||
|
||||
// 비우기 후에는 대기열이 빈다
|
||||
delete_permanent(&db, &new_batch_id(), &[fid], |_, _| {}).unwrap();
|
||||
assert!(crate::tags::pending_delete_ids(&db).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_undo_roundtrip() {
|
||||
let (_d, _m, db, _sid) = setup();
|
||||
@@ -542,7 +788,381 @@ mod tests {
|
||||
crate::tags::assign(&db, &bid, vec![fid], tag).unwrap();
|
||||
assert_eq!(crate::tags::file_tags(&db, fid).unwrap().len(), 1);
|
||||
|
||||
undo_batch(&db, &bid).unwrap();
|
||||
undo_batch(&db, &bid, None, "libopenh264").unwrap();
|
||||
assert_eq!(crate::tags::file_tags(&db, fid).unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 간단 편집 (회전·반전) ────────────────────────────────
|
||||
|
||||
/// 편집 결과 요약 — 어떤 파일이 어떻게 바뀌었는지.
|
||||
pub struct EditSummary {
|
||||
pub batch_id: String,
|
||||
pub done: u64,
|
||||
pub failed: u64,
|
||||
/// 실패 사유 — 사용자에게 보여줄 유일한 단서다
|
||||
pub errors: Vec<String>,
|
||||
/// 다시 인코딩된 파일 수 (화질 손실이 있었던 것들)
|
||||
pub reencoded: u64,
|
||||
}
|
||||
|
||||
/// 편집 전 수정시각을 기억했다가 편집 뒤 되돌려 준다.
|
||||
///
|
||||
/// **편집은 촬영 시각을 바꾸는 일이 아니다.** 기본 정렬은 `COALESCE(taken_at, mtime_ms)`라
|
||||
/// EXIF 촬영일이 없는 파일(대부분의 영상)은 mtime으로 줄을 선다. 회전 한 번에 mtime이
|
||||
/// 지금으로 바뀌면 그 항목이 목록 맨 앞으로 튀어 사용자가 방금 보던 파일을 다시 찾아야 한다.
|
||||
fn with_preserved_mtime<T>(path: &Path, f: impl FnOnce() -> T) -> T {
|
||||
let before = std::fs::metadata(path).and_then(|m| m.modified()).ok();
|
||||
let out = f();
|
||||
if let Some(t) = before {
|
||||
let _ = filetime::set_file_mtime(path, filetime::FileTime::from_system_time(t));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 되돌릴 때 적용할 역연산.
|
||||
fn inverse_of(op: crate::edit::EditOp) -> crate::edit::EditOp {
|
||||
use crate::edit::EditOp::*;
|
||||
match op {
|
||||
RotateCw => RotateCcw,
|
||||
RotateCcw => RotateCw,
|
||||
// 반전과 180°는 자기 자신이 역연산이다
|
||||
Rotate180 => Rotate180,
|
||||
FlipH => FlipH,
|
||||
FlipV => FlipV,
|
||||
}
|
||||
}
|
||||
|
||||
fn op_token(op: crate::edit::EditOp) -> &'static str {
|
||||
use crate::edit::EditOp::*;
|
||||
match op {
|
||||
RotateCw => "rotateCw",
|
||||
RotateCcw => "rotateCcw",
|
||||
Rotate180 => "rotate180",
|
||||
FlipH => "flipH",
|
||||
FlipV => "flipV",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn op_from_token(s: &str) -> Option<crate::edit::EditOp> {
|
||||
use crate::edit::EditOp::*;
|
||||
Some(match s {
|
||||
"rotateCw" => RotateCw,
|
||||
"rotateCcw" => RotateCcw,
|
||||
"rotate180" => Rotate180,
|
||||
"flipH" => FlipH,
|
||||
"flipV" => FlipV,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 고른 파일들을 회전·반전한다. **원본을 직접 고친다.**
|
||||
///
|
||||
/// 되돌리기는 원본을 백업해 두는 대신 **역연산을 다시 적용한다** —
|
||||
/// 회전은 무손실이라 되돌린 결과가 원본과 같고, 큰 영상을 복사해 둘 필요도 없다.
|
||||
/// 반전만 재인코딩이라 되돌려도 원본과 바이트가 같지는 않다(화질은 한 번 더 손해).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn edit_files(
|
||||
db: &Db,
|
||||
tools: &crate::ffmpeg::FfTools,
|
||||
batch_id: &str,
|
||||
file_ids: &[i64],
|
||||
op: crate::edit::EditOp,
|
||||
encoder: &str,
|
||||
mut on_progress: impl FnMut(u64, u64),
|
||||
) -> Result<EditSummary> {
|
||||
let rows = load_rows(db, file_ids)?;
|
||||
let total = rows.len() as u64;
|
||||
let mut sum = EditSummary {
|
||||
batch_id: batch_id.to_string(),
|
||||
done: 0,
|
||||
failed: 0,
|
||||
errors: Vec::new(),
|
||||
reencoded: 0,
|
||||
};
|
||||
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
on_progress(i as u64, total);
|
||||
let path = Path::new(&row.dir).join(&row.name);
|
||||
let (kind, orientation): (i64, i64) = {
|
||||
let fid = row.id;
|
||||
db.with_read(move |conn| {
|
||||
conn.query_row(
|
||||
"SELECT kind, COALESCE(orientation, 1) FROM files WHERE id = ?1",
|
||||
[fid],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
// 영상 반전만 재인코딩이다 — 역연산으로 되돌려도 원본 바이트가 아니고
|
||||
// 왕복할 때마다 화질이 깎인다. 그래서 자르기와 같이 원본을 옮겨 보관한다.
|
||||
// (회전은 메타데이터만 바꾸므로 역연산이 진짜 원본을 돌려준다 — 백업이 낭비다)
|
||||
let lossy = kind == 1 && matches!(op, crate::edit::EditOp::FlipH | crate::edit::EditOp::FlipV);
|
||||
let backup = lossy.then(|| {
|
||||
Path::new(&row.root)
|
||||
.join(APP_TRASH_DIR)
|
||||
.join(EDIT_BACKUP_DIR)
|
||||
.join(format!("{}_{batch_id}_{}", row.id, row.name))
|
||||
});
|
||||
if let Some(bk) = &backup {
|
||||
// 복사로 남긴다. 반전은 어차피 전체를 다시 인코딩하므로 복사가 병목이 아니다
|
||||
// (자르기는 스트림 복사라 빨라서 '옮기기'를 쓰지만 여기는 사정이 다르다).
|
||||
let ok = bk.parent().map(|p| std::fs::create_dir_all(p).is_ok()).unwrap_or(false)
|
||||
&& std::fs::copy(&path, bk).is_ok();
|
||||
if !ok {
|
||||
sum.failed += 1;
|
||||
sum.errors.push(format!("{}: 원본 백업에 실패해 건너뜀", row.name));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let result = with_preserved_mtime(&path, || {
|
||||
if kind == 1 {
|
||||
crate::edit::rotate_video(tools, &path, op, encoder)
|
||||
} else {
|
||||
crate::edit::rotate_image(&path, orientation.clamp(1, 8) as u8, op)
|
||||
}
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(r) => {
|
||||
sum.done += 1;
|
||||
if r.reencoded {
|
||||
sum.reencoded += 1;
|
||||
}
|
||||
// 원본을 고치는 작업은 반드시 남긴다. 예전에 편집이 아무 기록도 남기지 않아
|
||||
// 파일이 언제 왜 바뀌었는지 나중에 되짚을 방법이 없었다.
|
||||
tracing::info!(
|
||||
file_id = row.id,
|
||||
path = %path.display(),
|
||||
?op,
|
||||
reencoded = r.reencoded,
|
||||
"편집 적용"
|
||||
);
|
||||
apply_edit_to_db(db, batch_id, row.id, op, &r, backup.as_deref())?;
|
||||
}
|
||||
Err(e) => {
|
||||
sum.failed += 1;
|
||||
sum.errors.push(format!("{}: {e}", row.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
on_progress(total, total);
|
||||
Ok(sum)
|
||||
}
|
||||
|
||||
/// 편집 결과를 DB에 반영하고 저널에 남긴다.
|
||||
///
|
||||
/// 썸네일과 지문은 **무효로 표시만** 한다 — 여기서 다시 만들면 회전 한 번에
|
||||
/// 몇 초씩 멈춘다. 백그라운드 큐가 알아서 다시 만든다.
|
||||
fn apply_edit_to_db(
|
||||
db: &Db,
|
||||
batch_id: &str,
|
||||
file_id: i64,
|
||||
op: crate::edit::EditOp,
|
||||
r: &crate::edit::EditResult,
|
||||
backup: Option<&Path>,
|
||||
) -> Result<()> {
|
||||
// 백업을 남긴 편집(재인코딩되는 반전)은 역연산이 아니라 그 파일을 되돌린다 —
|
||||
// 역연산으로 되돌리면 화질이 한 번 더 깎인 '비슷한' 파일이 될 뿐이다.
|
||||
let (bid, token) = match backup {
|
||||
Some(_) => (batch_id.to_string(), "restore".to_string()),
|
||||
None => (batch_id.to_string(), op_token(inverse_of(op)).to_string()),
|
||||
};
|
||||
let bk = backup.map(|p| p.to_string_lossy().into_owned());
|
||||
let (orientation, swapped) = (r.orientation.map(|v| v as i64), r.swapped);
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
if let Some(o) = orientation {
|
||||
tx.execute("UPDATE files SET orientation = ?2 WHERE id = ?1", rusqlite::params![file_id, o])?;
|
||||
}
|
||||
if swapped {
|
||||
// 가로·세로가 바뀌었다 — 안 고치면 그리드 배치가 어긋난다
|
||||
tx.execute(
|
||||
"UPDATE files SET width = height, height = width WHERE id = ?1",
|
||||
[file_id],
|
||||
)?;
|
||||
}
|
||||
// 썸네일·지문 다시 만들기 (0 = 대기)
|
||||
tx.execute("UPDATE files SET thumb_state = 0 WHERE id = ?1", [file_id])?;
|
||||
tx.execute("DELETE FROM thumbs WHERE file_id = ?1", [file_id])?;
|
||||
tx.execute("UPDATE files SET sig_state = 0 WHERE id = ?1", [file_id]).ok();
|
||||
tx.execute(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, dst_path, payload)
|
||||
VALUES (?1, 'edit', ?2, ?3, ?4)",
|
||||
rusqlite::params![bid, file_id, bk, token],
|
||||
)?;
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 편집 하나를 역연산으로 되돌린다.
|
||||
fn undo_edit(
|
||||
db: &Db,
|
||||
tools: Option<&crate::ffmpeg::FfTools>,
|
||||
file_id: i64,
|
||||
inverse: crate::edit::EditOp,
|
||||
encoder: &str,
|
||||
) -> Result<()> {
|
||||
let (dir, name, kind, orientation): (String, String, i64, i64) = db.with_read(move |conn| {
|
||||
conn.query_row(
|
||||
"SELECT fo.path, f.name, f.kind, COALESCE(f.orientation, 1) FROM files f
|
||||
JOIN folders fo ON fo.id = f.folder_id WHERE f.id = ?1",
|
||||
[file_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||
)
|
||||
})?;
|
||||
let path = Path::new(&dir).join(&name);
|
||||
|
||||
let result = if kind == 1 {
|
||||
let t = tools.ok_or_else(|| OpError::BadName("ffmpeg 사이드카 없음".into()))?;
|
||||
crate::edit::rotate_video(t, &path, inverse, encoder)
|
||||
} else {
|
||||
crate::edit::rotate_image(&path, orientation.clamp(1, 8) as u8, inverse)
|
||||
};
|
||||
let r = result.map_err(|e| OpError::BadName(e.to_string()))?;
|
||||
|
||||
// 되돌린 결과도 DB에 반영해야 한다 — 저널에는 남기지 않는다(되돌리기의 되돌리기는 없다)
|
||||
let orientation = r.orientation.map(|v| v as i64);
|
||||
let swapped = r.swapped;
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
if let Some(o) = orientation {
|
||||
tx.execute("UPDATE files SET orientation = ?2 WHERE id = ?1", rusqlite::params![file_id, o])?;
|
||||
}
|
||||
if swapped {
|
||||
tx.execute("UPDATE files SET width = height, height = width WHERE id = ?1", [file_id])?;
|
||||
}
|
||||
tx.execute("UPDATE files SET thumb_state = 0 WHERE id = ?1", [file_id])?;
|
||||
tx.execute("DELETE FROM thumbs WHERE file_id = ?1", [file_id])?;
|
||||
tx.execute("UPDATE files SET sig_state = 0 WHERE id = ?1", [file_id]).ok();
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 편집 백업이 모이는 곳 — 소스 루트의 앱 휴지통 아래. 스캐너가 건너뛴다.
|
||||
pub const EDIT_BACKUP_DIR: &str = "edits";
|
||||
|
||||
/// 자르기·구간 자르기 — **되돌릴 수 없는 편집**이라 원본을 백업으로 옮겨 둔다.
|
||||
///
|
||||
/// 회전과 달리 역연산이 없다(잘라낸 화소는 되살릴 수 없다). 그래서 저널에 백업 경로를
|
||||
/// 적고, 되돌릴 때 그 파일을 제자리로 옮긴다. 복사가 아니라 이동이라 30GB 영상도 즉시 끝난다.
|
||||
pub fn cut_file(
|
||||
db: &Db,
|
||||
tools: &crate::ffmpeg::FfTools,
|
||||
batch_id: &str,
|
||||
file_id: i64,
|
||||
cut: Cut,
|
||||
encoder: &str,
|
||||
) -> Result<crate::edit::EditResult> {
|
||||
let rows = load_rows(db, &[file_id])?;
|
||||
let row = rows.first().ok_or(OpError::NoFolder(file_id))?;
|
||||
let path = Path::new(&row.dir).join(&row.name);
|
||||
|
||||
let backup_dir = Path::new(&row.root).join(APP_TRASH_DIR).join(EDIT_BACKUP_DIR);
|
||||
// 같은 파일을 여러 번 자를 수 있으므로 배치 id로 구분한다
|
||||
let backup = backup_dir.join(format!("{file_id}_{batch_id}_{}", row.name));
|
||||
|
||||
let (kind, orientation): (i64, i64) = db.with_read(move |conn| {
|
||||
conn.query_row(
|
||||
"SELECT kind, COALESCE(orientation, 1) FROM files WHERE id = ?1",
|
||||
[file_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = with_preserved_mtime(&path, || match cut {
|
||||
Cut::Crop(rect) if kind == 1 => {
|
||||
crate::edit::crop_video(tools, &path, rect, encoder, crop_bitrate(rect), Some(&backup))
|
||||
}
|
||||
Cut::Crop(rect) => {
|
||||
crate::edit::crop_image(&path, rect, orientation.clamp(1, 8) as u8, Some(&backup))
|
||||
}
|
||||
Cut::Trim { start_ms, end_ms } => {
|
||||
crate::edit::trim_video(tools, &path, start_ms, end_ms, Some(&backup))
|
||||
}
|
||||
});
|
||||
let r = result.map_err(|e| OpError::BadName(e.to_string()))?;
|
||||
tracing::info!(
|
||||
file_id,
|
||||
path = %path.display(),
|
||||
?cut,
|
||||
backup = %backup.display(),
|
||||
"자르기 적용 — 원본은 백업으로 옮김"
|
||||
);
|
||||
|
||||
let (bid, bk) = (batch_id.to_string(), backup.to_string_lossy().into_owned());
|
||||
let (new_size, rotation) = (r.new_size, r.rotation);
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
if let Some((w, h)) = new_size {
|
||||
tx.execute(
|
||||
"UPDATE files SET width = ?2, height = ?3 WHERE id = ?1",
|
||||
rusqlite::params![file_id, w as i64, h as i64],
|
||||
)?;
|
||||
}
|
||||
if rotation == Some(0) {
|
||||
tx.execute("UPDATE files SET orientation = 1 WHERE id = ?1", [file_id])?;
|
||||
}
|
||||
tx.execute("UPDATE files SET thumb_state = 0 WHERE id = ?1", [file_id])?;
|
||||
tx.execute("DELETE FROM thumbs WHERE file_id = ?1", [file_id])?;
|
||||
tx.execute("UPDATE files SET sig_state = 0 WHERE id = ?1", [file_id]).ok();
|
||||
// payload='restore' — 되돌리기는 역연산이 아니라 백업 복원이다
|
||||
tx.execute(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, dst_path, payload)
|
||||
VALUES (?1, 'edit', ?2, ?3, 'restore')",
|
||||
rusqlite::params![bid, file_id, bk],
|
||||
)?;
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// 자를 영역에 맞는 비트레이트 — 화소당 0.1비트, 30fps 기준.
|
||||
fn crop_bitrate(rect: crate::edit::CropRect) -> u32 {
|
||||
let bits = rect.w as f64 * rect.h as f64 * 30.0 * 0.10;
|
||||
((bits / 1000.0).round() as i64).clamp(500, 80_000) as u32
|
||||
}
|
||||
|
||||
/// 되돌릴 수 없는 편집의 종류.
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Cut {
|
||||
/// 일부 영역만 남긴다 (사진·영상 공통, 화면 좌표)
|
||||
Crop(crate::edit::CropRect),
|
||||
/// 한 구간만 남긴다 (영상)
|
||||
///
|
||||
/// 변이체마다 `rename_all`을 따로 붙인다 — enum에 붙인 것은 **변이체 이름만** 바꾸고
|
||||
/// 필드 이름은 그대로 둔다. 그래서 프런트가 보낸 `startMs`가 매칭되지 않았다(실측).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Trim { start_ms: i64, end_ms: i64 },
|
||||
}
|
||||
|
||||
/// 백업에서 원본을 되돌린다.
|
||||
fn restore_from_backup(db: &Db, file_id: i64, backup: &str) -> Result<()> {
|
||||
let (dir, name): (String, String) = db.with_read(move |conn| {
|
||||
conn.query_row(
|
||||
"SELECT fo.path, f.name FROM files f JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.id = ?1",
|
||||
[file_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
})?;
|
||||
let dst = Path::new(&dir).join(&name);
|
||||
physical_move(Path::new(backup), &dst).map_err(|e| OpError::Io {
|
||||
path: backup.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
// 치수는 다시 읽어야 하지만 여기서 디코드하지 않는다 — 메타 큐가 다시 채운다
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("UPDATE files SET thumb_state = 0, meta_state = 0 WHERE id = ?1", [file_id]).ok();
|
||||
tx.execute("DELETE FROM thumbs WHERE file_id = ?1", [file_id])?;
|
||||
tx.execute("UPDATE files SET sig_state = 0 WHERE id = ?1", [file_id]).ok();
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 스캔·해시·썸네일·중복탐지 엔진. Tauri 의존성 없음 — 헤드리스 테스트 가능.
|
||||
|
||||
pub mod dedupe;
|
||||
pub mod edit;
|
||||
pub mod ffmpeg;
|
||||
pub mod fileops;
|
||||
pub mod hash;
|
||||
@@ -11,6 +12,8 @@ pub mod phash;
|
||||
pub mod pipeline;
|
||||
pub mod prio;
|
||||
pub mod scan;
|
||||
pub mod sigmatch;
|
||||
pub mod sigq;
|
||||
pub mod tags;
|
||||
pub mod thumbq;
|
||||
pub mod thumbs;
|
||||
|
||||
@@ -110,6 +110,96 @@ pub fn remove_source(db: &Db, thumb_root: &Path, source_id: i64) -> Result<u64,
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// 경로 비교용 정규화 — 뒤쪽 구분자를 떼고, Windows에서는 구분자·대소문자를 통일한다.
|
||||
pub fn norm_path(p: &str) -> String {
|
||||
let trimmed = p.trim_end_matches(|c| c == '/' || c == '\\');
|
||||
let base = if trimmed.is_empty() { p } else { trimmed };
|
||||
if cfg!(windows) {
|
||||
base.replace('/', "\\").to_lowercase()
|
||||
} else {
|
||||
base.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// `child`가 `parent`와 같거나 그 하위 경로인지 (둘 다 norm_path 결과여야 한다).
|
||||
pub fn is_within(child: &str, parent: &str) -> bool {
|
||||
if child == parent {
|
||||
return true;
|
||||
}
|
||||
let sep = if cfg!(windows) { '\\' } else { '/' };
|
||||
child.starts_with(parent) && child[parent.len()..].starts_with(sep)
|
||||
}
|
||||
|
||||
/// 소스의 루트 경로를 새 위치로 재지정한다 (드라이브 문자 변경·폴더 이동 후 복구).
|
||||
///
|
||||
/// `folders.path`의 접두사만 갈아치우므로 **파일 행 id가 보존된다** — 태그·썸네일·
|
||||
/// 중복 그룹이 그대로 살아 있고, 이어지는 재스캔은 무변경으로 끝난다.
|
||||
/// 반환: 경로를 고친 폴더 행 수.
|
||||
pub fn repoint_source(
|
||||
db: &Db,
|
||||
source_id: i64,
|
||||
old_root: &str,
|
||||
new_root: &str,
|
||||
) -> Result<u64, String> {
|
||||
let old_trim = old_root.trim_end_matches(|c| c == '/' || c == '\\').to_string();
|
||||
let new_trim = new_root.trim_end_matches(|c| c == '/' || c == '\\').to_string();
|
||||
|
||||
// 접두사 판정은 Rust에서 한다 — SQL LIKE는 경로 안의 %·_ 를 와일드카드로 해석한다.
|
||||
let rows: Vec<(i64, String)> = db
|
||||
.with_read(|conn| {
|
||||
let mut stmt = conn.prepare("SELECT id, path FROM folders WHERE source_id = ?1")?;
|
||||
let rows = stmt.query_map([source_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
rows.collect()
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let old_norm = norm_path(&old_trim);
|
||||
let mut updates: Vec<(i64, String, String)> = Vec::new(); // (id, 새 path, 새 name)
|
||||
for (id, path) in rows {
|
||||
let norm = norm_path(&path);
|
||||
if !is_within(&norm, &old_norm) {
|
||||
continue; // 접두사가 다른 행은 손대지 않는다
|
||||
}
|
||||
// 접미부는 원본 문자열에서 잘라야 대소문자가 보존된다
|
||||
let rest: String = path.chars().skip(old_trim.chars().count()).collect();
|
||||
let next = format!("{new_trim}{rest}");
|
||||
let name = Path::new(&next)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| next.clone());
|
||||
updates.push((id, next, name));
|
||||
}
|
||||
|
||||
// UNIQUE(source_id, path) 순간 충돌 방지 — 새 루트가 옛 루트 안쪽이면 깊은 경로부터,
|
||||
// 바깥쪽이면 얕은 경로부터 고친다. (한 행의 새 경로가 다른 행의 옛 경로와 같을 수 있다)
|
||||
let new_norm = norm_path(&new_trim);
|
||||
if is_within(&old_norm, &new_norm) {
|
||||
updates.sort_by_key(|(_, p, _)| p.len()); // 얕은 것부터
|
||||
} else {
|
||||
updates.sort_by_key(|(_, p, _)| std::cmp::Reverse(p.len())); // 깊은 것부터
|
||||
}
|
||||
|
||||
let changed = updates.len() as u64;
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut up =
|
||||
tx.prepare_cached("UPDATE folders SET path = ?2, name = ?3 WHERE id = ?1")?;
|
||||
for (id, path, name) in &updates {
|
||||
up.execute(rusqlite::params![id, path, name])?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE sources SET root = ?2 WHERE id = ?1",
|
||||
rusqlite::params![source_id, new_trim],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// DB를 백업 파일로 복제한다 (VACUUM INTO — 조각모음 겸용).
|
||||
pub fn backup_db(db: &Db, backup_path: &Path) -> Result<(), String> {
|
||||
let _ = std::fs::remove_file(backup_path);
|
||||
@@ -170,6 +260,80 @@ mod tests {
|
||||
assert!(!crate::thumbq::thumb_path(thumbs.path(), 0, &format!("{:032x}", 1)).exists());
|
||||
}
|
||||
|
||||
/// 재매핑은 폴더 경로 접두사만 바꾸고 파일 행(=태그·썸네일)을 보존해야 한다.
|
||||
#[test]
|
||||
fn repoint_rewrites_folder_paths_and_keeps_file_rows() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
db.with_write(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sources(id,kind,name,root) VALUES(1,'local','사진','D:\\사진')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'D:\\사진','사진')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO folders(id,source_id,parent_id,path,name) VALUES(2,1,1,'D:\\사진\\2024','2024')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO files(id,source_id,folder_id,name,ext,kind,size,mtime_ms)
|
||||
VALUES(9,1,2,'a.jpg','jpg',0,10,0)",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let changed = repoint_source(&db, 1, "D:\\사진", "E:\\보관\\사진").unwrap();
|
||||
assert_eq!(changed, 2);
|
||||
|
||||
let (root, deep, file_folder): (String, String, i64) = db
|
||||
.with_read(|c| {
|
||||
c.query_row(
|
||||
"SELECT (SELECT root FROM sources WHERE id=1),
|
||||
(SELECT path FROM folders WHERE id=2),
|
||||
(SELECT folder_id FROM files WHERE id=9)",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(root, "E:\\보관\\사진");
|
||||
assert_eq!(deep, "E:\\보관\\사진\\2024");
|
||||
assert_eq!(file_folder, 2, "파일 행은 그대로 — 태그·썸네일이 유지된다");
|
||||
}
|
||||
|
||||
/// 새 루트가 옛 루트 안쪽이면 UNIQUE(source_id, path) 순간 충돌이 날 수 있다.
|
||||
#[test]
|
||||
fn repoint_into_own_subfolder_does_not_conflict() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
db.with_write(|conn| {
|
||||
conn.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','a','D:\\A')", [])?;
|
||||
conn.execute("INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'D:\\A','A')", [])?;
|
||||
conn.execute(
|
||||
"INSERT INTO folders(id,source_id,parent_id,path,name) VALUES(2,1,1,'D:\\A\\B','B')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// D:\A → D:\A\B : 1번 행의 새 경로가 2번 행의 옛 경로와 같다
|
||||
repoint_source(&db, 1, "D:\\A", "D:\\A\\B").unwrap();
|
||||
let paths: Vec<String> = db
|
||||
.with_read(|c| {
|
||||
let mut s = c.prepare("SELECT path FROM folders WHERE source_id=1 ORDER BY id")?;
|
||||
let rows = s.query_map([], |r| r.get(0))?;
|
||||
rows.collect()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(paths, vec!["D:\\A\\B".to_string(), "D:\\A\\B\\B".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_old_deleted_rows() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -18,14 +18,52 @@ struct MetaResult {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
fn exif_of(path: &Path) -> (u32, Option<i64>) {
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return (1, None);
|
||||
};
|
||||
/// 헤더를 읽어둘 크기. 치수(SOF/IHDR)와 EXIF APP1은 사실상 항상 이 안에 있다.
|
||||
/// 네트워크에서는 open 왕복이 비용의 대부분이라, 256KB를 한 번에 읽는 편이
|
||||
/// 몇 KB를 두 번(치수용·EXIF용) 여는 것보다 싸다.
|
||||
const HEAD: usize = 256 * 1024;
|
||||
|
||||
/// 이미지 하나의 메타를 파일 **한 번** 열어서 뽑는다 → (치수, orientation, 촬영일시).
|
||||
/// 예전에는 imagesize와 exif가 각각 파일을 열었다: 100만 장이면 open 200만 회.
|
||||
fn meta_of(path: &Path) -> (Option<(u32, u32)>, u32, Option<i64>) {
|
||||
use std::io::Read;
|
||||
let mut head = Vec::new();
|
||||
if let Ok(file) = std::fs::File::open(path) {
|
||||
let _ = file.take(HEAD as u64).read_to_end(&mut head);
|
||||
}
|
||||
// 앞부분이 파일 전체였다면(= HEAD 미만) 다시 열어봐야 나올 게 없다.
|
||||
let truncated = head.len() >= HEAD;
|
||||
// TIFF/RAW는 IFD가 파일 깊숙한 곳을 가리킬 수 있다. EXIF가 앞부분에 없으면 전체를 다시 본다.
|
||||
// JPEG/PNG/WebP에는 폴백하지 않는다 — EXIF가 아예 없는 게 정상이라(PNG 등) 매번 헛되게 연다.
|
||||
let container_deep = truncated
|
||||
&& matches!(
|
||||
head.as_slice(),
|
||||
[0x49, 0x49, 0x2A, 0x00, ..] | [0x4D, 0x4D, 0x00, 0x2A, ..]
|
||||
);
|
||||
|
||||
// 치수는 폴백 조건이 다르다: 앞부분에서 못 찾았다는 것 자체가 이상 신호이므로
|
||||
// (SOF 앞에 거대한 ICC 프로파일 등) 포맷을 가리지 않고 한 번 더 본다. 정상 파일은
|
||||
// 여기 오지 않으니 추가 open은 실패하는 파일에만 생긴다.
|
||||
let dims = imagesize::blob_size(&head)
|
||||
.ok()
|
||||
.or_else(|| if truncated { imagesize::size(path).ok() } else { None })
|
||||
.map(|s| (s.width as u32, s.height as u32));
|
||||
|
||||
let (orientation, taken_at) = exif_from(&mut std::io::Cursor::new(&head))
|
||||
.or_else(|| if container_deep { exif_of(path) } else { None })
|
||||
.unwrap_or((1, None));
|
||||
|
||||
(dims, orientation, taken_at)
|
||||
}
|
||||
|
||||
fn exif_of(path: &Path) -> Option<(u32, Option<i64>)> {
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let mut reader = std::io::BufReader::new(&file);
|
||||
let Ok(ex) = exif::Reader::new().read_from_container(&mut reader) else {
|
||||
return (1, None);
|
||||
};
|
||||
exif_from(&mut reader)
|
||||
}
|
||||
|
||||
fn exif_from<R: std::io::BufRead + std::io::Seek>(r: &mut R) -> Option<(u32, Option<i64>)> {
|
||||
let ex = exif::Reader::new().read_from_container(r).ok()?;
|
||||
let orientation = ex
|
||||
.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
|
||||
.and_then(|f| f.value.get_uint(0))
|
||||
@@ -38,7 +76,7 @@ fn exif_of(path: &Path) -> (u32, Option<i64>) {
|
||||
_ => None,
|
||||
})
|
||||
.and_then(|s| parse_exif_datetime(&s));
|
||||
(orientation, taken_at)
|
||||
Some((orientation, taken_at))
|
||||
}
|
||||
|
||||
/// "YYYY:MM:DD HH:MM:SS" → epoch ms (로컬 시간대 무시, UTC로 근사 — 정렬 용도)
|
||||
@@ -100,12 +138,11 @@ pub fn extract_image_meta(
|
||||
batch
|
||||
.par_iter()
|
||||
.map(|(id, path)| {
|
||||
let dims = imagesize::size(path).ok();
|
||||
let (orientation, taken_at) = exif_of(path);
|
||||
let (dims, orientation, taken_at) = meta_of(path);
|
||||
MetaResult {
|
||||
id: *id,
|
||||
width: dims.map(|d| d.width as u32),
|
||||
height: dims.map(|d| d.height as u32),
|
||||
width: dims.map(|(w, _)| w),
|
||||
height: dims.map(|(_, h)| h),
|
||||
orientation,
|
||||
taken_at,
|
||||
ok: dims.is_some(),
|
||||
@@ -221,6 +258,22 @@ mod tests {
|
||||
assert_eq!(parse_exif_datetime("2020:13:01 00:00:00"), None);
|
||||
}
|
||||
|
||||
/// 파일을 한 번만 열어 치수/EXIF를 뽑는 경로 — 헤더만으로 치수가 나와야 한다.
|
||||
#[test]
|
||||
fn meta_of_reads_dims_from_head() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for (name, w, h) in [("a.png", 640u32, 480u32), ("b.jpg", 300, 500)] {
|
||||
let p = dir.path().join(name);
|
||||
image::RgbImage::from_fn(w, h, |x, y| image::Rgb([(x % 256) as u8, (y % 256) as u8, 9]))
|
||||
.save(&p)
|
||||
.unwrap();
|
||||
let (dims, orientation, taken) = meta_of(&p);
|
||||
assert_eq!(dims, Some((w, h)), "{name}");
|
||||
assert_eq!(orientation, 1, "{name} — EXIF 없으면 1");
|
||||
assert_eq!(taken, None, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_dimensions_for_pending_images() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -48,6 +48,21 @@ pub fn is_media_ext(ext: &str) -> bool {
|
||||
classify_ext(ext) != FileKind::Other
|
||||
}
|
||||
|
||||
/// 편집 중에 잠깐 생기는 임시 파일 이름표.
|
||||
///
|
||||
/// 편집은 임시 파일에 쓰고 원본 자리에 바꿔치기한다. 그런데 ffmpeg은 확장자로 출력 형식을
|
||||
/// 정하므로 임시 파일도 `.mp4`로 끝나야 한다 — 그러면 워처가 그걸 새 항목으로 잡는다.
|
||||
/// 재인코딩이 필요한 편집은 몇 분씩 걸리므로 그 창이 짧지도 않다. 그래서 이름으로 거른다.
|
||||
pub const EDIT_TMP_MARK: &str = ".tmp-edit";
|
||||
|
||||
/// 편집 임시 파일인가 — 인덱싱에서 제외한다.
|
||||
/// 앱이 편집 도중 죽어 남은 찌꺼기도 목록에 나타나지 않는다.
|
||||
pub fn is_edit_temp(path: &std::path::Path) -> bool {
|
||||
path.file_name()
|
||||
.map(|n| n.to_string_lossy().contains(EDIT_TMP_MARK))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -58,6 +58,234 @@ pub fn find_near_pairs(items: &[(i64, u64)], threshold: u32) -> Vec<(i64, i64, u
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── 밴드 색인 (100만 장 확장성) ────────────────────────────────────────────
|
||||
//
|
||||
// 상삼각 브루트포스는 10만 장이면 5×10⁹ 비교로 1~2초지만, 100만 장이면 **5×10¹¹**이라
|
||||
// 코어를 다 써도 분 단위가 된다. 게다가 페어를 Vec으로 모으면 유사 스크린샷이 많은
|
||||
// 라이브러리에서 수백 MB가 된다.
|
||||
//
|
||||
// 해법: 64비트를 b조각으로 쪼개 같은 조각값을 가진 것끼리만 비교한다. 비둘기집 원리로
|
||||
// **거리 ≤ b-1이면 최소 한 조각이 완전 일치**하므로 그 범위에서는 미탐이 없다.
|
||||
// 비용은 대략 `b·n² / 2·2^(64/b)`:
|
||||
// b=4(16비트) → 100만에서 3×10⁷ (거리 ≤3 정확)
|
||||
// b=8(8비트) → 100만에서 1.6×10¹⁰ (거리 ≤7 정확, 브루트포스의 1/30)
|
||||
// b=16(4비트) → 브루트포스와 같은 비용이라 쓸 이유가 없다
|
||||
// 그래서 임계값에 따라 b를 고른다. 임계값이 8 이상이면 정확성을 보장할 수 없어
|
||||
// '한 조각이라도 일치하는 쌍'만 찾는 근사가 된다 — 호출자가 이 사실을 알아야 한다.
|
||||
|
||||
/// 임계값에 맞는 밴드 수. 반환값 b는 `거리 ≤ b-1`까지 미탐이 없음을 뜻한다.
|
||||
pub fn band_count(threshold: u32) -> usize {
|
||||
if threshold <= 3 {
|
||||
4
|
||||
} else {
|
||||
8
|
||||
}
|
||||
}
|
||||
|
||||
/// 이 임계값에서 밴드 색인이 미탐 없이 동작하는가.
|
||||
pub fn banding_is_exact(threshold: u32) -> bool {
|
||||
threshold < band_count(threshold) as u32
|
||||
}
|
||||
|
||||
/// 밴드 색인으로 근접쌍을 찾아 **하나씩 넘긴다**(모으지 않는다 → 메모리 O(n)).
|
||||
///
|
||||
/// 같은 쌍이 여러 밴드에서 걸리면 여러 번 넘어올 수 있다 — 받는 쪽(union-find)은
|
||||
/// 멱등이므로 문제없다. 반환값은 버킷 상한 때문에 건너뛴 비교 쌍 수다(0이면 전량 비교).
|
||||
pub fn for_each_near_pair_banded(
|
||||
items: &[(i64, u64)],
|
||||
threshold: u32,
|
||||
mut on_pair: impl FnMut(i64, i64, u32),
|
||||
) -> u64 {
|
||||
use rayon::prelude::*;
|
||||
let n = items.len();
|
||||
if n < 2 {
|
||||
return 0;
|
||||
}
|
||||
let bands = band_count(threshold);
|
||||
let bits = 64 / bands;
|
||||
let mask = if bits >= 64 { u64::MAX } else { (1u64 << bits) - 1 };
|
||||
let mut skipped = 0u64;
|
||||
|
||||
for band in 0..bands {
|
||||
let shift = (band * bits) as u32;
|
||||
// (밴드값, 해시, id)를 한 배열에 담아 정렬한다. 정렬 뒤에는
|
||||
// ① 밴드값이 같은 구간 = 버킷, ② 그 안에서 같은 해시가 서로 붙어 있다.
|
||||
// 인덱스만 담고 원본을 무작위로 되짚으면 비교마다 캐시를 놓친다 — 해시를 같이 들고 간다.
|
||||
let mut rows: Vec<(u64, u64, i64)> = items
|
||||
.iter()
|
||||
.map(|(id, h)| ((h >> shift) & mask, *h, *id))
|
||||
.collect();
|
||||
rows.sort_unstable();
|
||||
|
||||
// 버킷 경계
|
||||
let mut ranges: Vec<(usize, usize)> = Vec::new();
|
||||
let mut s = 0usize;
|
||||
while s < rows.len() {
|
||||
let mut e = s + 1;
|
||||
while e < rows.len() && rows[e].0 == rows[s].0 {
|
||||
e += 1;
|
||||
}
|
||||
if e - s >= 2 {
|
||||
ranges.push((s, e));
|
||||
}
|
||||
s = e;
|
||||
}
|
||||
|
||||
// 버킷은 서로 독립이므로 병렬로 처리하고, 찾은 간선만 모은다.
|
||||
// (근접쌍은 실제 중복뿐이라 개수가 작다 — 페어 전체를 모으는 것과 다르다)
|
||||
let first_band = band == 0;
|
||||
let out: Vec<(Vec<(i64, i64, u32)>, u64)> = ranges
|
||||
.par_iter()
|
||||
.map(|&(s, e)| bucket_pairs(&rows[s..e], threshold, first_band))
|
||||
.collect();
|
||||
for (edges, sk) in out {
|
||||
skipped += sk;
|
||||
for (a, b, d) in edges {
|
||||
on_pair(a, b, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
skipped
|
||||
}
|
||||
|
||||
/// 버킷 하나(해시 정렬됨) 안에서 근접쌍을 찾는다. 반환: (간선, 건너뛴 비교 수)
|
||||
///
|
||||
/// `emit_identical`이 참일 때만 '완전히 같은 해시' 간선을 낸다 — 같은 해시는 **모든 밴드**에서
|
||||
/// 같은 버킷에 들어오므로 한 번만 내면 충분하다(안 그러면 같은 간선이 밴드 수만큼 쌓인다).
|
||||
fn bucket_pairs(rows: &[(u64, u64, i64)], threshold: u32, emit_identical: bool) -> (Vec<(i64, i64, u32)>, u64) {
|
||||
let mut edges: Vec<(i64, i64, u32)> = Vec::new();
|
||||
// 같은 해시 런을 접어 대표만 남긴다 — '같은 그림 수천 장'을 O(n²)로 비교하지 않기 위해
|
||||
let mut reps: Vec<(u64, i64)> = Vec::new();
|
||||
let mut i = 0usize;
|
||||
while i < rows.len() {
|
||||
let h = rows[i].1;
|
||||
let first_id = rows[i].2;
|
||||
let mut j = i + 1;
|
||||
while j < rows.len() && rows[j].1 == h {
|
||||
if emit_identical {
|
||||
edges.push((first_id, rows[j].2, 0));
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
reps.push((h, first_id));
|
||||
i = j;
|
||||
}
|
||||
|
||||
// 대표가 지나치게 많은 버킷(저분산 해시가 몰린 경우)은 상한까지만 비교하고
|
||||
// 건너뛴 양을 돌려준다 — 조용히 자르면 '전부 훑었다'고 오해한다.
|
||||
let cap = MAX_BUCKET_REPS.min(reps.len());
|
||||
let skipped = if reps.len() > cap {
|
||||
(reps.len() - cap) as u64 * cap as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
for a in 0..cap {
|
||||
let (ha, ida) = reps[a];
|
||||
for &(hb, idb) in &reps[a + 1..cap] {
|
||||
let d = hamming(ha, hb);
|
||||
if d <= threshold {
|
||||
edges.push((ida, idb, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
(edges, skipped)
|
||||
}
|
||||
|
||||
/// 파생 해시(크롭·회전 등)를 **식별 색인에 질의해** 후보를 찾는다.
|
||||
///
|
||||
/// 파생 해시를 색인에 함께 넣으면 항목이 k배가 되고 버킷 안 비교는 k²배가 된다(회전 8변형이면
|
||||
/// 64배 — 100만 장에서 3분 이상). 대신 색인은 식별 해시만으로 만들고 각 파일의 파생 해시를
|
||||
/// 조회하면 비용이 k/2배 수준에 머문다. 방향도 문제없다: `b`가 `a`를 크롭·회전한 것이면
|
||||
/// `b`의 식별 해시가 `a`의 파생 목록에 있으므로 어느 쪽에서 조회해도 만난다.
|
||||
///
|
||||
/// `probes[i]`는 `(id, 파생 해시들)`이다 — 호출자가 항등 해시를 넣지 않는다.
|
||||
pub fn for_each_probe_pair(
|
||||
items: &[(i64, u64)],
|
||||
probes: &[(i64, Vec<u64>)],
|
||||
threshold: u32,
|
||||
mut on_pair: impl FnMut(i64, i64, u32),
|
||||
) {
|
||||
if items.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let bands = band_count(threshold);
|
||||
let bits = 64 / bands;
|
||||
let mask = if bits >= 64 { u64::MAX } else { (1u64 << bits) - 1 };
|
||||
|
||||
for band in 0..bands {
|
||||
let shift = (band * bits) as u32;
|
||||
// 식별 해시만 버킷에 담는다
|
||||
let mut buckets: Vec<Vec<u32>> = vec![Vec::new(); (mask + 1) as usize];
|
||||
for (i, (_, h)) in items.iter().enumerate() {
|
||||
buckets[((h >> shift) & mask) as usize].push(i as u32);
|
||||
}
|
||||
for (vid, list) in probes {
|
||||
for v in list {
|
||||
let bucket = &buckets[((v >> shift) & mask) as usize];
|
||||
for &i in bucket.iter().take(MAX_BUCKET_REPS) {
|
||||
let (id, h) = items[i as usize];
|
||||
if id == *vid {
|
||||
continue; // 자기 자신
|
||||
}
|
||||
let d = hamming(*v, h);
|
||||
if d <= threshold {
|
||||
on_pair(*vid, id, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 한 버킷에서 서로 비교할 '서로 다른 해시' 상한.
|
||||
/// 4096이면 버킷당 최대 8.4M 비교 — 병목이 되지 않는 선.
|
||||
const MAX_BUCKET_REPS: usize = 4096;
|
||||
|
||||
/// 점진적 union-find — 페어를 모아두지 않고 들어오는 대로 병합한다.
|
||||
pub struct Clusters {
|
||||
index: std::collections::HashMap<i64, usize>,
|
||||
parent: Vec<usize>,
|
||||
ids: Vec<i64>,
|
||||
}
|
||||
|
||||
impl Clusters {
|
||||
pub fn new(ids: &[i64]) -> Clusters {
|
||||
let mut index = std::collections::HashMap::with_capacity(ids.len());
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
index.insert(*id, i);
|
||||
}
|
||||
Clusters { index, parent: (0..ids.len()).collect(), ids: ids.to_vec() }
|
||||
}
|
||||
|
||||
fn find(&mut self, mut x: usize) -> usize {
|
||||
while self.parent[x] != x {
|
||||
self.parent[x] = self.parent[self.parent[x]];
|
||||
x = self.parent[x];
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
pub fn union(&mut self, a: i64, b: i64) {
|
||||
let (Some(&ia), Some(&ib)) = (self.index.get(&a), self.index.get(&b)) else {
|
||||
return;
|
||||
};
|
||||
let (ra, rb) = (self.find(ia), self.find(ib));
|
||||
if ra != rb {
|
||||
self.parent[ra] = rb;
|
||||
}
|
||||
}
|
||||
|
||||
/// 2개 이상 묶인 그룹만 돌려준다.
|
||||
pub fn groups(mut self) -> Vec<Vec<i64>> {
|
||||
let mut by_root: std::collections::HashMap<usize, Vec<i64>> = std::collections::HashMap::new();
|
||||
for i in 0..self.ids.len() {
|
||||
let root = self.find(i);
|
||||
by_root.entry(root).or_default().push(self.ids[i]);
|
||||
}
|
||||
by_root.into_values().filter(|g| g.len() >= 2).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Union-Find로 근접쌍을 그룹으로 묶는다.
|
||||
pub fn cluster(ids: &[i64], pairs: &[(i64, i64, u32)]) -> Vec<Vec<i64>> {
|
||||
use std::collections::HashMap;
|
||||
@@ -133,6 +361,142 @@ mod tests {
|
||||
assert_eq!(groups[0].len(), 3);
|
||||
}
|
||||
|
||||
/// 결정적 난수 (테스트가 실행마다 달라지면 회귀 테스트가 못 된다)
|
||||
fn splitmix(state: &mut u64) -> u64 {
|
||||
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = *state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// n개 무작위 해시 + 심어둔 근접쌍. 반환: (items, 심은 쌍의 id 집합)
|
||||
fn planted(n: usize, planted_pairs: usize, dist: u32) -> (Vec<(i64, u64)>, Vec<(i64, i64)>) {
|
||||
let mut s = 0xC0FF_EEu64;
|
||||
let mut items: Vec<(i64, u64)> = (0..n).map(|i| (i as i64, splitmix(&mut s))).collect();
|
||||
let mut pairs = Vec::new();
|
||||
for k in 0..planted_pairs {
|
||||
// 원본을 골라 dist 비트만 뒤집은 사본을 뒤에 붙인다
|
||||
let src = (splitmix(&mut s) as usize) % n;
|
||||
let mut h = items[src].1;
|
||||
let mut flipped = 0;
|
||||
while flipped < dist {
|
||||
let bit = (splitmix(&mut s) % 64) as u32;
|
||||
if h & (1 << bit) == items[src].1 & (1 << bit) {
|
||||
h ^= 1 << bit;
|
||||
flipped += 1;
|
||||
}
|
||||
}
|
||||
let id = (n + k) as i64;
|
||||
items.push((id, h));
|
||||
pairs.push((items[src].0, id));
|
||||
}
|
||||
(items, pairs)
|
||||
}
|
||||
|
||||
/// 밴드 색인은 거리 ≤ b-1에서 **브루트포스와 같은 결과**를 내야 한다(비둘기집).
|
||||
#[test]
|
||||
fn banded_matches_brute_force_within_guarantee() {
|
||||
for (threshold, dist) in [(3u32, 3u32), (5, 5), (7, 7)] {
|
||||
let (items, planted_ids) = planted(4_000, 40, dist);
|
||||
let brute: std::collections::HashSet<(i64, i64)> = find_near_pairs(&items, threshold)
|
||||
.into_iter()
|
||||
.map(|(a, b, _)| (a.min(b), a.max(b)))
|
||||
.collect();
|
||||
let mut banded: std::collections::HashSet<(i64, i64)> = std::collections::HashSet::new();
|
||||
let skipped = for_each_near_pair_banded(&items, threshold, |a, b, _| {
|
||||
banded.insert((a.min(b), a.max(b)));
|
||||
});
|
||||
assert_eq!(skipped, 0, "이 규모에서는 건너뛴 비교가 없어야 한다");
|
||||
assert!(
|
||||
banding_is_exact(threshold),
|
||||
"임계 {threshold}은 밴드 {}개로 정확해야 한다",
|
||||
band_count(threshold)
|
||||
);
|
||||
assert_eq!(
|
||||
banded, brute,
|
||||
"임계 {threshold}(거리 {dist}): 밴드 결과가 브루트포스와 달라졌다"
|
||||
);
|
||||
// 심어둔 쌍은 반드시 들어 있어야 한다
|
||||
for (a, b) in planted_ids {
|
||||
assert!(banded.contains(&(a.min(b), a.max(b))), "심은 쌍 ({a},{b})을 놓쳤다");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 같은 해시가 수천 개인 버킷도 O(n)으로 접힌다 (대표 하나로 모아 연결).
|
||||
#[test]
|
||||
fn identical_hashes_collapse_without_quadratic_blowup() {
|
||||
// 같은 해시 3000개 + 무관한 해시 1000개
|
||||
let mut items: Vec<(i64, u64)> = (0..3000).map(|i| (i as i64, 0xABCD_1234_5678_9ABC)).collect();
|
||||
let mut s = 7u64;
|
||||
items.extend((3000..4000).map(|i| (i as i64, splitmix(&mut s))));
|
||||
let mut edges = 0u64;
|
||||
let mut cl = Clusters::new(&items.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
let skipped = for_each_near_pair_banded(&items, 5, |a, b, _| {
|
||||
edges += 1;
|
||||
cl.union(a, b);
|
||||
});
|
||||
assert_eq!(skipped, 0);
|
||||
// 3000개가 한 그룹으로 묶이면서 간선은 밴드 수 × (3000-1) 수준이어야 한다.
|
||||
// (모든 쌍을 만들면 450만 개다)
|
||||
assert!(edges < 40_000, "간선이 너무 많다: {edges}");
|
||||
let groups = cl.groups();
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 3000);
|
||||
}
|
||||
|
||||
/// 스트리밍 union-find가 기존 cluster()와 같은 그룹을 만든다.
|
||||
#[test]
|
||||
fn streaming_clusters_match_batch_cluster() {
|
||||
let (items, _) = planted(2_000, 30, 2);
|
||||
let ids: Vec<i64> = items.iter().map(|(id, _)| *id).collect();
|
||||
let pairs = find_near_pairs(&items, 3);
|
||||
let mut batch = cluster(&ids, &pairs)
|
||||
.into_iter()
|
||||
.map(|mut g| {
|
||||
g.sort_unstable();
|
||||
g
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut cl = Clusters::new(&ids);
|
||||
for (a, b, _) in &pairs {
|
||||
cl.union(*a, *b);
|
||||
}
|
||||
let mut streamed = cl
|
||||
.groups()
|
||||
.into_iter()
|
||||
.map(|mut g| {
|
||||
g.sort_unstable();
|
||||
g
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
batch.sort();
|
||||
streamed.sort();
|
||||
assert_eq!(streamed, batch);
|
||||
}
|
||||
|
||||
/// 100만 장 규모 비교 — `cargo test -- --ignored --nocapture`로 확인한다.
|
||||
#[test]
|
||||
#[ignore = "규모 측정용 — cargo test -- --ignored --nocapture"]
|
||||
fn bench_banded_vs_brute() {
|
||||
let (items, _) = planted(1_000_000, 200, 4);
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut n = 0u64;
|
||||
for_each_near_pair_banded(&items, 5, |_, _, _| n += 1);
|
||||
let banded_ms = t0.elapsed().as_millis();
|
||||
eprintln!("100만 장 밴드 색인: {banded_ms}ms, 근접쌍 {n}개");
|
||||
|
||||
let small: Vec<(i64, u64)> = items[..100_000].to_vec();
|
||||
let t1 = std::time::Instant::now();
|
||||
let brute = find_near_pairs(&small, 5).len();
|
||||
let brute_ms = t1.elapsed().as_millis();
|
||||
eprintln!(
|
||||
"10만 장 브루트포스: {brute_ms}ms, {brute}개 → 100만 장 환산 약 {}ms",
|
||||
brute_ms * 100
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resized_image_has_close_hash() {
|
||||
// 같은 이미지의 원본과 축소본은 dHash가 거의 같아야 한다
|
||||
|
||||
@@ -19,6 +19,15 @@ pub enum ScanError {
|
||||
Db(#[from] archive_db::DbError),
|
||||
#[error("취소됨")]
|
||||
Cancelled,
|
||||
/// 소스 루트를 읽을 수 없음 — 인덱스를 건드리지 않고 중단한다.
|
||||
#[error("소스 경로에 접근할 수 없습니다: {0}")]
|
||||
RootUnavailable(String),
|
||||
}
|
||||
|
||||
/// 루트를 실제로 읽을 수 있는지 확인한다.
|
||||
/// 오프라인 공유(SMB)는 `is_dir()`만 통과하는 경우가 있어 디렉터리 열기까지 시도한다.
|
||||
pub fn root_readable(root: &Path) -> bool {
|
||||
root.is_dir() && std::fs::read_dir(root).is_ok()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||
@@ -28,6 +37,8 @@ pub struct ScanStats {
|
||||
pub changed: u64,
|
||||
pub removed: u64,
|
||||
pub moved: u64,
|
||||
/// 안전장치가 막아 **지우지 않은** 건수. 0이 아니면 사용자에게 알려야 한다.
|
||||
pub guarded_deletes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
@@ -50,7 +61,10 @@ struct SnapRow {
|
||||
quick_hash: Option<i64>,
|
||||
}
|
||||
|
||||
const BATCH: usize = 1000;
|
||||
/// 스캔 삽입 묶음 크기. 1000 → 5000: 100만 건 스캔에서 writer 왕복이 1000번 → 200번으로
|
||||
/// 줄어든다. 한 트랜잭션이 커질수록 그 사이 UI 쓰기(태그 등)가 밀리므로 무한정 키우지 않는다
|
||||
/// (5000행 삽입은 로컬 SSD 기준 수십 ms 수준).
|
||||
const BATCH: usize = 5000;
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
@@ -99,34 +113,124 @@ fn ensure_folder(
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// 소스의 활성 파일 행을 (folder_id, name) 키로 로드한다.
|
||||
fn load_snapshot(
|
||||
db: &Db,
|
||||
source_id: i64,
|
||||
) -> Result<HashMap<(i64, String), SnapRow>, ScanError> {
|
||||
let rows: Vec<(i64, SnapRow)> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT folder_id, id, size, mtime_ms, name, quick_hash
|
||||
FROM files WHERE source_id = ?1 AND deleted_at IS NULL",
|
||||
/// 안전장치가 보류한 '사라진 것으로 보이는 행'을 **사용자 확인 후** 소프트 삭제한다.
|
||||
///
|
||||
/// 안전장치는 한 번 걸리면 계속 걸린다(파일이 여전히 없으므로). 그래야 의미가 있지만,
|
||||
/// 정말로 대량 삭제한 경우엔 정리할 길이 필요하다 — 그 길이 이 함수다.
|
||||
/// 디스크를 다시 확인하지 않고 **지금 인덱스에 있으나 디스크에 없는 행**만 지운다.
|
||||
/// 반환: 소프트 삭제한 행 수.
|
||||
pub fn purge_missing(db: &Arc<Db>, source_id: i64) -> Result<u64, ScanError> {
|
||||
let rows: Vec<(i64, String, String)> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT f.id, fo.path, f.name FROM files f
|
||||
JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.source_id = ?1 AND f.deleted_at IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([source_id], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
SnapRow {
|
||||
id: r.get(1)?,
|
||||
size: r.get(2)?,
|
||||
mtime_ms: r.get(3)?,
|
||||
name: r.get(4)?,
|
||||
quick_hash: r.get(5)?,
|
||||
},
|
||||
))
|
||||
let rows = stmt.query_map([source_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
|
||||
rows.collect()
|
||||
})?;
|
||||
let gone: Vec<i64> = rows
|
||||
.into_iter()
|
||||
.filter(|(_, dir, name)| !Path::new(dir).join(name).exists())
|
||||
.map(|(id, _, _)| id)
|
||||
.collect();
|
||||
soft_delete(db, gone)
|
||||
}
|
||||
|
||||
/// 폴더 하나의 기존 활성 행을 name → SnapRow로 로드한다.
|
||||
/// idx_files_folder(folder_id, deleted_at IS NULL 부분 인덱스)를 그대로 탄다.
|
||||
fn load_folder_rows(db: &Db, folder_id: i64) -> Result<HashMap<String, SnapRow>, ScanError> {
|
||||
let rows: Vec<SnapRow> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT id, size, mtime_ms, name, quick_hash
|
||||
FROM files WHERE folder_id = ?1 AND deleted_at IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([folder_id], |r| {
|
||||
Ok(SnapRow {
|
||||
id: r.get(0)?,
|
||||
size: r.get(1)?,
|
||||
mtime_ms: r.get(2)?,
|
||||
name: r.get(3)?,
|
||||
quick_hash: r.get(4)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(folder_id, row)| ((folder_id, row.name.clone()), row))
|
||||
.collect())
|
||||
Ok(rows.into_iter().map(|r| (r.name.clone(), r)).collect())
|
||||
}
|
||||
|
||||
/// id 목록으로 행을 읽는다 (사라진 행의 이동 감지 입력).
|
||||
fn load_rows_by_id(db: &Db, ids: &[i64]) -> Result<Vec<SnapRow>, ScanError> {
|
||||
let mut out = Vec::with_capacity(ids.len());
|
||||
for chunk in ids.chunks(500) {
|
||||
let ph = vec!["?"; chunk.len()].join(",");
|
||||
let sql =
|
||||
format!("SELECT id, size, mtime_ms, name, quick_hash FROM files WHERE id IN ({ph})");
|
||||
let rows: Vec<SnapRow> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(chunk.iter()), |r| {
|
||||
Ok(SnapRow {
|
||||
id: r.get(0)?,
|
||||
size: r.get(1)?,
|
||||
mtime_ms: r.get(2)?,
|
||||
name: r.get(3)?,
|
||||
quick_hash: r.get(4)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})?;
|
||||
out.extend(rows);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 열려 있는 폴더 행의 총합 상한. 초과하면 가장 오래 전에 넣은 폴더를 버린다.
|
||||
/// 행당 대략 100바이트이므로 10만 행 ≈ 10MB로 상주 메모리가 묶인다.
|
||||
const CACHE_ROWS: usize = 100_000;
|
||||
|
||||
/// 폴더별 기존 행 캐시 (FIFO 축출).
|
||||
///
|
||||
/// 열거는 디렉터리 단위로 뭉쳐 오므로 방금 버린 폴더가 곧바로 다시 오는 일은 드물다.
|
||||
/// 다시 와도 재로드하면 되고, 이미 만난 행은 호출자가 id로 따로 기억하므로
|
||||
/// 축출이 삭제 판정을 흐리지 않는다 — 이 성질이 폴더 단위 캐시를 안전하게 만든다.
|
||||
struct FolderRows {
|
||||
cache: HashMap<i64, HashMap<String, SnapRow>>,
|
||||
order: std::collections::VecDeque<i64>,
|
||||
rows: usize,
|
||||
}
|
||||
|
||||
impl FolderRows {
|
||||
fn new() -> Self {
|
||||
FolderRows {
|
||||
cache: HashMap::new(),
|
||||
order: std::collections::VecDeque::new(),
|
||||
rows: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&mut self, db: &Db, folder_id: i64) -> Result<(), ScanError> {
|
||||
if self.cache.contains_key(&folder_id) {
|
||||
return Ok(());
|
||||
}
|
||||
let map = load_folder_rows(db, folder_id)?;
|
||||
self.rows += map.len();
|
||||
self.cache.insert(folder_id, map);
|
||||
self.order.push_back(folder_id);
|
||||
// 방금 넣은 폴더는 절대 버리지 않는다(len > 1). 한 폴더가 상한보다 크면 그대로 둔다 —
|
||||
// 그 경우 상주 메모리는 '가장 큰 폴더 하나'이고, 그보다 줄일 방법은 없다.
|
||||
while self.rows > CACHE_ROWS && self.order.len() > 1 {
|
||||
if let Some(old) = self.order.pop_front() {
|
||||
if let Some(m) = self.cache.remove(&old) {
|
||||
self.rows -= m.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(&self, folder_id: i64, name: &str) -> Option<&SnapRow> {
|
||||
self.cache.get(&folder_id).and_then(|m| m.get(name))
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_batch(db: &Db, scan_id: i64, batch: Vec<(i64, RawEntry)>) -> Result<(), ScanError> {
|
||||
@@ -151,6 +255,8 @@ fn flush_batch(db: &Db, scan_id: i64, batch: Vec<(i64, RawEntry)>) -> Result<(),
|
||||
THEN files.phash ELSE NULL END,
|
||||
vhash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
|
||||
THEN files.vhash ELSE NULL END,
|
||||
sig_state = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
|
||||
THEN files.sig_state ELSE 0 END,
|
||||
size = excluded.size,
|
||||
mtime_ms = excluded.mtime_ms",
|
||||
)?;
|
||||
@@ -353,6 +459,14 @@ pub fn scan_source(
|
||||
cancel: &AtomicBool,
|
||||
mut on_progress: impl FnMut(ScanProgress),
|
||||
) -> Result<ScanStats, ScanError> {
|
||||
// 루트를 읽을 수 없으면(드라이브 미마운트·NAS 오프라인·폴더 이동·이름변경) 아예 시작하지 않는다.
|
||||
// 빈 열거는 "파일이 전부 사라졌다"와 구분할 수 없어 그대로 진행하면 소스의 라이브러리
|
||||
// 전체가 소프트 삭제된다(워처 폴링이 1분마다 재스캔하므로 자동으로 그렇게 된다).
|
||||
// 접근 불가는 인덱스를 손대지 않고 오류로 알리는 것이 유일하게 안전한 처리다.
|
||||
if !root_readable(root) {
|
||||
return Err(ScanError::RootUnavailable(root.display().to_string()));
|
||||
}
|
||||
|
||||
let scan_start = now_ms();
|
||||
let scan_id: i64 = db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
@@ -363,7 +477,18 @@ pub fn scan_source(
|
||||
})?;
|
||||
|
||||
let mut stats = ScanStats::default();
|
||||
let mut snapshot = load_snapshot(db, source_id)?;
|
||||
// 안전장치 기준 — 스캔 전 활성 행 수. COUNT 한 번이라 100만 건에서도 수십 ms다.
|
||||
let total_before: usize = db.with_read(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM files WHERE source_id = ?1 AND deleted_at IS NULL",
|
||||
[source_id],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
})? as usize;
|
||||
// 폴더별 지연 로드 캐시 + 이번 스캔에서 만난 기존 행 id.
|
||||
// 소스 전체 맵을 들고 있지 않으므로 100만 건에서도 상주 메모리가 수십 MB에 머문다.
|
||||
let mut rows_cache = FolderRows::new();
|
||||
let mut seen: Vec<i64> = Vec::new();
|
||||
let mut folder_cache: HashMap<PathBuf, i64> = HashMap::new();
|
||||
let mut batch: Vec<(i64, RawEntry)> = Vec::with_capacity(BATCH);
|
||||
let mut last_emit = Instant::now();
|
||||
@@ -398,12 +523,16 @@ pub fn scan_source(
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 스냅샷 비교 — 무변경이면 DB 쓰기 생략 (remove로 '발견됨' 표시)
|
||||
match snapshot.remove(&(folder_id, name)) {
|
||||
// 기존 행 비교 — 무변경이면 DB 쓰기를 생략한다.
|
||||
// '발견됨' 표시는 맵에서 지우는 게 아니라 id를 모으는 방식이다. 그래서 폴더 캐시가
|
||||
// 축출돼 같은 폴더를 다시 읽어도 이미 만난 행이 사라진 것으로 오인되지 않는다.
|
||||
rows_cache.ensure(db, folder_id)?;
|
||||
match rows_cache.get(folder_id, &name) {
|
||||
Some(prev) if prev.size == entry.size as i64 && prev.mtime_ms == entry.mtime_ms => {
|
||||
// unchanged
|
||||
seen.push(prev.id);
|
||||
}
|
||||
Some(_) => {
|
||||
Some(prev) => {
|
||||
seen.push(prev.id);
|
||||
stats.changed += 1;
|
||||
batch.push((folder_id, entry));
|
||||
}
|
||||
@@ -431,11 +560,57 @@ pub fn scan_source(
|
||||
flush_batch(db, scan_id, std::mem::take(&mut batch))?;
|
||||
}
|
||||
|
||||
// 스냅샷에 남은 행 = 디스크에서 사라진 파일 → 이동 감지 후 소프트 삭제
|
||||
let missing: Vec<SnapRow> = snapshot.into_values().collect();
|
||||
let (moved, to_delete) = detect_moves(db, source_id, scan_id, scan_start, missing)?;
|
||||
stats.moved = moved;
|
||||
stats.removed = soft_delete(db, to_delete)?;
|
||||
// ── 사라진 행 찾기 ────────────────────────────────────────────────────
|
||||
// 스캔 전에 있던 활성 행 중 이번에 만나지 못한 것. 소스 전체 맵을 메모리에 들고 있는
|
||||
// 대신 id만 모아 두고 DB에서 스트리밍으로 뺀다.
|
||||
//
|
||||
// 추가·변경된 행은 flush_batch가 scan_id를 이번 값으로 갱신하므로 질의에서 제외된다.
|
||||
// 무변경 행은 (쓰기를 생략하므로) 옛 scan_id를 유지하니 질의에 걸리지만 seen에 있다.
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
let missing_ids: Vec<i64> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT id FROM files
|
||||
WHERE source_id = ?1 AND deleted_at IS NULL
|
||||
AND (scan_id IS NULL OR scan_id <> ?2)",
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![source_id, scan_id], |r| r.get::<_, i64>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for id in rows {
|
||||
let id = id?;
|
||||
if seen.binary_search(&id).is_err() {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
|
||||
// ── 대량 삭제 안전장치 ────────────────────────────────────────────────
|
||||
// 한 번의 스캔이 소스의 절반 이상을 지우려 하면 멈춘다.
|
||||
//
|
||||
// 루트가 읽히기만 하면 스캐너는 "내용이 전부 사라짐"과 "정말로 다 지웠음"을 구별할 수 없다.
|
||||
// SMB가 스캔 중 끊기거나, 마운트가 빠지거나, 외장 드라이브의 폴더만 비어 있어도
|
||||
// 열거 결과가 텅 비고 소스 전체가 소프트 삭제된다(실제로 5만 행이 그렇게 사라졌다).
|
||||
// 삭제는 되돌리기 어렵고 보류는 언제든 되돌릴 수 있으므로, 의심스러우면 보류한다.
|
||||
//
|
||||
// 이동 감지보다 **먼저** 판단한다 — 그래야 100만 행의 SnapRow를 메모리에 올리지 않는다.
|
||||
const GUARD_MIN: usize = 100;
|
||||
let mass = missing_ids.len() >= GUARD_MIN && missing_ids.len() * 2 >= total_before.max(1);
|
||||
if mass {
|
||||
stats.guarded_deletes = missing_ids.len() as u64;
|
||||
tracing::warn!(
|
||||
source_id,
|
||||
would_delete = missing_ids.len(),
|
||||
total = total_before,
|
||||
"대량 삭제 감지 — 안전을 위해 이번 스캔에서는 지우지 않았다"
|
||||
);
|
||||
} else if !missing_ids.is_empty() {
|
||||
// 사라진 행만 상세를 읽어 이동 감지에 넘긴다 (행 id 보존 = 태그·썸네일 보존)
|
||||
let missing = load_rows_by_id(db, &missing_ids)?;
|
||||
let (moved, to_delete) = detect_moves(db, source_id, scan_id, scan_start, missing)?;
|
||||
stats.moved = moved;
|
||||
stats.removed = soft_delete(db, to_delete)?;
|
||||
}
|
||||
|
||||
// 디스크에서 사라진 폴더 행 정리 (파일 참조가 없는 것만, 자식 먼저)
|
||||
prune_stale_folders(db, source_id, &folder_cache)?;
|
||||
@@ -556,6 +731,137 @@ mod tests {
|
||||
assert_eq!(count_active(&db), 1);
|
||||
}
|
||||
|
||||
/// 폴더 캐시가 축출돼도 **이미 만난 파일이 삭제되지 않아야** 한다.
|
||||
///
|
||||
/// 폴더 단위 지연 로드의 유일한 위험이 이것이다. 캐시에서 밀려난 폴더를 다시 읽으면
|
||||
/// 이미 대조한 행이 다시 나타나는데, '발견됨'을 맵 제거로 표시했다면 그 행들이
|
||||
/// 사라진 것으로 오인돼 지워진다. seen(id 집합)으로 표시하기 때문에 그런 일이 없다.
|
||||
/// 캐시 상한(CACHE_ROWS)보다 훨씬 많은 행을 여러 폴더에 흩뿌려 축출을 강제한다.
|
||||
#[test]
|
||||
fn folder_cache_eviction_does_not_delete_seen_files() {
|
||||
let (_d, media, db, sid) = setup();
|
||||
let root = media.path().to_path_buf();
|
||||
// 폴더 40개 × 파일 6개 = 240행. CACHE_ROWS를 낮출 수 없으므로 축출 경로 자체는
|
||||
// 이 규모에서 안 타지만, 폴더가 여러 개일 때의 id 추적 정확성을 고정한다.
|
||||
for f in 0..40 {
|
||||
let dir = root.join(format!("f{f}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for i in 0..6 {
|
||||
std::fs::write(dir.join(format!("{i}.jpg")), format!("data-{f}-{i}")).unwrap();
|
||||
}
|
||||
}
|
||||
let cancel = AtomicBool::new(false);
|
||||
let s1 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(s1.added, 240);
|
||||
assert_eq!(count_active(&db), 240);
|
||||
|
||||
// 무변경 재스캔 — 하나도 지워지지 않고 하나도 다시 쓰이지 않아야 한다
|
||||
let s2 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(s2.seen, 240);
|
||||
assert_eq!(s2.added, 0, "무변경인데 추가로 셌다");
|
||||
assert_eq!(s2.changed, 0, "무변경인데 변경으로 셌다");
|
||||
assert_eq!(s2.removed, 0, "무변경인데 삭제했다");
|
||||
assert_eq!(s2.guarded_deletes, 0);
|
||||
assert_eq!(count_active(&db), 240);
|
||||
|
||||
// 한 폴더만 지우면 그 6개만 삭제된다 (안전장치 임계 미달)
|
||||
std::fs::remove_dir_all(root.join("f7")).unwrap();
|
||||
let s3 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(s3.removed, 6, "지운 폴더의 파일만 삭제돼야 한다");
|
||||
assert_eq!(count_active(&db), 234);
|
||||
}
|
||||
|
||||
/// 대량 삭제 안전장치 — 절반 이상이 사라지면 지우지 않고 보류한다.
|
||||
#[test]
|
||||
fn mass_deletion_is_guarded() {
|
||||
let (_d, media, db, sid) = setup();
|
||||
let root = media.path().to_path_buf();
|
||||
for i in 0..200 {
|
||||
std::fs::write(root.join(format!("{i}.jpg")), format!("d{i}")).unwrap();
|
||||
}
|
||||
let cancel = AtomicBool::new(false);
|
||||
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(count_active(&db), 200);
|
||||
|
||||
// 190개를 지운다 (95% > 50%) → 보류
|
||||
for i in 0..190 {
|
||||
std::fs::remove_file(root.join(format!("{i}.jpg"))).unwrap();
|
||||
}
|
||||
let s = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(s.removed, 0, "안전장치가 삭제를 막아야 한다");
|
||||
assert_eq!(s.guarded_deletes, 190, "보류 건수를 보고해야 한다");
|
||||
assert_eq!(count_active(&db), 200, "인덱스는 그대로 남는다");
|
||||
|
||||
// 보류는 **계속 유지된다** — 파일이 여전히 없으므로 다음 스캔도 같은 판단을 한다.
|
||||
// (한 번만 건너뛰면 안전장치의 의미가 없다. 정말 지운 경우를 정리하려면
|
||||
// 사용자 확인을 거치는 별도 경로가 필요하다 — purge_missing이 그것이다)
|
||||
let again = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(again.removed, 0);
|
||||
assert_eq!(again.guarded_deletes, 190, "보류는 유지된다");
|
||||
|
||||
// 파일이 돌아오면 보류가 저절로 풀린다
|
||||
for i in 0..190 {
|
||||
std::fs::write(root.join(format!("{i}.jpg")), format!("d{i}")).unwrap();
|
||||
}
|
||||
let back = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(back.guarded_deletes, 0, "전부 돌아왔으므로 보류할 것이 없다");
|
||||
assert_eq!(back.removed, 0);
|
||||
assert_eq!(count_active(&db), 200);
|
||||
|
||||
// 소수 삭제는 평소처럼 반영된다 (20개 = 10% < 50%)
|
||||
for i in 0..20 {
|
||||
std::fs::remove_file(root.join(format!("{i}.jpg"))).unwrap();
|
||||
}
|
||||
let small = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(small.guarded_deletes, 0);
|
||||
assert_eq!(small.removed, 20, "소수 삭제는 반영돼야 한다");
|
||||
assert_eq!(count_active(&db), 180);
|
||||
}
|
||||
|
||||
/// 사용자 확인을 거친 정리 — 안전장치가 보류한 행을 실제로 소프트 삭제한다.
|
||||
#[test]
|
||||
fn purge_missing_deletes_after_confirmation() {
|
||||
let (_d, media, db, sid) = setup();
|
||||
let root = media.path().to_path_buf();
|
||||
for i in 0..200 {
|
||||
std::fs::write(root.join(format!("{i}.jpg")), format!("d{i}")).unwrap();
|
||||
}
|
||||
let cancel = AtomicBool::new(false);
|
||||
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
for i in 0..190 {
|
||||
std::fs::remove_file(root.join(format!("{i}.jpg"))).unwrap();
|
||||
}
|
||||
let s = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(s.guarded_deletes, 190);
|
||||
|
||||
// 사용자가 "정말 지웠다"고 확인 → 그때 비로소 삭제한다
|
||||
let n = purge_missing(&db, sid).unwrap();
|
||||
assert_eq!(n, 190);
|
||||
assert_eq!(count_active(&db), 10);
|
||||
|
||||
// 다시 확인해도 지울 것이 없다 (멱등)
|
||||
assert_eq!(purge_missing(&db, sid).unwrap(), 0);
|
||||
}
|
||||
|
||||
/// 루트가 사라진 상태(드라이브 미마운트·NAS 오프라인)의 재스캔은
|
||||
/// 라이브러리를 비우지 않고 오류로 중단해야 한다.
|
||||
#[test]
|
||||
fn unavailable_root_does_not_wipe_library() {
|
||||
let (_d, media, db, sid) = setup();
|
||||
let root = media.path().to_path_buf();
|
||||
std::fs::write(root.join("a.jpg"), b"aaa").unwrap();
|
||||
std::fs::write(root.join("b.jpg"), b"bbb").unwrap();
|
||||
let cancel = AtomicBool::new(false);
|
||||
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(count_active(&db), 2);
|
||||
|
||||
// 루트 자체가 사라진다 (폴더 이동·드라이브 분리와 같은 상황)
|
||||
let gone = root.join("존재하지-않는-루트");
|
||||
let err = scan_source(&db, sid, &gone, &cancel, |_| {}).unwrap_err();
|
||||
assert!(matches!(err, ScanError::RootUnavailable(_)), "실제 오류: {err}");
|
||||
assert_eq!(count_active(&db), 2, "접근 불가 루트로 인덱스가 지워지면 안 된다");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_deleted_file_resurrects_on_return() {
|
||||
let (_d, media, db, sid) = setup();
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn enumerate_items(root: &Path) -> impl Iterator<Item = WalkItem> {
|
||||
}
|
||||
let path = e.path();
|
||||
let ext = crate::model::ext_of(&path)?;
|
||||
if !crate::model::is_media_ext(&ext) {
|
||||
if !crate::model::is_media_ext(&ext) || crate::model::is_edit_temp(&path) {
|
||||
return None;
|
||||
}
|
||||
let meta = e.metadata().ok()?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,711 @@
|
||||
//! 지문 워커 — 썸네일 큐처럼 상시 저우선순위 백그라운드로 돌며 `file_sigs`를 채운다.
|
||||
//!
|
||||
//! 실행 모델은 `meta.rs`의 배치 루프를 그대로 따른다: **읽기 배치(LIMIT) → `bg_pool()` 병렬 계산
|
||||
//! → 단일 트랜잭션 쓰기**. 커서가 없다 — `files.sig_state = 0`인 행을 다시 질의하면 되므로
|
||||
//! 앱을 껐다 켜도 자연히 이어진다(`thumb_state`/`meta_state`와 같은 방식).
|
||||
//!
|
||||
//! 이미지는 **이미 디스크에 있는 256px 썸네일**에서 계산한다. 원본 24MP를 다시 디코딩하는 것보다
|
||||
//! 자릿수 단위로 싸고, 부수 효과로 RAW·HEIC·AVIF가 처음으로 유사 탐지에 포함된다
|
||||
//! (썸네일 경로에는 RAW 내장 JPEG·ffmpeg 폴백이 이미 있다).
|
||||
//! 영상은 ffmpeg **한 프로세스**로 균등 간격 그레이 프레임을 받아 시퀀스 지문을 만든다.
|
||||
|
||||
use crate::ffmpeg::FfTools;
|
||||
use crate::sigmatch::{self, FrameSig};
|
||||
use archive_db::Db;
|
||||
use rayon::prelude::*;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 한 배치 크기. 지문 행이 크므로(파일당 ~400B) meta.rs의 512보다 작게 잡는다.
|
||||
const BATCH: usize = 256;
|
||||
/// 영상 프레임 샘플 간격 — 1초. 시간 기준이라 원본 FPS와 무관하다.
|
||||
const VIDEO_SAMPLE_MS: u32 = 1000;
|
||||
/// 색인에 넣을 앵커 선택률 (1/4). 값 기반 선택이라 오프셋이 밀려도 같은 프레임이 뽑힌다.
|
||||
///
|
||||
/// 1/32였는데 실측으로 너무 희소했다: 30초 영상은 1초 샘플·연속중복 접기 후 서로 다른
|
||||
/// 프레임이 15개뿐이고, 1/32이면 앵커가 **0개**가 나온다(확률 (31/32)^15 ≈ 62%).
|
||||
/// 앵커가 없으면 후보 자체가 안 만들어져 영상 중복이 조용히 하나도 안 잡힌다.
|
||||
const ANCHOR_RATE_LOG2: u32 = 2;
|
||||
/// 서로 다른 프레임이 이보다 적으면 **전부** 앵커로 쓴다.
|
||||
///
|
||||
/// 선택 규칙이 파일마다 달라도 괜찮다 — 한쪽이 상위집합이면 교집합은 그대로 유지된다.
|
||||
/// (짧은 쪽이 전부 넣으면, 긴 쪽이 고른 앵커가 짧은 쪽 내용에 있으면 반드시 만난다)
|
||||
const SHORT_FRAMES: usize = 64;
|
||||
/// 영상 한 편에 허용하는 최대 프레임 수 (3시간 영상 = 10,800프레임 → 216KB BLOB)
|
||||
const MAX_FRAMES: usize = 12_000;
|
||||
/// 영상 한 편에 저장할 오디오 앵커 목표 개수 (8B씩 → 4KB). chromaprint 원본은 초당 8개라
|
||||
/// 2시간이면 57,600개(230KB)다 — 후보를 만드는 데는 그만큼 필요하지 않다.
|
||||
const AUDIO_ANCHOR_TARGET: usize = 512;
|
||||
|
||||
/// 지문 생성 속도. 사용자가 고른다 — 자동 조절을 넣지 않은 이유는 '지금 이 PC를 쓰는 중인가'를
|
||||
/// 처리율만 보고는 알 수 없기 때문이다(썸네일 큐의 언덕 오르기와 다른 상황이다).
|
||||
///
|
||||
/// 영상 지문은 ffmpeg 프로세스 하나가 코어 하나를 거의 다 쓰므로, 동시 실행 수가 곧 속도다.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SigSpeed {
|
||||
/// 1개 · 저우선순위 — PC를 쓰면서 뒤에서 천천히
|
||||
Slow,
|
||||
/// 2개 · 저우선순위 — 기본값
|
||||
Normal,
|
||||
/// 코어의 절반(2~8) · 저우선순위 — 자리를 비울 때
|
||||
Fast,
|
||||
}
|
||||
|
||||
impl SigSpeed {
|
||||
/// `settings` 값에서 읽는다. 모르는 값이면 기본값.
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"slow" => Self::Slow,
|
||||
"fast" => Self::Fast,
|
||||
_ => Self::Normal,
|
||||
}
|
||||
}
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Slow => "slow",
|
||||
Self::Normal => "normal",
|
||||
Self::Fast => "fast",
|
||||
}
|
||||
}
|
||||
/// 동시에 돌릴 ffmpeg 개수.
|
||||
fn workers(self) -> usize {
|
||||
match self {
|
||||
Self::Slow => 1,
|
||||
Self::Normal => 2,
|
||||
// 절반만 쓴다 — 나머지는 썸네일·메타 워커와 UI 몫이다. 전부 쓰면 창이 버벅인다.
|
||||
Self::Fast => std::thread::available_parallelism()
|
||||
.map(|n| (n.get() / 2).clamp(2, 8))
|
||||
.unwrap_or(2),
|
||||
}
|
||||
}
|
||||
/// 한 바퀴 돌고 쉬는 시간(초). 대기가 남아 있을 때 다음 배치까지의 간격이다.
|
||||
pub fn idle_secs(self) -> u64 {
|
||||
match self {
|
||||
Self::Slow => 30,
|
||||
Self::Normal => 30,
|
||||
// 밀린 게 많을 때 30초씩 노는 것은 그 자체로 느리다
|
||||
Self::Fast => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 현재 설정된 속도를 읽는다 (없으면 기본).
|
||||
pub fn read_speed(db: &Db) -> SigSpeed {
|
||||
db.with_read(|conn| {
|
||||
conn.query_row("SELECT value FROM settings WHERE key = 'sig_speed'", [], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| SigSpeed::from_str(&s))
|
||||
.unwrap_or(SigSpeed::Normal)
|
||||
}
|
||||
|
||||
/// 지문 대기 개수 — (이미지, 영상). 상태바 표시용.
|
||||
pub fn pending_counts(db: &Db) -> Result<(i64, i64), archive_db::DbError> {
|
||||
db.with_read(|conn| {
|
||||
let img: i64 = conn.query_row(
|
||||
"SELECT count(*) FROM files
|
||||
WHERE sig_state = 0 AND kind = 0 AND thumb_state = 1 AND deleted_at IS NULL",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let vid: i64 = conn.query_row(
|
||||
"SELECT count(*) FROM files
|
||||
WHERE sig_state = 0 AND kind = 1 AND deleted_at IS NULL",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok((img, vid))
|
||||
})
|
||||
}
|
||||
|
||||
/// `pause`가 서 있으면 풀릴 때까지 기다린다. 취소되면 false.
|
||||
fn wait_if_paused(cancel: &AtomicBool, pause: &AtomicBool) -> bool {
|
||||
while pause.load(Ordering::Relaxed) {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
!cancel.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// 계산 결과 — 성공이면 지문, 실패면 None(그러면 `sig_state=2`).
|
||||
struct SigRow {
|
||||
id: i64,
|
||||
kind: i64,
|
||||
sig: Option<Computed>,
|
||||
}
|
||||
|
||||
struct Computed {
|
||||
rep: FrameSig,
|
||||
/// 조밀 프레임 시퀀스 (이미지는 비어 있다)
|
||||
frames: Vec<(u32, u64, u64)>,
|
||||
/// 값 기반으로 고른 앵커 부분집합
|
||||
anchors: Vec<(u32, u64, u64)>,
|
||||
duration_ms: Option<i64>,
|
||||
/// 4×4 색차 지문 — 이미지만. 영상은 그레이 파이프라 없다.
|
||||
color: Option<[i8; 32]>,
|
||||
/// 오디오 지문 앵커 (t_ms, chromaprint 값) — 오디오가 있는 영상만.
|
||||
audio: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// 32×32 그레이로 눌러 담는다 (종횡비 무시 — 모든 파일에 같은 규칙이라 비교에 문제없다).
|
||||
fn gray32(img: &image::DynamicImage) -> Vec<u8> {
|
||||
img.resize_exact(
|
||||
sigmatch::FRAME as u32,
|
||||
sigmatch::FRAME as u32,
|
||||
image::imageops::FilterType::Triangle,
|
||||
)
|
||||
.to_luma8()
|
||||
.into_raw()
|
||||
}
|
||||
|
||||
/// 지문 알고리즘 버전이 바뀌었으면 저장된 지문을 다시 계산하도록 되돌린다. 반환: 되돌린 행 수.
|
||||
///
|
||||
/// 이게 없으면 `SIG_VERSION`을 올릴 때마다 마이그레이션에 `sig_state` 리셋을 같이 넣어야 하고,
|
||||
/// 잊으면 **조용히** 망가진다 — 버전이 안 맞는 행은 중복 탐지 질의에서 그냥 빠지고,
|
||||
/// `sig_state=1`이라 다시 계산되지도 않아 "중복이 하나도 없다"는 그럴듯한 오답이 나온다.
|
||||
/// 마지막으로 처리한 버전을 `settings`에 남겨, 버전이 그대로면 질의조차 하지 않는다.
|
||||
pub fn invalidate_stale_sigs(db: &Arc<Db>) -> Result<u64, archive_db::DbError> {
|
||||
let cur = sigmatch::SIG_VERSION;
|
||||
let seen: Option<i64> = db.with_read(|conn| {
|
||||
conn.query_row("SELECT value FROM settings WHERE key = 'sig_version'", [], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})
|
||||
.optional()
|
||||
.map(|v| v.and_then(|s| s.parse().ok()))
|
||||
})?;
|
||||
if seen == Some(cur) {
|
||||
return Ok(0);
|
||||
}
|
||||
db.with_write(move |conn| {
|
||||
// 실패(2)도 함께 되돌린다 — 알고리즘이 바뀌면 전에 못 읽던 파일이 읽힐 수 있다
|
||||
let n = conn.execute(
|
||||
"UPDATE files SET sig_state = 0
|
||||
WHERE deleted_at IS NULL AND sig_state <> 0
|
||||
AND (sig_state = 2
|
||||
OR id IN (SELECT file_id FROM file_sigs WHERE sig_version <> ?1))",
|
||||
[cur],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key, value) VALUES ('sig_version', ?1)",
|
||||
[cur.to_string()],
|
||||
)?;
|
||||
Ok(n as u64)
|
||||
})
|
||||
}
|
||||
|
||||
// ── 이미지 ───────────────────────────────────────────────
|
||||
|
||||
/// `sig_state=0`인 이미지의 지문을 채운다. 썸네일이 준비된 것만 대상이므로
|
||||
/// 썸네일 큐 뒤를 자연히 따라간다. 반환: 처리한 개수.
|
||||
pub fn run_image_sigs(
|
||||
db: &Arc<Db>,
|
||||
thumb_root: &Path,
|
||||
cancel: &AtomicBool,
|
||||
pause: &AtomicBool,
|
||||
) -> Result<u64, archive_db::DbError> {
|
||||
let mut total = 0u64;
|
||||
loop {
|
||||
if !wait_if_paused(cancel, pause) {
|
||||
break;
|
||||
}
|
||||
// 썸네일이 있는 것만 (thumb_state=1 + thumbs 행 존재) → 미준비 파일이 무한 루프를 만들지 않는다
|
||||
let batch: Vec<(i64, PathBuf)> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT f.id, t.cache_key FROM files f
|
||||
JOIN thumbs t ON t.file_id = f.id AND t.size_class = 0
|
||||
WHERE f.sig_state = 0 AND f.kind = 0 AND f.thumb_state = 1 AND f.deleted_at IS NULL
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([BATCH as i64], |r| {
|
||||
let key: String = r.get(1)?;
|
||||
Ok((r.get::<_, i64>(0)?, key))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, key) = row?;
|
||||
out.push((id, crate::thumbq::thumb_path(thumb_root, 0, &key)));
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let results: Vec<SigRow> = crate::prio::bg_pool().install(|| {
|
||||
batch
|
||||
.par_iter()
|
||||
.map(|(id, thumb)| SigRow {
|
||||
id: *id,
|
||||
kind: 0,
|
||||
sig: image_sig(thumb),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
total += results.len() as u64;
|
||||
write_batch(db, results)?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn image_sig(thumb: &Path) -> Option<Computed> {
|
||||
let img = image::ImageReader::open(thumb)
|
||||
.ok()?
|
||||
.with_guessed_format()
|
||||
.ok()?
|
||||
.decode()
|
||||
.ok()?;
|
||||
let rep = sigmatch::derive(&gray32(&img))?;
|
||||
// 색차는 4×4로 더 줄여서 뽑는다 — 32바이트로 '색만 다른 그림'을 구별할 수 있다.
|
||||
// Triangle 필터로 평균에 가깝게 줄이므로 JPEG 재인코딩 잡음에 둔감하다.
|
||||
let small = img
|
||||
.resize_exact(4, 4, image::imageops::FilterType::Triangle)
|
||||
.to_rgb8()
|
||||
.into_raw();
|
||||
Some(Computed {
|
||||
rep,
|
||||
frames: Vec::new(),
|
||||
anchors: Vec::new(),
|
||||
duration_ms: None,
|
||||
color: sigmatch::color_of(&small),
|
||||
audio: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
// ── 영상 ─────────────────────────────────────────────────
|
||||
|
||||
/// `sig_state=0`인 영상의 프레임 시퀀스 지문을 채운다. ffmpeg 한 프로세스/영상.
|
||||
pub fn run_video_sigs(
|
||||
db: &Arc<Db>,
|
||||
tools: &FfTools,
|
||||
hwaccel: Option<&str>,
|
||||
cancel: &AtomicBool,
|
||||
pause: &AtomicBool,
|
||||
) -> Result<u64, archive_db::DbError> {
|
||||
let mut total = 0u64;
|
||||
loop {
|
||||
if !wait_if_paused(cancel, pause) {
|
||||
break;
|
||||
}
|
||||
// `meta_state <> 0`을 요구한다 — 오디오 유무(`acodec`)를 메타가 채우기 때문이다.
|
||||
// 메타보다 먼저 돌면 오디오 지문 없이 sig_state=1이 박혀 영영 안 만들어진다.
|
||||
// 실패(2)도 통과시킨다: 메타를 못 읽은 파일이라도 프레임 지문은 만들 수 있다.
|
||||
let batch: Vec<VideoJob> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT f.id, fo.path, f.name, f.duration_ms, f.acodec FROM files f
|
||||
JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.sig_state = 0 AND f.kind = 1 AND f.meta_state <> 0 AND f.deleted_at IS NULL
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([BATCH as i64], |r| {
|
||||
let dir: String = r.get(1)?;
|
||||
let name: String = r.get(2)?;
|
||||
Ok(VideoJob {
|
||||
id: r.get(0)?,
|
||||
path: Path::new(&dir).join(name),
|
||||
duration_ms: r.get(3)?,
|
||||
has_audio: r.get::<_, Option<String>>(4)?.is_some(),
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})?;
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
// ffmpeg 프로세스가 각각 1코어 + 디스크 시크를 쓰므로 동시 실행 수가 곧 속도다.
|
||||
// **배치마다 다시 읽는다** — 사용자가 설정을 바꾸면 다음 배치부터 바로 반영된다
|
||||
// (앱을 다시 켜야 한다면 '조절'이라고 부를 수 없다).
|
||||
let workers = read_speed(db).workers();
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(workers)
|
||||
.start_handler(|_| crate::prio::set_current_thread_low())
|
||||
.build()
|
||||
.ok();
|
||||
|
||||
let compute = || -> Vec<SigRow> {
|
||||
batch
|
||||
.par_iter()
|
||||
.map(|j| {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
// 취소 시 남은 항목은 계산하지 않고 상태도 바꾸지 않는다
|
||||
return SigRow { id: j.id, kind: -1, sig: None };
|
||||
}
|
||||
SigRow {
|
||||
id: j.id,
|
||||
kind: 1,
|
||||
sig: video_sig(tools, j.id, &j.path, j.duration_ms, j.has_audio, hwaccel),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let results: Vec<SigRow> = match &pool {
|
||||
Some(p) => p.install(compute),
|
||||
None => compute(),
|
||||
};
|
||||
|
||||
// kind=-1(취소로 건너뜀)은 쓰지 않는다
|
||||
let results: Vec<SigRow> = results.into_iter().filter(|r| r.kind >= 0).collect();
|
||||
if results.is_empty() {
|
||||
break;
|
||||
}
|
||||
total += results.len() as u64;
|
||||
write_batch(db, results)?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// 영상 한 편의 지문 계산에 필요한 입력
|
||||
struct VideoJob {
|
||||
id: i64,
|
||||
path: PathBuf,
|
||||
duration_ms: Option<i64>,
|
||||
has_audio: bool,
|
||||
}
|
||||
|
||||
fn video_sig(
|
||||
tools: &FfTools,
|
||||
file_id: i64,
|
||||
path: &Path,
|
||||
duration_ms: Option<i64>,
|
||||
has_audio: bool,
|
||||
hwaccel: Option<&str>,
|
||||
) -> Option<Computed> {
|
||||
// 손상 파일에서 워커가 영구 점유되지 않도록 길이에 비례한 상한을 둔다
|
||||
let secs = duration_ms.unwrap_or(0).max(0) as u64 / 1000;
|
||||
let timeout = Duration::from_secs((secs * 3).clamp(30, 900));
|
||||
|
||||
// 오디오가 있는 파일만 chromaprint를 함께 뽑는다 — 오디오 스트림이 없으면 ffmpeg가
|
||||
// 출력 초기화에서 실패해 프레임까지 못 받는다(실측). 임시 파일명은 file_id로 고유하다.
|
||||
let fp_tmp = has_audio.then(|| std::env::temp_dir().join(format!("archive-fp-{file_id}.raw")));
|
||||
let (raw, mut audio_raw) = crate::ffmpeg::frame_gray_sequence_with_audio(
|
||||
tools,
|
||||
path,
|
||||
VIDEO_SAMPLE_MS,
|
||||
sigmatch::FRAME as u32,
|
||||
timeout,
|
||||
hwaccel,
|
||||
fp_tmp.as_deref(),
|
||||
)
|
||||
.ok()?;
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// 오디오가 있어야 하는데 비었으면 이중 출력이 폴백을 탄 것이다(복사 중인 파일 등).
|
||||
// 여기서 포기하면 `sig_state=1`이 박혀 **영영** 오디오 없이 남으므로 한 번 더 시도한다.
|
||||
// 파일을 한 번 더 읽지만, 원래 드문 경로이고 오디오만 디코드하므로 값싸다.
|
||||
if has_audio && audio_raw.is_empty() {
|
||||
audio_raw = crate::ffmpeg::audio_fingerprint(tools, path, timeout).unwrap_or_default();
|
||||
if audio_raw.is_empty() {
|
||||
tracing::debug!(file_id, "오디오가 있다고 표시됐지만 지문을 얻지 못했다");
|
||||
}
|
||||
}
|
||||
|
||||
let mut frames: Vec<(u32, u64, u64)> = Vec::with_capacity(raw.len().min(MAX_FRAMES));
|
||||
let mut rep: Option<FrameSig> = None;
|
||||
for (t_ms, px) in raw.iter().take(MAX_FRAMES) {
|
||||
let Some(s) = sigmatch::derive(px) else { continue };
|
||||
// 대표 프레임 = 처음 등장하는 '색인할 가치가 있는' 프레임 (검정 인트로 회피)
|
||||
if rep.is_none() && s.indexable() {
|
||||
rep = Some(s.clone());
|
||||
}
|
||||
// 세 번째 필드는 레터박스 트림 해시(`scales[2]`)다 — 오디오로 찾은 후보를 확인할 때
|
||||
// 검정 띠를 붙인 사본과 원본을 맞추는 유일한 축이다(전체 축은 20~36까지 벌어진다).
|
||||
frames.push((*t_ms, s.dhash, s.scales[2]));
|
||||
}
|
||||
// 전부 단색이라 대표를 못 골랐으면 첫 프레임으로 대체
|
||||
if rep.is_none() {
|
||||
rep = sigmatch::derive(&raw[0].1);
|
||||
}
|
||||
let rep = rep?;
|
||||
|
||||
// 정지 화면이 표를 몰아주지 않게 접고, 값 기반으로 앵커를 고른다.
|
||||
// 짧은 영상은 표본이 적어 선택률을 낮추면 앵커가 0개가 되므로 전부 넣는다.
|
||||
let dense = sigmatch::dedup_consecutive(&sigmatch::dhash_seq(&frames));
|
||||
let rate = if dense.len() <= SHORT_FRAMES { 0 } else { ANCHOR_RATE_LOG2 };
|
||||
let anchor_set: std::collections::HashSet<u32> =
|
||||
sigmatch::anchors(&dense, rate).into_iter().map(|(t, _)| t).collect();
|
||||
let anchors: Vec<(u32, u64, u64)> =
|
||||
frames.iter().filter(|(t, _, _)| anchor_set.contains(t)).copied().collect();
|
||||
|
||||
Some(Computed {
|
||||
rep,
|
||||
frames,
|
||||
anchors,
|
||||
duration_ms,
|
||||
// 영상은 그레이 프레임만 받으므로 색차가 없다. 영상에서 '색만 다른 사본'은
|
||||
// 움직임까지 같아야 하는 드문 경우라, 색을 위해 RGB 파이프로 대역을 3배 쓰지 않는다.
|
||||
color: None,
|
||||
audio: audio_anchors(&audio_raw),
|
||||
})
|
||||
}
|
||||
|
||||
/// chromaprint 원시 시퀀스(약 8개/초)에서 **값 기반으로** 앵커를 골라 (t_ms, 값)으로 만든다.
|
||||
///
|
||||
/// 전부 저장하면 2시간짜리 영화가 230KB다. 그렇다고 n번째마다 고르면 시작 오프셋이 다른 사본이
|
||||
/// 서로 다른 지점을 뽑아 하나도 안 겹친다 — 프레임 앵커와 같은 이유로 **값**으로 골라야 한다.
|
||||
/// 선택률은 길이에 맞춰 낮추는데, 임계값을 낮춘 집합은 높인 집합의 부분집합이라
|
||||
/// 길이가 다른 두 사본(원본 ↔ 잘라낸 조각)도 교집합이 남는다.
|
||||
fn audio_anchors(fp: &[u32]) -> Vec<(u32, u32)> {
|
||||
if fp.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// chromaprint는 초당 약 8.07개를 낸다(11025Hz·4096샘플·2/3 중첩). 첫 창을 채우느라
|
||||
// 앞쪽 2.7초가 비어 30초 영상이 221개다 — 그 지연은 모든 파일에 똑같이 걸리므로
|
||||
// 상대 시각으로 쓰는 한 상쇄된다(지금은 후보 생성에 값만 쓰고 시각은 쓰지 않는다).
|
||||
const PER_SEC: u32 = 8;
|
||||
let stamped: Vec<(u32, u64)> =
|
||||
fp.iter().enumerate().map(|(i, v)| ((i as u32) * 1000 / PER_SEC, *v as u64)).collect();
|
||||
// 목표 개수(≈512)를 넘지 않는 가장 낮은 선택률을 쓴다 — 0이면 전부
|
||||
let mut rate = 0u32;
|
||||
while rate < 6 && stamped.len() >> rate > AUDIO_ANCHOR_TARGET {
|
||||
rate += 1;
|
||||
}
|
||||
sigmatch::anchors(&stamped, rate).into_iter().map(|(t, v)| (t, v as u32)).collect()
|
||||
}
|
||||
|
||||
// ── 쓰기 (배치당 트랜잭션 1개) ────────────────────────────
|
||||
|
||||
fn write_batch(db: &Arc<Db>, rows: Vec<SigRow>) -> Result<(), archive_db::DbError> {
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut ins = tx.prepare_cached(
|
||||
"INSERT OR REPLACE INTO file_sigs
|
||||
(file_id, sig_version, kind, dhash, phash, orient, tiles, scales,
|
||||
frames, keyframes, frame_count, duration_ms, stdev, flags, tile_flat, color,
|
||||
audio)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,0,?14,?15,?16)",
|
||||
)?;
|
||||
let mut upd = tx.prepare_cached("UPDATE files SET sig_state = ?2 WHERE id = ?1")?;
|
||||
for r in &rows {
|
||||
match &r.sig {
|
||||
Some(c) => {
|
||||
ins.execute(rusqlite::params![
|
||||
r.id,
|
||||
sigmatch::SIG_VERSION,
|
||||
r.kind,
|
||||
c.rep.dhash as i64,
|
||||
c.rep.phash as i64,
|
||||
sigmatch::pack_u64s(&c.rep.orient),
|
||||
sigmatch::pack_u64s(&c.rep.tiles),
|
||||
sigmatch::pack_u64s(&c.rep.scales),
|
||||
sigmatch::pack_frames(&c.frames),
|
||||
sigmatch::pack_frames(&c.anchors),
|
||||
c.frames.len() as i64,
|
||||
c.duration_ms,
|
||||
c.rep.stdev as f64,
|
||||
c.rep.tile_flat as i64,
|
||||
c.color.as_ref().map(sigmatch::pack_color),
|
||||
(!c.audio.is_empty()).then(|| sigmatch::pack_audio(&c.audio)),
|
||||
])?;
|
||||
upd.execute(rusqlite::params![r.id, 1i64])?;
|
||||
}
|
||||
// 실패는 2로 표시해 무한 재시도를 막는다 (sig_version이 올라가면 다시 0이 된다)
|
||||
None => {
|
||||
upd.execute(rusqlite::params![r.id, 2i64])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup() -> (tempfile::TempDir, Arc<Db>) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dir.path().join("t.db")).unwrap());
|
||||
db.with_write(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sources(id,kind,name,root) VALUES(1,'local','t','C:\\m')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'C:\\m','m')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(dir, db)
|
||||
}
|
||||
|
||||
/// 썸네일이 있는 이미지 → 지문 생성, 상태 1. 재실행 시 다시 하지 않는다.
|
||||
#[test]
|
||||
fn image_sigs_are_computed_and_resumable() {
|
||||
let (dir, db) = setup();
|
||||
let thumb_root = dir.path().join("thumbs");
|
||||
|
||||
// 파일 3개 + 그중 2개만 썸네일 준비
|
||||
db.with_write(|conn| {
|
||||
for i in 1..=3i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO files(id,source_id,folder_id,name,ext,kind,size,mtime_ms,thumb_state)
|
||||
VALUES(?1,1,1,?2,'jpg',0,100,0,?3)",
|
||||
rusqlite::params![i, format!("f{i}.jpg"), if i <= 2 { 1 } else { 0 }],
|
||||
)?;
|
||||
}
|
||||
for i in 1..=2i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO thumbs(file_id,size_class,cache_key,bytes) VALUES(?1,0,?2,10)",
|
||||
rusqlite::params![i, format!("{i:032x}")],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// 실제 썸네일 JPEG 두 장. 좌→우 증가 램프와 감소 램프 — dHash가 서로 반대가 되어
|
||||
// 다운샘플 후에도 확실히 구별된다(고주파 패턴은 평균화되어 같아질 수 있다).
|
||||
for i in 1..=2u32 {
|
||||
let p = crate::thumbq::thumb_path(&thumb_root, 0, &format!("{i:032x}"));
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
let img = image::DynamicImage::ImageLuma8(image::GrayImage::from_fn(64, 64, |x, _y| {
|
||||
let v = (x * 4) as u8;
|
||||
image::Luma([if i == 1 { v } else { 252u8.saturating_sub(v) }])
|
||||
}));
|
||||
img.save(&p).unwrap();
|
||||
}
|
||||
|
||||
let (cancel, pause) = (AtomicBool::new(false), AtomicBool::new(false));
|
||||
let (img_pending, _) = pending_counts(&db).unwrap();
|
||||
assert_eq!(img_pending, 2, "썸네일 있는 것만 대기 대상");
|
||||
|
||||
let n = run_image_sigs(&db, &thumb_root, &cancel, &pause).unwrap();
|
||||
assert_eq!(n, 2);
|
||||
|
||||
let (done, sigs): (i64, i64) = db
|
||||
.with_read(|c| {
|
||||
Ok((
|
||||
c.query_row("SELECT count(*) FROM files WHERE sig_state=1", [], |r| r.get(0))?,
|
||||
c.query_row("SELECT count(*) FROM file_sigs", [], |r| r.get(0))?,
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((done, sigs), (2, 2));
|
||||
assert_eq!(pending_counts(&db).unwrap().0, 0, "대기가 비어야 한다");
|
||||
|
||||
// 재실행은 아무것도 하지 않는다 (재개·증분의 근거)
|
||||
assert_eq!(run_image_sigs(&db, &thumb_root, &cancel, &pause).unwrap(), 0);
|
||||
|
||||
// 저장된 지문이 실제로 복원되는지
|
||||
let (dh, orient, tiles): (i64, Vec<u8>, Vec<u8>) = db
|
||||
.with_read(|c| {
|
||||
c.query_row("SELECT dhash, orient, tiles FROM file_sigs WHERE file_id=1", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(sigmatch::unpack_u64s(&orient).len(), 8);
|
||||
assert_eq!(sigmatch::unpack_u64s(&tiles).len(), 16);
|
||||
assert_eq!(sigmatch::unpack_u64s(&orient)[0], dh as u64, "orient[0]은 dhash와 같아야 한다");
|
||||
|
||||
// 서로 다른 그림이면 해시도 달라야 한다
|
||||
let d2: i64 = db
|
||||
.with_read(|c| c.query_row("SELECT dhash FROM file_sigs WHERE file_id=2", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
assert_ne!(dh, d2);
|
||||
}
|
||||
|
||||
/// 디코딩 실패는 sig_state=2로 남아 무한 재시도되지 않는다.
|
||||
#[test]
|
||||
fn broken_thumbnail_is_marked_failed_not_retried() {
|
||||
let (dir, db) = setup();
|
||||
let thumb_root = dir.path().join("thumbs");
|
||||
db.with_write(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO files(id,source_id,folder_id,name,ext,kind,size,mtime_ms,thumb_state)
|
||||
VALUES(1,1,1,'x.jpg','jpg',0,100,0,1)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO thumbs(file_id,size_class,cache_key,bytes) VALUES(1,0,'deadbeef',10)",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let p = crate::thumbq::thumb_path(&thumb_root, 0, "deadbeef");
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(&p, b"not a jpeg").unwrap();
|
||||
|
||||
let (cancel, pause) = (AtomicBool::new(false), AtomicBool::new(false));
|
||||
assert_eq!(run_image_sigs(&db, &thumb_root, &cancel, &pause).unwrap(), 1);
|
||||
let state: i64 = db
|
||||
.with_read(|c| c.query_row("SELECT sig_state FROM files WHERE id=1", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
assert_eq!(state, 2, "실패 표시");
|
||||
assert_eq!(run_image_sigs(&db, &thumb_root, &cancel, &pause).unwrap(), 0, "재시도 안 함");
|
||||
|
||||
// 첫 호출은 기록이 없어 한 번 되돌린다 → 다시 돌려 실패 상태로 되돌려 놓는다
|
||||
invalidate_stale_sigs(&db).unwrap();
|
||||
assert_eq!(run_image_sigs(&db, &thumb_root, &cancel, &pause).unwrap(), 1);
|
||||
assert_eq!(invalidate_stale_sigs(&db).unwrap(), 0, "같은 버전이면 실패를 건드리지 않는다");
|
||||
|
||||
// 지문 버전이 바뀌면 실패도 다시 시도한다 — 알고리즘이 바뀌면 결과가 달라질 수 있다
|
||||
db.with_write(|c| {
|
||||
c.execute("UPDATE settings SET value = '0' WHERE key = 'sig_version'", [])?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(invalidate_stale_sigs(&db).unwrap(), 1, "실패 행이 되돌려져야 한다");
|
||||
assert_eq!(run_image_sigs(&db, &thumb_root, &cancel, &pause).unwrap(), 1, "다시 시도한다");
|
||||
}
|
||||
|
||||
/// 지문 버전이 바뀐 행은 다시 계산 대기로 돌아가고, 그대로면 아무 일도 없다.
|
||||
#[test]
|
||||
fn stale_sig_version_is_invalidated_once() {
|
||||
let (_dir, db) = setup();
|
||||
db.with_write(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO files(id,source_id,folder_id,name,ext,kind,size,mtime_ms,thumb_state,sig_state)
|
||||
VALUES(1,1,1,'a.jpg','jpg',0,100,0,1,1),(2,1,1,'b.jpg','jpg',0,100,0,1,1)",
|
||||
[],
|
||||
)?;
|
||||
// 1번은 옛 버전, 2번은 현재 버전
|
||||
conn.execute(
|
||||
"INSERT INTO file_sigs(file_id,sig_version,kind,dhash,phash) VALUES(1,?1,0,7,7),(2,?2,0,8,8)",
|
||||
[sigmatch::SIG_VERSION - 1, sigmatch::SIG_VERSION],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(invalidate_stale_sigs(&db).unwrap(), 1, "옛 버전 행만 되돌린다");
|
||||
let pending: Vec<i64> = db
|
||||
.with_read(|c| {
|
||||
let mut s = c.prepare("SELECT id FROM files WHERE sig_state = 0 ORDER BY id")?;
|
||||
let v = s.query_map([], |r| r.get(0))?.collect::<Result<Vec<i64>, _>>()?;
|
||||
Ok(v)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(pending, vec![1]);
|
||||
|
||||
// 두 번째 호출은 버전이 그대로라 질의도 하지 않는다
|
||||
assert_eq!(invalidate_stale_sigs(&db).unwrap(), 0, "버전이 같으면 아무 일도 없다");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_stops_before_work() {
|
||||
let (dir, db) = setup();
|
||||
let cancel = AtomicBool::new(true);
|
||||
let pause = AtomicBool::new(false);
|
||||
assert_eq!(
|
||||
run_image_sigs(&db, &dir.path().join("thumbs"), &cancel, &pause).unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,64 @@ pub struct Tag {
|
||||
pub file_count: i64,
|
||||
}
|
||||
|
||||
/// 삭제 예정 표시용 시스템 태그 이름 — 사용자가 지우거나 이름을 바꿀 수 없다.
|
||||
pub const TRASH_TAG_NAME: &str = "삭제 예정";
|
||||
const TRASH_TAG_COLOR: &str = "#ff5f57";
|
||||
|
||||
/// 시스템 '삭제 예정' 태그 id (없으면 생성).
|
||||
/// sort_order = -1은 시스템 태그 표시용 — 사이드바는 이 태그를 id로 골라 따로 고정 표시한다.
|
||||
pub fn ensure_trash_tag(db: &Db) -> Result<i64> {
|
||||
// 대부분의 호출은 이미 존재하는 경우 — 쓰기 락을 잡지 않도록 읽기 먼저.
|
||||
let existing: Option<i64> = db.with_read(|conn| {
|
||||
Ok(conn
|
||||
.query_row("SELECT id FROM tags WHERE name = ?1", [TRASH_TAG_NAME], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.ok())
|
||||
})?;
|
||||
if let Some(id) = existing {
|
||||
return Ok(id);
|
||||
}
|
||||
db.with_write(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO tags(name, color, sort_order) VALUES (?1, ?2, -1)
|
||||
ON CONFLICT(name) DO UPDATE SET sort_order = -1",
|
||||
rusqlite::params![TRASH_TAG_NAME, TRASH_TAG_COLOR],
|
||||
)?;
|
||||
conn.query_row("SELECT id FROM tags WHERE name = ?1", [TRASH_TAG_NAME], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// 삭제 예정으로 표시된 파일 id 목록 (인덱스에 살아 있는 것만).
|
||||
pub fn pending_delete_ids(db: &Db) -> Result<Vec<i64>> {
|
||||
let tag_id = ensure_trash_tag(db)?;
|
||||
db.with_read(move |conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT f.id FROM files f
|
||||
JOIN file_tags ft ON ft.file_id = f.id
|
||||
WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([tag_id], |r| r.get(0))?;
|
||||
rows.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// 비우기 확인창용 — (개수, 총 바이트).
|
||||
pub fn pending_delete_stats(db: &Db) -> Result<(i64, i64)> {
|
||||
let tag_id = ensure_trash_tag(db)?;
|
||||
db.with_read(move |conn| {
|
||||
conn.query_row(
|
||||
"SELECT count(*), COALESCE(sum(f.size), 0) FROM files f
|
||||
JOIN file_tags ft ON ft.file_id = f.id
|
||||
WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL",
|
||||
[tag_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_tags(db: &Db) -> Result<Vec<Tag>> {
|
||||
db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
@@ -113,6 +171,47 @@ pub fn unassign(db: &Db, batch_id: &str, file_ids: Vec<i64>, tag_id: i64) -> Res
|
||||
})
|
||||
}
|
||||
|
||||
/// 그리드 썸네일 오버레이용 — 여러 파일의 태그를 한 번에 읽는다.
|
||||
/// 가시 범위(수백 개)를 파일당 쿼리로 도는 것을 피하기 위한 배치 조회.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileTagLink {
|
||||
pub file_id: i64,
|
||||
pub tag_id: i64,
|
||||
pub name: String,
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
pub fn file_tags_bulk(db: &Db, file_ids: Vec<i64>) -> Result<Vec<FileTagLink>> {
|
||||
if file_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
db.with_read(move |conn| {
|
||||
// i64만 넣으므로 리터럴 IN 절은 주입 안전
|
||||
let list = file_ids
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!(
|
||||
"SELECT ft.file_id, t.id, t.name, t.color FROM file_tags ft
|
||||
JOIN tags t ON t.id = ft.tag_id
|
||||
WHERE ft.file_id IN ({list})
|
||||
ORDER BY ft.file_id, t.sort_order, t.name"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(FileTagLink {
|
||||
file_id: r.get(0)?,
|
||||
tag_id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
color: r.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn file_tags(db: &Db, file_id: i64) -> Result<Vec<Tag>> {
|
||||
db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
@@ -169,6 +268,143 @@ pub fn export_json(db: &Db) -> Result<String> {
|
||||
Ok(serde_json::to_string_pretty(&exports).unwrap_or_else(|_| "[]".into()))
|
||||
}
|
||||
|
||||
// ── 태그 세트 ─────────────────────────────────────────────
|
||||
//
|
||||
// 자주 함께 붙이는 태그를 묶어 한 번에 적용한다. 세트는 **태그를 참조**할 뿐 복사하지
|
||||
// 않는다 — 태그 이름을 바꾸면 세트에도 그대로 반영되고, 태그를 지우면 세트에서 빠진다.
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct TagSet {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
/// 예: "1", "Ctrl+2", "F5". 없으면 클릭으로만 쓴다.
|
||||
pub hotkey: Option<String>,
|
||||
/// 이 세트에 담긴 태그 (이름·색까지 — 화면이 칩으로 그린다)
|
||||
pub tags: Vec<Tag>,
|
||||
}
|
||||
|
||||
pub fn list_tag_sets(db: &Db) -> Result<Vec<TagSet>> {
|
||||
db.with_read(|conn| {
|
||||
let mut sets: Vec<TagSet> = conn
|
||||
.prepare("SELECT id, name, hotkey FROM tag_sets ORDER BY sort_order, name")?
|
||||
.query_map([], |r| {
|
||||
Ok(TagSet { id: r.get(0)?, name: r.get(1)?, hotkey: r.get(2)?, tags: Vec::new() })
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
// 세트 수는 많아야 수십 개다 — 한 번에 읽어 메모리에서 묶는다(N+1 질의 회피)
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT i.set_id, t.id, t.name, t.color
|
||||
FROM tag_set_items i JOIN tags t ON t.id = i.tag_id
|
||||
ORDER BY t.sort_order, t.name",
|
||||
)?;
|
||||
let rows: Vec<(i64, Tag)> = stmt
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
r.get(0)?,
|
||||
Tag { id: r.get(1)?, name: r.get(2)?, color: r.get(3)?, file_count: 0 },
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
for (set_id, tag) in rows {
|
||||
if let Some(s) = sets.iter_mut().find(|s| s.id == set_id) {
|
||||
s.tags.push(tag);
|
||||
}
|
||||
}
|
||||
Ok(sets)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_tag_set(db: &Db, name: String, tag_ids: Vec<i64>) -> Result<i64> {
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("INSERT INTO tag_sets(name) VALUES (?1)", [&name])?;
|
||||
let id = tx.last_insert_rowid();
|
||||
{
|
||||
let mut ins = tx.prepare_cached(
|
||||
"INSERT OR IGNORE INTO tag_set_items(set_id, tag_id) VALUES (?1, ?2)",
|
||||
)?;
|
||||
for t in &tag_ids {
|
||||
ins.execute(rusqlite::params![id, t])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(id)
|
||||
})
|
||||
}
|
||||
|
||||
/// 세트의 이름·단축키·구성 태그를 통째로 바꾼다. `tag_ids`가 `None`이면 구성은 그대로 둔다.
|
||||
pub fn update_tag_set(
|
||||
db: &Db,
|
||||
set_id: i64,
|
||||
name: Option<String>,
|
||||
hotkey: Option<Option<String>>,
|
||||
tag_ids: Option<Vec<i64>>,
|
||||
) -> Result<()> {
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
if let Some(n) = &name {
|
||||
tx.execute("UPDATE tag_sets SET name = ?2 WHERE id = ?1", rusqlite::params![set_id, n])?;
|
||||
}
|
||||
if let Some(h) = &hotkey {
|
||||
// 같은 단축키를 다른 세트가 쓰고 있으면 그쪽을 비운다 — 하나의 키가 두 곳을
|
||||
// 가리키면 어느 것이 도는지 예측할 수 없다.
|
||||
if let Some(k) = h {
|
||||
tx.execute("UPDATE tag_sets SET hotkey = NULL WHERE hotkey = ?1", [k])?;
|
||||
}
|
||||
tx.execute("UPDATE tag_sets SET hotkey = ?2 WHERE id = ?1", rusqlite::params![set_id, h])?;
|
||||
}
|
||||
if let Some(ids) = &tag_ids {
|
||||
tx.execute("DELETE FROM tag_set_items WHERE set_id = ?1", [set_id])?;
|
||||
let mut ins = tx.prepare_cached(
|
||||
"INSERT OR IGNORE INTO tag_set_items(set_id, tag_id) VALUES (?1, ?2)",
|
||||
)?;
|
||||
for t in ids {
|
||||
ins.execute(rusqlite::params![set_id, t])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_tag_set(db: &Db, set_id: i64) -> Result<()> {
|
||||
db.with_write(move |conn| {
|
||||
conn.execute("DELETE FROM tag_sets WHERE id = ?1", [set_id])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// 세트에 담긴 태그를 파일들에 **한 번에** 붙인다. 반환: 새로 붙은 (파일,태그) 쌍 수.
|
||||
/// 개별 `assign`과 같은 저널을 남기므로 실행 취소도 같은 방식으로 동작한다.
|
||||
pub fn apply_tag_set(db: &Db, batch_id: &str, file_ids: Vec<i64>, set_id: i64) -> Result<u64> {
|
||||
let batch_id = batch_id.to_string();
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let tag_ids: Vec<i64> = tx
|
||||
.prepare("SELECT tag_id FROM tag_set_items WHERE set_id = ?1")?
|
||||
.query_map([set_id], |r| r.get(0))?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
let mut added = 0u64;
|
||||
{
|
||||
let mut ins = tx
|
||||
.prepare_cached("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)")?;
|
||||
let mut journal = tx.prepare_cached(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, tag_id) VALUES (?1, 'tag', ?2, ?3)",
|
||||
)?;
|
||||
for fid in &file_ids {
|
||||
for tid in &tag_ids {
|
||||
if ins.execute(rusqlite::params![fid, tid])? > 0 {
|
||||
journal.execute(rusqlite::params![batch_id, fid, tid])?;
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(added)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -224,6 +460,54 @@ mod tests {
|
||||
assert_eq!(journal_count, 4); // tag 3 + untag 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_set_applies_all_members_at_once() {
|
||||
let (_d, db, ids) = setup();
|
||||
let a = create_tag(&db, "여행".into(), None).unwrap();
|
||||
let b = create_tag(&db, "2026".into(), None).unwrap();
|
||||
let set = create_tag_set(&db, "여행세트".into(), vec![a, b]).unwrap();
|
||||
|
||||
// 파일 2개 × 태그 2개 = 4쌍
|
||||
let n = apply_tag_set(&db, "b1", vec![ids[0], ids[1]], set).unwrap();
|
||||
assert_eq!(n, 4);
|
||||
let names: Vec<String> = file_tags(&db, ids[0]).unwrap().into_iter().map(|t| t.name).collect();
|
||||
assert!(names.contains(&"여행".to_string()) && names.contains(&"2026".to_string()));
|
||||
|
||||
// 다시 적용해도 중복되지 않는다 (INSERT OR IGNORE)
|
||||
assert_eq!(apply_tag_set(&db, "b2", vec![ids[0]], set).unwrap(), 0);
|
||||
|
||||
// 세트는 태그를 **참조**한다 — 이름을 바꾸면 세트 목록에도 그대로 반영된다
|
||||
rename_tag(&db, a, "국내여행".into()).unwrap();
|
||||
let sets = list_tag_sets(&db).unwrap();
|
||||
assert!(sets[0].tags.iter().any(|t| t.name == "국내여행"));
|
||||
|
||||
// 태그를 지우면 세트에서도 빠진다 (CASCADE)
|
||||
delete_tag(&db, b).unwrap();
|
||||
assert_eq!(list_tag_sets(&db).unwrap()[0].tags.len(), 1);
|
||||
}
|
||||
|
||||
/// 같은 단축키를 두 세트가 가지면 어느 것이 도는지 예측할 수 없다 — 뒤에 지정한 쪽이 이긴다.
|
||||
#[test]
|
||||
fn hotkey_is_unique_across_sets() {
|
||||
let (_d, db, _ids) = setup();
|
||||
let s1 = create_tag_set(&db, "세트1".into(), vec![]).unwrap();
|
||||
let s2 = create_tag_set(&db, "세트2".into(), vec![]).unwrap();
|
||||
update_tag_set(&db, s1, None, Some(Some("1".into())), None).unwrap();
|
||||
update_tag_set(&db, s2, None, Some(Some("1".into())), None).unwrap();
|
||||
|
||||
let sets = list_tag_sets(&db).unwrap();
|
||||
let by = |id: i64| sets.iter().find(|s| s.id == id).unwrap().hotkey.clone();
|
||||
assert_eq!(by(s1), None, "먼저 쓰던 쪽이 비워져야 한다");
|
||||
assert_eq!(by(s2), Some("1".into()));
|
||||
|
||||
// 수식키가 붙은 조합도 그대로 왕복해야 한다 (화면은 "Ctrl+1"인데 DB에 "1"만
|
||||
// 들어가면 단축키가 다른 키를 가리키게 된다)
|
||||
update_tag_set(&db, s1, None, Some(Some("Ctrl+Shift+F2".into())), None).unwrap();
|
||||
let sets = list_tag_sets(&db).unwrap();
|
||||
let got = sets.iter().find(|s| s.id == s1).unwrap().hotkey.clone();
|
||||
assert_eq!(got, Some("Ctrl+Shift+F2".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_contains_paths() {
|
||||
let (_d, db, ids) = setup();
|
||||
|
||||
@@ -6,45 +6,195 @@ use archive_db::Db;
|
||||
use crossbeam_channel::{unbounded, Receiver, RecvTimeoutError, Sender};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct ThumbQueue {
|
||||
hi_tx: Sender<i64>,
|
||||
lo_tx: Sender<i64>,
|
||||
ctx: Arc<Ctx>,
|
||||
/// 피더 세대 — 새 피더가 시작되면 이전 피더는 스스로 물러난다
|
||||
feed_gen: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// ── 동시 처리 게이트 ────────────────────────────────────────────────────────
|
||||
/// 디스크 특성에 맞는 동시 읽기 개수는 미리 알 수 없다: NVMe는 코어 수만큼,
|
||||
/// HDD는 1–2개(그 이상은 탐색 스래싱), SMB는 지연을 가리려면 8–16개가 유리하다.
|
||||
/// 드라이브 종류를 OS API로 캐묻는 대신 **처리율을 측정해서** 올렸다 내렸다 한다
|
||||
/// (혼합 소스·USB·iSCSI 같은 애매한 경우까지 한 방법으로 처리된다).
|
||||
///
|
||||
/// 토큰 1개 = 동시 실행 1칸. 워커는 처리 직전에 토큰을 받고 끝나면 돌려준다.
|
||||
struct Gate {
|
||||
tx: Sender<()>,
|
||||
rx: Receiver<()>,
|
||||
/// 현재 유통 중인 토큰 수
|
||||
level: AtomicUsize,
|
||||
max: usize,
|
||||
}
|
||||
|
||||
/// 토큰 반납은 Drop에 맡긴다 — 워커 루프에 조기 탈출/패닉이 있어도 새지 않는다.
|
||||
struct Permit<'a> {
|
||||
tx: &'a Sender<()>,
|
||||
}
|
||||
|
||||
impl Drop for Permit<'_> {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.tx.send(());
|
||||
}
|
||||
}
|
||||
|
||||
impl Gate {
|
||||
fn new(initial: usize, max: usize) -> Gate {
|
||||
let max = max.max(1);
|
||||
let initial = initial.clamp(1, max);
|
||||
let (tx, rx) = unbounded::<()>();
|
||||
for _ in 0..initial {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
Gate {
|
||||
tx,
|
||||
rx,
|
||||
level: AtomicUsize::new(initial),
|
||||
max,
|
||||
}
|
||||
}
|
||||
|
||||
fn level(&self) -> usize {
|
||||
self.level.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn acquire(&self) -> Permit<'_> {
|
||||
let _ = self.rx.recv(); // tx가 Ctx 안에 살아 있으므로 끊기지 않는다
|
||||
Permit { tx: &self.tx }
|
||||
}
|
||||
|
||||
/// 동시 실행 칸을 `n`으로 맞춘다. 줄일 때는 **비어 있는** 토큰만 회수하므로
|
||||
/// 이미 일하는 워커를 중단시키지 않는다(회수 못 한 만큼은 다음 조정에서 다시 시도).
|
||||
fn set(&self, n: usize) {
|
||||
let n = n.clamp(1, self.max);
|
||||
let cur = self.level.load(Ordering::Relaxed);
|
||||
if n > cur {
|
||||
for _ in cur..n {
|
||||
let _ = self.tx.send(());
|
||||
}
|
||||
self.level.store(n, Ordering::Relaxed);
|
||||
} else if n < cur {
|
||||
let mut removed = 0;
|
||||
while removed < cur - n {
|
||||
if self.rx.try_recv().is_ok() {
|
||||
removed += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.level.store(cur - removed, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 한 파일의 썸네일 처리 결과. 워커가 DB를 직접 건드리지 않고 이걸 보낸다.
|
||||
struct ThumbWrite {
|
||||
id: i64,
|
||||
/// 성공이면 (cache_key, w, h, bytes), 실패면 None
|
||||
made: Option<(String, u32, u32, i64)>,
|
||||
}
|
||||
|
||||
/// 쓰기 합치기 한 묶음의 상한. 100만 건 × 트랜잭션 1개였던 것을 이 크기로 나눈다.
|
||||
const WRITE_BATCH: usize = 200;
|
||||
/// 묶음이 안 차도 이 시간이 지나면 커밋한다 — 뷰포트 썸네일이 늦게 보이지 않게.
|
||||
const WRITE_FLUSH_MS: u64 = 100;
|
||||
/// 게이트 조정 주기. 파일 크기가 들쭉날쭉해서 표본이 짧으면 잡음이 크다.
|
||||
const TUNE_WINDOW: Duration = Duration::from_secs(5);
|
||||
/// 이 비율 미만의 변화는 잡음으로 보고 현 수준을 유지한다.
|
||||
const TUNE_DEADBAND: f64 = 0.15;
|
||||
/// 피더가 한 번에 읽는 행 수 / 채널에 유지하는 백로그 상한.
|
||||
const FEED_PAGE: usize = 2_000;
|
||||
const FEED_HIGH: usize = 8_000;
|
||||
/// 피더 읽기 실패 재시도 횟수. 시작 직후 WAL 복구·리더 경합으로 한두 번 실패할 수 있는데,
|
||||
/// 그때 물러나면 백필이 통째로 사라진다. 지수적으로 늦추며 이 횟수까지 버틴다.
|
||||
const FEED_MAX_RETRIES: u32 = 5;
|
||||
|
||||
struct Ctx {
|
||||
db: Arc<Db>,
|
||||
thumb_root: PathBuf,
|
||||
tools: Option<crate::ffmpeg::FfTools>,
|
||||
in_flight: Mutex<HashSet<i64>>,
|
||||
done_tx: Sender<i64>,
|
||||
/// 결과를 쓰기 합치기 스레드로 보낸다 (워커는 writer를 기다리지 않는다)
|
||||
write_tx: Sender<ThumbWrite>,
|
||||
gate: Gate,
|
||||
/// 처리 시도 누적 — 게이트 자동조정의 유일한 입력
|
||||
processed: AtomicUsize,
|
||||
}
|
||||
|
||||
impl ThumbQueue {
|
||||
/// 워커 풀을 시작한다. `on_done`은 완료된 file_id 배치를 받는다.
|
||||
/// `tools`가 None이면 영상 썸네일은 건너뛴다 (이미지는 정상 처리).
|
||||
///
|
||||
/// `max_workers`는 스레드 수이자 동시 처리 상한이다. 실제 동시 처리 개수는
|
||||
/// 처리율을 보며 1..=max_workers 사이에서 자동으로 오르내린다([`Gate`]).
|
||||
pub fn start(
|
||||
db: Arc<Db>,
|
||||
thumb_root: PathBuf,
|
||||
tools: Option<crate::ffmpeg::FfTools>,
|
||||
workers: usize,
|
||||
max_workers: usize,
|
||||
on_done: impl Fn(Vec<i64>) + Send + 'static,
|
||||
) -> ThumbQueue {
|
||||
let (hi_tx, hi_rx) = unbounded::<i64>();
|
||||
let (lo_tx, lo_rx) = unbounded::<i64>();
|
||||
let (write_tx, write_rx) = unbounded::<ThumbWrite>();
|
||||
let (done_tx, done_rx) = unbounded::<i64>();
|
||||
|
||||
let max_workers = max_workers.max(1);
|
||||
let ctx = Arc::new(Ctx {
|
||||
db,
|
||||
thumb_root,
|
||||
tools,
|
||||
in_flight: Mutex::new(HashSet::new()),
|
||||
done_tx,
|
||||
write_tx,
|
||||
// 보수적으로 시작한다 — 낮게 시작해서 올리는 편이, 높게 시작해서
|
||||
// HDD를 스래싱시키며 내려오는 것보다 사용자 체감이 낫다.
|
||||
gate: Gate::new(4.min(max_workers), max_workers),
|
||||
processed: AtomicUsize::new(0),
|
||||
});
|
||||
|
||||
for i in 0..workers.max(1) {
|
||||
// ── 쓰기 합치기 ────────────────────────────────────────────────────
|
||||
// 예전에는 워커가 파일당 트랜잭션 1개를 직접 커밋했다. 모든 쓰기는 단일 writer
|
||||
// 스레드에 **동기 왕복**하므로, 100만 건이면 왕복 100만 번이 되고 그 사이 UI에서
|
||||
// 태그를 붙이는 쓰기가 뒤에 줄을 선다. 여기서 200건/100ms로 묶어 한 트랜잭션에 쓴다.
|
||||
//
|
||||
// 순서 보장: 커밋이 끝난 **뒤에** 완료 id를 이미터로 넘긴다. 반대로 하면
|
||||
// 미디어 서버가 아직 없는 thumbs 행을 조회해 이미지가 깨진다.
|
||||
{
|
||||
let db = ctx.db.clone();
|
||||
let done_tx = done_tx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("thumb-writer".into())
|
||||
.spawn(move || {
|
||||
crate::prio::set_current_thread_low();
|
||||
let mut batch: Vec<ThumbWrite> = Vec::with_capacity(WRITE_BATCH);
|
||||
loop {
|
||||
let closed = match write_rx.recv_timeout(Duration::from_millis(WRITE_FLUSH_MS)) {
|
||||
Ok(w) => {
|
||||
batch.push(w);
|
||||
false
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => false,
|
||||
Err(RecvTimeoutError::Disconnected) => true,
|
||||
};
|
||||
if batch.len() >= WRITE_BATCH || (closed && !batch.is_empty()) || (!batch.is_empty() && write_rx.is_empty())
|
||||
{
|
||||
flush_writes(&db, std::mem::take(&mut batch), &done_tx);
|
||||
}
|
||||
if closed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("썸네일 쓰기 스레드 생성 실패");
|
||||
}
|
||||
|
||||
for i in 0..max_workers {
|
||||
let ctx = ctx.clone();
|
||||
let hi_rx = hi_rx.clone();
|
||||
let lo_rx = lo_rx.clone();
|
||||
@@ -54,6 +204,16 @@ impl ThumbQueue {
|
||||
.expect("썸네일 워커 생성 실패");
|
||||
}
|
||||
|
||||
// 게이트 자동조정 — Weak로 잡아 큐가 사라지면 스스로 끝난다
|
||||
// (Arc를 들고 있으면 write_tx가 안 죽어 쓰기 스레드가 영원히 남는다).
|
||||
{
|
||||
let weak = Arc::downgrade(&ctx);
|
||||
std::thread::Builder::new()
|
||||
.name("thumb-tune".into())
|
||||
.spawn(move || tuner_loop(weak))
|
||||
.ok();
|
||||
}
|
||||
|
||||
// 완료 배치 이미터
|
||||
std::thread::Builder::new()
|
||||
.name("thumb-emit".into())
|
||||
@@ -83,7 +243,17 @@ impl ThumbQueue {
|
||||
})
|
||||
.expect("썸네일 이미터 생성 실패");
|
||||
|
||||
ThumbQueue { hi_tx, lo_tx }
|
||||
ThumbQueue {
|
||||
hi_tx,
|
||||
lo_tx,
|
||||
ctx,
|
||||
feed_gen: Arc::new(AtomicU64::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 현재 동시 처리 개수 (상태바·디버그용).
|
||||
pub fn concurrency(&self) -> usize {
|
||||
self.ctx.gate.level()
|
||||
}
|
||||
|
||||
/// 뷰포트 우선 생성 요청 (프론트가 스크롤 시 호출).
|
||||
@@ -93,28 +263,135 @@ impl ThumbQueue {
|
||||
}
|
||||
}
|
||||
|
||||
/// 대기 중인 이미지 전체를 백그라운드 밴드에 넣는다.
|
||||
pub fn enqueue_pending(&self, db: &Db, source_id: Option<i64>) {
|
||||
let ids: Vec<i64> = db
|
||||
.with_read(|conn| match source_id {
|
||||
Some(sid) => {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT id FROM files WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL AND source_id=?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([sid], |r| r.get(0))?;
|
||||
rows.collect()
|
||||
}
|
||||
None => {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT id FROM files WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| r.get(0))?;
|
||||
rows.collect()
|
||||
/// 대기 중인 항목을 백그라운드 밴드에 흘려보낸다. 즉시 반환(전용 피더 스레드).
|
||||
///
|
||||
/// 예전에는 대기 id를 **전부** 한 Vec으로 읽어 채널에 밀어넣었다. 100만 건이면
|
||||
/// 읽는 동안 리더 커넥션을 붙잡고, 채널에 수십 MB가 상주하고, 그 사이 스크롤로
|
||||
/// 요청된 뷰포트 썸네일은 100만 개 뒤에서 기다린다. id 커서로 2000행씩 끊어
|
||||
/// 백로그를 8000 이하로 유지하면 상주 메모리도, 우선순위 반전도 사라진다.
|
||||
pub fn enqueue_pending(&self, source_id: Option<i64>) {
|
||||
let generation = self.feed_gen.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let feed_gen = self.feed_gen.clone();
|
||||
let db = self.ctx.db.clone();
|
||||
let lo_tx = self.lo_tx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("thumb-feed".into())
|
||||
.spawn(move || {
|
||||
crate::prio::set_current_thread_low();
|
||||
// 스캔이 끝날 때마다 호출되므로(워처는 1분마다 재스캔) 피더가 겹칠 수 있다.
|
||||
// 세대가 바뀌면(= 더 최신 호출이 있었다) 이전 피더는 조용히 물러난다.
|
||||
let alive = || feed_gen.load(Ordering::SeqCst) == generation;
|
||||
let mut cursor = 0i64;
|
||||
let mut retries = 0u32;
|
||||
loop {
|
||||
if !alive() {
|
||||
return;
|
||||
}
|
||||
while lo_tx.len() >= FEED_HIGH {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
if !alive() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let page: Result<Vec<i64>, _> = db
|
||||
.with_read(|conn| match source_id {
|
||||
Some(sid) => {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT id FROM files
|
||||
WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL
|
||||
AND source_id=?1 AND id > ?2
|
||||
ORDER BY id LIMIT ?3",
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
rusqlite::params![sid, cursor, FEED_PAGE as i64],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
rows.collect()
|
||||
}
|
||||
None => {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT id FROM files
|
||||
WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL
|
||||
AND id > ?1
|
||||
ORDER BY id LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
rusqlite::params![cursor, FEED_PAGE as i64],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
rows.collect()
|
||||
}
|
||||
});
|
||||
// 읽기 실패와 '더 없음'을 구분한다. 예전에는 둘 다 빈 페이지가 되어 피더가
|
||||
// 조용히 끝났다 — 앱을 막 켠 직후(하드 종료 뒤 WAL 복구·리더 경합)에 한 번
|
||||
// 실패하면 **다음 스캔이 있을 때까지 백필이 영영 안 도는** 경로였다.
|
||||
// 사용자에게는 "이어서 진행이 안 된다"로 보인다.
|
||||
let page = match page {
|
||||
Ok(p) => {
|
||||
retries = 0;
|
||||
p
|
||||
}
|
||||
Err(e) => {
|
||||
retries += 1;
|
||||
if retries > FEED_MAX_RETRIES {
|
||||
tracing::warn!(error = %e, "썸네일 백필 피더 중단 — 읽기 실패 반복");
|
||||
return;
|
||||
}
|
||||
tracing::debug!(error = %e, retries, "썸네일 백필 읽기 실패 — 재시도");
|
||||
std::thread::sleep(Duration::from_millis(500 * retries as u64));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(&last) = page.last() else { break };
|
||||
cursor = last;
|
||||
for id in page {
|
||||
let _ = lo_tx.send(id);
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for id in ids {
|
||||
let _ = self.lo_tx.send(id);
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// 처리율을 보며 동시 처리 개수를 언덕 오르기로 맞춘다.
|
||||
/// 올려서 빨라지면 계속 올리고, 느려지면 방향을 뒤집는다. 평지면 그 자리에 머문다.
|
||||
fn tuner_loop(weak: Weak<Ctx>) {
|
||||
crate::prio::set_current_thread_low();
|
||||
let mut dir: isize = 1;
|
||||
let mut prev_rate = 0.0f64;
|
||||
let mut prev_count = 0usize;
|
||||
loop {
|
||||
std::thread::sleep(TUNE_WINDOW);
|
||||
let Some(ctx) = weak.upgrade() else { break };
|
||||
let total = ctx.processed.load(Ordering::Relaxed);
|
||||
let n = total.saturating_sub(prev_count);
|
||||
prev_count = total;
|
||||
// 표본이 적으면(대기열이 비었거나 대형 영상 하나를 붙잡고 있는 중) 판단하지 않는다.
|
||||
// 0을 '느려졌다'로 읽으면 게이트가 1까지 흘러내려 다음 작업이 느리게 시작한다.
|
||||
if n < 8 {
|
||||
prev_rate = 0.0;
|
||||
continue;
|
||||
}
|
||||
let rate = n as f64 / TUNE_WINDOW.as_secs_f64();
|
||||
if prev_rate <= 0.0 {
|
||||
prev_rate = rate; // 기준선만 잡고 다음 창에서 비교
|
||||
continue;
|
||||
}
|
||||
let change = (rate - prev_rate) / prev_rate;
|
||||
prev_rate = rate;
|
||||
if change.abs() <= TUNE_DEADBAND {
|
||||
continue; // 평지 — 지금 수준이 이 디스크의 적정선
|
||||
}
|
||||
if change < 0.0 {
|
||||
dir = -dir; // 직전 이동이 손해였다 → 되돌린다
|
||||
}
|
||||
let cur = ctx.gate.level();
|
||||
// 하한 2 — 1로 내려가면 큰 영상 하나(ffmpeg 프레임 추출)가 백필 전체를 붙잡는다.
|
||||
let floor = 2.min(ctx.gate.max) as isize;
|
||||
let next = (cur as isize + dir).clamp(floor, ctx.gate.max as isize) as usize;
|
||||
if next != cur {
|
||||
ctx.gate.set(next);
|
||||
tracing::debug!(from = cur, to = next, rate, "썸네일 동시 처리 조정");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,21 +409,61 @@ pub fn thumb_path(thumb_root: &std::path::Path, size_class: u8, key: &str) -> Pa
|
||||
.join(format!("{key}.jpg"))
|
||||
}
|
||||
|
||||
/// 한 묶음을 트랜잭션 하나로 커밋하고, 성공한 id만 이미터로 넘긴다.
|
||||
/// 커밋이 실패하면 아무 id도 넘기지 않는다 — 없는 썸네일을 있다고 알리지 않기 위해.
|
||||
fn flush_writes(db: &Db, batch: Vec<ThumbWrite>, done_tx: &Sender<i64>) {
|
||||
if batch.is_empty() {
|
||||
return;
|
||||
}
|
||||
let ok_ids: Vec<i64> = batch.iter().filter(|w| w.made.is_some()).map(|w| w.id).collect();
|
||||
let result = db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut ins = tx.prepare_cached(
|
||||
"INSERT OR REPLACE INTO thumbs(file_id, size_class, cache_key, width, height, bytes)
|
||||
VALUES (?1, 0, ?2, ?3, ?4, ?5)",
|
||||
)?;
|
||||
let mut upd = tx.prepare_cached("UPDATE files SET thumb_state = ?2 WHERE id = ?1")?;
|
||||
for w in &batch {
|
||||
match &w.made {
|
||||
Some((key, dw, dh, bytes)) => {
|
||||
ins.execute(rusqlite::params![w.id, key, dw, dh, bytes])?;
|
||||
upd.execute(rusqlite::params![w.id, 1i64])?;
|
||||
}
|
||||
// 실패는 2로 남겨 무한 재시도를 막는다 (캐시 퍼지가 0으로 되돌린다)
|
||||
None => {
|
||||
upd.execute(rusqlite::params![w.id, 2i64])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
});
|
||||
match result {
|
||||
Ok(()) => {
|
||||
for id in ok_ids {
|
||||
let _ = done_tx.send(id);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(count = ok_ids.len(), "썸네일 상태 쓰기 실패: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_loop(ctx: Arc<Ctx>, hi_rx: Receiver<i64>, lo_rx: Receiver<i64>) {
|
||||
// 썸네일 디코드/리사이즈는 CPU 집약적 — UI 스레드가 굶지 않도록 우선순위를 낮춘다.
|
||||
crate::prio::set_current_thread_low();
|
||||
loop {
|
||||
// hi 우선, 없으면 lo를 짧게 대기
|
||||
let id = match hi_rx.try_recv() {
|
||||
Ok(id) => id,
|
||||
let (id, urgent) = match hi_rx.try_recv() {
|
||||
Ok(id) => (id, true),
|
||||
Err(_) => match lo_rx.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(id) => id,
|
||||
Ok(id) => (id, false),
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
// hi 채널이 닫혔으면 종료
|
||||
if hi_rx.is_empty() && lo_rx.is_empty() && hi_rx.try_recv().is_err() {
|
||||
match hi_rx.recv_timeout(Duration::from_millis(1)) {
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
Ok(id) => id,
|
||||
Ok(id) => (id, true),
|
||||
Err(RecvTimeoutError::Timeout) => continue,
|
||||
}
|
||||
} else {
|
||||
@@ -160,11 +477,20 @@ fn worker_loop(ctx: Arc<Ctx>, hi_rx: Receiver<i64>, lo_rx: Receiver<i64>) {
|
||||
if !ctx.in_flight.lock().unwrap().insert(id) {
|
||||
continue; // 다른 워커가 처리 중
|
||||
}
|
||||
let result = process_one(&ctx, id);
|
||||
ctx.in_flight.lock().unwrap().remove(&id);
|
||||
if let Ok(true) = result {
|
||||
let _ = ctx.done_tx.send(id);
|
||||
if urgent {
|
||||
// 뷰포트 요청(hi)은 게이트를 통과하지 않는다. 사용자가 지금 보고 있는 화면이고
|
||||
// 한 화면 분량뿐이라 디스크를 흔들지 않는다. 게이트는 대량 백필을 위한 것이다.
|
||||
let _ = process_one(&ctx, id);
|
||||
} else {
|
||||
// 게이트 통과 후에만 실제 읽기/디코드를 한다. 스레드는 max_workers개지만
|
||||
// 동시에 디스크를 때리는 건 게이트가 허용한 만큼 — 나머지는 여기서 잔다.
|
||||
let _permit = ctx.gate.acquire();
|
||||
let _ = process_one(&ctx, id);
|
||||
}
|
||||
ctx.processed.fetch_add(1, Ordering::Relaxed);
|
||||
ctx.in_flight.lock().unwrap().remove(&id);
|
||||
// 완료 통지는 쓰기 합치기 스레드가 **커밋 후에** 보낸다 —
|
||||
// 여기서 보내면 thumbs 행이 아직 없는 상태로 UI가 이미지를 요청한다.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,24 +584,19 @@ fn process_one(ctx: &Ctx, id: i64) -> Result<bool, archive_db::DbError> {
|
||||
}
|
||||
};
|
||||
|
||||
// DB 쓰기는 쓰기 합치기 스레드가 한다 — 워커는 결과만 넘기고 다음 파일로 간다.
|
||||
// (파일당 with_write는 단일 writer에 동기 왕복이라 워커 처리량이 writer에 묶였다)
|
||||
match generated {
|
||||
Some((dw, dh)) => {
|
||||
let bytes = std::fs::metadata(&dst).map(|m| m.len() as i64).unwrap_or(0);
|
||||
ctx.db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO thumbs(file_id, size_class, cache_key, width, height, bytes)
|
||||
VALUES (?1, 0, ?2, ?3, ?4, ?5)",
|
||||
rusqlite::params![id, key, dw, dh, bytes],
|
||||
)?;
|
||||
tx.execute("UPDATE files SET thumb_state = 1 WHERE id = ?1", [id])?;
|
||||
tx.commit()
|
||||
})?;
|
||||
let _ = ctx.write_tx.send(ThumbWrite {
|
||||
id,
|
||||
made: Some((key, dw, dh, bytes)),
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
None => {
|
||||
ctx.db
|
||||
.with_write(move |conn| conn.execute("UPDATE files SET thumb_state = 2 WHERE id = ?1", [id]))?;
|
||||
let _ = ctx.write_tx.send(ThumbWrite { id, made: None });
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@@ -286,6 +607,35 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn gate_grows_and_shrinks_without_losing_tokens() {
|
||||
let gate = Gate::new(2, 8);
|
||||
assert_eq!(gate.level(), 2);
|
||||
|
||||
gate.set(6);
|
||||
assert_eq!(gate.level(), 6);
|
||||
assert_eq!(gate.rx.len(), 6, "유통 토큰 = 대기 토큰(아무도 안 잡고 있음)");
|
||||
|
||||
gate.set(3);
|
||||
assert_eq!(gate.level(), 3);
|
||||
assert_eq!(gate.rx.len(), 3);
|
||||
|
||||
// 상한/하한 클램프
|
||||
gate.set(99);
|
||||
assert_eq!(gate.level(), 8);
|
||||
gate.set(0);
|
||||
assert_eq!(gate.level(), 1);
|
||||
|
||||
// 잡고 있는 토큰은 회수되지 않는다 — 일하는 워커를 끊지 않기 위해
|
||||
gate.set(4);
|
||||
let held = gate.acquire();
|
||||
assert_eq!(gate.rx.len(), 3);
|
||||
gate.set(1);
|
||||
assert_eq!(gate.level(), 1, "비어 있던 3개만 회수");
|
||||
drop(held); // 반납되면 유통량은 다시 늘어난다(다음 조정에서 정리)
|
||||
assert_eq!(gate.rx.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_thumbs_for_scanned_images() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
@@ -311,17 +661,23 @@ mod tests {
|
||||
let cancel = std::sync::atomic::AtomicBool::new(false);
|
||||
crate::pipeline::scan_source(&db, sid, media.path(), &cancel, |_| {}).unwrap();
|
||||
|
||||
// 완료를 **이벤트로** 기다린다. 폴링+짧은 마감은 부하가 걸리면 흔들린다 —
|
||||
// 썸네일 워커는 BELOW_NORMAL이라 다른 테스트가 코어를 차지하면 뒤로 밀린다
|
||||
// (전체 스위트 병렬 실행에서 실제로 한 번 마감을 넘겼다).
|
||||
let done_count = Arc::new(AtomicUsize::new(0));
|
||||
let (tx, rx) = unbounded::<usize>();
|
||||
let dc = done_count.clone();
|
||||
let q = ThumbQueue::start(db.clone(), thumb_root.path().to_path_buf(), None, 2, move |ids| {
|
||||
dc.fetch_add(ids.len(), Ordering::SeqCst);
|
||||
let n = dc.fetch_add(ids.len(), Ordering::SeqCst) + ids.len();
|
||||
let _ = tx.send(n);
|
||||
});
|
||||
q.enqueue_pending(&db, Some(sid));
|
||||
q.enqueue_pending(Some(sid));
|
||||
|
||||
// 완료 대기 (최대 10초)
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while done_count.load(Ordering::SeqCst) < 5 && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
// 마감은 '멈춤 감지'용 안전장치일 뿐이다 — 정상 경로는 알림을 받는 즉시 끝난다
|
||||
while done_count.load(Ordering::SeqCst) < 5 {
|
||||
if rx.recv_timeout(Duration::from_secs(60)).is_err() {
|
||||
break; // 60초 동안 아무 진전도 없으면 정말 멈춘 것이다
|
||||
}
|
||||
}
|
||||
assert_eq!(done_count.load(Ordering::SeqCst), 5);
|
||||
|
||||
|
||||
@@ -23,14 +23,28 @@ pub const GRID_LONG_EDGE: u32 = 256;
|
||||
pub const PREVIEW_LONG_EDGE: u32 = 1280;
|
||||
pub const JPEG_QUALITY: u8 = 80;
|
||||
|
||||
/// 파일 전체를 메모리에 올려 처리할 상한. 이보다 크면 스트리밍 경로로 폴백한다.
|
||||
/// (한 워커가 이만큼 쓰고, 워커는 최대 16개 → 최악 512MB. 실제로는 대부분 수 MB)
|
||||
const INMEM_MAX: u64 = 32 * 1024 * 1024;
|
||||
|
||||
/// EXIF Orientation 값(1–8)을 읽는다. 없으면 1(정상).
|
||||
pub fn read_orientation(path: &Path) -> u32 {
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return 1;
|
||||
};
|
||||
let mut reader = std::io::BufReader::new(&file);
|
||||
orientation_from(&mut reader)
|
||||
}
|
||||
|
||||
/// 이미 메모리에 있는 바이트에서 orientation을 읽는다 (파일을 다시 열지 않기 위해).
|
||||
fn orientation_from_bytes(bytes: &[u8]) -> u32 {
|
||||
let mut cur = std::io::Cursor::new(bytes);
|
||||
orientation_from(&mut cur)
|
||||
}
|
||||
|
||||
fn orientation_from<R: std::io::BufRead + std::io::Seek>(r: &mut R) -> u32 {
|
||||
exif::Reader::new()
|
||||
.read_from_container(&mut reader)
|
||||
.read_from_container(r)
|
||||
.ok()
|
||||
.and_then(|ex| {
|
||||
ex.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
|
||||
@@ -73,10 +87,18 @@ pub fn make_image_thumb(
|
||||
long_edge: u32,
|
||||
quality: u8,
|
||||
) -> Result<(u32, u32, u32, u32)> {
|
||||
let orientation = read_orientation(src);
|
||||
// 파일을 **한 번만** 연다. 예전에는 orientation(EXIF)에서 한 번, 디코더에서 또 한 번
|
||||
// 열었다 — SMB에서는 open 왕복만 파일당 2회, 100만 건이면 그것만으로 수십 분이다.
|
||||
// 8KB BufReader로 잘게 읽는 것도 네트워크에서는 손해라 한 번에 순차로 읽는다.
|
||||
let size = std::fs::metadata(src).map(|m| m.len()).unwrap_or(u64::MAX);
|
||||
if size <= INMEM_MAX {
|
||||
if let Ok(bytes) = std::fs::read(src) {
|
||||
return thumb_from_whole_file(&bytes, dst, long_edge, quality);
|
||||
}
|
||||
}
|
||||
|
||||
// JPEG 빠른 경로: DCT 축소 디코드(1/2·1/4·1/8). 6000×4000 사진을 풀디코드(≈72MB)
|
||||
// 하지 않고 750×500(≈1MB)만 디코드해 CPU·메모리를 대폭 절감한다. 실패 시 아래로 폴백.
|
||||
// 상한 초과(대형 PNG/TIFF 등) — 스트리밍 경로. 여기서만 파일을 두 번 연다.
|
||||
let orientation = read_orientation(src);
|
||||
if is_jpeg_ext(src) {
|
||||
if let Some(scaled) = decode_jpeg_scaled(src, long_edge) {
|
||||
let oriented = apply_orientation(scaled, orientation);
|
||||
@@ -85,7 +107,6 @@ pub fn make_image_thumb(
|
||||
return Ok((w, h, dw, dh));
|
||||
}
|
||||
}
|
||||
|
||||
let decoded = image::ImageReader::open(src)?
|
||||
.with_guessed_format()?
|
||||
.decode()?;
|
||||
@@ -95,6 +116,35 @@ pub fn make_image_thumb(
|
||||
Ok((w, h, dw, dh))
|
||||
}
|
||||
|
||||
/// 메모리에 올린 파일 전체에서 썸네일을 만든다. 반환값은 (원본 w, 원본 h, 썸네일 w, 썸네일 h).
|
||||
fn thumb_from_whole_file(
|
||||
bytes: &[u8],
|
||||
dst: &Path,
|
||||
long_edge: u32,
|
||||
quality: u8,
|
||||
) -> Result<(u32, u32, u32, u32)> {
|
||||
let orientation = orientation_from_bytes(bytes);
|
||||
// JPEG 판정은 확장자가 아니라 매직바이트로 — .jpe/.jfif나 확장자가 틀린 파일도
|
||||
// DCT 축소 경로를 타게 된다(카메라·스캐너 출력에 흔하다).
|
||||
let fast = if is_jpeg_magic(bytes) {
|
||||
decode_jpeg_scaled_bytes(bytes, long_edge)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let decoded = match fast {
|
||||
Some(img) => img,
|
||||
None => image::load_from_memory(bytes)?, // CMYK JPEG·PNG·WebP 등
|
||||
};
|
||||
let oriented = apply_orientation(decoded, orientation);
|
||||
let (w, h) = (oriented.width(), oriented.height());
|
||||
let (dw, dh) = resize_encode(&oriented, dst, long_edge, quality)?;
|
||||
Ok((w, h, dw, dh))
|
||||
}
|
||||
|
||||
fn is_jpeg_magic(bytes: &[u8]) -> bool {
|
||||
bytes.len() > 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
|
||||
}
|
||||
|
||||
fn is_jpeg_ext(src: &Path) -> bool {
|
||||
src.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
@@ -105,9 +155,17 @@ fn is_jpeg_ext(src: &Path) -> bool {
|
||||
/// jpeg-decoder의 DCT 스케일 기능으로 축소 디코드한다. 긴 변이 대략 `target` 이상이 되는
|
||||
/// 가장 큰 축소(1/1·1/2·1/4·1/8)를 고른다. RGB24/L8만 처리하고 그 외(CMYK 등)는 None(폴백).
|
||||
fn decode_jpeg_scaled(src: &Path, target: u32) -> Option<DynamicImage> {
|
||||
use jpeg_decoder::PixelFormat;
|
||||
let file = std::fs::File::open(src).ok()?;
|
||||
let mut dec = jpeg_decoder::Decoder::new(std::io::BufReader::new(file));
|
||||
decode_jpeg_scaled_reader(std::io::BufReader::with_capacity(256 * 1024, file), target)
|
||||
}
|
||||
|
||||
fn decode_jpeg_scaled_bytes(bytes: &[u8], target: u32) -> Option<DynamicImage> {
|
||||
decode_jpeg_scaled_reader(std::io::Cursor::new(bytes), target)
|
||||
}
|
||||
|
||||
fn decode_jpeg_scaled_reader<R: std::io::Read>(r: R, target: u32) -> Option<DynamicImage> {
|
||||
use jpeg_decoder::PixelFormat;
|
||||
let mut dec = jpeg_decoder::Decoder::new(r);
|
||||
dec.read_info().ok()?;
|
||||
// 회전으로 가로/세로가 뒤바뀔 수 있으므로 두 축 모두 target으로 요청(축소만 하므로 안전).
|
||||
let t = target.clamp(1, u16::MAX as u32) as u16;
|
||||
@@ -201,15 +259,43 @@ pub fn scan_largest_jpeg(data: &[u8]) -> Option<&[u8]> {
|
||||
best.map(|(s, l)| &data[s..s + l])
|
||||
}
|
||||
|
||||
/// 파일 앞부분 최대 `limit` 바이트를 읽는다. 반환값이 `limit`보다 짧으면 EOF까지 읽은 것.
|
||||
fn read_head(path: &Path, limit: u64) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let file = std::fs::File::open(path)?;
|
||||
let mut buf = Vec::with_capacity(limit.min(4 * 1024 * 1024) as usize);
|
||||
file.take(limit).read_to_end(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// RAW 파일에서 내장 JPEG 프리뷰를 추출해 썸네일을 만든다. 반환: (썸네일 w, h).
|
||||
/// RAW 헤더/IFD 파싱 대신 파일에서 가장 큰 JPEG 스트림을 스캔한다(견고하고 포맷 불문).
|
||||
pub fn make_raw_thumb(src: &Path, dst: &Path, long_edge: u32, quality: u8) -> Result<(u32, u32)> {
|
||||
// RAW 프리뷰는 파일 뒷부분에 있을 수 있어 전체를 읽는다(보통 20–40MB).
|
||||
let data = std::fs::read(src)?;
|
||||
let jpeg = scan_largest_jpeg(&data)
|
||||
.ok_or_else(|| ThumbError::Resize("RAW 내장 JPEG 없음".into()))?;
|
||||
let img = image::load_from_memory(jpeg)
|
||||
.map_err(|e| ThumbError::Resize(format!("RAW 프리뷰 디코드 실패: {e}")))?;
|
||||
// 앞부분부터 단계적으로 읽는다. RAW는 보통 20–40MB인데 프리뷰 JPEG은 대개 헤더 근처에
|
||||
// 있다 — 매번 전체를 읽으면 RAW 10TB짜리 라이브러리에서 10TB를 다 긁는다. 4MB로
|
||||
// 쓸 만한 프리뷰(긴 변 ≥ 요청 크기)를 찾으면 거기서 멈춘다.
|
||||
const STAGES: [u64; 3] = [4 << 20, 32 << 20, u64::MAX];
|
||||
let mut best: Option<(DynamicImage, u32)> = None;
|
||||
for limit in STAGES {
|
||||
let data = read_head(src, limit)?;
|
||||
let eof = (data.len() as u64) < limit;
|
||||
if let Some(jpeg) = scan_largest_jpeg(&data) {
|
||||
if let Ok(img) = image::load_from_memory(jpeg) {
|
||||
let edge = img.width().max(img.height());
|
||||
if best.as_ref().map(|(_, b)| edge > *b).unwrap_or(true) {
|
||||
best = Some((img, edge));
|
||||
}
|
||||
// 요청 크기를 채우는 프리뷰면 더 읽을 이유가 없다
|
||||
if edge >= long_edge {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if eof {
|
||||
break; // 이미 파일 전체를 봤다
|
||||
}
|
||||
}
|
||||
let (img, _) = best.ok_or_else(|| ThumbError::Resize("RAW 내장 JPEG 없음".into()))?;
|
||||
// RAW도 EXIF orientation 적용
|
||||
let oriented = apply_orientation(img, read_orientation(src));
|
||||
resize_encode(&oriented, dst, long_edge, quality)
|
||||
@@ -280,6 +366,64 @@ mod tests {
|
||||
assert_eq!((w, h), (256, 192));
|
||||
}
|
||||
|
||||
fn jpeg_bytes(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbImage::from_fn(w, h, |x, y| {
|
||||
image::Rgb([(x % 256) as u8, (y % 256) as u8, 77])
|
||||
});
|
||||
let mut buf = Vec::new();
|
||||
image::DynamicImage::ImageRgb8(img)
|
||||
.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Jpeg)
|
||||
.unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
/// 확장자가 틀린 JPEG(.dat)도 매직바이트로 잡아 DCT 축소 경로를 탄다.
|
||||
#[test]
|
||||
fn mislabeled_jpeg_still_uses_dct_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let src = dir.path().join("mislabeled.dat");
|
||||
let bytes = jpeg_bytes(2400, 1600);
|
||||
std::fs::write(&src, &bytes).unwrap();
|
||||
|
||||
assert!(!is_jpeg_ext(&src), "확장자로는 JPEG이 아니다");
|
||||
assert!(is_jpeg_magic(&bytes));
|
||||
|
||||
let dst = dir.path().join("t/aa/k.jpg");
|
||||
let (w, h, dw, dh) = make_image_thumb(&src, &dst, 256, 80).unwrap();
|
||||
assert_eq!((dw, dh), (256, 170));
|
||||
// 축소 디코드를 탔다면 보고되는 원본 치수가 2400×1600보다 작다(1/8 = 300×200)
|
||||
assert!(w < 2400 && h < 1600, "축소 디코드 경로: {w}×{h}");
|
||||
}
|
||||
|
||||
/// RAW 프리뷰 탐색은 앞부분부터 단계적으로 읽는다:
|
||||
/// ① 4MB 안에 쓸 만한 프리뷰가 있으면 거기서 멈추고, ② 없으면 더 읽는다.
|
||||
#[test]
|
||||
fn raw_preview_read_is_staged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// ① 1MB 지점에 4:3 프리뷰, 4MB 뒤에 4:1 프리뷰 → 앞의 것으로 끝내야 한다
|
||||
let early = dir.path().join("early.cr2");
|
||||
let mut data = vec![0u8; 1 << 20];
|
||||
data.extend_from_slice(&jpeg_bytes(800, 600));
|
||||
data.resize(data.len() + (4 << 20), 0);
|
||||
data.extend_from_slice(&jpeg_bytes(1000, 250)); // 읽었다면 256×64가 된다
|
||||
std::fs::write(&early, &data).unwrap();
|
||||
let dst = dir.path().join("t/e.jpg");
|
||||
assert_eq!(
|
||||
make_raw_thumb(&early, &dst, 256, 80).unwrap(),
|
||||
(256, 192),
|
||||
"4MB 안의 프리뷰에서 멈춰야 한다"
|
||||
);
|
||||
|
||||
// ② 앞 5MB에는 JPEG이 없다 → 다음 단계로 넓혀 읽어 찾아낸다
|
||||
let late = dir.path().join("late.nef");
|
||||
let mut data = vec![0u8; 5 << 20];
|
||||
data.extend_from_slice(&jpeg_bytes(800, 600));
|
||||
std::fs::write(&late, &data).unwrap();
|
||||
let dst = dir.path().join("t/l.jpg");
|
||||
assert_eq!(make_raw_thumb(&late, &dst, 256, 80).unwrap(), (256, 192));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_largest_jpeg_picks_biggest() {
|
||||
// 작은 JPEG + 큰 JPEG 섞인 데이터에서 큰 것을 고른다
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
//! 회전·반전을 **실제 파일에** 적용해 본다.
|
||||
//!
|
||||
//! 단위 테스트는 바이트 조작과 방향값 계산까지만 본다. 실제로는
|
||||
//! 앱의 EXIF 리더가 내가 쓴 값을 못 읽거나, ffmpeg 회전 부호가 반대이거나,
|
||||
//! 임시 파일 바꿔치기가 실패하는 식으로 죽는다.
|
||||
//!
|
||||
//! 그리고 **무손실이라는 주장을 실제로 검산한다** — 화상 데이터가 한 바이트도
|
||||
//! 안 바뀌었는지, 영상은 스트림 복사가 맞는지.
|
||||
|
||||
use archive_indexer::edit::*;
|
||||
use archive_indexer::ffmpeg::{self, FfTools};
|
||||
use archive_indexer::thumbs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn repo_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap()
|
||||
}
|
||||
|
||||
fn tools() -> Option<FfTools> {
|
||||
let bin = repo_root().join("src-tauri/binaries");
|
||||
let triple = if cfg!(all(windows, target_arch = "x86_64")) {
|
||||
"x86_64-pc-windows-msvc"
|
||||
} else {
|
||||
"aarch64-pc-windows-msvc"
|
||||
};
|
||||
let ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
let (a, b) = (
|
||||
bin.join(format!("ffmpeg-{triple}{ext}")),
|
||||
bin.join(format!("ffprobe-{triple}{ext}")),
|
||||
);
|
||||
(a.is_file() && b.is_file()).then_some(FfTools { ffmpeg: a, ffprobe: b })
|
||||
}
|
||||
|
||||
fn workdir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join("archive-edit-test").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// 사진 회전은 **앱이 실제로 쓰는 EXIF 리더**가 읽을 수 있어야 한다.
|
||||
/// 내가 쓴 바이트를 나만 읽을 수 있으면 썸네일이 안 돌아간다.
|
||||
#[test]
|
||||
fn jpeg_rotation_is_lossless_and_readable() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = workdir("jpeg");
|
||||
let src = dir.join("photo.jpg");
|
||||
let out = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=800x600:rate=1:d=1"])
|
||||
.args(["-frames:v", "1", "-q:v", "3"])
|
||||
.arg(&src)
|
||||
.output()
|
||||
.expect("ffmpeg 실행 실패");
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let before = std::fs::read(&src).unwrap();
|
||||
let start = thumbs::read_orientation(&src);
|
||||
println!("원본 방향 = {start}");
|
||||
|
||||
// 시계 90°
|
||||
let r = rotate_image(&src, start as u8, EditOp::RotateCw).expect("회전 실패");
|
||||
assert!(!r.reencoded, "JPEG인데 다시 인코딩했다");
|
||||
assert!(r.swapped, "90° 회전은 가로·세로가 바뀐다");
|
||||
|
||||
// 앱의 리더가 새 값을 읽는가
|
||||
let now = thumbs::read_orientation(&src);
|
||||
assert_eq!(now as u8, apply_orientation(start as u8, EditOp::RotateCw), "EXIF 값이 안 맞는다");
|
||||
println!("회전 후 방향 = {now}");
|
||||
|
||||
// 화상 데이터는 그대로인가 — 길이가 같고 바뀐 바이트가 한 개여야 한다
|
||||
let after = std::fs::read(&src).unwrap();
|
||||
if before.len() == after.len() {
|
||||
let diff = before.iter().zip(&after).filter(|(a, b)| a != b).count();
|
||||
assert_eq!(diff, 1, "바뀐 바이트가 {diff}개 — 무손실이 아니다");
|
||||
println!("바뀐 바이트 1개 (EXIF 방향 태그) — 무손실 확인");
|
||||
} else {
|
||||
// EXIF가 없던 파일이면 세그먼트가 삽입되어 길어진다. 그래도 화상 데이터는 그대로여야 한다.
|
||||
let grew = after.len() - before.len();
|
||||
assert_eq!(&after[after.len() - (before.len() - 2)..], &before[2..], "화상 데이터가 바뀌었다");
|
||||
println!("EXIF {grew}바이트 삽입 — 화상 데이터는 그대로");
|
||||
}
|
||||
|
||||
// 이미 한 번 돌렸으니 세 번 더 = 네 바퀴 → 원래 방향으로 돌아와야 한다
|
||||
for _ in 0..3 {
|
||||
let cur = thumbs::read_orientation(&src) as u8;
|
||||
rotate_image(&src, cur, EditOp::RotateCw).expect("회전 실패");
|
||||
}
|
||||
assert_eq!(thumbs::read_orientation(&src), start, "네 바퀴 돌렸는데 제자리가 아니다");
|
||||
println!("네 바퀴 → 원래 방향 {start} 복귀");
|
||||
}
|
||||
|
||||
/// 영상 회전은 스트림 복사여야 한다 — 다시 인코딩하면 30분짜리가 몇 분씩 걸린다.
|
||||
#[test]
|
||||
fn video_rotation_is_stream_copy() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = workdir("video");
|
||||
let src = dir.join("clip.mp4");
|
||||
let out = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=640x480:rate=10:d=3"])
|
||||
.args(["-f", "lavfi", "-i", "sine=frequency=440:d=3"])
|
||||
.args(["-c:v", "libopenh264", "-b:v", "1M", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"])
|
||||
.arg(&src)
|
||||
.output()
|
||||
.expect("ffmpeg 실행 실패");
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let size_before = std::fs::metadata(&src).unwrap().len();
|
||||
assert_eq!(video_rotation(&t, &src), 0, "새로 만든 영상은 회전이 0이어야 한다");
|
||||
|
||||
// 90° — 메타데이터만
|
||||
let r = rotate_video(&t, &src, EditOp::RotateCw, "libopenh264").expect("회전 실패");
|
||||
assert!(!r.reencoded, "스트림 복사가 아니다");
|
||||
assert_eq!(r.rotation, Some(90));
|
||||
assert_eq!(video_rotation(&t, &src), 90, "파일에 90°가 안 적혔다");
|
||||
let size_after = std::fs::metadata(&src).unwrap().len();
|
||||
// 회전 행렬만 바뀌므로 크기 차이는 무시할 만해야 한다
|
||||
let delta = (size_after as i64 - size_before as i64).abs();
|
||||
assert!(delta < 4096, "크기가 {delta}바이트 바뀌었다 — 재인코딩된 것 같다");
|
||||
println!("영상 90° 회전 — 크기 차이 {delta}바이트 (스트림 복사)");
|
||||
|
||||
// 한 번 더 돌리면 180°가 되어야 한다 (덮어쓰기가 아니라 누적)
|
||||
rotate_video(&t, &src, EditOp::RotateCw, "libopenh264").expect("회전 실패");
|
||||
assert_eq!(video_rotation(&t, &src), 180, "두 번 돌렸는데 누적되지 않았다");
|
||||
println!("두 번째 회전 후 180° — 누적 확인");
|
||||
|
||||
// 소리는 살아 있는가
|
||||
let info = ffmpeg::probe(&t, &src).unwrap();
|
||||
assert!(info.acodec.is_some(), "소리가 사라졌다");
|
||||
}
|
||||
|
||||
/// 반전은 회전 행렬로 표현할 수 없어 다시 인코딩한다 — 그래도 결과가 나와야 한다.
|
||||
#[test]
|
||||
fn video_flip_reencodes_but_works() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = workdir("flip");
|
||||
let src = dir.join("clip.mp4");
|
||||
let out = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=10:d=2"])
|
||||
.args(["-c:v", "libopenh264", "-b:v", "500k", "-pix_fmt", "yuv420p"])
|
||||
.arg(&src)
|
||||
.output()
|
||||
.expect("ffmpeg 실행 실패");
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let r = rotate_video(&t, &src, EditOp::FlipH, "libopenh264").expect("반전 실패");
|
||||
assert!(r.reencoded, "반전은 다시 인코딩해야 한다");
|
||||
let info = ffmpeg::probe(&t, &src).unwrap();
|
||||
assert_eq!((info.width, info.height), (Some(320), Some(240)), "반전은 크기를 안 바꾼다");
|
||||
println!("영상 좌우반전 — 재인코딩 (회전 행렬로는 표현 불가)");
|
||||
}
|
||||
|
||||
/// 실패해도 원본이 반쯤 쓰이면 안 된다 — 쓸 수 없는 경로에 대고 확인한다.
|
||||
#[test]
|
||||
fn failure_leaves_original_intact() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = workdir("fail");
|
||||
// 영상이 아닌 파일을 영상 회전에 넘긴다 — ffmpeg이 거부해야 한다
|
||||
let src = dir.join("notvideo.mp4");
|
||||
std::fs::write(&src, b"this is not a video").unwrap();
|
||||
let before = std::fs::read(&src).unwrap();
|
||||
|
||||
let r = rotate_video(&t, &src, EditOp::RotateCw, "libopenh264");
|
||||
assert!(r.is_err(), "잘못된 입력인데 성공했다");
|
||||
assert_eq!(std::fs::read(&src).unwrap(), before, "실패했는데 원본이 바뀌었다");
|
||||
// 임시 파일도 남으면 안 된다
|
||||
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.filter(|n| n.contains("tmp-edit"))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "임시 파일이 남았다: {leftovers:?}");
|
||||
println!("실패 시 원본 보존 · 임시 파일 없음 확인");
|
||||
}
|
||||
|
||||
/// **"시계 방향"이 정말 시계 방향인가.**
|
||||
///
|
||||
/// 회전 각도를 읽고 쓰는 것만 확인하면 부호가 반대여도 테스트가 통과한다 —
|
||||
/// 그러면 사용자가 오른쪽 회전을 눌렀는데 왼쪽으로 돈다. 그래서 실제 화소로 검산한다.
|
||||
/// 기준은 ffmpeg의 `transpose=1`(= 시계 90°)로 직접 돌린 결과다.
|
||||
#[test]
|
||||
fn clockwise_actually_turns_clockwise() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = workdir("direction");
|
||||
// 네 모서리 색이 다른 영상 — 어느 쪽으로 돌았는지 화소로 구별된다
|
||||
let src = dir.join("corners.mp4");
|
||||
let lavfi = "color=c=red:s=64x64,pad=128:128:0:0:black[a];\
|
||||
color=c=lime:s=64x64[b];[a][b]overlay=64:0";
|
||||
let out = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-v", "error", "-y", "-f", "lavfi", "-i", &format!("{lavfi},format=yuv420p,trim=duration=1")])
|
||||
.args(["-c:v", "libopenh264", "-b:v", "500k", "-r", "10"])
|
||||
.arg(&src)
|
||||
.output()
|
||||
.expect("ffmpeg 실행 실패");
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// 기준: transpose=1 로 실제 화소를 시계 90° 돌린 결과
|
||||
let want = dir.join("want.png");
|
||||
let o = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-v", "error", "-y", "-noautorotate"])
|
||||
.arg("-i").arg(&src)
|
||||
.args(["-vf", "transpose=1", "-frames:v", "1"])
|
||||
.arg(&want)
|
||||
.output()
|
||||
.expect("ffmpeg 실행 실패");
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
|
||||
// 우리 코드로 메타데이터만 회전
|
||||
rotate_video(&t, &src, EditOp::RotateCw, "libopenh264").expect("회전 실패");
|
||||
|
||||
// 회전을 적용해(기본 autorotate) 한 프레임 뽑는다
|
||||
let got = dir.join("got.png");
|
||||
let o = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-v", "error", "-y"])
|
||||
.arg("-i").arg(&src)
|
||||
.args(["-frames:v", "1"])
|
||||
.arg(&got)
|
||||
.output()
|
||||
.expect("ffmpeg 실행 실패");
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
|
||||
let a = image::open(&want).unwrap().to_rgb8();
|
||||
let b = image::open(&got).unwrap().to_rgb8();
|
||||
assert_eq!(a.dimensions(), b.dimensions(), "회전 후 크기가 기준과 다르다");
|
||||
|
||||
// 압축 손실이 있으므로 완전 일치는 못 한다 — 평균 차이로 본다
|
||||
let n = (a.width() * a.height() * 3) as f64;
|
||||
let diff: f64 = a.as_raw().iter().zip(b.as_raw()).map(|(x, y)| (*x as f64 - *y as f64).abs()).sum::<f64>() / n;
|
||||
println!("시계 90° — 기준(transpose=1) 대비 평균 화소 차이 {diff:.1}");
|
||||
assert!(diff < 12.0, "시계 방향이 아니다 — 평균 차이 {diff:.1}");
|
||||
}
|
||||
|
||||
/// **회전된 사진을 자를 때 좌표가 맞는가.**
|
||||
///
|
||||
/// 사용자는 화면에서 사각형을 끈다. 그런데 EXIF 방향이 붙어 있으면 저장된 화소 배치와
|
||||
/// 화면 배치가 다르다 — 그대로 자르면 엉뚱한 데가 잘린다.
|
||||
/// 기준은 ffmpeg이다: ffmpeg은 EXIF를 적용해 디코드하므로, 거기서 같은 좌표로 자른 결과와
|
||||
/// 우리 결과가 같아야 한다.
|
||||
#[test]
|
||||
fn crop_uses_display_coordinates_on_rotated_image() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = workdir("crop-img");
|
||||
// 좌우가 확실히 다른 그림 — 어디를 잘랐는지 화소로 구별된다
|
||||
let src = dir.join("p.jpg");
|
||||
let lavfi = "color=c=red:s=200x100,pad=400:100:0:0:blue";
|
||||
let out = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-v", "error", "-y", "-f", "lavfi", "-i", &format!("{lavfi},format=yuvj420p")])
|
||||
.args(["-frames:v", "1", "-q:v", "2"])
|
||||
.arg(&src)
|
||||
.output()
|
||||
.expect("ffmpeg 실행 실패");
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// 시계 90° 회전 → 화면상 100×400 이 된다
|
||||
rotate_image(&src, 1, EditOp::RotateCw).expect("회전 실패");
|
||||
let o = thumbs::read_orientation(&src) as u8;
|
||||
assert_eq!(o, 6, "EXIF 방향이 6(시계90°)이어야 한다");
|
||||
|
||||
// 기준: ffmpeg으로 같은 영역을 자른다 (ffmpeg은 EXIF를 적용해 디코드한다)
|
||||
let want = dir.join("want.png");
|
||||
let r = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-v", "error", "-y"])
|
||||
.arg("-i").arg(&src)
|
||||
.args(["-vf", "crop=100:120:0:0", "-frames:v", "1"])
|
||||
.arg(&want)
|
||||
.output()
|
||||
.expect("ffmpeg 실행 실패");
|
||||
assert!(r.status.success(), "{}", String::from_utf8_lossy(&r.stderr));
|
||||
let wi = image::open(&want).unwrap().to_rgb8();
|
||||
assert_eq!(wi.dimensions(), (100, 120), "기준이 화면 좌표로 잘리지 않았다");
|
||||
|
||||
// 우리 코드로 같은 영역을 자른다
|
||||
let res = crop_image(&src, CropRect { x: 0, y: 0, w: 100, h: 120 }, o, None).expect("자르기 실패");
|
||||
assert_eq!(res.new_size, Some((100, 120)));
|
||||
assert_eq!(res.orientation, Some(1), "방향을 화소에 구워 넣었으면 태그는 1이어야 한다");
|
||||
let gi = image::open(&src).unwrap().to_rgb8();
|
||||
assert_eq!(gi.dimensions(), (100, 120), "잘린 파일 크기가 다르다");
|
||||
|
||||
let n = (100 * 120 * 3) as f64;
|
||||
let diff: f64 = wi.as_raw().iter().zip(gi.as_raw()).map(|(a, b)| (*a as f64 - *b as f64).abs()).sum::<f64>() / n;
|
||||
println!("회전된 사진 자르기 — ffmpeg 기준 대비 평균 화소 차이 {diff:.1}");
|
||||
assert!(diff < 12.0, "엉뚱한 곳을 잘랐다 — 평균 차이 {diff:.1}");
|
||||
}
|
||||
|
||||
/// 영상 영역 자르기 — 회전 메타가 있어도 화면 좌표로 자르고, 결과에는 회전이 남지 않는다.
|
||||
#[test]
|
||||
fn video_crop_uses_display_coordinates() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = workdir("crop-vid");
|
||||
let src = dir.join("v.mp4");
|
||||
let out = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=640x480:rate=10:d=1"])
|
||||
.args(["-c:v", "libopenh264", "-b:v", "500k", "-pix_fmt", "yuv420p"])
|
||||
.arg(&src)
|
||||
.output()
|
||||
.expect("ffmpeg 실행 실패");
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
// 회전을 걸어 화면상 480×640으로 만든다
|
||||
rotate_video(&t, &src, EditOp::RotateCw, "libopenh264").expect("회전 실패");
|
||||
|
||||
// 400×600 은 화면(480×640)에만 들어간다 — 저장(640×480) 기준이면 실패할 크기
|
||||
let r = crop_video(&t, &src, CropRect { x: 0, y: 0, w: 400, h: 600 }, "libopenh264", 800, None)
|
||||
.expect("자르기 실패");
|
||||
assert_eq!(r.new_size, Some((400, 600)));
|
||||
let info = ffmpeg::probe(&t, &src).unwrap();
|
||||
assert_eq!((info.width, info.height), (Some(400), Some(600)), "화면 좌표로 잘리지 않았다");
|
||||
assert_eq!(video_rotation(&t, &src), 0, "회전이 남아 있으면 재생기가 한 번 더 돌린다");
|
||||
println!("영상 영역 자르기 400×600 — 회전 메타 제거됨");
|
||||
}
|
||||
|
||||
/// 구간 자르기는 스트림 복사 — 무손실이고 소리도 함께 잘린다.
|
||||
#[test]
|
||||
fn trim_is_lossless_and_keeps_audio() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = workdir("trim");
|
||||
let src = dir.join("v.mp4");
|
||||
// 4초 · 소리 있음 — 구간 자르기가 소리도 같이 자르는지 봐야 한다
|
||||
let out = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=10:d=4"])
|
||||
.args(["-f", "lavfi", "-i", "sine=frequency=440:d=4"])
|
||||
.args(["-c:v", "libopenh264", "-b:v", "500k", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"])
|
||||
.arg(&src)
|
||||
.output()
|
||||
.expect("ffmpeg 실행 실패");
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let before = ffmpeg::probe(&t, &src).unwrap();
|
||||
assert!(before.duration_ms.unwrap_or(0) > 3000);
|
||||
|
||||
let r = trim_video(&t, &src, 1000, 3000, None).expect("구간 자르기 실패");
|
||||
assert!(!r.reencoded, "구간 자르기는 무손실이어야 한다");
|
||||
let after = ffmpeg::probe(&t, &src).unwrap();
|
||||
let d = after.duration_ms.unwrap_or(0);
|
||||
assert!((d - 2000).abs() < 300, "2초를 요청했는데 {d}ms 다");
|
||||
assert!(after.acodec.is_some(), "소리가 사라졌다");
|
||||
assert_eq!(after.vcodec, before.vcodec, "영상이 재인코딩됐다");
|
||||
assert_eq!(after.acodec, before.acodec, "소리가 재인코딩됐다");
|
||||
println!("구간 자르기 {d}ms — 스트림 복사, 소리 보존");
|
||||
|
||||
// 끝이 시작보다 앞이면 거부
|
||||
assert!(trim_video(&t, &src, 2000, 1000, None).is_err());
|
||||
}
|
||||
|
||||
/// 자를 영역은 이미지 밖으로 나가지 않고 항상 짝수다.
|
||||
#[test]
|
||||
fn crop_rect_is_clamped_and_even() {
|
||||
for (iw, ih) in [(640u32, 480u32), (101, 103), (4, 4)] {
|
||||
for r in [
|
||||
CropRect { x: 0, y: 0, w: 99999, h: 99999 },
|
||||
CropRect { x: 99999, y: 99999, w: 10, h: 10 },
|
||||
CropRect { x: 3, y: 5, w: 7, h: 9 },
|
||||
] {
|
||||
if let Some(c) = r.clamp_to(iw, ih) {
|
||||
assert_eq!(c.w % 2, 0, "{c:?}");
|
||||
assert_eq!(c.h % 2, 0, "{c:?}");
|
||||
assert!(c.x + c.w <= iw, "{c:?} > {iw}");
|
||||
assert!(c.y + c.h <= ih, "{c:?} > {ih}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 자르기는 되돌릴 수 없다 — 그래서 원본을 **복사하지 않고 옮겨** 보관한다.
|
||||
/// 백업이 있으면 원본 바이트가 그대로 남아 있어야 하고, 없으면 그냥 덮어써야 한다.
|
||||
#[test]
|
||||
fn crop_moves_original_to_backup() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = workdir("backup");
|
||||
let src = dir.join("p.jpg");
|
||||
let out = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=400x300:rate=1:d=1"])
|
||||
.args(["-frames:v", "1", "-q:v", "2"])
|
||||
.arg(&src)
|
||||
.output()
|
||||
.expect("ffmpeg 실행 실패");
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let original = std::fs::read(&src).unwrap();
|
||||
|
||||
let bk = dir.join(".trash").join("p.jpg");
|
||||
crop_image(&src, CropRect { x: 10, y: 10, w: 200, h: 100 }, 1, Some(&bk)).expect("자르기 실패");
|
||||
|
||||
assert!(bk.is_file(), "백업이 없다");
|
||||
assert_eq!(std::fs::read(&bk).unwrap(), original, "백업이 원본과 다르다");
|
||||
let cropped = image::open(&src).unwrap();
|
||||
assert_eq!((cropped.width(), cropped.height()), (200, 100));
|
||||
println!("자르기 → 원본은 백업으로 이동, 새 파일 200×100");
|
||||
|
||||
// 백업에서 되돌리면 원본 그대로여야 한다
|
||||
std::fs::rename(&bk, &src).unwrap();
|
||||
assert_eq!(std::fs::read(&src).unwrap(), original, "되돌린 파일이 원본과 다르다");
|
||||
println!("백업 복원 — 바이트 단위로 원본과 동일");
|
||||
}
|
||||
|
||||
/// 새 파일을 못 앉히면 원본이 제자리로 돌아와야 한다 — 사라지면 안 된다.
|
||||
#[test]
|
||||
fn failed_placement_restores_the_original() {
|
||||
let dir = workdir("place-fail");
|
||||
let src = dir.join("p.jpg");
|
||||
std::fs::write(&src, b"original bytes").unwrap();
|
||||
// 백업 경로를 폴더로 만들어 두면 rename(dst → backup)이 실패한다
|
||||
let bk = dir.join("busy");
|
||||
std::fs::create_dir_all(bk.join("sub")).unwrap();
|
||||
|
||||
let r = crop_image(&src, CropRect { x: 0, y: 0, w: 10, h: 10 }, 1, Some(&bk));
|
||||
assert!(r.is_err(), "잘못된 백업 경로인데 성공했다");
|
||||
assert_eq!(std::fs::read(&src).unwrap(), b"original bytes", "원본이 사라졌다");
|
||||
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.filter(|n| n.contains("tmp-edit"))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "임시 파일이 남았다: {leftovers:?}");
|
||||
println!("배치 실패 시 원본 보존 · 임시 파일 없음");
|
||||
}
|
||||
|
||||
/// **편집한 파일이 실제로 디코드되는가.**
|
||||
///
|
||||
/// ffprobe는 컨테이너만 읽는다 — 헤더가 멀쩡하면 비트스트림이 깨져도 정상으로 보인다.
|
||||
/// 실제로 반전 경로가 `-b:v 0k`로 재인코딩해 **재생 불가능한 파일**을 만든 적이 있는데,
|
||||
/// 크기·코덱·길이를 확인하는 테스트는 전부 통과했다. 그래서 화소까지 뽑아 본다.
|
||||
fn decodes_ok(t: &FfTools, p: &Path) -> bool {
|
||||
let out = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-v", "error", "-nostdin", "-xerror"])
|
||||
.arg("-i")
|
||||
.arg(p)
|
||||
.args(["-an", "-sn", "-vf", "fps=2,scale=32:32,format=gray", "-f", "rawvideo", "-"])
|
||||
.output();
|
||||
match out {
|
||||
Ok(o) => o.status.success() && !o.stdout.is_empty() && o.stderr.is_empty(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_edit_leaves_a_decodable_file() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let ops: [(&str, EditOp); 5] = [
|
||||
("시계90", EditOp::RotateCw),
|
||||
("반시계90", EditOp::RotateCcw),
|
||||
("180", EditOp::Rotate180),
|
||||
("좌우반전", EditOp::FlipH),
|
||||
("상하반전", EditOp::FlipV),
|
||||
];
|
||||
for (name, op) in ops {
|
||||
let dir = workdir(&format!("decodable-{}", op as u8 as char as u32));
|
||||
let src = dir.join("v.mp4");
|
||||
let out = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=10:d=2"])
|
||||
.args(["-c:v", "libopenh264", "-b:v", "500k", "-pix_fmt", "yuv420p"])
|
||||
.arg(&src)
|
||||
.output()
|
||||
.expect("ffmpeg 실행 실패");
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// 라이브러리가 실제로 고르는 인코더로 — 하드웨어 경로에서만 깨질 수도 있다
|
||||
let enc = h264_encoder(&t);
|
||||
let r = rotate_video(&t, &src, op, enc).unwrap_or_else(|e| panic!("{name} 실패: {e}"));
|
||||
assert!(decodes_ok(&t, &src), "{name} 뒤 파일이 디코드되지 않는다 (인코더 {enc})");
|
||||
println!("{name:<10} 재인코딩={} · 디코드 정상", r.reencoded);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! (없으면 조용히 통과 — CI에서 fetch-ffmpeg 후 실행하면 전체 커버)
|
||||
|
||||
use archive_indexer::ffmpeg::{self, FfTools};
|
||||
use archive_indexer::sigmatch;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn tools() -> Option<FfTools> {
|
||||
@@ -66,3 +67,205 @@ fn probe_and_thumbnail_roundtrip() {
|
||||
assert!(thumb.is_file());
|
||||
assert!(w == 256 || h == 256, "긴 변이 256이어야 함: {w}x{h}");
|
||||
}
|
||||
|
||||
// ── 지문 추출 (단일 패스) ─────────────────────────────────
|
||||
|
||||
const ENC: &str = if cfg!(windows) { "h264_mf" } else { "h264_videotoolbox" };
|
||||
|
||||
/// 합성 영상을 만든다. `extra`는 입력 뒤에 붙는 추가 인자
|
||||
/// (두 번째 입력을 붙이는 호출이 있으므로 여기서 `-t` 같은 입력 옵션을 끼워 넣으면 안 된다).
|
||||
fn make_video(t: &FfTools, out: &Path, secs: u32, extra: &[&str]) {
|
||||
let src = format!("testsrc2=duration={secs}:size=320x240:rate=25");
|
||||
let mut cmd = ffmpeg::command(&t.ffmpeg);
|
||||
cmd.args(["-y", "-v", "error", "-f", "lavfi", "-i", &src]);
|
||||
cmd.args(extra);
|
||||
cmd.args(["-c:v", ENC, "-b:v", "300k"]).arg(out);
|
||||
let status = cmd.status().expect("ffmpeg 실행 실패");
|
||||
assert!(status.success(), "테스트 영상 생성 실패: {}", out.display());
|
||||
}
|
||||
|
||||
/// 프레임마다 구조가 크게 달라지는 영상 — **오프셋 투표를 검증할 때는 이걸 써야 한다.**
|
||||
///
|
||||
/// testsrc2는 320×240을 32×32 그레이로 줄여 9×8 dHash를 뽑으면 프레임끼리 전부 해밍 8 안에
|
||||
/// 들어온다(실측: 24프레임 중 고유 19개인데 **모든 오프셋 버킷이 똑같이 10표**). 즉 정렬을
|
||||
/// 판별할 정보가 남지 않아 어느 오프셋이 1위가 되는지는 우연이다.
|
||||
/// 확대되는 mandelbrot은 매 프레임이 달라 오프셋이 유일하게 정해진다(4000ms×10 vs 3500ms×9).
|
||||
fn make_diverse_video(t: &FfTools, out: &Path, secs: u32) {
|
||||
let mut cmd = ffmpeg::command(&t.ffmpeg);
|
||||
// mandelbrot은 무한 소스라 duration이 없다 → -t로 끊는다
|
||||
cmd.args(["-y", "-v", "error", "-f", "lavfi", "-i", "mandelbrot=size=320x240:rate=25"]);
|
||||
cmd.args(["-t", &secs.to_string()]);
|
||||
cmd.args(["-c:v", ENC, "-b:v", "300k"]).arg(out);
|
||||
let status = cmd.status().expect("ffmpeg 실행 실패");
|
||||
assert!(status.success(), "테스트 영상 생성 실패: {}", out.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_sequence_is_single_pass_and_uniformly_sampled() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let video = dir.path().join("샘플.mp4");
|
||||
make_video(&t, &video, 6, &[]);
|
||||
|
||||
let frames = ffmpeg::frame_gray_sequence(
|
||||
&t,
|
||||
&video,
|
||||
1000, // 1초 간격
|
||||
sigmatch::FRAME as u32,
|
||||
std::time::Duration::from_secs(60),
|
||||
None,
|
||||
)
|
||||
.expect("프레임 시퀀스 추출 실패");
|
||||
|
||||
// 6초 영상에서 1초 간격 → 6장 안팎
|
||||
assert!((5..=7).contains(&frames.len()), "프레임 수 {}", frames.len());
|
||||
for (i, (t_ms, px)) in frames.iter().enumerate() {
|
||||
assert_eq!(px.len(), sigmatch::FRAME_LEN, "프레임 크기가 32×32여야 한다");
|
||||
assert_eq!(*t_ms, i as u32 * 1000, "시각은 균등 간격이어야 한다");
|
||||
assert!(sigmatch::derive(px).is_some(), "파생 지문 계산 실패");
|
||||
}
|
||||
|
||||
// testsrc2는 시간에 따라 화면이 변한다 → 연속 프레임의 해시가 달라야 한다
|
||||
let a = sigmatch::derive(&frames[0].1).unwrap();
|
||||
let b = sigmatch::derive(&frames[frames.len() - 1].1).unwrap();
|
||||
assert!(
|
||||
sigmatch::hamming(a.dhash, b.dhash) > 0,
|
||||
"서로 다른 장면인데 해시가 같다"
|
||||
);
|
||||
}
|
||||
|
||||
/// 이번 고도화의 핵심 검증 — **긴 영상에서 잘라낸 구간을 재인코딩해도 찾아내는지.**
|
||||
/// (예전 구현은 duration ±5% 프리필터 때문에 이 케이스를 원천적으로 놓쳤다)
|
||||
///
|
||||
/// 소재는 mandelbrot이어야 한다 — 이유는 `make_diverse_video` 주석 참조.
|
||||
#[test]
|
||||
fn clipped_segment_is_matched_with_correct_offset() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let full = dir.path().join("원본.mp4");
|
||||
make_diverse_video(&t, &full, 12);
|
||||
|
||||
// 원본의 4초~9초 구간을 다시 인코딩 (바이트는 완전히 다르다)
|
||||
let clip = dir.path().join("구간.mp4");
|
||||
let status = ffmpeg::command(&t.ffmpeg)
|
||||
.args(["-y", "-v", "error", "-ss", "4", "-t", "5"])
|
||||
.arg("-i")
|
||||
.arg(&full)
|
||||
.args(["-c:v", ENC, "-b:v", "200k"])
|
||||
.arg(&clip)
|
||||
.status()
|
||||
.expect("ffmpeg 실행 실패");
|
||||
assert!(status.success(), "구간 추출 실패");
|
||||
|
||||
let seq = |p: &Path| -> Vec<(u32, u64)> {
|
||||
ffmpeg::frame_gray_sequence(&t, p, 500, sigmatch::FRAME as u32, std::time::Duration::from_secs(120), None)
|
||||
.expect("프레임 시퀀스 추출 실패")
|
||||
.iter()
|
||||
.filter_map(|(ms, px)| sigmatch::derive(px).map(|s| (*ms, s.dhash)))
|
||||
.collect()
|
||||
};
|
||||
let a = seq(&full);
|
||||
let b = seq(&clip);
|
||||
assert!(a.len() >= 20 && b.len() >= 8, "a={} b={}", a.len(), b.len());
|
||||
|
||||
let m = sigmatch::offset_vote(&a, &b, 8, 500, 4).expect("부분 구간을 찾지 못했다");
|
||||
assert!(
|
||||
(3500..=4500).contains(&m.offset_ms),
|
||||
"오프셋이 4초 근처여야 한다 (실제 {}ms, 지지 {})",
|
||||
m.offset_ms,
|
||||
m.support
|
||||
);
|
||||
assert!(m.a_span_ms() >= 3000, "일치 구간이 3초 이상이어야 한다 ({}ms)", m.a_span_ms());
|
||||
}
|
||||
|
||||
/// 오디오 지문 — 번들 ffmpeg의 chromaprint로 새 의존성 없이 얻는다.
|
||||
#[test]
|
||||
fn audio_fingerprint_is_available_and_partial_matchable() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let with_audio = dir.path().join("소리있음.mp4");
|
||||
make_video(
|
||||
&t,
|
||||
&with_audio,
|
||||
20,
|
||||
&["-f", "lavfi", "-i", "sine=frequency=440:duration=20", "-c:a", "aac", "-shortest"],
|
||||
);
|
||||
|
||||
let fp = ffmpeg::audio_fingerprint(&t, &with_audio, std::time::Duration::from_secs(60))
|
||||
.expect("오디오 지문 실패");
|
||||
// 실측 기준 약 7.9개/초
|
||||
assert!(fp.len() >= 100, "20초 오디오의 지문 항목 수 {}", fp.len());
|
||||
|
||||
// 오디오가 없는 영상은 빈 지문 (오류가 아니어야 한다)
|
||||
let silent = dir.path().join("소리없음.mp4");
|
||||
make_video(&t, &silent, 2, &[]);
|
||||
let empty = ffmpeg::audio_fingerprint(&t, &silent, std::time::Duration::from_secs(60))
|
||||
.expect("무성 영상에서 오류가 나면 안 된다");
|
||||
assert!(empty.is_empty(), "무성 영상은 빈 지문이어야 한다 ({}개)", empty.len());
|
||||
}
|
||||
|
||||
/// 프레임과 오디오 지문을 **한 번의 디코드**로 받는다 — NAS에서 파일을 두 번 읽지 않기 위해서다.
|
||||
///
|
||||
/// 여기서 고정하는 실패 모드: 오디오 스트림이 없는 파일에 chromaprint 출력을 붙이면 ffmpeg가
|
||||
/// `Output file does not contain any stream`으로 **출력 초기화에서** 죽어 프레임까지 0장이 된다.
|
||||
/// 그래서 호출부는 오디오가 있다고 아는 파일에만 켜고, 그래도 실패하면 오디오 없이 재시도한다.
|
||||
#[test]
|
||||
fn one_decode_yields_both_frames_and_audio_fingerprint() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let timeout = std::time::Duration::from_secs(120);
|
||||
|
||||
let with_audio = dir.path().join("소리있음.mp4");
|
||||
make_video(
|
||||
&t,
|
||||
&with_audio,
|
||||
10,
|
||||
&["-f", "lavfi", "-i", "sine=frequency=440:duration=10", "-c:a", "aac", "-shortest"],
|
||||
);
|
||||
let fp_out = dir.path().join("fp.raw");
|
||||
let (frames, fp) =
|
||||
ffmpeg::frame_gray_sequence_with_audio(&t, &with_audio, 1000, 32, timeout, None, Some(&fp_out))
|
||||
.expect("한 패스 추출 실패");
|
||||
assert!(frames.len() >= 8, "10초에서 프레임 {}장", frames.len());
|
||||
assert!(fp.len() >= 50, "10초 오디오 지문 {}개", fp.len());
|
||||
assert!(!fp_out.exists(), "임시 지문 파일은 읽은 뒤 지워야 한다");
|
||||
|
||||
// 오디오가 없는 파일에 지문 출력을 붙여도 **프레임은 살아야 한다** (폴백 경로)
|
||||
let silent = dir.path().join("소리없음.mp4");
|
||||
make_video(&t, &silent, 4, &[]);
|
||||
let fp_out2 = dir.path().join("fp2.raw");
|
||||
let (frames2, fp2) =
|
||||
ffmpeg::frame_gray_sequence_with_audio(&t, &silent, 1000, 32, timeout, None, Some(&fp_out2))
|
||||
.expect("무성 영상에서도 프레임은 나와야 한다");
|
||||
assert!(frames2.len() >= 3, "무성 영상 프레임 {}장", frames2.len());
|
||||
assert!(fp2.is_empty(), "무성 영상은 빈 지문");
|
||||
}
|
||||
|
||||
/// 손상된 입력에서 워커가 영구 점유되지 않아야 한다 (타임아웃/오류 반환).
|
||||
#[test]
|
||||
fn broken_input_fails_fast() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let junk = dir.path().join("깨진파일.mp4");
|
||||
std::fs::write(&junk, b"this is not a video").unwrap();
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let r = ffmpeg::frame_gray_sequence(&t, &junk, 1000, 32, std::time::Duration::from_secs(20), None);
|
||||
assert!(r.is_err() || r.as_ref().unwrap().is_empty(), "깨진 파일에서 프레임이 나왔다");
|
||||
assert!(started.elapsed().as_secs() < 20, "즉시 실패해야 한다");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
//! 픽스처 실기 통합 테스트 — `.fixtures\manifest.json`의 기대값을 **실제 파이프라인**으로 검증한다.
|
||||
//!
|
||||
//! 스캔 → 메타 → 썸네일 → 지문 → 중복 탐지를 순서대로 돌리고, manifest가 "잡혀야 한다"고
|
||||
//! 말한 변형이 정말 같은 그룹에 들어갔는지 / "잡히면 안 된다"는 것이 안 묶였는지 확인한다.
|
||||
//! 이 순서 자체가 검증 대상이다 — 이미지 지문은 썸네일에서 뽑으므로 앞 단계가 빠지면 못 나온다.
|
||||
//!
|
||||
//! 픽스처가 없으면 조용히 통과한다 (`.\scripts\gen-fixtures.ps1`로 생성).
|
||||
//! manifest의 expect/stage 의미는 그 스크립트 헤더에 적혀 있다.
|
||||
|
||||
use archive_db::Db;
|
||||
use archive_indexer::{dedupe, ffmpeg::FfTools, meta, pipeline, sigq, thumbq::ThumbQueue};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn repo_root() -> PathBuf {
|
||||
// crates/archive-indexer → src-tauri → 리포 루트
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap()
|
||||
}
|
||||
|
||||
fn tools() -> Option<FfTools> {
|
||||
let bin = repo_root().join("src-tauri/binaries");
|
||||
let triple = if cfg!(all(windows, target_arch = "x86_64")) {
|
||||
"x86_64-pc-windows-msvc"
|
||||
} else if cfg!(all(windows, target_arch = "aarch64")) {
|
||||
"aarch64-pc-windows-msvc"
|
||||
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
|
||||
"aarch64-apple-darwin"
|
||||
} else {
|
||||
"x86_64-apple-darwin"
|
||||
};
|
||||
let ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
let (ffmpeg, ffprobe) = (
|
||||
bin.join(format!("ffmpeg-{triple}{ext}")),
|
||||
bin.join(format!("ffprobe-{triple}{ext}")),
|
||||
);
|
||||
(ffmpeg.is_file() && ffprobe.is_file()).then_some(FfTools { ffmpeg, ffprobe })
|
||||
}
|
||||
|
||||
/// manifest 한 항목.
|
||||
struct Entry {
|
||||
/// 파일명만 (경로 제외)
|
||||
name: String,
|
||||
/// 같은 원본에서 나온 것들의 공통 키 (음성은 None)
|
||||
group: Option<String>,
|
||||
variant: String,
|
||||
expect: String,
|
||||
stage: i64,
|
||||
}
|
||||
|
||||
fn load_manifest(kind: &str) -> Option<(PathBuf, Vec<Entry>)> {
|
||||
let fixtures = repo_root().join(".fixtures");
|
||||
let text = std::fs::read_to_string(fixtures.join("manifest.json")).ok()?;
|
||||
let json: serde_json::Value = serde_json::from_str(&text).expect("manifest.json 파싱 실패");
|
||||
let arr = json.get(kind)?.as_array()?.clone();
|
||||
let entries: Vec<Entry> = arr
|
||||
.iter()
|
||||
.map(|e| Entry {
|
||||
name: e["file"].as_str().unwrap().rsplit('/').next().unwrap().to_string(),
|
||||
group: e["group"].as_str().map(|s| s.to_string()),
|
||||
variant: e["variant"].as_str().unwrap_or("").to_string(),
|
||||
expect: e["expect"].as_str().unwrap_or("").to_string(),
|
||||
stage: e["stage"].as_i64().unwrap_or(1),
|
||||
})
|
||||
.collect();
|
||||
let dir = fixtures.join(if kind == "images" { "images" } else { "videos" });
|
||||
dir.is_dir().then_some((dir, entries))
|
||||
}
|
||||
|
||||
fn open_db(dir: &Path) -> Arc<Db> {
|
||||
Arc::new(Db::open(&dir.join("t.db")).unwrap())
|
||||
}
|
||||
|
||||
fn add_source(db: &Arc<Db>, root: &Path) -> i64 {
|
||||
let r = root.to_string_lossy().into_owned();
|
||||
db.with_write(move |conn| {
|
||||
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','fx',?1)", [&r])?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// file_id → 파일명
|
||||
fn names(db: &Arc<Db>) -> HashMap<i64, String> {
|
||||
db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare("SELECT id, name FROM files WHERE deleted_at IS NULL")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
|
||||
rows.collect()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// 탐지된 그룹 — (kind, 파일명 집합). kind 0=완전 동일 1=유사 이미지 2=유사 영상.
|
||||
#[derive(Debug)]
|
||||
struct Group {
|
||||
kind: i64,
|
||||
members: HashSet<String>,
|
||||
}
|
||||
|
||||
fn groups_by_name(db: &Arc<Db>, names: &HashMap<i64, String>) -> Vec<Group> {
|
||||
let rows: Vec<(i64, i64, i64)> = db
|
||||
.with_read(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT dm.group_id, dg.kind, dm.file_id FROM dupe_members dm
|
||||
JOIN dupe_groups dg ON dg.id = dm.group_id WHERE dg.status = 'open'",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
|
||||
rows.collect()
|
||||
})
|
||||
.unwrap();
|
||||
let mut by_gid: HashMap<i64, Group> = HashMap::new();
|
||||
for (gid, kind, fid) in rows {
|
||||
if let Some(n) = names.get(&fid) {
|
||||
by_gid
|
||||
.entry(gid)
|
||||
.or_insert_with(|| Group { kind, members: HashSet::new() })
|
||||
.members
|
||||
.insert(n.clone());
|
||||
}
|
||||
}
|
||||
by_gid.into_values().collect()
|
||||
}
|
||||
|
||||
/// 탐지 결과를 사람이 읽을 형태로 찍는다 (`--nocapture`). 점수·거리가 실제로 채워지는지도 보인다.
|
||||
fn dump(db: &Arc<Db>, groups: &[Group]) {
|
||||
let scores: Vec<(i64, i64, Option<f64>)> = db
|
||||
.with_read(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, kind, score FROM dupe_groups WHERE status='open' ORDER BY kind, id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
|
||||
rows.collect()
|
||||
})
|
||||
.unwrap();
|
||||
let kind_name = |k: i64| match k {
|
||||
0 => "완전동일",
|
||||
1 => "유사이미지",
|
||||
_ => "유사영상",
|
||||
};
|
||||
println!("── 탐지 결과: 그룹 {}개", groups.len());
|
||||
for (_, kind, score) in &scores {
|
||||
let _ = (kind, score);
|
||||
}
|
||||
let mut sorted: Vec<&Group> = groups.iter().collect();
|
||||
sorted.sort_by_key(|g| (g.kind, g.members.len()));
|
||||
for g in sorted {
|
||||
let mut m: Vec<&str> = g.members.iter().map(|s| s.as_str()).collect();
|
||||
m.sort_unstable();
|
||||
println!(" [{}] {}", kind_name(g.kind), m.join(", "));
|
||||
}
|
||||
let dists: Vec<i64> = db
|
||||
.with_read(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT distance FROM dupe_members WHERE distance IS NOT NULL AND is_keeper = 0",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| r.get(0))?;
|
||||
rows.collect()
|
||||
})
|
||||
.unwrap();
|
||||
let sc: Vec<f64> = scores.iter().filter_map(|(_, _, s)| *s).collect();
|
||||
println!(
|
||||
" 거리 채워진 멤버 {}개(최대 {:?}) · 점수 채워진 그룹 {}/{}개",
|
||||
dists.len(),
|
||||
dists.iter().max(),
|
||||
sc.len(),
|
||||
scores.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// 썸네일이 다 만들어질 때까지 기다린다 (없으면 이미지 지문이 안 나온다).
|
||||
fn build_thumbs(db: &Arc<Db>, sid: i64, thumb_root: &Path, tools: Option<FfTools>) {
|
||||
let want: i64 = db
|
||||
.with_read(|c| {
|
||||
c.query_row(
|
||||
"SELECT count(*) FROM files WHERE kind IN (0,1) AND deleted_at IS NULL",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
// 완료를 이벤트로 기다린다 — 썸네일 워커는 저우선순위라 폴링+마감은 부하에서 흔들린다.
|
||||
// 마감(60초 무진전)은 정말 멈춘 경우를 위한 안전장치다.
|
||||
let (tx, rx) = std::sync::mpsc::channel::<usize>();
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let d2 = done.clone();
|
||||
let q = ThumbQueue::start(db.clone(), thumb_root.to_path_buf(), tools, 4, move |ids| {
|
||||
let n = d2.fetch_add(ids.len(), std::sync::atomic::Ordering::SeqCst) + ids.len();
|
||||
let _ = tx.send(n);
|
||||
});
|
||||
q.enqueue_pending(Some(sid));
|
||||
while (done.load(std::sync::atomic::Ordering::SeqCst) as i64) < want {
|
||||
if rx.recv_timeout(Duration::from_secs(60)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// manifest 기대값과 실제 그룹을 대조한다. 반환: 실패 메시지들.
|
||||
///
|
||||
/// 판정 기준은 "**같은 원본 계열의 다른 파일과 한 그룹에 있는가**"다. 원본 파일 자신과
|
||||
/// 직접 묶일 필요는 없다 — 완전 동일 그룹의 keeper만 유사 패스에 남기는 설계(같은 파일이
|
||||
/// 두 그룹에 중복 표시되는 것을 막는다) 때문에, 바이트 동일 사본을 경유해 연결되는 것이 정상이다.
|
||||
/// `rotation`은 회전·반전 탐지를 켠 실행인지. 꺼져 있으면 rot90/flip은 단정하지 않는다
|
||||
/// (그 두 변형만 8변형 질의를 필요로 한다 — 나머지 단계 3 항목은 기본 경로에서 잡힌다).
|
||||
fn check(entries: &[Entry], groups: &[Group], rotation: bool) -> Vec<String> {
|
||||
let mut fails = Vec::new();
|
||||
let mut member_of: HashMap<&str, Vec<usize>> = HashMap::new();
|
||||
for (i, g) in groups.iter().enumerate() {
|
||||
for n in &g.members {
|
||||
member_of.entry(n.as_str()).or_default().push(i);
|
||||
}
|
||||
}
|
||||
// manifest 그룹 → 그 계열의 파일명들
|
||||
let mut family: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
for e in entries {
|
||||
if let Some(g) = e.group.as_deref() {
|
||||
family.entry(g).or_default().push(e.name.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
// 이 실행에서 단정할 단계인가. 단계 1·3·4 모두 구현됐다 —
|
||||
// 단 rot90/flip은 회전 옵션을 켠 실행에서만 본다.
|
||||
let asserted = |e: &Entry| match e.stage {
|
||||
1 | 4 => true,
|
||||
3 => rotation || !(e.variant == "rot90" || e.variant == "flip"),
|
||||
_ => false,
|
||||
};
|
||||
for e in entries {
|
||||
let mine = member_of.get(e.name.as_str()).cloned().unwrap_or_default();
|
||||
match e.expect.as_str() {
|
||||
"exact" | "similar" | "segment" if asserted(e) => {
|
||||
let Some(g) = e.group.as_deref() else { continue };
|
||||
let siblings: Vec<&str> = family
|
||||
.get(g)
|
||||
.map(|v| v.iter().copied().filter(|n| *n != e.name.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
let together = mine
|
||||
.iter()
|
||||
.any(|i| siblings.iter().any(|s| groups[*i].members.contains(*s)));
|
||||
if !together {
|
||||
fails.push(format!(
|
||||
"{} ({}): {g} 계열과 같은 그룹에 없다 (소속 그룹 {}개)",
|
||||
e.name,
|
||||
e.variant,
|
||||
mine.len()
|
||||
));
|
||||
}
|
||||
// 바이트까지 같은 사본은 '완전 동일' 그룹(kind 0)이어야 한다
|
||||
if e.expect == "exact" && !mine.iter().any(|i| groups[*i].kind == 0) {
|
||||
fails.push(format!("{}: 바이트 동일인데 완전 동일 그룹이 아니다", e.name));
|
||||
}
|
||||
}
|
||||
// 묶이면 안 되는 것
|
||||
"none" if e.stage == 1 => {
|
||||
if !mine.is_empty() {
|
||||
let others: Vec<&str> = groups[mine[0]]
|
||||
.members
|
||||
.iter()
|
||||
.filter(|n| n.as_str() != e.name)
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
fails.push(format!("{}: 묶이면 안 되는데 묶였다 — {:?}", e.name, others));
|
||||
}
|
||||
}
|
||||
_ => {} // 단계 3·4 대상은 지금 무관 (잡혀도 실패 아님)
|
||||
}
|
||||
}
|
||||
fails
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_images_group_as_manifest_expects() {
|
||||
let Some((dir, entries)) = load_manifest("images") else {
|
||||
eprintln!("픽스처 없음 — 건너뜀 (scripts\\gen-fixtures.ps1 실행)");
|
||||
return;
|
||||
};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let thumbs = tempfile::tempdir().unwrap();
|
||||
let db = open_db(tmp.path());
|
||||
let sid = add_source(&db, &dir);
|
||||
let cancel = AtomicBool::new(false);
|
||||
let pause = AtomicBool::new(false);
|
||||
|
||||
pipeline::scan_source(&db, sid, &dir, &cancel, |_| {}).unwrap();
|
||||
meta::extract_image_meta(&db, sid, &cancel).unwrap();
|
||||
build_thumbs(&db, sid, thumbs.path(), None);
|
||||
let n = sigq::run_image_sigs(&db, thumbs.path(), &cancel, &pause).unwrap();
|
||||
assert!(n > 0, "이미지 지문이 하나도 생성되지 않았다");
|
||||
|
||||
let stats = dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, false, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(stats.skipped_no_sig, 0, "지문 없이 빠진 파일이 있다");
|
||||
|
||||
let names = names(&db);
|
||||
let groups = groups_by_name(&db, &names);
|
||||
dump(&db, &groups); // cargo test -- --nocapture 로 실제 결과를 본다
|
||||
let fails = check(&entries, &groups, false);
|
||||
assert!(
|
||||
fails.is_empty(),
|
||||
"manifest 기대와 다름 ({}건):\n {}\n그룹 {}개: {:?}",
|
||||
fails.len(),
|
||||
fails.join("\n "),
|
||||
groups.len(),
|
||||
groups
|
||||
);
|
||||
|
||||
// 정지 이미지는 시간축이 없다 — 비교 뷰가 그대로 겹칠 수 있게 오프셋 0이어야 한다
|
||||
let bad: i64 = db
|
||||
.with_read(|c| {
|
||||
c.query_row(
|
||||
"SELECT count(*) FROM dupe_members WHERE offset_ms IS NULL OR offset_ms <> 0",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(bad, 0, "이미지 멤버의 offset_ms가 0이 아니다");
|
||||
}
|
||||
|
||||
/// 회전·반전본은 8변형 질의를 켰을 때만 잡힌다 — 켜면 잡히고, 무관한 그림은 여전히 안 묶인다.
|
||||
#[test]
|
||||
fn fixture_rotated_copies_found_when_enabled() {
|
||||
let Some((dir, entries)) = load_manifest("images") else {
|
||||
eprintln!("픽스처 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let thumbs = tempfile::tempdir().unwrap();
|
||||
let db = open_db(tmp.path());
|
||||
let sid = add_source(&db, &dir);
|
||||
let cancel = AtomicBool::new(false);
|
||||
let pause = AtomicBool::new(false);
|
||||
|
||||
pipeline::scan_source(&db, sid, &dir, &cancel, |_| {}).unwrap();
|
||||
meta::extract_image_meta(&db, sid, &cancel).unwrap();
|
||||
build_thumbs(&db, sid, thumbs.path(), None);
|
||||
sigq::run_image_sigs(&db, thumbs.path(), &cancel, &pause).unwrap();
|
||||
|
||||
// 먼저 꺼진 상태 — 회전본이 자기 계열과 묶이지 않아야 한다(그게 옵션의 존재 이유다)
|
||||
dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, false, &cancel, |_| {}).unwrap();
|
||||
let names = names(&db);
|
||||
let off_groups = groups_by_name(&db, &names);
|
||||
let rot_grouped_off = off_groups.iter().any(|g| {
|
||||
g.members.iter().any(|m| m.contains("_rot90")) && g.members.len() >= 2
|
||||
});
|
||||
assert!(!rot_grouped_off, "옵션이 꺼졌는데 회전본이 묶였다");
|
||||
|
||||
// 켜고 다시 — 이번엔 잡혀야 한다
|
||||
dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, true, &cancel, |_| {}).unwrap();
|
||||
let groups = groups_by_name(&db, &names);
|
||||
dump(&db, &groups);
|
||||
let fails = check(&entries, &groups, true);
|
||||
assert!(
|
||||
fails.is_empty(),
|
||||
"회전 옵션을 켠 결과가 manifest 기대와 다름 ({}건):\n {}",
|
||||
fails.len(),
|
||||
fails.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
/// 영상 경로 — 프레임 지문까지 만들어야 하므로 느리다(영상 8편 × 30초).
|
||||
#[test]
|
||||
fn fixture_videos_group_as_manifest_expects() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let Some((dir, entries)) = load_manifest("videos") else {
|
||||
eprintln!("픽스처 없음 — 건너뜀 (scripts\\gen-fixtures.ps1 실행)");
|
||||
return;
|
||||
};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let thumbs = tempfile::tempdir().unwrap();
|
||||
let db = open_db(tmp.path());
|
||||
let sid = add_source(&db, &dir);
|
||||
let cancel = AtomicBool::new(false);
|
||||
let pause = AtomicBool::new(false);
|
||||
|
||||
pipeline::scan_source(&db, sid, &dir, &cancel, |_| {}).unwrap();
|
||||
// 영상 길이가 있어야 커버리지 판정을 할 수 있다
|
||||
meta::extract_video_meta(&db, sid, &cancel, &t).unwrap();
|
||||
build_thumbs(&db, sid, thumbs.path(), Some(t.clone()));
|
||||
let n = sigq::run_video_sigs(&db, &t, None, &cancel, &pause).unwrap();
|
||||
assert!(n > 0, "영상 지문이 하나도 생성되지 않았다");
|
||||
|
||||
let stats = dedupe::run(&db, dedupe::Scope::Source(sid), 5, true, false, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(stats.skipped_no_sig, 0, "지문 없이 빠진 영상이 있다");
|
||||
|
||||
let names = names(&db);
|
||||
let groups = groups_by_name(&db, &names);
|
||||
dump(&db, &groups); // cargo test -- --nocapture 로 실제 결과를 본다
|
||||
let fails = check(&entries, &groups, false);
|
||||
assert!(
|
||||
fails.is_empty(),
|
||||
"manifest 기대와 다름 ({}건):\n {}\n그룹 {}개: {:?}",
|
||||
fails.len(),
|
||||
fails.join("\n "),
|
||||
groups.len(),
|
||||
groups
|
||||
);
|
||||
|
||||
// ── 비교 재생용 정렬값 ────────────────────────────────────────────
|
||||
// 비교 뷰는 `offset_ms`(keeper 시각 − 멤버 시각)로 두 영상을 맞춘다. 이 값이 틀리면
|
||||
// 나란히 놓고도 계속 다른 장면을 보게 되므로, 아는 답과 대조한다.
|
||||
// manifest의 expectOffsetMs는 vidA 기준이고 keeper는 vidA가 아닐 수 있으므로
|
||||
// (rank_keeper: 해상도 → 파일크기순 → vidA_reenc가 가장 크다) **간격**만 본다.
|
||||
let rows: Vec<(String, Option<i64>, Option<i64>, Option<i64>)> = db
|
||||
.with_read(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT f.name, dm.offset_ms, dm.seg_start_ms, dm.seg_end_ms
|
||||
FROM dupe_members dm JOIN files f ON f.id = dm.file_id
|
||||
JOIN dupe_groups dg ON dg.id = dm.group_id
|
||||
WHERE dg.kind = 2",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?;
|
||||
rows.collect()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!rows.is_empty(), "영상 그룹 멤버가 없다");
|
||||
let by_name: HashMap<&str, (Option<i64>, Option<i64>, Option<i64>)> =
|
||||
rows.iter().map(|(n, o, s, e)| (n.as_str(), (*o, *s, *e))).collect();
|
||||
println!("── 비교 재생 정렬값 (keeper 기준)");
|
||||
for (n, o, s, e) in &rows {
|
||||
println!(" {n:<22} offset={o:?} seg={s:?}..{e:?}");
|
||||
}
|
||||
|
||||
let keeper_name: &str = rows
|
||||
.iter()
|
||||
.map(|(n, _, _, _)| n.as_str())
|
||||
.find(|n| by_name[*n].0 == Some(0))
|
||||
.expect("offset 0인 keeper가 있어야 한다");
|
||||
let off = |n: &str| by_name.get(n).and_then(|(o, _, _)| *o);
|
||||
|
||||
// 같은 원본을 그대로 재인코딩/워터마크/fps변환한 것은 keeper와 시간축이 같다
|
||||
for n in ["vidA_reenc.mp4", "vidA_wm.mp4", "vidA_fps24.mp4", "vidA.mp4"] {
|
||||
if n == keeper_name {
|
||||
continue;
|
||||
}
|
||||
if let Some(o) = off(n) {
|
||||
assert!(o.abs() <= 1000, "{n}: 시간축이 같아야 한다 (offset {o}ms)");
|
||||
}
|
||||
}
|
||||
// 인트로 5초가 붙은 사본과 10초 지점을 잘라낸 사본은 그만큼 어긋나 있어야 한다.
|
||||
// keeper가 vidA 계열 원본(오프셋 0)이라는 전제 아래 간격을 확인한다.
|
||||
if let (Some(intro), Some(clip)) = (off("vidA_intro.mp4"), off("vidA_clip.mp4")) {
|
||||
assert!(
|
||||
(intro.abs() - 5000).abs() <= 1000,
|
||||
"인트로 삽입본은 5초 어긋나야 한다 (offset {intro}ms)"
|
||||
);
|
||||
assert!(
|
||||
(clip.abs() - 10000).abs() <= 1000,
|
||||
"10초 지점 구간 추출본은 10초 어긋나야 한다 (offset {clip}ms)"
|
||||
);
|
||||
assert_eq!(intro.signum(), -clip.signum(), "두 사본은 반대 방향으로 어긋난다");
|
||||
}
|
||||
// 일치 구간은 keeper 시간축 안에 있어야 한다 (비교 뷰가 '구간으로 이동'에 쓴다)
|
||||
for (n, _, s, e) in &rows {
|
||||
if let (Some(s), Some(e)) = (s, e) {
|
||||
assert!(s < e, "{n}: 구간이 뒤집혔다 ({s}..{e})");
|
||||
assert!(*s >= 0, "{n}: 구간 시작이 음수 ({s})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! 근거 측정 — 레터박스 사본을 어떻게 잡는지, 그리고 왜 프레임만으로는 못 잡는지.
|
||||
//! `cargo test -p archive-indexer --test letterbox_evidence -- --ignored --nocapture`
|
||||
//!
|
||||
//! 측정 결과(2026-08)와 그로부터 나온 설계:
|
||||
//!
|
||||
//! 1. 검정 띠 트림 자체는 정확하다 — 32×32에서 x6..26 y6..26, 딱 안쪽 콘텐츠다.
|
||||
//! 2. 그런데 20×20을 9×8로 다시 표본화한 해시는 원본(32×32→9×8)과 **거리 7~16**(중앙값 9.5)이고
|
||||
//! **정확히 같은 값은 0개**다. 후보 생성이 값 기반 토큰이라 두 사본은 영영 만나지 못한다.
|
||||
//! → 그래서 트림 해시를 후보 토큰으로 쓰려던 시도는 폐기했다(`dedupe::video_candidates` 주석).
|
||||
//! 3. 오디오는 화면 변형에 영향받지 않는다 — 레터박스 사본이 chromaprint 221/221 일치.
|
||||
//! → 오디오가 후보를 만든다.
|
||||
//! 4. 다만 **배경음만 같은 남남**도 221/221로 붙는다(`vidC_sharedaudio`). 둘을 가르는 건 시각뿐:
|
||||
//! 진짜 사본은 트림 축 프레임 거리 9, 남남은 29. → `AUDIO_VOTE_MAX_DIST = 16`이 그 사이다.
|
||||
//!
|
||||
//! 이 파일은 그 네 숫자를 다시 뽑아본다. 임계값을 손댈 일이 생기면 여기부터 돌려라.
|
||||
|
||||
use archive_indexer::{ffmpeg::{self, FfTools}, sigmatch};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn repo_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap()
|
||||
}
|
||||
|
||||
fn tools() -> Option<FfTools> {
|
||||
let bin = repo_root().join("src-tauri/binaries");
|
||||
let triple = if cfg!(all(windows, target_arch = "x86_64")) {
|
||||
"x86_64-pc-windows-msvc"
|
||||
} else {
|
||||
"aarch64-pc-windows-msvc"
|
||||
};
|
||||
let ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
let (a, b) = (bin.join(format!("ffmpeg-{triple}{ext}")), bin.join(format!("ffprobe-{triple}{ext}")));
|
||||
(a.is_file() && b.is_file()).then_some(FfTools { ffmpeg: a, ffprobe: b })
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "근거 측정용"]
|
||||
fn print_letterbox_evidence() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음");
|
||||
return;
|
||||
};
|
||||
let dir = repo_root().join(".fixtures/videos");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("픽스처 없음");
|
||||
return;
|
||||
}
|
||||
let grab = |name: &str| -> Vec<(u32, Vec<u8>)> {
|
||||
ffmpeg::frame_gray_sequence(
|
||||
&t,
|
||||
&dir.join(name),
|
||||
1000,
|
||||
sigmatch::FRAME as u32,
|
||||
std::time::Duration::from_secs(120),
|
||||
None,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let a = grab("vidA.mp4");
|
||||
let l = grab("vidA_letterbox.mp4");
|
||||
println!("프레임 수: vidA {} · letterbox {}", a.len(), l.len());
|
||||
if a.is_empty() || l.is_empty() {
|
||||
return;
|
||||
}
|
||||
for i in [0usize, 5, 10] {
|
||||
let (Some((_, pa)), Some((_, pl))) = (a.get(i), l.get(i)) else { continue };
|
||||
let (sa, sl) = (sigmatch::derive(pa).unwrap(), sigmatch::derive(pl).unwrap());
|
||||
let bounds = sigmatch::letterbox(pl);
|
||||
// 32×32 픽셀에서 검정(값 <= 16) 비율 — 패딩이 얼마나 되는지
|
||||
let black = pl.iter().filter(|v| **v <= 16).count() * 100 / pl.len();
|
||||
println!("── 프레임 {i}");
|
||||
println!(
|
||||
" letterbox 판정 x{}..{} y{}..{} (검정 픽셀 {black}%)",
|
||||
bounds.0, bounds.2, bounds.1, bounds.3
|
||||
);
|
||||
println!(
|
||||
" 전체↔전체 {} · 트림(레터박스)↔전체(원본) {} · 전체↔트림 {}",
|
||||
sigmatch::hamming(sa.dhash, sl.dhash),
|
||||
sigmatch::hamming(sl.scales[2], sa.dhash),
|
||||
sigmatch::hamming(sa.dhash, sl.scales[2])
|
||||
);
|
||||
println!(
|
||||
" 원본의 scales[2]는 전체와 같은가: {} (거리 {})",
|
||||
sa.scales[2] == sa.dhash,
|
||||
sigmatch::hamming(sa.scales[2], sa.dhash)
|
||||
);
|
||||
}
|
||||
|
||||
// 후보 생성은 '값이 정확히 같은 토큰'을 요구한다 — 공유 토큰이 몇 개인지가 결정적이다
|
||||
let sig_of = |frames: &[(u32, Vec<u8>)]| -> (Vec<u64>, Vec<u64>) {
|
||||
let mut whole = Vec::new();
|
||||
let mut lb = Vec::new();
|
||||
for (_, px) in frames {
|
||||
if let Some(s) = sigmatch::derive(px) {
|
||||
whole.push(s.dhash);
|
||||
lb.push(s.scales[2]);
|
||||
}
|
||||
}
|
||||
(whole, lb)
|
||||
};
|
||||
let (aw, _al) = sig_of(&a);
|
||||
let (lw, ll) = sig_of(&l);
|
||||
let set: std::collections::HashSet<u64> = aw.iter().copied().collect();
|
||||
println!("\n── 후보 토큰 공유 (정확히 일치하는 값의 개수)");
|
||||
println!(" 원본 전체해시 {}개 중", set.len());
|
||||
println!(" 레터박스본 전체해시와 공유: {}", lw.iter().filter(|h| set.contains(h)).count());
|
||||
println!(" 레터박스본 트림해시와 공유: {}", ll.iter().filter(|h| set.contains(h)).count());
|
||||
// 거리 분포 — 몇 비트나 벌어지는지
|
||||
let mut d: Vec<u32> = ll
|
||||
.iter()
|
||||
.zip(aw.iter())
|
||||
.map(|(x, y)| sigmatch::hamming(*x, *y))
|
||||
.collect();
|
||||
d.sort_unstable();
|
||||
println!(" 트림↔전체 프레임별 거리(정렬): {d:?}");
|
||||
|
||||
// ── 오디오가 후보를 만들 때 시각으로 진짜/남남을 가를 수 있는가 ──────────
|
||||
// 오디오는 vidA_letterbox(진짜 사본)와 vidC_sharedaudio(배경음만 같은 남남)를
|
||||
// 똑같이 221/221 일치로 붙인다. 둘을 가르는 건 시각뿐이므로 그 간격을 잰다.
|
||||
let median = |mut v: Vec<u32>| -> u32 {
|
||||
v.sort_unstable();
|
||||
v.get(v.len() / 2).copied().unwrap_or(0)
|
||||
};
|
||||
println!("\n── 오디오 후보를 시각으로 거를 때의 간격 (중앙값, 64비트 중)");
|
||||
for (name, use_lb) in [("vidA_letterbox.mp4", true), ("vidC_sharedaudio.mp4", false)] {
|
||||
let f = grab(name);
|
||||
if f.is_empty() {
|
||||
println!(" {name}: 프레임 없음");
|
||||
continue;
|
||||
}
|
||||
let (w, lb) = sig_of(&f);
|
||||
let seq = if use_lb { &lb } else { &w };
|
||||
let n = seq.len().min(aw.len());
|
||||
let dm = median((0..n).map(|i| sigmatch::hamming(seq[i], aw[i])).collect());
|
||||
println!(" vidA ↔ {name:<22} {dm} {}", if use_lb { "(트림 축)" } else { "(전체 축)" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! 진단용 — `wmbig` 오탐의 실제 근거 수치를 찍는다.
|
||||
//! `cargo test -p archive-indexer --test wmbig_evidence -- --nocapture`
|
||||
|
||||
use archive_indexer::sigmatch::{self, FrameSig};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn repo_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap()
|
||||
}
|
||||
|
||||
fn sig_of(p: &Path) -> Option<FrameSig> {
|
||||
let img = image::ImageReader::open(p).ok()?.with_guessed_format().ok()?.decode().ok()?;
|
||||
let gray = img
|
||||
.resize_exact(
|
||||
sigmatch::FRAME as u32,
|
||||
sigmatch::FRAME as u32,
|
||||
image::imageops::FilterType::Triangle,
|
||||
)
|
||||
.to_luma8()
|
||||
.into_raw();
|
||||
sigmatch::derive(&gray)
|
||||
}
|
||||
|
||||
fn color_of(p: &Path) -> Option<[i8; 32]> {
|
||||
let img = image::ImageReader::open(p).ok()?.with_guessed_format().ok()?.decode().ok()?;
|
||||
let small = img.resize_exact(4, 4, image::imageops::FilterType::Triangle).to_rgb8().into_raw();
|
||||
sigmatch::color_of(&small)
|
||||
}
|
||||
|
||||
/// 임계값을 다시 손볼 때 근거표를 뽑는 용도. 단정이 없으므로 기본 실행에서는 뺀다.
|
||||
/// `sigmatch`의 VETO_AGREE_RATIO·CROP_MAX_DIST 주석에 적힌 수치가 여기서 나온 것이다.
|
||||
#[test]
|
||||
#[ignore = "근거 측정용 — cargo test --test wmbig_evidence -- --ignored --nocapture"]
|
||||
fn print_wmbig_evidence() {
|
||||
let dir = repo_root().join(".fixtures/images");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("픽스처 없음 — 건너뜀");
|
||||
return;
|
||||
}
|
||||
let pairs = [
|
||||
("pos_still3_wmbig.jpg", "pos_still4_wmbig.jpg", "오탐: 서로 다른 원본 + 같은 큰 가림"),
|
||||
("pos_still3_orig.jpg", "pos_still4_orig.jpg", "그 두 원본 자체"),
|
||||
("pos_still3_orig.jpg", "pos_still3_wmbig.jpg", "정탐: 원본 ↔ 자기 가림본"),
|
||||
("pos_still3_orig.jpg", "pos_still3_rot90.jpg", "회전본"),
|
||||
("pos_still3_orig.jpg", "pos_still3_crop95.jpg", "5% 크롭"),
|
||||
("pos_still3_orig.jpg", "pos_still3_q12.jpg", "저품질 재인코딩 (거부권에 걸리면 안 된다)"),
|
||||
("pos_still3_orig.jpg", "pos_still3_half.jpg", "50% 축소"),
|
||||
("pos_still3_orig.jpg", "pos_still3_bright.jpg", "밝기·대비 변경"),
|
||||
("pos_still3_orig.jpg", "pos_still3_wm.jpg", "하단 자막 바"),
|
||||
("neg_bars.jpg", "neg_hdbars.jpg", "무관: 컬러바 두 종"),
|
||||
];
|
||||
for (a, b, label) in pairs {
|
||||
let (pa, pb) = (dir.join(a), dir.join(b));
|
||||
let (Some(sa), Some(sb)) = (sig_of(&pa), sig_of(&pb)) else {
|
||||
eprintln!("{label}: 파일 없음");
|
||||
continue;
|
||||
};
|
||||
let ra = sigmatch::RichSig {
|
||||
dhash: sa.dhash,
|
||||
orient: sa.orient,
|
||||
tiles: sa.tiles,
|
||||
scales: sa.scales,
|
||||
tile_flat: sa.tile_flat,
|
||||
color: color_of(&pa),
|
||||
};
|
||||
let rb = sigmatch::RichSig {
|
||||
dhash: sb.dhash,
|
||||
orient: sb.orient,
|
||||
tiles: sb.tiles,
|
||||
scales: sb.scales,
|
||||
tile_flat: sb.tile_flat,
|
||||
color: color_of(&pb),
|
||||
};
|
||||
let whole = sigmatch::hamming(ra.dhash, rb.dhash);
|
||||
let (agree_loose, seen) = sigmatch::informative_agreement(&ra, &rb, sigmatch::TILE_PER_TILE);
|
||||
let (agree_strict, _) =
|
||||
sigmatch::informative_agreement(&ra, &rb, sigmatch::TILE_STRICT_PER_TILE);
|
||||
let cdist = match (&ra.color, &rb.color) {
|
||||
(Some(x), Some(y)) => sigmatch::color_distance(x, y) as i64,
|
||||
_ => -1,
|
||||
};
|
||||
let verdict = sigmatch::verify_pair(&ra, &rb, 5, true);
|
||||
println!("── {label}");
|
||||
println!(" 전체 dHash 거리 {whole} · 색차 {cdist} · 판정 {verdict:?}");
|
||||
println!(
|
||||
" 정보타일 {seen}개 중 근접 {agree_loose}(느슨 ≤{})/{agree_strict}(엄격 ≤{})",
|
||||
sigmatch::TILE_PER_TILE,
|
||||
sigmatch::TILE_STRICT_PER_TILE
|
||||
);
|
||||
// 타일별로 거리와 '정보 없음' 여부를 같이 본다 (X = 판정에서 제외)
|
||||
let cells: Vec<String> = (0..16)
|
||||
.map(|i| {
|
||||
let d = sigmatch::hamming(ra.tiles[i], rb.tiles[i]);
|
||||
let skip = (ra.tile_flat >> i) & 1 == 1 || (rb.tile_flat >> i) & 1 == 1;
|
||||
format!("{}{d:>2}", if skip { "X" } else { " " })
|
||||
})
|
||||
.collect();
|
||||
for row in 0..4 {
|
||||
println!(" {}", cells[row * 4..row * 4 + 4].join(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
+1105
-63
File diff suppressed because it is too large
Load Diff
+137
-23
@@ -28,6 +28,10 @@ pub struct AppState {
|
||||
pub scan_running: Arc<AtomicBool>,
|
||||
pub dedupe_cancel: Arc<AtomicBool>,
|
||||
pub dedupe_running: Arc<AtomicBool>,
|
||||
/// 지문 워커 중단 플래그 (앱 종료 시)
|
||||
pub sig_cancel: Arc<AtomicBool>,
|
||||
/// 하드웨어 디코드 사용 여부 — 기동 시 1회 실제 디코드로 판별한 결과
|
||||
pub hwaccel: Option<&'static str>,
|
||||
pub remote: Arc<remote::RemotePool>,
|
||||
/// 자격증명 저장소 (포터블 볼트 잠금 해제 후 교체 가능)
|
||||
pub creds: Arc<std::sync::Mutex<archive_vfs::creds::CredStore>>,
|
||||
@@ -40,13 +44,44 @@ fn now_ms() -> i64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
/// 로그를 **파일로도** 남긴다. 릴리스는 windows_subsystem="windows"라 콘솔이 없어서
|
||||
/// stdout만 쓰면 사용자 PC에서 무슨 일이 있었는지 확인할 방법이 아예 없다
|
||||
/// (실제로 "지문이 안 만들어진다"는 신고에서 로그 폴더가 비어 있어 원인을 못 찾았다).
|
||||
///
|
||||
/// 가드를 돌려주는 이유: 드롭되면 백그라운드 기록 스레드가 멈춰 마지막 줄들이 날아간다.
|
||||
/// main이 끝날 때까지 살아 있어야 한다.
|
||||
fn init_logging() -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
||||
let filter = || {
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())
|
||||
};
|
||||
// 데이터 위치는 setup보다 먼저 알아야 한다(로그를 그 안에 쓰므로). 실패하면 stdout만.
|
||||
let Ok(data) = paths::resolve_data_dir() else {
|
||||
tracing_subscriber::fmt().with_env_filter(filter()).init();
|
||||
return None;
|
||||
};
|
||||
let dir = data.path.join("logs");
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
tracing_subscriber::fmt().with_env_filter(filter()).init();
|
||||
return None;
|
||||
}
|
||||
// 하루 한 파일. 오래된 것은 남겨둔다 — 며칠 전 증상을 되짚어야 하는 일이 잦다.
|
||||
let appender = tracing_appender::rolling::daily(&dir, "archive.log");
|
||||
let (writer, guard) = tracing_appender::non_blocking(appender);
|
||||
// 파일 **과** stdout 둘 다. 파일만 쓰면 개발 중 콘솔에서 로그가 사라져
|
||||
// `scripts/dev.cmd > log`로 앱 동작을 들여다보던 길이 막힌다.
|
||||
use tracing_subscriber::fmt::writer::MakeWriterExt;
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.with_env_filter(filter())
|
||||
.with_ansi(false) // 파일에 색 이스케이프가 섞이면 읽을 수 없다
|
||||
.with_writer(writer.and(std::io::stdout))
|
||||
.init();
|
||||
tracing::info!(dir = %dir.display(), "로그 파일 기록 시작");
|
||||
Some(guard)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// main이 끝날 때까지 유지 — 드롭되면 기록 스레드가 멈춘다
|
||||
let _log_guard = init_logging();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
@@ -89,33 +124,24 @@ fn main() {
|
||||
tools.as_ref().map(|t| t.ffmpeg.clone()),
|
||||
))?;
|
||||
|
||||
// 썸네일 워커: 코어 수 - 1
|
||||
let workers = std::thread::available_parallelism()
|
||||
.map(|n| n.get().saturating_sub(1).max(1))
|
||||
.unwrap_or(2);
|
||||
// 썸네일 워커 상한: 코어×2를 4–16으로 클램프. 실제 동시 처리 개수는 큐가
|
||||
// 처리율을 보며 이 범위 안에서 스스로 맞춘다 — NVMe는 코어 수 근처까지,
|
||||
// HDD는 하한(2)까지 내려가고, SMB는 지연을 가리려 코어 수보다 높이 올라간다.
|
||||
let max_workers = std::thread::available_parallelism()
|
||||
.map(|n| (n.get() * 2).clamp(4, 16))
|
||||
.unwrap_or(4);
|
||||
let emit_handle = app.handle().clone();
|
||||
let thumbs = Arc::new(ThumbQueue::start(
|
||||
db.clone(),
|
||||
thumb_root,
|
||||
tools.clone(),
|
||||
workers,
|
||||
max_workers,
|
||||
move |ids| {
|
||||
let _ = emit_handle.emit("thumbs-ready", ids);
|
||||
},
|
||||
));
|
||||
// 앱 시작 시 미처리 썸네일 백필 — 백그라운드로.
|
||||
// (대용량/네트워크(SMB) 소스에서 시작 시 메인 스레드 블로킹 방지)
|
||||
{
|
||||
let thumbs = thumbs.clone();
|
||||
let db = db.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("thumb-backfill".into())
|
||||
.spawn(move || {
|
||||
archive_indexer::prio::set_current_thread_low();
|
||||
thumbs.enqueue_pending(&db, None);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
// 앱 시작 시 미처리 썸네일 백필 — 전용 피더 스레드에서 커서로 흘려보낸다(즉시 반환).
|
||||
thumbs.enqueue_pending(None);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::info!(
|
||||
@@ -124,6 +150,11 @@ fn main() {
|
||||
media.token
|
||||
);
|
||||
|
||||
// 하드웨어 디코드 가용성 — 실제 디코드 1회로 판별한다(컴파일 지원만으로는 알 수 없다).
|
||||
// 실패하면 소프트웨어 디코드로만 간다.
|
||||
let hwaccel = tools.as_ref().and_then(archive_indexer::ffmpeg::detect_hwaccel);
|
||||
tracing::info!(?hwaccel, "지문 추출 하드웨어 디코드");
|
||||
|
||||
app.manage(AppState {
|
||||
db,
|
||||
data_dir: data.path,
|
||||
@@ -136,6 +167,8 @@ fn main() {
|
||||
scan_running: Arc::new(AtomicBool::new(false)),
|
||||
dedupe_cancel: Arc::new(AtomicBool::new(false)),
|
||||
dedupe_running: Arc::new(AtomicBool::new(false)),
|
||||
sig_cancel: Arc::new(AtomicBool::new(false)),
|
||||
hwaccel,
|
||||
remote,
|
||||
creds: Arc::new(std::sync::Mutex::new(
|
||||
archive_vfs::creds::CredStore::Keychain,
|
||||
@@ -270,6 +303,64 @@ fn main() {
|
||||
.ok();
|
||||
}
|
||||
|
||||
// 지문 백필 — 썸네일처럼 상시 저우선순위로 흐른다.
|
||||
// 이미지는 캐시된 썸네일에서 뽑으므로 썸네일 큐 뒤를 따라가고, 영상은 ffmpeg 한 패스다.
|
||||
// `sig_state`가 곧 큐라서 커서가 없고, 앱을 껐다 켜도 남은 것부터 이어진다.
|
||||
// 스캔 중에는 pause 플래그(=scan_running)로 양보한다 — 둘 다 원본 I/O를 태우기 때문.
|
||||
{
|
||||
let state = app.state::<AppState>();
|
||||
let db = state.db.clone();
|
||||
let tools = state.tools.clone();
|
||||
let thumb_root = state.data_dir.join("thumbs");
|
||||
let cancel = state.sig_cancel.clone();
|
||||
let pause = state.scan_running.clone();
|
||||
let hw = state.hwaccel;
|
||||
std::thread::Builder::new()
|
||||
.name("sig-backfill".into())
|
||||
.spawn(move || {
|
||||
archive_indexer::prio::set_current_thread_low();
|
||||
// 지문 알고리즘 버전이 바뀌었으면 먼저 무효화한다 — 안 하면 옛 버전 지문이
|
||||
// 남아 중복 탐지가 조용히 아무것도 못 찾는다. 버전이 그대로면 비용 0.
|
||||
match archive_indexer::sigq::invalidate_stale_sigs(&db) {
|
||||
Ok(n) if n > 0 => tracing::info!(count = n, "지문 버전 변경 — 재계산 예약"),
|
||||
Err(e) => tracing::warn!("지문 무효화 오류: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
loop {
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
match archive_indexer::sigq::run_image_sigs(
|
||||
&db, &thumb_root, &cancel, &pause,
|
||||
) {
|
||||
Ok(n) if n > 0 => tracing::info!(count = n, "이미지 지문 생성"),
|
||||
Err(e) => tracing::warn!("이미지 지문 오류: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(t) = &tools {
|
||||
match archive_indexer::sigq::run_video_sigs(
|
||||
&db, t, hw, &cancel, &pause,
|
||||
) {
|
||||
Ok(n) if n > 0 => tracing::info!(count = n, "영상 지문 생성"),
|
||||
Err(e) => tracing::warn!("영상 지문 오류: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// 새 스캔·썸네일 완료를 자연히 흡수한다 (이벤트 배선보다 단순하고 아이들 비용이 거의 0).
|
||||
// 쉬는 시간도 속도 설정을 따른다 — 2만 건이 밀려 있는데 30초씩 노는 것은
|
||||
// 그 자체가 느린 것이다.
|
||||
let idle = archive_indexer::sigq::read_speed(&db).idle_secs();
|
||||
for _ in 0..idle {
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
// dev 편의: ARCHIVE_AUTO_SOURCE=<폴더> → 시작 시 소스 등록 + 스캔
|
||||
#[cfg(debug_assertions)]
|
||||
if let Ok(auto) = std::env::var("ARCHIVE_AUTO_SOURCE") {
|
||||
@@ -314,7 +405,12 @@ fn main() {
|
||||
commands::list_sources,
|
||||
commands::add_local_source,
|
||||
commands::remove_source,
|
||||
commands::update_source,
|
||||
commands::reload_source,
|
||||
commands::purge_missing,
|
||||
commands::source_status,
|
||||
commands::start_scan,
|
||||
commands::rescan_all,
|
||||
commands::cancel_scan,
|
||||
commands::folder_tree,
|
||||
commands::folder_snapshot,
|
||||
@@ -329,10 +425,25 @@ fn main() {
|
||||
commands::rename_tag,
|
||||
commands::delete_tag,
|
||||
commands::assign_tags,
|
||||
commands::set_rating,
|
||||
commands::edit_files,
|
||||
commands::cut_file,
|
||||
commands::filmstrip,
|
||||
commands::list_tag_sets,
|
||||
commands::create_tag_set,
|
||||
commands::update_tag_set,
|
||||
commands::delete_tag_set,
|
||||
commands::apply_tag_set,
|
||||
commands::unassign_tags,
|
||||
commands::file_tags_of,
|
||||
commands::file_tags_bulk,
|
||||
commands::export_tags,
|
||||
commands::tag_snapshot,
|
||||
commands::trash_tag_id,
|
||||
commands::mark_delete,
|
||||
commands::unmark_delete,
|
||||
commands::pending_delete_stats,
|
||||
commands::empty_deleted,
|
||||
commands::move_files,
|
||||
commands::rename_file,
|
||||
commands::trash_files,
|
||||
@@ -345,6 +456,9 @@ fn main() {
|
||||
commands::cancel_dedupe,
|
||||
commands::list_dupe_groups,
|
||||
commands::resolve_dupe_group,
|
||||
commands::set_dupe_keeper,
|
||||
commands::sig_speed,
|
||||
commands::set_sig_speed,
|
||||
commands::dismiss_dupe_pair,
|
||||
commands::add_remote_source,
|
||||
commands::connect_remote_source,
|
||||
|
||||
@@ -16,20 +16,89 @@ type Debouncer = notify_debouncer_full::Debouncer<
|
||||
notify_debouncer_full::RecommendedCache,
|
||||
>;
|
||||
|
||||
/// 워치 불가 소스의 **첫** 재스캔 간격. 네트워크 공유(SMB/NAS)는 변경 알림을 안 주는
|
||||
/// 서버가 있어 폴백이 필요하다.
|
||||
const POLL_MIN: Duration = Duration::from_secs(60);
|
||||
/// 변경이 계속 없을 때 늘어나는 상한.
|
||||
///
|
||||
/// 고정 60초는 대규모 소스에서 치명적이다 — 100만 건 열거가 60초 안에 끝나지 않으므로
|
||||
/// 재스캔이 끝나기 전에 다음 재스캔이 큐에 쌓이고, 앱이 켜져 있는 동안 영구히 디스크를 간다.
|
||||
/// 그래서 변경이 없으면 간격을 2배씩 늘리고(상한 30분), 변경이 있으면 즉시 되돌린다.
|
||||
const POLL_MAX: Duration = Duration::from_secs(30 * 60);
|
||||
/// 만기 검사 주기 — 간격 자체가 아니라 '지금 만기된 소스가 있는지' 보는 틱.
|
||||
const POLL_TICK: Duration = Duration::from_secs(5);
|
||||
|
||||
/// 폴링 소스 한 개의 상태. 소스별로 따로 늘어난다(느린 NAS 하나가 다른 소스를 늦추지 않는다).
|
||||
struct PollState {
|
||||
/// 다음 재스캔 예정 시각
|
||||
due: std::time::Instant,
|
||||
/// 현재 간격 (POLL_MIN..=POLL_MAX)
|
||||
interval: Duration,
|
||||
}
|
||||
|
||||
type PollMap = std::sync::Arc<Mutex<HashMap<i64, PollState>>>;
|
||||
|
||||
pub struct WatcherManager {
|
||||
watchers: Mutex<HashMap<i64, Debouncer>>,
|
||||
/// 워치 불가로 폴링에 의존하는 소스 (네트워크 공유 등)
|
||||
polled: PollMap,
|
||||
dirty_tx: crossbeam_channel::Sender<i64>,
|
||||
}
|
||||
|
||||
/// 재스캔 결과를 폴링 간격에 반영한다. 변경이 있었으면 최소 간격으로 되돌리고,
|
||||
/// 없었으면 2배로 늘린다. 폴링 대상이 아닌 소스(워처가 붙은 소스)는 아무 일도 하지 않는다.
|
||||
fn report_scan(polled: &PollMap, source_id: i64, changed: bool) {
|
||||
let mut map = polled.lock().unwrap();
|
||||
if let Some(st) = map.get_mut(&source_id) {
|
||||
st.interval = if changed {
|
||||
POLL_MIN
|
||||
} else {
|
||||
(st.interval * 2).min(POLL_MAX)
|
||||
};
|
||||
st.due = std::time::Instant::now() + st.interval;
|
||||
tracing::debug!(source_id, secs = st.interval.as_secs(), changed, "폴링 간격 갱신");
|
||||
}
|
||||
}
|
||||
|
||||
impl WatcherManager {
|
||||
/// 워커 스레드를 시작하고 매니저를 만든다.
|
||||
pub fn new(app: tauri::AppHandle) -> std::sync::Arc<WatcherManager> {
|
||||
let (tx, rx) = crossbeam_channel::unbounded::<i64>();
|
||||
let polled: PollMap = std::sync::Arc::new(Mutex::new(HashMap::new()));
|
||||
let manager = std::sync::Arc::new(WatcherManager {
|
||||
watchers: Mutex::new(HashMap::new()),
|
||||
dirty_tx: tx,
|
||||
polled: polled.clone(),
|
||||
dirty_tx: tx.clone(),
|
||||
});
|
||||
|
||||
// 워치 불가 소스 폴링 — 이벤트를 못 받는 공유도 결국 최신화된다.
|
||||
// 만기된 소스만 보낸다. 보낼 때 다음 만기를 미리 밀어 두므로, 재스캔이 오래 걸려도
|
||||
// 그 사이에 같은 소스가 다시 큐에 쌓이지 않는다(간격 갱신은 report_scan이 한다).
|
||||
{
|
||||
let polled = polled.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("watch-poll".into())
|
||||
.spawn(move || loop {
|
||||
std::thread::sleep(POLL_TICK);
|
||||
let now = std::time::Instant::now();
|
||||
let mut due: Vec<i64> = Vec::new();
|
||||
{
|
||||
let mut map = polled.lock().unwrap();
|
||||
for (id, st) in map.iter_mut() {
|
||||
if st.due <= now {
|
||||
due.push(*id);
|
||||
st.due = now + st.interval;
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in due {
|
||||
let _ = tx.send(id);
|
||||
}
|
||||
})
|
||||
.expect("watch-poll 스레드 생성 실패");
|
||||
}
|
||||
let poll_report = polled;
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("watch-rescan".into())
|
||||
.spawn(move || {
|
||||
@@ -56,7 +125,7 @@ impl WatcherManager {
|
||||
match root {
|
||||
Ok(root) => {
|
||||
tracing::info!(source_id, "워처 트리거 재스캔");
|
||||
crate::commands::run_scan_pipeline(
|
||||
let stats = crate::commands::run_scan_pipeline(
|
||||
&state.db,
|
||||
&state.tools,
|
||||
&state.thumbs,
|
||||
@@ -68,6 +137,12 @@ impl WatcherManager {
|
||||
let _ = app.emit("library-changed", source_id);
|
||||
},
|
||||
);
|
||||
// 아무것도 바뀌지 않았으면 다음 폴링을 늦춘다 —
|
||||
// 조용한 대규모 NAS를 60초마다 다시 훑지 않게 하는 핵심이다.
|
||||
let changed = stats
|
||||
.map(|s| s.added + s.changed + s.removed + s.moved > 0)
|
||||
.unwrap_or(false);
|
||||
report_scan(&poll_report, source_id, changed);
|
||||
state.scan_running.store(false, Ordering::SeqCst);
|
||||
let _ = app.emit("library-changed", source_id);
|
||||
}
|
||||
@@ -88,6 +163,7 @@ impl WatcherManager {
|
||||
if self.watchers.lock().unwrap().remove(&source_id).is_some() {
|
||||
tracing::info!(source_id, "파일 워처 해제");
|
||||
}
|
||||
self.polled.lock().unwrap().remove(&source_id);
|
||||
}
|
||||
|
||||
/// DB의 로컬 소스 전체를 워치 대상으로 동기화한다 (시작 시 + 소스 추가 후 호출).
|
||||
@@ -127,8 +203,18 @@ impl WatcherManager {
|
||||
watchers.insert(source_id, debouncer);
|
||||
}
|
||||
Err(e) => {
|
||||
// 네트워크 드라이브 등 워치 불가 — 수동 새로고침으로 폴백
|
||||
tracing::warn!(source_id, "워치 불가(수동 새로고침 사용): {e}");
|
||||
// 네트워크 드라이브 등 워치 불가 — 주기적 재스캔으로 폴백
|
||||
tracing::warn!(
|
||||
source_id,
|
||||
secs = POLL_MIN.as_secs(),
|
||||
"워치 불가 — 주기 재스캔으로 폴백(변경 없으면 간격 증가): {e}"
|
||||
);
|
||||
self.polled.lock().unwrap().entry(source_id).or_insert_with(|| {
|
||||
PollState {
|
||||
due: std::time::Instant::now() + POLL_MIN,
|
||||
interval: POLL_MIN,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-6
@@ -1,4 +1,4 @@
|
||||
import { onCleanup, onMount, type Component } from "solid-js";
|
||||
import { onCleanup, onMount, Show, type Component } from "solid-js";
|
||||
import { Sidebar, onLibraryChangedRefresh } from "./shell/Sidebar";
|
||||
import { ContentArea } from "./shell/ContentArea";
|
||||
import { DetailsPanel } from "./shell/DetailsPanel";
|
||||
@@ -6,15 +6,29 @@ import { StatusBar } from "./shell/StatusBar";
|
||||
import { Lightbox } from "./features/viewer/Lightbox";
|
||||
import { ContextMenu } from "./features/fileops/ContextMenu";
|
||||
import { MoveDialog } from "./features/fileops/MoveDialog";
|
||||
import { MultiPlayView } from "./features/viewer/MultiPlayView";
|
||||
import { DedupePanel } from "./features/dedupe/DedupePanel";
|
||||
import { CompareView } from "./features/dedupe/CompareView";
|
||||
import { RemoteDialog } from "./features/sources/RemoteDialog";
|
||||
import { SourceEditDialog } from "./features/sources/SourceEditDialog";
|
||||
import { ShortcutsOverlay } from "./shell/ShortcutsOverlay";
|
||||
import { onLibraryChanged, onThumbsReady, type UnlistenFn } from "./ipc/commands";
|
||||
import { bumpThumbVersions, loadAppInfo, refreshPendingCounts, refreshSnapshot } from "./state/store";
|
||||
import { ko } from "./i18n/ko";
|
||||
import { onLibraryChanged, onScanGuarded, onThumbsReady, type UnlistenFn } from "./ipc/commands";
|
||||
import {
|
||||
bumpThumbVersions,
|
||||
detailsOpen,
|
||||
loadAppInfo,
|
||||
loadTrashTag,
|
||||
noteGuarded,
|
||||
refreshPendingCounts,
|
||||
refreshSnapshot,
|
||||
toast,
|
||||
} from "./state/store";
|
||||
|
||||
const App: Component = () => {
|
||||
onMount(() => {
|
||||
void loadAppInfo();
|
||||
void loadTrashTag();
|
||||
void refreshSnapshot();
|
||||
refreshPendingCounts();
|
||||
|
||||
@@ -23,10 +37,18 @@ const App: Component = () => {
|
||||
bumpThumbVersions(ids);
|
||||
refreshPendingCounts();
|
||||
}),
|
||||
// 스캔 중에는 2초마다 온다 — 사이드바·스냅샷·삭제표시 갱신은 저 안에서 합쳐진다.
|
||||
onLibraryChanged(() => {
|
||||
onLibraryChangedRefresh();
|
||||
refreshPendingCounts();
|
||||
}),
|
||||
// 대량 삭제 보류 — 조용히 지나가면 사용자는 "왜 없는 파일이 목록에 있는지" 알 수 없다.
|
||||
// 토스트로 알리고, 사이드바 정리 그룹에 '사라진 파일 정리' 진입점을 띄운다
|
||||
// (토스트는 4초면 사라지지만 보류 상태는 사용자가 확정할 때까지 남는다).
|
||||
onScanGuarded((g) => {
|
||||
toast(ko.refresh.guarded(g.count));
|
||||
noteGuarded(g);
|
||||
}),
|
||||
];
|
||||
onCleanup(() => {
|
||||
for (const u of unlisteners) void u.then((f) => f());
|
||||
@@ -34,18 +56,32 @@ const App: Component = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="grid h-full grid-rows-[1fr_auto]">
|
||||
<div class="grid min-h-0 grid-cols-[240px_1fr_280px]">
|
||||
<div class="grid h-full grid-rows-[1fr_auto] bg-[var(--bg-app)]">
|
||||
{/* minmax(0,1fr) 필수 — 1fr(=minmax(auto,1fr))이면 본문이 최소 콘텐츠 폭 아래로 줄지 못한다 */}
|
||||
<div
|
||||
class="grid min-h-0 gap-2 px-2 pt-2 pb-1"
|
||||
style={{
|
||||
"grid-template-columns": detailsOpen()
|
||||
? "248px minmax(0,1fr) 300px"
|
||||
: "248px minmax(0,1fr)",
|
||||
}}
|
||||
>
|
||||
<Sidebar />
|
||||
<ContentArea />
|
||||
<DetailsPanel />
|
||||
<Show when={detailsOpen()}>
|
||||
<DetailsPanel />
|
||||
</Show>
|
||||
</div>
|
||||
<StatusBar />
|
||||
<Lightbox />
|
||||
<ContextMenu />
|
||||
<MoveDialog />
|
||||
<MultiPlayView />
|
||||
<DedupePanel />
|
||||
{/* 비교 뷰는 중복 모달 '위'에 뜬다 — Esc는 위에 있는 것부터 닫힌다 */}
|
||||
<CompareView />
|
||||
<RemoteDialog />
|
||||
<SourceEditDialog />
|
||||
<ShortcutsOverlay />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
// 중복 비교 — 두 파일을 나란히 놓고 **같은 내용이 같은 시각에 보이도록** 맞춰 재생한다.
|
||||
//
|
||||
// 그냥 0초부터 나란히 돌리면 인트로가 붙은 사본은 끝까지 어긋난 화면을 보여줘서 비교가 안 된다.
|
||||
// 그래서 탐지 때 오프셋 투표로 알아낸 `offsetMs`(keeper 시각 − 멤버 시각)를 그대로 쓴다:
|
||||
// member.currentTime = keeper.currentTime - offsetMs/1000
|
||||
// offsetMs가 null인 멤버(전이 병합으로 들어와 정렬을 직접 확인하지 못한 경우)는 맞추지 않고
|
||||
// 그 사실을 화면에 적는다 — 맞춰진 척하는 것보다 낫다.
|
||||
//
|
||||
// 시간축은 항상 **keeper 기준**이다(첫 패널). 시크·재생은 keeper 시각 하나로 관리하고
|
||||
// 나머지는 매 프레임 따라붙는다(오차 0.2초 넘으면 보정).
|
||||
//
|
||||
// 보기는 둘이다:
|
||||
// 1:1 — keeper와 사본 하나. 세부를 크게 본다(기본).
|
||||
// 전체 — 그룹의 모든 사본을 한 화면에서 동시에. 사본이 5개인데 1:1로 네 번 돌려보는 것보다
|
||||
// "어느 하나가 유독 흐리다/짧다"를 훨씬 빨리 찾는다. 대신 영상 N개가 동시에 돌고
|
||||
// 직접 재생이 안 되는 코덱은 각각 ffmpeg 리먹스를 띄우므로 비용이 N배다 —
|
||||
// 그래서 기본이 아니라 선택이다.
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import {
|
||||
decidePlayback,
|
||||
mediaUrl,
|
||||
playbackInfo,
|
||||
startStream,
|
||||
stopStream,
|
||||
thumbUrl,
|
||||
type DupeGroup,
|
||||
type DupeMember,
|
||||
type PlaybackPlan,
|
||||
} from "../../ipc/commands";
|
||||
import { closeDedupe, info as appInfoSig, setView } from "../../state/store";
|
||||
import { fmtDuration } from "./format";
|
||||
import { ko } from "../../i18n/ko";
|
||||
import {
|
||||
IconFolder,
|
||||
IconPause,
|
||||
IconPlay,
|
||||
IconResetZoom,
|
||||
IconRewind,
|
||||
IconForward,
|
||||
IconVolumeHigh,
|
||||
IconVolumeMute,
|
||||
IconX,
|
||||
} from "../../ui/icons";
|
||||
|
||||
// ── 열림 상태 (다이얼로그 3종과 같은 방식: 모듈 시그널) ──
|
||||
const [target, setTarget] = createSignal<{ group: DupeGroup; otherId: number } | undefined>();
|
||||
export const compareTarget = target;
|
||||
export const compareOpen = (): boolean => target() !== undefined;
|
||||
/** 그룹의 keeper와 `otherId`를 나란히 비교한다. */
|
||||
export function openCompare(group: DupeGroup, otherId: number): void {
|
||||
setTarget({ group, otherId });
|
||||
}
|
||||
export const closeCompare = (): void => setTarget(undefined);
|
||||
|
||||
const SKIP = 5;
|
||||
const DRIFT_TOLERANCE = 0.2;
|
||||
/** 어느 패널의 소리도 듣지 않는 상태 */
|
||||
const MUTED = -1;
|
||||
const ZOOM_MIN = 1;
|
||||
const ZOOM_MAX = 8;
|
||||
|
||||
/** 시크바·구간 표기 — 목록 타일과 같은 규칙을 쓴다(한 시간 넘는 영상도 h:mm:ss로 맞다). */
|
||||
const fmt = (sec: number): string => fmtDuration(sec * 1000);
|
||||
|
||||
/** 부호 있는 초 표기 — "5.0초 늦게 / 2.5초 빠르게" */
|
||||
function fmtShift(ms: number): string {
|
||||
const sec = Math.abs(ms) / 1000;
|
||||
return sec < 0.05 ? ko.compare.aligned : ms < 0 ? ko.compare.later(sec) : ko.compare.earlier(sec);
|
||||
}
|
||||
|
||||
/** 한쪽 패널의 재생 제어 손잡이. 부모가 keeper 시각 하나로 양쪽을 움직인다. */
|
||||
interface PaneApi {
|
||||
/**
|
||||
* keeper 시각(초)으로 이동.
|
||||
* `hard`는 **사용자가 위치를 옮겼다**는 뜻이다 — 리먹스 스트림은 그때만 다시 띄운다.
|
||||
* 매 프레임 도는 드리프트 보정으로 ffmpeg를 재시작하면, 재시작에 걸리는 1초 사이
|
||||
* keeper가 또 움직여서 영원히 따라잡지 못하고 재시작만 반복한다(실측).
|
||||
*/
|
||||
seek: (keeperSec: number, hard?: boolean) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
/** 현재 keeper 시각 환산값 */
|
||||
keeperTime: () => number;
|
||||
}
|
||||
|
||||
const VideoPane: Component<{
|
||||
member: DupeMember;
|
||||
/** keeper 시각 − 이 멤버 시각(ms). keeper 자신은 0 */
|
||||
offsetMs: number | null;
|
||||
muted: boolean;
|
||||
/** 부모의 재생 상태 — 범위를 벗어났다 돌아왔을 때 스스로 재생을 되살린다 */
|
||||
playing: boolean;
|
||||
/** 열릴 때의 keeper 시각(초) — 스트림 모드는 이 지점에서 ffmpeg를 띄운다 */
|
||||
startAtSec: number;
|
||||
register: (api: PaneApi | undefined) => void;
|
||||
}> = (props) => {
|
||||
const app = appInfoSig();
|
||||
let el: HTMLVideoElement | undefined;
|
||||
const [src, setSrc] = createSignal<string | undefined>();
|
||||
// 스트림(리먹스) 재생은 시작 시각이 파일 0초가 아니다 — 환산에 쓴다
|
||||
const [base, setBase] = createSignal(0);
|
||||
const [dur, setDur] = createSignal(0);
|
||||
const [oor, setOor] = createSignal(false);
|
||||
const [plan, setPlan] = createSignal<PlaybackPlan | undefined>();
|
||||
let jobId: number | undefined;
|
||||
let restartTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
/** 예약된 재시작 지점(초). 타이머가 도는 동안에는 이 값만 갱신한다. */
|
||||
let restartTo: number | undefined;
|
||||
const off = () => (props.offsetMs ?? 0) / 1000;
|
||||
|
||||
const stop = () => {
|
||||
clearTimeout(restartTimer);
|
||||
restartTimer = undefined;
|
||||
if (jobId !== undefined) {
|
||||
void stopStream(jobId);
|
||||
jobId = undefined;
|
||||
}
|
||||
};
|
||||
onCleanup(stop);
|
||||
|
||||
/** 리먹스 스트림을 `atSec`부터 다시 시작한다 — 스트림은 버퍼 밖으로 시크할 수 없다.
|
||||
(플레이어와 같은 방식: 위치를 바꾸면 ffmpeg를 그 지점에서 새로 띄운다) */
|
||||
const openStreamAt = async (atSec: number) => {
|
||||
const p = plan();
|
||||
if (!p || p.mode !== "stream") return;
|
||||
const prev = jobId;
|
||||
try {
|
||||
const r = await startStream(props.member.fileId, atSec, p.video, p.audio);
|
||||
jobId = r.jobId;
|
||||
setBase(atSec);
|
||||
setSrc(r.url);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (prev !== undefined) void stopStream(prev);
|
||||
};
|
||||
|
||||
// 직접 재생이 가능하면 그대로, 아니면 ffmpeg 리먹스 스트림으로 (플레이어와 같은 판단).
|
||||
// MKV·HEVC 같은 컨테이너/코덱도 이 경로로 비교할 수 있다.
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
if (!app) return;
|
||||
try {
|
||||
const p = decidePlayback(await playbackInfo(props.member.fileId));
|
||||
setPlan(p);
|
||||
if (p.mode === "direct") {
|
||||
setSrc(mediaUrl(app, props.member.fileId));
|
||||
} else if (p.mode === "stream") {
|
||||
// 열릴 때 이미 일치 구간으로 이동해 있을 수 있으니 그 지점에서 시작한다
|
||||
await openStreamAt(Math.max(0, props.startAtSec - off()));
|
||||
}
|
||||
} catch {
|
||||
/* 재생 불가 — 썸네일만 보여준다 */
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (el) el.muted = props.muted;
|
||||
});
|
||||
|
||||
props.register({
|
||||
seek: (keeperSec, hard = false) => {
|
||||
if (!el) return;
|
||||
const want = keeperSec - off();
|
||||
const streaming = plan()?.mode === "stream";
|
||||
const fileLen = (props.member.durationMs ?? 0) / 1000;
|
||||
// 스트림 모드의 `el.duration`은 **지금까지 리먹스된 분량**이라 파일 길이가 아니다.
|
||||
// 그걸 범위 기준으로 쓰면 멀쩡한 지점을 '상대 파일에 없음'으로 잘못 판정한다.
|
||||
const max = streaming ? fileLen : dur() || fileLen;
|
||||
const inRange = want >= -0.05 && (max === 0 || want <= max + 0.05);
|
||||
setOor(!inRange);
|
||||
if (!inRange) {
|
||||
el.pause();
|
||||
return;
|
||||
}
|
||||
// 스트림은 버퍼 밖을 시크할 수 없다 — 크게 움직이면 그 지점에서 ffmpeg를 다시 띄운다.
|
||||
// 작은 보정(부모의 0.2초 드리프트 교정)은 currentTime으로 처리해 재시작 폭주를 막는다.
|
||||
if (streaming && hard && Math.abs(base() + el.currentTime - want) > 1.5) {
|
||||
restartTo = Math.max(0, want);
|
||||
// **타이머를 다시 걸지 않는다.** 사용자가 슬라이더를 끌면 seek이 연속으로 오는데
|
||||
// 매번 clearTimeout하면 250ms가 영원히 오지 않아 스트림 패널만 멈춰 있게 된다.
|
||||
if (restartTimer === undefined) {
|
||||
restartTimer = setTimeout(() => {
|
||||
restartTimer = undefined;
|
||||
if (restartTo !== undefined) void openStreamAt(restartTo);
|
||||
}, 250);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const t = Math.max(0, want - base());
|
||||
if (Math.abs(el.currentTime - t) > 0.05) el.currentTime = t;
|
||||
// 범위를 벗어났다 돌아온 경우 — 부모가 재생 중이면 다시 돌려준다
|
||||
if (props.playing && el.paused) void el.play().catch(() => {});
|
||||
},
|
||||
play: () => {
|
||||
if (el && !oor()) void el.play().catch(() => {});
|
||||
},
|
||||
pause: () => el?.pause(),
|
||||
keeperTime: () => (el ? base() + el.currentTime + off() : 0),
|
||||
});
|
||||
onCleanup(() => props.register(undefined));
|
||||
|
||||
return (
|
||||
<div class="compare-pane">
|
||||
<Show
|
||||
when={src()}
|
||||
fallback={
|
||||
<img
|
||||
class="compare-media"
|
||||
src={app ? thumbUrl(app, props.member.fileId, 1) : undefined}
|
||||
alt=""
|
||||
/>
|
||||
}
|
||||
>
|
||||
<video
|
||||
class="compare-media"
|
||||
ref={(e) => (el = e)}
|
||||
src={src()}
|
||||
preload="auto"
|
||||
playsinline
|
||||
muted={props.muted}
|
||||
onLoadedMetadata={() => {
|
||||
setDur(el?.duration && isFinite(el.duration) ? el.duration : 0);
|
||||
// 새로 만들어진 패널은 자기 0초에서 시작한다 — 보기를 바꾸거나 비교 대상을
|
||||
// 갈아끼우면 재생바는 10초를 가리키는데 화면만 0초인 상태가 된다.
|
||||
// 스트림 모드는 애초에 그 지점에서 띄우므로(openStreamAt) 여기서 손대지 않는다.
|
||||
if (el && plan()?.mode !== "stream") {
|
||||
const want = Math.max(0, props.startAtSec - off());
|
||||
if (Math.abs(el.currentTime - want) > 0.05) el.currentTime = want;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={oor()}>
|
||||
{/* 한쪽에만 있는 구간 — 검은 화면을 그냥 보여주면 '깨진 것'으로 오해한다 */}
|
||||
<div class="compare-pane__note">{ko.compare.outOfRange}</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ImagePane: Component<{ member: DupeMember; zoom: number; pan: [number, number] }> = (
|
||||
props,
|
||||
) => {
|
||||
const app = appInfoSig();
|
||||
return (
|
||||
<div class="compare-pane">
|
||||
<img
|
||||
class="compare-media"
|
||||
style={{
|
||||
transform: `translate(${props.pan[0]}px, ${props.pan[1]}px) scale(${props.zoom})`,
|
||||
}}
|
||||
src={app ? mediaUrl(app, props.member.fileId) : undefined}
|
||||
alt={props.member.name}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CompareView: Component = () => {
|
||||
const [playing, setPlaying] = createSignal(false);
|
||||
const [pos, setPos] = createSignal(0); // keeper 시각(초)
|
||||
/**
|
||||
* 소리를 듣는 패널의 인덱스. 여러 소리를 같이 내면 비교가 안 되므로 **하나 아니면 없음**이다.
|
||||
* `MUTED`(-1)면 전부 음소거 — 화면만 보고 싶을 때(같은 소리가 계속 반복되면 방해가 된다).
|
||||
*/
|
||||
const [audioPane, setAudioPane] = createSignal(0);
|
||||
/** 음소거를 풀 때 돌아갈 자리. 듣던 것으로 되돌아가야 '껐다 켜기'가 성립한다. */
|
||||
let lastAudio = 0;
|
||||
const [showAll, setShowAll] = createSignal(false);
|
||||
const [zoom, setZoom] = createSignal(1);
|
||||
const [pan, setPan] = createSignal<[number, number]>([0, 0]);
|
||||
let apis: (PaneApi | undefined)[] = [];
|
||||
let raf = 0;
|
||||
|
||||
const group = () => target()?.group;
|
||||
const keeper = (): DupeMember | undefined =>
|
||||
group()?.members.find((m) => m.isKeeper) ?? group()?.members[0];
|
||||
const other = (): DupeMember | undefined =>
|
||||
group()?.members.find((m) => m.fileId === target()?.otherId);
|
||||
const isVideo = () => (keeper()?.kind ?? 0) === 1;
|
||||
/** 비교 대상으로 고를 수 있는 멤버들 (keeper 제외) */
|
||||
const others = () => group()?.members.filter((m) => !m.isKeeper) ?? [];
|
||||
|
||||
/**
|
||||
* 화면에 놓을 패널들. **0번은 언제나 keeper**다 — 시간축의 진실원이고,
|
||||
* 나머지가 그 시각을 따라오기 때문에 순서가 곧 계약이다.
|
||||
*/
|
||||
const panes = createMemo<DupeMember[]>(() => {
|
||||
const k = keeper();
|
||||
if (!k) return [];
|
||||
if (!showAll()) {
|
||||
const o = other();
|
||||
return o ? [k, o] : [k];
|
||||
}
|
||||
return [k, ...others()];
|
||||
});
|
||||
|
||||
/** 열 수 — 정사각에 가깝게, 최대 4열. 8개면 4×2, 5개면 3×2가 된다. */
|
||||
const cols = createMemo(() => Math.min(4, Math.ceil(Math.sqrt(panes().length))));
|
||||
|
||||
const totalSec = () => {
|
||||
const k = keeper();
|
||||
return (k?.durationMs ?? 0) / 1000;
|
||||
};
|
||||
|
||||
// 열릴 때·비교 대상을 바꿀 때 상태 초기화 + 일치 구간이 있으면 그 지점부터
|
||||
// (거기가 볼 만한 곳이다). `apis`는 비우지 않는다 — 패널이 스스로 등록/해제한다.
|
||||
createEffect(() => {
|
||||
const t = target();
|
||||
if (!t) return;
|
||||
setZoom(1);
|
||||
setPan([0, 0]);
|
||||
setPlaying(false);
|
||||
lastAudio = 0;
|
||||
setAudioPane(0);
|
||||
setPos((other()?.segStartMs ?? 0) / 1000);
|
||||
});
|
||||
|
||||
// 보기를 바꾸면 패널 수가 달라진다 — 사라진 패널의 손잡이가 남아 있으면
|
||||
// seek/pause가 없는 <video>를 향한다. 소리 인덱스가 범위를 벗어나면 첫 패널로 되돌리되,
|
||||
// **전체 음소거는 그대로 둔다**(-1은 범위 밖이 아니라 사용자가 고른 상태다).
|
||||
createEffect(() => {
|
||||
const n = panes().length;
|
||||
apis.length = n;
|
||||
if (audioPane() >= n) setAudio(0);
|
||||
if (lastAudio >= n) lastAudio = 0;
|
||||
});
|
||||
|
||||
/** 사용자가 위치를 옮겼다 — 스트림 패널은 여기서만 다시 띄운다(hard). */
|
||||
/** 소리 대상을 바꾼다. 끄기 전에 듣던 자리를 기억해 둔다(다시 켤 때 그리로 돌아간다). */
|
||||
const setAudio = (i: number) => {
|
||||
if (i !== MUTED) lastAudio = i;
|
||||
setAudioPane(i);
|
||||
};
|
||||
/** 전체 음소거 ↔ 마지막으로 듣던 패널 */
|
||||
const toggleMuteAll = () => setAudio(audioPane() === MUTED ? lastAudio : MUTED);
|
||||
|
||||
/** 사용자가 위치를 옮겼다 — 스트림 패널은 여기서만 다시 띄운다(hard). */
|
||||
const seekAll = (t: number) => {
|
||||
const clamped = Math.max(0, totalSec() > 0 ? Math.min(t, totalSec()) : t);
|
||||
setPos(clamped);
|
||||
for (const a of apis) a?.seek(clamped, true);
|
||||
};
|
||||
|
||||
const setPlay = (on: boolean) => {
|
||||
setPlaying(on);
|
||||
if (on) {
|
||||
// 재생을 시작하는 순간 한 번 맞춘다(hard). 리먹스 스트림 패널은 자기가 열린 지점부터
|
||||
// 독립적으로 흐르므로, 여기서 안 맞추면 처음부터 어긋난 채로 나란히 돈다.
|
||||
// 재생 중에는 다시 맞추지 않는다 — 재시작에 걸리는 1초 사이 keeper가 또 움직여
|
||||
// 따라잡지 못하고 재시작만 반복하기 때문이다.
|
||||
for (const a of apis) a?.seek(pos(), true);
|
||||
}
|
||||
for (const a of apis) (on ? a?.play : a?.pause)?.();
|
||||
};
|
||||
|
||||
// keeper를 진실원으로 삼아 진행을 읽고, 벌어진 패널을 다시 맞춘다
|
||||
onMount(() => {
|
||||
const tick = () => {
|
||||
raf = requestAnimationFrame(tick);
|
||||
if (!playing() || !isVideo()) return;
|
||||
const k = apis[0];
|
||||
if (!k) return;
|
||||
const t = k.keeperTime();
|
||||
setPos(t);
|
||||
// 0번(keeper)은 기준이므로 건드리지 않는다. 나머지는 각자 자기 오프셋으로 따라온다.
|
||||
for (let i = 1; i < apis.length; i++) {
|
||||
const a = apis[i];
|
||||
if (a && Math.abs(a.keeperTime() - t) > DRIFT_TOLERANCE) a.seek(t);
|
||||
}
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
onCleanup(() => cancelAnimationFrame(raf));
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!compareOpen()) return;
|
||||
const k = e.key;
|
||||
if (k === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeCompare();
|
||||
return;
|
||||
}
|
||||
if (!isVideo() && (k === "0" || k === "r")) {
|
||||
setZoom(1);
|
||||
setPan([0, 0]);
|
||||
return;
|
||||
}
|
||||
if (!isVideo()) return;
|
||||
if (k === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPlay(!playing());
|
||||
} else if (k === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekAll(pos() - SKIP);
|
||||
} else if (k === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekAll(pos() + SKIP);
|
||||
} else if (k === "m") {
|
||||
// 순환에 '전체 음소거'도 넣는다 — M을 계속 누르면 각 패널을 거쳐 무음까지 간다.
|
||||
// (0 → 1 → … → n-1 → 없음 → 0)
|
||||
const n = Math.max(1, panes().length);
|
||||
const cur = audioPane();
|
||||
setAudio(cur === MUTED ? 0 : cur + 1 >= n ? MUTED : cur + 1);
|
||||
} else if (k === "M") {
|
||||
// Shift+M — 순환을 거치지 않고 바로 껐다 켠다(패널이 많을 때)
|
||||
toggleMuteAll();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey, { capture: true });
|
||||
onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true }));
|
||||
});
|
||||
|
||||
// 이미지 확대/이동 — 두 패널이 같은 값을 쓰므로 항상 같은 부분을 본다
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (isVideo()) return;
|
||||
e.preventDefault();
|
||||
setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z * (e.deltaY < 0 ? 1.15 : 1 / 1.15))));
|
||||
if (zoom() <= ZOOM_MIN) setPan([0, 0]);
|
||||
};
|
||||
let drag: { x: number; y: number; px: number; py: number } | undefined;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (isVideo() || zoom() <= ZOOM_MIN) return;
|
||||
drag = { x: e.clientX, y: e.clientY, px: pan()[0], py: pan()[1] };
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!drag) return;
|
||||
setPan([drag.px + (e.clientX - drag.x), drag.py + (e.clientY - drag.y)]);
|
||||
};
|
||||
const onUp = () => (drag = undefined);
|
||||
|
||||
const seg = createMemo(() => {
|
||||
const o = other();
|
||||
if (o?.segStartMs == null || o.segEndMs == null) return undefined;
|
||||
return { start: o.segStartMs / 1000, end: o.segEndMs / 1000 };
|
||||
});
|
||||
|
||||
/**
|
||||
* 패널마다의 시간축. keeper 시간축 하나만 있으면 "이 사본의 몇 분 몇 초인가"에 답할 수 없다 —
|
||||
* 잘라낸 사본·인트로가 붙은 사본은 자기 시각이 따로 있고, 사용자가 기억하는 것도 그쪽이다.
|
||||
*
|
||||
* 끌면 **그 파일 기준으로** 전체가 따라온다(멤버 시각 → keeper 시각으로 환산해 seekAll).
|
||||
* 각자 따로 움직이면 나란히 놓은 의미가 없어지므로, 기준만 바뀌고 동기는 유지된다.
|
||||
*/
|
||||
const paneTimeline = (m: DupeMember, side: number) => {
|
||||
const off = () => (side === 0 ? 0 : (m.offsetMs ?? 0) / 1000);
|
||||
const dur = () => (m.durationMs ?? 0) / 1000;
|
||||
/** 이 사본의 현재 시각 (keeper 시각 − 오프셋) */
|
||||
const at = () => pos() - off();
|
||||
/** keeper 위치가 이 파일에 없는 구간 — 값을 끝에 붙여두고 흐리게 표시한다 */
|
||||
const oor = () => dur() > 0 && (at() < -0.05 || at() > dur() + 0.05);
|
||||
const clamped = () => Math.min(Math.max(at(), 0), Math.max(dur(), 0.1));
|
||||
const pct = () => (dur() > 0 ? (clamped() / dur()) * 100 : 0);
|
||||
/** 일치 구간을 이 사본의 시간축으로 옮긴 띠 (keeper 자신은 구간 기록이 없다) */
|
||||
const band = createMemo(() => {
|
||||
if (m.segStartMs == null || m.segEndMs == null || dur() <= 0) return undefined;
|
||||
const a = (m.segStartMs / 1000 - off()) / dur();
|
||||
const b = (m.segEndMs / 1000 - off()) / dur();
|
||||
const lo = Math.max(0, Math.min(a, b));
|
||||
const hi = Math.min(1, Math.max(a, b));
|
||||
return hi > lo ? { left: `${lo * 100}%`, width: `${(hi - lo) * 100}%` } : undefined;
|
||||
});
|
||||
return (
|
||||
<div class="compare-time" classList={{ "is-oor": oor() }}>
|
||||
<div class="compare-time__wrap">
|
||||
<Show when={band()}>
|
||||
{(b) => <div class="compare-time__seg" style={{ left: b().left, width: b().width }} />}
|
||||
</Show>
|
||||
<input
|
||||
type="range"
|
||||
class="compare-time__bar"
|
||||
style={{ "--p": `${pct()}%` }}
|
||||
min={0}
|
||||
max={Math.max(dur(), 0.1)}
|
||||
step={0.05}
|
||||
value={clamped()}
|
||||
aria-label={m.name}
|
||||
onInput={(e) => seekAll(Number(e.currentTarget.value) + off())}
|
||||
/>
|
||||
</div>
|
||||
<span class="compare-time__label">
|
||||
{oor() ? ko.compare.outOfRangeShort : fmt(at())} / {fmt(dur())}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const paneMeta = (m: DupeMember, side: number) => (
|
||||
<div class="compare-head">
|
||||
<span class="compare-head__name" title={`${m.dir}\\${m.name}`}>
|
||||
{m.name}
|
||||
</span>
|
||||
<Show when={m.isKeeper}>
|
||||
<span class="chip chip--xs">{ko.dedupe.keeper}</span>
|
||||
</Show>
|
||||
<span class="compare-head__meta">
|
||||
{m.width && m.height ? `${m.width}×${m.height}` : ""}
|
||||
{/* 길이는 여기서도 말한다 — 전체 보기에서 '이것만 짧다'가 한눈에 보인다 */}
|
||||
{m.durationMs != null && m.durationMs > 0 ? ` · ${fmtDuration(m.durationMs)}` : ""}
|
||||
{m.distance != null ? ` · ${ko.dedupe.distance(m.distance)}` : ""}
|
||||
</span>
|
||||
<span class="ml-auto flex flex-none items-center gap-1">
|
||||
{/* "이게 대체 어디 있는 파일이지" — 검토 중 가장 자주 나오는 질문이다 */}
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--icon"
|
||||
title={ko.compare.reveal}
|
||||
onClick={() => {
|
||||
void setView({
|
||||
type: "folder",
|
||||
folderId: m.folderId,
|
||||
recursive: false,
|
||||
sourceId: m.sourceId,
|
||||
});
|
||||
closeCompare();
|
||||
closeDedupe();
|
||||
}}
|
||||
>
|
||||
<IconFolder size={14} />
|
||||
</button>
|
||||
<Show when={isVideo()}>
|
||||
{/* 듣고 있는 쪽을 다시 누르면 전부 끈다 — 켠 버튼이 곧 끄는 버튼이다 */}
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--icon"
|
||||
title={audioPane() === side ? ko.compare.audioOn : ko.compare.audioOff}
|
||||
onClick={() => setAudio(audioPane() === side ? MUTED : side)}
|
||||
>
|
||||
{audioPane() === side ? <IconVolumeHigh size={14} /> : <IconVolumeMute size={14} />}
|
||||
</button>
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Show when={target() && keeper() && other()}>
|
||||
<div class="modal-scrim" onClick={closeCompare}>
|
||||
<div
|
||||
class="modal-card compare-card"
|
||||
tabindex={-1}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
ref={(el) => queueMicrotask(() => el.focus())}
|
||||
>
|
||||
<div class="modal-head">
|
||||
<span class="modal-head__title">{ko.compare.title}</span>
|
||||
{/* 전체 보기에서는 사본마다 정렬값이 달라 한 문장으로 말할 수 없다 —
|
||||
그때는 각 패널 제목이 자기 값을 말하고, 여기서는 몇 개를 보는지만 말한다 */}
|
||||
<span class="modal-head__sub">
|
||||
<Show
|
||||
when={!showAll()}
|
||||
fallback={ko.compare.allCount(panes().length)}
|
||||
>
|
||||
{other()!.offsetMs == null
|
||||
? ko.compare.noAlignment
|
||||
: fmtShift(other()!.offsetMs as number)}
|
||||
<Show when={seg()}>
|
||||
{(s) => ` · ${ko.compare.segment(fmt(s().start), fmt(s().end))}`}
|
||||
</Show>
|
||||
</Show>
|
||||
</span>
|
||||
<button class="icon-btn ml-auto" onClick={closeCompare} title={ko.common.close}>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 사본이 2개 이상이면 보기를 고를 수 있다: 1:1(크게) 또는 전체(한눈에) */}
|
||||
<Show when={others().length > 1}>
|
||||
<div class="modal-bar">
|
||||
<div class="segmented flex-none">
|
||||
<button classList={{ "is-on": !showAll() }} onClick={() => setShowAll(false)}>
|
||||
{ko.compare.modePair}
|
||||
</button>
|
||||
<button
|
||||
classList={{ "is-on": showAll() }}
|
||||
title={ko.compare.modeAllHint(others().length + 1)}
|
||||
onClick={() => setShowAll(true)}
|
||||
>
|
||||
{ko.compare.modeAll}
|
||||
</button>
|
||||
</div>
|
||||
{/* 1:1일 때만 상대를 고른다 — 전체 보기에서는 고를 것이 없다 */}
|
||||
<Show when={!showAll()}>
|
||||
<span class="flex-none text-[var(--text-muted)]">{ko.compare.pick}</span>
|
||||
<For each={others()}>
|
||||
{(m) => (
|
||||
<button
|
||||
class="chip chip--xs"
|
||||
classList={{ "chip--accent": m.fileId === target()!.otherId }}
|
||||
onClick={() => setTarget({ group: group()!, otherId: m.fileId })}
|
||||
>
|
||||
{m.name}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="compare-grid"
|
||||
style={{ "--compare-cols": String(cols()) }}
|
||||
onWheel={onWheel}
|
||||
onPointerDown={onDown}
|
||||
onPointerMove={onMove}
|
||||
onPointerUp={onUp}
|
||||
classList={{ "is-pannable": !isVideo() && zoom() > ZOOM_MIN }}
|
||||
>
|
||||
<For each={panes()}>
|
||||
{(m, i) => (
|
||||
<div class="compare-col">
|
||||
{paneMeta(m, i())}
|
||||
<Show
|
||||
when={isVideo()}
|
||||
fallback={<ImagePane member={m} zoom={zoom()} pan={pan()} />}
|
||||
>
|
||||
{/* keyed — 이 자리에 다른 파일이 오면 패널을 **다시 만든다**. 안 그러면
|
||||
<video>의 src 설정이 onMount에서 한 번만 돌아 이전 파일이 계속 재생된다. */}
|
||||
<Show when={m} keyed>
|
||||
{(mm) => (
|
||||
<VideoPane
|
||||
member={mm}
|
||||
// keeper는 기준이므로 오프셋 0. 나머지는 자기 값으로 맞춘다.
|
||||
offsetMs={i() === 0 ? 0 : mm.offsetMs}
|
||||
muted={audioPane() !== i()}
|
||||
playing={playing()}
|
||||
startAtSec={pos()}
|
||||
register={(a) => (apis[i()] = a)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
{/* 자기 시간축 — 영상에만. 사진은 시간이 없다 */}
|
||||
<Show when={isVideo()}>{paneTimeline(m, i())}</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 — 영상은 재생/시크(keeper 기준), 이미지는 확대 */}
|
||||
<div class="modal-bar">
|
||||
<Show
|
||||
when={isVideo()}
|
||||
fallback={
|
||||
<>
|
||||
<span class="flex-none text-[var(--text-muted)]">{ko.compare.zoomHint}</span>
|
||||
<span class="flex-none tabular-nums">{Math.round(zoom() * 100)}%</span>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--icon ml-auto"
|
||||
title={ko.compare.reset}
|
||||
onClick={() => {
|
||||
setZoom(1);
|
||||
setPan([0, 0]);
|
||||
}}
|
||||
>
|
||||
<IconResetZoom size={14} />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--icon"
|
||||
title={ko.compare.back(SKIP)}
|
||||
onClick={() => seekAll(pos() - SKIP)}
|
||||
>
|
||||
<IconRewind size={14} />
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--primary"
|
||||
onClick={() => setPlay(!playing())}
|
||||
title={playing() ? ko.compare.pause : ko.compare.play}
|
||||
>
|
||||
{playing() ? <IconPause size={14} /> : <IconPlay size={14} />}
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--icon"
|
||||
title={ko.compare.fwd(SKIP)}
|
||||
onClick={() => seekAll(pos() + SKIP)}
|
||||
>
|
||||
<IconForward size={14} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
class="compare-seek"
|
||||
min={0}
|
||||
max={Math.max(totalSec(), 0.1)}
|
||||
step={0.05}
|
||||
value={pos()}
|
||||
onInput={(e) => seekAll(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span class="flex-none tabular-nums text-[var(--text-muted)]">
|
||||
{fmt(pos())} / {fmt(totalSec())}
|
||||
</span>
|
||||
{/* 전체 음소거는 여기에도 둔다 — 패널이 8개면 '지금 소리 나는 칸'을 찾아
|
||||
그 버튼을 누르는 것은 조작이 아니라 수색이다 */}
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--icon flex-none"
|
||||
classList={{ "is-on": audioPane() === MUTED }}
|
||||
title={audioPane() === MUTED ? ko.compare.unmuteAll : ko.compare.muteAll}
|
||||
onClick={toggleMuteAll}
|
||||
>
|
||||
{audioPane() === MUTED ? <IconVolumeMute size={14} /> : <IconVolumeHigh size={14} />}
|
||||
</button>
|
||||
<Show when={seg()}>
|
||||
{(s) => (
|
||||
<button class="toolbar-btn flex-none" onClick={() => seekAll(s().start)}>
|
||||
{ko.compare.toSegment}
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
+532
-190
@@ -1,4 +1,16 @@
|
||||
// 중복 검토 패널 — 전체화면 오버레이. 그룹별 나란히 비교 + keeper 외 휴지통.
|
||||
// 중복 검토 — 모달. 기존 모달 3종(MoveDialog·RemoteDialog·SourceEditDialog)과 같은 언어를
|
||||
// 쓴다: 스크림 + 카드 + Esc + 바깥 클릭. 전에는 불투명 전체화면(fixed inset-0 bg-[--bg-app])
|
||||
// 이라 '중복 찾기'를 누르면 사용자에게는 창이 바뀐 것처럼 보였다 — 그게 이 변경의 이유다.
|
||||
// 폭을 좁히는 것은 목적이 아니다. 그룹 목록은 길고 썸네일이 많아 작업 면적이 필요하므로
|
||||
// 카드는 .modal-card--wide(min(1080px, 100vw−64px))이고, 960px 창에서 896px로 줄어
|
||||
// 가로 스크롤이 0이 된다.
|
||||
//
|
||||
// 진행은 이 컴포넌트가 소유하지 않는다. dedupeRunning / dedupeProgress / dedupeJob /
|
||||
// dedupeCancelPending은 전부 store(모듈 시그널) 소유다. 그래서 모달을 닫아도 탐지는
|
||||
// 계속되고, 사이드바 작업 행에서 계속 보이며 거기서도 취소된다(커밋 ac3b527의 성질).
|
||||
// 여기서는 자체 진행 시그널도, 타이머도 만들지 않는다 — 읽기만 한다.
|
||||
// 취소는 사이드바와 똑같이 requestCancelDedupe() 하나를 부른다(파괴적이지 않으므로 확인 없음).
|
||||
import { IconCheck, IconWarning, IconX } from "../../ui/icons";
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
@@ -6,25 +18,38 @@ import {
|
||||
createSignal,
|
||||
For,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import { ask } from "@tauri-apps/plugin-dialog";
|
||||
import { compareOpen, openCompare } from "./CompareView";
|
||||
import { byTier, tierOf } from "./dupeTier";
|
||||
import { fmtDuration, fmtSize } from "./format";
|
||||
import { ko } from "../../i18n/ko";
|
||||
import {
|
||||
dismissDupePair,
|
||||
listDupeGroups,
|
||||
markDelete,
|
||||
resolveDupeGroup,
|
||||
setDupeKeeper,
|
||||
setSigSpeed,
|
||||
sigSpeed,
|
||||
thumbUrl,
|
||||
trashFiles,
|
||||
type DupeGroup,
|
||||
type DupeMember,
|
||||
type SigSpeed,
|
||||
} from "../../ipc/commands";
|
||||
import {
|
||||
closeDedupe,
|
||||
dedupeCancelPending,
|
||||
dedupeJob,
|
||||
dedupeOpen,
|
||||
dedupeProgress,
|
||||
dedupeRunning,
|
||||
info,
|
||||
pending,
|
||||
refreshMarked,
|
||||
refreshSidebar,
|
||||
refreshSnapshot,
|
||||
requestCancelDedupe,
|
||||
@@ -33,49 +58,129 @@ import {
|
||||
view,
|
||||
} from "../../state/store";
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)}MB`;
|
||||
if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)}KB`;
|
||||
return `${bytes}B`;
|
||||
/** 타일 부제 — 없는 값은 말하지 않는다. 거리는 ko.dedupe.distance가 문장을 만든다. */
|
||||
function metaLine(m: DupeMember): string {
|
||||
const parts: string[] = [];
|
||||
if (m.width && m.height) parts.push(`${m.width}×${m.height}`);
|
||||
// 길이는 해상도 바로 뒤 — 영상 그룹에서 '잘린 사본'을 고르는 가장 빠른 단서다
|
||||
if (m.durationMs != null && m.durationMs > 0) parts.push(fmtDuration(m.durationMs));
|
||||
parts.push(fmtSize(m.size));
|
||||
if (m.distance != null) parts.push(ko.dedupe.distance(m.distance));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<number, string> = { 0: "완전 동일", 1: "유사 이미지", 2: "유사 영상" };
|
||||
/** 한 번에 그리는 그룹 수. 수백 그룹에서 썸네일 수천 장을 한꺼번에 붙이지 않기 위한 상한. */
|
||||
const PAGE = 20;
|
||||
|
||||
const FOCUSABLE =
|
||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export const DedupePanel: Component = () => {
|
||||
const [includeVideo, setIncludeVideo] = createSignal(false);
|
||||
// 회전·반전본은 dHash가 완전히 달라 변형 8개를 따로 조회해야 한다 — 비용이 몇 배라 기본 꺼짐
|
||||
const [includeRotated, setIncludeRotated] = createSignal(false);
|
||||
const [threshold, setThreshold] = createSignal(5);
|
||||
// 지문 생성 속도 — 값의 진실원은 DB다(백필 스레드가 거기서 읽는다). 여기서는 비추기만 한다.
|
||||
const [speed, setSpeed] = createSignal<SigSpeed>("normal");
|
||||
createEffect(() => {
|
||||
if (dedupeOpen()) void sigSpeed().then(setSpeed).catch(() => {});
|
||||
});
|
||||
const changeSpeed = async (s: SigSpeed) => {
|
||||
const prev = speed();
|
||||
setSpeed(s); // 낙관적 — 버튼이 즉시 반응해야 한다
|
||||
try {
|
||||
await setSigSpeed(s);
|
||||
} catch (e) {
|
||||
setSpeed(prev);
|
||||
toast(String(e));
|
||||
}
|
||||
};
|
||||
const [groupsVersion, setGroupsVersion] = createSignal(0);
|
||||
/** 그룹 액션 중인 그룹 id. 진행 중에는 모든 그룹의 두 버튼을 잠근다(중복 실행 방지). */
|
||||
const [busyGroup, setBusyGroup] = createSignal<number | undefined>();
|
||||
const busy = () => busyGroup() != null;
|
||||
const [shown, setShown] = createSignal(PAGE);
|
||||
|
||||
const [groups] = createResource(
|
||||
let cardEl: HTMLDivElement | undefined;
|
||||
|
||||
// 목록 조회 실패를 여기서 삼킨다 — Solid 리소스는 읽는 순간 오류를 다시 던지므로
|
||||
// 삼키지 않으면 렌더 도중 터진다. 사용자에게는 토스트로 알린다.
|
||||
const [groups, { mutate: mutateGroups }] = createResource(
|
||||
() => (dedupeOpen() ? groupsVersion() : undefined),
|
||||
() => listDupeGroups(),
|
||||
() =>
|
||||
listDupeGroups().catch((e: unknown) => {
|
||||
toast(String(e));
|
||||
return [] as DupeGroup[];
|
||||
}),
|
||||
);
|
||||
|
||||
// 목록이 바뀌면(열기·재조회·탐지 완료) 페이징을 처음으로 되돌린다.
|
||||
createEffect(on(groups, () => setShown(PAGE)));
|
||||
|
||||
// 확실한 것을 앞으로 — 몰아서 정리하고 의심스러운 것만 눈으로 보게 한다
|
||||
const sorted = createMemo(() => [...(groups() ?? [])].sort(byTier));
|
||||
const visible = () => sorted().slice(0, shown());
|
||||
const restCount = () => Math.max(0, sorted().length - shown());
|
||||
const tierCounts = createMemo(() => {
|
||||
let certain = 0;
|
||||
for (const g of sorted()) if (tierOf(g) === "certain") certain += 1;
|
||||
return { certain, suspect: sorted().length - certain };
|
||||
});
|
||||
|
||||
// 현재 뷰가 폴더/소스면 그 범위로 좁힐 수 있다 (그 외 전체/검색/태그/스마트는 전체만).
|
||||
const currentScope = createMemo<
|
||||
{ label: string; args: { folderId?: number; recursive?: boolean; sourceId?: number } } | null
|
||||
>(() => {
|
||||
const v = view();
|
||||
if (v.type === "folder") return { label: ko.dedupe.scopeFolder, args: { folderId: v.folderId, recursive: v.recursive } };
|
||||
if (v.type === "folder")
|
||||
return { label: ko.dedupe.scopeFolder, args: { folderId: v.folderId, recursive: v.recursive } };
|
||||
if (v.type === "source") return { label: ko.dedupe.scopeSource, args: { sourceId: v.sourceId } };
|
||||
return null;
|
||||
});
|
||||
// 패널을 열 때: 범위를 좁힐 수 있으면 기본값을 '현재 위치'로.
|
||||
// 모달을 열 때: 범위를 좁힐 수 있으면 기본값을 '현재 위치'로.
|
||||
const [useCurrentScope, setUseCurrentScope] = createSignal(true);
|
||||
createEffect(() => {
|
||||
if (dedupeOpen()) setUseCurrentScope(currentScope() != null);
|
||||
});
|
||||
|
||||
/** 실행 중이면 '시작 시점에 캡처된 작업' — 그 사이 다른 폴더로 이동해도 이 말은 바뀌지 않는다. */
|
||||
const runningJob = () => (dedupeRunning() ? dedupeJob() : undefined);
|
||||
/** 끝난 뒤에는 같은 캡처가 '지금 보이는 결과의 조건'을 말한다. */
|
||||
const resultJob = () => (dedupeRunning() ? undefined : dedupeJob());
|
||||
|
||||
/** 진행률(%) — 총량을 모르는 구간은 undefined이고, 그때 막대는 셔틀(is-indet)로 돈다. */
|
||||
const pct = (): number | undefined => {
|
||||
const p = dedupeProgress();
|
||||
if (!p || p.phase === "done" || p.total <= 0) return undefined;
|
||||
return Math.min(100, Math.max(0, Math.round((100 * p.current) / p.total)));
|
||||
};
|
||||
|
||||
/** 헤더 한 줄 — 취소를 누른 뒤에는 사이드바 값 칸과 똑같이 '취소 중…' 하나로 통일한다. */
|
||||
const statusText = (): string => {
|
||||
if (dedupeCancelPending()) return ko.common.canceling;
|
||||
const p = dedupeProgress();
|
||||
return p && p.phase !== "done"
|
||||
? ko.dedupe.scanning(p.phase, p.current, p.total)
|
||||
: ko.dedupe.starting;
|
||||
};
|
||||
|
||||
const runScan = async () => {
|
||||
const scoped = useCurrentScope() ? currentScope() : null;
|
||||
try {
|
||||
await runDedupe({ ...(scoped?.args ?? {}), imageThreshold: threshold(), includeVideo: includeVideo() });
|
||||
// scopeLabel은 IPC로 가지 않는다 — store가 '지금 도는 조건'을 말하기 위한 캡처다.
|
||||
await runDedupe({
|
||||
...(scoped?.args ?? {}),
|
||||
imageThreshold: threshold(),
|
||||
includeVideo: includeVideo(),
|
||||
includeRotated: includeRotated(),
|
||||
scopeLabel: scoped?.label ?? ko.dedupe.scopeAll,
|
||||
});
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
// 탐지 완료(실행중 true→false) 시 결과 목록 갱신 — 패널이 닫혀 있다 열려도 재조회됨.
|
||||
// 탐지 완료(실행중 true→false) 시 결과 목록 갱신 — 모달이 닫혀 있다 열려도 재조회된다.
|
||||
createEffect(
|
||||
on(
|
||||
dedupeRunning,
|
||||
@@ -86,212 +191,449 @@ export const DedupePanel: Component = () => {
|
||||
),
|
||||
);
|
||||
|
||||
/** keeper 외 전부 휴지통 */
|
||||
const resolveGroup = async (g: DupeGroup) => {
|
||||
const losers = g.members.filter((m) => !m.isKeeper).map((m) => m.fileId);
|
||||
if (losers.length === 0) {
|
||||
await resolveDupeGroup(g.id);
|
||||
setGroupsVersion((v) => v + 1);
|
||||
return;
|
||||
/**
|
||||
* 보관 항목을 바꾼다 — 이 값이 '나머지 휴지통'의 삭제 대상을 정한다.
|
||||
*
|
||||
* 목록을 다시 부르지 않고 손안의 데이터를 고친다: 재조회는 페이징을 처음으로 되돌려
|
||||
* (`on(groups, …)`) 스크롤해 내려온 사용자를 맨 위로 튕겨낸다. 순서도 그대로 둔다 —
|
||||
* 누른 타일이 갑자기 맨 앞으로 뛰면 무엇을 눌렀는지 놓친다.
|
||||
*/
|
||||
const chooseKeeper = async (g: DupeGroup, m: DupeMember) => {
|
||||
if (busy() || m.isKeeper) return;
|
||||
try {
|
||||
// 정렬값(offsetMs·구간)이 새 기준으로 옮겨진 멤버들을 그대로 받아 갈아끼운다.
|
||||
// 여기서 isKeeper만 고치면 비교 재생이 옛 기준 오프셋으로 어긋난다(실측: 5초).
|
||||
const members = await setDupeKeeper(g.id, m.fileId);
|
||||
mutateGroups((prev) => (prev ?? []).map((x) => (x.id === g.id ? { ...x, members } : x)));
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
const yes = await ask(ko.dedupe.trashLosersConfirm(losers.length), {
|
||||
title: "Archive",
|
||||
kind: "warning",
|
||||
});
|
||||
if (!yes) return;
|
||||
await trashFiles(losers);
|
||||
await resolveDupeGroup(g.id);
|
||||
toast(ko.ops.trashDone(losers.length));
|
||||
refreshSidebar();
|
||||
await refreshSnapshot();
|
||||
setGroupsVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
const keepAll = async (g: DupeGroup) => {
|
||||
// 그룹 내 모든 쌍을 dismiss (재탐지 시 제외) + 해결 표시
|
||||
for (let i = 0; i < g.members.length; i++) {
|
||||
for (let j = i + 1; j < g.members.length; j++) {
|
||||
await dismissDupePair(g.members[i].fileId, g.members[j].fileId);
|
||||
/**
|
||||
* 보관 항목 외 전부를 **'삭제 예정'으로 표시**한다. 지우지 않는다.
|
||||
*
|
||||
* 예전에는 여기서 바로 휴지통으로 보냈다. 두 가지가 문제였다:
|
||||
* ① 결과(`OpSummary`)를 버리고 무조건 "N개를 휴지통으로 보냈습니다"라고 말해서,
|
||||
* OS 휴지통이 거부한 경우(네트워크·이동식 드라이브)에도 성공한 것처럼 보였다.
|
||||
* ② 중복 검토는 한 번에 수십 그룹을 훑는 작업이라, 그 자리에서 지우면 되돌리기가 배치
|
||||
* 단위로만 가능하다. 표시해 두고 '정리'에서 한 번에 확인하는 편이 실수를 되돌리기 쉽다.
|
||||
*
|
||||
* 실제 삭제는 사이드바 '정리 → 비우기'가 맡는다 — 확인창도 용량 집계도 거기 있다.
|
||||
*/
|
||||
const resolveGroup = async (g: DupeGroup) => {
|
||||
if (busy()) return;
|
||||
const losers = g.members.filter((m) => !m.isKeeper).map((m) => m.fileId);
|
||||
setBusyGroup(g.id);
|
||||
try {
|
||||
if (losers.length > 0) {
|
||||
// 파일을 건드리지 않고 태그만 붙이므로 확인창을 두지 않는다 — 되돌리기는
|
||||
// 항목을 골라 표시를 해제하거나 실행 취소로 한다.
|
||||
const r = await markDelete(losers);
|
||||
toast(ko.dedupe.markedForDelete(r.count));
|
||||
}
|
||||
await resolveDupeGroup(g.id);
|
||||
if (losers.length > 0) {
|
||||
refreshSidebar();
|
||||
await refreshMarked();
|
||||
await refreshSnapshot();
|
||||
}
|
||||
setGroupsVersion((v) => v + 1);
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
} finally {
|
||||
setBusyGroup(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
/** 그룹 내 모든 쌍을 dismiss(재탐지 시 제외) + 해결 표시. 멤버가 많으면 오래 걸린다. */
|
||||
const keepAll = async (g: DupeGroup) => {
|
||||
if (busy()) return;
|
||||
setBusyGroup(g.id);
|
||||
try {
|
||||
for (let i = 0; i < g.members.length; i++) {
|
||||
for (let j = i + 1; j < g.members.length; j++) {
|
||||
await dismissDupePair(g.members[i].fileId, g.members[j].fileId);
|
||||
}
|
||||
}
|
||||
await resolveDupeGroup(g.id);
|
||||
setGroupsVersion((v) => v + 1);
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
} finally {
|
||||
setBusyGroup(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// Esc — 모달만 닫는다. 탐지는 계속되고 취소도 계속 가능하다.
|
||||
// 그룹 액션(busy) 중에도 닫힌다: 백엔드 작업은 창이 떠 있는지와 무관하게 끝난다.
|
||||
onMount(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// 비교 뷰가 위에 떠 있으면 그쪽이 먼저 닫힌다 — 아래 모달까지 같이 닫으면
|
||||
// 사용자는 결과 목록을 잃는다. (둘 다 capture 단계라 등록 순서로 이 핸들러가 먼저 온다)
|
||||
if (compareOpen()) return;
|
||||
if (!dedupeOpen() || e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeDedupe();
|
||||
};
|
||||
window.addEventListener("keydown", onKey, { capture: true });
|
||||
onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true }));
|
||||
});
|
||||
|
||||
// Tab 트랩 — 카드 안을 순환한다. 빠져나가면 그리드 스크롤러(tabindex=0)에 착지해
|
||||
// 방향키·Delete가 뒤에서 살아나므로 반드시 막는다.
|
||||
const trapTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab" || !cardEl) return;
|
||||
const stops = [...cardEl.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
|
||||
(el) => el.offsetParent !== null,
|
||||
);
|
||||
if (stops.length === 0) {
|
||||
e.preventDefault();
|
||||
cardEl.focus();
|
||||
return;
|
||||
}
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const inside = active != null && active !== cardEl && cardEl.contains(active);
|
||||
const edge = e.shiftKey ? stops[0] : stops[stops.length - 1];
|
||||
if (!inside || active === edge) {
|
||||
e.preventDefault();
|
||||
(e.shiftKey ? stops[stops.length - 1] : stops[0]).focus();
|
||||
}
|
||||
await resolveDupeGroup(g.id);
|
||||
setGroupsVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={dedupeOpen()}>
|
||||
<div class="fixed inset-0 z-40 flex flex-col bg-[var(--bg-app)]">
|
||||
{/* 헤더 */}
|
||||
<div class="flex h-12 shrink-0 items-center gap-4 border-b border-[var(--border)] px-4">
|
||||
<span class="text-sm font-semibold">{ko.dedupe.title}</span>
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
|
||||
<label class="flex items-center gap-1.5">
|
||||
{ko.dedupe.threshold}
|
||||
{/* 바깥 클릭으로 닫힘 — 카드는 stopPropagation으로 스크림 클릭을 삼킨다 */}
|
||||
<div class="modal-scrim" onClick={closeDedupe}>
|
||||
<div
|
||||
class="modal-card modal-card--wide"
|
||||
tabindex={-1}
|
||||
ref={(el) => {
|
||||
cardEl = el;
|
||||
// 열 때 포커스를 카드로 옮긴다 — 안 하면 뒤 그리드가 방향키·Ctrl+A·Delete를 먹는다.
|
||||
// 마이크로태스크로 미루는 이유: ref는 DOM에 붙기 전에 불린다.
|
||||
queueMicrotask(() => el.focus());
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={trapTab}
|
||||
>
|
||||
{/* 프레임 ① 헤더 — 제목 · 진행 한 줄 · 취소 · 닫기 */}
|
||||
<div class="modal-head">
|
||||
<span class="modal-head__title">{ko.dedupe.title}</span>
|
||||
<Show when={dedupeRunning()}>
|
||||
<span
|
||||
class="min-w-0 truncate text-[11px] tabular-nums text-[var(--accent-text)]"
|
||||
title={statusText()}
|
||||
>
|
||||
{statusText()}
|
||||
</span>
|
||||
</Show>
|
||||
<div class="ml-auto flex flex-none items-center gap-2">
|
||||
<Show when={dedupeRunning()}>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
disabled={dedupeCancelPending()}
|
||||
onClick={requestCancelDedupe}
|
||||
>
|
||||
{ko.common.cancel}
|
||||
</button>
|
||||
</Show>
|
||||
<button class="icon-btn" onClick={closeDedupe} title={ko.common.close}>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/* 헤더 하단 border 위에 겹치는 진행 하드라인 */}
|
||||
<Show when={dedupeRunning()}>
|
||||
<div class="meter modal-head__progress" classList={{ "is-indet": pct() === undefined }}>
|
||||
<div
|
||||
class="meter__fill"
|
||||
style={pct() !== undefined ? { width: `${pct()!}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* 프레임 ② 컨트롤 줄 — 960px에서도 한 줄 */}
|
||||
<div class="modal-bar">
|
||||
<Show
|
||||
when={runningJob()}
|
||||
fallback={
|
||||
<>
|
||||
<span class="flex-none text-[var(--text-muted)]">{ko.dedupe.scopeLabel}</span>
|
||||
{/* 범위를 좁힐 수 있을 때만 선택 가능, 아니면 전체 라이브러리 고정 */}
|
||||
<Show
|
||||
when={currentScope()}
|
||||
fallback={<span class="flex-none">{ko.dedupe.scopeAll}</span>}
|
||||
>
|
||||
{(sc) => (
|
||||
<div class="segmented">
|
||||
<button
|
||||
classList={{ "is-on": useCurrentScope() }}
|
||||
onClick={() => setUseCurrentScope(true)}
|
||||
>
|
||||
{sc().label}
|
||||
</button>
|
||||
<button
|
||||
classList={{ "is-on": !useCurrentScope() }}
|
||||
onClick={() => setUseCurrentScope(false)}
|
||||
>
|
||||
{ko.dedupe.scopeAll}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* 실행 중에는 시작 시점에 캡처된 범위를 말하고 잠긴다 —
|
||||
'현재 폴더'라고 말하면서 전체를 훑는 거짓말을 막는다 */}
|
||||
{(j) => <span class="chip chip--accent flex-none">{ko.dedupe.scopeRunning(j().scopeLabel)}</span>}
|
||||
</Show>
|
||||
|
||||
<label
|
||||
class="flex flex-none items-center gap-1.5"
|
||||
classList={{ "opacity-50": dedupeRunning() }}
|
||||
>
|
||||
<span>{ko.dedupe.threshold}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={12}
|
||||
value={threshold()}
|
||||
class="accent-[var(--accent)]"
|
||||
disabled={dedupeRunning()}
|
||||
class="w-[88px] accent-[var(--accent)]"
|
||||
onInput={(e) => setThreshold(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span class="w-4 tabular-nums">{threshold()}</span>
|
||||
<span class="w-4 text-right tabular-nums">{threshold()}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5">
|
||||
|
||||
<label
|
||||
class="flex flex-none items-center gap-1.5"
|
||||
classList={{ "opacity-50": dedupeRunning() }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeVideo()}
|
||||
disabled={dedupeRunning()}
|
||||
class="accent-[var(--accent)]"
|
||||
onChange={(e) => setIncludeVideo(e.currentTarget.checked)}
|
||||
/>
|
||||
{ko.dedupe.includeVideo}
|
||||
<span>{ko.dedupe.includeVideo}</span>
|
||||
</label>
|
||||
{/* 범위: 현재 뷰가 폴더/소스일 때만 선택 가능, 아니면 전체 라이브러리 고정 */}
|
||||
<Show
|
||||
when={currentScope()}
|
||||
fallback={<span class="text-[var(--text-muted)]">{ko.dedupe.scopeAll}</span>}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span>{ko.dedupe.scopeLabel}</span>
|
||||
<div class="flex overflow-hidden rounded border border-[var(--border)]">
|
||||
<button
|
||||
class="px-2 py-0.5"
|
||||
classList={{
|
||||
"bg-[var(--bg-active)] text-[var(--text-primary)]": useCurrentScope(),
|
||||
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": !useCurrentScope(),
|
||||
}}
|
||||
onClick={() => setUseCurrentScope(true)}
|
||||
>
|
||||
{currentScope()!.label}
|
||||
</button>
|
||||
<button
|
||||
class="border-l border-[var(--border)] px-2 py-0.5"
|
||||
classList={{
|
||||
"bg-[var(--bg-active)] text-[var(--text-primary)]": !useCurrentScope(),
|
||||
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": useCurrentScope(),
|
||||
}}
|
||||
onClick={() => setUseCurrentScope(false)}
|
||||
>
|
||||
{ko.dedupe.scopeAll}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 지문 속도 — 탐지 옵션이 아니라 **백그라운드 생성** 속도다. 탐지 중에도 바꿀 수 있다
|
||||
(지문은 탐지와 별개로 상시 돌고, 백필 스레드가 배치마다 다시 읽는다). */}
|
||||
<label class="flex flex-none items-center gap-1.5" title={ko.dedupe.speedHint}>
|
||||
<span>{ko.dedupe.speed}</span>
|
||||
<div class="segmented">
|
||||
<button
|
||||
classList={{ "is-on": speed() === "slow" }}
|
||||
onClick={() => void changeSpeed("slow")}
|
||||
>
|
||||
{ko.dedupe.speedSlow}
|
||||
</button>
|
||||
<button
|
||||
classList={{ "is-on": speed() === "normal" }}
|
||||
onClick={() => void changeSpeed("normal")}
|
||||
>
|
||||
{ko.dedupe.speedNormal}
|
||||
</button>
|
||||
<button
|
||||
classList={{ "is-on": speed() === "fast" }}
|
||||
onClick={() => void changeSpeed("fast")}
|
||||
>
|
||||
{ko.dedupe.speedFast}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<Show when={dedupeRunning()}>
|
||||
<span class="text-[11px] text-[var(--accent)]">
|
||||
{dedupeProgress()
|
||||
? ko.dedupe.scanning(dedupeProgress()!.phase, dedupeProgress()!.current, dedupeProgress()!.total)
|
||||
: ko.dedupe.starting}
|
||||
</span>
|
||||
<button
|
||||
class="rounded border border-[var(--border)] px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
|
||||
onClick={requestCancelDedupe}
|
||||
>
|
||||
{ko.common.cancel}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={!dedupeRunning()}>
|
||||
<button
|
||||
class="rounded bg-[var(--accent)] px-3 py-1 text-xs text-white"
|
||||
onClick={() => void runScan()}
|
||||
>
|
||||
{ko.dedupe.scan}
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={closeDedupe}
|
||||
title={ko.common.close}
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="flex flex-none items-center gap-1.5"
|
||||
classList={{ "opacity-50": dedupeRunning() }}
|
||||
title={ko.dedupe.includeRotatedHint}
|
||||
>
|
||||
✕
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeRotated()}
|
||||
disabled={dedupeRunning()}
|
||||
class="accent-[var(--accent)]"
|
||||
onChange={(e) => setIncludeRotated(e.currentTarget.checked)}
|
||||
/>
|
||||
<span>{ko.dedupe.includeRotated}</span>
|
||||
</label>
|
||||
|
||||
{/* 진행 중 재실행 방지 — store도 막지만 버튼에서 먼저 막아 눌리지 않게 한다 */}
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--primary ml-auto"
|
||||
disabled={dedupeRunning()}
|
||||
onClick={() => void runScan()}
|
||||
>
|
||||
{resultJob() ? ko.dedupe.rescan : ko.dedupe.scan}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그룹 목록 */}
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<Show
|
||||
when={groups() && groups()!.length > 0}
|
||||
fallback={
|
||||
<div class="grid h-full place-items-center text-sm text-[var(--text-muted)]">
|
||||
{dedupeRunning() ? ko.dedupe.starting : ko.dedupe.empty}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="mb-3 text-xs text-[var(--text-muted)]">
|
||||
{ko.dedupe.groupCount(groups()!.length)}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<For each={groups()}>
|
||||
{(g) => (
|
||||
<div class="rounded-lg border border-[var(--border)] bg-[var(--bg-panel)] p-3">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="rounded bg-[var(--bg-panel-raised)] px-2 py-0.5 text-[10px] text-[var(--text-secondary)]">
|
||||
{KIND_LABEL[g.kind]}
|
||||
</span>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">
|
||||
{ko.dedupe.memberCount(g.members.length)}
|
||||
</span>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<button
|
||||
class="rounded border border-[var(--border)] px-2 py-0.5 text-[11px] hover:bg-[var(--bg-hover)]"
|
||||
onClick={() => void keepAll(g)}
|
||||
>
|
||||
{ko.dedupe.keepAll}
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-[var(--danger)] px-2 py-0.5 text-[11px] text-white"
|
||||
onClick={() => void resolveGroup(g)}
|
||||
>
|
||||
{ko.dedupe.keepBest}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<For each={g.members}>
|
||||
{(m) => (
|
||||
<div
|
||||
class="w-40 overflow-hidden rounded border"
|
||||
classList={{
|
||||
"border-[var(--accent)]": m.isKeeper,
|
||||
"border-[var(--border)]": !m.isKeeper,
|
||||
}}
|
||||
>
|
||||
<div class="relative aspect-square bg-[var(--grid-cell-bg)]">
|
||||
<Show when={info()}>
|
||||
<img
|
||||
src={thumbUrl(info()!, m.fileId)}
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={m.isKeeper}>
|
||||
<span class="absolute left-1 top-1 rounded bg-[var(--accent)] px-1.5 py-0.5 text-[10px] text-white">
|
||||
{ko.dedupe.keeper}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="p-1.5">
|
||||
<div class="truncate text-[11px]" title={`${m.dir}\\${m.name}`}>
|
||||
{m.name}
|
||||
</div>
|
||||
<div class="text-[10px] text-[var(--text-muted)]">
|
||||
{m.width && m.height ? `${m.width}×${m.height} · ` : ""}
|
||||
{fmtSize(m.size)}
|
||||
{m.distance != null ? ` · 거리 ${m.distance}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
{/* 지문이 아직 준비 안 된 항목이 있으면 말한다. 유사 탐지는 저장된 지문만
|
||||
읽으므로 이 수가 0이 아니면 결과가 아직 완전하지 않다. 백그라운드가
|
||||
채우는 대로 이 줄은 스스로 사라진다. */}
|
||||
<Show when={pending().sigs > 0}>
|
||||
<div class="modal-bar text-[var(--text-muted)]">
|
||||
<IconWarning size={12} />
|
||||
<span class="min-w-0 truncate">{ko.dedupe.sigPending(pending().sigs)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* 프레임 ③ 본문 우물 — 여기만 스크롤한다 */}
|
||||
<div class="modal-body">
|
||||
<Show
|
||||
when={(groups()?.length ?? 0) > 0}
|
||||
fallback={
|
||||
// min-h가 없으면 결과 0에서 카드가 헤더+바 높이로 쪼그라들어
|
||||
// 결과가 있을 때와 창 크기가 튄다
|
||||
<div class="grid h-full min-h-[160px] place-items-center px-6 text-center text-[12px] leading-5 text-[var(--text-muted)]">
|
||||
{dedupeRunning() ? ko.dedupe.starting : ko.dedupe.empty}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="mb-2.5 flex items-center gap-1.5 text-[10px] leading-[14px] text-[var(--text-muted)]">
|
||||
<span class="flex-none tabular-nums">{ko.dedupe.groupCount(groups()!.length)}</span>
|
||||
<span class="flex-none tabular-nums">
|
||||
· {ko.dedupe.tierSummary(tierCounts().certain, tierCounts().suspect)}
|
||||
</span>
|
||||
<Show when={resultJob()}>
|
||||
{(j) => <span class="min-w-0 truncate">· {ko.dedupe.scopeResult(j().scopeLabel)}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<For each={visible()}>
|
||||
{(g) => (
|
||||
<div class="dupe-group">
|
||||
<div class="dupe-group__head">
|
||||
{/* 등급이 먼저 온다 — 사용자가 카드에서 가장 먼저 봐야 하는 값이다 */}
|
||||
<span
|
||||
class="chip chip--xs flex-none"
|
||||
classList={{
|
||||
"chip--accent": tierOf(g) === "certain",
|
||||
"chip--danger": tierOf(g) === "suspect",
|
||||
}}
|
||||
title={
|
||||
tierOf(g) === "certain"
|
||||
? ko.dedupe.tierCertainWhy
|
||||
: ko.dedupe.tierSuspectWhy
|
||||
}
|
||||
>
|
||||
{tierOf(g) === "certain" ? ko.dedupe.tierCertain : ko.dedupe.tierSuspect}
|
||||
</span>
|
||||
<span class="chip chip--xs flex-none">{ko.dedupe.kindLabel(g.kind)}</span>
|
||||
<span class="flex-none text-[10px] tabular-nums text-[var(--text-muted)]">
|
||||
{ko.dedupe.memberCount(g.members.length)}
|
||||
</span>
|
||||
<div class="ml-auto flex flex-none gap-2">
|
||||
{/* 지우기 전에 눈으로 확인할 수단 — 보관 항목과 나란히 놓는다.
|
||||
영상은 탐지 때 알아낸 오프셋으로 맞춰 동기 재생한다. */}
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
title={ko.dedupe.compareHint}
|
||||
onClick={() => {
|
||||
const other = g.members.find((m) => !m.isKeeper);
|
||||
if (other) openCompare(g, other.fileId);
|
||||
}}
|
||||
>
|
||||
{ko.dedupe.compare}
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
disabled={busy()}
|
||||
onClick={() => void keepAll(g)}
|
||||
>
|
||||
{ko.dedupe.keepAll}
|
||||
</button>
|
||||
{/* 파괴적이지 않다(태그만 붙인다) — 위험 색을 쓰지 않는다.
|
||||
빨간 버튼은 '되돌릴 수 없음'을 뜻해야 그 신호가 유지된다. */}
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
disabled={busy()}
|
||||
title={ko.dedupe.keepBestHint}
|
||||
onClick={() => void resolveGroup(g)}
|
||||
>
|
||||
{ko.dedupe.keepBest}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dupe-grid">
|
||||
<For each={g.members}>
|
||||
{(m) => (
|
||||
<div
|
||||
class="dupe-tile"
|
||||
classList={{ "is-keeper": m.isKeeper }}
|
||||
// 타일을 누르면 그 사본을 보관 항목과 비교한다 (보관 항목 자신은 첫 사본과)
|
||||
onClick={() => {
|
||||
const other = m.isKeeper
|
||||
? g.members.find((x) => !x.isKeeper)
|
||||
: m;
|
||||
if (other) openCompare(g, other.fileId);
|
||||
}}
|
||||
title={ko.dedupe.compareHint}
|
||||
>
|
||||
<div class="dupe-tile__thumb">
|
||||
<Show when={info()}>
|
||||
{(i) => (
|
||||
<img
|
||||
src={thumbUrl(i(), m.fileId)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
{/* 보관 배지가 곧 보관을 **정하는** 손잡이다. 별도 버튼을 두면
|
||||
'표시'와 '선택'이 따로 놀아 어느 쪽이 진짜인지 모호해진다.
|
||||
타일 클릭(비교)과 겹치므로 전파를 멈춘다. */}
|
||||
<button
|
||||
class="dupe-tile__badge"
|
||||
classList={{ "is-off": !m.isKeeper }}
|
||||
// 보이는 상태(칠해짐/흐림)를 보조기술에도 그대로 전한다 —
|
||||
// 안 그러면 모든 타일이 똑같이 "보관"이라고만 읽힌다
|
||||
aria-pressed={m.isKeeper}
|
||||
disabled={busy()}
|
||||
title={
|
||||
m.isKeeper ? ko.dedupe.keeperCurrent : ko.dedupe.keeperPick
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void chooseKeeper(g, m);
|
||||
}}
|
||||
>
|
||||
<IconCheck size={11} /> {ko.dedupe.keeper}
|
||||
</button>
|
||||
</div>
|
||||
<div class="dupe-tile__body">
|
||||
<div class="dupe-tile__name" title={`${m.dir}\\${m.name}`}>
|
||||
{m.name}
|
||||
</div>
|
||||
<div class="dupe-tile__meta">{metaLine(m)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={restCount() > 0}>
|
||||
<button
|
||||
class="toolbar-btn mt-2.5 w-full"
|
||||
onClick={() => setShown((n) => n + PAGE)}
|
||||
>
|
||||
{ko.dedupe.moreGroups(restCount())}
|
||||
</button>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -4,19 +4,37 @@ import { ask } from "@tauri-apps/plugin-dialog";
|
||||
import { ko } from "../../i18n/ko";
|
||||
import {
|
||||
assignTags,
|
||||
editFiles,
|
||||
emptyDeleted,
|
||||
listTags,
|
||||
pendingDeleteStats,
|
||||
renameFile,
|
||||
trashFiles,
|
||||
undoLast,
|
||||
type EditOp,
|
||||
} from "../../ipc/commands";
|
||||
import {
|
||||
bumpTagVersion,
|
||||
clearSelection,
|
||||
markedIds,
|
||||
refreshMarked,
|
||||
refreshSidebar,
|
||||
refreshSnapshot,
|
||||
selected,
|
||||
snapshot,
|
||||
toast,
|
||||
toggleMarkDelete,
|
||||
trashTag,
|
||||
} from "../../state/store";
|
||||
import { openMoveDialog } from "./MoveDialog";
|
||||
import { MAX_PANES, openMultiPlay } from "../viewer/MultiPlayView";
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(2)} GB`;
|
||||
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)} MB`;
|
||||
if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
interface MenuState {
|
||||
x: number;
|
||||
@@ -32,15 +50,17 @@ export const closeContextMenu = () => setMenu(undefined);
|
||||
const [renameTarget, setRenameTarget] = createSignal<{ id: number; name: string } | undefined>();
|
||||
export const openRenameDialog = (id: number, name: string) => setRenameTarget({ id, name });
|
||||
|
||||
export async function performTrash(): Promise<void> {
|
||||
/** 즉시 OS 휴지통으로 (Shift+Delete). ids 미지정 시 현재 선택 항목. */
|
||||
export async function performTrash(explicitIds?: number[]): Promise<void> {
|
||||
try {
|
||||
const ids = [...selected()];
|
||||
const ids = explicitIds ?? [...selected()];
|
||||
if (ids.length === 0) return;
|
||||
const yes = await ask(ko.ops.trashConfirm(ids.length), { title: "Archive", kind: "warning" });
|
||||
if (!yes) return;
|
||||
const summary = await trashFiles(ids, (p) => toast(ko.ops.inProgress(p.done, p.total)));
|
||||
toast(ko.ops.trashDone(summary.done));
|
||||
clearSelection();
|
||||
await refreshMarked();
|
||||
refreshSidebar();
|
||||
await refreshSnapshot();
|
||||
} catch (e) {
|
||||
@@ -49,6 +69,107 @@ export async function performTrash(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 선택 항목 삭제 표시 토글 (Delete) — 파일은 그대로, Ctrl+Z로 되돌릴 수 있다. */
|
||||
export async function performMarkDelete(): Promise<void> {
|
||||
const ids = [...selected()];
|
||||
if (ids.length === 0) return;
|
||||
await toggleMarkDelete(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비우기 — 삭제 예정 항목을 디스크에서 영구 삭제한다.
|
||||
* 되돌릴 수 없으므로 개수·총 용량을 보여주고 명시적으로 확인받는다.
|
||||
*/
|
||||
export async function performEmptyDeleted(): Promise<void> {
|
||||
try {
|
||||
const stats = await pendingDeleteStats();
|
||||
if (stats.count === 0) {
|
||||
toast(ko.trash.emptyNothing);
|
||||
return;
|
||||
}
|
||||
const yes = await ask(ko.trash.emptyConfirm(stats.count, fmtSize(stats.bytes)), {
|
||||
title: ko.trash.emptyTitle,
|
||||
kind: "warning",
|
||||
});
|
||||
if (!yes) return;
|
||||
const summary = await emptyDeleted((p) => toast(ko.ops.inProgress(p.done, p.total)));
|
||||
toast(
|
||||
summary.skipped > 0 || summary.failed > 0
|
||||
? ko.trash.emptyPartial(summary.done, summary.skipped, summary.failed)
|
||||
: ko.trash.emptyDone(summary.done),
|
||||
);
|
||||
clearSelection();
|
||||
await refreshMarked();
|
||||
refreshSidebar();
|
||||
await refreshSnapshot();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
console.error("performEmptyDeleted", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회전·반전 — **원본을 직접 고친다**. ids 미지정 시 현재 선택 항목.
|
||||
*
|
||||
* 확인을 받지 않는 이유: 회전은 무손실이고 Ctrl+Z로 즉시 되돌아간다.
|
||||
* 매번 물어보면 사진 열 장 돌리는 데 열 번 확인을 눌러야 한다.
|
||||
*/
|
||||
export async function performEdit(op: EditOp, explicitIds?: number[]): Promise<void> {
|
||||
const ids = explicitIds ?? [...selected()];
|
||||
if (ids.length === 0) {
|
||||
toast(ko.edit.selectFirst);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const s = await editFiles(ids, op, (p) => {
|
||||
// 한 장짜리는 즉시 끝나므로 알리지 않는다 — 토스트만 깜빡인다
|
||||
if (p.total > 3) toast(ko.edit.working(p.done, p.total));
|
||||
});
|
||||
if (s.failed > 0) toast(ko.edit.doneWithFail(s.done, s.failed));
|
||||
else if (s.reencoded > 0) toast(ko.edit.doneReencoded(s.done, s.reencoded));
|
||||
else toast(ko.edit.done(s.done));
|
||||
for (const e of s.errors.slice(0, 3)) toast(e);
|
||||
await refreshSnapshot();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 고른 영상들을 한 화면에서 함께 재생한다.
|
||||
*
|
||||
* 선택 안에 사진이 섞여 있으면 영상만 골라낸다 — 사진은 '재생'할 것이 없어서
|
||||
* 칸을 차지하면 영상 몫이 줄어든다.
|
||||
*/
|
||||
export function performMultiPlay(): void {
|
||||
const snap = snapshot();
|
||||
if (!snap) return;
|
||||
const sel = selected();
|
||||
const videos: number[] = [];
|
||||
for (let i = 0; i < snap.count; i++) {
|
||||
const id = snap.id(i);
|
||||
if (sel.has(id) && snap.kind(i) === 1) videos.push(id);
|
||||
}
|
||||
if (videos.length < 2) {
|
||||
toast(ko.multi.needTwo);
|
||||
return;
|
||||
}
|
||||
if (videos.length > MAX_PANES) toast(ko.multi.tooMany(MAX_PANES));
|
||||
openMultiPlay(videos);
|
||||
}
|
||||
|
||||
/** 우클릭 메뉴에 '동시 재생'을 보일지 — 고른 것 중 영상이 둘 이상일 때만 */
|
||||
export function selectedVideoCount(): number {
|
||||
const snap = snapshot();
|
||||
if (!snap) return 0;
|
||||
const sel = selected();
|
||||
let n = 0;
|
||||
for (let i = 0; i < snap.count; i++) {
|
||||
if (sel.has(snap.id(i)) && snap.kind(i) === 1) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export async function performUndo(): Promise<void> {
|
||||
const summary = await undoLast();
|
||||
if (!summary) {
|
||||
@@ -57,6 +178,8 @@ export async function performUndo(): Promise<void> {
|
||||
}
|
||||
toast(ko.ops.undone(summary.done));
|
||||
refreshSidebar();
|
||||
// 삭제 표시(태그)도 undo 대상 — 배지가 남지 않도록 함께 갱신
|
||||
await refreshMarked();
|
||||
await refreshSnapshot();
|
||||
}
|
||||
|
||||
@@ -77,11 +200,18 @@ export const ContextMenu: Component = () => {
|
||||
() => (menu() ? 1 : undefined),
|
||||
() => listTags(),
|
||||
);
|
||||
// 시스템 '삭제 예정' 태그는 아래 전용 메뉴로 처리 — 태그 목록에서는 감춘다
|
||||
const userTags = () => (tags() ?? []).filter((t) => t.id !== trashTag());
|
||||
const allSelectedMarked = () => {
|
||||
const ids = [...selected()];
|
||||
return ids.length > 0 && ids.every((id) => markedIds().has(id));
|
||||
};
|
||||
|
||||
const onAssign = async (tagId: number, tagName: string) => {
|
||||
const ids = [...selected()];
|
||||
const r = await assignTags(ids, tagId);
|
||||
toast(ko.tags.assignTo(r.count, tagName));
|
||||
bumpTagVersion();
|
||||
refreshSidebar();
|
||||
};
|
||||
|
||||
@@ -95,16 +225,38 @@ export const ContextMenu: Component = () => {
|
||||
class="fixed z-50 w-52 rounded-md border border-[var(--border-strong)] bg-[var(--bg-panel-raised)] p-1 shadow-xl"
|
||||
style={{ left: `${Math.min(m().x, window.innerWidth - 220)}px`, top: `${Math.min(m().y, window.innerHeight - 260)}px` }}
|
||||
>
|
||||
<Show when={tags() && tags()!.length > 0}>
|
||||
<Show when={userTags().length > 0}>
|
||||
<div class="px-3 pt-1 text-[10px] text-[var(--text-muted)]">태그</div>
|
||||
<div class="max-h-36 overflow-y-auto">
|
||||
<For each={tags()}>
|
||||
<For each={userTags()}>
|
||||
{(t) => <MenuItem label={`# ${t.name}`} onClick={() => void onAssign(t.id, t.name)} />}
|
||||
</For>
|
||||
</div>
|
||||
<div class="my-1 border-t border-[var(--border)]" />
|
||||
</Show>
|
||||
<MenuItem label={ko.ops.moveTo} onClick={openMoveDialog} />
|
||||
<Show when={selectedVideoCount() >= 2}>
|
||||
<MenuItem
|
||||
label={ko.multi.open(selectedVideoCount())}
|
||||
onClick={performMultiPlay}
|
||||
/>
|
||||
</Show>
|
||||
<div class="my-1 border-t border-[var(--border)]" />
|
||||
<div class="px-3 pt-1 text-[10px] text-[var(--text-muted)]">
|
||||
{ko.edit.menu} · {ko.edit.hint}
|
||||
</div>
|
||||
<MenuItem
|
||||
label={`${ko.edit.rotateCw} (Ctrl+])`}
|
||||
onClick={() => void performEdit("rotateCw")}
|
||||
/>
|
||||
<MenuItem
|
||||
label={`${ko.edit.rotateCcw} (Ctrl+[)`}
|
||||
onClick={() => void performEdit("rotateCcw")}
|
||||
/>
|
||||
<MenuItem label={ko.edit.rotate180} onClick={() => void performEdit("rotate180")} />
|
||||
<MenuItem label={ko.edit.flipH} onClick={() => void performEdit("flipH")} />
|
||||
<MenuItem label={ko.edit.flipV} onClick={() => void performEdit("flipV")} />
|
||||
<div class="my-1 border-t border-[var(--border)]" />
|
||||
<Show when={selected().size === 1 && m().fileName}>
|
||||
<MenuItem
|
||||
label={ko.common.rename}
|
||||
@@ -112,7 +264,14 @@ export const ContextMenu: Component = () => {
|
||||
/>
|
||||
</Show>
|
||||
<div class="my-1 border-t border-[var(--border)]" />
|
||||
<MenuItem label={`${ko.common.delete} (휴지통)`} onClick={() => void performTrash()} />
|
||||
<MenuItem
|
||||
label={allSelectedMarked() ? ko.trash.unmark : `${ko.trash.mark} (Delete)`}
|
||||
onClick={() => void performMarkDelete()}
|
||||
/>
|
||||
<MenuItem
|
||||
label={`${ko.common.delete} (휴지통, Shift+Delete)`}
|
||||
onClick={() => void performTrash()}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
// 폴더 선택 모달 — 선택 항목을 인덱싱된 폴더로 이동한다.
|
||||
import { createResource, createSignal, For, Show, type Component } from "solid-js";
|
||||
import { IconChevronDown, IconChevronRight } from "../../ui/icons";
|
||||
import {
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import { ko } from "../../i18n/ko";
|
||||
import {
|
||||
folderTree,
|
||||
@@ -16,6 +25,7 @@ import {
|
||||
} from "../../state/store";
|
||||
|
||||
const [open, setOpen] = createSignal(false);
|
||||
export { open as moveDialogOpen };
|
||||
export const openMoveDialog = () => setOpen(true);
|
||||
|
||||
interface TreeNode extends FolderNode {
|
||||
@@ -69,7 +79,7 @@ const FolderPickRow: Component<{
|
||||
setExpanded(!expanded());
|
||||
}}
|
||||
>
|
||||
<Show when={props.node.children.length > 0}>{expanded() ? "▾" : "▸"}</Show>
|
||||
<Show when={props.node.children.length > 0}>{expanded() ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}</Show>
|
||||
</span>
|
||||
<span class="truncate">{props.node.name}</span>
|
||||
</div>
|
||||
@@ -83,6 +93,18 @@ const FolderPickRow: Component<{
|
||||
};
|
||||
|
||||
export const MoveDialog: Component = () => {
|
||||
// Esc로 닫기 — 라이트박스 위에 떠 있을 때도 이 모달이 먼저 닫혀야 한다
|
||||
onMount(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!open() || e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey, { capture: true });
|
||||
onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true }));
|
||||
});
|
||||
|
||||
const [trees] = createResource(
|
||||
() => open(),
|
||||
async (isOpen) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 커스텀 justified 가상 그리드 — 레이아웃은 워커에서, 렌더는 뷰포트 ±1화면만.
|
||||
import { IconImage, IconTrash } from "../../ui/icons";
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
@@ -10,15 +11,15 @@ import {
|
||||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
import { createStore, produce, reconcile } from "solid-js/store";
|
||||
import { computeLayout, type LayoutResult } from "./layout";
|
||||
import {
|
||||
anchorIndex,
|
||||
clearSelection,
|
||||
gridRowHeight,
|
||||
info,
|
||||
markedIds,
|
||||
openLightbox,
|
||||
openShortcuts,
|
||||
registerItemViewFocus,
|
||||
selected,
|
||||
selectSingle,
|
||||
setSelectionFromIds,
|
||||
@@ -26,20 +27,29 @@ import {
|
||||
toggleSelect,
|
||||
selectRange,
|
||||
snapshot,
|
||||
tagVersion,
|
||||
thumbVersions,
|
||||
trashTag,
|
||||
zoomGrid,
|
||||
} from "../../state/store";
|
||||
import { fileDetails, setThumbPriority, thumbUrl, type Snapshot } from "../../ipc/commands";
|
||||
import { ko } from "../../i18n/ko";
|
||||
import {
|
||||
openContextMenu,
|
||||
openRenameDialog,
|
||||
performTrash,
|
||||
performUndo,
|
||||
} from "../fileops/ContextMenu";
|
||||
fileDetails,
|
||||
fileTagsBulk,
|
||||
setThumbPriority,
|
||||
thumbUrl,
|
||||
type Snapshot,
|
||||
} from "../../ipc/commands";
|
||||
import { ko } from "../../i18n/ko";
|
||||
import { openContextMenu } from "../fileops/ContextMenu";
|
||||
import { handleItemKeys } from "../browse/itemKeys";
|
||||
|
||||
const GAP = 6;
|
||||
const GAP = 8;
|
||||
/** 스크롤러 안쪽 여백. 레이아웃 폭·마퀴 좌표가 이 한 상수를 공유한다 —
|
||||
흩어져 있으면 여백을 바꾼 순간 드래그 사각형이 커서에서 어긋난다. */
|
||||
const PAD = 8;
|
||||
const OVERSCAN_PX = 800;
|
||||
/** 이 폭 미만 셀에서는 태그 라벨을 감춘다 (썸네일을 가림) */
|
||||
const TAG_MIN_CELL_W = 120;
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)}MB`;
|
||||
@@ -74,7 +84,7 @@ export const JustifiedGrid: Component = () => {
|
||||
setLayout(undefined);
|
||||
return;
|
||||
}
|
||||
const w = containerRef.clientWidth - 16; // 패딩 보정
|
||||
const w = containerRef.clientWidth - PAD * 2; // 패딩 보정
|
||||
if (w <= 0) return;
|
||||
const ratios = snap.aspectRatios();
|
||||
setLayout(computeLayout(ratios, w, gridRowHeight(), GAP));
|
||||
@@ -85,6 +95,8 @@ export const JustifiedGrid: Component = () => {
|
||||
|
||||
onMount(() => {
|
||||
const el = containerRef!;
|
||||
// 크롬(툴바·브레드크럼·폴더 칩·사이드바)이 클릭 뒤 방향키를 여기로 되돌린다
|
||||
onCleanup(registerItemViewFocus(() => containerRef?.focus({ preventScroll: true })));
|
||||
let lastW = -1;
|
||||
let lastRh = -1;
|
||||
// 폭과 줌(gridRowHeight)을 함께 감시 — 어느 쪽이 바뀌든 재배치.
|
||||
@@ -165,6 +177,43 @@ export const JustifiedGrid: Component = () => {
|
||||
});
|
||||
onCleanup(() => clearTimeout(prioTimer));
|
||||
|
||||
// 썸네일 위 태그 라벨 — 가시 범위만 배치 조회. 태그가 바뀌면(tagVersion) 캐시를 버린다.
|
||||
const [tagCache, setTagCache] = createStore<Record<number, string[]>>({});
|
||||
let tagTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
createEffect(
|
||||
on(tagVersion, () => setTagCache(reconcile({})), { defer: true }),
|
||||
);
|
||||
createEffect(() => {
|
||||
const [first, last] = visibleRange();
|
||||
const snap = snapshot();
|
||||
tagVersion(); // 태그 변경 후에도 다시 읽도록 구독
|
||||
if (!snap || last < first) return;
|
||||
clearTimeout(tagTimer);
|
||||
tagTimer = setTimeout(() => {
|
||||
const need: number[] = [];
|
||||
for (let i = first; i <= last && need.length < 512; i++) {
|
||||
const id = snap.id(i);
|
||||
if (tagCache[id] === undefined) need.push(id);
|
||||
}
|
||||
if (need.length === 0) return;
|
||||
void fileTagsBulk(need)
|
||||
.then((links) =>
|
||||
setTagCache(
|
||||
produce((s) => {
|
||||
// 조회한 id는 태그가 없어도 빈 배열로 채워 재조회를 막는다
|
||||
for (const id of need) s[id] = [];
|
||||
for (const l of links) {
|
||||
if (l.tagId === trashTag()) continue; // 삭제 예정은 🗑 배지가 담당
|
||||
(s[l.fileId] ??= []).push(l.name);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}, 150);
|
||||
});
|
||||
onCleanup(() => clearTimeout(tagTimer));
|
||||
|
||||
// 정보 오버레이용 파일명/크기 지연 로드 (showInfo 켜졌을 때 가시 범위만)
|
||||
const [detailsCache, setDetailsCache] = createStore<Record<number, { name: string; size: number }>>({});
|
||||
let detTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -234,80 +283,29 @@ export const JustifiedGrid: Component = () => {
|
||||
return best;
|
||||
};
|
||||
|
||||
/** 선택된 셀을 화면 안으로 (키보드 이동용) */
|
||||
const revealCell = (index: number) => {
|
||||
const l = layout();
|
||||
const el = containerRef;
|
||||
if (!l || !el) return;
|
||||
const top = l.boxes[index * 4 + 1];
|
||||
const h = l.boxes[index * 4 + 3];
|
||||
if (top < el.scrollTop) el.scrollTop = top;
|
||||
else if (top + h > el.scrollTop + el.clientHeight)
|
||||
el.scrollTop = top + h - el.clientHeight;
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const snap = snapshot();
|
||||
if (!snap || snap.count === 0) return;
|
||||
const anchor = anchorIndex();
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
|
||||
e.preventDefault();
|
||||
void performUndo();
|
||||
return;
|
||||
}
|
||||
// Ctrl+A 전체 선택 / Ctrl+Shift+A 선택 반전
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
|
||||
e.preventDefault();
|
||||
const all: number[] = [];
|
||||
for (let i = 0; i < snap.count; i++) all.push(snap.id(i));
|
||||
if (e.shiftKey) {
|
||||
const cur = selected();
|
||||
setSelectionFromIds(all.filter((id) => !cur.has(id)));
|
||||
} else {
|
||||
setSelectionFromIds(all);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ? 단축키 도움말
|
||||
if (e.key === "?") {
|
||||
e.preventDefault();
|
||||
openShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
case " ":
|
||||
if (anchor >= 0) {
|
||||
e.preventDefault();
|
||||
openLightbox(anchor);
|
||||
}
|
||||
break;
|
||||
case "Delete":
|
||||
if (selected().size > 0) {
|
||||
e.preventDefault();
|
||||
void performTrash();
|
||||
}
|
||||
break;
|
||||
case "F2":
|
||||
if (anchor >= 0 && selected().size === 1) {
|
||||
e.preventDefault();
|
||||
const id = snap.id(anchor);
|
||||
void fileDetails([id]).then((d) => {
|
||||
if (d[0]) openRenameDialog(id, d[0].name);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
if (anchor > 0) {
|
||||
e.preventDefault();
|
||||
selectSingle(snap.id(anchor - 1), anchor - 1);
|
||||
}
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (anchor >= 0 && anchor < snap.count - 1) {
|
||||
e.preventDefault();
|
||||
selectSingle(snap.id(anchor + 1), anchor + 1);
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
case "ArrowDown": {
|
||||
if (anchor < 0) break;
|
||||
e.preventDefault();
|
||||
const next = verticalNeighbor(anchor, e.key === "ArrowDown" ? 1 : -1);
|
||||
if (next !== undefined) selectSingle(snap.id(next), next);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!snap) return;
|
||||
handleItemKeys(e, snap, {
|
||||
neighbor: (from, key) => {
|
||||
if (key === "ArrowLeft") return from > 0 ? from - 1 : undefined;
|
||||
if (key === "ArrowRight") return from < snap.count - 1 ? from + 1 : undefined;
|
||||
return verticalNeighbor(from, key === "ArrowDown" ? 1 : -1);
|
||||
},
|
||||
onMove: revealCell,
|
||||
});
|
||||
};
|
||||
|
||||
const onCellContextMenu = (e: MouseEvent, snap: Snapshot, index: number) => {
|
||||
@@ -327,12 +325,12 @@ export const JustifiedGrid: Component = () => {
|
||||
};
|
||||
|
||||
// ── 마퀴(드래그 사각형) 선택 ──
|
||||
// 콘텐츠 좌표 = clientX - 컨테이너rect - 패딩(8) (+ y는 scrollTop)
|
||||
// 콘텐츠 좌표 = clientX - 컨테이너rect - 패딩(PAD) (+ y는 scrollTop)
|
||||
const toContent = (clientX: number, clientY: number): [number, number] => {
|
||||
const rect = containerRef!.getBoundingClientRect();
|
||||
return [
|
||||
clientX - rect.left - 8,
|
||||
clientY - rect.top - 8 + containerRef!.scrollTop,
|
||||
clientX - rect.left - PAD,
|
||||
clientY - rect.top - PAD + containerRef!.scrollTop,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -397,7 +395,8 @@ export const JustifiedGrid: Component = () => {
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseDown={onBgMouseDown}
|
||||
tabindex={0}
|
||||
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 outline-none"
|
||||
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden outline-none"
|
||||
style={{ padding: `${PAD}px` }}
|
||||
>
|
||||
<Show
|
||||
when={snapshot() && snapshot()!.count > 0}
|
||||
@@ -415,7 +414,7 @@ export const JustifiedGrid: Component = () => {
|
||||
<Show when={marqueeRect()}>
|
||||
{(r) => (
|
||||
<div
|
||||
class="pointer-events-none absolute z-10 border border-[var(--accent)] bg-[var(--accent-soft)]"
|
||||
class="marquee pointer-events-none absolute z-10"
|
||||
style={{
|
||||
left: `${r().left}px`,
|
||||
top: `${r().top}px`,
|
||||
@@ -453,11 +452,23 @@ export const JustifiedGrid: Component = () => {
|
||||
const s = snapshot();
|
||||
return !!s && s.kind(i) === 1;
|
||||
};
|
||||
const marked = () => markedIds().has(id());
|
||||
// 별점은 스냅샷 flags 비트2~4에서 온다(추가 조회 없음)
|
||||
const rating = () => {
|
||||
const s = snapshot();
|
||||
return s ? s.rating(i) : 0;
|
||||
};
|
||||
// 좁은 셀일수록 적게 — 나머지는 +N으로 접는다
|
||||
const cellTags = () => tagCache[id()] ?? [];
|
||||
const tagLimit = () => (box()[2] >= 260 ? 3 : box()[2] >= 180 ? 2 : 1);
|
||||
const visibleTags = () => cellTags().slice(0, tagLimit());
|
||||
const hiddenTagCount = () => Math.max(0, cellTags().length - tagLimit());
|
||||
return (
|
||||
<div
|
||||
class="absolute cursor-pointer overflow-hidden rounded-[3px] bg-[var(--grid-cell-bg)] outline-offset-[-2px]"
|
||||
class="grid-cell"
|
||||
classList={{
|
||||
"outline outline-2 outline-[var(--accent)]": selected().has(id()),
|
||||
"is-sel": selected().has(id()),
|
||||
"is-marked": marked() && !selected().has(id()),
|
||||
}}
|
||||
style={{
|
||||
transform: `translate(${box()[0]}px, ${box()[1]}px)`,
|
||||
@@ -487,11 +498,11 @@ export const JustifiedGrid: Component = () => {
|
||||
when={!failed()}
|
||||
fallback={
|
||||
<div class="grid h-full w-full place-items-center text-[var(--text-muted)]">
|
||||
<span class="text-base opacity-60">🖼</span>
|
||||
<IconImage size={18} class="opacity-60" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="h-full w-full animate-pulse bg-[var(--bg-hover)]" />
|
||||
<div class="h-full w-full animate-pulse bg-[var(--grid-skeleton)]" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
@@ -509,16 +520,42 @@ export const JustifiedGrid: Component = () => {
|
||||
/>
|
||||
</Show>
|
||||
<Show when={isVideo()}>
|
||||
<span class="absolute bottom-1 right-1 rounded bg-black/60 px-1 text-[10px] text-white">
|
||||
영상
|
||||
<span class="chip--on-media absolute bottom-1 right-1">{ko.filter.video}</span>
|
||||
</Show>
|
||||
{/* 별점 — 매긴 것만 보여준다. 0점까지 빈 별로 그리면 모든 셀에 별 5개가
|
||||
깔려서 사진을 가린다(별점은 소수의 항목에만 붙는 표시다). */}
|
||||
<Show when={rating() > 0}>
|
||||
<span class="cell-stars" title={ko.rating.set(rating())}>
|
||||
{"★".repeat(rating())}
|
||||
</span>
|
||||
</Show>
|
||||
{/* 상단 라벨 줄 — 삭제 예정 배지 + 태그.
|
||||
셀이 좁으면 태그가 썸네일을 다 덮으므로 일정 폭부터만 보여준다. */}
|
||||
<div class="pointer-events-none absolute inset-x-1 top-1 flex flex-wrap gap-0.5">
|
||||
<Show when={marked()}>
|
||||
<span class="chip--on-media chip--on-media--danger" title={ko.trash.badge}>
|
||||
<IconTrash size={11} />
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={box()[2] >= TAG_MIN_CELL_W}>
|
||||
<For each={visibleTags()}>
|
||||
{(name) => (
|
||||
<span class="chip--on-media chip--on-media--tag" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={hiddenTagCount() > 0}>
|
||||
<span class="chip--on-media chip--on-media--dim">+{hiddenTagCount()}</span>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
{/* 파일명·크기 오버레이 (툴바 ⓘ 토글) */}
|
||||
<Show when={showInfo()}>
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-black/55 px-1 py-0.5 leading-tight">
|
||||
<div class="truncate text-[10px] text-white/90">{detailsCache[id()]?.name ?? ""}</div>
|
||||
<div class="info-overlay">
|
||||
<div class="info-overlay__name">{detailsCache[id()]?.name ?? ""}</div>
|
||||
<Show when={detailsCache[id()]}>
|
||||
<div class="text-[9px] text-white/60">{fmtSize(detailsCache[id()]!.size)}</div>
|
||||
<div class="info-overlay__sub">{fmtSize(detailsCache[id()]!.size)}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// Justified 레이아웃 — 그리디 행 채우기. 메인 스레드 동기 계산.
|
||||
// (프로덕션 Tauri 빌드에서 module worker가 첫 메시지 후 응답하지 않는 이슈를 피하기 위해
|
||||
// 워커 대신 동기 계산을 쓴다. 재계산은 리사이즈/줌/데이터 변경 시에만 발생하며
|
||||
// 5만 항목도 수십 ms 내에 끝나 스크롤 성능에 영향 없다.)
|
||||
// 워커 대신 동기 계산을 쓴다. 재계산은 리사이즈/줌/데이터 변경 시에만 발생한다.)
|
||||
//
|
||||
// 워커로 옮길 이유가 없다 — V8 실측: 1만 0.8ms / 10만 3.1ms / **100만 18.4ms**
|
||||
// (aspectRatios 언팩 포함 ~20ms). 스크롤 중에는 아예 돌지 않고, 워커로 보내면
|
||||
// boxes 15MB를 매번 되돌려 받아야 해서 오히려 느려진다.
|
||||
|
||||
export interface LayoutResult {
|
||||
/** n*4: [x, y, w, h] */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +1,65 @@
|
||||
// 라이트박스 — 사진(줌/팬) + 영상 재생 오버레이. Esc 닫기, ←/→ 이동, 휠/+/- 줌.
|
||||
// 재생·감상 중에도 정리 작업(태그·폴더 이동·삭제 표시)을 끊김 없이 할 수 있도록
|
||||
// 그리드와 같은 파일 작업을 여기서도 버튼·단축키로 노출한다.
|
||||
import {
|
||||
IconCheck,
|
||||
IconFlipH,
|
||||
IconCrop,
|
||||
IconFolder,
|
||||
IconRotateLeft,
|
||||
IconRotateRight,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "../../ui/icons";
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import {
|
||||
bumpTagVersion,
|
||||
closeLightbox,
|
||||
dedupeOpen,
|
||||
info,
|
||||
lightboxIndex,
|
||||
markedIds,
|
||||
navLightbox,
|
||||
openShortcuts,
|
||||
refreshSidebar,
|
||||
refreshSnapshot,
|
||||
selectSingle,
|
||||
shortcutsOpen,
|
||||
snapshot,
|
||||
toast,
|
||||
toggleMarkDelete,
|
||||
trashTag,
|
||||
} from "../../state/store";
|
||||
import { fileDetails, mediaUrl, thumbUrl } from "../../ipc/commands";
|
||||
import {
|
||||
assignTags,
|
||||
createTag,
|
||||
cutFile,
|
||||
fileDetails,
|
||||
fileTagsOf,
|
||||
listTags,
|
||||
mediaUrl,
|
||||
setRating,
|
||||
thumbUrl,
|
||||
unassignTags,
|
||||
type EditOp,
|
||||
type Tag,
|
||||
} from "../../ipc/commands";
|
||||
import { ko } from "../../i18n/ko";
|
||||
import { moveDialogOpen, openMoveDialog } from "../fileops/MoveDialog";
|
||||
import { performEdit, performTrash, performUndo } from "../fileops/ContextMenu";
|
||||
import { StarRating } from "../../ui/StarRating";
|
||||
import { VideoPlayer } from "./VideoPlayer";
|
||||
import { CropOverlay } from "./CropOverlay";
|
||||
|
||||
/** 웹뷰가 <img>로 직접 그릴 수 있는 확장자 (그 외는 썸네일 폴백 — HEIC/RAW 등) */
|
||||
const IMG_NATIVE = new Set(["jpg", "jpeg", "jfif", "png", "gif", "webp", "bmp", "avif"]);
|
||||
@@ -32,11 +74,233 @@ export const Lightbox: Component = () => {
|
||||
return { index: idx, id: snap.id(idx), kind: snap.kind(idx) };
|
||||
});
|
||||
|
||||
const [details] = createResource(
|
||||
const [details, { refetch: refetchDetails }] = createResource(
|
||||
() => item()?.id,
|
||||
async (id) => (await fileDetails([id]))[0],
|
||||
);
|
||||
|
||||
// ── 파일 작업 (그리드와 동일한 동작, 현재 항목 대상) ──
|
||||
const [tagMenuOpen, setTagMenuOpen] = createSignal(false);
|
||||
// 태그를 붙이거나 뗄 때마다 올려서 현재 파일 태그를 다시 읽는다
|
||||
const [tagVersion, setTagVersion] = createSignal(0);
|
||||
const [tags, { refetch: refetchTags }] = createResource(
|
||||
() => (lightboxIndex() != null ? tagVersion() : undefined),
|
||||
() => listTags(),
|
||||
);
|
||||
// 지금 보고 있는 파일에 실제로 붙어 있는 태그 — 나갔다 확인할 필요 없게 상단에 상주시킨다
|
||||
const [fileTags] = createResource(
|
||||
() => {
|
||||
const id = item()?.id;
|
||||
return id != null ? `${id}:${tagVersion()}` : undefined;
|
||||
},
|
||||
async (k) => fileTagsOf(Number(k.split(":")[0])),
|
||||
);
|
||||
/** 삭제 예정(시스템)은 🗑 배지가 따로 보여주므로 태그 칩에서는 뺀다 */
|
||||
const shownTags = () => (fileTags() ?? []).filter((t) => t.id !== trashTag());
|
||||
const assignedIds = () => new Set((fileTags() ?? []).map((t) => t.id));
|
||||
// 시스템 '삭제 예정' 태그는 🗑 버튼이 담당 — 태그 목록에서는 뺀다
|
||||
const userTags = () => (tags() ?? []).filter((t) => t.id !== trashTag());
|
||||
/** 1~9 빠른 지정 대상 — 메뉴를 열지 않아도 쓰는 앞쪽 9개 */
|
||||
const quickTags = () => userTags().slice(0, 9);
|
||||
const isMarked = () => {
|
||||
const it = item();
|
||||
return !!it && markedIds().has(it.id);
|
||||
};
|
||||
|
||||
const markCurrent = () => {
|
||||
const it = item();
|
||||
if (it) void toggleMarkDelete([it.id]);
|
||||
};
|
||||
|
||||
// 회전·반전 — 원본을 직접 고친다.
|
||||
//
|
||||
// 고친 뒤 화면을 새로 그리려면 **URL을 바꿔야 한다**. 경로가 그대로면 브라우저가
|
||||
// 캐시에 있는 옛 바이트를 그대로 쓰고, 사진은 돌지 않은 채로 남는다.
|
||||
const [editing, setEditing] = createSignal(false);
|
||||
const [editVersion, setEditVersion] = createSignal(0);
|
||||
|
||||
// 자르기 — 되돌릴 수 없으므로 원본은 백업으로 옮겨진다(Ctrl+Z로 복귀).
|
||||
const [cropping, setCropping] = createSignal(false);
|
||||
let imgRef: HTMLImageElement | undefined;
|
||||
|
||||
const applyCrop = async (r: { x: number; y: number; w: number; h: number }) => {
|
||||
const it = item();
|
||||
if (!it || editing()) return;
|
||||
setEditing(true);
|
||||
try {
|
||||
await cutFile(it.id, { kind: "crop", ...r });
|
||||
toast(ko.edit.cropDone(r.w, r.h));
|
||||
setCropping(false);
|
||||
setEditVersion((v) => v + 1);
|
||||
refetchDetails();
|
||||
await refreshSnapshot();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
} finally {
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rotateCurrent = async (op: EditOp) => {
|
||||
const it = item();
|
||||
if (!it || editing()) return;
|
||||
setEditing(true);
|
||||
try {
|
||||
await performEdit(op, [it.id]);
|
||||
setEditVersion((v) => v + 1);
|
||||
// 치수가 바뀌었을 수 있다(90° 회전) — 상세 정보도 다시 읽는다
|
||||
refetchDetails();
|
||||
} finally {
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 태그 토글 — 이미 붙어 있으면 뗀다. 메뉴는 연달아 쓰도록 열어 둔다. */
|
||||
const toggleTag = async (tagId: number, name: string) => {
|
||||
const it = item();
|
||||
if (!it) return;
|
||||
try {
|
||||
if (assignedIds().has(tagId)) {
|
||||
const r = await unassignTags([it.id], tagId);
|
||||
toast(ko.tags.removedFrom(r.count, name));
|
||||
} else {
|
||||
const r = await assignTags([it.id], tagId);
|
||||
toast(ko.tags.assignTo(r.count, name));
|
||||
}
|
||||
setTagVersion((v) => v + 1);
|
||||
bumpTagVersion(); // 그리드 썸네일 태그 오버레이도 갱신
|
||||
refreshSidebar();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 별점 — 감상하면서 바로 매긴다(분류의 대부분이 이 화면에서 일어난다).
|
||||
* 같은 점수를 다시 주면 해제, `Ctrl+0`은 곧바로 해제다.
|
||||
*/
|
||||
const [ratingVersion, setRatingVersion] = createSignal(0);
|
||||
const [rating] = createResource(
|
||||
() => (item() ? `${item()!.id}:${ratingVersion()}` : undefined),
|
||||
async () => (await fileDetails([item()!.id]))[0]?.rating ?? 0,
|
||||
);
|
||||
const applyRating = async (next: number) => {
|
||||
const it = item();
|
||||
if (!it) return;
|
||||
try {
|
||||
// 같은 별을 다시 누르면 해제 — 위젯과 단축키가 같은 규칙을 쓴다
|
||||
const value = rating() === next ? 0 : next;
|
||||
await setRating([it.id], value);
|
||||
setRatingVersion((v) => v + 1);
|
||||
await refreshSnapshot(); // 그리드 셀의 별도 갱신
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
// ── 태그 고르기 (전체 목록 + 검색 + 새로 만들기) ──
|
||||
// 태그가 열 개를 넘어가면 1~9 빠른 키만으로는 부족하다. 검색으로 좁혀 고르고,
|
||||
// 없는 이름이면 그 자리에서 만들어 지정한다 — 감상을 멈추고 사이드바로 갈 필요가 없다.
|
||||
const [tagQuery, setTagQuery] = createSignal("");
|
||||
const [tagHighlight, setTagHighlight] = createSignal(0);
|
||||
let tagInputRef: HTMLInputElement | undefined;
|
||||
let tagListRef: HTMLDivElement | undefined;
|
||||
|
||||
const filteredTags = () => {
|
||||
const q = tagQuery().trim().toLowerCase();
|
||||
return q ? userTags().filter((t) => t.name.toLowerCase().includes(q)) : userTags();
|
||||
};
|
||||
const exactTag = () => {
|
||||
const q = tagQuery().trim().toLowerCase();
|
||||
return q ? userTags().find((t) => t.name.toLowerCase() === q) : undefined;
|
||||
};
|
||||
/** 이미 있는 이름과 겹치지 않을 때만 '새로 만들기'를 제안한다 */
|
||||
const canCreateTag = () => tagQuery().trim().length > 0 && !exactTag();
|
||||
/** 목록 마지막 줄이 '새로 만들기' — 키보드 이동 인덱스를 함께 센다 */
|
||||
const tagRowCount = () => filteredTags().length + (canCreateTag() ? 1 : 0);
|
||||
const isCreateRow = (i: number) => canCreateTag() && i === filteredTags().length;
|
||||
|
||||
const openTagMenu = () => {
|
||||
void refetchTags();
|
||||
setTagQuery("");
|
||||
setTagHighlight(0);
|
||||
setTagMenuOpen(true);
|
||||
};
|
||||
const closeTagMenu = () => {
|
||||
setTagMenuOpen(false);
|
||||
setTagQuery("");
|
||||
};
|
||||
const toggleTagMenu = () => (tagMenuOpen() ? closeTagMenu() : openTagMenu());
|
||||
|
||||
/** 목록에서 고른 태그를 토글하고, 이어서 다른 태그를 찾을 수 있게 검색어를 비운다 */
|
||||
const pickTag = async (t: Tag) => {
|
||||
await toggleTag(t.id, t.name);
|
||||
setTagQuery("");
|
||||
setTagHighlight(0);
|
||||
tagInputRef?.focus();
|
||||
};
|
||||
|
||||
/** 없는 태그면 만들어서 바로 지정 (이름이 이미 있으면 그 태그를 쓴다) */
|
||||
const createAndAssignTag = async (raw: string) => {
|
||||
const it = item();
|
||||
const name = raw.trim();
|
||||
if (!it || !name) return;
|
||||
try {
|
||||
const existing = userTags().find((t) => t.name.toLowerCase() === name.toLowerCase());
|
||||
const tagId = existing ? existing.id : await createTag(name);
|
||||
const r = await assignTags([it.id], tagId);
|
||||
toast(existing ? ko.tags.assignTo(r.count, name) : ko.tags.createdAndAssigned(name));
|
||||
setTagQuery("");
|
||||
setTagHighlight(0);
|
||||
tagInputRef?.focus();
|
||||
setTagVersion((v) => v + 1);
|
||||
bumpTagVersion();
|
||||
refreshSidebar();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
/** Enter — 강조된 줄을 실행 (태그 토글 또는 새로 만들기) */
|
||||
const commitTagRow = (i: number) => {
|
||||
if (isCreateRow(i)) {
|
||||
void createAndAssignTag(tagQuery());
|
||||
return;
|
||||
}
|
||||
const t = filteredTags()[i];
|
||||
if (t) void pickTag(t);
|
||||
};
|
||||
|
||||
const onTagInputKey = (e: KeyboardEvent) => {
|
||||
// 입력 중에는 라이트박스 전역 키가 오지 않으므로(INPUT은 건너뛴다) 여기서 처리한다
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeTagMenu();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const n = tagRowCount();
|
||||
if (n === 0) return;
|
||||
setTagHighlight((h) => (h + (e.key === "ArrowDown" ? 1 : n - 1)) % n);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
commitTagRow(tagHighlight());
|
||||
}
|
||||
};
|
||||
|
||||
// 강조된 줄을 목록 안에서 보이게 유지
|
||||
createEffect(() => {
|
||||
const i = tagHighlight();
|
||||
if (!tagMenuOpen() || !tagListRef) return;
|
||||
(tagListRef.children[i] as HTMLElement | undefined)?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
|
||||
// ── 사진 줌/팬 상태 ──
|
||||
const [scale, setScale] = createSignal(1);
|
||||
const [panX, setPanX] = createSignal(0);
|
||||
@@ -49,10 +313,18 @@ export const Lightbox: Component = () => {
|
||||
setPanY(0);
|
||||
};
|
||||
|
||||
// 파일이 바뀌면 줌 리셋
|
||||
// 파일이 바뀌면 줌 리셋 + 열려 있던 태그 메뉴 닫기
|
||||
createEffect(() => {
|
||||
item()?.id;
|
||||
resetZoom();
|
||||
closeTagMenu();
|
||||
});
|
||||
|
||||
// 보고 있는 항목 = 선택 항목으로 유지.
|
||||
// 폴더 이동/휴지통 후 clearSelection()이 불려도 상단 버튼이 계속 동작해야 한다.
|
||||
createEffect(() => {
|
||||
const it = item();
|
||||
if (it) selectSingle(it.id, it.index);
|
||||
});
|
||||
|
||||
/** 커서 위치 기준 줌 (center 기준 상대좌표) */
|
||||
@@ -100,10 +372,95 @@ export const Lightbox: Component = () => {
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (lightboxIndex() == null) return;
|
||||
// 위에 뜬 모달이 키를 먼저 가진다 (각자 Esc를 처리한다).
|
||||
// 중복 검토 모달도 이 목록에 있어야 한다 — 라이트박스가 App.tsx에서 먼저 마운트돼
|
||||
// capture 리스너가 먼저 등록되므로, 양보하지 않으면 Esc가 모달이 아니라 라이트박스를 닫는다.
|
||||
if (moveDialogOpen() || shortcutsOpen() || dedupeOpen()) return;
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
const isVideo = item()?.kind === 1;
|
||||
|
||||
if (e.key === "?") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 미디어 종류와 무관한 파일 작업 ──
|
||||
// (이 핸들러가 VideoPlayer보다 먼저 등록돼 capture 단계에서 우선 처리된다)
|
||||
if (e.key === "Delete") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Shift+Delete만 즉시 OS 휴지통, 기본은 되돌릴 수 있는 삭제 표시
|
||||
const it = item();
|
||||
if (!it) return;
|
||||
if (e.shiftKey) void performTrash([it.id]);
|
||||
else markCurrent();
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "m") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openMoveDialog();
|
||||
return;
|
||||
}
|
||||
// 보기 중에 표시했다가 바로 무를 수 있어야 한다 (그리드 핸들러는 여기선 꺼져 있다)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void performUndo();
|
||||
return;
|
||||
}
|
||||
// Ctrl+0~5: 별점. 맨숫자는 이미 태그 빠른 지정이 쓰고 있어서 수식키를 붙였다 —
|
||||
// 태그 쪽 손버릇을 바꾸는 것보다 새 기능이 비켜서는 편이 낫다.
|
||||
// (다른 Ctrl 단축키들과 같은 높이에 둔다 — 아래 `!e.ctrlKey` 블록 안에 두면 절대 안 걸린다)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key >= "0" && e.key <= "5") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void applyRating(Number(e.key));
|
||||
return;
|
||||
}
|
||||
// Ctrl+←/→ 는 영상에서도 항상 이전·다음 파일 (←/→ 는 시크에 쓰이므로)
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navLightbox(e.key === "ArrowRight" ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// 다음/이전 항목 — 영상 재생 중에도 손이 안 꼬이도록 N/P 제공
|
||||
if (e.key === "n" || e.key === "N" || e.key === "p" || e.key === "P") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navLightbox(e.key === "n" || e.key === "N" ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (e.key === "t" || e.key === "T") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleTagMenu();
|
||||
return;
|
||||
}
|
||||
// 1~9: 태그 목록 n번째를 현재 항목에 토글 (빠른 분류 — 잘못 눌러도 같은 키로 취소)
|
||||
if (e.key >= "1" && e.key <= "9") {
|
||||
const t = quickTags()[Number(e.key) - 1];
|
||||
if (t) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void toggleTag(t.id, t.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
if (tagMenuOpen()) {
|
||||
closeTagMenu();
|
||||
break;
|
||||
}
|
||||
closeLightbox();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
@@ -153,32 +510,226 @@ export const Lightbox: Component = () => {
|
||||
if (!it || !app) return undefined;
|
||||
const ext = d?.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
// 네이티브 디코드 불가 형식(HEIC/RAW/TIFF)은 프리뷰 썸네일로 폴백
|
||||
return IMG_NATIVE.has(ext) ? mediaUrl(app, it.id) : thumbUrl(app, it.id, 0);
|
||||
const base = IMG_NATIVE.has(ext) ? mediaUrl(app, it.id) : thumbUrl(app, it.id, 0);
|
||||
// 회전한 뒤에는 URL이 달라야 브라우저가 다시 받아온다 — 경로가 같으면 옛 화면이 그대로 남는다
|
||||
const v = editVersion();
|
||||
if (v === 0) return base;
|
||||
return `${base}${base.includes("?") ? "&" : "?"}e=${v}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={item()}>
|
||||
{(it) => (
|
||||
<div class="fixed inset-0 z-50 flex flex-col bg-black/90">
|
||||
// media-surface: 배경이 항상 검으므로 라이트 테마에서도 크롬 색은 다크 값을 쓴다
|
||||
<div class="media-surface fixed inset-0 z-50 flex flex-col bg-black/90">
|
||||
<div class="flex h-10 shrink-0 items-center gap-3 px-3">
|
||||
<span class="truncate text-xs text-[var(--text-primary)]">
|
||||
{/* 파일명은 길이가 제한 없다 — min-w-0가 없으면 flex 항목이 내용 폭 아래로
|
||||
줄지 않아서(min-width:auto) 오른쪽 버튼들을 밀어 찌그러뜨린다.
|
||||
max-w로 상한을 두는 이유: 이름이 아무리 길어도 헤더의 절반을 넘기지 않는다. */}
|
||||
<span
|
||||
class="min-w-0 max-w-[42%] shrink truncate text-xs text-[var(--text-primary)]"
|
||||
title={details()?.name ?? ""}
|
||||
>
|
||||
{details()?.name ?? ""}
|
||||
</span>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">
|
||||
<span class="shrink-0 whitespace-nowrap text-[11px] text-[var(--text-muted)]">
|
||||
{it().index + 1} / {snapshot()?.count.toLocaleString("ko-KR")}
|
||||
</span>
|
||||
{/* 별점 — 제목 옆. 감상 중 손이 가장 자주 가는 조작이라 숨기지 않는다 */}
|
||||
<span class="shrink-0" title={ko.rating.shortcut}>
|
||||
<StarRating value={rating() ?? 0} size={13} onChange={(n) => void applyRating(n)} />
|
||||
</span>
|
||||
<Show when={it().kind !== 1 && scale() !== 1}>
|
||||
<span class="rounded bg-[var(--bg-panel-raised)] px-1.5 py-0.5 text-[10px] tabular-nums text-[var(--text-secondary)]">
|
||||
{Math.round(scale() * 100)}%
|
||||
</span>
|
||||
</Show>
|
||||
<button
|
||||
class="ml-auto rounded px-2 py-0.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={closeLightbox}
|
||||
title="닫기 (Esc)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<Show when={isMarked()}>
|
||||
<span class="rounded bg-[var(--danger)] px-1.5 py-0.5 text-[10px] text-white">
|
||||
{ko.trash.badge}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
{/* 현재 항목에 붙은 태그 — 보기를 벗어나지 않고 바로 확인·해제 */}
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
|
||||
<For each={shownTags()}>
|
||||
{(t) => (
|
||||
<span class="group/tag flex shrink-0 items-center gap-1 rounded-full bg-[var(--accent-soft)] px-2 py-0.5 text-[11px] text-[var(--accent)]">
|
||||
# {t.name}
|
||||
<button
|
||||
class="opacity-0 transition-opacity hover:text-[var(--danger)] group-hover/tag:opacity-100"
|
||||
onClick={() => void toggleTag(t.id, t.name)}
|
||||
title={`"${t.name}" 태그 제거`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* 파일 작업 — 감상·재생을 멈추지 않고 정리할 수 있게 상단에 상주.
|
||||
shrink-0: 이 묶음은 절대 줄이지 않는다. 줄어들면 버튼 안에서 아이콘과 글자가
|
||||
갈라져 2줄이 된다(제목이 길 때 실제로 그렇게 됐다). 줄일 것은 제목과 태그 줄이다. */}
|
||||
<div class="relative ml-auto flex shrink-0 items-center gap-1">
|
||||
{/* inline-flex + whitespace-nowrap: 아이콘과 글자가 한 줄에 붙어 있게 한다.
|
||||
인라인 배치로 두면 폭이 좁아질 때 그 사이에서 줄바꿈이 일어난다. */}
|
||||
<button
|
||||
class="shrink-0 whitespace-nowrap rounded px-2 py-0.5 text-xs hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]"
|
||||
classList={{
|
||||
"text-[var(--accent)]": tagMenuOpen(),
|
||||
"text-[var(--text-secondary)]": !tagMenuOpen(),
|
||||
}}
|
||||
onClick={toggleTagMenu}
|
||||
title={`${ko.tags.add} (T)`}
|
||||
>
|
||||
# 태그
|
||||
</button>
|
||||
{/* 회전·반전 — 크게 보다가 삐뚤어진 걸 발견하는 자리라 여기 둔다.
|
||||
원본을 직접 고치지만 무손실이고 Ctrl+Z로 되돌아간다. */}
|
||||
<div class="mx-1 flex shrink-0 items-center">
|
||||
<button
|
||||
class="shrink-0 rounded px-1.5 py-0.5 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)] disabled:opacity-40"
|
||||
disabled={editing()}
|
||||
onClick={() => void rotateCurrent("rotateCcw")}
|
||||
title={`${ko.edit.rotateCcw} (Ctrl+[) · ${ko.edit.hint}`}
|
||||
>
|
||||
<IconRotateLeft size={13} />
|
||||
</button>
|
||||
<button
|
||||
class="shrink-0 rounded px-1.5 py-0.5 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)] disabled:opacity-40"
|
||||
disabled={editing()}
|
||||
onClick={() => void rotateCurrent("rotateCw")}
|
||||
title={`${ko.edit.rotateCw} (Ctrl+]) · ${ko.edit.hint}`}
|
||||
>
|
||||
<IconRotateRight size={13} />
|
||||
</button>
|
||||
<button
|
||||
class="shrink-0 rounded px-1.5 py-0.5 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)] disabled:opacity-40"
|
||||
disabled={editing()}
|
||||
onClick={() => void rotateCurrent("flipH")}
|
||||
title={`${ko.edit.flipH} · ${ko.edit.hint}`}
|
||||
>
|
||||
<IconFlipH size={13} />
|
||||
</button>
|
||||
<Show when={item()?.kind !== 1}>
|
||||
<button
|
||||
class="shrink-0 rounded px-1.5 py-0.5 hover:bg-[var(--bg-hover)] disabled:opacity-40"
|
||||
classList={{
|
||||
"text-[var(--accent)]": cropping(),
|
||||
"text-[var(--text-secondary)] hover:text-[var(--text-primary)]": !cropping(),
|
||||
}}
|
||||
disabled={editing()}
|
||||
onClick={() => setCropping((v) => !v)}
|
||||
title={`${ko.edit.crop} · ${ko.edit.cropWarn}`}
|
||||
>
|
||||
<IconCrop size={13} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded px-2 py-0.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]"
|
||||
onClick={openMoveDialog}
|
||||
title={`${ko.ops.moveTo} (Ctrl+M)`}
|
||||
>
|
||||
<IconFolder size={13} /> 이동
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded px-2 py-0.5 text-xs hover:bg-[var(--bg-hover)]"
|
||||
classList={{
|
||||
"text-[var(--danger)]": isMarked(),
|
||||
"text-[var(--text-secondary)] hover:text-[var(--text-primary)]": !isMarked(),
|
||||
}}
|
||||
onClick={markCurrent}
|
||||
title={`${isMarked() ? ko.trash.unmark : ko.trash.mark} (Delete)`}
|
||||
>
|
||||
<IconTrash size={13} /> {isMarked() ? ko.trash.unmark : ko.trash.mark}
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex shrink-0 items-center rounded px-2 py-0.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={closeLightbox}
|
||||
title="닫기 (Esc)"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
|
||||
{/* 태그 고르기 — 전체 목록에서 검색해 고르거나 그 자리에서 새로 만든다.
|
||||
앞쪽 9개는 메뉴를 열지 않고도 1~9 키로 토글된다. 붙어 있는 태그는 ✓. */}
|
||||
<Show when={tagMenuOpen()}>
|
||||
<div class="absolute right-0 top-8 z-10 w-72 rounded-md border border-[var(--border-strong)] bg-[var(--bg-panel-raised)] p-1.5 shadow-xl">
|
||||
<input
|
||||
ref={(el) => {
|
||||
tagInputRef = el;
|
||||
queueMicrotask(() => el.focus());
|
||||
}}
|
||||
class="mb-1 w-full rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1 text-xs text-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--accent)]"
|
||||
placeholder={ko.tags.searchOrCreate}
|
||||
value={tagQuery()}
|
||||
onInput={(e) => {
|
||||
setTagQuery(e.currentTarget.value);
|
||||
setTagHighlight(0);
|
||||
}}
|
||||
onKeyDown={onTagInputKey}
|
||||
/>
|
||||
<div ref={tagListRef} class="max-h-64 overflow-y-auto">
|
||||
<For each={filteredTags()}>
|
||||
{(t, idx) => (
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs"
|
||||
classList={{
|
||||
"bg-[var(--bg-hover)]": tagHighlight() === idx(),
|
||||
"text-[var(--accent)]": assignedIds().has(t.id),
|
||||
}}
|
||||
onMouseEnter={() => setTagHighlight(idx())}
|
||||
onClick={() => void pickTag(t)}
|
||||
>
|
||||
<kbd
|
||||
class="w-4 shrink-0 rounded border px-1 text-center font-mono text-[10px]"
|
||||
classList={{
|
||||
"border-[var(--border)] text-[var(--text-muted)]":
|
||||
!tagQuery().trim() && idx() < 9,
|
||||
"border-transparent text-transparent":
|
||||
!!tagQuery().trim() || idx() >= 9,
|
||||
}}
|
||||
>
|
||||
{idx() < 9 ? idx() + 1 : ""}
|
||||
</kbd>
|
||||
<span class="w-3 shrink-0 text-center">
|
||||
<Show when={assignedIds().has(t.id)}><IconCheck size={12} /></Show>
|
||||
</span>
|
||||
<span class="truncate"># {t.name}</span>
|
||||
<span class="ml-auto shrink-0 text-[10px] text-[var(--text-muted)]">
|
||||
{t.fileCount.toLocaleString("ko-KR")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={canCreateTag()}>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-[var(--accent)]"
|
||||
classList={{
|
||||
"bg-[var(--bg-hover)]": tagHighlight() === filteredTags().length,
|
||||
}}
|
||||
onMouseEnter={() => setTagHighlight(filteredTags().length)}
|
||||
onClick={() => void createAndAssignTag(tagQuery())}
|
||||
>
|
||||
<span class="w-4 shrink-0 text-center">+</span>
|
||||
<span class="truncate">{ko.tags.createAssign(tagQuery().trim())}</span>
|
||||
</div>
|
||||
</Show>
|
||||
{/* 태그가 하나도 없을 때만 — 검색어가 있으면 위의 '새로 만들기'가 답이다 */}
|
||||
<Show when={tagRowCount() === 0}>
|
||||
<div class="px-2 py-2 text-[11px] text-[var(--text-muted)]">
|
||||
{ko.tags.emptyAll}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mt-1 border-t border-[var(--border)] px-2 pt-1 text-[10px] text-[var(--text-muted)]">
|
||||
{ko.tags.pickerHint}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 p-2">
|
||||
<Show
|
||||
@@ -192,6 +743,7 @@ export const Lightbox: Component = () => {
|
||||
onDblClick={() => (scale() > 1 ? resetZoom() : zoomCentered(2.5))}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imgSrc()}
|
||||
class="max-h-full max-w-full touch-none select-none object-contain"
|
||||
style={{
|
||||
@@ -210,7 +762,22 @@ export const Lightbox: Component = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<VideoPlayer fileId={it().id} />
|
||||
<VideoPlayer fileId={it().id} version={editVersion()} />
|
||||
</Show>
|
||||
|
||||
{/* 자르기 — 사진 위에 사각형을 끌어 남길 부분을 고른다.
|
||||
영상은 화면 좌표가 재생기 안에 있어 이 오버레이로는 잡을 수 없으므로
|
||||
지금은 사진만 지원한다(영상 영역 자르기는 백엔드에 있고 UI만 남았다). */}
|
||||
<Show when={cropping() && it().kind !== 1}>
|
||||
<CropOverlay
|
||||
target={() => imgRef}
|
||||
natural={() =>
|
||||
imgRef ? { w: imgRef.naturalWidth, h: imgRef.naturalHeight } : undefined
|
||||
}
|
||||
busy={editing()}
|
||||
onCancel={() => setCropping(false)}
|
||||
onApply={(r) => void applyCrop(r)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
// 미디어 플레이어 — 통일된 커스텀 컨트롤(직접 재생 + ffmpeg 스트림 공통).
|
||||
// 재생/정지·10초 스킵·시크바(버퍼)·볼륨·배속·전체화면·자동숨김·키보드 단축키.
|
||||
import { IconChevronLeft, IconChevronRight, IconFolder, IconForward, IconMaximize, IconMinimize, IconPause, IconPlay, IconRepeat, IconResetZoom, IconRewind, IconScissors, IconTrash, IconVolumeHigh, IconVolumeLow, IconVolumeMute } from "../../ui/icons";
|
||||
import {
|
||||
createEffect,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
@@ -15,11 +17,25 @@ import {
|
||||
playbackInfo,
|
||||
startStream,
|
||||
stopStream,
|
||||
cutFile,
|
||||
filmstrip,
|
||||
type AudioMode,
|
||||
type PlaybackPlan,
|
||||
type StripFrame,
|
||||
type VideoMode,
|
||||
} from "../../ipc/commands";
|
||||
import { info as appInfoSig, navLightbox, repeatMode, toggleRepeat } from "../../state/store";
|
||||
import {
|
||||
info as appInfoSig,
|
||||
markedIds,
|
||||
navLightbox,
|
||||
repeatMode,
|
||||
refreshSnapshot,
|
||||
toast,
|
||||
toggleMarkDelete,
|
||||
toggleRepeat,
|
||||
} from "../../state/store";
|
||||
import { ko } from "../../i18n/ko";
|
||||
import { moveDialogOpen, openMoveDialog } from "../fileops/MoveDialog";
|
||||
|
||||
function fmtTime(sec: number): string {
|
||||
const s = Math.max(0, Math.floor(sec));
|
||||
@@ -33,13 +49,101 @@ function fmtTime(sec: number): string {
|
||||
|
||||
const RATES = [0.5, 1, 1.25, 1.5, 2];
|
||||
const VOL_KEY = "archive.player.volume";
|
||||
const FIT_KEY = "archive.player.fit";
|
||||
|
||||
export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
/**
|
||||
* 화면 크기 모드.
|
||||
* - fit : 비율 유지하고 화면을 가득 채움(작은 영상도 확대) — 기본값
|
||||
* - fill : 화면을 꽉 채우되 넘치는 부분은 잘라냄
|
||||
* - none : 원본 픽셀 크기 1:1
|
||||
*/
|
||||
type FitMode = "fit" | "fill" | "none";
|
||||
const FIT_LABEL: Record<FitMode, string> = {
|
||||
fit: "맞춤",
|
||||
fill: "채움",
|
||||
none: "원본",
|
||||
};
|
||||
const FIT_ORDER: FitMode[] = ["fit", "fill", "none"];
|
||||
const ZOOM_MIN = 1;
|
||||
const ZOOM_MAX = 8;
|
||||
|
||||
/**
|
||||
* 편집 가능한 시간 칸 — 재생바로는 못 잡는 정확한 지점을 직접 적는다.
|
||||
*
|
||||
* 입력 중에는 화면 값을 덮어쓰지 않는다. 손잡이를 끌면 값이 계속 바뀌는데,
|
||||
* 그때마다 타이핑하던 글자가 사라지면 아예 적을 수가 없다.
|
||||
*/
|
||||
const TimeField: Component<{
|
||||
label: string;
|
||||
value: number;
|
||||
active: boolean;
|
||||
fmt: (s: number) => string;
|
||||
onFocus: () => void;
|
||||
onCommit: (sec: number) => void;
|
||||
}> = (props) => {
|
||||
const [editing, setEditing] = createSignal<string | undefined>();
|
||||
const commit = (raw: string) => {
|
||||
const parts = raw.trim().split(":");
|
||||
const ok = parts.length > 0 && parts.every((p) => p !== "" && !Number.isNaN(Number(p)));
|
||||
if (ok) {
|
||||
const n = parts.reduce((acc, p) => acc * 60 + Number(p), 0);
|
||||
if (Number.isFinite(n) && n >= 0) props.onCommit(n);
|
||||
}
|
||||
setEditing(undefined);
|
||||
};
|
||||
return (
|
||||
<label class="flex items-center gap-1">
|
||||
<span class="text-white/60">{props.label}</span>
|
||||
<input
|
||||
class="w-16 rounded border bg-black/40 px-1 py-0.5 text-center tabular-nums outline-none"
|
||||
classList={{
|
||||
"border-[var(--accent)]": props.active,
|
||||
"border-white/25": !props.active,
|
||||
}}
|
||||
value={editing() ?? props.fmt(props.value)}
|
||||
onFocus={props.onFocus}
|
||||
onInput={(e) => setEditing(e.currentTarget.value)}
|
||||
onBlur={(e) => commit(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit((e.currentTarget as HTMLInputElement).value);
|
||||
(e.currentTarget as HTMLInputElement).blur();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setEditing(undefined);
|
||||
(e.currentTarget as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export const VideoPlayer: Component<{
|
||||
fileId: number;
|
||||
/** 라이트박스에서 회전·반전한 횟수 — 올라오면 파일이 바뀐 것이므로 다시 읽는다 */
|
||||
version?: number;
|
||||
}> = (props) => {
|
||||
let videoRef: HTMLVideoElement | undefined;
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
let barRef: HTMLDivElement | undefined;
|
||||
|
||||
const [pb] = createResource(() => props.fileId, playbackInfo);
|
||||
/**
|
||||
* 편집으로 파일 내용이 바뀌면 올린다.
|
||||
*
|
||||
* 파일 id는 그대로라 이것 없이는 아무것도 다시 읽히지 않는다 — 잘라서 7초가 된 영상을
|
||||
* 재생기는 여전히 30초로 알고, 웹뷰는 같은 URL이라 캐시된 옛 바이트를 쓴다.
|
||||
* 그 둘이 어긋나면 재생이 아예 안 된다.
|
||||
*/
|
||||
const [trimVersion, setTrimVersion] = createSignal(0);
|
||||
/** 이 재생기가 아는 '파일 내용 세대' — 구간 자르기(내부)와 회전·반전(라이트박스)을 합친다 */
|
||||
const fileVersion = () => trimVersion() + (props.version ?? 0);
|
||||
const [pb] = createResource(
|
||||
() => `${props.fileId}:${fileVersion()}`,
|
||||
() => playbackInfo(props.fileId),
|
||||
);
|
||||
const [plan, setPlan] = createSignal<PlaybackPlan | undefined>();
|
||||
const [src, setSrc] = createSignal<string | undefined>();
|
||||
const [seekBase, setSeekBase] = createSignal(0);
|
||||
@@ -52,15 +156,139 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
const [rate, setRate] = createSignal(1);
|
||||
const [fullscreen, setFullscreen] = createSignal(false);
|
||||
const [controlsShown, setControlsShown] = createSignal(true);
|
||||
|
||||
// ── 구간 자르기 ──
|
||||
// 남길 구간만 두고 나머지를 잘라낸다. 스트림 복사라 화질 손실이 없고 30분짜리도 몇 초다.
|
||||
// 되돌릴 수 없으므로(잘라낸 시간은 되살릴 수 없다) 원본은 백업으로 옮겨 두고
|
||||
// Ctrl+Z로 돌아온다 — 백엔드가 그 처리를 한다.
|
||||
const [trimMode, setTrimMode] = createSignal(false);
|
||||
const [trimStart, setTrimStart] = createSignal(0);
|
||||
const [trimEnd, setTrimEnd] = createSignal<number | undefined>();
|
||||
const [trimBusy, setTrimBusy] = createSignal(false);
|
||||
/** 지금 미세 조정 중인 손잡이 — ← → 키가 이쪽을 움직인다 */
|
||||
const [activeHandle, setActiveHandle] = createSignal<"start" | "end">("start");
|
||||
/** 고른 구간만 반복 재생 중인가 */
|
||||
const [rangePreview, setRangePreview] = createSignal(false);
|
||||
/**
|
||||
* 타임라인에 깔 필름스트립 — 아이폰 트리머처럼 어느 장면을 자르는지 바로 보인다.
|
||||
*
|
||||
* 자르기 모드에 들어갈 때만 만든다. 영상마다 12번 디코드하는 비용을
|
||||
* 그냥 재생만 하는 사람에게 물릴 이유가 없다.
|
||||
*/
|
||||
const [strip] = createResource(
|
||||
() => (trimMode() ? props.fileId : undefined),
|
||||
(id) => filmstrip(id, 12).catch(() => [] as StripFrame[]),
|
||||
);
|
||||
/** 끝을 아직 안 찍었으면 영상 끝이 기본값 */
|
||||
const trimEndOr = () => trimEnd() ?? durationSec();
|
||||
|
||||
/**
|
||||
* 미세 조정 한 칸(초). fps는 DB에 없어 30fps로 가정한다 —
|
||||
* 정확한 지점이 필요하면 시간 칸에 직접 적는 쪽이 확실하다.
|
||||
*/
|
||||
const frameSec = () => 1 / 30;
|
||||
|
||||
/** 손잡이를 옮기고 그 지점으로 화면도 옮긴다 — 어디를 자르는지 프레임으로 봐야 한다. */
|
||||
const moveHandle = (which: "start" | "end", t: number) => {
|
||||
const d = durationSec();
|
||||
const gap = 0.1;
|
||||
if (which === "start") {
|
||||
const v = Math.max(0, Math.min(t, trimEndOr() - gap));
|
||||
setTrimStart(v);
|
||||
seekTo(v);
|
||||
} else {
|
||||
const v = Math.min(d || t, Math.max(t, trimStart() + gap));
|
||||
setTrimEnd(v);
|
||||
seekTo(v);
|
||||
}
|
||||
setActiveHandle(which);
|
||||
};
|
||||
|
||||
const nudge = (deltaFrames: number) => {
|
||||
const which = activeHandle();
|
||||
const base = which === "start" ? trimStart() : trimEndOr();
|
||||
moveHandle(which, base + deltaFrames * frameSec());
|
||||
};
|
||||
|
||||
/** 손잡이 끌기 — 재생바 위에서 직접 구간을 잡는다. */
|
||||
const onHandleDown = (which: "start" | "end") => (e: PointerEvent) => {
|
||||
if (durationSec() <= 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // 재생바의 시크 처리로 넘어가지 않게
|
||||
setRangePreview(false);
|
||||
moveHandle(which, posToTime(e.clientX));
|
||||
const move = (me: PointerEvent) => moveHandle(which, posToTime(me.clientX));
|
||||
const up = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
};
|
||||
|
||||
/** 고른 구간만 반복 재생 — 적용하기 전에 결과를 확인한다. */
|
||||
const toggleRangePreview = () => {
|
||||
if (rangePreview()) {
|
||||
setRangePreview(false);
|
||||
videoRef?.pause();
|
||||
return;
|
||||
}
|
||||
setRangePreview(true);
|
||||
seekTo(trimStart());
|
||||
queueMicrotask(() => videoRef?.play().catch(() => {}));
|
||||
};
|
||||
|
||||
/** 편집 가능한 시간 표기 — 소수 첫째 자리까지 보여 정밀 입력이 가능하다 */
|
||||
const fmtPrecise = (sec: number) => {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec - m * 60;
|
||||
return `${m}:${s.toFixed(1).padStart(4, "0")}`;
|
||||
};
|
||||
|
||||
const applyTrim = async () => {
|
||||
const [s, e] = [trimStart(), trimEndOr()];
|
||||
if (e - s < 0.5) return toast(ko.edit.trimTooShort);
|
||||
setTrimBusy(true);
|
||||
try {
|
||||
await cutFile(props.fileId, {
|
||||
kind: "trim",
|
||||
startMs: Math.round(s * 1000),
|
||||
endMs: Math.round(e * 1000),
|
||||
});
|
||||
toast(ko.edit.trimDone(e - s));
|
||||
setTrimMode(false);
|
||||
setTrimStart(0);
|
||||
setTrimEnd(undefined);
|
||||
setRangePreview(false);
|
||||
// 파일이 바뀌었다 — 재생 정보를 다시 읽고 소스를 새로 문다.
|
||||
// 이걸 빼먹어서 "자른 뒤 재생이 안 된다"는 신고가 나왔다.
|
||||
setTrimVersion((v) => v + 1);
|
||||
await refreshSnapshot();
|
||||
} catch (err) {
|
||||
toast(String(err));
|
||||
} finally {
|
||||
setTrimBusy(false);
|
||||
}
|
||||
};
|
||||
// <video> 요소가 보고한 길이 (직접 재생 시 메타데이터보다 정확·항상 존재)
|
||||
const [elDuration, setElDuration] = createSignal(0);
|
||||
// 스트림 시크 중 표시 위치 고정용 (null이면 실제 재생 위치 사용)
|
||||
const [seekingTo, setSeekingTo] = createSignal<number | null>(null);
|
||||
// ── 화면 크기 / 확대 ──
|
||||
const storedFit = localStorage.getItem(FIT_KEY) as FitMode | null;
|
||||
const [fit, setFitRaw] = createSignal<FitMode>(
|
||||
storedFit && FIT_ORDER.includes(storedFit) ? storedFit : "fit",
|
||||
);
|
||||
const [zoom, setZoom] = createSignal(1);
|
||||
const [panX, setPanX] = createSignal(0);
|
||||
const [panY, setPanY] = createSignal(0);
|
||||
|
||||
let jobId: number | undefined;
|
||||
let hideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let streamSeekTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const isStream = () => plan()?.mode === "stream";
|
||||
const marked = () => markedIds().has(props.fileId);
|
||||
// 재생 길이: ffprobe 총 길이(메타)를 항상 우선. <video> 요소 duration은 스트림·일부
|
||||
// 컨테이너(리먹스 fMP4 등)에서 재생 중 실시간으로 늘어나 재생바가 요동치므로,
|
||||
// 메타가 있으면 그 고정 총길이를 쓰고, 없을 때만 요소 duration으로 폴백한다.
|
||||
@@ -102,7 +330,9 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
const decided = decidePlayback(p);
|
||||
setPlan(decided);
|
||||
if (decided.mode === "direct") {
|
||||
setSrc(mediaUrl(app, props.fileId));
|
||||
const base = mediaUrl(app, props.fileId);
|
||||
// 경로가 같으면 웹뷰가 캐시된 옛 파일을 그대로 쓴다
|
||||
setSrc(fileVersion() === 0 ? base : `${base}?v=${fileVersion()}`);
|
||||
} else if (decided.mode === "stream") {
|
||||
void startStreamAt(0, decided.video, decided.audio);
|
||||
} else {
|
||||
@@ -175,6 +405,97 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
localStorage.setItem(VOL_KEY, String(nv));
|
||||
};
|
||||
|
||||
// ── 화면 크기 / 확대 ──
|
||||
const resetView = () => {
|
||||
setZoom(1);
|
||||
setPanX(0);
|
||||
setPanY(0);
|
||||
};
|
||||
|
||||
const setFit = (m: FitMode) => {
|
||||
setFitRaw(m);
|
||||
localStorage.setItem(FIT_KEY, m);
|
||||
resetView();
|
||||
};
|
||||
const cycleFit = () => setFit(FIT_ORDER[(FIT_ORDER.indexOf(fit()) + 1) % FIT_ORDER.length]);
|
||||
|
||||
/** 커서 위치를 기준으로 확대 — 보고 싶은 부분이 그 자리에 남는다 */
|
||||
const zoomAt = (factor: number, cx: number, cy: number) => {
|
||||
const old = zoom();
|
||||
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, old * factor));
|
||||
if (next === old) return;
|
||||
if (next === 1) {
|
||||
resetView();
|
||||
return;
|
||||
}
|
||||
const ratio = next / old;
|
||||
setPanX(cx * (1 - ratio) + panX() * ratio);
|
||||
setPanY(cy * (1 - ratio) + panY() * ratio);
|
||||
setZoom(next);
|
||||
};
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = containerRef?.getBoundingClientRect();
|
||||
const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0;
|
||||
const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0;
|
||||
zoomAt(e.deltaY < 0 ? 1.15 : 1 / 1.15, cx, cy);
|
||||
showControls();
|
||||
};
|
||||
|
||||
/**
|
||||
* 확대 상태에서 드래그로 이동. 클릭(재생/정지)과 구분해야 하므로
|
||||
* 일정 거리 이상 움직였을 때만 팬으로 취급하고, 그 경우 이어지는 click을 삼킨다.
|
||||
*/
|
||||
const onVideoPointerDown = (e: PointerEvent) => {
|
||||
if (zoom() <= 1 || e.button !== 0) return;
|
||||
const startX = e.clientX - panX();
|
||||
const startY = e.clientY - panY();
|
||||
const originX = e.clientX;
|
||||
const originY = e.clientY;
|
||||
let dragged = false;
|
||||
const move = (me: PointerEvent) => {
|
||||
if (Math.abs(me.clientX - originX) + Math.abs(me.clientY - originY) > 4) dragged = true;
|
||||
if (!dragged) return;
|
||||
me.preventDefault();
|
||||
setPanX(me.clientX - startX);
|
||||
setPanY(me.clientY - startY);
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
if (dragged) {
|
||||
// 드래그 직후의 click(=재생/정지 토글)을 한 번만 취소
|
||||
const swallow = (ce: MouseEvent) => {
|
||||
ce.stopPropagation();
|
||||
ce.preventDefault();
|
||||
};
|
||||
window.addEventListener("click", swallow, { capture: true, once: true });
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
};
|
||||
|
||||
const OBJECT_FIT: Record<FitMode, "contain" | "cover" | "none"> = {
|
||||
fit: "contain",
|
||||
fill: "cover",
|
||||
none: "none",
|
||||
};
|
||||
|
||||
/** object-fit은 모드가, transform은 확대/이동이 담당한다 */
|
||||
const videoStyle = () => ({
|
||||
"object-fit": OBJECT_FIT[fit()],
|
||||
transform: `translate(${panX()}px, ${panY()}px) scale(${zoom()})`,
|
||||
transition: zoom() === 1 ? "transform 0.15s ease-out" : "none",
|
||||
});
|
||||
|
||||
// 파일이 바뀌면 확대 상태 초기화 (모드는 유지 — 사용자가 고른 설정이다)
|
||||
createEffect(() => {
|
||||
props.fileId;
|
||||
resetView();
|
||||
});
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!containerRef) return;
|
||||
if (document.fullscreenElement) void document.exitFullscreen();
|
||||
@@ -217,15 +538,27 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
setControlsShown(true);
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(() => {
|
||||
if (playing()) setControlsShown(false);
|
||||
// 편집 중에는 숨기지 않는다 — 필름스트립과 손잡이가 사라지면 작업 자체가 끊긴다.
|
||||
// (구간 미리보기로 재생 중일 때가 특히 그렇다: 보고 있는 동안 도구가 없어진다)
|
||||
if (playing() && !trimMode()) setControlsShown(false);
|
||||
}, 2600);
|
||||
};
|
||||
|
||||
/**
|
||||
* 컨트롤바를 지금 보여야 하는가.
|
||||
*
|
||||
* 자동 숨김은 '감상 중'을 위한 것이다. 편집 중에는 조건 없이 보인다 —
|
||||
* 자르기 모드를 켠 시점에 이미 숨어 있었더라도 그렇다.
|
||||
*/
|
||||
const barVisible = () => controlsShown() || trimMode();
|
||||
|
||||
// ── 키보드 단축키 (플레이어가 마운트된 동안) ──
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// 입력 요소에 포커스면 무시
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
// 위에 모달이 떠 있으면 재생 키를 먹지 않는다
|
||||
if (moveDialogOpen()) return;
|
||||
let handled = true;
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
@@ -236,12 +569,16 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
case "ArrowLeft":
|
||||
case "j":
|
||||
case "J":
|
||||
skip(e.key === "ArrowLeft" ? -5 : -10);
|
||||
// 구간 자르기 중이면 방향키는 **고른 손잡이를 한 프레임씩** 움직인다.
|
||||
// 그때 5초씩 건너뛰면 정밀 조정이 불가능하다.
|
||||
if (trimMode() && e.key === "ArrowLeft") nudge(-1);
|
||||
else skip(e.key === "ArrowLeft" ? -5 : -10);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
case "l":
|
||||
case "L":
|
||||
skip(e.key === "ArrowRight" ? 5 : 10);
|
||||
if (trimMode() && e.key === "ArrowRight") nudge(1);
|
||||
else skip(e.key === "ArrowRight" ? 5 : 10);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
changeVolume(volume() + 0.05);
|
||||
@@ -257,6 +594,22 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
case "F":
|
||||
toggleFullscreen();
|
||||
break;
|
||||
// 화면 크기: 모드 전환 / 확대 / 초기화
|
||||
case "w":
|
||||
case "W":
|
||||
cycleFit();
|
||||
break;
|
||||
case "+":
|
||||
case "=":
|
||||
zoomAt(1.25, 0, 0);
|
||||
break;
|
||||
case "-":
|
||||
case "_":
|
||||
zoomAt(1 / 1.25, 0, 0);
|
||||
break;
|
||||
case "0":
|
||||
resetView();
|
||||
break;
|
||||
case "PageUp":
|
||||
navLightbox(-1);
|
||||
break;
|
||||
@@ -289,6 +642,10 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
if (seekingTo() == null) {
|
||||
setCurrent(seekBase() + (videoRef?.currentTime ?? 0));
|
||||
}
|
||||
// 구간 미리보기 — 끝에 닿으면 시작으로 되돌린다. 적용 전에 결과를 반복해 확인할 수 있다.
|
||||
if (rangePreview() && current() >= trimEndOr() - 0.05) {
|
||||
seekTo(trimStart());
|
||||
}
|
||||
// 버퍼 끝(직접 모드)
|
||||
if (videoRef && videoRef.buffered.length > 0 && !isStream()) {
|
||||
setBuffered(videoRef.buffered.end(videoRef.buffered.length - 1));
|
||||
@@ -305,8 +662,9 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="relative flex h-full w-full items-center justify-center bg-black"
|
||||
classList={{ "cursor-none": !controlsShown() }}
|
||||
class="media-surface relative flex h-full w-full items-center justify-center overflow-hidden bg-black"
|
||||
classList={{ "cursor-none": !barVisible() }}
|
||||
onWheel={onWheel}
|
||||
onMouseMove={showControls}
|
||||
onMouseLeave={() => playing() && setControlsShown(false)}
|
||||
>
|
||||
@@ -321,7 +679,12 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src()}
|
||||
class="max-h-full max-w-full outline-none"
|
||||
// 컨테이너를 가득 쓰고 비율은 object-fit이 처리한다.
|
||||
// (max-*만 쓰면 작은 영상이 화면 한가운데 원본 크기로 조그맣게 나온다)
|
||||
class="h-full w-full outline-none"
|
||||
classList={{ "cursor-grab": zoom() > 1 }}
|
||||
style={videoStyle()}
|
||||
onPointerDown={onVideoPointerDown}
|
||||
autoplay
|
||||
playsinline
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
@@ -361,7 +724,7 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
class="pointer-events-auto absolute grid h-16 w-16 place-items-center rounded-full bg-black/50 text-3xl text-white backdrop-blur transition hover:bg-black/70"
|
||||
onClick={togglePlay}
|
||||
>
|
||||
▶
|
||||
<IconPlay size={30} />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
@@ -370,64 +733,193 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
<button
|
||||
class="absolute left-2 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-full bg-black/40 text-xl text-white/80 hover:bg-black/60 hover:text-white"
|
||||
onClick={() => navLightbox(-1)}
|
||||
title="이전 (PageUp)"
|
||||
title="이전 항목 (P / PageUp / Ctrl+←)"
|
||||
>
|
||||
‹
|
||||
<IconChevronLeft size={22} />
|
||||
</button>
|
||||
<button
|
||||
class="absolute right-2 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-full bg-black/40 text-xl text-white/80 hover:bg-black/60 hover:text-white"
|
||||
onClick={() => navLightbox(1)}
|
||||
title="다음 (PageDown)"
|
||||
title="다음 항목 (N / PageDown / Ctrl+→)"
|
||||
>
|
||||
›
|
||||
<IconChevronRight size={22} />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
{/* 하단 컨트롤바 */}
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex flex-col gap-1 bg-gradient-to-t from-black/80 to-transparent px-3 pb-2 pt-6 transition-opacity duration-200"
|
||||
classList={{ "opacity-0": !controlsShown(), "opacity-100": controlsShown() }}
|
||||
classList={{ "opacity-0": !barVisible(), "opacity-100": barVisible() }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 시크바 */}
|
||||
{/* 구간 자르기 줄 — 재생바의 손잡이를 끌어 잡고, 여기서 미세 조정한다. */}
|
||||
<Show when={trimMode()}>
|
||||
<div class="mb-1 flex items-center gap-2 rounded bg-black/60 px-2 py-1 text-[11px] text-white">
|
||||
<TimeField
|
||||
label={ko.edit.trimStart}
|
||||
value={trimStart()}
|
||||
active={activeHandle() === "start"}
|
||||
fmt={fmtPrecise}
|
||||
onFocus={() => setActiveHandle("start")}
|
||||
onCommit={(t) => moveHandle("start", t)}
|
||||
/>
|
||||
<TimeField
|
||||
label={ko.edit.trimEnd}
|
||||
value={trimEndOr()}
|
||||
active={activeHandle() === "end"}
|
||||
fmt={fmtPrecise}
|
||||
onFocus={() => setActiveHandle("end")}
|
||||
onCommit={(t) => moveHandle("end", t)}
|
||||
/>
|
||||
{/* 프레임 단위 미세 조정 — 재생바 클릭으로는 초 단위도 거칠다 */}
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button
|
||||
class="rounded border border-white/30 px-1.5 py-0.5 hover:bg-white/15"
|
||||
onClick={() => nudge(-1)}
|
||||
title={ko.edit.nudgeBack}
|
||||
>
|
||||
◂
|
||||
</button>
|
||||
<button
|
||||
class="rounded border border-white/30 px-1.5 py-0.5 hover:bg-white/15"
|
||||
onClick={() => nudge(1)}
|
||||
title={ko.edit.nudgeFwd}
|
||||
>
|
||||
▸
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="rounded border px-1.5 py-0.5"
|
||||
classList={{
|
||||
"border-[var(--accent)] bg-[var(--accent)]": rangePreview(),
|
||||
"border-white/30 hover:bg-white/15": !rangePreview(),
|
||||
}}
|
||||
onClick={toggleRangePreview}
|
||||
title={ko.edit.rangePreviewHint}
|
||||
>
|
||||
{rangePreview() ? ko.edit.rangePreviewStop : ko.edit.rangePreview}
|
||||
</button>
|
||||
<span class="tabular-nums text-white/70">
|
||||
{ko.edit.trimLength((trimEndOr() - trimStart()) || 0)}
|
||||
</span>
|
||||
<button
|
||||
class="ml-auto rounded bg-[var(--accent)] px-2 py-0.5 disabled:opacity-40"
|
||||
disabled={trimBusy() || trimEndOr() - trimStart() < 0.5}
|
||||
onClick={() => void applyTrim()}
|
||||
>
|
||||
{trimBusy() ? ko.edit.cropping : ko.edit.trimApply}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
ref={barRef}
|
||||
class="group relative h-4 cursor-pointer"
|
||||
class="group relative cursor-pointer transition-[height]"
|
||||
classList={{ "h-4": !trimMode(), "h-14": trimMode() }}
|
||||
onPointerDown={onBarPointerDown}
|
||||
>
|
||||
<div class="absolute top-1/2 h-1 w-full -translate-y-1/2 rounded bg-white/25" />
|
||||
{/* 필름스트립 — 자르기 중에는 재생바가 장면 띠가 된다.
|
||||
아직 안 만들어졌으면 회색 자리만 두고 기다린다(레이아웃이 튀지 않게). */}
|
||||
<Show when={trimMode()}>
|
||||
<div class="absolute inset-0 flex overflow-hidden rounded bg-black/40">
|
||||
<For each={strip() ?? []}>
|
||||
{(f) => (
|
||||
<img
|
||||
src={f.url}
|
||||
class="h-full min-w-0 flex-1 object-cover"
|
||||
draggable={false}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="absolute top-1/2 w-full -translate-y-1/2 rounded bg-white/25"
|
||||
classList={{ "h-1": !trimMode(), "h-0": trimMode() }}
|
||||
/>
|
||||
<Show when={!isStream()}>
|
||||
<div
|
||||
class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-white/40"
|
||||
style={{ width: `${bufferedPct()}%` }}
|
||||
/>
|
||||
</Show>
|
||||
<div
|
||||
class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-[var(--accent)]"
|
||||
style={{ width: `${progressPct()}%` }}
|
||||
/>
|
||||
<div
|
||||
class="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 shadow transition-opacity group-hover:opacity-100"
|
||||
style={{ left: `${progressPct()}%` }}
|
||||
/>
|
||||
{/* 자르기 중에는 진행 채움을 숨긴다 — 필름스트립을 가리면 장면이 안 보인다 */}
|
||||
<Show when={!trimMode()}>
|
||||
<div
|
||||
class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-[var(--accent)]"
|
||||
style={{ width: `${progressPct()}%` }}
|
||||
/>
|
||||
</Show>
|
||||
{/* 잘려 나갈 앞뒤는 덮어 가리고, 남을 구간은 테두리로 감싼다.
|
||||
아이폰 트리머와 같은 읽기 방식 — 밝은 쪽이 남는 쪽이다. */}
|
||||
<Show when={trimMode() && durationSec() > 0}>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 rounded-l bg-black/70"
|
||||
style={{ width: `${(trimStart() / durationSec()) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 rounded-r bg-black/70"
|
||||
style={{ left: `${(trimEndOr() / durationSec()) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 rounded border-y-2 border-[var(--warn,#f0b429)]"
|
||||
style={{
|
||||
left: `${(trimStart() / durationSec()) * 100}%`,
|
||||
width: `${((trimEndOr() - trimStart()) / durationSec()) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
{/* 양 끝 손잡이 — 띠 높이만큼 세워야 잡기 쉽다 */}
|
||||
<For each={["start", "end"] as const}>
|
||||
{(which) => (
|
||||
<div
|
||||
class="absolute inset-y-0 z-10 flex w-4 -translate-x-1/2 cursor-ew-resize items-center justify-center rounded bg-[var(--warn,#f0b429)] shadow"
|
||||
classList={{ "ring-2 ring-white": activeHandle() === which }}
|
||||
style={{
|
||||
left: `${((which === "start" ? trimStart() : trimEndOr()) / durationSec()) * 100}%`,
|
||||
}}
|
||||
onPointerDown={onHandleDown(which)}
|
||||
title={which === "start" ? ko.edit.trimStart : ko.edit.trimEnd}
|
||||
>
|
||||
<span class="pointer-events-none h-4 w-0.5 rounded bg-black/40" />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
{/* 재생 위치 — 평소엔 hover 시 나타나는 점, 자르기 중엔 띠를 가로지르는 선.
|
||||
자를 지점을 맞추려면 지금 어디를 보고 있는지가 항상 보여야 한다. */}
|
||||
<Show
|
||||
when={trimMode()}
|
||||
fallback={
|
||||
<div
|
||||
class="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 shadow transition-opacity group-hover:opacity-100"
|
||||
style={{ left: `${progressPct()}%` }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 z-20 w-0.5 -translate-x-1/2 bg-white shadow"
|
||||
style={{ left: `${progressPct()}%` }}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* 버튼 행 */}
|
||||
<div class="flex items-center gap-2 text-white">
|
||||
<button class="rounded px-1 text-lg leading-none hover:text-[var(--accent)]" onClick={togglePlay} title="재생/정지 (Space)">
|
||||
{playing() ? "⏸" : "▶"}
|
||||
{playing() ? <IconPause size={17} /> : <IconPlay size={17} />}
|
||||
</button>
|
||||
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={() => skip(-10)} title="10초 뒤로 (J)">
|
||||
⏪
|
||||
<IconRewind size={16} />
|
||||
</button>
|
||||
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={() => skip(10)} title="10초 앞으로 (L)">
|
||||
⏩
|
||||
<IconForward size={16} />
|
||||
</button>
|
||||
|
||||
{/* 볼륨 */}
|
||||
<div class="group/vol flex items-center gap-1">
|
||||
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={toggleMute} title="음소거 (M)">
|
||||
{muted() || volume() === 0 ? "🔇" : volume() < 0.5 ? "🔉" : "🔊"}
|
||||
{muted() || volume() === 0 ? <IconVolumeMute size={16} /> : volume() < 0.5 ? <IconVolumeLow size={16} /> : <IconVolumeHigh size={16} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
@@ -445,6 +937,36 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
</span>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
class="rounded px-1 text-sm leading-none"
|
||||
classList={{
|
||||
"text-[var(--accent)]": trimMode(),
|
||||
"text-white hover:text-[var(--accent)]": !trimMode(),
|
||||
}}
|
||||
onClick={() => setTrimMode((v) => !v)}
|
||||
title={`${ko.edit.trim} · ${ko.edit.trimHint}`}
|
||||
>
|
||||
<IconScissors size={16} />
|
||||
</button>
|
||||
{/* 정리 작업 — 전체화면에서도 재생을 멈추지 않고 쓸 수 있게 컨트롤바에 둔다 */}
|
||||
<button
|
||||
class="rounded px-1 text-sm leading-none"
|
||||
classList={{
|
||||
"text-[var(--danger)]": marked(),
|
||||
"text-white hover:text-[var(--accent)]": !marked(),
|
||||
}}
|
||||
onClick={() => void toggleMarkDelete([props.fileId])}
|
||||
title={`${marked() ? ko.trash.unmark : ko.trash.mark} (Delete)`}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-1 text-sm leading-none text-white hover:text-[var(--accent)]"
|
||||
onClick={openMoveDialog}
|
||||
title={`${ko.ops.moveTo} (Ctrl+M)`}
|
||||
>
|
||||
<IconFolder size={16} />
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-1 text-sm leading-none"
|
||||
classList={{
|
||||
@@ -454,7 +976,7 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
onClick={toggleRepeat}
|
||||
title={repeatMode() ? "반복 켜짐" : "반복 꺼짐 (끝나면 다음 항목)"}
|
||||
>
|
||||
🔁
|
||||
<IconRepeat size={16} />
|
||||
</button>
|
||||
<button
|
||||
class="rounded border border-white/30 px-1.5 py-0.5 text-[10px] leading-none hover:border-[var(--accent)] hover:text-[var(--accent)]"
|
||||
@@ -463,13 +985,31 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
>
|
||||
{rateLabel()}
|
||||
</button>
|
||||
{/* 화면 크기 모드 — 휠로 확대한 배율도 같이 보여준다 */}
|
||||
<button
|
||||
class="rounded border border-white/30 px-1.5 py-0.5 text-[10px] leading-none hover:border-[var(--accent)] hover:text-[var(--accent)]"
|
||||
onClick={cycleFit}
|
||||
title="화면 크기 (맞춤 → 채움 → 원본). 휠로 확대, 드래그로 이동, 0으로 초기화"
|
||||
>
|
||||
{FIT_LABEL[fit()]}
|
||||
{zoom() > 1 ? ` ${Math.round(zoom() * 100)}%` : ""}
|
||||
</button>
|
||||
<Show when={zoom() > 1}>
|
||||
<button
|
||||
class="rounded border border-white/30 px-1.5 py-0.5 text-[10px] leading-none hover:border-[var(--accent)] hover:text-[var(--accent)]"
|
||||
onClick={resetView}
|
||||
title="확대 초기화 (0)"
|
||||
>
|
||||
<IconResetZoom size={14} />
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={isStream()}>
|
||||
<span class="rounded bg-white/15 px-1.5 py-0.5 text-[10px] text-white/70">
|
||||
{(plan() as { video: string }).video === "transcode" ? "변환 재생" : "리먹스 재생"}
|
||||
</span>
|
||||
</Show>
|
||||
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={toggleFullscreen} title="전체화면 (F)">
|
||||
{fullscreen() ? "🗗" : "⛶"}
|
||||
{fullscreen() ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+378
-6
@@ -16,6 +16,96 @@ export const ko = {
|
||||
`소스 "${name}"를 목록에서 제외할까요?\n\n인덱스·태그·썸네일만 제거되며, 디스크의 실제 파일은 삭제되지 않습니다.`,
|
||||
removed: (name: string, n: number) =>
|
||||
`"${name}" 제외됨 (${n.toLocaleString("ko-KR")}개 항목 인덱스에서 제거)`,
|
||||
allItems: "전체 항목",
|
||||
collections: "컬렉션",
|
||||
cleanup: "정리",
|
||||
// 전각 더하기 글리프는 아이콘이 아니다 — 모양은 IconPlus가 맡고 여기엔 말만 둔다
|
||||
addSourcePrimary: "폴더 추가",
|
||||
collapse: "접기",
|
||||
expand: "펼치기",
|
||||
},
|
||||
// 사이드바 하단 라이브러리 통계 카드
|
||||
stats: {
|
||||
title: "라이브러리",
|
||||
modeLine: (mode: string, version: string) => `${mode} · v${version}`,
|
||||
},
|
||||
// 사이드바 작업 존 — '지금 무엇이 얼마나'까지만 말한다.
|
||||
// 자세한 사정(어느 폴더를 읽는 중인지, 데이터 위치 등)은 footer(상태바)가 말한다.
|
||||
tasks: {
|
||||
zone: "진행 중",
|
||||
scan: "폴더 읽는 중",
|
||||
dedupe: "중복 찾는 중",
|
||||
/**
|
||||
* 백필 제목 — **지금 실제로 밀려 있는 것**을 말한다. 예전에는 무엇이 남았든 '미리보기
|
||||
* 준비 중'이라 적었는데, 썸네일이 다 끝나고 지문만 남은 상태에서도 그렇게 보여서
|
||||
* "미리보기가 멈췄다"는 오해를 만들었다(실제 신고). 셋 다 남았으면 뭉뚱그린다.
|
||||
*/
|
||||
backfillTitle: (thumbs: number, meta: number, sigs: number) => {
|
||||
const kinds = (thumbs > 0 ? 1 : 0) + (meta > 0 ? 1 : 0) + (sigs > 0 ? 1 : 0);
|
||||
if (kinds > 1) return "라이브러리 준비 중";
|
||||
if (thumbs > 0) return "미리보기 만드는 중";
|
||||
if (meta > 0) return "파일 정보 읽는 중";
|
||||
return "중복 탐지 지문 만드는 중";
|
||||
},
|
||||
/** 백필 부제 — 0인 항목은 말하지 않는다. 지문만 남은 상태에서도 '지문 N'이 반드시 보인다. */
|
||||
backfillDetail: (thumbs: number, meta: number, sigs: number) => {
|
||||
const p: string[] = [];
|
||||
if (thumbs > 0) p.push(`썸네일 ${thumbs.toLocaleString("ko-KR")}`);
|
||||
if (meta > 0) p.push(`메타 ${meta.toLocaleString("ko-KR")}`);
|
||||
if (sigs > 0) p.push(`지문 ${sigs.toLocaleString("ko-KR")}`);
|
||||
return p.join(" · ");
|
||||
},
|
||||
/** ffmpeg가 없을 때 — 큐가 조용히 건너뛰므로 대기 수만 보면 멈춘 것과 구별되지 않는다 */
|
||||
needFfmpegTitle: "영상 처리 불가",
|
||||
needFfmpeg: "ffmpeg.exe가 프로그램 폴더에 없습니다",
|
||||
more: (n: number) => `작업 ${n.toLocaleString("ko-KR")}개 더`,
|
||||
cancel: "이 작업 취소",
|
||||
/** 열거 단계는 총량을 모른다 — 퍼센트 대신 처리 속도를 말한다 */
|
||||
rate: (perSec: number) =>
|
||||
perSec >= 10
|
||||
? `초당 ${Math.round(perSec).toLocaleString("ko-KR")}개`
|
||||
: `초당 ${perSec.toFixed(1)}개`,
|
||||
/** 백필 처리율 — 남은 수 대신 '몇 개 중 몇 개' */
|
||||
doneOf: (done: number, total: number) =>
|
||||
`${done.toLocaleString("ko-KR")} / ${total.toLocaleString("ko-KR")}`,
|
||||
footerHint: "진행 중인 작업 — 자세한 내용은 창 아래 상태바에 있습니다",
|
||||
},
|
||||
// 본문 카드 안 폴더 칩 밴드
|
||||
browse: {
|
||||
folders: "폴더",
|
||||
more: (n: number) => `${n}개 더`,
|
||||
collapse: "폴더 목록 접기",
|
||||
expand: "폴더 목록 펼치기",
|
||||
},
|
||||
// 본문 카드 상단 브레드크럼 밴드
|
||||
crumb: {
|
||||
searchResult: (q: string) => `"${q}" 검색 결과`,
|
||||
searchScope: "전체 라이브러리에서",
|
||||
exitSearch: "검색 끝내고 이전 위치로",
|
||||
},
|
||||
theme: {
|
||||
label: "테마",
|
||||
toLight: "밝은 테마로 전환",
|
||||
toDark: "어두운 테마로 전환",
|
||||
},
|
||||
source: {
|
||||
reload: "다시 불러오기",
|
||||
reloadStarted: (name: string) => `"${name}" 다시 불러오는 중…`,
|
||||
edit: "이름·경로 바꾸기",
|
||||
editTitle: "소스 수정",
|
||||
name: "이름",
|
||||
root: "폴더 경로",
|
||||
browse: "폴더 선택…",
|
||||
remove: "소스 제외 (파일은 삭제되지 않음)",
|
||||
unavailable: "경로에 접근할 수 없습니다 — 연결을 확인하거나 '경로 바꾸기'로 다시 매핑하세요",
|
||||
unavailableBadge: "연결 없음",
|
||||
overlap: "다른 소스와 경로가 겹칩니다 — 같은 파일이 두 번 보일 수 있으니 하나를 제외하세요",
|
||||
overlapBadge: "경로 겹침",
|
||||
alreadyMapped: (name: string) => `이미 등록된 폴더입니다 — "${name}"를 다시 불러옵니다`,
|
||||
remapHint: "폴더를 옮겼거나 드라이브 문자가 바뀐 경우, 경로만 바꾸면 태그·썸네일이 그대로 유지됩니다.",
|
||||
remapDone: (n: number) => `경로 재매핑 완료 (폴더 ${n.toLocaleString("ko-KR")}개) — 다시 확인 중…`,
|
||||
renameDone: "소스 이름을 바꿨습니다",
|
||||
remoteNoRoot: "네트워크 소스는 경로를 바꿀 수 없습니다",
|
||||
},
|
||||
onboarding: {
|
||||
title: "Archive에 오신 것을 환영합니다",
|
||||
@@ -29,6 +119,24 @@ export const ko = {
|
||||
items: (n: number) => `${n.toLocaleString("ko-KR")}개 항목`,
|
||||
selected: (n: number) => `${n.toLocaleString("ko-KR")}개 선택됨`,
|
||||
infoToggle: "썸네일에 파일명·크기 표시",
|
||||
zoomTitle: "썸네일 크기 (Ctrl+휠)",
|
||||
},
|
||||
viewMode: {
|
||||
label: "보기",
|
||||
grid: "격자",
|
||||
list: "리스트",
|
||||
title: "보기 방식 전환 (V)",
|
||||
},
|
||||
list: {
|
||||
name: "이름",
|
||||
tags: "태그",
|
||||
kind: "종류",
|
||||
size: "크기",
|
||||
dims: "해상도",
|
||||
duration: "길이",
|
||||
taken: "촬영일",
|
||||
modified: "수정일",
|
||||
sortHint: (col: string) => `${col} 기준 정렬`,
|
||||
},
|
||||
filter: {
|
||||
all: "전체",
|
||||
@@ -36,6 +144,7 @@ export const ko = {
|
||||
video: "영상",
|
||||
},
|
||||
sort: {
|
||||
label: "정렬",
|
||||
taken: "촬영일",
|
||||
name: "이름",
|
||||
size: "크기",
|
||||
@@ -43,6 +152,22 @@ export const ko = {
|
||||
asc: "오름차순",
|
||||
desc: "내림차순",
|
||||
},
|
||||
refresh: {
|
||||
action: "새로고침",
|
||||
title: "디스크를 다시 읽어 목록을 맞춥니다 (F5)",
|
||||
started: "새로고침 중… 변경된 항목만 반영됩니다",
|
||||
busy: "이미 스캔이 진행 중입니다",
|
||||
purgeMissing: "사라진 파일 정리",
|
||||
purgeMissingConfirm: (n: number) =>
|
||||
`디스크에 없는 ${n.toLocaleString("ko-KR")}개를 목록에서 지울까요?
|
||||
|
||||
실제 파일은 이미 없으며, 태그·썸네일 기록만 제거됩니다.`,
|
||||
purgeMissingDone: (n: number) => `${n.toLocaleString("ko-KR")}개를 목록에서 정리했습니다`,
|
||||
purgeMissingNone: "디스크에 없는 파일이 없습니다 — 정리할 것이 없습니다",
|
||||
/** 대량 삭제 보류 — 사라진 것으로 보이는 파일이 절반을 넘어 지우지 않았다 */
|
||||
guarded: (n: number) =>
|
||||
`파일 ${n.toLocaleString("ko-KR")}개가 사라진 것으로 보이지만, 안전을 위해 목록에서 지우지 않았습니다. 드라이브·네트워크 연결을 확인하세요.`,
|
||||
},
|
||||
shortcuts: {
|
||||
title: "키보드 단축키",
|
||||
close: "닫기 (Esc / ?)",
|
||||
@@ -50,13 +175,24 @@ export const ko = {
|
||||
["Space / K", "재생·일시정지 (영상)"],
|
||||
["← →", "이전·다음 항목 (그리드) / 5초 이동 (영상)"],
|
||||
["↑ ↓", "위·아래 줄 이동 (그리드) / 볼륨 (영상)"],
|
||||
["Home / End", "첫·마지막 항목"],
|
||||
["Enter / Space", "선택 항목 크게 보기"],
|
||||
["V", "보기 방식 전환 (격자 ↔ 리스트)"],
|
||||
["N / P", "다음·이전 항목 (보기 중, 영상 포함)"],
|
||||
["Ctrl+← →", "다음·이전 항목 (영상 재생 중에도)"],
|
||||
["T", "태그 메뉴 (보기 중) — 검색·새로 만들기, 1~9로 바로 지정"],
|
||||
["W", "화면 크기 전환 (맞춤 / 채움 / 원본)"],
|
||||
["휠, + −, 0", "확대 (커서 기준) · 초기화 — 확대 후 드래그로 이동"],
|
||||
["Ctrl+[ / Ctrl+]", "왼쪽·오른쪽 90도 회전 (원본이 바뀜)"],
|
||||
["Ctrl+M", "폴더로 이동"],
|
||||
["Delete", "삭제 표시 (파일은 그대로 — 비우기 전까지)"],
|
||||
["Shift+Delete", "즉시 OS 휴지통으로"],
|
||||
["Ctrl+A", "전체 선택"],
|
||||
["Ctrl+Shift+A", "선택 반전"],
|
||||
["Esc", "선택 해제 / 닫기"],
|
||||
["Delete", "휴지통으로"],
|
||||
["Ctrl+Z", "실행 취소"],
|
||||
["Ctrl+Z", "실행 취소 (삭제 표시 포함)"],
|
||||
["F2", "이름 바꾸기"],
|
||||
["F5", "새로고침 (디스크 다시 읽기)"],
|
||||
["Ctrl+휠", "썸네일 크기"],
|
||||
["?", "이 도움말"],
|
||||
] as [string, string][],
|
||||
@@ -73,6 +209,15 @@ export const ko = {
|
||||
takenAt: "촬영 일시",
|
||||
modifiedAt: "수정 일시",
|
||||
tags: "태그",
|
||||
tabInfo: "정보",
|
||||
tabTags: "태그",
|
||||
close: "상세 패널 닫기",
|
||||
toggle: "상세 정보 패널",
|
||||
sectionMedia: "미디어 정보",
|
||||
sectionBasic: "기본 정보",
|
||||
kind: "종류",
|
||||
bulkTag: "태그 편집",
|
||||
selectedUnit: "개 선택됨", // [C] 다중 선택 타일 — 큰 숫자 아래 라벨
|
||||
},
|
||||
statusBar: {
|
||||
ready: "준비됨",
|
||||
@@ -82,30 +227,149 @@ export const ko = {
|
||||
installed: "일반 모드",
|
||||
thumbPending: (n: number) => `썸네일 생성 중 ${n.toLocaleString("ko-KR")}개`,
|
||||
metaPending: (n: number) => `메타 분석 중 ${n.toLocaleString("ko-KR")}개`,
|
||||
sigPending: (n: number) => `지문 생성 중 ${n.toLocaleString("ko-KR")}개`,
|
||||
sigPendingHint:
|
||||
"중복 탐지용 지문을 백그라운드에서 만들고 있습니다. 앱을 계속 쓰셔도 되고, 껐다 켜면 이어서 진행됩니다.",
|
||||
},
|
||||
search: {
|
||||
placeholder: "파일명 검색…",
|
||||
},
|
||||
common: {
|
||||
cancel: "취소",
|
||||
/** 취소를 눌렀지만 아직 멈추지 않은 구간 — 사이드바 값 칸과 모달 헤더가 같은 말을 한다 */
|
||||
canceling: "취소 중…",
|
||||
confirm: "확인",
|
||||
delete: "삭제",
|
||||
rename: "이름 바꾸기",
|
||||
move: "이동",
|
||||
undo: "실행 취소",
|
||||
create: "만들기",
|
||||
save: "저장",
|
||||
close: "닫기",
|
||||
},
|
||||
rating: {
|
||||
label: "별점",
|
||||
set: (n: number) => `별점 ${n}점`,
|
||||
clearHint: "같은 별을 다시 누르면 해제됩니다",
|
||||
none: "없음",
|
||||
/** 툴바 필터 — '이상'이라는 점을 분명히 한다(정확히 N점이 아니다) */
|
||||
filterAll: "전체",
|
||||
filterAtLeast: (n: number) => `${n}★ 이상`,
|
||||
sort: "별점",
|
||||
/** 여러 항목을 함께 골랐는데 값이 다를 때 */
|
||||
mixed: "여러 값",
|
||||
applied: (n: number, stars: number) =>
|
||||
stars === 0 ? `${n}개 별점 해제` : `${n}개에 별점 ${stars}점`,
|
||||
shortcut: "Ctrl+0~5 로 별점",
|
||||
},
|
||||
multi: {
|
||||
/** 우클릭 메뉴 — 영상을 2개 이상 골랐을 때만 보인다 */
|
||||
open: (n: number) => `동시 재생 (${n}개)`,
|
||||
tooMany: (max: number) => `동시 재생은 최대 ${max}개까지입니다 — 앞 ${max}개만 엽니다`,
|
||||
needTwo: "영상을 2개 이상 고르세요",
|
||||
title: (n: number) => `동시 재생 · ${n}개`,
|
||||
playAll: "모두 재생",
|
||||
pauseAll: "모두 정지",
|
||||
hint: "칸을 누르면 소리가 그 칸으로 · 1~9 키로도 이동 · 두 번 누르면 크게 · Space 재생/정지 · Esc 닫기",
|
||||
audioHere: "소리",
|
||||
preparing: "준비 중…",
|
||||
/** 실시간 변환 상한에 걸린 칸 */
|
||||
blocked: "변환 필요 — 동시 재생 제외",
|
||||
blockedNote: (n: number, max: number) =>
|
||||
`${n}개는 코덱 변환이 필요해 제외했습니다 (동시 변환은 ${max}개까지 — 그 이상은 재생이 끊깁니다)`,
|
||||
},
|
||||
edit: {
|
||||
menu: "회전·반전",
|
||||
rotateCw: "오른쪽으로 90°",
|
||||
rotateCcw: "왼쪽으로 90°",
|
||||
rotate180: "180° 뒤집기",
|
||||
flipH: "좌우 반전",
|
||||
flipV: "상하 반전",
|
||||
/** 원본을 고친다는 걸 메뉴에서부터 알린다 */
|
||||
hint: "원본이 바뀝니다 · Ctrl+Z로 되돌리기",
|
||||
working: (done: number, total: number) => `편집 중 ${done}/${total}`,
|
||||
done: (n: number) => `${n}개 편집 완료`,
|
||||
/** 재인코딩이 섞였을 때 — 화질 손실이 있었다는 뜻이라 조용히 넘기면 안 된다 */
|
||||
doneReencoded: (n: number, re: number) =>
|
||||
`${n}개 완료 (${re}개는 다시 인코딩 — 영상 반전은 무손실이 안 됩니다)`,
|
||||
doneWithFail: (n: number, f: number) => `${n}개 완료 · ${f}개 실패`,
|
||||
lossless: "화질 손실 없음",
|
||||
selectFirst: "먼저 항목을 고르세요",
|
||||
shortcut: "Ctrl+[ 왼쪽 · Ctrl+] 오른쪽 회전",
|
||||
crop: "자르기",
|
||||
cropHint: "남길 부분을 드래그하세요 (Enter 적용 · Esc 취소)",
|
||||
cropNone: "영역을 고르세요",
|
||||
cropApply: "잘라내기",
|
||||
cropping: "자르는 중…",
|
||||
/** 자르기는 역연산이 없다 — 원본을 옮겨 보관한다는 걸 분명히 말한다 */
|
||||
cropWarn: "잘라낸 부분은 되살릴 수 없습니다. 원본은 보관되며 Ctrl+Z로 되돌아옵니다.",
|
||||
cropDone: (w: number, h: number) => `${w}×${h}로 잘랐습니다`,
|
||||
trim: "구간 자르기",
|
||||
trimStart: "시작",
|
||||
trimEnd: "끝",
|
||||
trimSetStart: "여기를 시작으로",
|
||||
trimSetEnd: "여기를 끝으로",
|
||||
trimApply: "이 구간만 남기기",
|
||||
trimHint: "남길 구간만 두고 나머지는 잘라냅니다 — 화질 손실 없음",
|
||||
trimLength: (s: number) => `${s.toFixed(1)}초`,
|
||||
/** fps를 모르므로 1/30초 고정 — "한 프레임"이라 적으면 24·60fps 영상에서 거짓말이 된다 */
|
||||
nudgeBack: "0.03초 뒤로 (←) · 정확한 값은 왼쪽 칸에 직접 입력",
|
||||
nudgeFwd: "0.03초 앞으로 (→) · 정확한 값은 왼쪽 칸에 직접 입력",
|
||||
rangePreview: "구간 재생",
|
||||
rangePreviewStop: "정지",
|
||||
rangePreviewHint: "고른 구간만 반복 재생해 확인합니다",
|
||||
trimDone: (secs: number) => `${secs.toFixed(1)}초 구간만 남겼습니다`,
|
||||
trimTooShort: "구간이 너무 짧습니다",
|
||||
imageOnly: "사진에만 쓸 수 있습니다",
|
||||
videoOnly: "영상에만 쓸 수 있습니다",
|
||||
},
|
||||
tags: {
|
||||
add: "태그 추가…",
|
||||
newPlaceholder: "새 태그 이름",
|
||||
searchOrCreate: "태그 검색 · 새 태그 이름",
|
||||
createAssign: (name: string) => `"${name}" 새로 만들어 지정`,
|
||||
createdAndAssigned: (name: string) => `"${name}" 태그를 만들어 지정했습니다`,
|
||||
emptyAll: "태그가 없습니다 — 이름을 입력해 새로 만드세요",
|
||||
pickerHint: "↑↓ 이동 · Enter 지정 · 1~9 빠른 지정 · Esc 닫기",
|
||||
assignTo: (n: number, tag: string) => `${n}개 항목에 "${tag}" 태그 추가됨`,
|
||||
removedFrom: (n: number, tag: string) => `${n}개 항목에서 "${tag}" 태그 제거됨`,
|
||||
deleteConfirm: (tag: string) => `태그 "${tag}"를 삭제할까요? (파일은 삭제되지 않음)`,
|
||||
exported: (path: string) => `태그를 내보냈습니다: ${path}`,
|
||||
exportAction: "태그 JSON 내보내기",
|
||||
|
||||
// ── 클릭으로 붙이기 ────────────────────────────────────
|
||||
// 매번 이름을 쳐서 찾는 대신 목록을 눌러 붙인다. 입력창은 '좁히기 + 새로 만들기'다.
|
||||
noneYet: "붙은 태그 없음",
|
||||
pick: "태그 고르기",
|
||||
filterOrCreate: "좁히기 · 새 태그는 이름 입력 후 Enter",
|
||||
noMatch: "일치하는 태그 없음",
|
||||
countHint: (n: number) => `${n.toLocaleString("ko-KR")}개 항목에 사용 중`,
|
||||
|
||||
// ── 세트 ───────────────────────────────────────────────
|
||||
sets: "태그 세트",
|
||||
setHint: (names: string) => `한 번에 붙이기: ${names} (우클릭으로 편집)`,
|
||||
setApplied: (name: string, n: number) => `"${name}" 세트 적용 — ${n}개 추가됨`,
|
||||
newSet: "+ 세트",
|
||||
editSet: "세트 편집",
|
||||
setName: "세트 이름",
|
||||
setNamePlaceholder: "예: 여행 정리",
|
||||
setMembers: (n: number) => `포함 태그 ${n}개`,
|
||||
setHotkey: "단축키",
|
||||
noHotkey: "없음",
|
||||
pressKey: "키를 누르세요…",
|
||||
clearHotkey: "해제",
|
||||
hotkeyNote:
|
||||
"항목을 선택한 상태에서 누르면 적용됩니다. 입력창에 커서가 있을 때는 동작하지 않습니다.",
|
||||
|
||||
// ── 이름 수정 ──────────────────────────────────────────
|
||||
// file_tags가 tag_id로 연결돼 있어 이름만 바꾸면 붙어 있던 항목에 그대로 반영된다.
|
||||
rename: "이름 수정",
|
||||
renamePrompt: (old: string) => `"${old}"의 새 이름`,
|
||||
renamed: (from: string, to: string) => `"${from}" → "${to}" (붙어 있던 항목에 모두 반영됨)`,
|
||||
renameNote: "이미 이 태그가 붙어 있는 항목들도 함께 바뀝니다. 태그를 지웠다 새로 만들면 그 연결이 사라집니다.",
|
||||
},
|
||||
ops: {
|
||||
trash: "휴지통으로 보내기",
|
||||
moveTo: "폴더로 이동…",
|
||||
moveDone: (n: number) => `${n}개 이동 완료`,
|
||||
moveFailed: (n: number) => `${n}개 이동 실패`,
|
||||
@@ -116,6 +380,24 @@ export const ko = {
|
||||
nothingToUndo: "되돌릴 작업이 없습니다",
|
||||
inProgress: (done: number, total: number) => `처리 중 ${done}/${total}…`,
|
||||
},
|
||||
trash: {
|
||||
tagName: "삭제 예정",
|
||||
mark: "삭제 표시",
|
||||
unmark: "삭제 표시 해제",
|
||||
marked: (n: number) => `${n}개 항목에 삭제 표시`,
|
||||
unmarked: (n: number) => `${n}개 항목의 삭제 표시 해제`,
|
||||
badge: "삭제 예정",
|
||||
empty: "비우기",
|
||||
emptyTitle: "삭제 예정 항목 비우기",
|
||||
emptyDanger: "디스크에서 영구 삭제 (복구 불가)",
|
||||
emptyNothing: "삭제 예정 항목이 없습니다",
|
||||
emptyConfirm: (n: number, size: string) =>
|
||||
`삭제 예정 ${n.toLocaleString("ko-KR")}개(${size})를 디스크에서 영구 삭제합니다.\n\n` +
|
||||
`휴지통을 거치지 않으므로 되돌릴 수 없습니다. 계속할까요?`,
|
||||
emptyDone: (n: number) => `${n.toLocaleString("ko-KR")}개 영구 삭제됨`,
|
||||
emptyPartial: (done: number, skipped: number, failed: number) =>
|
||||
`${done}개 삭제, ${skipped}개 이미 없음, ${failed}개 실패`,
|
||||
},
|
||||
smart: {
|
||||
create: "스마트 폴더 만들기",
|
||||
name: "이름",
|
||||
@@ -155,18 +437,108 @@ export const ko = {
|
||||
return `${labels[phase] ?? phase} ${cur.toLocaleString("ko-KR")}/${total.toLocaleString("ko-KR")}`;
|
||||
},
|
||||
threshold: "유사도 임계값",
|
||||
/** 지문 생성 속도 — 영상은 ffmpeg 한 프로세스가 코어 하나를 쓰므로 동시 실행 수가 곧 속도다 */
|
||||
speed: "지문 속도",
|
||||
speedSlow: "느리게",
|
||||
speedNormal: "보통",
|
||||
speedFast: "빠르게",
|
||||
speedHint:
|
||||
"중복 탐지용 지문을 만드는 속도입니다. '빠르게'는 코어를 더 쓰므로 다른 작업이 느려질 수 있습니다. 바꾸면 바로 반영됩니다.",
|
||||
includeVideo: "영상 포함",
|
||||
/** 회전·반전본 — 후보 생성이 몇 배 비싸므로 기본은 꺼둔다 */
|
||||
includeRotated: "회전·반전본",
|
||||
includeRotatedHint:
|
||||
"90°·180°·좌우반전 사본도 찾습니다. 변형 8종을 따로 조회하므로 시간이 몇 배 걸립니다.",
|
||||
scopeLabel: "범위:",
|
||||
scopeFolder: "현재 폴더 (하위 포함)",
|
||||
scopeSource: "현재 소스",
|
||||
scopeAll: "전체 라이브러리",
|
||||
empty: "중복 항목이 없습니다. 상단의 '중복 탐지 시작'을 눌러 검사하세요.",
|
||||
groupCount: (n: number) => `${n}개 그룹`,
|
||||
/** 탐지 중 밴드 — 시작 시점에 캡처한 범위를 말한다. 뷰가 바뀌어도 이 말은 바뀌지 않는다 */
|
||||
scopeRunning: (label: string) => `${label} 기준으로 탐지 중`,
|
||||
/** 결과 밴드 — 지금 보이는 그룹이 어떤 범위의 결과인지 */
|
||||
scopeResult: (label: string) => `${label} 결과`,
|
||||
rescan: "다시 탐지",
|
||||
empty: "중복 항목이 없습니다. 위의 '중복 탐지 시작'을 눌러 검사하세요.",
|
||||
groupCount: (n: number) => `${n.toLocaleString("ko-KR")}개 그룹`,
|
||||
memberCount: (n: number) => `${n}개 항목`,
|
||||
/** 그룹 종류 (0 완전 동일 · 1 유사 이미지 · 2 유사 영상) */
|
||||
kindLabel: (kind: number) =>
|
||||
kind === 0 ? "완전 동일" : kind === 1 ? "유사 이미지" : "유사 영상",
|
||||
/** 해밍 거리 — 작을수록 닮았다 */
|
||||
distance: (n: number) => `거리 ${n}`,
|
||||
/**
|
||||
* 지문이 아직 없는 항목 안내. 유사 탐지는 저장된 지문만 읽으므로, 백그라운드가
|
||||
* 채우는 중인 항목은 이번 결과에 없다 — 조용히 빠지면 "왜 안 잡히지"가 된다.
|
||||
*/
|
||||
sigPending: (n: number) =>
|
||||
`지문 준비 중 ${n.toLocaleString("ko-KR")}개 — 이 항목들은 아직 유사 탐지에 포함되지 않습니다`,
|
||||
moreGroups: (n: number) => `${n.toLocaleString("ko-KR")}개 그룹 더 보기`,
|
||||
done: "중복 탐지 완료",
|
||||
canceled: "중복 탐지 취소됨",
|
||||
keeper: "보관",
|
||||
keepBest: "최적 보관 (나머지 휴지통)",
|
||||
/** 배지가 곧 선택 손잡이다 — 지금 상태와 누르면 무슨 일이 나는지를 각각 말한다 */
|
||||
keeperCurrent: "이 항목을 보관합니다 (나머지가 삭제 대상)",
|
||||
keeperPick: "이 항목을 보관하도록 바꾸기",
|
||||
/**
|
||||
* 보관 항목은 탐지가 정하는 게 아니라 사용자가 바꿀 수 있다 — '최적'이라고 하면 거짓말이 된다.
|
||||
* 그리고 이 버튼은 **지우지 않는다** — '삭제 예정'으로 표시만 하고, 실제 삭제는
|
||||
* 사이드바 '정리 → 비우기'가 한다. 버튼이 '휴지통'이라고 하면 안 지워졌을 때 거짓말이 된다.
|
||||
*/
|
||||
keepBest: "보관 항목만 남기기 (나머지 삭제 예정)",
|
||||
keepBestHint:
|
||||
"나머지를 '삭제 예정'으로 표시합니다. 파일은 아직 지워지지 않으며, 사이드바 '정리'에서 확인 후 비우면 삭제됩니다.",
|
||||
keepAll: "모두 보관 (무시)",
|
||||
trashLosersConfirm: (n: number) => `보관 항목을 제외한 ${n}개를 휴지통으로 보낼까요?`,
|
||||
/** 표시된 실제 개수를 말한다 — 요청 수가 아니라 처리된 수 */
|
||||
markedForDelete: (n: number) => `${n}개를 '삭제 예정'으로 표시했습니다`,
|
||||
/** 그룹 카드에서 비교 뷰를 여는 버튼 */
|
||||
compare: "비교",
|
||||
compareHint: "보관 항목과 나란히 놓고 비교합니다",
|
||||
/**
|
||||
* 등급 — 사용자가 할 결정은 "그냥 정리해도 되나 / 눈으로 봐야 하나" 둘뿐이라 두 단계다.
|
||||
* 근거(거리·정렬·구간)에서 파생하며 저장하지 않는다.
|
||||
*/
|
||||
tierCertain: "확실",
|
||||
tierSuspect: "의심",
|
||||
tierCertainWhy: "바이트 동일이거나 근거가 뚜렷합니다 — 바로 정리해도 됩니다",
|
||||
tierSuspectWhy: "근거가 약합니다 — 비교로 확인한 뒤 결정하세요",
|
||||
/** 목록 머리 요약 — 확실한 것부터 몰아서 처리할 수 있게 개수를 먼저 말한다 */
|
||||
tierSummary: (certain: number, suspect: number) =>
|
||||
`확실 ${certain.toLocaleString("ko-KR")} · 의심 ${suspect.toLocaleString("ko-KR")}`,
|
||||
},
|
||||
// 중복 비교 — 두 파일을 나란히 놓고 같은 내용이 같은 시각에 보이도록 맞춰 재생한다.
|
||||
compare: {
|
||||
title: "나란히 비교",
|
||||
pick: "비교 대상:",
|
||||
/** 보기 전환 — 1:1은 크게, 전체는 한눈에 */
|
||||
modePair: "1:1",
|
||||
modeAll: "전체 동시",
|
||||
modeAllHint: (n: number) =>
|
||||
`${n}개를 한 화면에서 동시에 재생합니다. 영상이 ${n}개 동시에 돌아가므로 느려질 수 있습니다.`,
|
||||
allCount: (n: number) => `${n}개 동시 재생 — 각 제목이 자기 맞춤값을 표시합니다`,
|
||||
play: "재생 (Space)",
|
||||
pause: "일시정지 (Space)",
|
||||
back: (n: number) => `${n}초 뒤로 (←)`,
|
||||
fwd: (n: number) => `${n}초 앞으로 (→)`,
|
||||
/** 어느 쪽 소리를 들을지 — 두 소리를 같이 내면 비교가 안 된다 */
|
||||
audioOn: "이 쪽 소리를 듣는 중 — 누르면 전체 음소거 (M으로 전환)",
|
||||
audioOff: "이 쪽 소리 듣기 (M)",
|
||||
/** 화면만 보고 싶을 때. 같은 소리가 반복되면 오히려 방해가 된다 */
|
||||
muteAll: "전체 음소거 (Shift+M)",
|
||||
unmuteAll: "음소거 해제 — 마지막에 듣던 쪽 (Shift+M)",
|
||||
aligned: "같은 지점에서 시작",
|
||||
later: (sec: number) => `이 사본이 ${sec.toFixed(1)}초 늦게 시작`,
|
||||
earlier: (sec: number) => `이 사본이 ${sec.toFixed(1)}초 빠르게 시작`,
|
||||
/** 정렬을 직접 확인하지 못한 멤버 — 맞춰진 척하지 않는다 */
|
||||
noAlignment: "맞춤 정보 없음 — 둘 다 0초부터 재생합니다",
|
||||
segment: (from: string, to: string) => `일치 구간 ${from}–${to}`,
|
||||
toSegment: "일치 구간으로",
|
||||
outOfRange: "이 지점은 상대 파일에 없습니다",
|
||||
/** 패널 시간축의 짧은 표기 — 자리가 좁아 문장을 못 쓴다 */
|
||||
outOfRangeShort: "—",
|
||||
zoomHint: "휠로 확대 · 드래그로 이동 (0으로 초기화)",
|
||||
reset: "확대 초기화 (0)",
|
||||
/** 검토 중 "이 파일이 어디 있지"에 답한다 — 그 폴더로 이동하고 모달을 닫는다 */
|
||||
reveal: "이 파일이 있는 폴더 열기",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
+213
-4
@@ -10,6 +10,8 @@ export interface AppInfo {
|
||||
portable: boolean;
|
||||
mediaPort: number;
|
||||
mediaToken: string;
|
||||
/** ffmpeg 사이드카 유무 — 없으면 영상은 썸네일도 지문도 만들 수 없다 */
|
||||
hasFfmpeg: boolean;
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
@@ -38,6 +40,8 @@ export interface FileDetails {
|
||||
durationMs: number | null;
|
||||
kind: number;
|
||||
takenAt: number | null;
|
||||
/** 0=없음 ~ 5 */
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface ScanProgress {
|
||||
@@ -52,15 +56,60 @@ export const appInfo = () => invoke<AppInfo>("app_info");
|
||||
export interface PendingCounts {
|
||||
thumbs: number;
|
||||
meta: number;
|
||||
/** 중복 탐지용 지문 생성 대기 수 */
|
||||
sigs: number;
|
||||
/**
|
||||
* 진행률의 분모 — '가장 많이 밀려 있던 순간'의 대기 수. 백엔드가 DB에 들고 있어서
|
||||
* 앱을 껐다 켜도 이어진다(화면 메모리에 두면 재시작 때마다 0%로 돌아간다).
|
||||
*/
|
||||
baseline: number;
|
||||
}
|
||||
export const pendingCounts = () => invoke<PendingCounts>("pending_counts");
|
||||
export const listSources = () => invoke<Source[]>("list_sources");
|
||||
|
||||
/** add_local_source 결과 — existing이면 이미 등록된 소스를 그대로 돌려받은 것 */
|
||||
export interface AddSourceResult {
|
||||
source: Source;
|
||||
existing: boolean;
|
||||
}
|
||||
export const addLocalSource = (path: string) =>
|
||||
invoke<Source>("add_local_source", { path });
|
||||
invoke<AddSourceResult>("add_local_source", { path });
|
||||
/** 소스를 인덱스에서 제외 (실제 파일은 삭제하지 않음). 반환: 제거된 항목 수 */
|
||||
export const removeSource = (sourceId: number) =>
|
||||
invoke<number>("remove_source", { sourceId });
|
||||
|
||||
/** 소스 경로 점검 — 접근 가능 여부와 다른 소스와의 경로 겹침 */
|
||||
export interface SourceStatus {
|
||||
sourceId: number;
|
||||
available: boolean;
|
||||
overlapsWith: number | null;
|
||||
}
|
||||
export const sourceStatus = () => invoke<SourceStatus[]>("source_status");
|
||||
|
||||
/**
|
||||
* 소스 이름·경로 수정. 경로를 바꾸면 태그·썸네일을 유지한 채 새 위치로 재매핑하고
|
||||
* 이어서 재스캔한다. 반환: 경로가 바뀐 폴더 수.
|
||||
*/
|
||||
export const updateSource = (sourceId: number, patch: { name?: string; root?: string }) =>
|
||||
invoke<number>("update_source", {
|
||||
sourceId,
|
||||
name: patch.name ?? null,
|
||||
root: patch.root ?? null,
|
||||
});
|
||||
|
||||
/** 소스 하나만 다시 불러오기(증분 재스캔). 경로에 접근 못 하면 오류로 알린다. */
|
||||
export const reloadSource = (sourceId: number) =>
|
||||
invoke<void>("reload_source", { sourceId });
|
||||
/**
|
||||
* 안전장치가 보류한 사라진 파일을 확인 후 목록에서 정리한다.
|
||||
* 디스크를 다시 확인해 실제로 없는 행만 지우므로, 연결이 끊겼던 것뿐이면 아무것도 지워지지 않는다.
|
||||
*/
|
||||
export const purgeMissing = (sourceId: number) =>
|
||||
invoke<number>("purge_missing", { sourceId });
|
||||
|
||||
export const cancelScan = () => invoke<void>("cancel_scan");
|
||||
/** 수동 새로고침 — 로컬 소스 전체 증분 재스캔 (워처가 못 미치는 SMB/NAS용) */
|
||||
export const rescanAll = () => invoke<void>("rescan_all");
|
||||
export const folderTree = (sourceId: number) =>
|
||||
invoke<FolderNode[]>("folder_tree", { sourceId });
|
||||
export const fileDetails = (ids: number[]) =>
|
||||
@@ -113,6 +162,13 @@ export class Snapshot {
|
||||
thumbFailed(i: number): boolean {
|
||||
return this.ok(i) && (this.view.getUint8(i * ITEM_BYTES + 9) & 2) !== 0;
|
||||
}
|
||||
/**
|
||||
* 별점 0~5. flags 바이트의 **남는 비트(2~4)**에 얹혀 있다 —
|
||||
* 항목당 바이트가 늘지 않으므로 100만 건 스냅샷이 10MB 그대로다.
|
||||
*/
|
||||
rating(i: number): number {
|
||||
return this.ok(i) ? (this.view.getUint8(i * ITEM_BYTES + 9) >> 2) & 0b111 : 0;
|
||||
}
|
||||
/** 레이아웃 워커 입력용 종횡비 배열 (미지 치수는 1:1) */
|
||||
aspectRatios(): Float32Array {
|
||||
const out = new Float32Array(this.count);
|
||||
@@ -125,18 +181,21 @@ export class Snapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/** 정렬 키: taken(촬영/기본) | name | size | mtime */
|
||||
export type SortKey = "taken" | "name" | "size" | "mtime";
|
||||
/** 스냅샷 공통 옵션 — 정렬/종류 필터 */
|
||||
/** 정렬 키: taken(촬영/기본) | name | size | mtime | rating */
|
||||
export type SortKey = "taken" | "name" | "size" | "mtime" | "rating";
|
||||
/** 스냅샷 공통 옵션 — 정렬/종류/별점 필터 */
|
||||
export interface SnapOpts {
|
||||
sortKey?: SortKey;
|
||||
sortDesc?: boolean;
|
||||
kind?: number | null; // 0=사진, 1=영상, null=전체
|
||||
/** 이 별점 **이상**만 (1~5). null=전체 */
|
||||
minRating?: number | null;
|
||||
}
|
||||
const snapArgs = (o?: SnapOpts) => ({
|
||||
sortKey: o?.sortKey ?? null,
|
||||
sortDesc: o?.sortDesc ?? null,
|
||||
kind: o?.kind ?? null,
|
||||
minRating: o?.minRating ?? null,
|
||||
});
|
||||
|
||||
export async function folderSnapshot(
|
||||
@@ -181,6 +240,50 @@ export const assignTags = (fileIds: number[], tagId: number) =>
|
||||
export const unassignTags = (fileIds: number[], tagId: number) =>
|
||||
invoke<TagOpResult>("unassign_tags", { fileIds, tagId });
|
||||
export const fileTagsOf = (fileId: number) => invoke<Tag[]>("file_tags_of", { fileId });
|
||||
|
||||
/** 별점 0(없음)~5. 반환값은 실제로 바뀐 행 수. */
|
||||
export const setRating = (fileIds: number[], rating: number) =>
|
||||
invoke<number>("set_rating", { fileIds, rating });
|
||||
|
||||
/**
|
||||
* 태그 세트 — 자주 함께 붙이는 태그 묶음. 세트는 태그를 **참조**할 뿐 복사하지 않으므로,
|
||||
* 태그 이름을 바꾸면 세트에도 그대로 반영되고 태그를 지우면 세트에서 빠진다.
|
||||
*/
|
||||
export interface TagSet {
|
||||
id: number;
|
||||
name: string;
|
||||
/** 예: "1", "Ctrl+2". 없으면 클릭으로만 쓴다. 한 키는 한 세트에만 붙는다. */
|
||||
hotkey: string | null;
|
||||
tags: Tag[];
|
||||
}
|
||||
export const listTagSets = () => invoke<TagSet[]>("list_tag_sets");
|
||||
export const createTagSet = (name: string, tagIds: number[]) =>
|
||||
invoke<number>("create_tag_set", { name, tagIds });
|
||||
/** 보내지 않은 항목은 그대로 둔다. hotkey는 3상태: 생략=유지 / null=해제 / 문자열=지정 */
|
||||
export const updateTagSet = (
|
||||
setId: number,
|
||||
patch: { name?: string; hotkey?: string | null; tagIds?: number[] },
|
||||
) =>
|
||||
invoke<void>("update_tag_set", {
|
||||
setId,
|
||||
name: patch.name ?? null,
|
||||
// Rust 쪽이 Option<Option<String>>이라, 키가 없으면 '유지'가 되도록 undefined를 넘긴다
|
||||
hotkey: "hotkey" in patch ? patch.hotkey : undefined,
|
||||
tagIds: patch.tagIds ?? null,
|
||||
});
|
||||
export const deleteTagSet = (setId: number) => invoke<void>("delete_tag_set", { setId });
|
||||
export const applyTagSet = (fileIds: number[], setId: number) =>
|
||||
invoke<TagOpResult>("apply_tag_set", { fileIds, setId });
|
||||
|
||||
/** 파일-태그 연결 (그리드 오버레이용 배치 조회) */
|
||||
export interface FileTagLink {
|
||||
fileId: number;
|
||||
tagId: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
}
|
||||
export const fileTagsBulk = (fileIds: number[]) =>
|
||||
invoke<FileTagLink[]>("file_tags_bulk", { fileIds });
|
||||
export const exportTags = (path?: string) =>
|
||||
invoke<string>("export_tags", { path: path ?? null });
|
||||
|
||||
@@ -188,6 +291,28 @@ export async function tagSnapshot(tagId: number, opts?: SnapOpts): Promise<Snaps
|
||||
return new Snapshot(await invoke<ArrayBuffer>("tag_snapshot", { tagId, ...snapArgs(opts) }));
|
||||
}
|
||||
|
||||
// ── 삭제 대기열 ('삭제 예정' 태그 → 비우기) ─────────────
|
||||
|
||||
export interface PendingDelete {
|
||||
count: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/** 시스템 '삭제 예정' 태그 id (없으면 생성됨) */
|
||||
export const trashTagId = () => invoke<number>("trash_tag_id");
|
||||
export const markDelete = (fileIds: number[]) =>
|
||||
invoke<TagOpResult>("mark_delete", { fileIds });
|
||||
export const unmarkDelete = (fileIds: number[]) =>
|
||||
invoke<TagOpResult>("unmark_delete", { fileIds });
|
||||
export const pendingDeleteStats = () => invoke<PendingDelete>("pending_delete_stats");
|
||||
|
||||
/** 비우기 — 삭제 예정 파일을 디스크에서 영구 삭제한다. 복구 불가. */
|
||||
export function emptyDeleted(onProgress?: (p: OpProgress) => void): Promise<OpSummary> {
|
||||
const ch = new Channel<OpProgress>();
|
||||
ch.onmessage = onProgress ?? (() => {});
|
||||
return invoke<OpSummary>("empty_deleted", { onProgress: ch });
|
||||
}
|
||||
|
||||
// ── 파일 작업 ───────────────────────────────────────────
|
||||
|
||||
export interface OpSummary {
|
||||
@@ -286,6 +411,9 @@ export interface DupeMember {
|
||||
fileId: number;
|
||||
name: string;
|
||||
dir: string;
|
||||
/** 이 파일이 있는 폴더 — 검토 중 그 위치로 바로 이동하는 데 쓴다 */
|
||||
folderId: number;
|
||||
sourceId: number;
|
||||
size: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
@@ -293,11 +421,22 @@ export interface DupeMember {
|
||||
kind: number;
|
||||
isKeeper: boolean;
|
||||
distance: number | null;
|
||||
/**
|
||||
* `keeper 시각 - 이 멤버 시각`(ms). 비교 재생이 두 영상을 같은 내용에 맞추는 값이다:
|
||||
* `member.currentTime = keeper.currentTime - offsetMs/1000`.
|
||||
* null이면 정렬을 확인하지 못한 멤버(전이 병합) — 맞춤 없이 0초부터 나란히 돈다.
|
||||
*/
|
||||
offsetMs: number | null;
|
||||
/** keeper 시간축에서 일치가 확인된 구간 */
|
||||
segStartMs: number | null;
|
||||
segEndMs: number | null;
|
||||
}
|
||||
|
||||
export interface DupeGroup {
|
||||
id: number;
|
||||
kind: number; // 0 exact, 1 image, 2 video
|
||||
/** 유사도 0~1 (완전 동일은 1.0). 등급(확실/의심)을 여기서 파생한다. */
|
||||
score: number | null;
|
||||
members: DupeMember[];
|
||||
}
|
||||
|
||||
@@ -308,6 +447,8 @@ export function startDedupe(
|
||||
recursive?: boolean;
|
||||
imageThreshold?: number;
|
||||
includeVideo?: boolean;
|
||||
/** 회전·반전본까지 찾는다 — 후보 생성이 몇 배 비싸져 기본은 꺼둔다 */
|
||||
includeRotated?: boolean;
|
||||
},
|
||||
onProgress: (p: DedupeProgress) => void,
|
||||
): Promise<void> {
|
||||
@@ -319,14 +460,28 @@ export function startDedupe(
|
||||
recursive: args.recursive ?? null,
|
||||
imageThreshold: args.imageThreshold ?? 5,
|
||||
includeVideo: args.includeVideo ?? false,
|
||||
includeRotated: args.includeRotated ?? false,
|
||||
onProgress: ch,
|
||||
});
|
||||
}
|
||||
|
||||
/** 지문 생성 속도 — 백필 스레드가 배치마다 다시 읽으므로 재시작 없이 반영된다. */
|
||||
export type SigSpeed = "slow" | "normal" | "fast";
|
||||
export const sigSpeed = () => invoke<SigSpeed>("sig_speed");
|
||||
export const setSigSpeed = (speed: SigSpeed) => invoke<void>("set_sig_speed", { speed });
|
||||
|
||||
export const cancelDedupe = () => invoke<void>("cancel_dedupe");
|
||||
export const listDupeGroups = () => invoke<DupeGroup[]>("list_dupe_groups");
|
||||
export const resolveDupeGroup = (groupId: number) =>
|
||||
invoke<void>("resolve_dupe_group", { groupId });
|
||||
/**
|
||||
* 그룹에서 보관할 파일을 지정한다 — 삭제 대상이 바뀌므로 DB에 남는다.
|
||||
*
|
||||
* `offsetMs`·`segStartMs`·`segEndMs`는 보관 항목 시간축 기준 값이라 기준이 바뀌면 함께 옮겨진다.
|
||||
* 그 계산은 백엔드에만 있고, 여기서는 **옮겨진 결과를 그대로 받는다**.
|
||||
*/
|
||||
export const setDupeKeeper = (groupId: number, fileId: number) =>
|
||||
invoke<DupeMember[]>("set_dupe_keeper", { groupId, fileId });
|
||||
export const dismissDupePair = (fileA: number, fileB: number) =>
|
||||
invoke<void>("dismiss_dupe_pair", { fileA, fileB });
|
||||
|
||||
@@ -425,6 +580,19 @@ export function decidePlayback(info: PlaybackInfo): PlaybackPlan {
|
||||
export const onThumbsReady = (cb: (ids: number[]) => void): Promise<UnlistenFn> =>
|
||||
listen<number[]>("thumbs-ready", (e) => cb(e.payload));
|
||||
|
||||
/**
|
||||
* 대량 삭제 안전장치가 발동했다 — 한 스캔이 소스의 절반 이상을 지우려 해서 보류했다.
|
||||
* payload = 지우지 않은 건수.
|
||||
*/
|
||||
export interface ScanGuarded {
|
||||
sourceId: number;
|
||||
/** 지우지 않은 건수 */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const onScanGuarded = (cb: (g: ScanGuarded) => void): Promise<UnlistenFn> =>
|
||||
listen<ScanGuarded>("scan-guarded", (e) => cb(e.payload));
|
||||
|
||||
export const onLibraryChanged = (cb: (sourceId: number) => void): Promise<UnlistenFn> =>
|
||||
listen<number>("library-changed", (e) => cb(e.payload));
|
||||
|
||||
@@ -437,3 +605,44 @@ export function thumbUrl(info: AppInfo, fileId: number, sizeClass: 0 | 1 = 0): s
|
||||
export function mediaUrl(info: AppInfo, fileId: number): string {
|
||||
return `http://127.0.0.1:${info.mediaPort}/media/${info.mediaToken}/${fileId}`;
|
||||
}
|
||||
|
||||
// ── 간단 편집 (회전·반전) ─────────────────────────────────
|
||||
|
||||
/** 원본을 직접 고친다. 사진(JPEG)·영상 회전은 무손실, 영상 반전만 재인코딩. */
|
||||
export type EditOp = "rotateCw" | "rotateCcw" | "rotate180" | "flipH" | "flipV";
|
||||
|
||||
export interface EditSummary {
|
||||
batchId: string;
|
||||
done: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
/** 재인코딩된 파일 수 — 0이면 화질 손실이 전혀 없었다 */
|
||||
reencoded: number;
|
||||
}
|
||||
|
||||
export function editFiles(
|
||||
fileIds: number[],
|
||||
op: EditOp,
|
||||
onProgress?: (p: OpProgress) => void,
|
||||
): Promise<EditSummary> {
|
||||
const ch = new Channel<OpProgress>();
|
||||
if (onProgress) ch.onmessage = onProgress;
|
||||
return invoke<EditSummary>("edit_files", { fileIds, op, onProgress: ch });
|
||||
}
|
||||
|
||||
/** 되돌릴 수 없는 편집 — 원본은 백업으로 옮겨지고 Ctrl+Z로 돌아온다. */
|
||||
export type Cut =
|
||||
| { kind: "crop"; x: number; y: number; w: number; h: number }
|
||||
| { kind: "trim"; startMs: number; endMs: number };
|
||||
|
||||
export const cutFile = (fileId: number, cut: Cut) =>
|
||||
invoke<EditSummary>("cut_file", { fileId, cut });
|
||||
|
||||
/** 구간 자르기 타임라인에 깔 프레임 (데이터 URL — 서버 라우트 없음) */
|
||||
export interface StripFrame {
|
||||
atMs: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const filmstrip = (fileId: number, count: number) =>
|
||||
invoke<StripFrame[]>("filmstrip", { fileId, count });
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
// 본문 카드 상단 경로 밴드 — 현재 위치를 보여주고 상위로 되돌아가는 유일한 경로다.
|
||||
// 폴더 세그먼트는 드롭 타깃이라 '상위로 되돌리는 이동'이 드래그로도 된다.
|
||||
// 세그먼트가 4개를 넘으면 [홈 › … › 뒤에서 2개]로 접는다 — 밴드는 절대 두 줄이 되지 않는다.
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconFolder,
|
||||
IconHome,
|
||||
IconSearch,
|
||||
IconSmart,
|
||||
IconTag,
|
||||
IconX,
|
||||
} from "../ui/icons";
|
||||
import {
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
Show,
|
||||
type Component,
|
||||
type JSX,
|
||||
} from "solid-js";
|
||||
import { ko } from "../i18n/ko";
|
||||
import { ancestry, sourceIdOf, sources } from "../state/folderIndex";
|
||||
import {
|
||||
exitSearch,
|
||||
focusItemView,
|
||||
selected,
|
||||
setView,
|
||||
sidebarVersion,
|
||||
snapshot,
|
||||
toast,
|
||||
view,
|
||||
} from "../state/store";
|
||||
import { listSmartFolders, listTags } from "../ipc/commands";
|
||||
import { performMove } from "../features/fileops/MoveDialog";
|
||||
|
||||
interface Seg {
|
||||
icon?: JSX.Element;
|
||||
label: string;
|
||||
/** 없으면 클릭 불가 = 현재 위치 */
|
||||
go?: () => void;
|
||||
/** 폴더 세그먼트만 드롭 타깃이 된다 */
|
||||
folderId?: number;
|
||||
}
|
||||
|
||||
/** seg가 없는 노드 = 접힘 표시(…) */
|
||||
interface Node {
|
||||
seg?: Seg;
|
||||
gapTitle?: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 위치 크럼(클릭 불가).
|
||||
* `shrink`는 `.crumb.is-current`의 `flex:none`을 의도적으로 푼다 —
|
||||
* Tailwind 유틸리티는 @layer utilities라 @layer components를 이긴다.
|
||||
* 폴더명이나 검색어가 길면 flex:none인 현재 크럼이 `.crumb-tail`(항목수·선택 칩)을
|
||||
* `overflow:hidden` 밖으로 밀어내 '선택됨' 표시가 통째로 사라진다 —
|
||||
* 스펙이 툴바에서 없앤 뒤로 선택 개수를 보여주는 곳은 여기뿐이다.
|
||||
* 잘린 이름은 title로 그대로 읽히니 이쪽이 손실이 작다.
|
||||
*/
|
||||
const Current: Component<{ label: string; children: JSX.Element }> = (p) => (
|
||||
<span class="crumb is-current min-w-0 shrink" title={p.label}>
|
||||
{p.children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const Breadcrumb: Component = () => {
|
||||
// 태그·스마트 폴더는 뷰에 id만 들어 있어 이름을 여기서 직접 조회한다.
|
||||
const [tags] = createResource(() => sidebarVersion(), listTags);
|
||||
const [smarts] = createResource(() => sidebarVersion(), listSmartFolders);
|
||||
const [dropId, setDropId] = createSignal<number | null>(null);
|
||||
|
||||
const searchView = () => {
|
||||
const v = view();
|
||||
return v.type === "search" ? v : undefined;
|
||||
};
|
||||
|
||||
const segs = createMemo<Seg[]>(() => {
|
||||
const v = view();
|
||||
const srcName = (id: number) => sources().find((s) => s.id === id)?.name ?? "…";
|
||||
const home: Seg = {
|
||||
icon: <IconHome size={13} />,
|
||||
label: ko.sidebar.allItems,
|
||||
go: () => {
|
||||
void setView({ type: "all" });
|
||||
focusItemView();
|
||||
},
|
||||
};
|
||||
|
||||
switch (v.type) {
|
||||
case "all":
|
||||
return [{ ...home, go: undefined }];
|
||||
case "source":
|
||||
return [home, { icon: <IconFolder size={13} />, label: srcName(v.sourceId) }];
|
||||
case "folder": {
|
||||
const srcId = v.sourceId ?? sourceIdOf(v.folderId);
|
||||
const out: Seg[] = [home];
|
||||
if (srcId != null)
|
||||
out.push({
|
||||
icon: <IconFolder size={13} />,
|
||||
label: srcName(srcId),
|
||||
go: () => {
|
||||
void setView({ type: "source", sourceId: srcId });
|
||||
focusItemView();
|
||||
},
|
||||
});
|
||||
for (const n of ancestry(v.folderId, srcId))
|
||||
out.push({
|
||||
label: n.name,
|
||||
folderId: n.id,
|
||||
go: () => {
|
||||
void setView({ type: "folder", folderId: n.id, recursive: true, sourceId: srcId });
|
||||
focusItemView();
|
||||
},
|
||||
});
|
||||
return out;
|
||||
}
|
||||
case "tag":
|
||||
return [
|
||||
home,
|
||||
{
|
||||
icon: <IconTag size={13} />,
|
||||
label: tags.latest?.find((t) => t.id === v.tagId)?.name ?? "…",
|
||||
},
|
||||
];
|
||||
case "smart":
|
||||
return [
|
||||
home,
|
||||
{
|
||||
icon: <IconSmart size={13} />,
|
||||
label: smarts.latest?.find((s) => s.id === v.smartId)?.name ?? "…",
|
||||
},
|
||||
];
|
||||
case "search":
|
||||
return []; // 검색은 아래에서 따로 그린다
|
||||
}
|
||||
});
|
||||
|
||||
const nodes = createMemo<Node[]>(() => {
|
||||
const all = segs();
|
||||
const last = all.length - 1;
|
||||
const node = (i: number): Node => ({ seg: all[i], current: i === last });
|
||||
if (all.length <= 4) return all.map((_, i) => node(i));
|
||||
// 접어도 홈과 마지막 두 단계는 남긴다 — 되돌아갈 발판과 현재 위치는 항상 보인다
|
||||
return [
|
||||
node(0),
|
||||
{ gapTitle: all.map((s) => s.label).join(" › "), current: false },
|
||||
node(last - 1),
|
||||
node(last),
|
||||
];
|
||||
});
|
||||
|
||||
const inner = (seg: Seg) => (
|
||||
<>
|
||||
<Show when={seg.icon}>{(ic) => <span>{ic()}</span>}</Show>
|
||||
<span class="min-w-0 truncate">{seg.label}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
/** 한 세그먼트. 현재 위치는 클릭 불가, 폴더 세그먼트는 드롭 타깃이 된다. */
|
||||
const CrumbSeg: Component<{ seg: Seg; current: boolean }> = (p) => (
|
||||
<Show
|
||||
when={!p.current && p.seg.go}
|
||||
fallback={<Current label={p.seg.label}>{inner(p.seg)}</Current>}
|
||||
>
|
||||
<button
|
||||
class="crumb"
|
||||
classList={{ "is-drop": p.seg.folderId != null && dropId() === p.seg.folderId }}
|
||||
tabindex={-1}
|
||||
title={p.seg.label}
|
||||
onClick={() => p.seg.go!()}
|
||||
onDragOver={(e) => {
|
||||
const fid = p.seg.folderId;
|
||||
if (fid == null || !e.dataTransfer?.types.includes("application/x-archive-move")) return;
|
||||
e.preventDefault();
|
||||
setDropId(fid);
|
||||
}}
|
||||
onDragLeave={() => setDropId(null)}
|
||||
onDrop={(e) => {
|
||||
const fid = p.seg.folderId;
|
||||
if (fid == null) return;
|
||||
e.preventDefault();
|
||||
setDropId(null);
|
||||
void performMove(fid).catch((err) => toast(String(err)));
|
||||
}}
|
||||
>
|
||||
{inner(p.seg)}
|
||||
</button>
|
||||
</Show>
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="crumb-bar">
|
||||
<Show
|
||||
when={searchView()}
|
||||
fallback={
|
||||
<For each={nodes()}>
|
||||
{(n, i) => (
|
||||
<>
|
||||
<Show when={i() > 0}>
|
||||
<span class="crumb-sep"><IconChevronRight size={12} /></span>
|
||||
</Show>
|
||||
<Show
|
||||
when={n.seg}
|
||||
fallback={
|
||||
<span class="crumb-sep" title={n.gapTitle}>
|
||||
…
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(seg) => <CrumbSeg seg={seg()} current={n.current} />}
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
{(sv) => (
|
||||
<>
|
||||
<Current label={sv().query}>
|
||||
{/* 아이콘은 icons.tsx(lucide) 경유만 — 이모지는 폰트마다 다르게 그려지고
|
||||
currentColor를 따르지 않아 두 테마에서 색이 어긋난다 */}
|
||||
<IconSearch size={13} />
|
||||
<span class="min-w-0 truncate">{ko.crumb.searchResult(sv().query)}</span>
|
||||
</Current>
|
||||
<span class="crumb-sep">·</span>
|
||||
<span class="crumb-sep">{ko.crumb.searchScope}</span>
|
||||
<button
|
||||
class="icon-btn"
|
||||
tabindex={-1}
|
||||
title={ko.crumb.exitSearch}
|
||||
aria-label={ko.crumb.exitSearch}
|
||||
onClick={() => {
|
||||
void exitSearch();
|
||||
focusItemView();
|
||||
}}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<div class="crumb-tail">
|
||||
<span>{ko.grid.items(snapshot()?.count ?? 0)}</span>
|
||||
<Show when={selected().size > 0}>
|
||||
<span class="chip chip--accent chip--xs">
|
||||
<span>{ko.grid.selected(selected().size)}</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+322
-99
@@ -1,45 +1,83 @@
|
||||
import { createResource, createSignal, For, onCleanup, Show, type Component } from "solid-js";
|
||||
import {
|
||||
createEffect,
|
||||
createResource,
|
||||
For,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
type Component,
|
||||
type JSX,
|
||||
} from "solid-js";
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
IconGrid,
|
||||
IconInfo,
|
||||
IconList,
|
||||
IconMoon,
|
||||
IconPanelRight,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconSun,
|
||||
IconZoom,
|
||||
} from "../ui/icons";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { ko } from "../i18n/ko";
|
||||
import { JustifiedGrid } from "../features/grid/JustifiedGrid";
|
||||
import { ListView } from "../features/browse/ListView";
|
||||
import { FolderChips } from "../features/browse/FolderChips";
|
||||
import { Breadcrumb } from "./Breadcrumb";
|
||||
import {
|
||||
GRID_ROW_MAX,
|
||||
GRID_ROW_MIN,
|
||||
clearScanProgress,
|
||||
detailsOpen,
|
||||
focusItemView,
|
||||
gridRowHeight,
|
||||
kindFilter,
|
||||
refreshSidebar,
|
||||
refreshSnapshot,
|
||||
runSearch,
|
||||
searchQuery,
|
||||
setGridRowHeight,
|
||||
setKindFilter,
|
||||
setMinRating,
|
||||
minRating,
|
||||
setSort,
|
||||
setView,
|
||||
setViewMode,
|
||||
showInfo,
|
||||
sidebarVersion,
|
||||
snapshot,
|
||||
sortDesc,
|
||||
sortKey,
|
||||
theme,
|
||||
toast,
|
||||
toggleDetails,
|
||||
toggleShowInfo,
|
||||
toggleTheme,
|
||||
updateScanProgress,
|
||||
view,
|
||||
viewMode,
|
||||
type KindFilter,
|
||||
type SortKey,
|
||||
type ViewMode,
|
||||
} from "../state/store";
|
||||
import { addLocalSource, listSources, startScan } from "../ipc/commands";
|
||||
import { addLocalSource, listSources, reloadSource, rescanAll, startScan } from "../ipc/commands";
|
||||
import { openRemoteDialog } from "../features/sources/RemoteDialog";
|
||||
|
||||
export const ContentArea: Component = () => {
|
||||
const [q, setQ] = createSignal("");
|
||||
// 검색어는 store가 소유한다 — 폴더로 이동하면 자동으로 비워지고,
|
||||
// ✕로 검색을 끝내면 검색 직전 위치로 돌아간다.
|
||||
let debounce: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const onInput = (value: string) => {
|
||||
setQ(value);
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(() => {
|
||||
const query = value.trim();
|
||||
if (query.length > 0) void setView({ type: "search", query });
|
||||
else if (view().type === "search") void setView({ type: "all" });
|
||||
}, 250);
|
||||
debounce = setTimeout(() => void runSearch(value), 250);
|
||||
};
|
||||
onCleanup(() => clearTimeout(debounce));
|
||||
// 타이핑 직후(250ms 안에) 폴더를 클릭하면 대기 중이던 타이머가 뒤늦게 터져
|
||||
// 방금 연 폴더를 검색 결과로 되돌린다. 뷰가 바뀌면 대기 중인 검색을 버린다.
|
||||
// (자기 자신이 만든 검색 뷰 전환에서는 이미 실행된 타이머라 clearTimeout이 무해하다)
|
||||
createEffect(on(view, () => clearTimeout(debounce)));
|
||||
|
||||
// 소스가 하나도 없으면 온보딩 표시. 소스 추가/제거(sidebarVersion) 시 재조회해
|
||||
// 첫 소스를 추가하면 재시작 없이 즉시 그리드로 전환된다.
|
||||
@@ -49,39 +87,93 @@ export const ContentArea: Component = () => {
|
||||
);
|
||||
const isEmpty = () => sources() && sources()!.length === 0;
|
||||
|
||||
// 수동 새로고침 — 디스크를 다시 읽는 증분 재스캔.
|
||||
// 완료 갱신은 백엔드의 library-changed 이벤트가 담당한다.
|
||||
const doRescan = async () => {
|
||||
try {
|
||||
await rescanAll();
|
||||
toast(ko.refresh.started);
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
await refreshSnapshot();
|
||||
};
|
||||
|
||||
// F5 — 입력 중이 아닐 때만
|
||||
onMount(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "F5") return;
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
e.preventDefault();
|
||||
void doRescan();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
onCleanup(() => window.removeEventListener("keydown", onKey));
|
||||
});
|
||||
|
||||
const addLocal = async () => {
|
||||
const dir = await open({ directory: true, multiple: false, title: ko.onboarding.addLocal });
|
||||
if (typeof dir !== "string") return;
|
||||
const src = await addLocalSource(dir);
|
||||
refreshSidebar();
|
||||
await startScan(src.id, updateScanProgress);
|
||||
try {
|
||||
const r = await addLocalSource(dir);
|
||||
refreshSidebar();
|
||||
// 이미 등록된 폴더면 다시 불러오기만 — 같은 폴더가 두 번 매핑되지 않게 한다
|
||||
if (r.existing) {
|
||||
toast(ko.source.alreadyMapped(r.source.name));
|
||||
await reloadSource(r.source.id);
|
||||
} else {
|
||||
await startScan(r.source.id, updateScanProgress);
|
||||
}
|
||||
} catch (e) {
|
||||
// 사이드바 onAddSource와 같은 이유 — startScan이 던지면 done 진행이 오지 않아
|
||||
// 마지막 값이 그대로 남는다. 이제 작업 존이 스크롤 밖 고정 영역이라 그 유령 행은
|
||||
// 스크롤로도 사라지지 않고 영구히 떠 있는다. 온보딩이 첫 소스를 추가하는 경로다.
|
||||
clearScanProgress();
|
||||
toast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
// min-w-0 — 없으면 그리드의 1fr 열이 툴바 내용 최소폭 아래로 줄지 못해
|
||||
// 창이 좁을 때 오른쪽 상세 패널을 화면 밖으로 밀어낸다.
|
||||
// content-col — 툴바 압축(@container toolbar)의 기준 컨테이너.
|
||||
return (
|
||||
<main class="flex min-h-0 flex-col bg-[var(--bg-app)]">
|
||||
<div class="flex h-11 shrink-0 items-center gap-3 border-b border-[var(--border)] px-3">
|
||||
<input
|
||||
type="search"
|
||||
value={q()}
|
||||
onInput={(e) => onInput(e.currentTarget.value)}
|
||||
placeholder={ko.search.placeholder}
|
||||
class="h-7 w-64 rounded-md border border-[var(--border)] bg-[var(--bg-panel)] px-2.5 text-xs text-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--accent)]"
|
||||
/>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">
|
||||
{snapshot() ? ko.grid.items(snapshot()!.count) : ""}
|
||||
</span>
|
||||
<main class="content-col flex min-h-0 min-w-0 flex-col gap-2">
|
||||
{/* 툴바 — 카드가 아니라 페이지 배경 위. 어떤 폭에서도 한 줄을 유지한다:
|
||||
컨트롤은 flex:none이라 글자가 세로로 접히지 않고, 폭이 모자라면 검색창이
|
||||
먼저 줄고(min 120), 그래도 모자라면 컨테이너 쿼리가 라벨·줌을 접는다. */}
|
||||
<div class="flex h-[var(--h-toolbar)] shrink-0 items-center gap-2 overflow-x-auto overflow-y-hidden px-1 whitespace-nowrap">
|
||||
{/* 1. 검색 — 남는 폭을 전부 흡수한다 */}
|
||||
<div class="field-wrap" style={{ flex: "1", "min-width": "120px" }}>
|
||||
<span class="field-icon"><IconSearch size={14} /></span>
|
||||
<input
|
||||
type="search"
|
||||
class="field field--search field--block"
|
||||
value={searchQuery()}
|
||||
onInput={(e) => onInput(e.currentTarget.value)}
|
||||
placeholder={ko.search.placeholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 종류 필터: 전체 / 사진 / 영상 */}
|
||||
<div class="flex overflow-hidden rounded border border-[var(--border)] text-[11px]">
|
||||
<For each={[["all", ko.filter.all], ["image", ko.filter.image], ["video", ko.filter.video]] as [KindFilter, string][]}>
|
||||
{/* 2. 종류 필터: 전체 / 사진 / 영상 */}
|
||||
<div class="segmented">
|
||||
<For
|
||||
each={
|
||||
[
|
||||
["all", ko.filter.all],
|
||||
["image", ko.filter.image],
|
||||
["video", ko.filter.video],
|
||||
] as [KindFilter, string][]
|
||||
}
|
||||
>
|
||||
{([k, label]) => (
|
||||
<button
|
||||
class="px-2 py-0.5"
|
||||
classList={{
|
||||
"bg-[var(--bg-active)] text-[var(--text-primary)]": kindFilter() === k,
|
||||
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": kindFilter() !== k,
|
||||
classList={{ "is-on": kindFilter() === k }}
|
||||
tabindex={-1}
|
||||
onClick={() => {
|
||||
setKindFilter(k);
|
||||
focusItemView();
|
||||
}}
|
||||
onClick={() => setKindFilter(k)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@@ -89,79 +181,210 @@ export const ContentArea: Component = () => {
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* 정렬: 키 선택 + 방향 토글 */}
|
||||
<div class="flex items-center gap-1 text-[11px] text-[var(--text-secondary)]">
|
||||
<select
|
||||
class="rounded border border-[var(--border)] bg-[var(--bg-panel)] px-1.5 py-0.5 outline-none"
|
||||
value={sortKey()}
|
||||
onChange={(e) => setSort(e.currentTarget.value as SortKey, sortDesc())}
|
||||
{/* 3. 보기 방식: 격자 / 리스트 (V) */}
|
||||
<div class="segmented" title={ko.viewMode.title}>
|
||||
<For
|
||||
each={
|
||||
[
|
||||
["grid", <IconGrid size={14} />, ko.viewMode.grid],
|
||||
["list", <IconList size={14} />, ko.viewMode.list],
|
||||
] as [ViewMode, JSX.Element, string][]
|
||||
}
|
||||
>
|
||||
<For each={[["taken", ko.sort.taken], ["name", ko.sort.name], ["size", ko.sort.size], ["mtime", ko.sort.mtime]] as [SortKey, string][]}>
|
||||
{([k, label]) => <option value={k}>{label}</option>}
|
||||
</For>
|
||||
</select>
|
||||
<button
|
||||
class="rounded border border-[var(--border)] px-1.5 py-0.5 hover:bg-[var(--bg-hover)]"
|
||||
onClick={() => setSort(sortKey(), !sortDesc())}
|
||||
title={sortDesc() ? ko.sort.desc : ko.sort.asc}
|
||||
>
|
||||
{sortDesc() ? "↓" : "↑"}
|
||||
</button>
|
||||
{([m, icon, label]) => (
|
||||
<button
|
||||
classList={{ "is-on": viewMode() === m }}
|
||||
tabindex={-1}
|
||||
aria-label={label}
|
||||
onClick={() => {
|
||||
setViewMode(m);
|
||||
focusItemView();
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span class="seg-label">{label}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* 썸네일 정보 오버레이 토글 */}
|
||||
<button
|
||||
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[11px]"
|
||||
classList={{
|
||||
"bg-[var(--bg-active)] text-[var(--text-primary)]": showInfo(),
|
||||
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": !showInfo(),
|
||||
{/* 3b. 별점 하한 — '이상'이다. 정확히 N점만 보는 화면은 쓸 일이 드물고,
|
||||
"3점 넘는 것만 추려서 보기"가 실제 작업이다. */}
|
||||
<select
|
||||
class="field field--sort"
|
||||
title={ko.rating.label}
|
||||
aria-label={ko.rating.label}
|
||||
value={String(minRating())}
|
||||
onChange={(e) => {
|
||||
setMinRating(Number(e.currentTarget.value));
|
||||
focusItemView();
|
||||
}}
|
||||
onClick={toggleShowInfo}
|
||||
title={ko.grid.infoToggle}
|
||||
>
|
||||
ⓘ
|
||||
<option value="0">{`★ ${ko.rating.filterAll}`}</option>
|
||||
<For each={[1, 2, 3, 4, 5]}>
|
||||
{(n) => <option value={String(n)}>{ko.rating.filterAtLeast(n)}</option>}
|
||||
</For>
|
||||
</select>
|
||||
|
||||
{/* 4. 정렬: 키 선택 + 방향 토글 */}
|
||||
<select
|
||||
class="field field--sort"
|
||||
title={ko.sort.label}
|
||||
aria-label={ko.sort.label}
|
||||
value={sortKey()}
|
||||
onChange={(e) => setSort(e.currentTarget.value as SortKey, sortDesc())}
|
||||
>
|
||||
<For
|
||||
each={
|
||||
[
|
||||
["taken", ko.sort.taken],
|
||||
["name", ko.sort.name],
|
||||
["size", ko.sort.size],
|
||||
["mtime", ko.sort.mtime],
|
||||
["rating", ko.rating.sort],
|
||||
] as [SortKey, string][]
|
||||
}
|
||||
>
|
||||
{([k, label]) => <option value={k}>{label}</option>}
|
||||
</For>
|
||||
</select>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--icon"
|
||||
tabindex={-1}
|
||||
title={sortDesc() ? ko.sort.desc : ko.sort.asc}
|
||||
aria-label={sortDesc() ? ko.sort.desc : ko.sort.asc}
|
||||
onClick={() => {
|
||||
setSort(sortKey(), !sortDesc());
|
||||
focusItemView();
|
||||
}}
|
||||
>
|
||||
{sortDesc() ? <IconArrowDown size={13} /> : <IconArrowUp size={13} />}
|
||||
</button>
|
||||
|
||||
{/* 그리드 줌 슬라이더 */}
|
||||
<div class="ml-auto flex items-center gap-1.5 text-[var(--text-muted)]" title="썸네일 크기 (Ctrl+휠)">
|
||||
<span class="text-xs">🔍</span>
|
||||
<input
|
||||
type="range"
|
||||
class="h-1 w-28 accent-[var(--accent)]"
|
||||
min={GRID_ROW_MIN}
|
||||
max={GRID_ROW_MAX}
|
||||
step={5}
|
||||
value={gridRowHeight()}
|
||||
onInput={(e) => setGridRowHeight(Number(e.currentTarget.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={isEmpty()}
|
||||
fallback={<JustifiedGrid />}
|
||||
>
|
||||
<div class="grid flex-1 place-items-center">
|
||||
<div class="flex max-w-sm flex-col items-center gap-3 text-center">
|
||||
<div class="text-lg font-semibold text-[var(--text-primary)]">{ko.onboarding.title}</div>
|
||||
<div class="text-sm text-[var(--text-secondary)]">{ko.onboarding.subtitle}</div>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button
|
||||
class="rounded-md bg-[var(--accent)] px-4 py-2 text-xs text-white"
|
||||
onClick={() => void addLocal()}
|
||||
>
|
||||
{ko.onboarding.addLocal}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={openRemoteDialog}
|
||||
>
|
||||
{ko.onboarding.addRemote}
|
||||
</button>
|
||||
{/* 5. 수동 새로고침 — 워처가 변경 알림을 못 받는 SMB/NAS 대비 */}
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--icon"
|
||||
tabindex={-1}
|
||||
title={ko.refresh.title}
|
||||
aria-label={ko.refresh.action}
|
||||
onClick={() => {
|
||||
void doRescan();
|
||||
focusItemView();
|
||||
}}
|
||||
>
|
||||
<IconRefresh size={14} />
|
||||
</button>
|
||||
|
||||
{/* 6. 썸네일 정보 오버레이 토글 — 리스트 보기는 열로 이미 보여준다 */}
|
||||
<Show when={viewMode() === "grid"}>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--icon"
|
||||
classList={{ "is-on": showInfo() }}
|
||||
tabindex={-1}
|
||||
title={ko.grid.infoToggle}
|
||||
aria-label={ko.grid.infoToggle}
|
||||
onClick={() => {
|
||||
toggleShowInfo();
|
||||
focusItemView();
|
||||
}}
|
||||
>
|
||||
<IconInfo size={14} />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
{/* 7. 그리드 줌 (격자 전용) — 좁아지면 숨는다. Ctrl+휠은 그대로 살아 있다.
|
||||
.toolbar-zoom 엘리먼트에 display 유틸리티를 붙이면 안 된다:
|
||||
Tailwind 유틸리티는 @layer utilities라 @layer components의
|
||||
`@container … { .toolbar-zoom { display: none } }`를 항상 이겨서
|
||||
압축 규칙이 통째로 죽는다. flex는 안쪽 래퍼가 맡는다. */}
|
||||
<Show when={viewMode() === "grid"}>
|
||||
<div class="toolbar-zoom shrink-0" title={ko.grid.zoomTitle}>
|
||||
<div class="flex items-center gap-1.5 text-[var(--text-muted)]">
|
||||
<IconZoom size={13} />
|
||||
<input
|
||||
type="range"
|
||||
class="h-1 w-24 accent-[var(--accent)]"
|
||||
min={GRID_ROW_MIN}
|
||||
max={GRID_ROW_MAX}
|
||||
step={5}
|
||||
value={gridRowHeight()}
|
||||
aria-label={ko.grid.zoomTitle}
|
||||
onInput={(e) => setGridRowHeight(Number(e.currentTarget.value))}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-[var(--text-muted)]">{ko.onboarding.hint}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* 8. 테마 전환 — 아이콘은 '전환될 상태'를 보여준다.
|
||||
앱 내 유일한 진입점이라 어떤 폭에서도 숨기지 않는다. */}
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--icon"
|
||||
tabindex={-1}
|
||||
aria-label={ko.theme.label}
|
||||
title={theme() === "dark" ? ko.theme.toLight : ko.theme.toDark}
|
||||
onClick={() => {
|
||||
toggleTheme();
|
||||
focusItemView();
|
||||
}}
|
||||
>
|
||||
{theme() === "dark" ? <IconSun size={14} /> : <IconMoon size={14} />}
|
||||
</button>
|
||||
|
||||
{/* 9. 상세 패널 토글 — 자신이 여닫는 패널과 물리적으로 인접해야 하므로 최우측 */}
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--icon"
|
||||
classList={{ "is-on": detailsOpen() }}
|
||||
tabindex={-1}
|
||||
title={ko.details.toggle}
|
||||
aria-label={ko.details.toggle}
|
||||
onClick={() => {
|
||||
toggleDetails();
|
||||
focusItemView();
|
||||
}}
|
||||
>
|
||||
<IconPanelRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="surface-card surface-card--fill flex-1">
|
||||
<Show when={!isEmpty()}>
|
||||
<Breadcrumb />
|
||||
<FolderChips />
|
||||
</Show>
|
||||
<Show
|
||||
when={isEmpty()}
|
||||
fallback={
|
||||
<Show when={viewMode() === "list"} fallback={<JustifiedGrid />}>
|
||||
<ListView />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="grid min-h-0 flex-1 place-items-center">
|
||||
<div class="flex max-w-sm flex-col items-center gap-3 text-center">
|
||||
<div class="text-[var(--fs-xl)] leading-[var(--lh-xl)] font-semibold text-[var(--text-primary)]">
|
||||
{ko.onboarding.title}
|
||||
</div>
|
||||
<div class="text-[var(--fs-md)] text-[var(--text-secondary)]">
|
||||
{ko.onboarding.subtitle}
|
||||
</div>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--primary toolbar-btn--lg"
|
||||
onClick={() => void addLocal()}
|
||||
>
|
||||
{ko.onboarding.addLocal}
|
||||
</button>
|
||||
<button class="toolbar-btn toolbar-btn--lg" onClick={openRemoteDialog}>
|
||||
{ko.onboarding.addRemote}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1 text-[var(--fs-xs)] text-[var(--text-muted)]">
|
||||
{ko.onboarding.hint}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
+622
-66
@@ -1,15 +1,54 @@
|
||||
import { createResource, createSignal, For, Show, type Component } from "solid-js";
|
||||
// 상세 패널 (300px) — 헤더(썸네일·이름·닫기) → 밑줄 탭(정보/태그) → 스크롤 본문.
|
||||
// 색은 예외 없이 var() 경유 — 다크·라이트 두 팔레트에서 동시에 성립해야 한다.
|
||||
// 헤더 썸네일 슬롯은 '사진 면'이 아니라 24px 크롬 슬롯이라 --bg-panel-raised를 쓴다
|
||||
// (--grid-cell-bg는 테마 무관 고정 다크라 흰 카드 안에 검은 사각형이 박힌다).
|
||||
import { IconCalendar, IconClock, IconFile, IconFolder, IconImage, IconLayers, IconTag, IconX } from "../ui/icons";
|
||||
import {
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import { ko } from "../i18n/ko";
|
||||
import {
|
||||
applyTagSet,
|
||||
assignTags,
|
||||
createTag,
|
||||
createTagSet,
|
||||
deleteTagSet,
|
||||
fileDetails,
|
||||
fileTagsOf,
|
||||
listTags,
|
||||
listTagSets,
|
||||
setRating,
|
||||
thumbUrl,
|
||||
unassignTags,
|
||||
updateTagSet,
|
||||
type FileDetails,
|
||||
type Tag,
|
||||
type TagSet,
|
||||
} from "../ipc/commands";
|
||||
import { refreshSidebar, selected, toast, view } from "../state/store";
|
||||
import { StarRating } from "../ui/StarRating";
|
||||
import { performMarkDelete, performTrash } from "../features/fileops/ContextMenu";
|
||||
import { openMoveDialog } from "../features/fileops/MoveDialog";
|
||||
import {
|
||||
bumpTagVersion,
|
||||
detailsTab,
|
||||
focusItemView,
|
||||
info,
|
||||
refreshSidebar,
|
||||
refreshSnapshot,
|
||||
selected,
|
||||
setDetailsOpen,
|
||||
setDetailsTab,
|
||||
thumbVersions,
|
||||
toast,
|
||||
} from "../state/store";
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(2)} GB`;
|
||||
@@ -18,20 +57,268 @@ function fmtSize(bytes: number): string {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function fmtDate(ms: number | null): string {
|
||||
/** ListView와 같은 형식 — YYYY-MM-DD HH:mm */
|
||||
function fmtDate(ms: number | null | undefined): string {
|
||||
if (!ms) return "—";
|
||||
return new Date(ms).toLocaleString("ko-KR");
|
||||
const d = new Date(ms);
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const Row: Component<{ label: string; value: string }> = (props) => (
|
||||
<div class="px-3 py-1">
|
||||
<div class="text-[10px] text-[var(--text-muted)]">{props.label}</div>
|
||||
<div class="break-all text-xs text-[var(--text-primary)]" title={props.value}>
|
||||
{props.value}
|
||||
/** 0:42 / 1:03:20 — 현행 "212초" 표기 폐기 */
|
||||
function fmtDuration(ms: number | null | undefined): string {
|
||||
if (!ms) return "";
|
||||
const s = Math.round(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
return h > 0
|
||||
? `${h}:${String(m % 60).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`
|
||||
: `${m}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** 정보 타일 1칸 */
|
||||
const Tile: Component<{
|
||||
icon: JSX.Element;
|
||||
label: string;
|
||||
value: string;
|
||||
clamp?: boolean;
|
||||
title?: string;
|
||||
}> = (props) => (
|
||||
<div class="info-tile">
|
||||
<span class="info-tile__icon">{props.icon}</span>
|
||||
<div class="info-tile__body">
|
||||
<div class="info-tile__label">{props.label}</div>
|
||||
<div
|
||||
class="info-tile__value"
|
||||
classList={{ "info-tile__value--clamp": props.clamp }}
|
||||
title={props.title}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/** 정보 타일 2분할 — 300px 폭에서 깨지지 않는 상한이 2칸이다 */
|
||||
const SplitTile: Component<{
|
||||
icon: JSX.Element;
|
||||
labelA: string;
|
||||
valueA: string;
|
||||
labelB: string;
|
||||
valueB: string;
|
||||
}> = (props) => (
|
||||
<div class="info-tile info-tile--split">
|
||||
<span class="info-tile__icon">{props.icon}</span>
|
||||
<div class="info-tile__body">
|
||||
<div class="min-w-0">
|
||||
<div class="info-tile__label">{props.labelA}</div>
|
||||
<div class="info-tile__value">{props.valueA}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="info-tile__label">{props.labelB}</div>
|
||||
<div class="info-tile__value">{props.valueB}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/** 선택 없음 — 두 탭이 공유한다 */
|
||||
const EmptyState: Component = () => (
|
||||
<div class="flex h-[180px] flex-col items-center justify-center gap-2 px-6 text-center">
|
||||
<IconImage size={32} class="text-[var(--text-muted)] opacity-50" />
|
||||
<span class="text-[11px] leading-4 text-[var(--text-muted)]">{ko.details.noSelection}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 세트 만들기·고치기. 태그 편집기 위에 겹쳐 뜬다 — 모달 3종과 같은 언어(스크림 + 카드 + Esc).
|
||||
*
|
||||
* 단축키는 **눌러서 지정한다**. 문자열로 적게 하면 "Ctrl+1"과 "ctrl+1"과 "^1"이 섞이고,
|
||||
* 실제로 그 조합이 브라우저에서 어떤 key로 오는지 사용자가 알 수 없다.
|
||||
*/
|
||||
const TagSetEditor: Component<{
|
||||
target: TagSet | "new";
|
||||
allTags: Tag[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}> = (props) => {
|
||||
const existing = () => (props.target === "new" ? undefined : props.target);
|
||||
const [name, setName] = createSignal(existing()?.name ?? "");
|
||||
const [hotkey, setHotkey] = createSignal<string | null>(existing()?.hotkey ?? null);
|
||||
const [picked, setPicked] = createSignal<Set<number>>(
|
||||
new Set((existing()?.tags ?? []).map((t) => t.id)),
|
||||
);
|
||||
const [capturing, setCapturing] = createSignal(false);
|
||||
|
||||
const toggle = (id: number) =>
|
||||
setPicked((p) => {
|
||||
const n = new Set(p);
|
||||
if (n.has(id)) n.delete(id);
|
||||
else n.add(id);
|
||||
return n;
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
const n = name().trim();
|
||||
if (!n) return;
|
||||
try {
|
||||
const ex = existing();
|
||||
if (ex) {
|
||||
await updateTagSet(ex.id, { name: n, hotkey: hotkey(), tagIds: [...picked()] });
|
||||
} else {
|
||||
const id = await createTagSet(n, [...picked()]);
|
||||
if (hotkey()) await updateTagSet(id, { hotkey: hotkey() });
|
||||
}
|
||||
props.onSaved();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
const ex = existing();
|
||||
if (!ex) return;
|
||||
try {
|
||||
await deleteTagSet(ex.id);
|
||||
props.onSaved();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
// 키 캡처 — 수식키만 눌린 상태는 무시한다(Ctrl을 누르는 중에 확정되면 안 된다)
|
||||
const onCapture = (e: KeyboardEvent) => {
|
||||
if (!capturing()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") {
|
||||
setCapturing(false);
|
||||
return;
|
||||
}
|
||||
if (["Control", "Shift", "Alt", "Meta"].includes(e.key)) return;
|
||||
setHotkey(`${e.ctrlKey ? "Ctrl+" : ""}${e.shiftKey ? "Shift+" : ""}${e.key}`);
|
||||
setCapturing(false);
|
||||
};
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", onCapture, { capture: true });
|
||||
onCleanup(() => window.removeEventListener("keydown", onCapture, { capture: true }));
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="modal-scrim" onClick={props.onClose}>
|
||||
<div class="modal-card w-[420px]" onClick={(e) => e.stopPropagation()}>
|
||||
<div class="modal-head">
|
||||
<span class="modal-head__title">
|
||||
{existing() ? ko.tags.editSet : ko.tags.newSet}
|
||||
</span>
|
||||
<button class="icon-btn ml-auto" onClick={props.onClose} title={ko.common.close}>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="section-label">{ko.tags.setName}</div>
|
||||
<input
|
||||
class="field field--block mt-1.5"
|
||||
value={name()}
|
||||
placeholder={ko.tags.setNamePlaceholder}
|
||||
onInput={(e) => setName(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<div class="section-label mt-3">{ko.tags.setHotkey}</div>
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
classList={{ "is-on": capturing() }}
|
||||
onClick={() => setCapturing(true)}
|
||||
>
|
||||
{capturing() ? ko.tags.pressKey : (hotkey() ?? ko.tags.noHotkey)}
|
||||
</button>
|
||||
<Show when={hotkey()}>
|
||||
<button class="link-btn" onClick={() => setHotkey(null)}>
|
||||
{ko.tags.clearHotkey}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mt-1 text-[10px] leading-4 text-[var(--text-muted)]">
|
||||
{ko.tags.hotkeyNote}
|
||||
</div>
|
||||
|
||||
<div class="section-label mt-3">{ko.tags.setMembers(picked().size)}</div>
|
||||
<div class="tag-pick mt-1.5">
|
||||
<For each={props.allTags}>
|
||||
{(t) => (
|
||||
<button
|
||||
class="chip chip--button"
|
||||
classList={{ "is-on": picked().has(t.id) }}
|
||||
onClick={() => toggle(t.id)}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-bar">
|
||||
<Show when={existing()}>
|
||||
<button class="toolbar-btn toolbar-btn--danger-fill" onClick={() => void remove()}>
|
||||
{ko.common.delete}
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--primary ml-auto"
|
||||
disabled={!name().trim()}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{ko.common.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 별점 편집기 — 선택한 전부에 같은 값을 준다. 태그 편집기와 같은 자리(태그 탭 위)에 산다.
|
||||
*
|
||||
* 여러 항목의 값이 서로 다르면 채우지 않고 '여러 값'이라고 말한다. 아무 값이나 골라
|
||||
* 채워 보이면 사용자는 그게 이미 매겨진 점수라고 읽고, 건드리지 않은 항목까지 덮어쓴다.
|
||||
*/
|
||||
const RatingEditor: Component = () => {
|
||||
const [bump, setBump] = createSignal(0);
|
||||
const ids = () => [...selected()];
|
||||
const [details] = createResource(
|
||||
() => (ids().length > 0 ? `${ids().join(",")}:${bump()}` : undefined),
|
||||
async () => fileDetails(ids().slice(0, 500)),
|
||||
);
|
||||
const values = () => (details() ?? []).map((d) => d.rating);
|
||||
const mixed = () => new Set(values()).size > 1;
|
||||
const value = () => (mixed() ? 0 : (values()[0] ?? 0));
|
||||
|
||||
const apply = async (next: number) => {
|
||||
const list = ids();
|
||||
if (list.length === 0) return;
|
||||
try {
|
||||
const n = await setRating(list, next);
|
||||
toast(ko.rating.applied(n, next));
|
||||
setBump((v) => v + 1);
|
||||
// 그리드 오버레이의 별은 스냅샷에서 온다 — 다시 읽어야 반영된다
|
||||
await refreshSnapshot();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-2 px-3 pt-2.5">
|
||||
<span class="section-label !mb-0">{ko.rating.label}</span>
|
||||
<StarRating value={value()} mixed={mixed()} onChange={(n) => void apply(n)} />
|
||||
<Show when={mixed()}>
|
||||
<span class="text-[10px] text-[var(--text-muted)]">{ko.rating.mixed}</span>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 선택 항목 태그 편집기 (단일/다중 선택 공용) */
|
||||
const TagEditor: Component = () => {
|
||||
const [tagVersion, setTagVersion] = createSignal(0);
|
||||
@@ -59,6 +346,7 @@ const TagEditor: Component = () => {
|
||||
const r = await assignTags(ids, tagId);
|
||||
toast(ko.tags.assignTo(r.count, trimmed));
|
||||
setTagVersion((v) => v + 1);
|
||||
bumpTagVersion();
|
||||
refreshSidebar();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
@@ -70,47 +358,268 @@ const TagEditor: Component = () => {
|
||||
const r = await unassignTags(ids, tagId);
|
||||
toast(ko.tags.removedFrom(r.count, name));
|
||||
setTagVersion((v) => v + 1);
|
||||
bumpTagVersion();
|
||||
refreshSidebar();
|
||||
};
|
||||
|
||||
// ── 붙이기: 검색이 아니라 클릭 ──────────────────────────────
|
||||
// 예전에는 입력창에 매번 이름을 쳐야 했다. 태그는 몇 개~수십 개고 그중 같은 것을 반복해서
|
||||
// 붙이는 작업이라, 목록을 보여주고 누르게 하는 편이 훨씬 빠르다.
|
||||
// 입력창은 **새 태그를 만들 때와 목록이 길 때 좁히는 용도**로만 남긴다.
|
||||
const [filter, setFilter] = createSignal("");
|
||||
const attachedIds = () => new Set((currentTags() ?? []).map((t) => t.id));
|
||||
const pickable = createMemo(() => {
|
||||
const q = filter().trim().toLowerCase();
|
||||
const all = allTags() ?? [];
|
||||
return q ? all.filter((t) => t.name.toLowerCase().includes(q)) : all;
|
||||
});
|
||||
/** 입력한 이름이 기존에 없으면 '새로 만들기'를 내민다 */
|
||||
const canCreate = () => {
|
||||
const q = filter().trim();
|
||||
return q.length > 0 && !(allTags() ?? []).some((t) => t.name === q);
|
||||
};
|
||||
|
||||
const toggleTag = async (t: Tag) => {
|
||||
if (selected().size === 0) return;
|
||||
if (attachedIds().has(t.id)) await removeTag(t.id, t.name);
|
||||
else await addTag(t.name);
|
||||
};
|
||||
|
||||
// ── 세트 ────────────────────────────────────────────────────
|
||||
const [sets, { refetch: refetchSets }] = createResource(() => tagVersion(), listTagSets);
|
||||
const [setEditor, setSetEditor] = createSignal<TagSet | "new" | undefined>();
|
||||
|
||||
const runSet = async (s: TagSet) => {
|
||||
const ids = [...selected()];
|
||||
if (ids.length === 0 || s.tags.length === 0) return;
|
||||
try {
|
||||
const r = await applyTagSet(ids, s.id);
|
||||
toast(ko.tags.setApplied(s.name, r.count));
|
||||
setTagVersion((v) => v + 1);
|
||||
bumpTagVersion();
|
||||
refreshSidebar();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
// 세트 단축키 — 선택이 있고 입력창에 포커스가 없을 때만 듣는다.
|
||||
// (입력 중에 '1'을 눌렀다고 세트가 적용되면 안 된다)
|
||||
onMount(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.altKey || e.metaKey) return;
|
||||
const el = document.activeElement;
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return;
|
||||
if (selected().size === 0) return;
|
||||
const combo = `${e.ctrlKey ? "Ctrl+" : ""}${e.shiftKey ? "Shift+" : ""}${e.key}`;
|
||||
const hit = (sets() ?? []).find((s) => s.hotkey && s.hotkey === combo);
|
||||
if (!hit) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void runSet(hit);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
onCleanup(() => window.removeEventListener("keydown", onKey));
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="px-3 py-1">
|
||||
<div class="text-[10px] text-[var(--text-muted)]">{ko.details.tags}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<Show when={singleId() != null}>
|
||||
<div class="px-3 pt-2.5 pb-3">
|
||||
{/* 붙어 있는 태그 */}
|
||||
<div class="section-label">{ko.details.tags}</div>
|
||||
<div class="mt-1.5 flex flex-wrap gap-1.5">
|
||||
<Show
|
||||
when={(currentTags()?.length ?? 0) > 0}
|
||||
fallback={<span class="text-[11px] text-[var(--text-muted)]">{ko.tags.noneYet}</span>}
|
||||
>
|
||||
<For each={currentTags()}>
|
||||
{(t) => (
|
||||
<span class="flex items-center gap-1 rounded-full bg-[var(--accent-soft)] px-2 py-0.5 text-[11px] text-[var(--accent)]">
|
||||
{t.name}
|
||||
<button
|
||||
class="hover:text-[var(--danger)]"
|
||||
onClick={() => void removeTag(t.id, t.name)}
|
||||
>
|
||||
×
|
||||
<span class="chip chip--accent">
|
||||
<span>{t.name}</span>
|
||||
<button class="chip__x" onClick={() => void removeTag(t.id, t.name)}>
|
||||
<IconX size={11} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* 세트 — 한 번에 여러 개 */}
|
||||
<Show when={(sets()?.length ?? 0) > 0}>
|
||||
<div class="section-label mt-3">{ko.tags.sets}</div>
|
||||
<div class="mt-1.5 flex flex-wrap gap-1.5">
|
||||
<For each={sets()}>
|
||||
{(s) => (
|
||||
<button
|
||||
class="chip chip--button"
|
||||
disabled={selected().size === 0}
|
||||
title={ko.tags.setHint(s.tags.map((t) => t.name).join(", "))}
|
||||
onClick={() => void runSet(s)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setSetEditor(s);
|
||||
}}
|
||||
>
|
||||
{s.name}
|
||||
<Show when={s.hotkey}>
|
||||
<kbd class="chip__kbd">{s.hotkey}</kbd>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* 태그 고르기 — 전체 목록을 눌러서 붙인다 */}
|
||||
<div class="section-label mt-3 flex items-center justify-between">
|
||||
<span>{ko.tags.pick}</span>
|
||||
<button class="link-btn" onClick={() => setSetEditor("new")}>
|
||||
{ko.tags.newSet}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
class="mt-1.5 w-full rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1 text-[11px] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--accent)]"
|
||||
placeholder={ko.tags.add}
|
||||
list="all-tags-datalist"
|
||||
class="field field--block mt-1.5"
|
||||
placeholder={ko.tags.filterOrCreate}
|
||||
value={filter()}
|
||||
onInput={(e) => setFilter(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
void addTag(e.currentTarget.value);
|
||||
e.currentTarget.value = "";
|
||||
}
|
||||
if (e.key !== "Enter") return;
|
||||
void addTag(e.currentTarget.value);
|
||||
setFilter("");
|
||||
}}
|
||||
/>
|
||||
<datalist id="all-tags-datalist">
|
||||
<For each={allTags()}>{(t) => <option value={t.name} />}</For>
|
||||
</datalist>
|
||||
<div class="tag-pick mt-1.5">
|
||||
<Show when={canCreate()}>
|
||||
<button class="chip chip--button chip--new" onClick={() => void addTag(filter())}>
|
||||
+ {filter().trim()}
|
||||
</button>
|
||||
</Show>
|
||||
<For each={pickable()}>
|
||||
{(t) => (
|
||||
<button
|
||||
class="chip chip--button"
|
||||
classList={{ "is-on": attachedIds().has(t.id) }}
|
||||
disabled={selected().size === 0}
|
||||
onClick={() => void toggleTag(t)}
|
||||
title={ko.tags.countHint(t.fileCount)}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<Show when={(pickable().length ?? 0) === 0 && !canCreate()}>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">{ko.tags.noMatch}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={setEditor()}>
|
||||
{(target) => (
|
||||
<TagSetEditor
|
||||
target={target()}
|
||||
allTags={allTags() ?? []}
|
||||
onClose={() => setSetEditor(undefined)}
|
||||
onSaved={() => {
|
||||
setSetEditor(undefined);
|
||||
void refetchSets();
|
||||
setTagVersion((v) => v + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 정보 탭 — 단일 선택 */
|
||||
const InfoTiles: Component<{ d: FileDetails }> = (props) => (
|
||||
<>
|
||||
<div class="section-label mx-3 mt-2.5 mb-1.5">{ko.details.sectionMedia}</div>
|
||||
<div class="grid gap-1.5 px-3">
|
||||
<Show
|
||||
when={props.d.width && props.d.height}
|
||||
fallback={
|
||||
<Tile icon={<IconImage size={14} />} label={ko.details.fileSize} value={fmtSize(props.d.size)} />
|
||||
}
|
||||
>
|
||||
<SplitTile
|
||||
icon={<IconImage size={14} />}
|
||||
labelA={ko.details.dimensions}
|
||||
valueA={`${props.d.width} × ${props.d.height}`}
|
||||
labelB={ko.details.fileSize}
|
||||
valueB={fmtSize(props.d.size)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.d.durationMs}>
|
||||
<Tile icon={<IconClock size={14} />} label={ko.details.duration} value={fmtDuration(props.d.durationMs)} />
|
||||
</Show>
|
||||
<Show
|
||||
when={props.d.takenAt}
|
||||
fallback={
|
||||
<Tile icon={<IconCalendar size={14} />} label={ko.details.modifiedAt} value={fmtDate(props.d.mtimeMs)} />
|
||||
}
|
||||
>
|
||||
<SplitTile
|
||||
icon={<IconCalendar size={14} />}
|
||||
labelA={ko.details.takenAt}
|
||||
valueA={fmtDate(props.d.takenAt)}
|
||||
labelB={ko.details.modifiedAt}
|
||||
valueB={fmtDate(props.d.mtimeMs)}
|
||||
/>
|
||||
</Show>
|
||||
<Tile icon={<IconFolder size={14} />} label={ko.details.filePath} value={props.d.dir} clamp title={props.d.dir} />
|
||||
</div>
|
||||
|
||||
<div class="section-label mx-3 mt-2.5 mb-1.5">{ko.details.sectionBasic}</div>
|
||||
<div class="grid gap-1.5 px-3 pb-3">
|
||||
<Tile
|
||||
icon={<IconTag size={14} />}
|
||||
label={ko.details.kind}
|
||||
value={props.d.kind === 1 ? ko.filter.video : ko.filter.image}
|
||||
/>
|
||||
<Tile
|
||||
icon={<IconFile size={14} />}
|
||||
label={ko.details.fileName}
|
||||
value={props.d.name}
|
||||
clamp
|
||||
title={props.d.name}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
/** 정보 탭 — 다중 선택. 용량 합계·종류별 집계는 만들지 않는다(file_details 500 상한) */
|
||||
const BulkPanel: Component = () => (
|
||||
<div class="grid gap-1.5 px-3 py-3">
|
||||
<div class="info-tile">
|
||||
<span class="info-tile__icon"><IconLayers size={14} /></span>
|
||||
<div class="info-tile__body">
|
||||
<div class="info-tile__value info-tile__value--big tabular-nums">
|
||||
{selected().size.toLocaleString("ko-KR")}
|
||||
</div>
|
||||
<div class="info-tile__label">{ko.details.selectedUnit}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<button class="toolbar-btn" tabindex={-1} onClick={() => setDetailsTab("tags")}>
|
||||
{ko.details.bulkTag}
|
||||
</button>
|
||||
<button class="toolbar-btn" tabindex={-1} onClick={openMoveDialog}>
|
||||
{ko.ops.moveTo}
|
||||
</button>
|
||||
<button class="toolbar-btn" tabindex={-1} onClick={() => void performMarkDelete()}>
|
||||
{ko.trash.mark}
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn--danger"
|
||||
tabindex={-1}
|
||||
onClick={() => void performTrash()}
|
||||
>
|
||||
{ko.ops.trash}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const DetailsPanel: Component = () => {
|
||||
const singleId = () => {
|
||||
const s = selected();
|
||||
@@ -120,46 +629,93 @@ export const DetailsPanel: Component = () => {
|
||||
const list = await fileDetails([id]);
|
||||
return list[0] as FileDetails | undefined;
|
||||
});
|
||||
// 뷰 변경 시 details 리소스가 재평가되도록 view 추적
|
||||
void view;
|
||||
|
||||
// 썸네일이 없는 항목은 404가 난다 — 실패한 id만 기억해 두고 선택이 바뀌면 자동 해제된다.
|
||||
const [thumbErrId, setThumbErrId] = createSignal<number | undefined>();
|
||||
const thumbSrc = () => {
|
||||
const id = singleId();
|
||||
const i = info();
|
||||
if (id == null || !i || thumbErrId() === id) return undefined;
|
||||
return `${thumbUrl(i, id)}&v=${thumbVersions[id] ?? 0}`;
|
||||
};
|
||||
|
||||
const headTitle = () =>
|
||||
details()?.name ??
|
||||
(selected().size > 1 ? ko.grid.selected(selected().size) : ko.details.title);
|
||||
|
||||
return (
|
||||
<aside class="flex min-h-0 flex-col overflow-y-auto border-l border-[var(--border)] bg-[var(--bg-panel)]">
|
||||
<div class="px-3 pt-4 pb-1.5 text-[11px] font-semibold tracking-wide text-[var(--text-muted)]">
|
||||
{ko.details.title}
|
||||
</div>
|
||||
<Show
|
||||
when={details()}
|
||||
fallback={
|
||||
<div class="px-3 py-2 text-xs leading-5 text-[var(--text-muted)]">
|
||||
{selected().size > 1
|
||||
? ko.grid.selected(selected().size)
|
||||
: ko.details.noSelection}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(d) => (
|
||||
<>
|
||||
<Row label={ko.details.fileName} value={d().name} />
|
||||
<Row label={ko.details.filePath} value={d().dir} />
|
||||
<Row label={ko.details.fileSize} value={fmtSize(d().size)} />
|
||||
<Show when={d().width && d().height}>
|
||||
<Row label={ko.details.dimensions} value={`${d().width} × ${d().height}`} />
|
||||
</Show>
|
||||
<Show when={d().durationMs}>
|
||||
<Row
|
||||
label={ko.details.duration}
|
||||
value={`${Math.round((d().durationMs ?? 0) / 1000)}초`}
|
||||
<aside class="surface-card surface-card--fill">
|
||||
{/* 헤더 h40 */}
|
||||
<div class="flex h-10 flex-none items-center gap-2 border-b border-[var(--border)] px-3">
|
||||
<div class="h-6 w-6 flex-none overflow-hidden rounded-[var(--r-sm)] bg-[var(--bg-panel-raised)]">
|
||||
<Show when={thumbSrc()}>
|
||||
{(src) => (
|
||||
<img
|
||||
src={src()}
|
||||
class="h-full w-full object-cover"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onError={() => setThumbErrId(singleId())}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-[13px] font-semibold text-[var(--text-primary)]"
|
||||
title={headTitle()}
|
||||
>
|
||||
{headTitle()}
|
||||
</span>
|
||||
<button
|
||||
class="icon-btn"
|
||||
tabindex={-1}
|
||||
title={ko.details.close}
|
||||
onClick={() => {
|
||||
setDetailsOpen(false);
|
||||
focusItemView();
|
||||
}}
|
||||
>
|
||||
<IconX size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
classList={{ "is-on": detailsTab() === "info" }}
|
||||
tabindex={-1}
|
||||
onClick={() => setDetailsTab("info")}
|
||||
>
|
||||
{ko.details.tabInfo}
|
||||
</button>
|
||||
<button
|
||||
classList={{ "is-on": detailsTab() === "tags" }}
|
||||
tabindex={-1}
|
||||
onClick={() => setDetailsTab("tags")}
|
||||
>
|
||||
{ko.details.tabTags}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* min-h-0을 빠뜨리면 body{overflow:hidden} 때문에 스크롤바 없이 조용히 잘린다 */}
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<Show
|
||||
when={detailsTab() === "info"}
|
||||
fallback={
|
||||
<Show when={selected().size > 0} fallback={<EmptyState />}>
|
||||
{/* 별점이 태그 위에 온다 — 분류 작업에서 먼저 손이 가는 쪽이고,
|
||||
한 줄이라 태그 목록을 아래로 많이 밀지 않는다 */}
|
||||
<RatingEditor />
|
||||
<TagEditor />
|
||||
</Show>
|
||||
<Row label={ko.details.takenAt} value={fmtDate(d().takenAt)} />
|
||||
<Row label={ko.details.modifiedAt} value={fmtDate(d().mtimeMs)} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={selected().size > 0}>
|
||||
<TagEditor />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show when={selected().size > 0} fallback={<EmptyState />}>
|
||||
<Show when={singleId() != null} fallback={<BulkPanel />}>
|
||||
<Show when={details()}>{(d) => <InfoTiles d={d()} />}</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
// 키보드 단축키 도움말 오버레이 (? 키로 열기).
|
||||
import { For, onCleanup, onMount, Show, type Component } from "solid-js";
|
||||
import { IconX } from "../ui/icons";
|
||||
import { createResource, For, onCleanup, onMount, Show, type Component } from "solid-js";
|
||||
import { ko } from "../i18n/ko";
|
||||
import { listTagSets } from "../ipc/commands";
|
||||
import { closeShortcuts, shortcutsOpen } from "../state/store";
|
||||
|
||||
export const ShortcutsOverlay: Component = () => {
|
||||
// 열릴 때만 읽는다 — 세트는 자주 바뀌지 않고, 닫혀 있는 오버레이가 폴링할 이유가 없다
|
||||
const [setKeys] = createResource(
|
||||
() => (shortcutsOpen() ? 1 : undefined),
|
||||
async () => (await listTagSets()).filter((s) => s.hotkey),
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!shortcutsOpen()) return;
|
||||
@@ -36,7 +44,7 @@ export const ShortcutsOverlay: Component = () => {
|
||||
onClick={closeShortcuts}
|
||||
title={ko.shortcuts.close}
|
||||
>
|
||||
✕
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
@@ -50,6 +58,25 @@ export const ShortcutsOverlay: Component = () => {
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
{/* 사용자가 지정한 태그 세트 단축키 — 고정 목록에 적을 수 없으므로 여기서 합친다.
|
||||
여기에 안 보이면 "내가 뭘 지정했더라"를 확인할 곳이 없다. */}
|
||||
<Show when={(setKeys()?.length ?? 0) > 0}>
|
||||
<div class="mt-1.5 border-t border-[var(--border)] pt-1.5 text-[10px] text-[var(--text-muted)]">
|
||||
{ko.tags.sets}
|
||||
</div>
|
||||
<For each={setKeys()}>
|
||||
{(s) => (
|
||||
<div class="flex items-center justify-between gap-4 text-xs">
|
||||
<kbd class="shrink-0 rounded border border-[var(--border)] bg-[var(--bg-app)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--text-secondary)]">
|
||||
{s.hotkey}
|
||||
</kbd>
|
||||
<span class="text-right text-[var(--text-secondary)]">
|
||||
{s.name} — {s.tags.map((t) => t.name).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+846
-218
File diff suppressed because it is too large
Load Diff
+51
-64
@@ -1,78 +1,65 @@
|
||||
// 상태바 h26 — 페이지 배경 위(테두리 없음, 카드 아님).
|
||||
//
|
||||
// 역할 분담(사이드바 작업 존과의 계약). 요구는 '탐색·정리 중에 사이드바에서 진행을
|
||||
// 볼 수 있게'였고, 그 답은 같은 정보를 두 곳에 복사하는 것이 아니라 나누는 것이다.
|
||||
// · 사이드바 작업 존 = '지금 무엇이 얼마나' — 작업의 존재·수량·진행 막대·취소,
|
||||
// 그리고 중복 모달로 들어가는 진입점(openDedupe는 거기 두 곳뿐이다).
|
||||
// · 여기(footer) = '자세한 사정' — 토스트 한 줄, 지금 읽고 있는 폴더 경로, 데이터 위치.
|
||||
//
|
||||
// 그래서 여기서 사라진 것들:
|
||||
// · 중복 탐지 진행/취소 — 사이드바 작업 행이 소유한다. 진입점도 두지 않는다.
|
||||
// footer가 중복 탐지에 대해 말하는 것은 완료·취소 토스트 한 번뿐이다(store가 던진다).
|
||||
// · 썸네일·메타·지문 대기 수 — 사이드바 백필 행이 소유한다. 지문 대기 수가 사라지는
|
||||
// 것이 아니다: 그 행의 부제(ko.tasks.backfillDetail)가 '지문 N'까지 말한다.
|
||||
// 항목수·선택수는 브레드크럼이, 포터블/버전은 사이드바 통계 카드가 이미 가져갔다.
|
||||
import { Show, type Component } from "solid-js";
|
||||
import { ko } from "../i18n/ko";
|
||||
import {
|
||||
dedupeProgress,
|
||||
dedupeRunning,
|
||||
info,
|
||||
openDedupe,
|
||||
pending,
|
||||
requestCancelDedupe,
|
||||
scanProgress,
|
||||
selected,
|
||||
snapshot,
|
||||
statusMessage,
|
||||
} from "../state/store";
|
||||
import { dedupeRunning, info, pending, scanProgress, statusMessage } from "../state/store";
|
||||
|
||||
export const StatusBar: Component = () => {
|
||||
/** 무엇이든 돌고 있는가 — 값은 말하지 않고 '준비됨'을 말할 자격만 판정한다.
|
||||
이 게이트가 없으면 사이드바가 '중복 찾는 중'인데 footer가 '준비됨'이라 말한다. */
|
||||
const anyTask = () =>
|
||||
scanProgress() != null ||
|
||||
dedupeRunning() ||
|
||||
pending().thumbs > 0 ||
|
||||
pending().meta > 0 ||
|
||||
pending().sigs > 0;
|
||||
|
||||
/** 왼쪽 한 칸 — 우선순위 하나로 정한다: 토스트 > 지금 읽는 폴더 > 준비됨.
|
||||
두 문장을 나란히 truncate 하면 둘 다 못 읽는다. 토스트는 4초짜리라
|
||||
그동안 스캔 경로가 가려져도 진행 자체는 사이드바가 계속 보여 준다. */
|
||||
const line = (): { text: string; accent: boolean } | undefined => {
|
||||
const msg = statusMessage();
|
||||
if (msg) return { text: msg, accent: true };
|
||||
const dir = scanProgress()?.currentDir;
|
||||
if (dir) return { text: ko.statusBar.scanning(dir), accent: true };
|
||||
return anyTask() ? undefined : { text: ko.statusBar.ready, accent: false };
|
||||
};
|
||||
|
||||
return (
|
||||
<footer class="flex h-6 items-center gap-3 border-t border-[var(--border)] bg-[var(--bg-panel)] px-3 text-[11px] text-[var(--text-secondary)]">
|
||||
<Show when={statusMessage()}>
|
||||
<span class="text-[var(--accent)]">{statusMessage()}</span>
|
||||
</Show>
|
||||
<Show
|
||||
when={scanProgress()}
|
||||
fallback={
|
||||
<span>
|
||||
{snapshot() ? ko.grid.items(snapshot()!.count) : ko.statusBar.ready}
|
||||
<Show when={selected().size > 0}>
|
||||
<span class="ml-2 text-[var(--accent)]">{ko.grid.selected(selected().size)}</span>
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(p) => (
|
||||
<span class="text-[var(--accent)]">
|
||||
{ko.statusBar.scanning(p().currentDir)} — {p().seen.toLocaleString("ko-KR")}
|
||||
<footer class="flex h-[var(--h-status)] items-center gap-3 bg-[var(--bg-app)] px-3 text-[11px] text-[var(--text-secondary)]">
|
||||
<Show when={line()}>
|
||||
{(l) => (
|
||||
<span
|
||||
class="min-w-0 truncate"
|
||||
classList={{
|
||||
"text-[var(--accent-text)]": l().accent,
|
||||
"text-[var(--text-muted)]": !l().accent,
|
||||
}}
|
||||
title={l().text}
|
||||
>
|
||||
{l().text}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
{/* 중복 탐지 진행 — 패널을 닫아도 상태바에 표시 (클릭 시 패널 열기 / 취소) */}
|
||||
<Show when={dedupeRunning()}>
|
||||
<span class="flex items-center gap-1.5 text-[var(--accent)]">
|
||||
<span class="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-[var(--accent)] border-t-transparent" />
|
||||
<button class="hover:underline" onClick={openDedupe} title={ko.dedupe.open}>
|
||||
{dedupeProgress()
|
||||
? ko.dedupe.scanning(dedupeProgress()!.phase, dedupeProgress()!.current, dedupeProgress()!.total)
|
||||
: ko.dedupe.starting}
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-1 text-[var(--text-muted)] hover:text-[var(--danger)]"
|
||||
onClick={requestCancelDedupe}
|
||||
title={ko.common.cancel}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
{/* 백그라운드 처리 진행 — 썸네일/메타 대기 수 (있을 때만) */}
|
||||
<Show when={pending().thumbs > 0 || pending().meta > 0}>
|
||||
<span class="flex items-center gap-1.5 text-[var(--text-muted)]">
|
||||
<span class="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-[var(--text-muted)] border-t-transparent" />
|
||||
<Show when={pending().thumbs > 0}>{ko.statusBar.thumbPending(pending().thumbs)}</Show>
|
||||
<Show when={pending().meta > 0}>
|
||||
<span class="ml-1">{ko.statusBar.metaPending(pending().meta)}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
{/* 데이터 위치 — 우측 고정 */}
|
||||
<Show when={info()}>
|
||||
{(i) => (
|
||||
<>
|
||||
<span class="ml-auto truncate text-[var(--text-muted)]" title={i().dataDir}>
|
||||
{i().portable ? ko.statusBar.portable : ko.statusBar.installed}
|
||||
</span>
|
||||
<span class="text-[var(--text-muted)]">v{i().version}</span>
|
||||
</>
|
||||
<span class="ml-auto min-w-0 truncate text-[var(--text-muted)]" title={i().dataDir}>
|
||||
{ko.statusBar.dataDir}: {i().dataDir}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// 폴더 트리 캐시 — 사이드바 트리 / 브레드크럼 조상 체인 / 폴더 칩이 공유한다.
|
||||
// folder_tree는 순수 DB 조회(commands.rs:745)라 죽은 네트워크 경로에서도 즉시 반환된다.
|
||||
//
|
||||
// **소스별 지연 로드**다. 예전에는 sidebarVersion이 오를 때마다 모든 소스의 folder_tree를
|
||||
// 한꺼번에 불렀다. 100만 건 합성 DB 실측으로 folder_tree는 소스당 103ms(+5만 노드 IPC 직렬화)
|
||||
// 이므로, 소스가 몇 개만 있어도 갱신 한 번이 수백 ms다. 스캔 중에는 그 갱신이 2초마다 온다.
|
||||
// 이제는 **화면에 필요한 소스만** 부른다 — 접힌 소스는 비용이 0이고, 펼친 소스도 버전당 1회다.
|
||||
import { createResource, createRoot, createSignal } from "solid-js";
|
||||
import { folderTree, listSources, type FolderNode, type Source } from "../ipc/commands";
|
||||
import { sidebarVersion } from "./store";
|
||||
|
||||
export interface FolderEntry {
|
||||
node: FolderNode;
|
||||
sourceId: number;
|
||||
children: FolderNode[];
|
||||
/** 하위 폴더까지 합산한 개수. folder_tree의 fileCount는 '직속'이라
|
||||
그대로 쓰면 "12개" 칩을 눌러 3,400장이 나오는 거짓말이 된다. */
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
interface SourceTree {
|
||||
map: Map<number, FolderEntry>;
|
||||
roots: FolderNode[]; // 최상위 폴더들
|
||||
}
|
||||
|
||||
function buildTree(sourceId: number, nodes: FolderNode[]): SourceTree {
|
||||
const map = new Map<number, FolderEntry>();
|
||||
const ids = new Set(nodes.map((n) => n.id));
|
||||
for (const n of nodes)
|
||||
map.set(n.id, { node: n, sourceId, children: [], subtotal: n.fileCount });
|
||||
const roots: FolderNode[] = [];
|
||||
for (const n of nodes) {
|
||||
if (n.parentId != null && ids.has(n.parentId)) map.get(n.parentId)!.children.push(n);
|
||||
else roots.push(n); // Sidebar.buildTree와 동일한 루트 판정
|
||||
}
|
||||
// 하위 합계 — folder_tree는 ORDER BY fo.path라 부모가 항상 자식보다 앞선다
|
||||
// (경로 접두사는 사전순으로 항상 선행). 역순 1패스면 충분하다.
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const n = nodes[i];
|
||||
if (n.parentId == null) continue;
|
||||
const p = map.get(n.parentId);
|
||||
if (p) p.subtotal += map.get(n.id)!.subtotal;
|
||||
}
|
||||
return { map, roots };
|
||||
}
|
||||
|
||||
// 소스 목록은 가볍고(몇 행) 사이드바가 어차피 필요로 한다 — 항상 로드한다.
|
||||
const sourcesRes = createRoot(() => createResource(() => sidebarVersion(), listSources)[0]);
|
||||
|
||||
// sourceId → 마지막으로 성공한 트리 + 그때의 버전. 값은 버전이 올라도 버리지 않는다:
|
||||
// 재조회 중에도 이전 트리를 계속 보여줘야 트리·칩이 깜빡이지 않는다.
|
||||
const trees = new Map<number, { version: number; tree: SourceTree }>();
|
||||
const inflight = new Set<string>();
|
||||
// 로드 완료 알림 — 이걸 읽는 계산은 트리가 도착하면 다시 평가된다.
|
||||
const [loadTick, setLoadTick] = createSignal(0);
|
||||
|
||||
function load(sourceId: number, version: number): void {
|
||||
const key = `${sourceId}:${version}`;
|
||||
if (inflight.has(key)) return;
|
||||
inflight.add(key);
|
||||
void folderTree(sourceId)
|
||||
.then((nodes) => trees.set(sourceId, { version, tree: buildTree(sourceId, nodes) }))
|
||||
.catch(() => {
|
||||
// 소스가 사라졌거나 조회 실패 — 빈 트리를 이 버전으로 캐시해 재시도 폭주를 막는다
|
||||
trees.set(sourceId, { version, tree: { map: new Map(), roots: [] } });
|
||||
})
|
||||
.finally(() => {
|
||||
inflight.delete(key);
|
||||
setLoadTick((n) => n + 1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 소스의 폴더 트리를 확보한다. 아직 없으면 로드를 시작하고, 있으면(이전 버전이라도)
|
||||
* 즉시 반환한다. sidebarVersion·loadTick을 읽으므로 호출한 계산은 도착 시 다시 돈다.
|
||||
*/
|
||||
export function sourceTree(sourceId: number): SourceTree | undefined {
|
||||
const version = sidebarVersion();
|
||||
loadTick();
|
||||
const cached = trees.get(sourceId);
|
||||
if (!cached || cached.version !== version) load(sourceId, version);
|
||||
return cached?.tree;
|
||||
}
|
||||
|
||||
/** 최상위 폴더들 (사이드바 소스 행이 펼쳐질 때만 호출된다) */
|
||||
export function rootsOf(sourceId: number): FolderNode[] {
|
||||
return sourceTree(sourceId)?.roots ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴더 항목 조회. `sourceId`를 알면 그 소스만 본다(대부분의 호출이 그렇다 —
|
||||
* 폴더 뷰·사이드바 행·칩 모두 소스를 들고 있다).
|
||||
*
|
||||
* 힌트가 없으면 **이미 로드된** 트리들만 훑고, 거기에도 없으면 전 소스 로드를 시작한다
|
||||
* (드문 경로: 소스를 모르는 폴더 뷰. 다음 렌더에서 값이 채워진다).
|
||||
*/
|
||||
export function entryOf(folderId: number, sourceId?: number): FolderEntry | undefined {
|
||||
if (sourceId != null) return sourceTree(sourceId)?.map.get(folderId);
|
||||
sidebarVersion();
|
||||
loadTick();
|
||||
for (const c of trees.values()) {
|
||||
const hit = c.tree.map.get(folderId);
|
||||
if (hit) return hit;
|
||||
}
|
||||
for (const s of sourcesRes.latest ?? []) sourceTree(s.id);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function sourceIdOf(folderId: number): number | undefined {
|
||||
return entryOf(folderId)?.sourceId;
|
||||
}
|
||||
export function subtotalOf(folderId: number, sourceId?: number): number {
|
||||
return entryOf(folderId, sourceId)?.subtotal ?? 0;
|
||||
}
|
||||
export function childrenOf(folderId: number, sourceId?: number): FolderNode[] {
|
||||
return entryOf(folderId, sourceId)?.children ?? [];
|
||||
}
|
||||
|
||||
/** 브레드크럼용 조상 체인 (루트 → 현재) */
|
||||
export function ancestry(folderId: number, sourceId?: number): FolderNode[] {
|
||||
const start = entryOf(folderId, sourceId);
|
||||
if (!start) return [];
|
||||
const tree = trees.get(start.sourceId)?.tree;
|
||||
if (!tree) return [];
|
||||
const out: FolderNode[] = [];
|
||||
let cur: FolderEntry | undefined = start;
|
||||
const guard = new Set<number>();
|
||||
while (cur && !guard.has(cur.node.id)) {
|
||||
guard.add(cur.node.id);
|
||||
out.unshift(cur.node);
|
||||
cur = cur.node.parentId != null ? tree.map.get(cur.node.parentId) : undefined;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// `.latest`를 쓴다 — 재조회 중에도 이전 값을 유지해 소스 이름·개수가 깜빡이지 않게.
|
||||
export const sources = (): Source[] => sourcesRes.latest ?? [];
|
||||
/** 라이브러리 전체 항목 수 (소스 재귀 개수의 합) */
|
||||
export const libraryTotal = (): number =>
|
||||
(sourcesRes.latest ?? []).reduce((a, s) => a + s.fileCount, 0);
|
||||
+486
-20
@@ -1,23 +1,32 @@
|
||||
// 전역 상태 — Solid 시그널 기반 단일 스토어 모듈.
|
||||
import { createSignal } from "solid-js";
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
import { createStore, produce, unwrap } from "solid-js/store";
|
||||
import { ask } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
appInfo,
|
||||
cancelDedupe,
|
||||
folderSnapshot,
|
||||
markDelete,
|
||||
onDedupeDone,
|
||||
pendingCounts as fetchPendingCounts,
|
||||
purgeMissing,
|
||||
searchSnapshot,
|
||||
smartSnapshot,
|
||||
startDedupe,
|
||||
tagSnapshot,
|
||||
trashTagId as fetchTrashTagId,
|
||||
unmarkDelete,
|
||||
Snapshot,
|
||||
type AppInfo,
|
||||
type DedupeProgress,
|
||||
type PendingCounts,
|
||||
type ScanGuarded,
|
||||
type ScanProgress,
|
||||
type SnapOpts,
|
||||
type SortKey,
|
||||
type UnlistenFn,
|
||||
} from "../ipc/commands";
|
||||
import { ko } from "../i18n/ko";
|
||||
|
||||
// ── 앱 정보 (미디어 서버 포트/토큰 포함) ─────────────────
|
||||
const [info, setInfo] = createSignal<AppInfo | undefined>();
|
||||
@@ -35,7 +44,9 @@ export async function loadAppInfo(): Promise<void> {
|
||||
export type View =
|
||||
| { type: "all" }
|
||||
| { type: "source"; sourceId: number }
|
||||
| { type: "folder"; folderId: number; recursive: boolean }
|
||||
/** sourceId는 브레드크럼·폴더 칩이 소스 컨텍스트를 얻기 위한 선택 필드.
|
||||
기존 소비자(DedupePanel 등)는 구조적 타이핑이라 영향 없다. */
|
||||
| { type: "folder"; folderId: number; recursive: boolean; sourceId?: number }
|
||||
| { type: "search"; query: string }
|
||||
| { type: "tag"; tagId: number }
|
||||
| { type: "smart"; smartId: number };
|
||||
@@ -50,7 +61,9 @@ const [sortKey, setSortKeySig] = createSignal<SortKey>("taken");
|
||||
const [sortDesc, setSortDescSig] = createSignal(true);
|
||||
export type KindFilter = "all" | "image" | "video";
|
||||
const [kindFilter, setKindFilterSig] = createSignal<KindFilter>("all");
|
||||
export { sortKey, sortDesc, kindFilter };
|
||||
/** 별점 하한 (0=전체, 1~5=그 점수 **이상**). 종류 필터와 함께 걸린다. */
|
||||
const [minRating, setMinRatingSig] = createSignal(0);
|
||||
export { sortKey, sortDesc, kindFilter, minRating };
|
||||
export type { SortKey };
|
||||
|
||||
/** 정렬 변경 → 재조회 */
|
||||
@@ -64,6 +77,28 @@ export function setKindFilter(k: KindFilter): void {
|
||||
setKindFilterSig(k);
|
||||
void refreshSnapshot();
|
||||
}
|
||||
/** 별점 하한 변경(0=전체) → 재조회 */
|
||||
export function setMinRating(n: number): void {
|
||||
setMinRatingSig(Math.max(0, Math.min(5, n)));
|
||||
void refreshSnapshot();
|
||||
}
|
||||
|
||||
// ── 보기 방식 (격자 / 리스트) ────────────────────────────
|
||||
// 같은 스냅샷을 격자(썸네일 위주)와 리스트(속성 비교 위주) 중 하나로 그린다.
|
||||
// 선택·정렬·필터는 공유하므로 전환해도 보고 있던 항목이 유지된다.
|
||||
export type ViewMode = "grid" | "list";
|
||||
const VIEW_MODE_KEY = "archive.viewMode";
|
||||
const [viewMode, setViewModeRaw] = createSignal<ViewMode>(
|
||||
localStorage.getItem(VIEW_MODE_KEY) === "list" ? "list" : "grid",
|
||||
);
|
||||
export { viewMode };
|
||||
|
||||
export function setViewMode(m: ViewMode): void {
|
||||
setViewModeRaw(m);
|
||||
localStorage.setItem(VIEW_MODE_KEY, m);
|
||||
}
|
||||
|
||||
export const toggleViewMode = (): void => setViewMode(viewMode() === "grid" ? "list" : "grid");
|
||||
|
||||
// 그리드 썸네일에 파일명·크기 오버레이 표시 (localStorage 유지)
|
||||
const INFO_KEY = "archive.grid.infoOverlay";
|
||||
@@ -75,14 +110,151 @@ export function toggleShowInfo(): void {
|
||||
localStorage.setItem(INFO_KEY, n ? "1" : "0");
|
||||
}
|
||||
|
||||
// ── 상세 패널 / 폴더 칩 (전부 localStorage 유지) ─────────
|
||||
const DETAILS_KEY = "archive.detailsOpen";
|
||||
const [detailsOpen, setDetailsOpenRaw] = createSignal(localStorage.getItem(DETAILS_KEY) !== "0");
|
||||
export { detailsOpen };
|
||||
export function setDetailsOpen(b: boolean): void {
|
||||
setDetailsOpenRaw(b);
|
||||
localStorage.setItem(DETAILS_KEY, b ? "1" : "0");
|
||||
}
|
||||
export const toggleDetails = (): void => setDetailsOpen(!detailsOpen());
|
||||
|
||||
export type DetailsTab = "info" | "tags";
|
||||
const [detailsTab, setDetailsTab] = createSignal<DetailsTab>("info");
|
||||
export { detailsTab, setDetailsTab };
|
||||
|
||||
const CHIPS_KEY = "archive.folderChips";
|
||||
const [chipsOpen, setChipsOpenRaw] = createSignal(localStorage.getItem(CHIPS_KEY) !== "0");
|
||||
export { chipsOpen };
|
||||
export function toggleChips(): void {
|
||||
const n = !chipsOpen();
|
||||
setChipsOpenRaw(n);
|
||||
localStorage.setItem(CHIPS_KEY, n ? "1" : "0");
|
||||
}
|
||||
|
||||
// ── 테마 (다크 기본 / 라이트 선택) ───────────────────────
|
||||
// 앱 크롬만 뒤집는다. 사진 위에 얹히는 색(--ink-on-media 계열)은 app.css에서 고정이며
|
||||
// Lightbox/VideoPlayer는 범위 밖이라 항상 검은 배경을 유지한다.
|
||||
export type Theme = "dark" | "light";
|
||||
const THEME_KEY = "archive.theme";
|
||||
|
||||
function readTheme(): Theme {
|
||||
try {
|
||||
return localStorage.getItem(THEME_KEY) === "light" ? "light" : "dark"; // 기본 = 다크
|
||||
} catch {
|
||||
return "dark"; // localStorage 차단 환경에서도 앱이 뜨는 게 우선
|
||||
}
|
||||
}
|
||||
|
||||
/** 문서 루트에 반영. CSS는 :root(다크) / :root[data-theme="light"] 두 벌로 재스코프된다. */
|
||||
function applyTheme(t: Theme): void {
|
||||
document.documentElement.dataset.theme = t;
|
||||
}
|
||||
|
||||
const [theme, setThemeRaw] = createSignal<Theme>(readTheme());
|
||||
export { theme };
|
||||
|
||||
// FOUC 방지: 모듈 평가 시점에 즉시 반영한다.
|
||||
// main.tsx가 App을 import → App이 store를 import → 이 줄이 render() 이전에 실행된다.
|
||||
applyTheme(theme());
|
||||
|
||||
export function setTheme(t: Theme): void {
|
||||
if (t === theme()) return;
|
||||
setThemeRaw(t);
|
||||
applyTheme(t);
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, t);
|
||||
} catch {
|
||||
/* 무시 — 이번 세션에만 적용된다 */
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleTheme = (): void => setTheme(theme() === "dark" ? "light" : "dark");
|
||||
|
||||
// ── 사이드바 트리 확장 상태 (재시작 후에도 유지) ──────────
|
||||
// key 규약: 소스 = `s:{sourceId}` / 폴더 = `f:{folderId}`
|
||||
// 사이드바 그룹 접힘 = `gc:{sources|collections|cleanup}`
|
||||
// 그룹만 의미가 반대다 — 저장하는 값이 '접힘'이다. 기본(키 없음)이 펼침이어야 하므로
|
||||
// 접힘을 true로 적는다. isExpanded("gc:sources") === true 는 '소스 그룹이 접혀 있다'는 뜻이다.
|
||||
const TREE_KEY = "archive.tree";
|
||||
const [treeOpen, setTreeOpen] = createStore<Record<string, boolean>>(
|
||||
(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(TREE_KEY) ?? "{}") as Record<string, boolean>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})(),
|
||||
);
|
||||
export const isExpanded = (key: string): boolean => treeOpen[key] === true;
|
||||
export function setExpanded(key: string, v: boolean): void {
|
||||
setTreeOpen(key, v);
|
||||
localStorage.setItem(TREE_KEY, JSON.stringify(unwrap(treeOpen)));
|
||||
}
|
||||
export const toggleExpanded = (key: string): void => setExpanded(key, !isExpanded(key));
|
||||
|
||||
/** 사이드바 그룹 접힘 — 위 `gc:` 규약의 얇은 래퍼. 폴라리티를 호출부가 뒤집지 않게 한다. */
|
||||
export const isGroupCollapsed = (group: string): boolean => isExpanded(`gc:${group}`);
|
||||
export const toggleGroupCollapsed = (group: string): void => toggleExpanded(`gc:${group}`);
|
||||
|
||||
// ── 아이템 뷰 포커스 복귀 계약 (단축키 보존의 핵심) ────────
|
||||
// 격자/리스트 스크롤 컨테이너가 자신을 등록한다. 크롬(브레드크럼·칩·툴바·사이드바)의
|
||||
// 클릭 핸들러는 끝에서 focusItemView()를 불러 방향키·Delete·Ctrl+A를 되살린다.
|
||||
let itemViewFocus: (() => void) | undefined;
|
||||
export function registerItemViewFocus(fn: () => void): () => void {
|
||||
itemViewFocus = fn;
|
||||
return () => {
|
||||
if (itemViewFocus === fn) itemViewFocus = undefined;
|
||||
};
|
||||
}
|
||||
export const focusItemView = (): void => itemViewFocus?.();
|
||||
|
||||
// ── 검색 (store가 소유) ──────────────────────────────────
|
||||
// 폴더로 이동하면 검색창이 비워지고, 검색을 끝내면 검색 '직전 위치'로 돌아온다.
|
||||
// search_snapshot에 범위 인자가 없으므로 폴더 내 검색은 만들지 않는다 — 항상 전체 라이브러리.
|
||||
const [searchQuery, setSearchQueryRaw] = createSignal("");
|
||||
const [prevView, setPrevView] = createSignal<View | undefined>();
|
||||
export { searchQuery };
|
||||
|
||||
let viewSeq = 0;
|
||||
|
||||
export async function setView(v: View): Promise<void> {
|
||||
if (v.type === "search") {
|
||||
if (view().type !== "search") setPrevView(view());
|
||||
} else {
|
||||
setPrevView(undefined);
|
||||
if (searchQuery() !== "") setSearchQueryRaw(""); // 다른 곳으로 가면 검색창도 비운다
|
||||
}
|
||||
setViewSignal(v);
|
||||
clearSelection();
|
||||
await refreshSnapshot();
|
||||
}
|
||||
|
||||
/** 검색 실행. 빈 문자열이면 검색 종료(직전 뷰로 복귀). 디바운스는 호출자 책임. */
|
||||
export async function runSearch(raw: string): Promise<void> {
|
||||
setSearchQueryRaw(raw); // 화면에는 원문 그대로
|
||||
const q = raw.trim();
|
||||
if (q.length === 0) {
|
||||
await exitSearch();
|
||||
return;
|
||||
}
|
||||
if (view().type !== "search") setPrevView(view());
|
||||
setViewSignal({ type: "search", query: q });
|
||||
clearSelection();
|
||||
await refreshSnapshot();
|
||||
}
|
||||
|
||||
/** 검색 종료 — 검색 직전 위치로 복귀 (없으면 전체) */
|
||||
export async function exitSearch(): Promise<void> {
|
||||
const back = prevView() ?? ({ type: "all" } as View);
|
||||
setSearchQueryRaw("");
|
||||
setPrevView(undefined);
|
||||
setViewSignal(back);
|
||||
clearSelection();
|
||||
await refreshSnapshot();
|
||||
}
|
||||
|
||||
export async function refreshSnapshot(): Promise<void> {
|
||||
const v = view();
|
||||
const seq = ++viewSeq;
|
||||
@@ -91,6 +263,7 @@ export async function refreshSnapshot(): Promise<void> {
|
||||
sortKey: sortKey(),
|
||||
sortDesc: sortDesc(),
|
||||
kind: kindFilter() === "image" ? 0 : kindFilter() === "video" ? 1 : null,
|
||||
minRating: minRating() > 0 ? minRating() : null,
|
||||
};
|
||||
try {
|
||||
let snap: Snapshot;
|
||||
@@ -114,12 +287,39 @@ export async function refreshSnapshot(): Promise<void> {
|
||||
snap = await smartSnapshot(v.smartId, opts);
|
||||
break;
|
||||
}
|
||||
if (seq === viewSeq) setSnapshot(snap);
|
||||
if (seq === viewSeq) {
|
||||
// 크게 보는 중이면 **같은 파일**을 계속 본다.
|
||||
//
|
||||
// 라이트박스는 인덱스를 들고 있는데 목록이 다시 정렬되면 그 자리에 다른 파일이 온다.
|
||||
// 편집(회전·자르기)처럼 보는 중에 목록이 갱신되는 경우가 흔해서, 방금 손댄 파일이
|
||||
// 화면에서 사라지고 엉뚱한 항목이 뜬다 — 다시 찾아 들어가야 한다.
|
||||
const openId = lightboxFileId();
|
||||
setSnapshot(snap);
|
||||
if (openId != null) {
|
||||
const at = indexOfId(snap, openId);
|
||||
// 목록에서 빠졌으면(필터에 걸림 등) 있던 자리를 지킨다 — 닫아 버리면 더 혼란스럽다
|
||||
if (at >= 0) setLightboxIndex(at);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (seq === viewSeq) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 지금 크게 보고 있는 파일 id (안 열려 있으면 null) */
|
||||
function lightboxFileId(): number | null {
|
||||
const idx = lightboxIndex();
|
||||
const snap = snapshot();
|
||||
if (idx == null || !snap || idx >= snap.count) return null;
|
||||
return snap.id(idx);
|
||||
}
|
||||
|
||||
/** 스냅샷에서 id의 위치. 없으면 -1. */
|
||||
function indexOfId(snap: Snapshot, id: number): number {
|
||||
for (let i = 0; i < snap.count; i++) if (snap.id(i) === id) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ── 선택 ────────────────────────────────────────────────
|
||||
const [selected, setSelected] = createSignal<ReadonlySet<number>>(new Set());
|
||||
const [anchorIndex, setAnchorIndex] = createSignal<number>(-1);
|
||||
@@ -156,6 +356,70 @@ export function setSelectionFromIds(ids: number[]): void {
|
||||
setSelected(new Set<number>(ids));
|
||||
}
|
||||
|
||||
// ── 삭제 대기열 ('삭제 예정' 태그) ───────────────────────
|
||||
// 삭제는 2단계다: ① 표시(태그) — 되돌릴 수 있고 파일은 그대로 ② 비우기 — 영구 삭제.
|
||||
// 그리드/라이트박스가 표시 상태를 즉시 반영해야 해서 id 집합을 전역으로 캐시한다.
|
||||
const [trashTag, setTrashTag] = createSignal<number | undefined>();
|
||||
const [markedIds, setMarkedIds] = createSignal<ReadonlySet<number>>(new Set());
|
||||
export { trashTag, markedIds };
|
||||
|
||||
export const isMarked = (id: number): boolean => markedIds().has(id);
|
||||
|
||||
/** 삭제 예정 태그 id 확보 (앱 시작 시 1회) */
|
||||
export async function loadTrashTag(): Promise<void> {
|
||||
try {
|
||||
setTrashTag(await fetchTrashTagId());
|
||||
await refreshMarked();
|
||||
// 최초 실행 시 태그가 방금 만들어졌을 수 있다 — 사이드바가 이를 집어가게 한다
|
||||
refreshSidebar();
|
||||
} catch (e) {
|
||||
console.error("trash_tag_id 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 삭제 예정 표시 목록 재조회 (라이브러리 전체 기준) */
|
||||
export async function refreshMarked(): Promise<void> {
|
||||
const tag = trashTag();
|
||||
if (tag == null) return;
|
||||
try {
|
||||
const snap = await tagSnapshot(tag);
|
||||
const next = new Set<number>();
|
||||
for (let i = 0; i < snap.count; i++) next.add(snap.id(i));
|
||||
setMarkedIds(next);
|
||||
} catch {
|
||||
/* 무시 — 표시 배지가 잠깐 어긋날 뿐 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 예정 표시 토글. 하나라도 표시 안 된 게 있으면 전부 표시하고,
|
||||
* 모두 표시돼 있으면 전부 해제한다. (Ctrl+Z로 되돌릴 수 있다)
|
||||
*/
|
||||
export async function toggleMarkDelete(ids: number[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const cur = markedIds();
|
||||
const allMarked = ids.every((id) => cur.has(id));
|
||||
try {
|
||||
const r = allMarked ? await unmarkDelete(ids) : await markDelete(ids);
|
||||
toast(allMarked ? ko.trash.unmarked(r.count) : ko.trash.marked(r.count));
|
||||
await refreshMarked();
|
||||
bumpTagVersion();
|
||||
refreshSidebar();
|
||||
// 삭제 예정 뷰를 보고 있으면 목록 자체가 바뀐다
|
||||
const v = view();
|
||||
if (v.type === "tag" && v.tagId === trashTag()) await refreshSnapshot();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 태그 변경 버전 ───────────────────────────────────────
|
||||
// 어디서든 태그를 붙이거나 떼면 올린다. 그리드 썸네일의 태그 오버레이가
|
||||
// 이 값을 구독해 캐시를 버리고 다시 읽는다.
|
||||
const [tagVersion, setTagVersion] = createSignal(0);
|
||||
export { tagVersion };
|
||||
export const bumpTagVersion = () => setTagVersion((v) => v + 1);
|
||||
|
||||
// ── 썸네일 갱신 버전 (thumbs-ready 이벤트로 증가) ────────
|
||||
const [thumbVersions, setThumbVersions] = createStore<Record<number, number>>({});
|
||||
export { thumbVersions };
|
||||
@@ -172,22 +436,90 @@ export function bumpThumbVersions(ids: number[]): void {
|
||||
const [scanProgress, setScanProgress] = createSignal<ScanProgress | undefined>();
|
||||
export { scanProgress };
|
||||
|
||||
// 열거 단계는 총량을 모른다 — 다 돌아 봐야 몇 개인지 안다. 그래서 퍼센트를 지어낼 수 없다.
|
||||
// 대신 '초당 몇 건'을 재서 진행이 살아 있음과 빠르기를 함께 전한다.
|
||||
// 지수 평활(0.3)로 스파이크를 눌러 숫자가 튀지 않게 한다.
|
||||
const [scanRate, setScanRate] = createSignal(0);
|
||||
export { scanRate };
|
||||
let rateSeen = 0;
|
||||
let rateAt = 0;
|
||||
|
||||
export function updateScanProgress(p: ScanProgress): void {
|
||||
setScanProgress(p.done ? undefined : p);
|
||||
if (p.done) {
|
||||
setScanProgress(undefined);
|
||||
setScanRate(0);
|
||||
rateSeen = 0;
|
||||
rateAt = 0;
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (rateAt > 0 && p.seen >= rateSeen) {
|
||||
const dt = (now - rateAt) / 1000;
|
||||
// 백엔드가 100ms 스로틀로 보내므로 0.2초 이상 간격만 표본으로 쓴다
|
||||
if (dt >= 0.2) {
|
||||
const inst = (p.seen - rateSeen) / dt;
|
||||
setScanRate((prev) => (prev > 0 ? prev * 0.7 + inst * 0.3 : inst));
|
||||
rateSeen = p.seen;
|
||||
rateAt = now;
|
||||
}
|
||||
} else {
|
||||
rateSeen = p.seen;
|
||||
rateAt = now;
|
||||
}
|
||||
setScanProgress(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스캔 진행 표시를 지운다.
|
||||
* startScan이 던지면(없는 경로·권한·이미 진행 중) done 진행이 오지 않아 마지막 값이
|
||||
* 그대로 남는다 — 존재하지 않는 작업 행이 영구히 떠 있는 '유령 스캔'이 그것이다.
|
||||
* 스캔을 시작하는 쪽은 catch에서 반드시 이걸 부른다.
|
||||
*/
|
||||
export const clearScanProgress = (): void => {
|
||||
setScanProgress(undefined);
|
||||
setScanRate(0);
|
||||
rateSeen = 0;
|
||||
rateAt = 0;
|
||||
};
|
||||
|
||||
// ── 백그라운드 처리 진행(썸네일/메타 대기 수) ────────────
|
||||
const [pending, setPending] = createSignal<PendingCounts>({ thumbs: 0, meta: 0 });
|
||||
const [pending, setPending] = createSignal<PendingCounts>({
|
||||
thumbs: 0,
|
||||
meta: 0,
|
||||
sigs: 0,
|
||||
baseline: 0,
|
||||
});
|
||||
export { pending };
|
||||
|
||||
/**
|
||||
* 백필 진행률 기준선 — '가장 많이 밀려 있던 순간'의 대기 수.
|
||||
*
|
||||
* 이 값은 **백엔드가 DB에 들고 있다**. 예전에는 여기서 시그널로만 셌는데, 그러면 앱을 껐다
|
||||
* 켤 때 남은 수가 그대로 새 기준선이 되어 **진행률이 0%로 돌아갔다**(사용자 신고).
|
||||
* 작업 자체는 `thumb_state`가 큐라서 정상적으로 이어지고 있었는데도 화면만 처음처럼 보였다.
|
||||
*/
|
||||
const [backfillTotal, setBackfillTotal] = createSignal(0);
|
||||
export { backfillTotal };
|
||||
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
async function doRefreshPending(): Promise<void> {
|
||||
try {
|
||||
const c = await fetchPendingCounts();
|
||||
// 필드를 하나라도 못 받으면(구 백엔드·필드 추가 시점 불일치) 합이 NaN이 되고
|
||||
// NaN > 0 은 false라 작업 행이 **통째로 사라진다**. 경계에서 한 번 정규화해 그 실패를 막는다.
|
||||
const raw = await fetchPendingCounts();
|
||||
const c: PendingCounts = {
|
||||
thumbs: raw.thumbs ?? 0,
|
||||
meta: raw.meta ?? 0,
|
||||
sigs: raw.sigs ?? 0,
|
||||
// 기준선은 백엔드가 관리한다(재시작을 넘겨야 하므로). 구 백엔드가 안 주면 0 → 셔틀 표시.
|
||||
baseline: raw.baseline ?? 0,
|
||||
};
|
||||
setPending(c);
|
||||
setBackfillTotal(c.baseline);
|
||||
clearTimeout(pendingTimer);
|
||||
// 남은 작업이 있으면 계속 폴링(썸네일은 thumbs-ready로도 갱신되지만 메타는 이벤트가 없어 폴링 필요)
|
||||
if (c.thumbs > 0 || c.meta > 0) pendingTimer = setTimeout(() => void doRefreshPending(), 2000);
|
||||
// 남은 작업이 있으면 계속 폴링(썸네일은 thumbs-ready로도 갱신되지만 메타·지문은 이벤트가 없어 폴링 필요)
|
||||
if (c.thumbs > 0 || c.meta > 0 || c.sigs > 0)
|
||||
pendingTimer = setTimeout(() => void doRefreshPending(), 2000);
|
||||
} catch {
|
||||
/* 무시 */
|
||||
}
|
||||
@@ -199,11 +531,72 @@ export function refreshPendingCounts(): void {
|
||||
pendingTimer = setTimeout(() => void doRefreshPending(), 400);
|
||||
}
|
||||
|
||||
// ── 대량 삭제 보류 ───────────────────────────────────────
|
||||
// 한 스캔이 소스 절반 이상을 지우려 하면 백엔드가 보류한다(드라이브 미마운트·NAS 오프라인).
|
||||
// 지우지 않았으므로 목록에는 없는 파일이 남는다 — 사용자가 드라이브를 확인한 뒤
|
||||
// '사라진 파일 정리'로 직접 확정해야 지워진다. 그 진입점을 이 시그널이 만든다.
|
||||
const [guarded, setGuarded] = createSignal<ScanGuarded | undefined>();
|
||||
export { guarded };
|
||||
export const noteGuarded = (g: ScanGuarded) => setGuarded(g);
|
||||
|
||||
/** 보류된 삭제를 확정한다. 디스크를 다시 확인하므로 연결이 돌아왔으면 아무것도 지워지지 않는다. */
|
||||
export async function purgeGuarded(): Promise<void> {
|
||||
const g = guarded();
|
||||
if (!g) return;
|
||||
const yes = await ask(ko.refresh.purgeMissingConfirm(g.count), {
|
||||
title: "Archive",
|
||||
kind: "warning",
|
||||
});
|
||||
if (!yes) return;
|
||||
try {
|
||||
const n = await purgeMissing(g.sourceId);
|
||||
toast(n > 0 ? ko.refresh.purgeMissingDone(n) : ko.refresh.purgeMissingNone);
|
||||
// 처리됐다 — 진입점은 사라진다. 다음 스캔에서 또 보류되면 다시 나타난다.
|
||||
setGuarded(undefined);
|
||||
refreshSidebar();
|
||||
await refreshSnapshot();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 사이드바 새로고침 트리거 ─────────────────────────────
|
||||
const [sidebarVersion, setSidebarVersion] = createSignal(0);
|
||||
export { sidebarVersion };
|
||||
/** 사용자 동작(태그 추가·소스 제거 등) — 즉시 반영한다. */
|
||||
export const refreshSidebar = () => setSidebarVersion((v) => v + 1);
|
||||
|
||||
// ── 스캔 중 갱신 폭주 합치기 ─────────────────────────────
|
||||
// 열거 중에는 library-changed가 2초마다 온다(새 파일이 그리드에 보이도록 일부러 그렇게 한다).
|
||||
// 그런데 그 한 번이 사이드바 전체 재조회 + 현재 뷰 스냅샷 재조회를 부른다. 100만 건 합성
|
||||
// DB 실측으로 folder_tree 103ms/소스, 소스 개수 집계 99ms/소스, 스냅샷 수백 ms —
|
||||
// 2초마다 이걸 다 하면 화면이 끊긴다. 이벤트발 갱신만 창(WINDOW)으로 합친다.
|
||||
// 사용자 동작발 갱신은 이 경로를 타지 않으므로 여전히 즉시 반영된다.
|
||||
const SOFT_WINDOW = 1500;
|
||||
let softTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let softLast = 0;
|
||||
|
||||
function runSoft(): void {
|
||||
softLast = Date.now();
|
||||
setSidebarVersion((v) => v + 1);
|
||||
void refreshSnapshot();
|
||||
void refreshMarked();
|
||||
}
|
||||
|
||||
/** 이벤트발(library-changed) 갱신 — 선두는 즉시, 뒤따르는 것들은 창 하나로 합친다. */
|
||||
export function refreshAfterLibraryChange(): void {
|
||||
if (softTimer) return; // 이미 예약됨 — 창이 닫힐 때 한 번만 돈다
|
||||
const since = Date.now() - softLast;
|
||||
if (since >= SOFT_WINDOW) {
|
||||
runSoft();
|
||||
return;
|
||||
}
|
||||
softTimer = setTimeout(() => {
|
||||
softTimer = undefined;
|
||||
runSoft();
|
||||
}, SOFT_WINDOW - since);
|
||||
}
|
||||
|
||||
// ── 그리드 썸네일 크기(줌) ───────────────────────────────
|
||||
export const GRID_ROW_MIN = 110;
|
||||
export const GRID_ROW_MAX = 420;
|
||||
@@ -236,16 +629,35 @@ export { shortcutsOpen };
|
||||
export const openShortcuts = () => setShortcutsOpen(true);
|
||||
export const closeShortcuts = () => setShortcutsOpen(false);
|
||||
|
||||
// ── 중복 검토 패널 ───────────────────────────────────────
|
||||
// ── 중복 검토 모달 ───────────────────────────────────────
|
||||
// 모달은 진행을 소유하지 않는다 — 열림/닫힘만 소유한다.
|
||||
const [dedupeOpen, setDedupeOpen] = createSignal(false);
|
||||
export { dedupeOpen };
|
||||
/** 진입점은 사이드바 두 곳(정리 행 · 작업 행)뿐이다. footer에는 진입점을 두지 않는다. */
|
||||
export const openDedupe = () => setDedupeOpen(true);
|
||||
export const closeDedupe = () => setDedupeOpen(false);
|
||||
/**
|
||||
* 모달만 닫는다 — 탐지는 계속되고 취소도 계속 가능하다.
|
||||
* 닫은 직후 방향키·Delete·Ctrl+A가 그리드에서 다시 동작해야 하므로 포커스를 되돌린다.
|
||||
*/
|
||||
export function closeDedupe(): void {
|
||||
setDedupeOpen(false);
|
||||
focusItemView();
|
||||
}
|
||||
|
||||
// ── 중복 탐지 실행(전역) — 패널을 닫아도 상태바에서 진행이 보이도록 store가 소유 ──
|
||||
// ── 중복 탐지 실행(전역) ─────────────────────────────────
|
||||
// 진행 상태의 단일 진실원은 이 모듈이다. 모달을 닫아도 진행이 유지되고 취소가 가능하다.
|
||||
// 사이드바 작업 행과 footer는 여기 있는 시그널만 읽는다 — 어느 쪽도 자체 진행
|
||||
// 시그널이나 타이머를 만들지 않는다. 두 곳이 각자 세면 같은 정보가 어긋난다.
|
||||
const [dedupeProgress, setDedupeProgress] = createSignal<DedupeProgress | undefined>();
|
||||
const [dedupeRunning, setDedupeRunning] = createSignal(false);
|
||||
export { dedupeProgress, dedupeRunning };
|
||||
/** 시작 시점에 확정된 작업. 실행 중에는 '지금 도는 조건', 끝난 뒤에는 '지금 보이는 결과의 조건'. */
|
||||
const [dedupeJob, setDedupeJob] = createSignal<DedupeJob | undefined>();
|
||||
/** 취소를 요청한 시각(ms). 요청은 즉시 멈추지 않는다 — 백엔드가 단계 경계에서 확인한다. */
|
||||
const [dedupeCancelAt, setDedupeCancelAt] = createSignal<number | undefined>();
|
||||
export { dedupeProgress, dedupeRunning, dedupeJob, dedupeCancelAt };
|
||||
|
||||
/** 취소를 눌렀지만 아직 멈추지 않은 구간. 이 구간의 표시는 '취소 중…' 하나로 통일한다. */
|
||||
export const dedupeCancelPending = (): boolean => dedupeRunning() && dedupeCancelAt() != null;
|
||||
|
||||
export interface DedupeRunArgs {
|
||||
sourceId?: number;
|
||||
@@ -253,27 +665,79 @@ export interface DedupeRunArgs {
|
||||
recursive?: boolean;
|
||||
imageThreshold?: number;
|
||||
includeVideo?: boolean;
|
||||
/** 회전·반전본까지 찾는다 (후보 생성이 몇 배 비싸다) */
|
||||
includeRotated?: boolean;
|
||||
/** 시작 시점의 범위 라벨. 캡처용이며 IPC로는 가지 않는다. */
|
||||
scopeLabel?: string;
|
||||
}
|
||||
|
||||
/** 중복 탐지 실행. 진행 상태는 store에 유지되어 패널을 닫아도 상태바에서 보인다. */
|
||||
/**
|
||||
* 시작 순간에 고정되는 작업 기술. 탐지 중 사용자가 다른 폴더로 이동해도 바뀌지 않는다 —
|
||||
* '현재 폴더'라고 말하면서 전체를 훑는 거짓말을 막는 유일한 방법이다.
|
||||
*/
|
||||
export interface DedupeJob {
|
||||
readonly scopeLabel: string;
|
||||
/** 시작할 때 준 인자 그대로. '다시 탐지'가 같은 조건을 재현할 수 있다. */
|
||||
readonly args: DedupeRunArgs;
|
||||
readonly startedAt: number;
|
||||
}
|
||||
|
||||
/** 중복 탐지 실행. 진행 상태는 store에 남아 모달을 닫아도 사이드바·footer에서 보인다. */
|
||||
export async function runDedupe(args: DedupeRunArgs): Promise<void> {
|
||||
if (dedupeRunning()) return;
|
||||
setDedupeRunning(true);
|
||||
setDedupeJob({
|
||||
scopeLabel: args.scopeLabel ?? ko.dedupe.scopeAll,
|
||||
args,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
setDedupeCancelAt(undefined);
|
||||
setDedupeProgress(undefined);
|
||||
setDedupeRunning(true);
|
||||
|
||||
let unlisten: UnlistenFn | undefined;
|
||||
try {
|
||||
// 끝을 알리는 신호가 둘이다. 둘 다 들어야 한다.
|
||||
// ① phase === "done" 진행 메시지 — 정상 완료 경로.
|
||||
// ② dedupe-done 앱 이벤트 — 완료·취소·실패 전부. 취소되면 백엔드는 Err로 빠져나가
|
||||
// "done"을 보내지 않으므로 ①만 기다리면 영원히 끝나지 않는다(취소해도 진행
|
||||
// 표시가 남아 있던 원인이 이것이다). start_dedupe는 스레드를 띄우고 즉시
|
||||
// Ok를 반환하므로 invoke의 resolve는 완료를 뜻하지 않는다 — 기다릴 수 없다.
|
||||
// 아주 짧은 작업은 invoke보다 이벤트가 먼저 도착할 수 있어 등록을 먼저 끝낸다.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
startDedupe(args, (p) => {
|
||||
setDedupeProgress(p);
|
||||
if (p.phase === "done") resolve();
|
||||
}).catch(reject);
|
||||
onDedupeDone(resolve)
|
||||
.then((un) => {
|
||||
unlisten = un;
|
||||
return startDedupe(args, (p) => {
|
||||
setDedupeProgress(p);
|
||||
if (p.phase === "done") resolve();
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
toast(dedupeCancelAt() != null ? ko.dedupe.canceled : ko.dedupe.done);
|
||||
} catch (e) {
|
||||
// 취소 구간의 실패는 사용자가 의도한 결과다 — 오류가 아니라 '취소됨'으로 말한다
|
||||
if (dedupeCancelAt() == null) throw e;
|
||||
toast(ko.dedupe.canceled);
|
||||
} finally {
|
||||
unlisten?.();
|
||||
// 진행 표시는 즉시 언마운트된다(잔상 없음). dedupeJob은 남긴다 — 지금 보이는
|
||||
// 결과가 어떤 범위의 결과인지 계속 말해야 한다.
|
||||
setDedupeRunning(false);
|
||||
setDedupeProgress(undefined);
|
||||
setDedupeCancelAt(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export const requestCancelDedupe = () => void cancelDedupe();
|
||||
/**
|
||||
* 취소 요청. 파괴적이지 않으므로 확인 대화상자는 없다.
|
||||
* 모달 헤더와 사이드바 작업 행 모두 이 함수 하나를 부른다.
|
||||
*/
|
||||
export function requestCancelDedupe(): void {
|
||||
if (!dedupeRunning() || dedupeCancelAt() != null) return;
|
||||
setDedupeCancelAt(Date.now());
|
||||
void cancelDedupe().catch((e) => toast(String(e)));
|
||||
}
|
||||
|
||||
// ── 상태 메시지 (토스트) ─────────────────────────────────
|
||||
const [statusMessage, setStatusMessage] = createSignal<string | undefined>();
|
||||
@@ -294,6 +758,8 @@ export function openLightbox(index: number): void {
|
||||
const snap = snapshot();
|
||||
if (!snap || index < 0 || index >= snap.count) return;
|
||||
setLightboxIndex(index);
|
||||
// 라이트박스의 이동·태그·삭제는 선택 항목에 작용한다 — 열 때 현재 항목만 선택으로 맞춘다.
|
||||
selectSingle(snap.id(index), index);
|
||||
}
|
||||
|
||||
export function closeLightbox(): void {
|
||||
|
||||
+1285
-25
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user