Files
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

272 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! ffmpeg 사이드카 실기 테스트 — src-tauri/binaries에 사이드카가 있을 때만 실행된다.
//! (없으면 조용히 통과 — CI에서 fetch-ffmpeg 후 실행하면 전체 커버)
use archive_indexer::ffmpeg::{self, FfTools};
use archive_indexer::sigmatch;
use std::path::{Path, PathBuf};
fn tools() -> Option<FfTools> {
// crates/archive-indexer → ../../binaries
let bin = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../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 = bin.join(format!("ffmpeg-{triple}{ext}"));
let ffprobe = bin.join(format!("ffprobe-{triple}{ext}"));
if ffmpeg.is_file() && ffprobe.is_file() {
Some(FfTools { ffmpeg, ffprobe })
} else {
None
}
}
fn make_test_video(t: &FfTools, dir: &Path) -> PathBuf {
let out = dir.join("테스트영상.mp4");
let vcodec = if cfg!(windows) { "h264_mf" } else { "h264_videotoolbox" };
let status = ffmpeg::command(&t.ffmpeg)
.args([
"-y", "-v", "error",
"-f", "lavfi", "-i", "testsrc2=duration=2:size=320x240:rate=25",
"-c:v", vcodec, "-b:v", "300k",
])
.arg(&out)
.status()
.expect("ffmpeg 실행 실패");
assert!(status.success(), "테스트 영상 생성 실패");
out
}
#[test]
fn probe_and_thumbnail_roundtrip() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀 (scripts/fetch-ffmpeg.ps1 실행 후 재시도)");
return;
};
let dir = tempfile::tempdir().unwrap();
let video = make_test_video(&t, dir.path());
// ffprobe 메타
let info = ffmpeg::probe(&t, &video).unwrap();
assert_eq!(info.vcodec.as_deref(), Some("h264"));
assert_eq!(info.width, Some(320));
assert_eq!(info.height, Some(240));
let dur = info.duration_ms.expect("duration 없음");
assert!((1500..=2500).contains(&dur), "duration {dur}ms");
assert!(info.fps.unwrap() > 20.0);
// 프레임 썸네일
let thumb = dir.path().join("thumbs/aa/key.jpg");
let (w, h) = ffmpeg::extract_frame_jpeg(&t, &video, &thumb, 0.5, 256).unwrap();
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, "즉시 실패해야 한다");
}