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