Files
Archive/src-tauri/crates/archive-indexer/tests/edit_files.rs
T
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

491 lines
22 KiB
Rust
Raw 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.
//! 회전·반전을 **실제 파일에** 적용해 본다.
//!
//! 단위 테스트는 바이트 조작과 방향값 계산까지만 본다. 실제로는
//! 앱의 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);
}
}