# 중복 탐지 회귀 픽스처 생성 — .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 "썸네일·지문 백필이 끝난 뒤(사이드바 '진행 중'이 비면) 중복 찾기를 실행한다."