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