1use std::fs::{self, File};
21use std::io::{self, BufWriter, Write};
22use std::path::{Path, PathBuf};
23use std::process::{Child, Command, Stdio};
24
25use image::{ImageBuffer, Rgba, RgbaImage};
26use monsoon_core::emulation::palette_util::RgbColor;
27
28use crate::cli::args::VideoFormat;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum VideoResolution {
37 Native,
39 IntegerScale(u32),
41 Hd720,
43 Hd1080,
45 Uhd4k,
47 Custom(u32, u32),
49}
50
51impl VideoResolution {
52 pub fn parse(s: &str) -> Result<Self, String> {
60 let s = s.to_lowercase();
61 match s.as_str() {
62 "native" | "1x" => Ok(VideoResolution::Native),
63 "2x" => Ok(VideoResolution::IntegerScale(2)),
64 "3x" => Ok(VideoResolution::IntegerScale(3)),
65 "4x" => Ok(VideoResolution::IntegerScale(4)),
66 "5x" => Ok(VideoResolution::IntegerScale(5)),
67 "6x" => Ok(VideoResolution::IntegerScale(6)),
68 "720p" | "hd" => Ok(VideoResolution::Hd720),
69 "1080p" | "fullhd" | "fhd" => Ok(VideoResolution::Hd1080),
70 "4k" | "uhd" | "2160p" => Ok(VideoResolution::Uhd4k),
71 _ => {
72 if let Some((w, h)) = s.split_once('x') {
74 let width = w
75 .trim()
76 .parse()
77 .map_err(|_| format!("Invalid width: {}", w))?;
78 let height = h
79 .trim()
80 .parse()
81 .map_err(|_| format!("Invalid height: {}", h))?;
82 Ok(VideoResolution::Custom(width, height))
83 } else {
84 Err(format!(
85 "Unknown resolution: '{}'. Try: native, 2x, 3x, 4x, 720p, 1080p, 4k, or WxH",
86 s
87 ))
88 }
89 }
90 }
91 }
92
93 pub fn dimensions(&self, src_width: u32, src_height: u32) -> (u32, u32) {
98 const NES_PAR: f64 = 8.0 / 7.0;
100
101 match self {
102 VideoResolution::Native => (src_width, src_height),
103 VideoResolution::IntegerScale(scale) => (src_width * scale, src_height * scale),
104 VideoResolution::Hd720 => fit_to_bounds(src_width, src_height, 1280, 720, NES_PAR),
105 VideoResolution::Hd1080 => fit_to_bounds(src_width, src_height, 1920, 1080, NES_PAR),
106 VideoResolution::Uhd4k => fit_to_bounds(src_width, src_height, 3840, 2160, NES_PAR),
107 VideoResolution::Custom(w, h) => (*w, *h),
108 }
109 }
110}
111
112fn fit_to_bounds(
114 src_width: u32,
115 src_height: u32,
116 max_width: u32,
117 max_height: u32,
118 par: f64,
119) -> (u32, u32) {
120 let scale_x = max_width as f64 / (src_width as f64 * par);
122 let scale_y = max_height as f64 / src_height as f64;
123 let scale = scale_x.min(scale_y);
124
125 let int_scale = scale.floor() as u32;
127 let int_scale = int_scale.max(1); let out_width = (src_width as f64 * par * int_scale as f64).round() as u32;
131 let out_height = src_height * int_scale;
132
133 let out_width = (out_width + 1) & !1;
135 let out_height = (out_height + 1) & !1;
136
137 (out_width, out_height)
138}
139
140use crate::cli::args::VideoExportMode;
145
146pub const NES_NTSC_FPS: f64 = 39375000.0 / 655171.0;
148
149pub const NES_NTSC_FPS_NUM: u64 = 39375000;
151pub const NES_NTSC_FPS_DEN: u64 = 655171;
152
153pub const SMOOTH_FPS: f64 = 60.0;
155
156#[derive(Debug, Clone)]
162pub struct FpsConfig {
163 pub multiplier: u32,
165 pub mode: VideoExportMode,
167}
168
169impl FpsConfig {
170 pub fn parse(s: &str, mode: VideoExportMode) -> Result<Self, String> {
175 let s = s.trim().to_lowercase();
176
177 if let Some(mult_str) = s.strip_suffix('x') {
179 let multiplier: u32 = mult_str
180 .parse()
181 .map_err(|_| format!("Invalid FPS multiplier: '{}'", s))?;
182 if multiplier == 0 {
183 return Err("FPS multiplier must be at least 1".to_string());
184 }
185 return Ok(Self {
186 multiplier,
187 mode,
188 });
189 }
190
191 let fps: f64 = s.parse().map_err(|_| {
193 format!(
194 "Invalid FPS value: '{}'. Use multipliers like '2x' or fixed values like '60.0'",
195 s
196 )
197 })?;
198
199 if fps <= 0.0 {
200 return Err("FPS must be positive".to_string());
201 }
202
203 let base_fps = match mode {
205 VideoExportMode::Accurate => NES_NTSC_FPS,
206 VideoExportMode::Smooth => SMOOTH_FPS,
207 };
208
209 let multiplier = (fps / base_fps).round() as u32;
211 let multiplier = multiplier.max(1);
212
213 Ok(Self {
214 multiplier,
215 mode,
216 })
217 }
218
219 pub fn output_fps(&self) -> f64 {
221 match self.mode {
222 VideoExportMode::Accurate => NES_NTSC_FPS * self.multiplier as f64,
223 VideoExportMode::Smooth => SMOOTH_FPS * self.multiplier as f64,
224 }
225 }
226
227 pub fn output_fps_rational(&self) -> String {
232 match self.mode {
233 VideoExportMode::Accurate => {
234 let numerator = NES_NTSC_FPS_NUM * self.multiplier as u64;
236 format!("{}/{}", numerator, NES_NTSC_FPS_DEN)
237 }
238 VideoExportMode::Smooth => {
239 let fps = 60 * self.multiplier;
241 format!("{}/1", fps)
242 }
243 }
244 }
245
246 pub fn captures_per_frame(&self) -> u32 { self.multiplier }
252
253 pub fn needs_mid_frame_capture(&self) -> bool { self.multiplier > 1 }
255}
256
257impl Default for FpsConfig {
258 fn default() -> Self {
259 Self {
260 multiplier: 1,
261 mode: VideoExportMode::Accurate,
262 }
263 }
264}
265
266#[derive(Debug)]
272pub enum VideoError {
273 FfmpegNotFound,
275 FfmpegFailed(String),
277 IoError(io::Error),
279 ImageError(String),
281 InvalidDimensions {
283 expected: (u32, u32),
284 got: (u32, u32),
285 },
286}
287
288impl std::fmt::Display for VideoError {
289 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290 match self {
291 VideoError::FfmpegNotFound => {
292 write!(
293 f,
294 "FFmpeg not found. Please install FFmpeg for MP4 export, or use PNG/PPM format."
295 )
296 }
297 VideoError::FfmpegFailed(msg) => write!(f, "FFmpeg encoding failed: {}", msg),
298 VideoError::IoError(e) => write!(f, "I/O error: {}", e),
299 VideoError::ImageError(e) => write!(f, "Image encoding error: {}", e),
300 VideoError::InvalidDimensions {
301 expected,
302 got,
303 } => {
304 write!(
305 f,
306 "Invalid frame dimensions: expected {}x{}, got {}x{}",
307 expected.0, expected.1, got.0, got.1
308 )
309 }
310 }
311 }
312}
313
314impl std::error::Error for VideoError {
315 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
316 match self {
317 VideoError::IoError(e) => Some(e),
318 _ => None,
319 }
320 }
321}
322
323impl From<io::Error> for VideoError {
324 fn from(e: io::Error) -> Self {
325 if e.kind() == io::ErrorKind::NotFound {
326 VideoError::FfmpegNotFound
327 } else {
328 VideoError::IoError(e)
329 }
330 }
331}
332
333impl From<image::ImageError> for VideoError {
334 fn from(e: image::ImageError) -> Self { VideoError::ImageError(e.to_string()) }
335}
336
337pub trait VideoEncoder: Send {
345 fn write_frame(&mut self, pixel_buffer: &[RgbColor]) -> Result<(), VideoError>;
350
351 fn finish(&mut self) -> Result<(), VideoError>;
353
354 fn frames_written(&self) -> u64;
356}
357
358pub fn create_encoder(
366 format: VideoFormat,
367 output_path: &Path,
368 width: u32,
369 height: u32,
370 fps: f64,
371) -> Result<Box<dyn VideoEncoder>, VideoError> {
372 match format {
373 VideoFormat::Png => Ok(Box::new(PngSequenceEncoder::new(
374 output_path,
375 width,
376 height,
377 )?)),
378 VideoFormat::Ppm => Ok(Box::new(PpmSequenceEncoder::new(
379 output_path,
380 width,
381 height,
382 )?)),
383 VideoFormat::Mp4 => Ok(Box::new(FfmpegMp4Encoder::new(
384 output_path,
385 width,
386 height,
387 fps,
388 None,
389 )?)),
390 VideoFormat::Raw => Ok(Box::new(RawEncoder::new(width, height)?)),
391 }
392}
393
394pub fn create_encoder_with_scale(
399 output_path: &Path,
400 src_width: u32,
401 src_height: u32,
402 dst_width: u32,
403 dst_height: u32,
404 fps: f64,
405) -> Result<Box<dyn VideoEncoder>, VideoError> {
406 Ok(Box::new(FfmpegMp4Encoder::new(
407 output_path,
408 src_width,
409 src_height,
410 fps,
411 Some((dst_width, dst_height)),
412 )?))
413}
414
415pub struct PngSequenceEncoder {
421 base_path: PathBuf,
422 width: u32,
423 height: u32,
424 frame_count: u64,
425}
426
427impl PngSequenceEncoder {
428 pub fn new(output_path: &Path, width: u32, height: u32) -> Result<Self, VideoError> {
430 if let Some(parent) = output_path.parent()
431 && !parent.exists()
432 {
433 fs::create_dir_all(parent)?;
434 }
435
436 Ok(Self {
437 base_path: output_path.to_path_buf(),
438 width,
439 height,
440 frame_count: 0,
441 })
442 }
443
444 fn frame_path(&self, frame: u64) -> PathBuf {
445 let stem = self
446 .base_path
447 .file_stem()
448 .map(|s| s.to_string_lossy().to_string())
449 .unwrap_or_else(|| "frame".to_string());
450 let dir = self
451 .base_path
452 .parent()
453 .unwrap_or(Path::new("../../../../../../.."));
454 dir.join(format!("{}_{:06}.png", stem, frame))
455 }
456}
457
458impl VideoEncoder for PngSequenceEncoder {
459 fn write_frame(&mut self, pixel_buffer: &[RgbColor]) -> Result<(), VideoError> {
460 let expected_size = (self.width * self.height) as usize;
461 if pixel_buffer.len() != expected_size {
462 let got_height = pixel_buffer.len() / self.width as usize;
463 return Err(VideoError::InvalidDimensions {
464 expected: (self.width, self.height),
465 got: (self.width, got_height as u32),
466 });
467 }
468
469 let img: RgbaImage = ImageBuffer::from_fn(self.width, self.height, |x, y| {
470 let color = pixel_buffer[(y * self.width + x) as usize];
471 Rgba([color.r, color.g, color.b, 255])
472 });
473
474 let path = self.frame_path(self.frame_count);
475 img.save(&path)?;
476
477 self.frame_count += 1;
478 Ok(())
479 }
480
481 fn finish(&mut self) -> Result<(), VideoError> { Ok(()) }
482
483 fn frames_written(&self) -> u64 { self.frame_count }
484}
485
486pub struct PpmSequenceEncoder {
492 base_path: PathBuf,
493 width: u32,
494 height: u32,
495 frame_count: u64,
496}
497
498impl PpmSequenceEncoder {
499 pub fn new(output_path: &Path, width: u32, height: u32) -> Result<Self, VideoError> {
501 if let Some(parent) = output_path.parent()
502 && !parent.exists()
503 {
504 fs::create_dir_all(parent)?;
505 }
506
507 Ok(Self {
508 base_path: output_path.to_path_buf(),
509 width,
510 height,
511 frame_count: 0,
512 })
513 }
514
515 fn frame_path(&self, frame: u64) -> PathBuf {
516 let stem = self
517 .base_path
518 .file_stem()
519 .map(|s| s.to_string_lossy().to_string())
520 .unwrap_or_else(|| "frame".to_string());
521 let dir = self
522 .base_path
523 .parent()
524 .unwrap_or(Path::new("../../../../../../.."));
525 dir.join(format!("{}_{:06}.ppm", stem, frame))
526 }
527}
528
529impl VideoEncoder for PpmSequenceEncoder {
530 fn write_frame(&mut self, pixel_buffer: &[RgbColor]) -> Result<(), VideoError> {
531 let expected_size = (self.width * self.height) as usize;
532 if pixel_buffer.len() != expected_size {
533 let got_height = pixel_buffer.len() / self.width as usize;
534 return Err(VideoError::InvalidDimensions {
535 expected: (self.width, self.height),
536 got: (self.width, got_height as u32),
537 });
538 }
539
540 let path = self.frame_path(self.frame_count);
541 let file = File::create(&path)?;
542 let mut writer = BufWriter::new(file);
543
544 writeln!(writer, "P6")?;
545 writeln!(writer, "{} {}", self.width, self.height)?;
546 writeln!(writer, "255")?;
547
548 for &RgbColor {
549 r,
550 g,
551 b,
552 } in pixel_buffer
553 {
554 writer.write_all(&[r, g, b])?;
555 }
556
557 writer.flush()?;
558 self.frame_count += 1;
559 Ok(())
560 }
561
562 fn finish(&mut self) -> Result<(), VideoError> { Ok(()) }
563
564 fn frames_written(&self) -> u64 { self.frame_count }
565}
566
567pub struct FfmpegMp4Encoder {
575 child: Option<Child>,
576 stdin: Option<BufWriter<std::process::ChildStdin>>,
577 stderr_path: Option<PathBuf>,
578 width: u32,
579 height: u32,
580 frame_count: u64,
581}
582
583impl FfmpegMp4Encoder {
584 pub fn new(
589 output_path: &Path,
590 width: u32,
591 height: u32,
592 fps: f64,
593 scale_to: Option<(u32, u32)>,
594 ) -> Result<Self, VideoError> {
595 let ffmpeg_check = Command::new("ffmpeg").arg("-version").output();
597
598 match ffmpeg_check {
599 Err(e) if e.kind() == io::ErrorKind::NotFound => {
600 return Err(VideoError::FfmpegNotFound);
601 }
602 Err(e) => return Err(VideoError::IoError(e)),
603 Ok(_) => {}
604 }
605
606 if let Some(parent) = output_path.parent()
608 && !parent.exists()
609 {
610 fs::create_dir_all(parent)?;
611 }
612
613 let stderr_path =
614 std::env::temp_dir().join(format!("nes_ffmpeg_stderr_{}.log", std::process::id()));
615 let stderr_file = File::create(&stderr_path)?;
616
617 let path = output_path.with_extension("mp4");
618
619 let fps_str = fps_to_rational(fps);
623
624 let mut args = vec![
626 "-y".to_string(),
627 "-f".to_string(),
628 "rawvideo".to_string(),
629 "-pixel_format".to_string(),
630 "bgra".to_string(),
631 "-video_size".to_string(),
632 format!("{}x{}", width, height),
633 "-framerate".to_string(),
634 fps_str,
635 "-i".to_string(),
636 "-".to_string(),
637 ];
638
639 if let Some((dst_w, dst_h)) = scale_to
641 && (dst_w != width || dst_h != height)
642 {
643 eprintln!(
644 "FFmpeg scaling {}x{} -> {}x{} (nearest neighbor)",
645 width, height, dst_w, dst_h
646 );
647 args.extend([
648 "-vf".to_string(),
649 format!("scale={}:{}:flags=neighbor", dst_w, dst_h),
650 ]);
651 }
652
653 args.extend([
655 "-c:v".to_string(),
656 "libx264".to_string(),
657 "-preset".to_string(),
658 "fast".to_string(),
659 "-crf".to_string(),
660 "16".to_string(),
661 "-vsync".to_string(),
662 "cfr".to_string(),
663 "-video_track_timescale".to_string(),
664 "39375000".to_string(),
665 "-pix_fmt".to_string(),
666 "yuv420p".to_string(),
667 "-movflags".to_string(),
668 "+faststart".to_string(),
669 "-f".to_string(),
670 "mp4".to_string(),
671 path.to_str().unwrap_or("output.mp4").to_string(),
672 ]);
673
674 let mut child = Command::new("ffmpeg")
675 .args(&args)
676 .stdin(Stdio::piped())
677 .stdout(Stdio::null())
678 .stderr(Stdio::from(stderr_file))
679 .spawn()
680 .map_err(|e| {
681 if e.kind() == io::ErrorKind::NotFound {
682 VideoError::FfmpegNotFound
683 } else {
684 VideoError::IoError(e)
685 }
686 })?;
687
688 let stdin = child.stdin.take().map(BufWriter::new);
689
690 Ok(Self {
691 child: Some(child),
692 stdin,
693 stderr_path: Some(stderr_path),
694 width,
695 height,
696 frame_count: 0,
697 })
698 }
699}
700
701impl VideoEncoder for FfmpegMp4Encoder {
702 fn write_frame(&mut self, pixel_buffer: &[RgbColor]) -> Result<(), VideoError> {
703 let expected_size = (self.width * self.height) as usize;
704 if pixel_buffer.len() != expected_size {
705 let got_height = pixel_buffer.len() / self.width as usize;
706 return Err(VideoError::InvalidDimensions {
707 expected: (self.width, self.height),
708 got: (self.width, got_height as u32),
709 });
710 }
711
712 let mut bgra_buffer = Vec::with_capacity(pixel_buffer.len() * 4);
714 for &RgbColor {
715 r,
716 g,
717 b,
718 } in pixel_buffer
719 {
720 bgra_buffer.extend_from_slice(&[b, g, r, 255]);
721 }
722
723 match &mut self.stdin {
724 Some(stdin) => {
725 stdin.write_all(&bgra_buffer).map_err(|e| {
726 if e.kind() == io::ErrorKind::BrokenPipe {
727 VideoError::FfmpegFailed(
728 "FFmpeg closed input pipe unexpectedly".to_string(),
729 )
730 } else {
731 VideoError::IoError(e)
732 }
733 })?;
734 }
735 None => {
736 return Err(VideoError::FfmpegFailed(
737 "FFmpeg stdin not available".to_string(),
738 ));
739 }
740 }
741
742 self.frame_count += 1;
743 Ok(())
744 }
745
746 fn finish(&mut self) -> Result<(), VideoError> {
747 self.stdin.take();
749
750 if let Some(mut child) = self.child.take() {
752 let status = child.wait()?;
753 if !status.success() {
754 let stderr_content = if let Some(ref path) = self.stderr_path {
755 fs::read_to_string(path).unwrap_or_default()
756 } else {
757 String::new()
758 };
759 return Err(VideoError::FfmpegFailed(format!(
760 "FFmpeg exited with status {}: {}",
761 status,
762 stderr_content
763 .lines()
764 .take(10)
765 .collect::<Vec<_>>()
766 .join("\n")
767 )));
768 }
769 }
770
771 if let Some(ref path) = self.stderr_path {
773 let _ = fs::remove_file(path);
774 }
775
776 Ok(())
777 }
778
779 fn frames_written(&self) -> u64 { self.frame_count }
780}
781
782impl Drop for FfmpegMp4Encoder {
783 fn drop(&mut self) {
784 self.stdin.take();
786
787 if let Some(mut child) = self.child.take() {
789 let _ = child.wait();
790 }
791
792 if let Some(ref path) = self.stderr_path {
794 let _ = fs::remove_file(path);
795 }
796 }
797}
798
799pub struct RawEncoder {
805 width: u32,
806 height: u32,
807 frame_count: u64,
808 stdout: BufWriter<io::Stdout>,
809}
810
811impl RawEncoder {
812 pub fn new(width: u32, height: u32) -> Result<Self, VideoError> {
814 Ok(Self {
815 width,
816 height,
817 frame_count: 0,
818 stdout: BufWriter::new(io::stdout()),
819 })
820 }
821}
822
823impl VideoEncoder for RawEncoder {
824 fn write_frame(&mut self, pixel_buffer: &[RgbColor]) -> Result<(), VideoError> {
825 let expected_size = (self.width * self.height) as usize;
826 if pixel_buffer.len() != expected_size {
827 let got_height = pixel_buffer.len() / self.width as usize;
828 return Err(VideoError::InvalidDimensions {
829 expected: (self.width, self.height),
830 got: (self.width, got_height as u32),
831 });
832 }
833
834 for &RgbColor {
836 r,
837 g,
838 b,
839 } in pixel_buffer
840 {
841 self.stdout.write_all(&[b, g, r, 255])?;
842 }
843
844 self.frame_count += 1;
845 Ok(())
846 }
847
848 fn finish(&mut self) -> Result<(), VideoError> {
849 self.stdout.flush()?;
850 Ok(())
851 }
852
853 fn frames_written(&self) -> u64 { self.frame_count }
854}
855
856fn fps_to_rational(fps: f64) -> String {
868 const NES_TOLERANCE: f64 = 0.01;
872 const STANDARD_TOLERANCE: f64 = 0.001;
873
874 for multiplier in 1..=10 {
877 let target = NES_NTSC_FPS * multiplier as f64;
878 if (fps - target).abs() < NES_TOLERANCE {
879 let numerator = NES_NTSC_FPS_NUM * multiplier as u64;
880 return format!("{}/{}", numerator, NES_NTSC_FPS_DEN);
881 }
882 }
883
884 for multiplier in 1..=10 {
886 let target = SMOOTH_FPS * multiplier as f64;
887 if (fps - target).abs() < STANDARD_TOLERANCE {
888 return format!("{}/1", 60 * multiplier);
889 }
890 }
891
892 if (fps - 30.0).abs() < STANDARD_TOLERANCE {
894 return "30/1".to_string();
895 }
896 if (fps - 24.0).abs() < STANDARD_TOLERANCE {
897 return "24/1".to_string();
898 }
899 if (fps - 59.94).abs() < NES_TOLERANCE {
900 return "60000/1001".to_string(); }
902 if (fps - 29.97).abs() < NES_TOLERANCE {
903 return "30000/1001".to_string(); }
905 if (fps - 23.976).abs() < NES_TOLERANCE {
906 return "24000/1001".to_string(); }
908
909 let numerator = (fps * 1000.0).round() as u64;
912 format!("{}/1000", numerator)
913}
914
915pub fn encode_frames(
917 frames: &[Vec<RgbColor>],
918 format: VideoFormat,
919 output_path: &Path,
920 width: u32,
921 height: u32,
922 fps: f64,
923) -> Result<u64, VideoError> {
924 let mut encoder = create_encoder(format, output_path, width, height, fps)?;
925
926 for frame in frames {
927 encoder.write_frame(frame)?;
928 }
929
930 encoder.finish()?;
931 Ok(encoder.frames_written())
932}
933
934pub fn is_ffmpeg_available() -> bool {
936 Command::new("ffmpeg")
937 .arg("-version")
938 .output()
939 .map(|o| o.status.success())
940 .unwrap_or(false)
941}
942
943pub struct StreamingVideoEncoder {
955 encoder: Box<dyn VideoEncoder>,
956 src_width: u32,
957 src_height: u32,
958 dst_width: u32,
959 dst_height: u32,
960 fps_config: FpsConfig,
961}
962
963impl StreamingVideoEncoder {
964 pub fn with_fps_config(
968 format: VideoFormat,
969 output_path: &Path,
970 src_width: u32,
971 src_height: u32,
972 resolution: &VideoResolution,
973 fps_config: FpsConfig,
974 ) -> Result<Self, VideoError> {
975 let (dst_width, dst_height) = resolution.dimensions(src_width, src_height);
976 let fps = fps_config.output_fps();
977
978 let encoder: Box<dyn VideoEncoder> = match format {
979 VideoFormat::Mp4 => {
980 if dst_width != src_width || dst_height != src_height {
981 Box::new(FfmpegMp4Encoder::new(
983 output_path,
984 src_width,
985 src_height,
986 fps,
987 Some((dst_width, dst_height)),
988 )?)
989 } else {
990 Box::new(FfmpegMp4Encoder::new(
991 output_path,
992 src_width,
993 src_height,
994 fps,
995 None,
996 )?)
997 }
998 }
999 _ => {
1000 if dst_width != src_width || dst_height != src_height {
1002 eprintln!(
1003 "Warning: Scaling only supported for MP4 format. Using native resolution."
1004 );
1005 }
1006 create_encoder(format, output_path, src_width, src_height, fps)?
1007 }
1008 };
1009
1010 Ok(Self {
1011 encoder,
1012 src_width,
1013 src_height,
1014 dst_width,
1015 dst_height,
1016 fps_config,
1017 })
1018 }
1019
1020 pub fn write_frame(&mut self, frame: &[RgbColor]) -> Result<(), VideoError> {
1022 self.encoder.write_frame(frame)
1023 }
1024
1025 pub fn finish(&mut self) -> Result<(), VideoError> { self.encoder.finish() }
1027
1028 pub fn frames_written(&self) -> u64 { self.encoder.frames_written() }
1030
1031 pub fn source_dimensions(&self) -> (u32, u32) { (self.src_width, self.src_height) }
1033
1034 pub fn output_dimensions(&self) -> (u32, u32) { (self.dst_width, self.dst_height) }
1036
1037 pub fn is_scaling(&self) -> bool {
1039 self.dst_width != self.src_width || self.dst_height != self.src_height
1040 }
1041
1042 pub fn fps_config(&self) -> &FpsConfig { &self.fps_config }
1044
1045 pub fn needs_mid_frame_capture(&self) -> bool { self.fps_config.needs_mid_frame_capture() }
1047
1048 pub fn captures_per_frame(&self) -> u32 { self.fps_config.captures_per_frame() }
1050}
1051
1052#[cfg(test)]
1057mod tests {
1058 use super::*;
1059
1060 #[test]
1061 fn test_video_resolution_parse() {
1062 assert_eq!(
1063 VideoResolution::parse("native").unwrap(),
1064 VideoResolution::Native
1065 );
1066 assert_eq!(
1067 VideoResolution::parse("1x").unwrap(),
1068 VideoResolution::Native
1069 );
1070 assert_eq!(
1071 VideoResolution::parse("2x").unwrap(),
1072 VideoResolution::IntegerScale(2)
1073 );
1074 assert_eq!(
1075 VideoResolution::parse("4x").unwrap(),
1076 VideoResolution::IntegerScale(4)
1077 );
1078 assert_eq!(
1079 VideoResolution::parse("720p").unwrap(),
1080 VideoResolution::Hd720
1081 );
1082 assert_eq!(
1083 VideoResolution::parse("1080p").unwrap(),
1084 VideoResolution::Hd1080
1085 );
1086 assert_eq!(
1087 VideoResolution::parse("4k").unwrap(),
1088 VideoResolution::Uhd4k
1089 );
1090 assert_eq!(
1091 VideoResolution::parse("1920x1080").unwrap(),
1092 VideoResolution::Custom(1920, 1080)
1093 );
1094 }
1095
1096 #[test]
1097 fn test_video_resolution_dimensions() {
1098 assert_eq!(VideoResolution::Native.dimensions(256, 240), (256, 240));
1100
1101 assert_eq!(
1103 VideoResolution::IntegerScale(2).dimensions(256, 240),
1104 (512, 480)
1105 );
1106 assert_eq!(
1107 VideoResolution::IntegerScale(4).dimensions(256, 240),
1108 (1024, 960)
1109 );
1110
1111 let (w, h) = VideoResolution::Hd1080.dimensions(256, 240);
1113 assert!(w <= 1920);
1114 assert!(h <= 1080);
1115 }
1116
1117 #[test]
1118 fn test_video_error_display() {
1119 let err = VideoError::FfmpegNotFound;
1120 assert!(err.to_string().contains("FFmpeg not found"));
1121
1122 let err = VideoError::InvalidDimensions {
1123 expected: (256, 240),
1124 got: (128, 120),
1125 };
1126 assert!(err.to_string().contains("256x240"));
1127 assert!(err.to_string().contains("128x120"));
1128 }
1129
1130 #[test]
1131 fn test_png_encoder_frame_path() {
1132 let encoder = PngSequenceEncoder::new(Path::new("/tmp/test/frames"), 256, 240).unwrap();
1133 let path = encoder.frame_path(0);
1134 assert!(path.to_string_lossy().contains("frames_000000.png"));
1135
1136 let path = encoder.frame_path(42);
1137 assert!(path.to_string_lossy().contains("frames_000042.png"));
1138 }
1139
1140 #[test]
1141 fn test_ppm_encoder_frame_path() {
1142 let encoder = PpmSequenceEncoder::new(Path::new("/tmp/test/output"), 256, 240).unwrap();
1143 let path = encoder.frame_path(123);
1144 assert!(path.to_string_lossy().contains("output_000123.ppm"));
1145 }
1146
1147 #[test]
1148 fn test_ffmpeg_availability_check() { let _available = is_ffmpeg_available(); }
1149
1150 #[test]
1151 fn test_invalid_frame_dimensions() {
1152 let mut encoder =
1153 PpmSequenceEncoder::new(Path::new("/tmp/test_invalid"), 256, 240).unwrap();
1154
1155 let bad_frame: Vec<RgbColor> = vec![RgbColor::new(0, 0, 0); 100];
1156 let result = encoder.write_frame(&bad_frame);
1157
1158 assert!(result.is_err());
1159 if let Err(VideoError::InvalidDimensions {
1160 expected, ..
1161 }) = result
1162 {
1163 assert_eq!(expected, (256, 240));
1164 } else {
1165 panic!("Expected InvalidDimensions error");
1166 }
1167 }
1168
1169 #[test]
1170 fn test_fps_to_rational() {
1171 let nes_ntsc = 39375000.0 / 655171.0;
1173 assert_eq!(fps_to_rational(nes_ntsc), "39375000/655171");
1174
1175 assert_eq!(fps_to_rational(60.0), "60/1");
1177 assert_eq!(fps_to_rational(30.0), "30/1");
1178 assert_eq!(fps_to_rational(24.0), "24/1");
1179
1180 assert_eq!(fps_to_rational(59.94), "60000/1001");
1182 assert_eq!(fps_to_rational(29.97), "30000/1001");
1183 assert_eq!(fps_to_rational(23.976), "24000/1001");
1184
1185 assert_eq!(fps_to_rational(50.0), "50000/1000");
1187
1188 let nes_ntsc_2x = nes_ntsc * 2.0;
1190 assert_eq!(fps_to_rational(nes_ntsc_2x), "78750000/655171");
1191
1192 assert_eq!(fps_to_rational(120.0), "120/1");
1194 assert_eq!(fps_to_rational(180.0), "180/1");
1195 }
1196
1197 #[test]
1198 fn test_fps_config_parse_multipliers() {
1199 let config = FpsConfig::parse("1x", VideoExportMode::Accurate).unwrap();
1201 assert_eq!(config.multiplier, 1);
1202 assert_eq!(config.mode, VideoExportMode::Accurate);
1203
1204 let config = FpsConfig::parse("2x", VideoExportMode::Accurate).unwrap();
1205 assert_eq!(config.multiplier, 2);
1206
1207 let config = FpsConfig::parse("3x", VideoExportMode::Smooth).unwrap();
1208 assert_eq!(config.multiplier, 3);
1209 assert_eq!(config.mode, VideoExportMode::Smooth);
1210 }
1211
1212 #[test]
1213 fn test_fps_config_parse_fixed_values() {
1214 let config = FpsConfig::parse("60.0", VideoExportMode::Smooth).unwrap();
1216 assert_eq!(config.multiplier, 1); let config = FpsConfig::parse("120", VideoExportMode::Smooth).unwrap();
1219 assert_eq!(config.multiplier, 2); let config = FpsConfig::parse("60.0988", VideoExportMode::Accurate).unwrap();
1222 assert_eq!(config.multiplier, 1); let config = FpsConfig::parse("120.2", VideoExportMode::Accurate).unwrap();
1225 assert_eq!(config.multiplier, 2); }
1227
1228 #[test]
1229 fn test_fps_config_output_fps() {
1230 let config = FpsConfig::parse("1x", VideoExportMode::Accurate).unwrap();
1232 assert!((config.output_fps() - NES_NTSC_FPS).abs() < 0.001);
1233
1234 let config = FpsConfig::parse("1x", VideoExportMode::Smooth).unwrap();
1236 assert!((config.output_fps() - 60.0).abs() < 0.001);
1237
1238 let config = FpsConfig::parse("2x", VideoExportMode::Accurate).unwrap();
1240 assert!((config.output_fps() - NES_NTSC_FPS * 2.0).abs() < 0.001);
1241
1242 let config = FpsConfig::parse("2x", VideoExportMode::Smooth).unwrap();
1244 assert!((config.output_fps() - 120.0).abs() < 0.001);
1245 }
1246
1247 #[test]
1248 fn test_fps_config_output_rational() {
1249 let config = FpsConfig::parse("1x", VideoExportMode::Accurate).unwrap();
1251 assert_eq!(config.output_fps_rational(), "39375000/655171");
1252
1253 let config = FpsConfig::parse("1x", VideoExportMode::Smooth).unwrap();
1255 assert_eq!(config.output_fps_rational(), "60/1");
1256
1257 let config = FpsConfig::parse("2x", VideoExportMode::Accurate).unwrap();
1259 assert_eq!(config.output_fps_rational(), "78750000/655171");
1260
1261 let config = FpsConfig::parse("2x", VideoExportMode::Smooth).unwrap();
1263 assert_eq!(config.output_fps_rational(), "120/1");
1264 }
1265
1266 #[test]
1267 fn test_fps_config_parse_errors() {
1268 assert!(FpsConfig::parse("0x", VideoExportMode::Accurate).is_err());
1270 assert!(FpsConfig::parse("-1x", VideoExportMode::Accurate).is_err());
1271
1272 assert!(FpsConfig::parse("abc", VideoExportMode::Accurate).is_err());
1274 assert!(FpsConfig::parse("", VideoExportMode::Accurate).is_err());
1275
1276 assert!(FpsConfig::parse("-60", VideoExportMode::Accurate).is_err());
1278 }
1279
1280 #[test]
1281 fn test_fps_config_captures_per_frame() {
1282 let config = FpsConfig::parse("1x", VideoExportMode::Accurate).unwrap();
1283 assert_eq!(config.captures_per_frame(), 1);
1284 assert!(!config.needs_mid_frame_capture());
1285
1286 let config = FpsConfig::parse("2x", VideoExportMode::Accurate).unwrap();
1287 assert_eq!(config.captures_per_frame(), 2);
1288 assert!(config.needs_mid_frame_capture());
1289
1290 let config = FpsConfig::parse("3x", VideoExportMode::Smooth).unwrap();
1291 assert_eq!(config.captures_per_frame(), 3);
1292 assert!(config.needs_mid_frame_capture());
1293 }
1294}