Files
Archive/scripts/verify-fixtures.mjs
ncakanghanandClaude Opus 5 8aa74c1a8d
CI / windows-x64 (push) Canceled after 0s
CI / windows-arm64-cross (push) Canceled after 0s
CI / windows-arm64-native (push) Canceled after 0s
CI / macos (push) Canceled after 0s
영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
중복 탐지
- 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시)
- 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(8변형 질의, 선택 옵션)
- 오디오 지문(chromaprint)을 같은 디코드 패스에서 뽑아 레터박스 사본을 잡는다.
  배경음만 같은 남남은 시각 확인 패스(트림 축 프레임 거리)로 걸러낸다 — 실측 9 vs 29.
- 비교 재생(CompareView): 여러 사본을 offset 정렬해 나란히 재생
- 지문 생성 속도 조절(느림·보통·빠름) — 재시작 없이 반영

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:18:24 +09:00

194 lines
9.0 KiB
JavaScript

// 픽스처 검증 — 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);