영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
중복 탐지 - 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시) - 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(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
@@ -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);
|
||||
Reference in New Issue
Block a user