영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
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>
This commit is contained in:
ncakanghan
2026-08-03 16:18:24 +09:00
co-authored by Claude Opus 5
parent 67d2ddc59e
commit 8aa74c1a8d
67 changed files with 19747 additions and 1283 deletions
@@ -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);
}
}