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